Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -74,17 +78,26 @@ ft sales list --status CONFIRMED --json
| `ft login` | Browser login (device flow); `--key <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 <id\|slug>` · `show` | List, switch, and show the active workspace | VIEWER |
| `ft events list` · `get <id>` | Workspace events | VIEWER |
| `ft events create` · `update <id>` · `delete <id>` | Manage events (`--data <json>`) | ADMIN |
| `ft events publish <id>` | Publish an event | ADMIN |
| `ft event-dates list` · `create` · `update` · `delete` | Event dates | ADMIN |
| `ft ticket-types list` · `get <id>` | Ticket types (`--event-date-id`) | VIEWER |
| `ft ticket-types create` · `update <id>` · `delete <id>` | Manage ticket types (`--data <json>`) | ADMIN |
| `ft sales list` · `get <id>` | Sales (`--status`) | STAFF |
| `ft sales cancel <id>` · `refund <id>` | Cancel / refund a sale (`--data` for partial refund) | ADMIN |
| `ft plans list` · `get <id>` | Membership plans | VIEWER |
| `ft plans create` · `update <id>` · `delete <id>` | Manage membership plans (`--data <json>`) | ADMIN |
| `ft venues list` · `get <id>` | Venues | VIEWER |
| `ft staff list` | Workspace staff | ADMIN |
| `ft venues create` · `update <id>` · `delete <id>` | Manage venues (`--data <json>`) | ADMIN |
| `ft staff list` · `create` · `set-role <id>` | Workspace staff (`--data <json>`) | 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 <inline-json>` or
> `--data @file.json`. `delete` prompts for confirmation unless you pass `--yes`.

### Common flags

Comment on lines +98 to 103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Readme missing minimum cli version 📎 Requirement gap ⚙ Maintainability

The updated README documents new/updated CLI behaviors (writes, --all, --raw, workspace
switching) but does not specify a minimum CLI version or add troubleshooting/update steps to prevent
version drift. Users running an older cached/global version may not have these behaviors, causing
operational confusion and support churn.
Agent Prompt
## Issue description
README describes behaviors that may not exist in older installed/cached CLI versions, but it does not state a minimum required CLI version nor provide upgrade/troubleshooting steps.

## Issue Context
Compliance requires documentation to avoid version drift, including minimum version guidance and troubleshooting (e.g., checking version and using `@latest`). The README was updated for writes/pagination/workspace behavior but still lacks those guardrails.

## Fix Focus Areas
- README.md[98-113]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Expand All @@ -93,6 +106,11 @@ ft sales list --status CONFIRMED --json
| `--json` | all commands | Raw JSON output, ideal for `jq` and scripts |
| `--workspace <id>` | all commands | Run the command against another workspace |
| `--limit <n>` `--cursor <id>` | 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 <a,b,c>` `--full` | list commands | Pick specific columns · show every field |
| `--csv` | list commands | CSV output for spreadsheets/accounting |
| `--data <json>` `--yes` | write commands | JSON body (inline or `@file`) · skip delete confirmation |

## Configuration

Expand All @@ -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 <id>` hint to *stderr* so `--json` stays clean on *stdout*.
- **Pagination:** list responses include `page.nextCursor` / `page.hasMore`. The
CLI prints the `--cursor <id>` 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`).
Expand Down
43 changes: 39 additions & 4 deletions src/commands/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,12 +56,17 @@ export function registerResource(program: Command, spec: ResourceSpec): void {
.description(`List ${spec.name} from the active workspace`)
.option("--limit <n>", "results per page (1-100)", "20")
.option("--cursor <id>", "pagination cursor")
.option("--all", "auto-paginate: fetch every page (ignores --cursor)")
.option("--columns <list>", "comma-separated columns to display")
.option("--full", "show every field instead of the curated columns")
.option("--workspace <id>", "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<string, unknown> = {
limit: Number(opts.limit),
cursor: opts.cursor,
Expand All @@ -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 },
);
Comment on lines +79 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Autopagination can stop early 🐞 Bug ≡ Correctness

In --all mode, pagination stops when page.hasMore is true but page.nextCursor is
missing/empty, returning partial results without any error. The --raw output in --all mode also
hardcodes { hasMore: false, nextCursor: null }, which can mask that early termination.
Agent Prompt
### Issue description
The `--all` autopagination loop assumes that `page.hasMore === true` always comes with a usable `page.nextCursor`. If that assumption is violated (or if `nextCursor` is accidentally empty), the loop stops early and returns incomplete results with no warning.

