flipstream 0.5.0 → 0.6.1

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 (98) hide show
  1. package/README.md +168 -12
  2. package/dist/commands/auth/clear-all.d.ts +1 -0
  3. package/dist/commands/auth/clear-all.js +91 -24
  4. package/dist/commands/auth/login.js +8 -2
  5. package/dist/commands/auth/logout.js +2 -2
  6. package/dist/commands/auth/status.js +35 -8
  7. package/dist/commands/catalog.d.ts +15 -0
  8. package/dist/commands/catalog.js +110 -0
  9. package/dist/commands/connections/list.d.ts +1 -0
  10. package/dist/commands/connections/list.js +28 -2
  11. package/dist/commands/contract.d.ts +11 -0
  12. package/dist/commands/contract.js +35 -0
  13. package/dist/commands/health.d.ts +10 -0
  14. package/dist/commands/health.js +31 -0
  15. package/dist/commands/log/add.js +7 -3
  16. package/dist/commands/log/list.js +1 -1
  17. package/dist/commands/query.d.ts +15 -2
  18. package/dist/commands/query.js +256 -43
  19. package/dist/commands/skills/install.d.ts +16 -0
  20. package/dist/commands/skills/install.js +55 -0
  21. package/dist/commands/workspaces/connections.js +4 -2
  22. package/dist/commands/workspaces/get.js +5 -3
  23. package/dist/commands/workspaces/list.js +4 -1
  24. package/dist/lib/api/errors.d.ts +1 -0
  25. package/dist/lib/api/errors.js +13 -2
  26. package/dist/lib/api/http.d.ts +2 -0
  27. package/dist/lib/api/http.js +40 -4
  28. package/dist/lib/api/ids.d.ts +1 -0
  29. package/dist/lib/api/ids.js +5 -0
  30. package/dist/lib/api/retry.d.ts +1 -1
  31. package/dist/lib/api/retry.js +1 -1
  32. package/dist/lib/api/short-uuid.d.ts +1 -0
  33. package/dist/lib/api/short-uuid.js +30 -0
  34. package/dist/lib/auth/claims.d.ts +1 -1
  35. package/dist/lib/auth/claims.js +2 -2
  36. package/dist/lib/auth/flow.js +10 -2
  37. package/dist/lib/auth/headless.js +14 -10
  38. package/dist/lib/auth/refresh.js +33 -3
  39. package/dist/lib/auth/session.js +1 -1
  40. package/dist/lib/command/base.d.ts +4 -0
  41. package/dist/lib/command/base.js +102 -3
  42. package/dist/lib/command/flags.d.ts +4 -0
  43. package/dist/lib/command/flags.js +11 -0
  44. package/dist/lib/command/planner.d.ts +9 -0
  45. package/dist/lib/command/planner.js +14 -0
  46. package/dist/lib/config/constants.d.ts +3 -1
  47. package/dist/lib/config/constants.js +14 -1
  48. package/dist/lib/config/xdg.d.ts +4 -0
  49. package/dist/lib/config/xdg.js +56 -1
  50. package/dist/lib/errors.d.ts +20 -1
  51. package/dist/lib/errors.js +125 -17
  52. package/dist/lib/output/dialogs.d.ts +27 -0
  53. package/dist/lib/output/dialogs.js +94 -0
  54. package/dist/lib/output/interactivity.d.ts +11 -0
  55. package/dist/lib/output/interactivity.js +54 -0
  56. package/dist/lib/output/machine-mode.d.ts +2 -0
  57. package/dist/lib/output/machine-mode.js +25 -0
  58. package/dist/lib/output/redact.d.ts +1 -0
  59. package/dist/lib/output/redact.js +12 -0
  60. package/dist/lib/output/runlog.d.ts +3 -0
  61. package/dist/lib/output/runlog.js +72 -0
  62. package/dist/lib/output/sanitize.d.ts +2 -0
  63. package/dist/lib/output/sanitize.js +57 -0
  64. package/dist/lib/output/sidecar.d.ts +30 -0
  65. package/dist/lib/output/sidecar.js +58 -0
  66. package/dist/lib/output/table.js +5 -1
  67. package/dist/lib/output/trace.d.ts +11 -0
  68. package/dist/lib/output/trace.js +89 -0
  69. package/dist/lib/planner/catalog.d.ts +26 -0
  70. package/dist/lib/planner/catalog.js +60 -0
  71. package/dist/lib/planner/client.d.ts +14 -0
  72. package/dist/lib/planner/client.js +47 -0
  73. package/dist/lib/planner/connection.d.ts +14 -0
  74. package/dist/lib/planner/connection.js +139 -0
  75. package/dist/lib/planner/diagnose.d.ts +8 -0
  76. package/dist/lib/planner/diagnose.js +50 -0
  77. package/dist/lib/planner/errors.d.ts +14 -0
  78. package/dist/lib/planner/errors.js +129 -0
  79. package/dist/lib/planner/filters.d.ts +8 -0
  80. package/dist/lib/planner/filters.js +74 -0
  81. package/dist/lib/planner/request.d.ts +24 -0
  82. package/dist/lib/planner/request.js +51 -0
  83. package/dist/lib/planner/suggest.d.ts +2 -0
  84. package/dist/lib/planner/suggest.js +45 -0
  85. package/dist/lib/planner/vocabulary.d.ts +9 -0
  86. package/dist/lib/planner/vocabulary.js +95 -0
  87. package/dist/lib/skills/install.d.ts +24 -0
  88. package/dist/lib/skills/install.js +69 -0
  89. package/dist/lib/store/keychain-child.d.ts +11 -0
  90. package/dist/lib/store/keychain-child.js +135 -0
  91. package/dist/lib/store/keyring.d.ts +27 -10
  92. package/dist/lib/store/keyring.js +288 -17
  93. package/dist/lib/store/memory-store.d.ts +6 -5
  94. package/dist/lib/store/memory-store.js +14 -6
  95. package/docs/AGENT-CONTRACT.md +238 -0
  96. package/oclif.manifest.json +392 -8
  97. package/package.json +7 -3
  98. package/skill/SKILL.md +55 -0
