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,7 @@
1
1
  export declare const DEFAULT_HOST = "https://pulse.admin.api.flipstream.io";
2
2
  export declare const DATA_HOST = "https://pulse.data.query.api.flipstream.io";
3
+ export declare const PLANNER_HOST = "https://query.flipstream.io";
3
4
  export declare const ADMIN_HOST = "https://pulse.admin.api.flipstream.io";
5
+ export declare const PULSE_FE_HOST = "https://pulse.flipstream.io";
4
6
  export declare const OAUTH_CLI_CLIENT_ID = "a975d9be-5ad6-44a5-8c1f-b95b995f3f70";
5
- export declare const CONTRACT_VERSION = "1";
7
+ export declare const CONTRACT_VERSION = "3";
@@ -2,11 +2,24 @@
2
2
  // canonical form; resolveHost() guarantees it.
3
3
  export const DEFAULT_HOST = 'https://pulse.admin.api.flipstream.io';
4
4
  // The data API host (the /query path is appended by the data client in E4).
5
+ // This is pulse-data, the raw SQL engine. As of E8 the CLI targets the QUERY
6
+ // PLANNER instead, which proxies to this host with the caller's own token.
5
7
  export const DATA_HOST = 'https://pulse.data.query.api.flipstream.io';
8
+ // The query-planner host (E8) — resolves a LOGICAL query (source + dimensions +
9
+ // metrics + filters) into a physical request for pulse-data, asking data-model
10
+ // which table answers it. Serves GET /health, GET /catalog, GET /catalog/{source}
11
+ // and POST /query. It has no identity of its own: it verifies the caller's bearer
12
+ // and forwards that same token onward, so a query runs with the caller's
13
+ // authority and can never reach data they could not reach directly.
14
+ export const PLANNER_HOST = 'https://query.flipstream.io';
6
15
  // The admin API host (E7). Today it is the SAME origin as the AUTH issuer
7
16
  // (unified auth — the stored bearer is accepted here), so it aliases DEFAULT_HOST;
8
17
  // kept as its own constant in case the admin API splits to a distinct origin later.
9
18
  export const ADMIN_HOST = DEFAULT_HOST;
19
+ // The Pulse web app. The CLI never calls it — it only builds links INTO it, so a
20
+ // refusal can say where to go and not just what went wrong. The workspace segment
21
+ // is the client_id short-UUID encoded (see api/short-uuid.ts).
22
+ export const PULSE_FE_HOST = 'https://pulse.flipstream.io';
10
23
  // The CLI's fixed first-party public OAuth client_id (RFC 8252 native app).
11
24
  // NON-SECRET: a public client has no secret — PKCE (S256) is the security
12
25
  // boundary — so this is safe to ship in source. Every login reuses this ONE
@@ -17,4 +30,4 @@ export const ADMIN_HOST = DEFAULT_HOST;
17
30
  export const OAUTH_CLI_CLIENT_ID = 'a975d9be-5ad6-44a5-8c1f-b95b995f3f70';
18
31
  // The agent-facing output/exit-code contract version. Bumped when the JSON shape,
19
32
  // exit codes, or flags change in a way agents must notice. Surfaced by `version`.
