flipstream 0.5.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 (85) hide show
  1. package/README.md +168 -12
  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 +27 -1
  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.js +6 -2
  13. package/dist/commands/query.d.ts +15 -2
  14. package/dist/commands/query.js +255 -42
  15. package/dist/commands/skills/install.d.ts +16 -0
  16. package/dist/commands/skills/install.js +55 -0
  17. package/dist/commands/workspaces/connections.js +3 -1
  18. package/dist/commands/workspaces/get.js +4 -2
  19. package/dist/commands/workspaces/list.js +3 -0
  20. package/dist/lib/api/errors.d.ts +1 -0
  21. package/dist/lib/api/errors.js +13 -2
  22. package/dist/lib/api/http.d.ts +2 -0
  23. package/dist/lib/api/http.js +40 -4
  24. package/dist/lib/api/ids.d.ts +1 -0
  25. package/dist/lib/api/ids.js +5 -0
  26. package/dist/lib/api/short-uuid.d.ts +1 -0
  27. package/dist/lib/api/short-uuid.js +30 -0
  28. package/dist/lib/auth/flow.js +8 -1
  29. package/dist/lib/auth/headless.js +14 -10
  30. package/dist/lib/auth/refresh.js +21 -1
  31. package/dist/lib/command/base.d.ts +4 -0
  32. package/dist/lib/command/base.js +97 -3
  33. package/dist/lib/command/flags.d.ts +4 -0
  34. package/dist/lib/command/flags.js +11 -0
  35. package/dist/lib/command/planner.d.ts +9 -0
  36. package/dist/lib/command/planner.js +14 -0
  37. package/dist/lib/config/constants.d.ts +3 -1
  38. package/dist/lib/config/constants.js +14 -1
  39. package/dist/lib/config/xdg.d.ts +4 -0
  40. package/dist/lib/config/xdg.js +56 -1
  41. package/dist/lib/errors.d.ts +20 -1
  42. package/dist/lib/errors.js +125 -17
  43. package/dist/lib/output/dialogs.d.ts +27 -0
  44. package/dist/lib/output/dialogs.js +94 -0
  45. package/dist/lib/output/interactivity.d.ts +11 -0
  46. package/dist/lib/output/interactivity.js +48 -0
  47. package/dist/lib/output/redact.d.ts +1 -0
  48. package/dist/lib/output/redact.js +12 -0
  49. package/dist/lib/output/runlog.d.ts +3 -0
  50. package/dist/lib/output/runlog.js +72 -0
  51. package/dist/lib/output/sanitize.d.ts +2 -0
  52. package/dist/lib/output/sanitize.js +57 -0
  53. package/dist/lib/output/sidecar.d.ts +30 -0
  54. package/dist/lib/output/sidecar.js +58 -0
  55. package/dist/lib/output/table.js +5 -1
  56. package/dist/lib/output/trace.d.ts +11 -0
  57. package/dist/lib/output/trace.js +89 -0
  58. package/dist/lib/planner/catalog.d.ts +26 -0
  59. package/dist/lib/planner/catalog.js +60 -0
  60. package/dist/lib/planner/client.d.ts +14 -0
  61. package/dist/lib/planner/client.js +47 -0
  62. package/dist/lib/planner/connection.d.ts +14 -0
  63. package/dist/lib/planner/connection.js +139 -0
  64. package/dist/lib/planner/diagnose.d.ts +8 -0
  65. package/dist/lib/planner/diagnose.js +50 -0
  66. package/dist/lib/planner/errors.d.ts +14 -0
  67. package/dist/lib/planner/errors.js +129 -0
  68. package/dist/lib/planner/filters.d.ts +8 -0
  69. package/dist/lib/planner/filters.js +74 -0
  70. package/dist/lib/planner/request.d.ts +24 -0
  71. package/dist/lib/planner/request.js +51 -0
  72. package/dist/lib/planner/suggest.d.ts +2 -0
  73. package/dist/lib/planner/suggest.js +45 -0
  74. package/dist/lib/planner/vocabulary.d.ts +9 -0
  75. package/dist/lib/planner/vocabulary.js +95 -0
  76. package/dist/lib/skills/install.d.ts +24 -0
  77. package/dist/lib/skills/install.js +69 -0
  78. package/dist/lib/store/keyring.d.ts +3 -0
  79. package/dist/lib/store/keyring.js +45 -2
  80. package/dist/lib/store/memory-store.d.ts +1 -0
  81. package/dist/lib/store/memory-store.js +5 -0
  82. package/docs/AGENT-CONTRACT.md +238 -0
  83. package/oclif.manifest.json +392 -8
  84. package/package.json +7 -3
  85. package/skill/SKILL.md +55 -0