@@ -10,7 +10,7 @@ import { createStore } from '../../lib/store/index.js';
10
10
  export default class WorkspacesConnections extends BaseCommand {
11
11
  static aliases = ['ws:connections'];
12
12
  static args = {
13
- id: Args.string({ description: 'Workspace id (UUID).', required: true }),
13
+ id: Args.string({ description: 'Workspace id (UUID).', ignoreStdin: true, required: true }),
14
14
  };
15
15
  static description = "List one workspace's connections — the ergonomic form of `connections list --workspace <id>`. " +
16
16
  'Targets the OAuth/admin host.';
@@ -25,9 +25,10 @@ export default class WorkspacesConnections extends BaseCommand {
25
25
  assertUuid(this.args.id, 'workspace id'); // exit 2, no request
26
26
  const host = this.resolvedHost();
27
27
  const store = createStore();
28
- if (!store.load(host))
28
+ if (!(await store.load(host)))
29
29
  throw AuthRequiredError.notLoggedIn();
30
30
  const client = authedAdminClient(host, store, this.flags.timeout);
31
+ const startedMs = Date.now();
31
32
  const projected = await fetchConnections(client, {
32
33
  all: this.flags.all,
33
34
  limit: this.flags.limit,
@@ -37,6 +38,7 @@ export default class WorkspacesConnections extends BaseCommand {
37
38
  workspace: this.args.id,
38
39
  });
39
40
  await hydrate(client, projected.records, [WORKSPACE_NAME]);
41
+ this.verboseLog(`GET ${host}/connections (${Date.now() - startedMs} ms)`);
40
42
  return this.respondList(projected, (records) => renderConnectionsTable(records));
41
43
  }
42
44
  }
@@ -11,10 +11,10 @@ import { createStore } from '../../lib/store/index.js';
11
11
  export default class WorkspacesGet extends BaseCommand {
12
12
  static aliases = ['ws:get'];
13
13
  static args = {
14
- id: Args.string({ description: 'Workspace id (UUID).', required: true }),
14
+ id: Args.string({ description: 'Workspace id (UUID).', ignoreStdin: true, required: true }),
15
15
  };
16
16
  static description = 'Fetch one workspace by id (the admin API "Client"). A foreign or missing id collapses to a single ' +
17
- "not-found message (no existence leak). Targets the OAuth/admin host.";
17
+ 'not-found message (no existence leak). Targets the OAuth/admin host.';
18
18
  static examples = [
19
19
  '<%= config.bin %> workspaces get <id>',
20
20
  '<%= config.bin %> ws get <id> --json',
@@ -31,7 +31,7 @@ export default class WorkspacesGet extends BaseCommand {
31
31
  assertUuid(this.args.id, 'workspace id'); // exit 2, no request issued
32
32
  const host = this.resolvedHost();
33
33
  const store = createStore();
34
- if (!store.load(host))
34
+ if (!(await store.load(host)))
35
35
  throw AuthRequiredError.notLoggedIn();
36
36
  const client = createAuthedAdminClient({
37
37
  accessTokenIfFresh: (authHost) => store.accessTokenIfFresh(authHost),
@@ -42,7 +42,9 @@ export default class WorkspacesGet extends BaseCommand {
42
42
  });
43
43
  let raw;
44
44
  try {
45
+ const startedMs = Date.now();
45
46
  raw = await client.get(`/clients/${this.args.id}`, { query: { refresh: this.flags.refresh || undefined } });
47
+ this.verboseLog(`GET ${host}/clients/${this.args.id} (${Date.now() - startedMs} ms)`);
46
48
  }
47
49
  catch (error) {
48
50
  // Collapse a foreign-id 403 and a 404 to ONE message so neither leaks existence.
@@ -32,7 +32,7 @@ export default class WorkspacesList extends BaseCommand {
32
32
  // The admin host == the OAuth issuer (creds live here); --host overrides both.
33
33
  const host = this.resolvedHost();
34
34
  const store = createStore();
35
- if (!store.load(host))
35
+ if (!(await store.load(host)))
36
36
  throw AuthRequiredError.notLoggedIn();
37
37
  const client = createAuthedAdminClient({
38
38
  accessTokenIfFresh: (authHost) => store.accessTokenIfFresh(authHost),
@@ -53,7 +53,10 @@ export default class WorkspacesList extends BaseCommand {
53
53
  }
54
54
  };
55
55
  const start = { limit: this.flags.limit, offset: this.flags.offset };
56
+ const startedMs = Date.now();
56
57
  const envelope = this.flags.all ? await drainPages(fetchPage, start) : await fetchPage(start);
58
+ // Which origin answered (E11-2): planner vs admin is the first debugging question.
59
+ this.verboseLog(`GET ${host}/clients (${Date.now() - startedMs} ms)`);
57
60
  const projected = { count: envelope.count, records: envelope.records.map((record) => projectWorkspace(record)) };
58
61
  if (this.flags['with-connection-counts'])
59
62
  await this.addConnectionCounts(client, projected.records);
@@ -4,4 +4,5 @@ export declare function parseDataErrorMessage(status: number, bodyText: string):
4
4
  message: string;
5
5
  };
6
6
  export declare function notFoundError(): CliError;
7
+ export declare function rateLimited(message: string, retryAfterMs?: number): CliError;
7
8
  export declare function mapDataError(error: unknown): CliError;
@@ -1,4 +1,4 @@
1
- import { AuthFailedError, CliError, DataHttpError, NetworkError } from '../errors.js';
1
+ import { AuthFailedError, CliError, DataHttpError, NetworkError, retryPolicy } from '../errors.js';
2
2
  import { ExitCode } from '../exit-codes.js';
3
3
  import { redact } from '../output/redact.js';
4
4
  const DETAIL_MAX = 500;
@@ -33,6 +33,15 @@ export function parseDataErrorMessage(status, bodyText) {
33
33
  export function notFoundError() {
34
34
  return new CliError('Resource not found or not in your organization.', 'not_found', ExitCode.GENERIC);
35
35
  }
36
+ // The one 429 mapping, shared by the generic and planner mappers so the two
37
+ // can never drift: code `rate_limited`, exit 7 (transient), retry policy with
38
+ // the server-stated wait when one was sent.
39
+ export function rateLimited(message, retryAfterMs) {
40
+ return new CliError(message, 'rate_limited', ExitCode.NETWORK).withDetails({
41
+ hint: 'Wait, then re-run the same command.',
42
+ ...retryPolicy(retryAfterMs),
43
+ });
44
+ }
36
45
  // Map a DataClient/retry failure onto the E3 error model with a deterministic exit
37
46
  // code (AuthRequired->4, AuthFailed->5, Network->7, Timeout->8). Network, Timeout
38
47
  // and AuthRequired (session_expired) pass through unchanged; a DataHttpError is
@@ -54,8 +63,10 @@ export function mapDataError(error) {
54
63
  }
55
64
  if (error.status === 404)
56
65
  return notFoundError();
66
+ if (error.status === 429)
67
+ return rateLimited('Rate limited by the service.', error.retryAfterMs);
57
68
  if (error.status >= 500) {
58
- return new NetworkError(message, 'data_upstream_error');
69
+ return new NetworkError(message, 'data_upstream_error').withDetails(retryPolicy(error.retryAfterMs));
59
70
  }
60
71
  return new CliError(message, providerError ?? 'data_request_failed', ExitCode.GENERIC);
61
72
  }
@@ -6,4 +6,6 @@ export interface JsonRequest {
6
6
  token?: null | string;
7
7
  url: string;
8
8
  }
9
+ export declare const MAX_RETRY_AFTER_MS = 3600000;
10
+ export declare function parseRetryAfterMs(header: null | string): number | undefined;
9
11
  export declare function requestJson(req: JsonRequest): Promise<unknown>;
@@ -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
  }
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { type DataClient } from './client.js';
2
2
  export interface RefreshDeps {
3
- accessTokenIfFresh: (authHost: string) => null | string;
3
+ accessTokenIfFresh: (authHost: string) => Promise<null | string>;
4
4
  authHost: string;
5
5
  refresh: (authHost: string) => Promise<null | string>;
6
6
  }
@@ -8,7 +8,7 @@ import { requestJson } from './http.js';
8
8
  // session_expired (exit 4). Non-401 errors (403/5xx/network/timeout) are never
9
9
  // retried; a second consecutive 401 propagates. Refresh always targets authHost.
10
10
  export async function withFreshToken(deps, fn) {
11
- let token = deps.accessTokenIfFresh(deps.authHost) ?? (await deps.refresh(deps.authHost));
11
+ let token = (await deps.accessTokenIfFresh(deps.authHost)) ?? (await deps.refresh(deps.authHost));
12
12
  if (!token)
13
13
  throw AuthRequiredError.sessionExpired();
14
14
  try {
@@ -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
+ }
@@ -1,3 +1,3 @@
1
1
  import type { TokenStore } from '../store/index.js';
2
2
  export declare function decodeJwtClaimsUnverified(accessToken: string): null | Record<string, unknown>;
3
- export declare function tokenClaims(host: string, store: TokenStore): null | Record<string, unknown>;
3
+ export declare function tokenClaims(host: string, store: TokenStore): Promise<null | Record<string, unknown>>;
@@ -14,7 +14,7 @@ export function decodeJwtClaimsUnverified(accessToken) {
14
14
  }
15
15
  }
16
16
  // Decoded claims of the stored access token for `host` (display only), or null.
17
- export function tokenClaims(host, store) {
18
- const creds = store.load(host);
17
+ export async function tokenClaims(host, store) {
18
+ const creds = await store.load(host);
19
19
  return creds ? decodeJwtClaimsUnverified(creds.accessToken) : null;
20
20
  }
@@ -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);
@@ -101,7 +108,8 @@ export async function login(options = {}) {
101
108
  const pasted = await (options.promptRedirect ?? defaultPromptRedirect)(authorizeUrl);
102
109
  if (!pasted || !pasted.trim()) {
103
110
  throw new AuthFailedError('No redirected URL was provided. Browser login is not possible in a non-interactive ' +
104
- 'environment; use a service-account / client_credentials flow instead (PLANNED).', 'headless_no_input');
111
+ 'environment; over SSH or any other remote access, use a Flipstream service account ' +
112
+ '(client_credentials) from your Flipstream account instead — it needs no browser.', 'headless_no_input');
105
113
  }
106
114
  return parseRedirect(pasted, state).code;
107
115
  };
@@ -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
@@ -10,7 +11,7 @@ import { fetchMetadata } from './discovery.js';
10
11
  export async function refresh(host, options = {}) {
11
12
  const store = options.store ?? createStore();
12
13
  const timeoutMs = options.timeoutMs ?? 10_000;
13
- const creds = store.load(host);
14
+ const creds = await store.load(host);
14
15
  if (!creds || !creds.refreshToken)
15
16
  return null;
16
17
  let tokenEndpoint;
@@ -43,7 +44,36 @@ 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. If somebody else's token is there now, their
54
+ // session is live: HAND OFF to it rather than reporting failure, because
55
+ // returning null would surface as "Session expired. Run `flipstream auth
56
+ // login`" while perfectly good credentials sit in the keychain.
57
+ const current = await store.reload(host);
58
+ if (current !== null && current.refreshToken !== creds.refreshToken) {
59
+ // May still be null if their access token is also stale; either way we do
60
+ // not touch a session we do not own.
61
+ return freshAccessToken(current);
62
+ }
63
+ // AND WE DO NOT DELETE, even though this token looks dead to us.
64
+ //
65
+ // The check above cannot be made atomic. Every store operation is its own
66
+ // child process now (~125ms), so between that reload and any delete there is
67
+ // a wide window in which the process that DID win the rotation completes its
68
+ // save — and we would then erase credentials that had just become valid. One
69
+ // command would return a token while every later command looked logged out.
70
+ //
71
+ // The two mistakes are not the same size. Deleting wrongly destroys a live
72
+ // session silently. Keeping a genuinely dead token costs one more failed
73
+ // refresh, which returns null here, surfaces as session_expired, and is
74
+ // overwritten by the next login anyway. Given a race we cannot close without
75
+ // a cross-process lock that every save would also have to respect, the
76
+ // cheaper mistake is the right default.
47
77
  return null;
48
78
  }
49
79
  if (!response.ok)
@@ -64,6 +94,6 @@ export async function refresh(host, options = {}) {
64
94
  // Rotate the refresh token ONLY when the server issued a new one.
65
95
  refreshToken: token.refresh_token ?? creds.refreshToken,
66
96
  };
67
- store.save(host, updated);
97
+ await store.save(host, updated);
68
98
  return token.access_token;
69
99
  }
@@ -6,7 +6,7 @@ import { refresh } from './refresh.js';
6
6
  // client (E4) uses to obtain a bearer token.
7
7
  export async function getFreshAccessToken(host, options = {}) {
8
8
  const store = options.store ?? createStore();
9
- const cached = store.accessTokenIfFresh(host);
9
+ const cached = await store.accessTokenIfFresh(host);
10
10
  if (cached)
11
11
  return cached;
12
12
  return refresh(host, { store, timeoutMs: options.timeoutMs });
@@ -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,18 @@
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';
5
+ import { setMachineMode } from '../output/machine-mode.js';
4
6
  import { renderNdjson } from '../output/ndjson.js';
5
7
  import { redact } from '../output/redact.js';
8
+ import { appendRunLog, currentRunLogPath } from '../output/runlog.js';
9
+ import { sanitizeTerminal } from '../output/sanitize.js';
10
+ import { writeOutputEntry } from '../output/sidecar.js';
11
+ import { enableStderrTrace } from '../output/trace.js';
12
+ // Cap on any single argv element / message written to a persistent file, so a
13
+ // huge --body can neither balloon the log nor tear an NDJSON line under
14
+ // concurrent O_APPEND (E11-6 review).
15
+ const DISK_FIELD_MAX = 2000;
6
16
  // Repo-wide clig.dev conventions in one place (E3-5). Every command extends this:
7
17
  // - stdout = data, stderr = help/prompts/progress/diagnostics
8
18
  // - --json (machine JSON, oclif-serialized) / --ndjson (one object per line),
@@ -19,11 +29,65 @@ export class BaseCommand extends Command {
19
29
  static enableJsonFlag = true;
20
30
  args;
21
31
  flags;
32
+ // Command start, for the sidecar's duration_ms.
33
+ startedAtMs = 0;
22
34
  // Centralized error rendering: redacted, machine {error:{code,message}} under
23
35
  // --json/--ndjson, a human line otherwise. Replaces oclif's default reporter.
24
36
  async catch(error) {
25
37
  const machine = this.jsonEnabled() || Boolean(this.flags?.ndjson);
26
- this.exit(renderError(error, { json: machine }));
38
+ // Sidecar + run log see every failure (E11-6): structured for the
39
+ // supervising process, one line for the post-mortem trail. Message is
40
+ // redacted and capped so it can't tear a shared NDJSON line.
41
+ const classified = classifyError(error);
42
+ const message = redact(classified.message).slice(0, DISK_FIELD_MAX);
43
+ appendRunLog(`command-failed ${this.id ?? ''}: [${classified.code}] ${message}`);
44
+ writeOutputEntry({
45
+ command: this.id ?? '',
46
+ error_code: classified.code,
47
+ exit: classified.exitCode,
48
+ log_file_path: currentRunLogPath(),
49
+ message,
50
+ type: 'command-failed',
51
+ version: 1,
52
+ ...(classified.details.retryAfterMs === undefined ? {} : { retry_after_ms: classified.details.retryAfterMs }),
53
+ });
54
+ const exitCode = renderError(error, { json: machine });
55
+ // The pointer to the full trace, human mode only (machine consumers get
56
+ // log_file_path in the sidecar entry instead — stderr stays quiet).
57
+ const logPath = currentRunLogPath();
58
+ if (!machine && logPath !== null)
59
+ process.stderr.write(`🪵 Logs were written to ${logPath}\n`);
60
+ this.exit(exitCode);
61
+ }
62
+ // Success bookkeeping for the sidecar. oclif calls finally() with undefined on
63
+ // the clean path (the failure entry is written in catch()), so only a success
64
+ // logs a result here.
65
+ async finally(error) {
66
+ if (error === undefined && this.startedAtMs > 0) {
67
+ writeOutputEntry({
68
+ command: this.id ?? '',
69
+ duration_ms: Date.now() - this.startedAtMs,
70
+ exit: 0,
71
+ type: 'result',
72
+ version: 1,
73
+ });
74
+ }
75
+ await super.finally(error);
76
+ }
77
+ // A multi-line guidance BLOCK on stderr, human mode only (E11-3/E11-6
78
+ // review): shares note()'s redact + sanitize + machine-mode gating, but
79
+ // emits the text verbatim without the `→ ` prefix. This is the one writer
80
+ // for the command footers (catalog example, connections usage, login next),
81
+ // so mode gating no longer depends on each call sitting inside a human
82
+ // closure. Newlines are preserved (it is a block); each line is sanitized.
83
+ footer(text) {
84
+ if (this.jsonEnabled() || Boolean(this.flags?.ndjson))
85
+ return;
86
+ const safe = redact(text)
87
+ .split('\n')
88
+ .map((line) => sanitizeTerminal(line))
89
+ .join('\n');
90
+ process.stderr.write(safe.endsWith('\n') ? safe : `${safe}\n`);
27
91
  }
28
92
  async init() {
29
93
  await super.init();
@@ -36,11 +100,46 @@ export class BaseCommand extends Command {
36
100
  });
37
101
  this.flags = flags;
38
102
  this.args = args;
103
+ // Tell the rest of the process which stream discipline applies, so code far
104
+ // from here — the keychain's "still waiting" notice, for one — can honour
105
+ // the empty-stderr guarantee without being handed the command object.
106
+ setMachineMode(this.jsonEnabled() || Boolean(this.flags.ndjson));
39
107
  // --json and --ndjson both claim stdout; refuse the ambiguous combination.
40
108
  // UsageError carries exit 2 (oclif's {exit:2} would map to GENERIC instead).
41
109
  if (this.jsonEnabled() && this.flags.ndjson) {
42
110
  throw new UsageError('--json and --ndjson are mutually exclusive.');
43
111
  }
112
+ // E11-6 wiring: --verbose mirrors the wire trace to stderr (the run log
113
+ // always gets it), and the sidecar records the session before any work.
114
+ // argv is REDACTED (a --body/flag can carry a token) and capped — the run
115
+ // log's own contract is "callers redact before appendRunLog".
116
+ this.startedAtMs = Date.now();
117
+ if (this.flags.verbose)
118
+ enableStderrTrace();
119
+ const safeArgv = redact([this.id ?? '', ...this.argv]).map((part) => part.slice(0, DISK_FIELD_MAX));
120
+ appendRunLog(`session ${this.config.version} contract=${CONTRACT_VERSION} argv=${JSON.stringify(safeArgv)}`);
121
+ writeOutputEntry({
122
+ argv: safeArgv,
123
+ cli_version: this.config.version,
124
+ contract_version: CONTRACT_VERSION,
125
+ log_file_path: currentRunLogPath(),
126
+ type: 'session',
127
+ version: 1,
128
+ });
129
+ }
130
+ // Context narration (E11-2, #101): resolved state and environmental inferences,
131
+ // one `→ ` line each, HUMAN MODE ONLY. Under --json/--ndjson these vanish
132
+ // entirely — stdout purity is untouchable and machine consumers branch on the
133
+ // envelope/sidecar, not prose. stderr, redacted, terminal-sanitized.
134
+ note(message) {
135
+ if (this.jsonEnabled() || Boolean(this.flags?.ndjson))
136
+ return;
137
+ // ONE line per note, enforced: a newline smuggled inside an interpolated
138
+ // value (a service-supplied connection name, a filter column) would
139
+ // otherwise forge additional arrow-prefixed lines — misleading context
140
+ // injected into the exact channel agents read (sec review, #109).
141
+ const line = sanitizeTerminal(redact(message)).replaceAll(/[\t\n\r]+/g, ' ');
142
+ process.stderr.write(`→ ${line}\n`);
44
143
  }
45
144
  // The resolved active host (flag > config.defaultHost > DEFAULT_HOST).
46
145
  resolvedHost() {
@@ -70,6 +169,6 @@ export class BaseCommand extends Command {
70
169
  // always redacted (never leaks a token).
71
170
  verboseLog(message) {
72
171
  if (this.flags.verbose)
73
- process.stderr.write(`${redact(message)}\n`);
172
+ process.stderr.write(`${sanitizeTerminal(redact(message))}\n`);
74
173
  }
75
174
  }
@@ -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
+ }