20
- export const CONTRACT_VERSION = '1';
33
+ export const CONTRACT_VERSION = '3';
@@ -7,6 +7,7 @@ export interface HostConfig {
7
7
  export interface Config {
8
8
  defaultHost?: string;
9
9
  hosts?: Record<string, HostConfig>;
10
+ plannerUrl?: string;
10
11
  }
11
12
  export declare function configDir(): string;
12
13
  export declare function configPath(): string;
@@ -19,3 +20,6 @@ export declare function removeConfigDir(): boolean;
19
20
  export declare function resolveHost(options?: {
20
21
  hostFlag?: string;
21
22
  }): string;
23
+ export declare function resolvePlannerUrl(options?: {
24
+ urlFlag?: string;
25
+ }): string;
@@ -1,7 +1,8 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { DEFAULT_HOST } from './constants.js';
4
+ import { UsageError } from '../errors.js';
5
+ import { DEFAULT_HOST, PLANNER_HOST } from './constants.js';
5
6
  // Defense-in-depth: any key that looks like a credential is stripped before a
6
7
  // write, so a token can never be persisted to the (non-secret) config file.
7
8
  const SECRET_KEY_PATTERN = /token|secret|verifier|code|authorization/i;
@@ -95,3 +96,57 @@ export function resolveHost(options = {}) {
95
96
  const raw = options.hostFlag ?? readConfig().defaultHost ?? DEFAULT_HOST;
96
97
  return raw.replace(/\/+$/, '');
97
98
  }
99
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
100
+ const TRUSTED_SUFFIX = '.flipstream.io';
101
+ // Is this a host we may present the user's bearer token to?
102
+ //
103
+ // The planner URL is NOT like --host. Pointing --host somewhere else is harmless
104
+ // by accident: credentials are loaded FOR that host, so a wrong one simply has
105
+ // none and the command exits 4 before anything is sent. The planner URL is
106
+ // independent of the issuer — the token comes from the real auth host and is then
107
+ // sent wherever this resolves to. So `--url http://attacker.example` would hand a
108
+ // production token to an attacker, and `http://` would put it on the wire in
109
+ // cleartext.
110
+ //
111
+ // Allowed: loopback on any scheme (your own machine, which is where a locally-run
112
+ // planner lives), and HTTPS on flipstream.io. Everything else must be opted into
113
+ // explicitly, because the cost of being wrong here is a leaked credential.
114
+ function isTrustedPlannerHost(raw) {
115
+ let url;
116
+ try {
117
+ url = new URL(raw);
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ const host = url.hostname.toLowerCase();
123
+ if (LOOPBACK.has(host))
124
+ return true;
125
+ if (url.protocol !== 'https:')
126
+ return false;
127
+ return host === 'flipstream.io' || host.endsWith(TRUSTED_SUFFIX);
128
+ }
129
+ // Resolve the query-planner base URL: --url flag > FLIPSTREAM_PLANNER_URL >
130
+ // config.plannerUrl > PLANNER_HOST. The env override is what lets you point at a
131
+ // locally-run planner (`uvicorn … --port 8080`) without editing config, mirroring
132
+ // QUERY_PLANNER_URL in the planner repo's own reference client.
133
+ //
134
+ // Every source is checked, not just the flag: an env var or a stale config entry
135
+ // sends the token just as effectively as a typed flag, and the config file is the
136
+ // one a caller is least likely to look at.
137
+ export function resolvePlannerUrl(options = {}) {
138
+ const env = process.env.FLIPSTREAM_PLANNER_URL?.trim();
139
+ const raw = options.urlFlag ?? (env && env.length > 0 ? env : undefined) ?? readConfig().plannerUrl ?? PLANNER_HOST;
140
+ const url = raw.replace(/\/+$/, '');
141
+ // The escape hatch is deliberately awkward and deliberately named: a self-hosted
142
+ // planner is a real case, but it should be a decision someone made on purpose,
143
+ // not something a generated command line falls into.
144
+ if (process.env.FLIPSTREAM_ALLOW_ANY_PLANNER === '1')
145
+ return url;
146
+ if (!isTrustedPlannerHost(url)) {
147
+ throw new UsageError(`Refusing to send your access token to '${url}'. The planner URL must be HTTPS on ` +
148
+ 'flipstream.io, or a loopback address for local development. Set ' +
149
+ 'FLIPSTREAM_ALLOW_ANY_PLANNER=1 to override this deliberately.', 'untrusted_planner_url');
150
+ }
151
+ return url;
152
+ }
@@ -1,7 +1,19 @@
1
+ export interface CliErrorDetails {
2
+ docs?: string;
3
+ hint?: string;
4
+ next?: string[];
5
+ retryable?: boolean;
6
+ retryAfterMs?: number;
7
+ upstreamCode?: string;
8
+ }
9
+ export declare function retryPolicy(retryAfterMs?: number): CliErrorDetails;
1
10
  export declare class CliError extends Error {
11
+ #private;
2
12
  readonly code: string;
3
13
  readonly exitCode: number;
4
14
  constructor(message: string, code: string, exitCode: number);
15
+ get details(): Readonly<CliErrorDetails>;
16
+ withDetails(details: CliErrorDetails): this;
5
17
  }
6
18
  export declare class AuthRequiredError extends CliError {
7
19
  constructor(message?: string, code?: 'not_logged_in' | 'session_expired');
@@ -29,10 +41,17 @@ export declare class LoopbackError extends CliError {
29
41
  }
30
42
  export declare class DataHttpError extends CliError {
31
43
  readonly bodyText: string;
44
+ readonly retryAfterMs: number | undefined;
32
45
  readonly status: number;
33
- constructor(status: number, bodyText: string);
46
+ constructor(status: number, bodyText: string, retryAfterMs?: number);
34
47
  }
35
48
  export declare function toExitCode(error: unknown): number;
49
+ export declare function classifyError(error: unknown): {
50
+ code: string;
51
+ details: CliErrorDetails;
52
+ exitCode: number;
53
+ message: string;
54
+ };
36
55
  export declare function renderError(error: unknown, options?: {
37
56
  json?: boolean;
38
57
  }): number;
@@ -1,16 +1,42 @@
1
1
  import { ExitCode } from './exit-codes.js';
2
2
  import { redact } from './output/redact.js';
3
+ import { sanitizeTerminal } from './output/sanitize.js';
4
+ // {retryable: true} + the server-stated wait — the one shape every retryable
5
+ // mapping site uses, so the policy pair can never half-apply.
6
+ export function retryPolicy(retryAfterMs) {
7
+ return { retryable: true, retryAfterMs };
8
+ }
3
9
  // Base class for every CLI error. Carries a stable string `code` (for agent
4
10
  // branching under --json) and the deterministic `exitCode` from ExitCode.
11
+ // `details` is read-only from outside; withDetails is the ONLY mutator, so the
12
+ // merge semantics below cannot be bypassed by direct assignment.
5
13
  export class CliError extends Error {
6
14
  code;
7
15
  exitCode;
16
+ #details = {};
8
17
  constructor(message, code, exitCode) {
9
18
  super(message);
10
19
  this.name = 'CliError';
11
20
  this.code = code;
12
21
  this.exitCode = exitCode;
13
22
  }
23
+ get details() {
24
+ return this.#details;
25
+ }
26
+ // Merge remediation details in. Scalars: later wins. `next`: CONCATENATED
27
+ // (deduped) — enrichment layers add commands, they never silently replace an
28
+ // earlier layer's guidance. An empty `next` normalizes to absent so there is
29
+ // exactly one representation of "no suggestion".
30
+ withDetails(details) {
31
+ const merged = { ...this.#details, ...details };
32
+ const next = [...(this.#details.next ?? []), ...(details.next ?? [])];
33
+ if (next.length > 0)
34
+ merged.next = [...new Set(next)];
35
+ else
36
+ delete merged.next;
37
+ this.#details = merged;
38
+ return this;
39
+ }
14
40
  }
15
41
  // Not authenticated (or session expired) — exit 4. Two distinct codes let an
16
42
  // agent tell "never logged in" apart from "token expired, re-login".
@@ -20,10 +46,18 @@ export class AuthRequiredError extends CliError {
20
46
  this.name = 'AuthRequiredError';
21
47
  }
22
48
  static notLoggedIn(message = 'Not logged in. Run `flipstream auth login`.') {
23
- return new AuthRequiredError(message, 'not_logged_in');
49
+ return new AuthRequiredError(message, 'not_logged_in').withDetails({
50
+ hint: 'Log in once; the session then refreshes itself.',
51
+ next: ['flipstream auth login'],
52
+ retryable: false,
53
+ });
24
54
  }
25
55
  static sessionExpired(message = 'Session expired. Run `flipstream auth login`.') {
26
- return new AuthRequiredError(message, 'session_expired');
56
+ return new AuthRequiredError(message, 'session_expired').withDetails({
57
+ hint: 'The refresh token could not renew the session — a new login is needed.',
58
+ next: ['flipstream auth login'],
59
+ retryable: false,
60
+ });
27
61
  }
28
62
  }
29
63
  // Authentication attempt failed (bad state/iss, token exchange rejected) — exit 5.
@@ -33,18 +67,24 @@ export class AuthFailedError extends CliError {
33
67
  this.name = 'AuthFailedError';
34
68
  }
35
69
  }
36
- // Network/transport failure (incl. discovery) — exit 7.
70
+ // Network/transport failure (incl. discovery) — exit 7. Retryable BY CLASS:
71
+ // the contract names transport as retryable, and a class default is how the
72
+ // fourteenth mapping site can't forget it (per-site literals are how the first
73
+ // thirteen almost did).
37
74
  export class NetworkError extends CliError {
38
75
  constructor(message, code = 'network_error') {
39
76
  super(message, code, ExitCode.NETWORK);
40
77
  this.name = 'NetworkError';
78
+ this.withDetails({ retryable: true });
41
79
  }
42
80
  }
43
81
  // Operation timed out (e.g. user never finished the browser flow) — exit 8.
82
+ // Retryable by class, like NetworkError.
44
83
  export class TimeoutError extends CliError {
45
84
  constructor(message, code = 'timeout') {
46
85
  super(message, code, ExitCode.TIMEOUT);
47
86
  this.name = 'TimeoutError';
87
+ this.withDetails({ retryable: true });
48
88
  }
49
89
  }
50
90
  // Bad invocation — exit 2 (aligns with oclif's usage exit).
@@ -78,12 +118,17 @@ export class LoopbackError extends CliError {
78
118
  // mapper can assign a deterministic exit code. Default exit is GENERIC until E4-3.
79
119
  export class DataHttpError extends CliError {
80
120
  bodyText;
121
+ // Milliseconds from the response's Retry-After header, when one was sent.
122
+ // Carried structurally so the mapper can put it in the envelope rather than
123
+ // requiring consumers to parse it out of a human-readable message (E11-1).
124
+ retryAfterMs;
81
125
  status;
82
- constructor(status, bodyText) {
126
+ constructor(status, bodyText, retryAfterMs) {
83
127
  super(`Data request failed: HTTP ${status}`, 'data_http_error', ExitCode.GENERIC);
84
128
  this.name = 'DataHttpError';
85
129
  this.status = status;
86
130
  this.bodyText = bodyText;
131
+ this.retryAfterMs = retryAfterMs;
87
132
  }
88
133
  }
89
134
  // Map any thrown value to a deterministic exit code. Known CliErrors carry their
@@ -98,9 +143,33 @@ export function toExitCode(error) {
98
143
  if (error.name === 'LoopbackError') {
99
144
  return /tim(e|ed) ?out/i.test(error.message) ? ExitCode.TIMEOUT : ExitCode.AUTH_FAILED;
100
145
  }
146
+ // oclif parse/usage errors (missing or unknown flag, bad arg) carry their own
147
+ // exit — honor it so a missing required flag is exit 2 ("bad invocation").
148
+ const oclifExit = error.oclif?.exit;
149
+ if (typeof oclifExit === 'number' && oclifExit > 0)
150
+ return oclifExit;
101
151
  }
102
152
  return ExitCode.GENERIC;
103
153
  }
154
+ // One classification for every consumer of a thrown value: the renderer below,
155
+ // and the E11-6 sidecar's command-failed entry. Message comes back REDACTED.
156
+ export function classifyError(error) {
157
+ const exitCode = toExitCode(error);
158
+ let code = 'error';
159
+ if (error instanceof CliError)
160
+ code = error.code;
161
+ else if (exitCode === ExitCode.USAGE)
162
+ code = 'usage'; // oclif parse/usage errors
163
+ else if (error instanceof Error)
164
+ code = error.name;
165
+ const rawMessage = error instanceof Error ? error.message : String(error);
166
+ return {
167
+ code,
168
+ details: error instanceof CliError ? error.details : {},
169
+ exitCode,
170
+ message: redact(rawMessage),
171
+ };
172
+ }
104
173
  // Render an error (redacted) and return its exit code. AGENT CONTRACT: under
105
174
  // --json/--ndjson the error envelope IS the machine output, so a single
106
175
  // {error:{code,message,exit}} JSON document is written to STDOUT — this is what
@@ -108,15 +177,65 @@ export function toExitCode(error) {
108
177
  // writes a line to STDERR. Both routes pass the message through redaction so
109
178
  // tokens never leak.
110
179
  export function renderError(error, options = {}) {
111
- const exitCode = toExitCode(error);
112
- const code = error instanceof CliError ? error.code : error instanceof Error ? error.name : 'error';
113
- const rawMessage = error instanceof Error ? error.message : String(error);
114
- const message = redact(rawMessage);
115
- if (options.json) {
116
- process.stdout.write(`${JSON.stringify({ error: { code, exit: exitCode, message } })}\n`);
180
+ const { code, details, exitCode, message } = classifyError(error);
181
+ if (options.json)
182
+ writeJsonEnvelope(code, exitCode, message, details);
183
+ else
184
+ writeHumanError(message, details);
185
+ return exitCode;
186
+ }
187
+ // Machine mode is left byte-faithful: JSON.stringify already escapes control
188
+ // characters, so the document stays valid and an agent sees exactly what the
189
+ // service said. Envelope v3 fields are ADDITIVE and absent unless set, so a
190
+ // pre-E11 consumer sees the exact same document it always did.
191
+ function writeJsonEnvelope(code, exitCode, message, details) {
192
+ const envelope = { code, exit: exitCode, message };
193
+ // EVERY details string is redacted here, unconditionally — not because any
194
+ // current producer is untrusted, but so "no upstream content reaches stdout
195
+ // unredacted" is an invariant of the writer rather than an audit result.
196
+ if (details.hint !== undefined)
197
+ envelope.hint = redact(details.hint);
198
+ if (details.next !== undefined && details.next.length > 0) {
199
+ envelope.next = details.next.map((command) => redact(command));
117
200
  }
118
- else {
119
- process.stderr.write(`Error: ${message}\n`);
201
+ if (details.docs !== undefined)
202
+ envelope.docs = redact(details.docs);
203
+ if (details.retryable !== undefined)
204
+ envelope.retryable = details.retryable;
205
+ // The contract promises retry_after_ms only ever appears alongside
206
+ // retryable: true — enforce it here so the code makes the doc's promise.
207
+ if (details.retryable === true && details.retryAfterMs !== undefined) {
208
+ envelope.retry_after_ms = details.retryAfterMs;
209
+ }
210
+ if (details.upstreamCode !== undefined)
211
+ envelope.upstream_code = redact(details.upstreamCode);
212
+ process.stdout.write(`${JSON.stringify({ error: envelope })}\n`);
213
+ }
214
+ // Human mode goes to a terminal, where a service-supplied message could carry
215
+ // ANSI/OSC sequences that rewrite the display or reach terminal features.
216
+ function writeHumanError(message, details) {
217
+ process.stderr.write(`Error: ${sanitizeTerminal(message)}\n`);
218
+ if (details.hint !== undefined) {
219
+ process.stderr.write(` hint: ${sanitizeTerminal(redact(details.hint))}\n`);
220
+ }
221
+ for (const command of details.next ?? []) {
222
+ process.stderr.write(` next: ${sanitizeTerminal(command)}\n`);
223
+ }
224
+ if (details.docs !== undefined)
225
+ process.stderr.write(` docs: ${sanitizeTerminal(details.docs)}\n`);
226
+ if (details.retryable === true) {
227
+ const wait = details.retryAfterMs === undefined ? '' : ` after ${Math.ceil(details.retryAfterMs / 1000)}s`;
228
+ process.stderr.write(` retry: yes — safe to retry${wait}\n`);
229
+ }
230
+ else if (details.retryable === false) {
231
+ // The anti-retry-loop notice (E11-4). The copy follows the data: when a
232
+ // `next:` step exists the INVOCATION is usually fine (not_logged_in — run
233
+ // the other command first, then re-run this one unchanged); only without
234
+ // one is "change the invocation" the right instruction. `retry: yes/no`
235
+ // is a stable stderr discriminator; the JSON envelope stays the contract.
236
+ const hasNext = (details.next ?? []).length > 0;
237
+ process.stderr.write(hasNext
238
+ ? ' retry: no — not retryable as-is; run the next: step above, then re-run this command\n'
239
+ : ' retry: no — re-running this unchanged will fail again; change the invocation, or report it\n');
120
240
  }
121
- return exitCode;
122
241
  }
@@ -0,0 +1,27 @@
1
+ import { UsageError } from '../errors.js';
2
+ export declare class NoDefaultValueProvided extends UsageError {
3
+ constructor(unblockFlag?: string);
4
+ }
5
+ export interface DialogStreams {
6
+ input?: NodeJS.ReadableStream & {
7
+ isTTY?: boolean;
8
+ };
9
+ output?: NodeJS.WritableStream & {
10
+ isTTY?: boolean;
11
+ };
12
+ }
13
+ export declare function readAnswer(promptText: string, streams?: DialogStreams): Promise<string>;
14
+ export interface ConfirmOptions {
15
+ defaultValue?: boolean;
16
+ fallbackValue?: boolean;
17
+ streams?: DialogStreams;
18
+ unblockFlag?: string;
19
+ }
20
+ export declare function confirm(question: string, options?: ConfirmOptions): Promise<boolean>;
21
+ export interface PromptTextOptions {
22
+ defaultValue?: string;
23
+ fallbackValue?: string;
24
+ streams?: DialogStreams;
25
+ unblockFlag?: string;
26
+ }
27
+ export declare function promptText(question: string, options?: PromptTextOptions): Promise<string>;
@@ -0,0 +1,94 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { UsageError } from '../errors.js';
3
+ import { isNonInteractiveOrCI } from './interactivity.js';
4
+ import { sanitizeTerminal } from './sanitize.js';
5
+ // Prompt primitives (E11-4, #103), ported from wrangler's dialogs.ts semantics:
6
+ //
7
+ // - `defaultValue` is the HUMAN pre-selection (what Enter means at a TTY).
8
+ // - `fallbackValue` is what happens when NO human is present. These are
9
+ // different questions — conflating them is the classic bug (a destructive
10
+ // prompt whose Enter-default of "yes" silently becomes the CI answer).
11
+ // - Non-interactive runs NEVER hang and NEVER decide silently: the question
12
+ // AND the chosen fallback are printed to stderr.
13
+ // - No safe fallback -> typed NoDefaultValueProvided (exit 2) naming the flag
14
+ // that unblocks, thrown immediately instead of blocking forever.
15
+ // - EOF and Ctrl-C at an interactive prompt SETTLE: a readline that closes
16
+ // without an answer rejects (prompt_aborted, exit 2) — never a silent
17
+ // exit 0 and never an unresolved await.
18
+ //
19
+ // All prompt text goes to STDERR: prompts are conversation, never data.
20
+ // This module is the ONE prompt chokepoint (headless.ts paste-back included)
21
+ // the way redact.ts is for secrets.
22
+ export class NoDefaultValueProvided extends UsageError {
23
+ constructor(unblockFlag = '--yes') {
24
+ super(`This prompt cannot be answered in a non-interactive context. Pass ${unblockFlag} (or run interactively).`, 'non_interactive');
25
+ this.name = 'NoDefaultValueProvided';
26
+ this.withDetails({
27
+ hint: `Re-run with ${unblockFlag} to accept, or run from an interactive terminal.`,
28
+ retryable: false,
29
+ });
30
+ }
31
+ }
32
+ function announceFallback(question, fallback, output) {
33
+ output.write(`? ${sanitizeTerminal(question)}\n`);
34
+ output.write(`Using fallback value in non-interactive context: ${sanitizeTerminal(fallback)}\n`);
35
+ }
36
+ // One readline question that ALWAYS settles: resolves with the answer, or
37
+ // rejects when the interface closes unanswered (EOF, Ctrl-C — readline in
38
+ // terminal mode swallows SIGINT and just closes, so `close` is the one signal
39
+ // both paths share).
40
+ export async function readAnswer(promptText, streams = {}) {
41
+ const input = streams.input ?? process.stdin;
42
+ const output = streams.output ?? process.stderr;
43
+ const rl = createInterface({ input, output });
44
+ try {
45
+ return await new Promise((resolve, reject) => {
46
+ let answered = false;
47
+ rl.on('SIGINT', () => rl.close());
48
+ rl.on('close', () => {
49
+ if (!answered) {
50
+ reject(new UsageError('Prompt aborted — no answer on stdin.', 'prompt_aborted').withDetails({ retryable: false }));
51
+ }
52
+ });
53
+ rl.question(promptText, (answer) => {
54
+ answered = true;
55
+ resolve(answer);
56
+ });
57
+ });
58
+ }
59
+ finally {
60
+ rl.close();
61
+ }
62
+ }
63
+ export async function confirm(question, options = {}) {
64
+ const { defaultValue = true, fallbackValue, streams = {}, unblockFlag } = options;
65
+ const output = streams.output ?? process.stderr;
66
+ if (isNonInteractiveOrCI(process.env, { stderr: streams.output ?? process.stderr, stdin: streams.input ?? process.stdin })) {
67
+ if (fallbackValue === undefined)
68
+ throw new NoDefaultValueProvided(unblockFlag);
69
+ announceFallback(question, fallbackValue ? 'yes' : 'no', output);
70
+ return fallbackValue;
71
+ }
72
+ const suffix = defaultValue ? '[Y/n]' : '[y/N]';
73
+ const answer = (await readAnswer(`? ${sanitizeTerminal(question)} ${suffix} `, streams)).trim().toLowerCase();
74
+ if (answer === '')
75
+ return defaultValue;
76
+ return answer === 'y' || answer === 'yes';
77
+ }
78
+ // NOTE: an interactive empty answer with no defaultValue resolves to '' — the
79
+ // caller owns validation/re-prompting; this primitive never loops.
80
+ export async function promptText(question, options = {}) {
81
+ const { defaultValue, fallbackValue, streams = {}, unblockFlag } = options;
82
+ const output = streams.output ?? process.stderr;
83
+ if (isNonInteractiveOrCI(process.env, { stderr: streams.output ?? process.stderr, stdin: streams.input ?? process.stdin })) {
84
+ if (fallbackValue === undefined)
85
+ throw new NoDefaultValueProvided(unblockFlag);
86
+ announceFallback(question, fallbackValue, output);
87
+ return fallbackValue;
88
+ }
89
+ const suffix = defaultValue === undefined ? '' : ` (${sanitizeTerminal(defaultValue)})`;
90
+ const answer = (await readAnswer(`? ${sanitizeTerminal(question)}${suffix} `, streams)).trim();
91
+ if (answer === '')
92
+ return defaultValue ?? '';
93
+ return answer;
94
+ }
@@ -0,0 +1,11 @@
1
+ export interface InteractivityProbe {
2
+ stderr?: {
3
+ isTTY?: boolean;
4
+ };
5
+ stdin?: {
6
+ isTTY?: boolean;
7
+ };
8
+ }
9
+ export declare function isInteractive(probe?: InteractivityProbe): boolean;
10
+ export declare function isCI(env?: NodeJS.ProcessEnv): boolean;
11
+ export declare function isNonInteractiveOrCI(env?: NodeJS.ProcessEnv, probe?: InteractivityProbe): boolean;
@@ -0,0 +1,48 @@
1
+ // Three-tier interactivity (E11-4, #103) — three DIFFERENT questions, ported
2
+ // from wrangler's split, including the tier most CLIs miss:
3
+ //
4
+ // isInteractive() gates CAPABILITIES (prompts can be answered and
5
+ // SEEN — stdin for answers,
6
+ // stderr because that is where
7
+ // prompts go in this repo)
8
+ // isNonInteractiveOrCI() gates DECISIONS (ask, or fall back + announce)
9
+ // isCI() gates DISCLOSURE (public build logs — redact
10
+ // account identifiers there, but
11
+ // NOT for a local non-interactive
12
+ // agent, which is non-interactive
13
+ // yet not public)
14
+ //
15
+ // A fourth tier — "an LLM is driving", detectable via am-i-vibing — is
16
+ // DEFERRED to E11-5 (#104), which introduces that dependency; agents that
17
+ // allocate a pty can look interactive until then. The escape hatch below
18
+ // (FLIPSTREAM_NO_INPUT) exists for exactly that gap: any wrapper can force the
19
+ // deterministic no-prompt path regardless of what the TTY claims.
20
+ // stdin (can the user answer?) AND stderr (can they SEE the prompt?). stdout
21
+ // is deliberately not consulted: piping stdout is this CLI's DESIGNED usage
22
+ // (`query --json > out.json`), and a human at a terminal doing that can still
23
+ // be prompted — the prompt stream is not the data stream here, which is why
24
+ // this diverges from wrangler's stdin+stdout pair.
25
+ export function isInteractive(probe = {}) {
26
+ const stdin = probe.stdin ?? process.stdin;
27
+ const stderr = probe.stderr ?? process.stderr;
28
+ return Boolean(stdin.isTTY) && Boolean(stderr.isTTY);
29
+ }
30
+ // CI by VALUE, not mere presence: `CI=false`/`CI=0` count as not-CI. This
31
+ // deliberately diverges from ci-info (which treats presence as truth, `false`
32
+ // excepted) — a stale `export CI=true` nearly deploying to the wrong account
33
+ // is the failure class wrangler#9001 documents, and honoring an explicit
34
+ // opt-out is the fix. Vendors that never set CI get explicit checks.
35
+ export function isCI(env = process.env) {
36
+ const value = env.CI;
37
+ if (value !== undefined && value !== '' && value !== 'false' && value !== '0')
38
+ return true;
39
+ // CI vendors that do not set CI= at all.
40
+ return Boolean(env.JENKINS_URL ?? env.BUILD_ID ?? env.TEAMCITY_VERSION ?? env.bamboo_buildKey);
41
+ }
42
+ export function isNonInteractiveOrCI(env = process.env, probe = {}) {
43
+ // The explicit force-deterministic switch: never prompt, take fallbacks.
44
+ if (env.FLIPSTREAM_NO_INPUT !== undefined && env.FLIPSTREAM_NO_INPUT !== '' && env.FLIPSTREAM_NO_INPUT !== '0') {
45
+ return true;
46
+ }
47
+ return !isInteractive(probe) || isCI(env);
48
+ }
@@ -1 +1,2 @@
1
+ export declare function maskAccount(account: string): string;
1
2
  export declare function redact(value: unknown): unknown;
@@ -29,6 +29,18 @@ const JWT_SHAPE = /eyJ[\w-]*\.[\w-]+\.[\w-]+/g;
29
29
  function redactString(value) {
30
30
  return value.replaceAll(SECRET_PARAMS, `$1$2=${MASK}`).replaceAll(JWT_SHAPE, MASK);
31
31
  }
32
+ // Account mask for PUBLIC-log contexts (E11-4's CI disclosure tier):
33
+ // 'michel@flipstream.io' -> '***@flipstream.io'; anything not email-shaped ->
34
+ // '***'. The local part is dropped ENTIRELY — on a small team an initial plus
35
+ // the domain identifies exactly one person, which defeats the point. This is
36
+ // deliberately NOT part of redact(): an account name is not a secret, and the
37
+ // human line is the only consumer — --json keeps the stable value by contract.
38
+ export function maskAccount(account) {
39
+ const at = account.lastIndexOf('@');
40
+ if (at > 0 && at < account.length - 1)
41
+ return `***${account.slice(at)}`;
42
+ return '***';
43
+ }
32
44
  // Return a redacted deep copy of `value` (never mutates the input). Applied to
33
45
  // every log/debug/error path so tokens never reach stdout/stderr/logs.
34
46
  export function redact(value) {
@@ -0,0 +1,3 @@
1
+ export declare function currentRunLogPath(): null | string;
2
+ export declare function resetRunLog(): void;
3
+ export declare function appendRunLog(line: string): void;