flipstream 0.4.0 → 0.6.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.
Files changed (103) hide show
  1. package/README.md +357 -27
  2. package/dist/commands/auth/login.js +7 -1
  3. package/dist/commands/auth/status.js +29 -3
  4. package/dist/commands/catalog.d.ts +15 -0
  5. package/dist/commands/catalog.js +110 -0
  6. package/dist/commands/connections/list.d.ts +1 -0
  7. package/dist/commands/connections/list.js +31 -4
  8. package/dist/commands/contract.d.ts +11 -0
  9. package/dist/commands/contract.js +35 -0
  10. package/dist/commands/health.d.ts +10 -0
  11. package/dist/commands/health.js +31 -0
  12. package/dist/commands/log/add.d.ts +16 -0
  13. package/dist/commands/log/add.js +48 -0
  14. package/dist/commands/log/list.d.ts +19 -0
  15. package/dist/commands/log/list.js +43 -0
  16. package/dist/commands/query.d.ts +15 -2
  17. package/dist/commands/query.js +255 -42
  18. package/dist/commands/skills/install.d.ts +16 -0
  19. package/dist/commands/skills/install.js +55 -0
  20. package/dist/commands/workspaces/connections.js +6 -3
  21. package/dist/commands/workspaces/get.js +4 -2
  22. package/dist/commands/workspaces/list.js +3 -0
  23. package/dist/lib/api/admin-client.d.ts +5 -0
  24. package/dist/lib/api/admin-client.js +19 -0
  25. package/dist/lib/api/connections.d.ts +0 -1
  26. package/dist/lib/api/connections.js +0 -25
  27. package/dist/lib/api/errors.d.ts +1 -0
  28. package/dist/lib/api/errors.js +13 -2
  29. package/dist/lib/api/http.d.ts +2 -0
  30. package/dist/lib/api/http.js +40 -4
  31. package/dist/lib/api/hydrate.d.ts +10 -0
  32. package/dist/lib/api/hydrate.js +46 -0
  33. package/dist/lib/api/ids.d.ts +1 -0
  34. package/dist/lib/api/ids.js +5 -0
  35. package/dist/lib/api/log.d.ts +22 -0
  36. package/dist/lib/api/log.js +56 -0
  37. package/dist/lib/api/projections.d.ts +1 -0
  38. package/dist/lib/api/projections.js +23 -0
  39. package/dist/lib/api/short-uuid.d.ts +1 -0
  40. package/dist/lib/api/short-uuid.js +30 -0
  41. package/dist/lib/auth/claims.js +3 -3
  42. package/dist/lib/auth/flow.js +8 -1
  43. package/dist/lib/auth/headless.js +14 -10
  44. package/dist/lib/auth/refresh.js +21 -1
  45. package/dist/lib/command/admin.d.ts +1 -0
  46. package/dist/lib/command/admin.js +21 -0
  47. package/dist/lib/command/base.d.ts +4 -0
  48. package/dist/lib/command/base.js +97 -3
  49. package/dist/lib/command/flags.d.ts +4 -0
  50. package/dist/lib/command/flags.js +11 -0
  51. package/dist/lib/command/planner.d.ts +9 -0
  52. package/dist/lib/command/planner.js +14 -0
  53. package/dist/lib/config/constants.d.ts +3 -1
  54. package/dist/lib/config/constants.js +14 -1
  55. package/dist/lib/config/xdg.d.ts +4 -0
  56. package/dist/lib/config/xdg.js +56 -1
  57. package/dist/lib/errors.d.ts +20 -1
  58. package/dist/lib/errors.js +132 -13
  59. package/dist/lib/output/dialogs.d.ts +27 -0
  60. package/dist/lib/output/dialogs.js +94 -0
  61. package/dist/lib/output/interactivity.d.ts +11 -0
  62. package/dist/lib/output/interactivity.js +48 -0
  63. package/dist/lib/output/redact.d.ts +1 -0
  64. package/dist/lib/output/redact.js +12 -0
  65. package/dist/lib/output/runlog.d.ts +3 -0
  66. package/dist/lib/output/runlog.js +72 -0
  67. package/dist/lib/output/sanitize.d.ts +2 -0
  68. package/dist/lib/output/sanitize.js +57 -0
  69. package/dist/lib/output/sidecar.d.ts +30 -0
  70. package/dist/lib/output/sidecar.js +58 -0
  71. package/dist/lib/output/table.js +5 -1
  72. package/dist/lib/output/trace.d.ts +11 -0
  73. package/dist/lib/output/trace.js +89 -0
  74. package/dist/lib/planner/catalog.d.ts +26 -0
  75. package/dist/lib/planner/catalog.js +60 -0
  76. package/dist/lib/planner/client.d.ts +14 -0
  77. package/dist/lib/planner/client.js +47 -0
  78. package/dist/lib/planner/connection.d.ts +14 -0
  79. package/dist/lib/planner/connection.js +139 -0
  80. package/dist/lib/planner/diagnose.d.ts +8 -0
  81. package/dist/lib/planner/diagnose.js +50 -0
  82. package/dist/lib/planner/errors.d.ts +14 -0
  83. package/dist/lib/planner/errors.js +129 -0
  84. package/dist/lib/planner/filters.d.ts +8 -0
  85. package/dist/lib/planner/filters.js +74 -0
  86. package/dist/lib/planner/request.d.ts +24 -0
  87. package/dist/lib/planner/request.js +51 -0
  88. package/dist/lib/planner/suggest.d.ts +2 -0
  89. package/dist/lib/planner/suggest.js +45 -0
  90. package/dist/lib/planner/vocabulary.d.ts +9 -0
  91. package/dist/lib/planner/vocabulary.js +95 -0
  92. package/dist/lib/skills/install.d.ts +24 -0
  93. package/dist/lib/skills/install.js +69 -0
  94. package/dist/lib/store/keyring.d.ts +3 -0
  95. package/dist/lib/store/keyring.js +45 -2
  96. package/dist/lib/store/memory-store.d.ts +1 -0
  97. package/dist/lib/store/memory-store.js +5 -0
  98. package/docs/AGENT-CONTRACT.md +238 -0
  99. package/oclif.manifest.json +606 -8
  100. package/package.json +22 -3
  101. package/skill/SKILL.md +55 -0
  102. package/dist/lib/auth/register.d.ts +0 -4
  103. package/dist/lib/auth/register.js +0 -43