@@ -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
@@ -106,13 +151,9 @@ export function toExitCode(error) {
106
151
  }
107
152
  return ExitCode.GENERIC;
108
153
  }
109
- // Render an error (redacted) and return its exit code. AGENT CONTRACT: under
110
- // --json/--ndjson the error envelope IS the machine output, so a single
111
- // {error:{code,message,exit}} JSON document is written to STDOUT — this is what
112
- // makes `<cmd> --json 2>/dev/null | jq .` work on failure paths too. Human mode
113
- // writes a line to STDERR. Both routes pass the message through redaction so
114
- // tokens never leak.
115
- export function renderError(error, options = {}) {
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) {
116
157
  const exitCode = toExitCode(error);
117
158
  let code = 'error';
118
159
  if (error instanceof CliError)
@@ -122,12 +163,79 @@ export function renderError(error, options = {}) {
122
163
  else if (error instanceof Error)
123
164
  code = error.name;
124
165
  const rawMessage = error instanceof Error ? error.message : String(error);
125
- const message = redact(rawMessage);
126
- if (options.json) {
127
- process.stdout.write(`${JSON.stringify({ error: { code, exit: exitCode, message } })}\n`);
166
+ return {
167
+ code,
168
+ details: error instanceof CliError ? error.details : {},
169
+ exitCode,
170
+ message: redact(rawMessage),
171
+ };
172
+ }
173
+ // Render an error (redacted) and return its exit code. AGENT CONTRACT: under
174
+ // --json/--ndjson the error envelope IS the machine output, so a single
175
+ // {error:{code,message,exit}} JSON document is written to STDOUT — this is what
176
+ // makes `<cmd> --json 2>/dev/null | jq .` work on failure paths too. Human mode
177
+ // writes a line to STDERR. Both routes pass the message through redaction so
178
+ // tokens never leak.
179
+ export function renderError(error, options = {}) {
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));
128
200
  }
129
- else {
130
- 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');
131
240
  }
