diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3a1c7..e444da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 @@ -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` diff --git a/README.md b/README.md index af2a3c8..57ed248 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 | +| `--raw` | list commands | JSON `{ data, page }` envelope, including `page.nextCursor` for scripted pagination | | `--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 | @@ -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 diff --git a/package.json b/package.json index 4e259bc..927b6dd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/admin.ts b/src/commands/admin.ts index 3d197f7..4d782a3 100644 --- a/src/commands/admin.ts +++ b/src/commands/admin.ts @@ -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, @@ -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"; @@ -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 ", + "the `better-auth.session_token` cookie value from an authenticated admin browser session", + ) + .option( + "--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") diff --git a/src/commands/auth.ts b/src/commands/auth.ts index b18888b..f6f02e1 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -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, }, {}, diff --git a/src/index.ts b/src/index.ts index 926ea73..8eb4a34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); @@ -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); + }); diff --git a/src/lib/api.ts b/src/lib/api.ts index 24d8332..288a62e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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({ @@ -78,7 +81,7 @@ export function unwrap(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: diff --git a/src/lib/config.ts b/src/lib/config.ts index d277698..28ed037 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -41,7 +41,7 @@ export function loadConfig(): FtConfig { export function saveConfig(patch: Partial): 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, }); diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts new file mode 100644 index 0000000..b018e2d --- /dev/null +++ b/src/lib/output.test.ts @@ -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}"'); + }); +}); diff --git a/src/lib/output.ts b/src/lib/output.ts index d8b47f7..45a16a8 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -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[]).map((r) => - cols.map((c) => esc(r[c])).join(","), + cols.map((c) => csvEsc(r[c])).join(","), ); return [head, ...body].join("\n"); } diff --git a/src/lib/update-check.test.ts b/src/lib/update-check.test.ts new file mode 100644 index 0000000..cd1b8e3 --- /dev/null +++ b/src/lib/update-check.test.ts @@ -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); + }); +}); diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..797a53d --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import chalk from "chalk"; + +const CACHE_PATH = join(homedir(), ".freeticket", "update-check.json"); +const REGISTRY = "https://registry.npmjs.org/@freeticket/cli/latest"; +const ONE_DAY = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT = 1500; + +/** + * Soft update notice. Prints to *stderr* when a newer @freeticket/cli is on npm, + * so `--json` on stdout stays clean and pipelines are never touched. The npm + * registry is hit at most once a day (cached in ~/.freeticket); every other run + * reads the cache and is instant. Best-effort: any failure is swallowed. + * + * Opt out with FT_NO_UPDATE_CHECK=1. Skipped in non-interactive/CI contexts so + * automation never sees it. + * + * ponytail: soft notify only. If the backend ever needs to *force* an upgrade + * (breaking contract), add a min-version gate here that reads a required version + * and calls fail() instead of just warning. + */ +export async function notifyUpdate(current: string): Promise { + if ( + !process.stderr.isTTY || + process.env.FT_NO_UPDATE_CHECK || + process.env.CI + ) { + return; + } + try { + const latest = await getLatest(); + if (latest && isNewer(latest, current)) { + process.stderr.write( + `\n${chalk.yellow(`⚠ Update available: @freeticket/cli ${current} → ${latest}`)}\n` + + `${chalk.dim(" npm i -g @freeticket/cli@latest · or use npx @freeticket/cli@latest")}\n\n`, + ); + } + } catch { + // ponytail: the update check must never break a command. Ignore everything. + } +} + +/** Latest version from cache (fresh ≤ 1 day) or the npm registry. */ +async function getLatest(): Promise { + const cached = readCache(); + if (cached && Date.now() - cached.at < ONE_DAY) return cached.latest; + + const res = await fetch(REGISTRY, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + headers: { Accept: "application/vnd.npm.install-v1+json" }, + }); + if (!res.ok) throw new Error(`registry ${res.status}`); + const latest = ((await res.json()) as { version?: string }).version ?? null; + if (latest) writeCache({ at: Date.now(), latest }); + return latest; +} + +function readCache(): { at: number; latest: string } | null { + if (!existsSync(CACHE_PATH)) return null; + try { + return JSON.parse(readFileSync(CACHE_PATH, "utf8")); + } catch { + return null; + } +} + +function writeCache(v: { at: number; latest: string }): void { + try { + mkdirSync(dirname(CACHE_PATH), { recursive: true }); + writeFileSync(CACHE_PATH, JSON.stringify(v)); + } catch { + // ponytail: caching is an optimization; a failed write just means we re-check. + } +} + +/** True when semver `a` is strictly greater than `b` (major.minor.patch; prerelease ignored). */ +export function isNewer(a: string, b: string): boolean { + const parse = (v: string): [number, number, number] => { + const [maj, min, pat] = (v.replace(/^v/, "").split("-")[0] ?? "") + .split(".") + .map((n) => Number.parseInt(n, 10) || 0); + return [maj ?? 0, min ?? 0, pat ?? 0]; + }; + const [aM, aN, aP] = parse(a); + const [bM, bN, bP] = parse(b); + return aM > bM || (aM === bM && (aN > bN || (aN === bN && aP > bP))); +}