@@ -1,5 +1,29 @@
1
1
  import { DataHttpError, NetworkError, TimeoutError } from '../errors.js';
2
+ import { traceFailure, traceRequest, traceResponse } from '../output/trace.js';
2
3
  const DEFAULT_TIMEOUT_MS = 30_000;
4
+ // Ceiling for a server-stated retry wait. A hostile or misconfigured header
5
+ // must never park an agent for hours ("Retry-After: 999999999" is ~31 years);
6
+ // anything above this clamps down, and the contract documents the bound.
7
+ export const MAX_RETRY_AFTER_MS = 3_600_000;
8
+ // Retry-After is either delta-seconds (1*DIGIT — parsed strictly, so no hex,
9
+ // exponents, fractions or signs) or an HTTP-date (RFC 9110 §10.2.3). Returns
10
+ // milliseconds clamped to [0, MAX_RETRY_AFTER_MS], or undefined when
11
+ // absent/unparseable — never a guess, because a wrong wait is worse than none.
12
+ export function parseRetryAfterMs(header) {
13
+ if (header === null)
14
+ return undefined;
15
+ const trimmed = header.trim();
16
+ if (/^\d{1,10}$/.test(trimmed))
17
+ return Math.min(MAX_RETRY_AFTER_MS, Number(trimmed) * 1000);
18
+ // An HTTP-date always carries letters (day/month names, GMT); refusing the
19
+ // rest keeps Date.parse's lenient number handling ('1.5', '0x10') out.
20
+ if (!/[A-Za-z]/.test(trimmed))
21
+ return undefined;
22
+ const dateMs = Date.parse(trimmed);
23
+ if (Number.isNaN(dateMs))
24
+ return undefined;
25
+ return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, dateMs - Date.now()));
26
+ }
3
27
  function withQuery(url, query) {
4
28
  if (!query)
5
29
  return url;
@@ -24,11 +48,18 @@ export async function requestJson(req) {
24
48
  headers['content-type'] = 'application/json';
25
49
  if (req.token)
26
50
  headers.authorization = `Bearer ${req.token}`;
51
+ // Wire tracing (E11-6): emitted AS IT HAPPENS, so the failure path carries
52
+ // diagnostics too (#92) — headers are never traced, bodies only via the
53
+ // explicit sanitize escape hatch. Only first-party API calls route through
54
+ // here; the token-bearing OAuth flows use fetch() directly (lint-enforced).
55
+ const fullUrl = withQuery(req.url, req.query);
56
+ const started = Date.now();
57
+ traceRequest(req.method, fullUrl, req.body);
27
58
  const controller = new AbortController();
28
59
  const timer = setTimeout(() => controller.abort(), timeoutMs);
29
60
  let response;
30
61
  try {
31
- response = await fetch(withQuery(req.url, req.query), {
62
+ response = await fetch(fullUrl, {
32
63
  body: req.body === undefined ? undefined : JSON.stringify(req.body),
33
64
  headers,
34
65
  method: req.method,
@@ -36,16 +67,21 @@ export async function requestJson(req) {
36
67
  });
37
68
  }
38
69
  catch (error) {
39
- if (controller.signal.aborted)
70
+ if (controller.signal.aborted) {
71
+ traceFailure(req.method, fullUrl, `timeout after ${timeoutMs}ms`, Date.now() - started);
40
72
  throw new TimeoutError(`Request timed out after ${timeoutMs}ms`);
73
+ }
74
+ traceFailure(req.method, fullUrl, error.message, Date.now() - started);
41
75
  throw new NetworkError(`Request failed: ${error.message}`);
42
76
  }
43
77
  finally {
44
78
  clearTimeout(timer);
45
79
  }
46
80
  const text = await response.text();
47
- if (response.status < 200 || response.status >= 300)
48
- throw new DataHttpError(response.status, text);
81
+ traceResponse({ bodyText: text, method: req.method, ms: Date.now() - started, status: response.status, url: fullUrl });
82
+ if (response.status < 200 || response.status >= 300) {
83
+ throw new DataHttpError(response.status, text, parseRetryAfterMs(response.headers.get('retry-after')));
84
+ }
49
85
  try {
50
86
  return JSON.parse(text);
51
87
  }
@@ -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
+ }
@@ -1 +1,2 @@
1
1
  export declare function assertUuid(id: string, label?: string): void;
2
+ export declare function isUuid(value: string): boolean;
@@ -6,3 +6,8 @@ export function assertUuid(id, label = 'id') {
6
6
  if (!UUID_RE.test(id))
7
7
  throw new UsageError(`Invalid ${label} — expected a UUID.`);
8
8
  }
9
+ // Is this already a UUID? Used where a flag accepts EITHER a UUID or a name, so
10
+ // only the non-UUID case pays for a lookup.
11
+ export function isUuid(value) {
12
+ return UUID_RE.test(value);
13
+ }
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export declare function uuidToShort(uuid: string): string;
@@ -0,0 +1,30 @@
1
+ // UUID → the 22-character short id pulse-fe puts in its URLs.
2
+ //
3
+ // A port of `short-uuid`'s default translator (flickrBase58), which is what
4
+ // front_end/src/utils/routing/urlEncoding.ts uses (`uuidToShort`). Ported rather
5
+ // than depended on: this is a fixed alphabet and one bignum conversion, and a
6
+ // new runtime dependency on a CLI that ships five is a worse trade than 20 lines
7
+ // with a locked-down test.
8
+ //
9
+ // The correctness risk here is specific and quiet: a wrong encoding does not
10
+ // throw, it produces a plausible-looking URL that 404s. So the test carries a
11
+ // vector captured from a REAL pulse-fe URL, not one this code generated.
12
+ //
13
+ // flickrBase58 omits 0/O/I/l — the characters people misread when copying a link
14
+ // out of a terminal, which is the whole reason the alphabet exists.
15
+ const ALPHABET = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
16
+ const SHORT_LENGTH = 22;
17
+ // Left-padded to a fixed 22 chars, matching short-uuid: without the pad, a UUID
18
+ // with leading zero bytes encodes shorter and the route stops resolving.
19
+ export function uuidToShort(uuid) {
20
+ const hex = uuid.replaceAll('-', '');
21
+ let value = BigInt(`0x${hex}`);
22
+ const base = BigInt(ALPHABET.length);
23
+ let out = '';
24
+ while (value > 0n) {
25
+ const digit = Number(value % base);
26
+ out = `${ALPHABET[digit] ?? ''}${out}`;
27
+ value /= base;
28
+ }
29
+ return out.padStart(SHORT_LENGTH, ALPHABET[0] ?? '1');
30
+ }
@@ -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 {
@@ -8,7 +8,14 @@ import { DEFAULT_REDIRECT_TIMEOUT_MS, startLoopback } from './loopback.js';
8
8
  import { generatePair } from './pkce.js';
9
9
  import { buildAuthorizeUrl } from './provider.js';
10
10
  export { decodeJwtClaimsUnverified } from './claims.js';
11
- const DEFAULT_SCOPES = ['read', 'write'];
11
+ // The whole forwarding chain, not just the first hop (E8-0). The query planner
12
+ // presents this token to pulse-data (grid_data:read), and pulse-data presents it
13
+ // onward to pulse-admin to resolve the connection (connections:read). A caller
14
+ // holding only the first gets a 403 raised two services away and wrapped as an
15
+ // opaque PULSE_SERVICE_ERROR, which says nothing about what to fix.
16
+ //
17
+ // `read`/`write` stay for the admin API (workspaces, connections, log).
18
+ const DEFAULT_SCOPES = ['read', 'write', 'grid_data:read', 'connections:read'];
12
19
  function tokenErrorMessage(status, body) {
13
20
  try {
14
21
  const parsed = JSON.parse(body);
@@ -1,5 +1,5 @@
1
- import { createInterface } from 'node:readline';
2
1
  import { LoopbackError } from '../errors.js';
2
+ import { readAnswer } from '../output/dialogs.js';
3
3
  // True when there is no interactive browser to drive the OAuth redirect: an
4
4
  // explicit open() failure, a CI run, an SSH session, or Linux without a display.
5
5
  export function detectHeadless(probe = {}) {
@@ -44,18 +44,22 @@ export function parseRedirect(pasted, expectedState) {
44
44
  return { code, state };
45
45
  }
46
46
  // Default manual-mode reader: print the authorize URL + instructions to STDERR
47
- // (stdout stays clean for the eventual JSON) and read the pasted redirect from stdin.
48
- export function defaultPromptRedirect(authorizeUrl) {
47
+ // (stdout stays clean for the eventual JSON) and read the pasted redirect from
48
+ // stdin through dialogs.readAnswer, the ONE prompt chokepoint, so EOF and
49
+ // Ctrl-C settle (prompt_aborted) instead of leaving an unresolved promise
50
+ // (E11-4 review: this file used to carry a second, unhardened readline).
51
+ export async function defaultPromptRedirect(authorizeUrl) {
49
52
  process.stderr.write('\nNo browser is available here (headless / SSH / CI).\n' +
50
53
  'Open this URL in a browser on any machine and approve access:\n\n' +
51
54
  ` ${authorizeUrl}\n\n` +
52
55
  'Your browser will then try to load a http://127.0.0.1/... address that fails to\n' +
53
56
  'connect. Copy that full URL from the address bar and paste it below.\n\n');
54
- const rl = createInterface({ input: process.stdin, output: process.stderr });
55
- return new Promise((resolve) => {
56
- rl.question('Paste the redirected URL here: ', (answer) => {
57
- rl.close();
58
- resolve(answer);
59
- });
60
- });
57
+ try {
58
+ return await readAnswer('Paste the redirected URL here: ');
59
+ }
60
+ catch {
61
+ // Aborted/EOF: the flow's own empty-input handling produces the right
62
+ // headless_no_input error with its auth exit code.
63
+ return '';
64
+ }
61
65
  }
@@ -1,3 +1,4 @@
1
+ import { freshAccessToken } from '../store/credentials.js';
1
2
  import { createStore } from '../store/index.js';
2
3
  import { fetchMetadata } from './discovery.js';
3
4
  // Silent refresh via the refresh_token grant. Returns the new access token, or
@@ -43,7 +44,26 @@ export async function refresh(host, options = {}) {
43
44
  clearTimeout(timer);
44
45
  }
45
46
  if (response.status >= 400 && response.status < 500) {
46
- store.clear(host); // invalid_grant / revoked session force re-login.
47
+ // COMPARE-AND-DELETE. A 4xx here usually means invalid_grant, and with refresh-
48
+ // token rotation the most likely reason is that ANOTHER process already
49
+ // refreshed: it exchanged this same token, got a new one, and saved it. Our
50
+ // token is dead — but the one now in the keychain is not, and clearing blindly
51
+ // would delete a working session and log the user out for no reason.
52
+ //
53
+ // Re-read past any cache and only delete if what is stored is still the token
54
+ // we just tried. Note this race predates the read cache: two processes could
55
+ // always interleave a load and a rotation.
56
+ const current = store.reload(host);
57
+ if (current !== null && current.refreshToken !== creds.refreshToken) {
58
+ // Someone else won the race and their session is live. HAND OFF to it rather
59
+ // than reporting failure: returning null here would surface as "Session
60
+ // expired. Run `flipstream auth login`" while perfectly good credentials sit
61
+ // in the keychain — telling the user to fix something that is not broken.
62
+ // May still be null if their access token is also stale; either way we do
63
+ // not clear a session we do not own.
64
+ return freshAccessToken(current);
65
+ }
66
+ store.clear(host); // genuinely invalid_grant / revoked session — force re-login.
47
67
  return null;
48
68
  }
49
69
  if (!response.ok)
@@ -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
+ }
@@ -11,10 +11,14 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
11
11
  static enableJsonFlag: boolean;
12
12
  protected args: BaseArgs<T>;
13
13
  protected flags: BaseFlags<T>;
14
+ private startedAtMs;
14
15
  protected catch(error: Error & {
15
16
  exitCode?: number;
16
17
  }): Promise<never>;
18
+ protected finally(error: Error | undefined): Promise<void>;
19
+ protected footer(text: string): void;
17
20
  init(): Promise<void>;
21
+ protected note(message: string): void;
18
22
  protected resolvedHost(): string;
19
23
  protected respond<D>(data: D, human: (data: D) => void): D;
20
24
  protected respondList<R>(envelope: {
@@ -1,8 +1,17 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
+ import { CONTRACT_VERSION } from '../config/constants.js';
2
3
  import { resolveHost } from '../config/xdg.js';
3
- import { renderError, UsageError } from '../errors.js';
4
+ import { classifyError, renderError, UsageError } from '../errors.js';
4
5
  import { renderNdjson } from '../output/ndjson.js';
5
6
  import { redact } from '../output/redact.js';
7
+ import { appendRunLog, currentRunLogPath } from '../output/runlog.js';
8
+ import { sanitizeTerminal } from '../output/sanitize.js';
9
+ import { writeOutputEntry } from '../output/sidecar.js';
10
+ import { enableStderrTrace } from '../output/trace.js';
11
+ // Cap on any single argv element / message written to a persistent file, so a
12
+ // huge --body can neither balloon the log nor tear an NDJSON line under
13
+ // concurrent O_APPEND (E11-6 review).
14
+ const DISK_FIELD_MAX = 2000;
6
15
  // Repo-wide clig.dev conventions in one place (E3-5). Every command extends this:
7
16
  // - stdout = data, stderr = help/prompts/progress/diagnostics
8
17
  // - --json (machine JSON, oclif-serialized) / --ndjson (one object per line),
@@ -19,11 +28,65 @@ export class BaseCommand extends Command {
19
28
  static enableJsonFlag = true;
20
29
  args;
21
30
  flags;
31
+ // Command start, for the sidecar's duration_ms.
32
+ startedAtMs = 0;
22
33
  // Centralized error rendering: redacted, machine {error:{code,message}} under
23
34
  // --json/--ndjson, a human line otherwise. Replaces oclif's default reporter.
24
35
  async catch(error) {
25
36
  const machine = this.jsonEnabled() || Boolean(this.flags?.ndjson);
26
- this.exit(renderError(error, { json: machine }));
37
+ // Sidecar + run log see every failure (E11-6): structured for the
38
+ // supervising process, one line for the post-mortem trail. Message is
39
+ // redacted and capped so it can't tear a shared NDJSON line.
40
+ const classified = classifyError(error);
41
+ const message = redact(classified.message).slice(0, DISK_FIELD_MAX);
42
+ appendRunLog(`command-failed ${this.id ?? ''}: [${classified.code}] ${message}`);
43
+ writeOutputEntry({
44
+ command: this.id ?? '',
45
+ error_code: classified.code,
46
+ exit: classified.exitCode,
47
+ log_file_path: currentRunLogPath(),
48
+ message,
49
+ type: 'command-failed',
50
+ version: 1,
51
+ ...(classified.details.retryAfterMs === undefined ? {} : { retry_after_ms: classified.details.retryAfterMs }),
52
+ });
53
+ const exitCode = renderError(error, { json: machine });
54
+ // The pointer to the full trace, human mode only (machine consumers get
55
+ // log_file_path in the sidecar entry instead — stderr stays quiet).
56
+ const logPath = currentRunLogPath();
57
+ if (!machine && logPath !== null)
58
+ process.stderr.write(`🪵 Logs were written to ${logPath}\n`);
59
+ this.exit(exitCode);
60
+ }
61
+ // Success bookkeeping for the sidecar. oclif calls finally() with undefined on
62
+ // the clean path (the failure entry is written in catch()), so only a success
63
+ // logs a result here.
64
+ async finally(error) {
65
+ if (error === undefined && this.startedAtMs > 0) {
66
+ writeOutputEntry({
67
+ command: this.id ?? '',
68
+ duration_ms: Date.now() - this.startedAtMs,
69
+ exit: 0,
70
+ type: 'result',
71
+ version: 1,
72
+ });
73
+ }
74
+ await super.finally(error);
75
+ }
76
+ // A multi-line guidance BLOCK on stderr, human mode only (E11-3/E11-6
77
+ // review): shares note()'s redact + sanitize + machine-mode gating, but
78
+ // emits the text verbatim without the `→ ` prefix. This is the one writer
79
+ // for the command footers (catalog example, connections usage, login next),
80
+ // so mode gating no longer depends on each call sitting inside a human
81
+ // closure. Newlines are preserved (it is a block); each line is sanitized.
82
+ footer(text) {
83
+ if (this.jsonEnabled() || Boolean(this.flags?.ndjson))
84
+ return;
85
+ const safe = redact(text)
86
+ .split('\n')
87
+ .map((line) => sanitizeTerminal(line))
88
+ .join('\n');
89
+ process.stderr.write(safe.endsWith('\n') ? safe : `${safe}\n`);
27
90
  }
28
91
  async init() {
29
92
  await super.init();
@@ -41,6 +104,37 @@ export class BaseCommand extends Command {
41
104
  if (this.jsonEnabled() && this.flags.ndjson) {
42
105
  throw new UsageError('--json and --ndjson are mutually exclusive.');
43
106
  }
107
+ // E11-6 wiring: --verbose mirrors the wire trace to stderr (the run log
108
+ // always gets it), and the sidecar records the session before any work.
109
+ // argv is REDACTED (a --body/flag can carry a token) and capped — the run
110
+ // log's own contract is "callers redact before appendRunLog".
111
+ this.startedAtMs = Date.now();
112
+ if (this.flags.verbose)
113
+ enableStderrTrace();
114
+ const safeArgv = redact([this.id ?? '', ...this.argv]).map((part) => part.slice(0, DISK_FIELD_MAX));
115
+ appendRunLog(`session ${this.config.version} contract=${CONTRACT_VERSION} argv=${JSON.stringify(safeArgv)}`);
116
+ writeOutputEntry({
117
+ argv: safeArgv,
118
+ cli_version: this.config.version,
119
+ contract_version: CONTRACT_VERSION,
120
+ log_file_path: currentRunLogPath(),
121
+ type: 'session',
122
+ version: 1,
123
+ });
124
+ }
125
+ // Context narration (E11-2, #101): resolved state and environmental inferences,
126
+ // one `→ ` line each, HUMAN MODE ONLY. Under --json/--ndjson these vanish
127
+ // entirely — stdout purity is untouchable and machine consumers branch on the
128
+ // envelope/sidecar, not prose. stderr, redacted, terminal-sanitized.
129
+ note(message) {
130
+ if (this.jsonEnabled() || Boolean(this.flags?.ndjson))
131
+ return;
132
+ // ONE line per note, enforced: a newline smuggled inside an interpolated
133
+ // value (a service-supplied connection name, a filter column) would
134
+ // otherwise forge additional arrow-prefixed lines — misleading context
135
+ // injected into the exact channel agents read (sec review, #109).
136
+ const line = sanitizeTerminal(redact(message)).replaceAll(/[\t\n\r]+/g, ' ');
137
+ process.stderr.write(`→ ${line}\n`);
44
138
  }
45
139
  // The resolved active host (flag > config.defaultHost > DEFAULT_HOST).
46
140
  resolvedHost() {
@@ -70,6 +164,6 @@ export class BaseCommand extends Command {
70
164
  // always redacted (never leaks a token).
71
165
  verboseLog(message) {
72
166
  if (this.flags.verbose)
73
- process.stderr.write(`${redact(message)}\n`);
167
+ process.stderr.write(`${sanitizeTerminal(redact(message))}\n`);
74
168
  }
75
169
  }
@@ -1,3 +1,7 @@
1
+ export declare const plannerFlags: {
2
+ 'auth-host': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
3
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
4
+ };
1
5
  export declare const paginationFlags: {
2
6
  all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
3
7
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,4 +1,15 @@
1
1
  import { Flags } from '@oclif/core';
2
+ // Shared flags for the commands that target the QUERY PLANNER (E8). `--url` is
3
+ // how you drive a locally-run planner; `--auth-host` names the issuer holding the
4
+ // credentials, which is a DIFFERENT origin from the planner itself.
5
+ export const plannerFlags = {
6
+ 'auth-host': Flags.string({
7
+ description: 'OAuth issuer host for credentials/refresh (defaults to the prod issuer).',
8
+ }),
9
+ url: Flags.string({
10
+ description: 'Query-planner base URL (default: the prod planner; env FLIPSTREAM_PLANNER_URL).',
11
+ }),
12
+ };
2
13
  // Shared pagination/filter flags for admin list commands (E7-1b). `limit`
3
14
  // defaults to 50 to force the {count, records} shape; `--all` drains every page.
4
15
  export const paginationFlags = {
@@ -0,0 +1,9 @@
1
+ import { type PlannerClient } from '../planner/client.js';
2
+ import { type TokenStore } from '../store/keyring.js';
3
+ export interface PlannerClientOptions {
4
+ authHost: string;
5
+ store: TokenStore;
6
+ timeoutMs?: number;
7
+ url: string;
8
+ }
9
+ export declare function authedPlannerClient(options: PlannerClientOptions): PlannerClient;
@@ -0,0 +1,14 @@
1
+ import { refresh } from '../auth/refresh.js';
2
+ import { createAuthedPlannerClient } from '../planner/client.js';
3
+ // Build the authed planner client for a command. Mirrors authedAdminClient, but
4
+ // keeps the two origins explicit — conflating them is how a refresh ends up
5
+ // pointed at a host that cannot issue anything.
6
+ export function authedPlannerClient(options) {
7
+ return createAuthedPlannerClient({
8
+ accessTokenIfFresh: (host) => options.store.accessTokenIfFresh(host),
9
+ authHost: options.authHost,
10
+ refresh: (host) => refresh(host, { store: options.store, timeoutMs: options.timeoutMs }),
11
+ timeoutMs: options.timeoutMs,
12
+ url: options.url,
13
+ });
14
+ }