### Issue Context
Current loop:
- sets `cursor = page?.nextCursor ?? undefined`
- continues while `(page?.hasMore && cursor)`
So `hasMore=true` + missing cursor will terminate.

Also, `--raw` in `--all` mode emits a fabricated `page` object with `hasMore:false`, which can hide that termination condition.

### Fix Focus Areas
- src/commands/resource.ts[79-101]

### Suggested fix
1. After each page, if `page?.hasMore` is true but `page?.nextCursor` is falsy, abort with `fail()` explaining the pagination metadata is inconsistent.
2. Consider tracking seen cursors and aborting if a cursor repeats to prevent potential infinite loops.
3. For `--raw --all`, either:
   - return the last real `page` from the server plus a flag like `aggregated: true`, or
   - include a warning field indicating the page metadata is no longer meaningful after aggregation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return;
}
if (opts.csv) {
process.stdout.write(`${toCsv(rows, columns)}\n`);
return;
}
print(rows, { json: opts.json, columns });
return;
Comment on lines +79 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. --all lacks guardrails 📎 Requirement gap ☼ Reliability

The new --all auto-pagination loop can fetch an unbounded number of pages with no limits or
safeguards, risking very large downloads and rate-limit/time issues. This does not meet the
requirement that --all be safe/guardrailed for large lists/exports.
Agent Prompt
## Issue description
The new `--all` implementation auto-paginates without any guardrails (max pages/items, timeout/abort, or confirmation), which can lead to unbounded requests and very large outputs.

## Issue Context
Compliance requires `--all` to be safe and consistent for large lists/exports. Currently, the loop continues while `page.hasMore` and `nextCursor` are truthy, with no upper bound.

## Fix Focus Areas
- src/commands/resource.ts[79-101]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

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);
});
}
Expand Down
80 changes: 80 additions & 0 deletions src/commands/workspace.ts
Original file line number Diff line number Diff line change
@@ -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 <idOrSlug>")
.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(
Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Workspace recovery blocked 🐞 Bug ☼ Reliability

ft workspace list|use|show calls configureClient() which automatically applies the persisted
workspaceId as X-Workspace-Id; if that ID is stale/unauthorized, getMe() can fail and the user
cannot use these commands to recover. workspace show also prefers the persisted workspaceId even
when it doesn’t exist in me.workspaces, causing an unnecessary hard failure.
Agent Prompt
### Issue description
The new `workspace` commands are intended to help users switch workspaces, but they currently depend on the existing configured workspace header being valid. Because `configureClient()` injects `X-Workspace-Id` from config by default, a stale/unauthorized `workspaceId` can prevent `getMe()` from succeeding, leaving users unable to run `ft workspace list/use/show` to fix the configuration.

### Issue Context
- `configureClient()` chooses `workspaceOverride ?? cfg.workspaceId` and sends it as `X-Workspace-Id`.
- Workspace commands call `configureClient()` with no override before calling `getMe()`.
- `workspace show` uses `cfg.workspaceId ?? me.activeWorkspaceId` and fails if that ID isn’t found in `me.workspaces`, instead of falling back.

### Fix Focus Areas
- src/lib/api.ts[9-21]
- src/commands/workspace.ts[23-79]

### Suggested fix
1. Add a supported way to configure the client **without** the workspace header (e.g., `configureClient({ workspaceOverride?: string, disableWorkspaceHeader?: boolean })` or accept `null` as “do not send”).
2. In `workspace list/use/show`, call the “no workspace header” configuration so `GET /me` works even when the saved workspace is broken.
3. In `workspace show` (and optionally `list`), if a configured workspaceId is not present in `me.workspaces`, fall back to `me.activeWorkspaceId` and/or print a warning that the local config is stale and needs `ft workspace use <slug>`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

(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 <slug>`.",
);
}
print(active, { json: opts.json });
});
}
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -51,6 +52,7 @@ program
.addHelpText("beforeAll", banner());

registerAuth(program);
registerWorkspace(program);

registerResource(program, {
name: "events",
Expand All @@ -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, {
Expand Down Expand Up @@ -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 <id>",
Expand All @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading