diff --git a/README.md b/README.md index d244ab4..00d4014 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,11 @@ ft login # 2. Who am I, and which workspaces can I access? ft whoami -# 3. Start exploring +# 3. Pick the workspace you want to operate on (persisted locally). +ft workspace list +ft workspace use my-workspace-slug + +# 4. Start exploring ft events list ft reports summary --period 30d ft sales list --status CONFIRMED --json @@ -74,17 +78,26 @@ ft sales list --status CONFIRMED --json | `ft login` | Browser login (device flow); `--key ` for CI | VIEWER | | `ft whoami` | Active user and accessible workspaces | VIEWER | | `ft config` · `ft logout` | Show config (masked key) · remove key | — | +| `ft workspace list` · `use ` · `show` | List, switch, and show the active workspace | VIEWER | | `ft events list` · `get ` | Workspace events | VIEWER | +| `ft events create` · `update ` · `delete ` | Manage events (`--data `) | ADMIN | +| `ft events publish ` | Publish an event | ADMIN | +| `ft event-dates list` · `create` · `update` · `delete` | Event dates | ADMIN | | `ft ticket-types list` · `get ` | Ticket types (`--event-date-id`) | VIEWER | +| `ft ticket-types create` · `update ` · `delete ` | Manage ticket types (`--data `) | ADMIN | | `ft sales list` · `get ` | Sales (`--status`) | STAFF | +| `ft sales cancel ` · `refund ` | Cancel / refund a sale (`--data` for partial refund) | ADMIN | | `ft plans list` · `get ` | Membership plans | VIEWER | +| `ft plans create` · `update ` · `delete ` | Manage membership plans (`--data `) | ADMIN | | `ft venues list` · `get ` | Venues | VIEWER | -| `ft staff list` | Workspace staff | ADMIN | +| `ft venues create` · `update ` · `delete ` | Manage venues (`--data `) | ADMIN | +| `ft staff list` · `create` · `set-role ` | Workspace staff (`--data `) | ADMIN | | `ft reports summary` | KPIs (`--period 7d\|30d\|90d\|1y`) | VIEWER | | `ft reports export buyers\|subscribers` | Export buyers / subscribers | ADMIN | -> **Write operations** (create/update/delete) are declared in the contract but -> currently return `501`. They are planned for **phase 2**. +> **Write operations** (`create`/`update`/`delete` and actions like `publish`, +> `cancel`, `refund`) send a JSON body via `--data ` or +> `--data @file.json`. `delete` prompts for confirmation unless you pass `--yes`. ### Common flags @@ -93,6 +106,11 @@ ft sales list --status CONFIRMED --json | `--json` | all commands | Raw JSON output, ideal for `jq` and scripts | | `--workspace ` | all commands | Run the command against another workspace | | `--limit ` `--cursor ` | list commands | Cursor pagination (1-100, default 20) | +| `--all` | list commands | Auto-paginate: fetch every page (ignores `--cursor`) | +| `--raw` | list commands | JSON output including the `page` pagination metadata | +| `--columns ` `--full` | list commands | Pick specific columns · show every field | +| `--csv` | list commands | CSV output for spreadsheets/accounting | +| `--data ` `--yes` | write commands | JSON body (inline or `@file`) · skip delete confirmation | ## Configuration @@ -113,8 +131,10 @@ your user can read it) because it stores the API key. See [`.env.example`](./.en ## Pagination, errors, and money -- **Pagination:** list responses include `page.nextCursor`. The CLI prints the - `--cursor ` hint to *stderr* so `--json` stays clean on *stdout*. +- **Pagination:** list responses include `page.nextCursor` / `page.hasMore`. The + CLI prints the `--cursor ` hint to *stderr* so `--json` stays clean on + *stdout*. Use `--all` to auto-paginate every page, or `--raw` to emit the full + `{ data, page }` envelope (metadata included) as JSON. - **Errors:** uniform format `{ error: { code, message, details } }`. The CLI translates `401/403/404/501` into actionable messages and exits with code `1`. - **Money:** number in the resource currency (`currency`, usually `COP`). diff --git a/src/commands/resource.ts b/src/commands/resource.ts index 165d246..83803d5 100644 --- a/src/commands/resource.ts +++ b/src/commands/resource.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import { configureClient, unwrap } from "../lib/api"; import { confirm, parseData } from "../lib/input"; -import { print, printNextCursor, toCsv } from "../lib/output"; +import { print, printNextCursor, resolveColumns, toCsv } from "../lib/output"; type SdkFn = ( opts: any, @@ -56,12 +56,17 @@ export function registerResource(program: Command, spec: ResourceSpec): void { .description(`List ${spec.name} from the active workspace`) .option("--limit ", "results per page (1-100)", "20") .option("--cursor ", "pagination cursor") + .option("--all", "auto-paginate: fetch every page (ignores --cursor)") + .option("--columns ", "comma-separated columns to display") + .option("--full", "show every field instead of the curated columns") .option("--workspace ", "workspace override") .option("--csv", "CSV output (for spreadsheets/accounting)") - .option("--json", "raw JSON output"); + .option("--json", "raw JSON output (data only)") + .option("--raw", "raw JSON output including pagination metadata (page)"); for (const f of spec.listFlags ?? []) cmd.option(f.flag, f.describe); cmd.action(async (opts) => { configureClient(opts.workspace); + const columns = resolveColumns(opts, spec.columns); const query: Record = { limit: Number(opts.limit), cursor: opts.cursor, @@ -70,12 +75,42 @@ export function registerResource(program: Command, spec: ResourceSpec): void { const v = opts[camel(f.query)]; if (v !== undefined) query[f.query] = v; } + + if (opts.all) { + const rows: unknown[] = []; + let cursor: string | undefined; + let page: { nextCursor?: string | null; hasMore?: boolean } | undefined; + do { + const body = unwrap(await list({ query: { ...query, cursor } })); + rows.push(...(body.data ?? [])); + page = body.page; + cursor = page?.nextCursor ?? undefined; + } while (page?.hasMore && cursor); + if (opts.raw) { + print( + { data: rows, page: { nextCursor: null, hasMore: false } }, + { json: true }, + ); + return; + } + if (opts.csv) { + process.stdout.write(`${toCsv(rows, columns)}\n`); + return; + } + print(rows, { json: opts.json, columns }); + return; + } + const body = unwrap(await list({ query })); + if (opts.raw) { + print(body, { json: true }); + return; + } if (opts.csv) { - process.stdout.write(`${toCsv(body.data, spec.columns)}\n`); + process.stdout.write(`${toCsv(body.data, columns)}\n`); return; } - print(body.data, { json: opts.json, columns: spec.columns }); + print(body.data, { json: opts.json, columns }); if (!opts.json) printNextCursor(body.page); }); } diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts new file mode 100644 index 0000000..bf99b10 --- /dev/null +++ b/src/commands/workspace.ts @@ -0,0 +1,80 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { getMe } from "../client/sdk.gen"; +import { configureClient, fail, unwrap } from "../lib/api"; +import { loadConfig, saveConfig } from "../lib/config"; +import { print } from "../lib/output"; + +/** + * `ft workspace list|use|show`: pick and persist the active workspace. + * Everything comes from GET /me (workspaces[] + activeWorkspaceId); the choice + * is stored locally and sent as the `X-Workspace-Id` header on every request. + */ +export function registerWorkspace(program: Command): void { + const root = program + .command("workspace") + .description("List, switch, and show the active workspace"); + + root + .command("list") + .alias("ls") + .description("List the workspaces you can access (marks the active one)") + .option("--json", "raw JSON output") + .action(async (opts) => { + configureClient(); + const me = unwrap(await getMe({})).data; + const activeId = loadConfig().workspaceId ?? me.activeWorkspaceId; + const rows = me.workspaces.map((w) => ({ + active: w.id === activeId ? "*" : "", + id: w.id, + name: w.name, + slug: w.slug, + })); + print(rows, { + json: opts.json, + columns: ["active", "id", "name", "slug"], + }); + }); + + root + .command("use ") + .description( + "Set the active workspace (persisted in ~/.freeticket/config.json)", + ) + .action(async (idOrSlug) => { + configureClient(); + const me = unwrap(await getMe({})).data; + const match = me.workspaces.find( + (w) => w.id === idOrSlug || w.slug === idOrSlug, + ); + if (!match) { + fail( + `No workspace matches "${idOrSlug}".`, + `Available: ${me.workspaces.map((w) => w.slug).join(", ") || "(none)"}`, + ); + } + saveConfig({ workspaceId: match.id }); + console.log( + `${chalk.green("✓")} Active workspace: ${chalk.bold(match.name)} ${chalk.dim(`(${match.slug})`)}`, + ); + }); + + root + .command("show") + .description("Show the active workspace") + .option("--json", "raw JSON output") + .action(async (opts) => { + configureClient(); + const cfg = loadConfig(); + const me = unwrap(await getMe({})).data; + const activeId = cfg.workspaceId ?? me.activeWorkspaceId; + const active = me.workspaces.find((w) => w.id === activeId); + if (!active) { + fail( + "No active workspace set.", + "Pick one with `ft workspace use `.", + ); + } + print(active, { json: opts.json }); + }); +} diff --git a/src/index.ts b/src/index.ts index 4fafcbe..5279c66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { registerAuth } from "./commands/auth"; import { registerEventDates } from "./commands/event-dates"; import { registerReports } from "./commands/reports"; import { registerResource } from "./commands/resource"; +import { registerWorkspace } from "./commands/workspace"; import { banner } from "./lib/banner"; const program = new Command(); @@ -51,6 +52,7 @@ program .addHelpText("beforeAll", banner()); registerAuth(program); +registerWorkspace(program); registerResource(program, { name: "events", @@ -63,7 +65,8 @@ registerResource(program, { actions: [ { name: "publish", describe: "Publish an event", fn: postEventsIdPublish }, ], - columns: ["id", "name", "status", "startsAt"], + // startsAt lives on EventDate, not Event — use createdAt for a temporal column. + columns: ["id", "name", "status", "createdAt"], }); registerEventDates(program, { @@ -101,7 +104,7 @@ registerResource(program, { create: postTicketTypes, update: patchTicketTypesId, del: deleteTicketTypesId, - columns: ["id", "name", "price", "currency", "stock"], + columns: ["id", "name", "price", "currency", "capacity"], listFlags: [ { flag: "--event-date-id ", @@ -119,7 +122,7 @@ registerResource(program, { create: postMembershipPlans, update: patchMembershipPlansId, del: deleteMembershipPlansId, - columns: ["id", "name", "price", "currency", "interval"], + columns: ["id", "name", "price", "currency", "billingCycle"], }); registerResource(program, { diff --git a/src/lib/api.ts b/src/lib/api.ts index 3296f70..24d8332 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -84,7 +84,7 @@ function hintFor(status?: number): string | undefined { case 404: return "The resource does not exist or belongs to another workspace."; case 501: - return "Write operation: planned for CLI phase 2."; + return "The backend has not implemented this operation yet."; } } diff --git a/src/lib/output.ts b/src/lib/output.ts index d919e57..d8b47f7 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -51,6 +51,25 @@ export function toCsv(rows: unknown, columns?: string[]): string { return [head, ...body].join("\n"); } +/** + * Resolves which columns to render for a list command. + * `--full` shows every field (inferred from the row); `--columns a,b,c` picks + * an explicit set; otherwise the resource's curated default is used. + */ +export function resolveColumns( + opts: { columns?: string; full?: boolean }, + fallback?: string[], +): string[] | undefined { + if (opts.full) return undefined; + if (opts.columns) { + return opts.columns + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + } + return fallback; +} + /** Pagination hint on stderr to avoid contaminating `--json` output. */ export function printNextCursor(page?: { nextCursor?: string | null }): void { if (page?.nextCursor) {