132
- return exitCode;
133
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;
@@ -0,0 +1,72 @@
1
+ import { appendFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ // Per-run log file (E11-6, #105) under the XDG state dir. EVERY line offered is
5
+ // written regardless of terminal verbosity — post-mortems need context and
6
+ // order, not just the lines that happened to be shown. Contents pass through
7
+ // the caller's redaction before they get here (trace.ts / base.ts).
8
+ //
9
+ // Lazy: no file is created until the first line, so commands that log nothing
10
+ // leave nothing behind.
11
+ const MAX_LOG_FILES = 20;
12
+ function stateDir() {
13
+ const xdg = process.env.XDG_STATE_HOME?.trim();
14
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.local', 'state');
15
+ return join(base, 'flipstream-cli', 'logs');
16
+ }
17
+ let runLogPath = null;
18
+ let runLogBroken = false;
19
+ // The current run's log path — null until something has been written.
20
+ export function currentRunLogPath() {
21
+ return runLogPath;
22
+ }
23
+ // Test seam + long-lived-process hygiene.
24
+ export function resetRunLog() {
25
+ runLogPath = null;
26
+ runLogBroken = false;
27
+ }
28
+ function timestampName() {
29
+ const iso = new Date().toISOString().replaceAll(':', '-').replace(/\..+$/, '');
30
+ return `${iso}-${process.pid}.log`;
31
+ }
32
+ // ISO-prefixed names sort chronologically, so pruning is a name sort — no
33
+ // stat() calls needed.
34
+ function prune(dir) {
35
+ try {
36
+ const logs = readdirSync(dir)
37
+ .filter((name) => name.endsWith('.log'))
38
+ .sort();
39
+ // +1: prune runs BEFORE the new file is created, so keep MAX-1 old ones.
40
+ for (const name of logs.slice(0, Math.max(0, logs.length + 1 - MAX_LOG_FILES))) {
41
+ rmSync(join(dir, name), { force: true });
42
+ }
43
+ }
44
+ catch {
45
+ // Pruning is hygiene, never worth failing a command over.
46
+ }
47
+ }
48
+ export function appendRunLog(line) {
49
+ if (runLogBroken)
50
+ return;
51
+ try {
52
+ if (runLogPath === null) {
53
+ const dir = stateDir();
54
+ // 0700/0600: these files hold argv and error messages that echo request
55
+ // payloads — not for other local users (the Python reference used 0600).
56
+ mkdirSync(dir, { mode: 0o700, recursive: true });
57
+ prune(dir);
58
+ const candidate = join(dir, timestampName());
59
+ appendFileSync(candidate, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
60
+ runLogPath = candidate;
61
+ return;
62
+ }
63
+ appendFileSync(runLogPath, `${new Date().toISOString()} ${line}\n`);
64
+ }
65
+ catch {
66
+ // A read-only or exotic filesystem must never break the actual command.
67
+ // Null the path too: never point currentRunLogPath() at a file that the
68
+ // failed write means does not exist.
69
+ runLogBroken = true;
70
+ runLogPath = null;
71
+ }
72
+ }
@@ -0,0 +1,2 @@
1
+ export declare function sanitizeTerminal(value: string): string;
2
+ export declare function sanitizeDeep<T>(value: T): T;
@@ -0,0 +1,57 @@
1
+ // Strip terminal control sequences from text the SERVER chose.
2
+ //
3
+ // `redact()` masks token-shaped content but leaves control bytes intact, so any
4
+ // human-mode path that prints a service-supplied string trusts those bytes. A
5
+ // hostile or malformed value can then rewrite what the terminal displays —
6
+ // overwrite earlier lines, hide text, fake a prompt — or reach terminal features
7
+ // like clipboard-setting OSC sequences.
8
+ //
9
+ // TWO RULES make this safe to apply broadly:
10
+ //
11
+ // 1. It runs on INPUT, never on rendered output. @oclif/table adds its own ANSI
12
+ // for borders and colour; sanitizing the finished table would strip that too.
13
+ // So cell VALUES are cleaned before rendering, not the rendered string.
14
+ //
15
+ // 2. It never touches machine output. JSON.stringify already escapes control
16
+ // characters, so --json is structurally safe without help — and it must stay
17
+ // byte-faithful to what the service sent. Redaction is the machine-mode
18
+ // protection; this is the human-mode one. Deliberately separate.
19
+ // ESC- and CSI-introduced sequences: colour, cursor movement, and OSC (which can
20
+ // carry a payload terminated by BEL or ST). Matching the introducer AND its
21
+ // payload matters - stripping a bare ESC would leave "[31m" as visible garbage.
22
+ // This is the well-known ansi-regex pattern, inlined rather than depended on.
23
+ // OSC gets its own pattern because its payload is free text up to a terminator
24
+ // (BEL, or ST as ESC-backslash / 0x9C) and can contain spaces — a window title
25
+ // does. The generic CSI pattern below stops at the first space, which would leave
26
+ // the tail of an OSC visible. Non-greedy, so an unterminated OSC simply does not
27
+ // match and its ESC is removed by CONTROL instead.
28
+ // eslint-disable-next-line no-control-regex
29
+ const OSC = /[\u001B\u009D]\][\S\s]*?(?:\u0007|\u001B\\|\u009C)/g;
30
+ const ANSI =
31
+ // eslint-disable-next-line no-control-regex
32
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[\w#&./:=?@~-]+)*|[\dA-Za-z]+(?:;[\w#&./:=?@~-]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nqry=><~]))/g;
33
+ // Whatever survives: C0 minus \t and \n (legitimate inside a message), DEL, and
34
+ // C1. \r IS stripped - a carriage return overwrites the line just printed, which
35
+ // is one of the cheaper ways to hide text. Also catches a lone ESC or CSI that
36
+ // did not form a complete sequence above.
37
+ // eslint-disable-next-line no-control-regex
38
+ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
39
+ export function sanitizeTerminal(value) {
40
+ return value.replaceAll(OSC, '').replaceAll(ANSI, '').replaceAll(CONTROL, '');
41
+ }
42
+ // Sanitize every string in a structure, preserving its shape. Used for table rows,
43
+ // where the values are service-supplied but the keys are ours.
44
+ export function sanitizeDeep(value) {
45
+ if (typeof value === 'string')
46
+ return sanitizeTerminal(value);
47
+ if (Array.isArray(value)) {
48
+ return value.map((item) => sanitizeDeep(item));
49
+ }
50
+ if (value !== null && typeof value === 'object') {
51
+ const out = {};
52
+ for (const [key, val] of Object.entries(value))
53
+ out[key] = sanitizeDeep(val);
54
+ return out;
55
+ }
56
+ return value;
57
+ }
@@ -0,0 +1,30 @@
1
+ interface SessionEntry {
2
+ argv: string[];
3
+ cli_version: string;
4
+ contract_version: string;
5
+ log_file_path: null | string;
6
+ type: 'session';
7
+ version: 1;
8
+ }
9
+ interface ResultEntry {
10
+ command: string;
11
+ duration_ms: number;
12
+ exit: 0;
13
+ type: 'result';
14
+ version: 1;
15
+ }
16
+ interface CommandFailedEntry {
17
+ command: string;
18
+ error_code: string;
19
+ exit: number;
20
+ log_file_path: null | string;
21
+ message: string;
22
+ retry_after_ms?: number;
23
+ type: 'command-failed';
24
+ version: 1;
25
+ }
26
+ export type OutputEntry = CommandFailedEntry | ResultEntry | SessionEntry;
27
+ export declare function resetSidecar(): void;
28
+ export declare function sidecarEnabled(): boolean;
29
+ export declare function writeOutputEntry(entry: OutputEntry): void;
30
+ export {};
@@ -0,0 +1,58 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { redact } from './redact.js';
4
+ let cachedPath;
5
+ let warned = false;
6
+ // Test seam (env changes between tests).
7
+ export function resetSidecar() {
8
+ cachedPath = undefined;
9
+ warned = false;
10
+ }
11
+ function timestampName() {
12
+ const iso = new Date().toISOString().replaceAll(':', '-').replace(/\..+$/, '');
13
+ return `flipstream-output-${iso}-${process.pid}.ndjson`;
14
+ }
15
+ function resolvePath() {
16
+ const file = process.env.FLIPSTREAM_OUTPUT_FILE?.trim();
17
+ if (file)
18
+ return file;
19
+ const dir = process.env.FLIPSTREAM_OUTPUT_FILE_DIRECTORY?.trim();
20
+ if (dir) {
21
+ mkdirSync(dir, { mode: 0o700, recursive: true });
22
+ return join(dir, timestampName());
23
+ }
24
+ return null;
25
+ }
26
+ export function sidecarEnabled() {
27
+ try {
28
+ cachedPath ??= resolvePath();
29
+ }
30
+ catch {
31
+ cachedPath = null;
32
+ }
33
+ return cachedPath !== null;
34
+ }
35
+ // Append one entry. Failures are swallowed after one warning-free attempt —
36
+ // a broken sidecar path must never break the actual command.
37
+ export function writeOutputEntry(entry) {
38
+ try {
39
+ cachedPath ??= resolvePath();
40
+ if (cachedPath === null)
41
+ return;
42
+ const record = { ...redact(entry), timestamp: new Date().toISOString() };
43
+ // 0600: the sidecar holds argv and command outcomes — not for other local
44
+ // users. mode applies only at creation, which is what we want.
45
+ appendFileSync(cachedPath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
46
+ }
47
+ catch (error) {
48
+ // A truncated sidecar reads to the supervisor like a run that crashed
49
+ // before its result entry — so say so ONCE, then go quiet. Only the CLI
50
+ // knows the difference, and only at the moment it happens.
51
+ if (!warned) {
52
+ warned = true;
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ process.stderr.write(`sidecar write failed (${cachedPath ?? 'unknown path'}): ${message}; disabling for this run\n`);
55
+ }
56
+ cachedPath = null;
57
+ }
58
+ }