flipstream 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,25 +6,20 @@ authenticates once and prints clean **structured JSON** for AI coding agents.
6
6
  > **Architecture:** this supersedes `flipstream-cli-kickoff.md`. See [`CLAUDE.md`](./CLAUDE.md)
7
7
  > for the corrected model (thin client, two hosts, **no natural-language "ask"**).
8
8
 
9
- ## Prerequisites
10
-
11
- - [Bun](https://bun.sh) (development toolchain)
12
- - Node.js **20+** (the published CLI runs under Node)
13
-
14
- ## Install (development)
9
+ ## Install
15
10
 
16
11
  ```sh
17
- bun install
18
- bun run build
12
+ npm i -g flipstream # requires Node.js 20+
19
13
  ```
20
14
 
21
15
  ## Usage
22
16
 
23
17
  ```sh
24
- ./bin/run.js --help # or during dev: bun bin/dev.js --help
25
- ./bin/run.js --version
26
- ./bin/run.js version --json # {version,node,platform,contractVersion}
27
- ./bin/run.js auth login # opens the browser to sign in (OAuth2 + PKCE)
18
+ flipstream --help
19
+ flipstream --version
20
+ flipstream version --json # {version,node,platform,contractVersion}
21
+ flipstream auth login # opens the browser to sign in (OAuth2 + PKCE)
22
+ flipstream ws list # list your organization's workspaces
28
23
  ```
29
24
 
30
25
  ## For agents (Claude Code / Codex / MCP)
@@ -46,19 +41,30 @@ taxonomy, per-command shapes, `CONTRACT_VERSION`) lives in
46
41
  [`docs/AGENT-CONTRACT.md`](./docs/AGENT-CONTRACT.md) and is enforced by a
47
42
  conformance suite.
48
43
 
