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
29 changes: 26 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ versioning follows [SemVer](https://semver.org/).

## [Unreleased]

## [0.7.0] - 2026-07-01

### Added
- **Update notice.** `ft` now prints a one-line "update available" hint on
*stderr* when a newer `@freeticket/cli` is on npm. Checked at most once a day
(cached in `~/.freeticket`), interactive terminals only — never on `--json`
output, pipes, or CI. Opt out with `FT_NO_UPDATE_CHECK=1`.
- **Manageable superadmin auth (`ft admin login|logout|config`)** — save a
SUPER_ADMIN session with `ft admin login --session <token>` (validated against
`GET /api/admin/me` before it's stored), inspect it masked with `ft admin
config`, and clear it with `ft admin logout`. No more hand-exporting
`FT_ADMIN_SESSION` every time (though it still works) (freeticket-cli#20).

### Changed
- **Auth wording is now "session", not "API key"** on the normal path: the
logged-out error, the 401 hint, `ft logout`, and `ft config` talk about your
session. `--key` / `FT_API_KEY` remain, framed as CI/headless only
(freeticket-cli#17).
- Empty `--csv` lists now keep their **header row** when the columns are known,
so a zero-result export still carries its schema (freeticket-cli#18).
- Documented the real `--json` (data array) vs `--raw` (`{ data, page }`
envelope) contract across README, changelog, and skill (freeticket-cli#19).

## [0.6.0] - 2026-07-01

### Added
Expand Down Expand Up @@ -49,9 +72,9 @@ versioning follows [SemVer](https://semver.org/).
workspace without editing `~/.freeticket/config.json` (#12).

### Changed
- **`--json` on lists now preserves pagination metadata:** returns the full
`{ data, page }` DTO so scripts can page from stdout alone, instead of dropping
`page.nextCursor` (#10).
- **`--raw` on lists emits the full `{ data, page }` envelope** so scripts can
page from stdout alone. `--json` stays the data array only (stable, parseable);
reach for `--raw` when you need `page.nextCursor` (#10).

### Fixed
- `ticket-types list` now shows `capacity` instead of the non-existent `stock`
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ ft sales list --status CONFIRMED --json

| Flag | Applies to | Description |
|---|---|---|
| `--json` | all commands | Raw JSON output, ideal for `jq` and scripts |
| `--json` | all commands | Raw JSON, ideal for `jq`/scripts. On **lists this is the data array only** — use `--raw` for the paginated envelope |
| `--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 |
| `--raw` | list commands | JSON `{ data, page }` envelope, including `page.nextCursor` for scripted pagination |
| `--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 |
Expand All @@ -133,11 +133,12 @@ flags > environment variables > ~/.freeticket/config.json > defaults
| Variable | Default | Description |
|---|---|---|
| `FT_API_URL` | `https://admin.appfreeticket.com` | API base URL (without `/api/v1`) |
| `FT_API_KEY` | — | Your `ft_live_...` key |
| `FT_API_KEY` | — | CI/headless only — a backend-issued `ft_live_...` token. Browser login (`ft login`) needs no key |
| `FT_WORKSPACE_ID` | *home* | Default active workspace |
| `FT_NO_UPDATE_CHECK` | — | Set to disable the "update available" notice |

The `~/.freeticket/config.json` file is created with `0600` permissions (only
your user can read it) because it stores the API key. See [`.env.example`](./.env.example).
your user can read it) because it stores your credentials. See [`.env.example`](./.env.example).

## Pagination, errors, and money

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@freeticket/cli",
"version": "0.6.0",
"version": "0.7.0",
"description": "Official FreeTicket CLI — consumes the B2B REST API for events, sales, tickets, memberships, venues, staff, and reports.",
"keywords": [
"freeticket",
Expand Down
55 changes: 54 additions & 1 deletion src/commands/admin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// biome-ignore-all lint/suspicious/noExplicitAny: generated SDK boundary — signatures vary by resource.
import chalk from "chalk";
import type { Command } from "commander";
import {
getAuditLog,
Expand All @@ -22,6 +23,7 @@ import {
putFeatureFlagsKey,
} from "../admin-client/sdk.gen";
import { configureAdminClient, unwrap } from "../lib/api";
import { CONFIG_PATH, loadConfig, saveConfig } from "../lib/config";
import { confirm, parseData } from "../lib/input";
import { print, printNextCursor, toCsv } from "../lib/output";

Expand Down Expand Up @@ -66,7 +68,58 @@ interface AdminResource {
export function registerAdmin(program: Command): void {
const admin = program
.command("admin")
.description("Superadmin (cross-tenant) — requires FT_ADMIN_SESSION");
.description("Superadmin (cross-tenant) — SUPER_ADMIN session required");

admin
.command("login")
.description(
"Save a SUPER_ADMIN session and validate it against /api/admin/me",
)
.requiredOption(
"--session <token>",
"the `better-auth.session_token` cookie value from an authenticated admin browser session",
)
.option(
"--url <url>",
"API base URL (default: https://admin.appfreeticket.com)",
)
.action(async (opts) => {
if (opts.url) saveConfig({ apiUrl: opts.url });
saveConfig({ adminSession: opts.session });
// Verify before declaring success — a bad cookie fails here, not later.
configureAdminClient();
const me = unwrap(await getMe({})).data;
console.log(
`${chalk.green("✓")} Admin session saved in ${chalk.dim(CONFIG_PATH)}`,
);
print(me, {});
});

admin
.command("logout")
.description("Remove the stored superadmin session")
.action(() => {
saveConfig({ adminSession: undefined });
console.log(
`${chalk.green("✓")} Admin session removed from ${chalk.dim(CONFIG_PATH)}`,
);
});

admin
.command("config")
.description("Show admin configuration (the session is masked)")
.action(() => {
const cfg = loadConfig();
print(
{
apiUrl: cfg.apiUrl,
adminSession: cfg.adminSession
? `${cfg.adminSession.slice(0, 12)}…`
: null,
},
{},
);
});

admin
.command("me")
Expand Down
9 changes: 5 additions & 4 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,24 @@ export function registerAuth(program: Command): void {

program
.command("logout")
.description("Remove the stored API key")
.description("Log out and remove the stored session")
.action(() => {
saveConfig({ apiKey: undefined });
console.log(
`${chalk.green("✓")} API key removed from ${chalk.dim(CONFIG_PATH)}`,
`${chalk.green("✓")} Logged out — session removed from ${chalk.dim(CONFIG_PATH)}`,
);
});

program
.command("config")
.description("Show active configuration (the API key is masked)")
.description("Show active configuration (the credential is masked)")
.action(() => {
const cfg = loadConfig();
print(
{
apiUrl: cfg.apiUrl,
apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 12)}…` : null,
// Same field whether it's a device-flow session or a CI API key.
session: cfg.apiKey ? `${cfg.apiKey.slice(0, 12)}…` : null,
workspaceId: cfg.workspaceId ?? null,
},
{},
Expand Down
14 changes: 10 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { registerResource } from "./commands/resource";
import { registerTickets } from "./commands/tickets";
import { registerWorkspace } from "./commands/workspace";
import { banner } from "./lib/banner";
import { notifyUpdate } from "./lib/update-check";

const program = new Command();

Expand Down Expand Up @@ -254,7 +255,12 @@ if (process.argv.length <= 2) {
process.exit(0);
}

program.parseAsync().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
program
.parseAsync()
// After the command runs, drop a one-line update notice on stderr (once/day,
// TTY-only). Never blocks or fails the command.
.then(() => notifyUpdate(pkg.version))
.catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
7 changes: 5 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { loadConfig } from "./config";
export function configureClient(workspaceOverride?: string): void {
const cfg = loadConfig();
if (!cfg.apiKey) {
fail("No API key configured. Run `ft login` or export FT_API_KEY.");
fail(
"Not logged in. Run `ft login` to sign in through your browser.",
"CI / headless only: set FT_API_KEY (or `ft login --key`) with a backend-issued token.",
);
}
const workspaceId = workspaceOverride ?? cfg.workspaceId;
client.setConfig({
Expand Down Expand Up @@ -78,7 +81,7 @@ export function unwrap<T>(res: {
function hintFor(status?: number): string | undefined {
switch (status) {
case 401:
return "Invalid, revoked, or expired API key. Run `ft login`.";
return "Your session is invalid, revoked, or expired. Run `ft login`.";
case 403:
return "Your role or workspace does not allow this action.";
case 404:
Expand Down
2 changes: 1 addition & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function loadConfig(): FtConfig {
export function saveConfig(patch: Partial<FtConfig>): void {
const next = { ...readFile(), ...patch };
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
// 0600: the file stores the API key.
// 0600: the file stores credentials (device-flow session or CI API key).
writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, {
mode: 0o600,
});
Expand Down
31 changes: 31 additions & 0 deletions src/lib/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { toCsv } from "./output";

describe("toCsv", () => {
const cols = ["id", "reference", "status", "total", "currency", "createdAt"];

it("emits only the header when rows are empty but columns are known", () => {
expect(toCsv([], cols)).toBe(
"id,reference,status,total,currency,createdAt",
);
});

it("emits only the header when data is undefined but columns are known", () => {
expect(toCsv(undefined, cols)).toBe(
"id,reference,status,total,currency,createdAt",
);
});

it("stays empty when there are neither rows nor columns", () => {
expect(toCsv([])).toBe("");
expect(toCsv(undefined)).toBe("");
});

it("serializes rows with RFC 4180 quoting", () => {
const csv = toCsv(
[{ id: 1, name: 'a,"b"', obj: { x: 1 } }],
["id", "name", "obj"],
);
expect(csv).toBe('id,name,obj\n1,"a,""b""","{""x"":1}"');
});
});
21 changes: 13 additions & 8 deletions src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,26 @@ export function print(data: unknown, opts: PrintOpts = {}): void {
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
}

function csvEsc(v: unknown): string {
if (v === null || v === undefined) return "";
const s = typeof v === "object" ? JSON.stringify(v) : String(v);
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

/**
* Serializes an array of flat rows to CSV (RFC 4180 quoting). Columns default
* to the first row's keys. Objects/arrays in cells are JSON-stringified.
* When there are no rows but the caller knows the columns, we still emit the
* header line so the CSV keeps its schema (useful for spreadsheets/pipelines).
*/
export function toCsv(rows: unknown, columns?: string[]): string {
if (!Array.isArray(rows) || rows.length === 0) return "";
if (!Array.isArray(rows) || rows.length === 0) {
return columns?.length ? columns.map(csvEsc).join(",") : "";
}
const cols = columns ?? Object.keys(rows[0] as object);
const esc = (v: unknown): string => {
if (v === null || v === undefined) return "";
const s = typeof v === "object" ? JSON.stringify(v) : String(v);
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const head = cols.map(esc).join(",");
const head = cols.map(csvEsc).join(",");
const body = (rows as Record<string, unknown>[]).map((r) =>
cols.map((c) => esc(r[c])).join(","),
cols.map((c) => csvEsc(r[c])).join(","),
);
return [head, ...body].join("\n");
}
Expand Down
21 changes: 21 additions & 0 deletions src/lib/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { isNewer } from "./update-check";

describe("isNewer", () => {
it("detects newer versions across each semver field", () => {
expect(isNewer("0.7.0", "0.6.0")).toBe(true);
expect(isNewer("1.0.0", "0.9.9")).toBe(true);
expect(isNewer("0.6.1", "0.6.0")).toBe(true);
});

it("is false for equal or older versions", () => {
expect(isNewer("0.6.0", "0.6.0")).toBe(false);
expect(isNewer("0.5.9", "0.6.0")).toBe(false);
expect(isNewer("0.6.0", "0.6.1")).toBe(false);
});

it("ignores a leading v and prerelease tags", () => {
expect(isNewer("v0.7.0", "0.6.0")).toBe(true);
expect(isNewer("0.7.0-beta.1", "0.7.0")).toBe(false);
});
});
Loading
Loading