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.
- package/README.md +168 -12
- package/dist/commands/auth/login.js +7 -1
- package/dist/commands/auth/status.js +29 -3
- package/dist/commands/catalog.d.ts +15 -0
- package/dist/commands/catalog.js +110 -0
- package/dist/commands/connections/list.d.ts +1 -0
- package/dist/commands/connections/list.js +27 -1
- package/dist/commands/contract.d.ts +11 -0
- package/dist/commands/contract.js +35 -0
- package/dist/commands/health.d.ts +10 -0
- package/dist/commands/health.js +31 -0
- package/dist/commands/log/add.js +6 -2
- package/dist/commands/query.d.ts +15 -2
- package/dist/commands/query.js +255 -42
- package/dist/commands/skills/install.d.ts +16 -0
- package/dist/commands/skills/install.js +55 -0
- package/dist/commands/workspaces/connections.js +3 -1
- package/dist/commands/workspaces/get.js +4 -2
- package/dist/commands/workspaces/list.js +3 -0
- package/dist/lib/api/errors.d.ts +1 -0
- package/dist/lib/api/errors.js +13 -2
- package/dist/lib/api/http.d.ts +2 -0
- package/dist/lib/api/http.js +40 -4
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/flow.js +8 -1
- package/dist/lib/auth/headless.js +14 -10
- package/dist/lib/auth/refresh.js +21 -1
- package/dist/lib/command/base.d.ts +4 -0
- package/dist/lib/command/base.js +97 -3
- package/dist/lib/command/flags.d.ts +4 -0
- package/dist/lib/command/flags.js +11 -0
- package/dist/lib/command/planner.d.ts +9 -0
- package/dist/lib/command/planner.js +14 -0
- package/dist/lib/config/constants.d.ts +3 -1
- package/dist/lib/config/constants.js +14 -1
- package/dist/lib/config/xdg.d.ts +4 -0
- package/dist/lib/config/xdg.js +56 -1
- package/dist/lib/errors.d.ts +20 -1
- package/dist/lib/errors.js +125 -17
- package/dist/lib/output/dialogs.d.ts +27 -0
- package/dist/lib/output/dialogs.js +94 -0
- package/dist/lib/output/interactivity.d.ts +11 -0
- package/dist/lib/output/interactivity.js +48 -0
- package/dist/lib/output/redact.d.ts +1 -0
- package/dist/lib/output/redact.js +12 -0
- package/dist/lib/output/runlog.d.ts +3 -0
- package/dist/lib/output/runlog.js +72 -0
- package/dist/lib/output/sanitize.d.ts +2 -0
- package/dist/lib/output/sanitize.js +57 -0
- package/dist/lib/output/sidecar.d.ts +30 -0
- package/dist/lib/output/sidecar.js +58 -0
- package/dist/lib/output/table.js +5 -1
- package/dist/lib/output/trace.d.ts +11 -0
- package/dist/lib/output/trace.js +89 -0
- package/dist/lib/planner/catalog.d.ts +26 -0
- package/dist/lib/planner/catalog.js +60 -0
- package/dist/lib/planner/client.d.ts +14 -0
- package/dist/lib/planner/client.js +47 -0
- package/dist/lib/planner/connection.d.ts +14 -0
- package/dist/lib/planner/connection.js +139 -0
- package/dist/lib/planner/diagnose.d.ts +8 -0
- package/dist/lib/planner/diagnose.js +50 -0
- package/dist/lib/planner/errors.d.ts +14 -0
- package/dist/lib/planner/errors.js +129 -0
- package/dist/lib/planner/filters.d.ts +8 -0
- package/dist/lib/planner/filters.js +74 -0
- package/dist/lib/planner/request.d.ts +24 -0
- package/dist/lib/planner/request.js +51 -0
- package/dist/lib/planner/suggest.d.ts +2 -0
- package/dist/lib/planner/suggest.js +45 -0
- package/dist/lib/planner/vocabulary.d.ts +9 -0
- package/dist/lib/planner/vocabulary.js +95 -0
- package/dist/lib/skills/install.d.ts +24 -0
- package/dist/lib/skills/install.js +69 -0
- package/dist/lib/store/keyring.d.ts +3 -0
- package/dist/lib/store/keyring.js +45 -2
- package/dist/lib/store/memory-store.d.ts +1 -0
- package/dist/lib/store/memory-store.js +5 -0
- package/docs/AGENT-CONTRACT.md +238 -0
- package/oclif.manifest.json +392 -8
- package/package.json +7 -3
- package/skill/SKILL.md +55 -0
package/dist/lib/errors.js
CHANGED
|
@@ -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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
@@ -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,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,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
|
+
}
|