49
- ## Scripts
44
+ ## Development
45
+
46
+ Built with [Bun](https://bun.sh); the published CLI runs under **Node.js 20+**.
47
+
48
+ ```sh
49
+ bun install
50
+ bun run build
51
+ ./bin/run.js --help # run from source (or during dev: bun bin/dev.js --help)
52
+ ```
53
+
54
+ Scripts:
50
55
 
51
56
  - `bun run build` — compile TypeScript to `dist/`
52
57
  - `bun run lint` — eslint (flat config)
53
58
  - `bun run check:secrets` — static deny-list gate over `src/lib/auth` (CI-enforced)
54
59
  - `bun run format` — prettier
55
60
  - `bun test` — run the test suite
61
+ - `bun run smoke` — pack the tarball + run the packaged binary (E5-4)
56
62
  - `bun run dev` — run the CLI from source (`./bin/dev.js`)
57
63
 
58
64
  ## Security
59
65
 
60
- The CLI is a **public OAuth2 client** (Authorization Code + PKCE + Dynamic Client
61
- Registration) — it holds **no client secret**.
66
+ The CLI is a **public OAuth2 client** (Authorization Code + PKCE, a fixed
67
+ first-party `client_id`) — it holds **no client secret**.
62
68
 
63
69
  - **Tokens live only in the OS keychain** (`@napi-rs/keyring`, service id
64
70
  `io.flipstream.cli`); nothing is written to disk in plaintext, and the
@@ -104,6 +110,10 @@ If you already removed the binary, clean up manually:
104
110
  * [`flipstream commands`](#flipstream-commands)
105
111
  * [`flipstream conn list`](#flipstream-conn-list)
106
112
  * [`flipstream connections list`](#flipstream-connections-list)
113
+ * [`flipstream log add MESSAGE`](#flipstream-log-add-message)
114
+ * [`flipstream log list`](#flipstream-log-list)
115
+ * [`flipstream logs add MESSAGE`](#flipstream-logs-add-message)
116
+ * [`flipstream logs list`](#flipstream-logs-list)
107
117
  * [`flipstream query`](#flipstream-query)
108
118
  * [`flipstream version`](#flipstream-version)
109
119
  * [`flipstream workspaces connections ID`](#flipstream-workspaces-connections-id)
@@ -341,6 +351,170 @@ EXAMPLES
341
351
  $ flipstream conn list --all
342
352
  ```
343
353
 
354
+ ## `flipstream log add MESSAGE`
355
+
356
+ Add an entry to a workspace's log.
357
+
358
+ ```
359
+ USAGE
360
+ $ flipstream log add MESSAGE --workspace <value> [--json] [-H <value>] [--ndjson] [--timeout <value>]
361
+ [--verbose] [--end <value>] [--start <value>]
362
+
363
+ ARGUMENTS
364
+ MESSAGE The log entry text.
365
+
366
+ FLAGS
367
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
368
+ --end=<value> End date (ISO 8601). Defaults to now.
369
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
370
+ --start=<value> Start date (ISO 8601). Defaults to now.
371
+ --timeout=<value> Network timeout in milliseconds.
372
+ --verbose Print redacted diagnostics to stderr.
373
+ --workspace=<value> (required) Workspace id (UUID) the entry belongs to.
374
+
375
+ GLOBAL FLAGS
376
+ --json Format output as json.
377
+
378
+ DESCRIPTION
379
+ Add an entry to a workspace's log.
380
+
381
+ Add an entry to a workspace's log (the logbook). You give the workspace (--workspace/--ws) and the message; the
382
+ organization is derived from the workspace, and the dates default to now (override with --start/--end). Returns the
383
+ created entry. Targets the OAuth/admin host.
384
+
385
+ ALIASES
386
+ $ flipstream logs add
387
+
388
+ EXAMPLES
389
+ $ flipstream log add "shipped the new pipeline" --workspace <id>
390
+
391
+ $ flipstream log add "kickoff call done" --ws <id> --json
392
+ ```
393
+
394
+ ## `flipstream log list`
395
+
396
+ Read a workspace's log entries.
397
+
398
+ ```
399
+ USAGE
400
+ $ flipstream log list --workspace <value> [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose]
401
+ [--all] [--limit <value>] [--offset <value>] [--q <value>] [--sort <value>]
402
+
403
+ FLAGS
404
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
405
+ --all Fetch every page (drains pagination).
406
+ --limit=<value> [default: 50] Page size.
407
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
408
+ --offset=<value> Starting offset.
409
+ --q=<value> Free-text filter.
410
+ --sort=<value> Sort field.
411
+ --timeout=<value> Network timeout in milliseconds.
412
+ --verbose Print redacted diagnostics to stderr.
413
+ --workspace=<value> (required) The workspace whose log to read (UUID).
414
+
415
+ GLOBAL FLAGS
416
+ --json Format output as json.
417
+
418
+ DESCRIPTION
419
+ Read a workspace's log entries.
420
+
421
+ Read a workspace's log entries (the logbook). --workspace is required — a log entry only makes sense in a workspace's
422
+ context, so there is no account-wide read. Shows each entry's description, dates, and author; the full record
423
+ (including any new fields like tags) is under --json. Targets the OAuth/admin host.
424
+
425
+ ALIASES
426
+ $ flipstream logs list
427
+
428
+ EXAMPLES
429
+ $ flipstream log list --workspace <id>
430
+
431
+ $ flipstream logs list --ws <id> --json
432
+
433
+ $ flipstream log list --ws <id> --all
434
+ ```
435
+
436
+ ## `flipstream logs add MESSAGE`
437
+
438
+ Add an entry to a workspace's log.
439
+
440
+ ```
441
+ USAGE
442
+ $ flipstream logs add MESSAGE --workspace <value> [--json] [-H <value>] [--ndjson] [--timeout <value>]
443
+ [--verbose] [--end <value>] [--start <value>]
444
+
445
+ ARGUMENTS
446
+ MESSAGE The log entry text.
447
+
448
+ FLAGS
449
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
450
+ --end=<value> End date (ISO 8601). Defaults to now.
451
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
452
+ --start=<value> Start date (ISO 8601). Defaults to now.
453
+ --timeout=<value> Network timeout in milliseconds.
454
+ --verbose Print redacted diagnostics to stderr.
455
+ --workspace=<value> (required) Workspace id (UUID) the entry belongs to.
456
+
457
+ GLOBAL FLAGS
458
+ --json Format output as json.
459
+
460
+ DESCRIPTION
461
+ Add an entry to a workspace's log.
462
+
463
+ Add an entry to a workspace's log (the logbook). You give the workspace (--workspace/--ws) and the message; the
464
+ organization is derived from the workspace, and the dates default to now (override with --start/--end). Returns the
465
+ created entry. Targets the OAuth/admin host.
466
+
467
+ ALIASES
468
+ $ flipstream logs add
469
+
470
+ EXAMPLES
471
+ $ flipstream log add "shipped the new pipeline" --workspace <id>
472
+
473
+ $ flipstream log add "kickoff call done" --ws <id> --json
474
+ ```
475
+
476
+ ## `flipstream logs list`
477
+
478
+ Read a workspace's log entries.
479
+
480
+ ```
481
+ USAGE
482
+ $ flipstream logs list --workspace <value> [--json] [-H <value>] [--ndjson] [--timeout <value>] [--verbose]
483
+ [--all] [--limit <value>] [--offset <value>] [--q <value>] [--sort <value>]
484
+
485
+ FLAGS
486
+ -H, --host=<value> OAuth provider host (defaults to the prod issuer).
487
+ --all Fetch every page (drains pagination).
488
+ --limit=<value> [default: 50] Page size.
489
+ --ndjson Stream output as NDJSON (one compact JSON object per line).
490
+ --offset=<value> Starting offset.
491
+ --q=<value> Free-text filter.
492
+ --sort=<value> Sort field.
493
+ --timeout=<value> Network timeout in milliseconds.
494
+ --verbose Print redacted diagnostics to stderr.
495
+ --workspace=<value> (required) The workspace whose log to read (UUID).
496
+
497
+ GLOBAL FLAGS
498
+ --json Format output as json.
499
+
500
+ DESCRIPTION
501
+ Read a workspace's log entries.
502
+
503
+ Read a workspace's log entries (the logbook). --workspace is required — a log entry only makes sense in a workspace's
504
+ context, so there is no account-wide read. Shows each entry's description, dates, and author; the full record
505
+ (including any new fields like tags) is under --json. Targets the OAuth/admin host.
506
+
507
+ ALIASES
508
+ $ flipstream logs list
509
+
510
+ EXAMPLES
511
+ $ flipstream log list --workspace <id>
512
+
513
+ $ flipstream logs list --ws <id> --json
514
+
515
+ $ flipstream log list --ws <id> --all
516
+ ```
517
+
344
518
  ## `flipstream query`
345
519
 
346
520
  Run an opaque query against the Flipstream data API.
@@ -1,5 +1,6 @@
1
1
  import { Flags } from '@oclif/core';
2
- import { fetchConnections, resolveWorkspaceNames } from '../../lib/api/connections.js';
2
+ import { fetchConnections } from '../../lib/api/connections.js';
3
+ import { hydrate, WORKSPACE_NAME } from '../../lib/api/hydrate.js';
3
4
  import { assertUuid } from '../../lib/api/ids.js';
4
5
  import { authedAdminClient, renderConnectionsTable } from '../../lib/command/admin.js';
5
6
  import { BaseCommand } from '../../lib/command/base.js';
@@ -17,7 +18,7 @@ export default class ConnectionsList extends BaseCommand {
17
18
  ];
18
19
  static flags = {
19
20
  ...paginationFlags,
20
- workspace: Flags.string({ description: "List only this workspace's connections (UUID)." }),
21
+ workspace: Flags.string({ aliases: ['ws'], description: "List only this workspace's connections (UUID)." }),
21
22
  };
22
23
  static summary = 'List connections, org-wide or for one workspace.';
23
24
  async run() {
@@ -37,7 +38,7 @@ export default class ConnectionsList extends BaseCommand {
37
38
  sort: this.flags.sort,
38
39
  workspace,
39
40
  });
40
- await resolveWorkspaceNames(client, projected.records);
41
+ await hydrate(client, projected.records, [WORKSPACE_NAME]);
41
42
  return this.respondList(projected, (records) => renderConnectionsTable(records));
42
43
  }
43
44
  }
@@ -0,0 +1,16 @@
1
+ import { BaseCommand } from '../../lib/command/base.js';
2
+ export default class LogAdd extends BaseCommand<typeof LogAdd> {
3
+ static aliases: string[];
4
+ static args: {
5
+ message: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static examples: string[];
9
+ static flags: {
10
+ end: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ start: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ static summary: string;
15
+ run(): Promise<unknown>;
16
+ }
@@ -0,0 +1,44 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { assertUuid } from '../../lib/api/ids.js';
3
+ import { addLogEntry, resolveWorkspaceOrg } from '../../lib/api/log.js';
4
+ import { authedAdminClient } from '../../lib/command/admin.js';
5
+ import { BaseCommand } from '../../lib/command/base.js';
6
+ import { AuthRequiredError } from '../../lib/errors.js';
7
+ import { createStore } from '../../lib/store/index.js';
8
+ export default class LogAdd extends BaseCommand {
9
+ static aliases = ['logs:add'];
10
+ static args = {
11
+ message: Args.string({ description: 'The log entry text.', required: true }),
12
+ };
13
+ static description = "Add an entry to a workspace's log (the logbook). You give the workspace (--workspace/--ws) and the message; the " +
14
+ 'organization is derived from the workspace, and the dates default to now (override with --start/--end). ' +
15
+ 'Returns the created entry. Targets the OAuth/admin host.';
16
+ static examples = [
17
+ '<%= config.bin %> log add "shipped the new pipeline" --workspace <id>',
18
+ '<%= config.bin %> log add "kickoff call done" --ws <id> --json',
19
+ ];
20
+ static flags = {
21
+ end: Flags.string({ description: 'End date (ISO 8601). Defaults to now.' }),
22
+ start: Flags.string({ description: 'Start date (ISO 8601). Defaults to now.' }),
23
+ workspace: Flags.string({ aliases: ['ws'], description: 'Workspace id (UUID) the entry belongs to.', required: true }),
24
+ };
25
+ static summary = "Add an entry to a workspace's log.";
26
+ async run() {
27
+ assertUuid(this.flags.workspace, 'workspace id'); // exit 2, no request
28
+ const host = this.resolvedHost();
29
+ const store = createStore();
30
+ if (!store.load(host))
31
+ throw AuthRequiredError.notLoggedIn();
32
+ const client = authedAdminClient(host, store, this.flags.timeout);
33
+ const organizationId = await resolveWorkspaceOrg(client, this.flags.workspace);
34
+ const now = new Date().toISOString();
35
+ const created = await addLogEntry(client, {
36
+ clientId: this.flags.workspace,
37
+ description: this.args.message,
38
+ endDate: this.flags.end ?? now,
39
+ organizationId,
40
+ startDate: this.flags.start ?? now,
41
+ });
42
+ return this.respond(created, (entry) => this.log(`Logged entry ${String(entry.id)} to workspace ${this.flags.workspace}.`));
43
+ }
44
+ }
@@ -0,0 +1,19 @@
1
+ import { BaseCommand } from '../../lib/command/base.js';
2
+ export default class LogList extends BaseCommand<typeof LogList> {
3
+ static aliases: string[];
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ workspace: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ offset: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
11
+ q: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ sort: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ static summary: string;
15
+ run(): Promise<{
16
+ count: number;
17
+ records: unknown[];
18
+ }>;
19
+ }
@@ -0,0 +1,43 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { CREATOR_EMAIL, hydrate, WORKSPACE_NAME } from '../../lib/api/hydrate.js';
3
+ import { assertUuid } from '../../lib/api/ids.js';
4
+ import { fetchLog } from '../../lib/api/log.js';
5
+ import { authedAdminClient, renderLogEntriesTable } from '../../lib/command/admin.js';
6
+ import { BaseCommand } from '../../lib/command/base.js';
7
+ import { paginationFlags } from '../../lib/command/flags.js';
8
+ import { AuthRequiredError } from '../../lib/errors.js';
9
+ import { createStore } from '../../lib/store/index.js';
10
+ export default class LogList extends BaseCommand {
11
+ static aliases = ['logs:list'];
12
+ static description = "Read a workspace's log entries (the logbook). --workspace is required — a log entry only makes sense in a " +
13
+ "workspace's context, so there is no account-wide read. Shows each entry's description, dates, and author; the " +
14
+ 'full record (including any new fields like tags) is under --json. Targets the OAuth/admin host.';
15
+ static examples = [
16
+ '<%= config.bin %> log list --workspace <id>',
17
+ '<%= config.bin %> logs list --ws <id> --json',
18
+ '<%= config.bin %> log list --ws <id> --all',
19
+ ];
20
+ static flags = {
21
+ ...paginationFlags,
22
+ workspace: Flags.string({ aliases: ['ws'], description: 'The workspace whose log to read (UUID).', required: true }),
23
+ };
24
+ static summary = "Read a workspace's log entries.";
25
+ async run() {
26
+ assertUuid(this.flags.workspace, 'workspace id'); // exit 2, no request
27
+ const host = this.resolvedHost();
28
+ const store = createStore();
29
+ if (!store.load(host))
30
+ throw AuthRequiredError.notLoggedIn();
31
+ const client = authedAdminClient(host, store, this.flags.timeout);
32
+ const projected = await fetchLog(client, {
33
+ all: this.flags.all,
34
+ limit: this.flags.limit,
35
+ offset: this.flags.offset,
36
+ q: this.flags.q,
37
+ sort: this.flags.sort,
38
+ workspace: this.flags.workspace,
39
+ });
40
+ await hydrate(client, projected.records, [WORKSPACE_NAME, CREATOR_EMAIL]);
41
+ return this.respondList(projected, (records) => renderLogEntriesTable(records));
42
+ }
43
+ }
@@ -1,5 +1,6 @@
1
1
  import { Args } from '@oclif/core';
2
- import { fetchConnections, resolveWorkspaceNames } from '../../lib/api/connections.js';
2
+ import { fetchConnections } from '../../lib/api/connections.js';
3
+ import { hydrate, WORKSPACE_NAME } from '../../lib/api/hydrate.js';
3
4
  import { assertUuid } from '../../lib/api/ids.js';
4
5
  import { authedAdminClient, renderConnectionsTable } from '../../lib/command/admin.js';
5
6
  import { BaseCommand } from '../../lib/command/base.js';
@@ -35,7 +36,7 @@ export default class WorkspacesConnections extends BaseCommand {
35
36
  sort: this.flags.sort,
36
37
  workspace: this.args.id,
37
38
  });
38
- await resolveWorkspaceNames(client, projected.records);
39
+ await hydrate(client, projected.records, [WORKSPACE_NAME]);
39
40
  return this.respondList(projected, (records) => renderConnectionsTable(records));
40
41
  }
41
42
  }
@@ -3,8 +3,13 @@ export interface AdminGetOptions {
3
3
  query?: Record<string, boolean | number | string | undefined>;
4
4
  timeoutMs?: number;
5
5
  }
6
+ export interface AdminPostOptions {
7
+ body?: unknown;
8
+ timeoutMs?: number;
9
+ }
6
10
  export interface AdminClient {
7
11
  get(path: string, options?: AdminGetOptions): Promise<unknown>;
12
+ post(path: string, options?: AdminPostOptions): Promise<unknown>;
8
13
  }
9
14
  export interface AdminClientOptions {
10
15
  getToken: () => null | Promise<null | string> | string;
@@ -22,6 +22,16 @@ export function createAdminClient(options) {
22
22
  url: `${base}${path}`,
23
23
  });
24
24
  },
25
+ async post(path, postOptions = {}) {
26
+ const token = await options.getToken();
27
+ return requestJson({
28
+ body: postOptions.body,
29
+ method: 'POST',
30
+ timeoutMs: postOptions.timeoutMs ?? defaultTimeout,
31
+ token,
32
+ url: `${base}${path}`,
33
+ });
34
+ },
25
35
  };
26
36
  }
27
37
  // The authed admin client: the SAME withFreshToken 401->refresh->retry-once
@@ -40,5 +50,14 @@ export function createAuthedAdminClient(deps) {
40
50
  url: `${base}${path}`,
41
51
  }));
42
52
  },
53
+ post(path, postOptions = {}) {
54
+ return withFreshToken(deps, (token) => requestJson({
55
+ body: postOptions.body,
56
+ method: 'POST',
57
+ timeoutMs: postOptions.timeoutMs ?? defaultTimeout,
58
+ token,
59
+ url: `${base}${path}`,
60
+ }));
61
+ },
43
62
  };
44
63
  }
@@ -11,4 +11,3 @@ export declare function fetchConnections(client: AdminClient, query: Connections
11
11
  count: number;
12
12
  records: Array<Record<string, unknown>>;
13
13
  }>;
14
- export declare function resolveWorkspaceNames(client: AdminClient, records: Array<Record<string, unknown>>): Promise<void>;
@@ -17,28 +17,3 @@ export async function fetchConnections(client, query) {
17
17
  const envelope = query.all ? await drainPages(fetchPage, start) : await fetchPage(start);
18
18
  return { count: envelope.count, records: envelope.records.map((record) => projectConnection(record)) };
19
19
  }
20
- // Fill `client_name` for connections that lack it (the API didn't nest the
21
- // client): one drained workspace lookup, keyed by id. Skipped when all already
22
- // have it; best-effort (leaves names unresolved rather than failing the list).
23
- export async function resolveWorkspaceNames(client, records) {
24
- const missing = records.some((record) => record.client_name === undefined && record.client_id !== undefined);
25
- if (!missing)
26
- return;
27
- const names = new Map();
28
- try {
29
- const workspaces = await drainPages(async (page) => normalizeList(await client.get('/clients', { query: { limit: page.limit, offset: page.offset } })), { limit: 100, offset: 0 });
30
- for (const workspace of workspaces.records) {
31
- const ws = workspace;
32
- if (ws.id)
33
- names.set(ws.id, ws.name ?? '');
34
- }
35
- }
36
- catch {
37
- return;
38
- }
39
- for (const record of records) {
40
- if (record.client_name === undefined && typeof record.client_id === 'string') {
41
- record.client_name = names.get(record.client_id) ?? null;
42
- }
43
- }
44
- }
@@ -0,0 +1,10 @@
1
+ import { type AdminClient } from './admin-client.js';
2
+ export interface HydrateSpec {
3
+ endpoint: string;
4
+ idField: string;
5
+ label: (entity: Record<string, unknown>) => unknown;
6
+ labelField: string;
7
+ }
8
+ export declare const WORKSPACE_NAME: HydrateSpec;
9
+ export declare const CREATOR_EMAIL: HydrateSpec;
10
+ export declare function hydrate(client: AdminClient, records: Array<Record<string, unknown>>, specs: HydrateSpec[]): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { drainPages, normalizeList } from './list.js';
2
+ // The shared workspace-name and author-email specs (used across list commands).
3
+ export const WORKSPACE_NAME = {
4
+ endpoint: '/clients',
5
+ idField: 'client_id',
6
+ label: (entity) => entity.name,
7
+ labelField: 'client_name',
8
+ };
9
+ export const CREATOR_EMAIL = {
10
+ endpoint: '/users',
11
+ idField: 'created_by_id',
12
+ label: (entity) => entity.email ?? entity.name,
13
+ labelField: 'created_by_email',
14
+ };
15
+ async function hydrateOne(client, records, spec) {
16
+ const needs = (record) => record[spec.labelField] === undefined &&
17
+ typeof record[spec.idField] === 'string' &&
18
+ record[spec.idField].length > 0;
19
+ if (!records.some((record) => needs(record)))
20
+ return;
21
+ const byId = new Map();
22
+ try {
23
+ const entities = await drainPages(async (page) => normalizeList(await client.get(spec.endpoint, { query: { limit: page.limit, offset: page.offset } })), { limit: 100, offset: 0 });
24
+ for (const raw of entities.records) {
25
+ const entity = (raw ?? {});
26
+ if (typeof entity.id === 'string') {
27
+ const label = spec.label(entity);
28
+ byId.set(entity.id, typeof label === 'string' ? label : '');
29
+ }
30
+ }
31
+ }
32
+ catch {
33
+ return; // best-effort: leave the label unresolved rather than fail the list
34
+ }
35
+ for (const record of records) {
36
+ if (needs(record))
37
+ record[spec.labelField] = byId.get(record[spec.idField]) ?? null;
38
+ }
39
+ }
40
+ // Hydrate a record set against one or more specs, running each lookup CONCURRENTLY
41
+ // and ONCE. Each spec is skipped when no record needs it, and is best-effort: a
42
+ // forbidden/unavailable lookup leaves its label unresolved rather than failing.
43
+ // Mutates `records` in place.
44
+ export async function hydrate(client, records, specs) {
45
+ await Promise.all(specs.map((spec) => hydrateOne(client, records, spec)));
46
+ }
@@ -0,0 +1,22 @@
1
+ import { type AdminClient } from './admin-client.js';
2
+ export interface LogQuery {
3
+ all?: boolean;
4
+ limit: number;
5
+ offset: number;
6
+ q?: string;
7
+ sort?: string;
8
+ workspace: string;
9
+ }
10
+ export declare function fetchLog(client: AdminClient, query: LogQuery): Promise<{
11
+ count: number;
12
+ records: Array<Record<string, unknown>>;
13
+ }>;
14
+ export interface NewLogEntry {
15
+ clientId: string;
16
+ description: string;
17
+ endDate: string;
18
+ organizationId: string;
19
+ startDate: string;
20
+ }
21
+ export declare function addLogEntry(client: AdminClient, entry: NewLogEntry): Promise<Record<string, unknown>>;
22
+ export declare function resolveWorkspaceOrg(client: AdminClient, workspaceId: string): Promise<string>;
@@ -0,0 +1,56 @@
1
+ import { DataHttpError } from '../errors.js';
2
+ import { mapDataError, notFoundError } from './errors.js';
3
+ import { drainPages, normalizeList } from './list.js';
4
+ import { projectLogEntry, projectWorkspace } from './projections.js';
5
+ // Fetch + project a workspace's log (event-log) entries via the per-client
6
+ // endpoint, draining pages when requested. Errors are mapped to the E3 model.
7
+ export async function fetchLog(client, query) {
8
+ const path = `/clients/${query.workspace}/event-logs`;
9
+ const fetchPage = async (page) => {
10
+ try {
11
+ return normalizeList(await client.get(path, { query: { limit: page.limit, offset: page.offset, q: query.q, sort: query.sort } }));
12
+ }
13
+ catch (error) {
14
+ throw mapDataError(error);
15
+ }
16
+ };
17
+ const start = { limit: query.limit, offset: query.offset };
18
+ const envelope = query.all ? await drainPages(fetchPage, start) : await fetchPage(start);
19
+ return { count: envelope.count, records: envelope.records.map((record) => projectLogEntry(record)) };
20
+ }
21
+ // Add a log entry: POST /event-logs (the workspace + org go in the body). The
22
+ // body is a structured builder, so future fields (e.g. tags) drop in here.
23
+ // Returns the projected created entry. Errors are mapped to the E3 model.
24
+ export async function addLogEntry(client, entry) {
25
+ const body = {
26
+ client_id: entry.clientId,
27
+ description: entry.description,
28
+ end_date: entry.endDate,
29
+ organization_id: entry.organizationId,
30
+ start_date: entry.startDate,
31
+ };
32
+ try {
33
+ return projectLogEntry(await client.post('/event-logs', { body }));
34
+ }
35
+ catch (error) {
36
+ throw mapDataError(error);
37
+ }
38
+ }
39
+ // Derive a workspace's organization_id (required to create an entry) via one
40
+ // workspace lookup. A foreign or missing id collapses to a single not-found
41
+ // message (no existence leak), mirroring `ws get`.
42
+ export async function resolveWorkspaceOrg(client, workspaceId) {
43
+ let raw;
44
+ try {
45
+ raw = await client.get(`/clients/${workspaceId}`);
46
+ }
47
+ catch (error) {
48
+ if (error instanceof DataHttpError && (error.status === 403 || error.status === 404))
49
+ throw notFoundError();
50
+ throw mapDataError(error);
51
+ }
52
+ const org = projectWorkspace(raw).organization_id;
53
+ if (typeof org !== 'string' || org.length === 0)
54
+ throw notFoundError();
55
+ return org;
56
+ }
@@ -1,2 +1,3 @@
1
1
  export declare function projectWorkspace(raw: unknown): Record<string, unknown>;
2
2
  export declare function projectConnection(raw: unknown): Record<string, unknown>;
3
+ export declare function projectLogEntry(raw: unknown): Record<string, unknown>;
@@ -1,6 +1,7 @@
1
1
  import { redact } from '../output/redact.js';
2
2
  const WORKSPACE_CORE = ['organization_id', 'slug', 'business_type', 'client_main_goal', 'created_on'];
3
3
  const CONNECTION_CORE = ['name', 'client_id', 'type', 'active', 'backfill', 'data_refresh_status', 'created_on'];
4
+ const LOG_CORE = ['description', 'start_date', 'end_date', 'client_id', 'organization_id', 'created_on', 'created_by_id'];
4
5
  // Project a raw admin Client into a stable "workspace" shape: id + name + present
5
6
  // core fields + owner_email (from the nested owner) + a redacted `raw` passthrough
6
7
  // so it survives schema drift. Adds/invents no fields.
@@ -32,3 +33,25 @@ export function projectConnection(raw) {
32
33
  out.raw = redact(raw);
33
34
  return out;
34
35
  }
36
+ // Project a raw EventLog (the "log" / logbook entry) into a stable shape: id +
37
+ // present core fields + the workspace name (nested `client`) and author (nested
38
+ // `created_by`) + a redacted `raw` passthrough. New API fields (e.g. `tags`)
39
+ // flow through `raw` automatically under --json; surface them by adding to
40
+ // LOG_CORE + a table column when they ship.
41
+ export function projectLogEntry(raw) {
42
+ const entry = (raw ?? {});
43
+ const out = { id: entry.id };
44
+ for (const key of LOG_CORE)
45
+ if (entry[key] !== undefined)
46
+ out[key] = entry[key];
47
+ const client = entry.client;
48
+ if (client?.name)
49
+ out.client_name = client.name;
50
+ const by = entry.created_by;
51
+ if (by?.name)
52
+ out.created_by_name = by.name;
53
+ if (by?.email)
54
+ out.created_by_email = by.email;
55
+ out.raw = redact(raw);
56
+ return out;
57
+ }
@@ -2,11 +2,11 @@
2
2
  // MUST NOT be used for any authorization / request-gating decision (the CLI is a
3
3
  // thin client; the server validates tokens). A convention test guards this.
4
4
  export function decodeJwtClaimsUnverified(accessToken) {
5
- const parts = accessToken.split('.');
6
- if (parts.length < 2)
5
+ const [, payload] = accessToken.split('.');
6
+ if (payload === undefined)
7
7
  return null;
8
8
  try {
9
- const padded = parts[1] + '='.repeat((4 - (parts[1].length % 4)) % 4);
9
+ const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
10
10
  return JSON.parse(Buffer.from(padded, 'base64url').toString('utf8'));
11
11
  }
12
12
  catch {
@@ -2,3 +2,4 @@ import { type AdminClient } from '../api/admin-client.js';
2
2
  import { type TokenStore } from '../store/keyring.js';
3
3
  export declare function authedAdminClient(host: string, store: TokenStore, timeoutMs?: number): AdminClient;
4
4
  export declare function renderConnectionsTable(records: Array<Record<string, unknown>>): void;
5
+ export declare function renderLogEntriesTable(records: Array<Record<string, unknown>>): void;
@@ -28,3 +28,24 @@ export function renderConnectionsTable(records) {
28
28
  { key: 'id', name: 'ID' },
29
29
  ], 'No connections.');
30
30
  }
31
+ // The log (event-log) human table — shared by `log list`. Date prefers
32
+ // created_on (falls back to start_date); By prefers the author name then email;
33
+ // Workspace falls back to the id when its name couldn't be resolved.
34
+ // First non-empty string among the candidates, else an em-dash placeholder.
35
+ function firstLabel(...values) {
36
+ return values.find((value) => typeof value === 'string' && value.length > 0) ?? '—';
37
+ }
38
+ export function renderLogEntriesTable(records) {
39
+ const rows = records.map((record) => ({
40
+ ...record,
41
+ by: firstLabel(record.created_by_name, record.created_by_email, record.created_by_id),
42
+ client_name: firstLabel(record.client_name, record.client_id),
43
+ when: firstLabel(record.created_on, record.start_date),
44
+ }));
45
+ renderTable(rows, [
46
+ { key: 'when', name: 'Date' },
47
+ { key: 'client_name', name: 'Workspace' },
48
+ { key: 'description', name: 'Entry' },
49
+ { key: 'by', name: 'By' },
50
+ ], 'No log entries.');
51
+ }
@@ -98,6 +98,11 @@ export function toExitCode(error) {
98
98
  if (error.name === 'LoopbackError') {
99
99
  return /tim(e|ed) ?out/i.test(error.message) ? ExitCode.TIMEOUT : ExitCode.AUTH_FAILED;
100
100
  }
101
+ // oclif parse/usage errors (missing or unknown flag, bad arg) carry their own
102
+ // exit — honor it so a missing required flag is exit 2 ("bad invocation").
103
+ const oclifExit = error.oclif?.exit;
104
+ if (typeof oclifExit === 'number' && oclifExit > 0)
105
+ return oclifExit;
101
106
  }
102
107
  return ExitCode.GENERIC;
103
108
  }
@@ -109,7 +114,13 @@ export function toExitCode(error) {
109
114
  // tokens never leak.
110
115
  export function renderError(error, options = {}) {
111
116
  const exitCode = toExitCode(error);
112
- const code = error instanceof CliError ? error.code : error instanceof Error ? error.name : 'error';
117
+ let code = 'error';
118
+ if (error instanceof CliError)
119
+ code = error.code;
120
+ else if (exitCode === ExitCode.USAGE)
121
+ code = 'usage'; // oclif parse/usage errors
122
+ else if (error instanceof Error)
123
+ code = error.name;
113
124
  const rawMessage = error instanceof Error ? error.message : String(error);
114
125
  const message = redact(rawMessage);
115
126
  if (options.json) {
@@ -518,6 +518,9 @@
518
518
  "type": "option"
519
519
  },
520
520
  "workspace": {
521
+ "aliases": [
522
+ "ws"
523
+ ],
521
524
  "description": "List only this workspace's connections (UUID).",
522
525
  "name": "workspace",
523
526
  "hasDynamicHelp": false,
@@ -542,6 +545,217 @@
542
545
  "list.js"
543
546
  ]
544
547
  },
548
+ "log:add": {
549
+ "aliases": [
550
+ "logs:add"
551
+ ],
552
+ "args": {
553
+ "message": {
554
+ "description": "The log entry text.",
555
+ "name": "message",
556
+ "required": true
557
+ }
558
+ },
559
+ "description": "Add an entry to a workspace's log (the logbook). You give the workspace (--workspace/--ws) and the message; the organization is derived from the workspace, and the dates default to now (override with --start/--end). Returns the created entry. Targets the OAuth/admin host.",
560
+ "examples": [
561
+ "<%= config.bin %> log add \"shipped the new pipeline\" --workspace <id>",
562
+ "<%= config.bin %> log add \"kickoff call done\" --ws <id> --json"
563
+ ],
564
+ "flags": {
565
+ "json": {
566
+ "description": "Format output as json.",
567
+ "helpGroup": "GLOBAL",
568
+ "name": "json",
569
+ "allowNo": false,
570
+ "type": "boolean"
571
+ },
572
+ "host": {
573
+ "char": "H",
574
+ "description": "OAuth provider host (defaults to the prod issuer).",
575
+ "name": "host",
576
+ "hasDynamicHelp": false,
577
+ "multiple": false,
578
+ "type": "option"
579
+ },
580
+ "ndjson": {
581
+ "description": "Stream output as NDJSON (one compact JSON object per line).",
582
+ "name": "ndjson",
583
+ "allowNo": false,
584
+ "type": "boolean"
585
+ },
586
+ "timeout": {
587
+ "description": "Network timeout in milliseconds.",
588
+ "name": "timeout",
589
+ "hasDynamicHelp": false,
590
+ "multiple": false,
591
+ "type": "option"
592
+ },
593
+ "verbose": {
594
+ "aliases": [
595
+ "debug"
596
+ ],
597
+ "description": "Print redacted diagnostics to stderr.",
598
+ "name": "verbose",
599
+ "allowNo": false,
600
+ "type": "boolean"
601
+ },
602
+ "end": {
603
+ "description": "End date (ISO 8601). Defaults to now.",
604
+ "name": "end",
605
+ "hasDynamicHelp": false,
606
+ "multiple": false,
607
+ "type": "option"
608
+ },
609
+ "start": {
610
+ "description": "Start date (ISO 8601). Defaults to now.",
611
+ "name": "start",
612
+ "hasDynamicHelp": false,
613
+ "multiple": false,
614
+ "type": "option"
615
+ },
616
+ "workspace": {
617
+ "aliases": [
618
+ "ws"
619
+ ],
620
+ "description": "Workspace id (UUID) the entry belongs to.",
621
+ "name": "workspace",
622
+ "required": true,
623
+ "hasDynamicHelp": false,
624
+ "multiple": false,
625
+ "type": "option"
626
+ }
627
+ },
628
+ "hasDynamicHelp": false,
629
+ "hiddenAliases": [],
630
+ "id": "log:add",
631
+ "pluginAlias": "flipstream",
632
+ "pluginName": "flipstream",
633
+ "pluginType": "core",
634
+ "strict": true,
635
+ "summary": "Add an entry to a workspace's log.",
636
+ "enableJsonFlag": true,
637
+ "isESM": true,
638
+ "relativePath": [
639
+ "dist",
640
+ "commands",
641
+ "log",
642
+ "add.js"
643
+ ]
644
+ },
645
+ "log:list": {
646
+ "aliases": [
647
+ "logs:list"
648
+ ],
649
+ "args": {},
650
+ "description": "Read a workspace's log entries (the logbook). --workspace is required — a log entry only makes sense in a workspace's context, so there is no account-wide read. Shows each entry's description, dates, and author; the full record (including any new fields like tags) is under --json. Targets the OAuth/admin host.",
651
+ "examples": [
652
+ "<%= config.bin %> log list --workspace <id>",
653
+ "<%= config.bin %> logs list --ws <id> --json",
654
+ "<%= config.bin %> log list --ws <id> --all"
655
+ ],
656
+ "flags": {
657
+ "json": {
658
+ "description": "Format output as json.",
659
+ "helpGroup": "GLOBAL",
660
+ "name": "json",
661
+ "allowNo": false,
662
+ "type": "boolean"
663
+ },
664
+ "host": {
665
+ "char": "H",
666
+ "description": "OAuth provider host (defaults to the prod issuer).",
667
+ "name": "host",
668
+ "hasDynamicHelp": false,
669
+ "multiple": false,
670
+ "type": "option"
671
+ },
672
+ "ndjson": {
673
+ "description": "Stream output as NDJSON (one compact JSON object per line).",
674
+ "name": "ndjson",
675
+ "allowNo": false,
676
+ "type": "boolean"
677
+ },
678
+ "timeout": {
679
+ "description": "Network timeout in milliseconds.",
680
+ "name": "timeout",
681
+ "hasDynamicHelp": false,
682
+ "multiple": false,
683
+ "type": "option"
684
+ },
685
+ "verbose": {
686
+ "aliases": [
687
+ "debug"
688
+ ],
689
+ "description": "Print redacted diagnostics to stderr.",
690
+ "name": "verbose",
691
+ "allowNo": false,
692
+ "type": "boolean"
693
+ },
694
+ "all": {
695
+ "description": "Fetch every page (drains pagination).",
696
+ "name": "all",
697
+ "allowNo": false,
698
+ "type": "boolean"
699
+ },
700
+ "limit": {
701
+ "description": "Page size.",
702
+ "name": "limit",
703
+ "default": 50,
704
+ "hasDynamicHelp": false,
705
+ "multiple": false,
706
+ "type": "option"
707
+ },
708
+ "offset": {
709
+ "description": "Starting offset.",
710
+ "name": "offset",
711
+ "default": 0,
712
+ "hasDynamicHelp": false,
713
+ "multiple": false,
714
+ "type": "option"
715
+ },
716
+ "q": {
717
+ "description": "Free-text filter.",
718
+ "name": "q",
719
+ "hasDynamicHelp": false,
720
+ "multiple": false,
721
+ "type": "option"
722
+ },
723
+ "sort": {
724
+ "description": "Sort field.",
725
+ "name": "sort",
726
+ "hasDynamicHelp": false,
727
+ "multiple": false,
728
+ "type": "option"
729
+ },
730
+ "workspace": {
731
+ "aliases": [
732
+ "ws"
733
+ ],
734
+ "description": "The workspace whose log to read (UUID).",
735
+ "name": "workspace",
736
+ "required": true,
737
+ "hasDynamicHelp": false,
738
+ "multiple": false,
739
+ "type": "option"
740
+ }
741
+ },
742
+ "hasDynamicHelp": false,
743
+ "hiddenAliases": [],
744
+ "id": "log:list",
745
+ "pluginAlias": "flipstream",
746
+ "pluginName": "flipstream",
747
+ "pluginType": "core",
748
+ "strict": true,
749
+ "summary": "Read a workspace's log entries.",
750
+ "enableJsonFlag": true,
751
+ "isESM": true,
752
+ "relativePath": [
753
+ "dist",
754
+ "commands",
755
+ "log",
756
+ "list.js"
757
+ ]
758
+ },
545
759
  "workspaces:connections": {
546
760
  "aliases": [
547
761
  "ws:connections"
@@ -847,5 +1061,5 @@
847
1061
  ]
848
1062
  }
849
1063
  },
850
- "version": "0.4.0"
1064
+ "version": "0.5.0"
851
1065
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flipstream",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Flipstream CLI — query your Flipstream data from the terminal.",
5
5
  "keywords": [
6
6
  "flipstream",
@@ -33,9 +33,19 @@
33
33
  "lint": "eslint .",
34
34
  "pack:prepare": "tsc -p tsconfig.json && oclif manifest",
35
35
  "posttest": "eslint .",
36
+ "prepare": "husky || true",
36
37
  "smoke": "bun scripts/smoke.ts",
37
38
  "test": "bun test"
38
39
  },
40
+ "lint-staged": {
41
+ "*.ts": [
42
+ "eslint --fix",
43
+ "prettier --write"
44
+ ],
45
+ "*.{json,yml,yaml}": [
46
+ "prettier --write"
47
+ ]
48
+ },
39
49
  "dependencies": {
40
50
  "@napi-rs/keyring": "^1.3.0",
41
51
  "@oclif/core": "^4",
@@ -51,6 +61,8 @@
51
61
  "eslint": "^10.4.1",
52
62
  "eslint-config-oclif": "^6.0.166",
53
63
  "eslint-config-prettier": "^10.1.8",
64
+ "husky": "^9.1.7",
65
+ "lint-staged": "^17.0.7",
54
66
  "oclif": "^4.23.8",
55
67
  "prettier": "^3.8.3",
56
68
  "typescript": "^5"
@@ -73,6 +85,9 @@
73
85
  },
74
86
  "connections": {
75
87
  "description": "List your data-source connections, org-wide or per workspace (alias: conn)."
88
+ },
89
+ "log": {
90
+ "description": "Read and add workspace log entries — the logbook (list, add; alias: logs)."
76
91
  }
77
92
  }
78
93
  }
@@ -1,4 +0,0 @@
1
- import type { Metadata } from './discovery.js';
2
- export declare function registerClient(meta: Metadata, redirectUri: string, scopes: string[], options?: {
3
- timeoutMs?: number;
4
- }): Promise<string>;
@@ -1,43 +0,0 @@
1
- import { CLIENT_NAME } from '../config/constants.js';
2
- import { AuthFailedError } from '../errors.js';
3
- // Dynamically register a PUBLIC OAuth client (RFC 7591) and return its
4
- // client_id. The CLI never requests, parses, or stores a client secret — the
5
- // return type is the bare client_id string so a refactor cannot leak a secret.
6
- export async function registerClient(meta, redirectUri, scopes, options = {}) {
7
- const timeoutMs = options.timeoutMs ?? 30_000;
8
- const controller = new AbortController();
9
- const timer = setTimeout(() => controller.abort(), timeoutMs);
10
- const body = {
11
- client_name: CLIENT_NAME,
12
- grant_types: ['authorization_code', 'refresh_token'],
13
- redirect_uris: [redirectUri],
14
- response_types: ['code'],
15
- scope: scopes.join(' '),
16
- token_endpoint_auth_method: 'none',
17
- };
18
- let response;
19
- try {
20
- response = await fetch(meta.registrationEndpoint, {
21
- body: JSON.stringify(body),
22
- headers: { accept: 'application/json', 'content-type': 'application/json' },
23
- method: 'POST',
24
- signal: controller.signal,
25
- });
26
- }
27
- catch (error) {
28
- const reason = controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error.message;
29
- throw new AuthFailedError(`Client registration failed: ${reason}`, 'registration_failed');
30
- }
31
- finally {
32
- clearTimeout(timer);
33
- }
34
- if (response.status >= 400) {
35
- const text = await response.text().catch(() => '');
36
- throw new AuthFailedError(`Client registration failed: HTTP ${response.status} ${text}`.trim(), 'registration_failed');
37
- }
38
- const json = (await response.json().catch(() => ({})));
39
- if (typeof json.client_id !== 'string' || json.client_id.length === 0) {
40
- throw new AuthFailedError('Client registration response did not include a client_id', 'registration_failed');
41
- }
42
- return json.client_id;
43
- }