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.
- package/README.md +357 -27
- 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 +31 -4
- 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.d.ts +16 -0
- package/dist/commands/log/add.js +48 -0
- package/dist/commands/log/list.d.ts +19 -0
- package/dist/commands/log/list.js +43 -0
- 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 +6 -3
- package/dist/commands/workspaces/get.js +4 -2
- package/dist/commands/workspaces/list.js +3 -0
- package/dist/lib/api/admin-client.d.ts +5 -0
- package/dist/lib/api/admin-client.js +19 -0
- package/dist/lib/api/connections.d.ts +0 -1
- package/dist/lib/api/connections.js +0 -25
- 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/hydrate.d.ts +10 -0
- package/dist/lib/api/hydrate.js +46 -0
- package/dist/lib/api/ids.d.ts +1 -0
- package/dist/lib/api/ids.js +5 -0
- package/dist/lib/api/log.d.ts +22 -0
- package/dist/lib/api/log.js +56 -0
- package/dist/lib/api/projections.d.ts +1 -0
- package/dist/lib/api/projections.js +23 -0
- package/dist/lib/api/short-uuid.d.ts +1 -0
- package/dist/lib/api/short-uuid.js +30 -0
- package/dist/lib/auth/claims.js +3 -3
- 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/admin.d.ts +1 -0
- package/dist/lib/command/admin.js +21 -0
- 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 +132 -13
- 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 +606 -8
- package/package.json +22 -3
- package/skill/SKILL.md +55 -0
- package/dist/lib/auth/register.d.ts +0 -4
- package/dist/lib/auth/register.js +0 -43
|
@@ -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
|
+
}
|
package/dist/lib/output/table.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeTable } from '@oclif/table';
|
|
2
|
+
import { sanitizeDeep } from './sanitize.js';
|
|
2
3
|
// Render a table to STDOUT — HUMAN output ONLY. Callers invoke this from a
|
|
3
4
|
// respond/respondList human formatter, which never runs under --json/--ndjson, so
|
|
4
5
|
// the agent contract (machine output is pure JSON/NDJSON) is preserved. Honors
|
|
@@ -8,10 +9,13 @@ export function renderTable(data, columns, emptyMessage = '(none)') {
|
|
|
8
9
|
process.stdout.write(`${emptyMessage}\n`);
|
|
9
10
|
return;
|
|
10
11
|
}
|
|
12
|
+
// Sanitize the DATA, not the rendered table: makeTable adds its own ANSI for
|
|
13
|
+
// borders and colour, so cleaning the finished string would strip that too.
|
|
14
|
+
// Every value here came from a service, so none of it may carry control bytes.
|
|
11
15
|
const table = makeTable({
|
|
12
16
|
borderStyle: 'vertical-with-outline',
|
|
13
17
|
columns,
|
|
14
|
-
data,
|
|
18
|
+
data: sanitizeDeep(data),
|
|
15
19
|
noStyle: Boolean(process.env.NO_COLOR),
|
|
16
20
|
overflow: 'truncate-end',
|
|
17
21
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function enableStderrTrace(): void;
|
|
2
|
+
export declare function resetTrace(): void;
|
|
3
|
+
export declare function traceRequest(method: string, url: string, body?: unknown): void;
|
|
4
|
+
export declare function traceFailure(method: string, url: string, reason: string, ms: number): void;
|
|
5
|
+
export declare function traceResponse(response: {
|
|
6
|
+
bodyText?: string;
|
|
7
|
+
method: string;
|
|
8
|
+
ms: number;
|
|
9
|
+
status: number;
|
|
10
|
+
url: string;
|
|
11
|
+
}): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { redact } from './redact.js';
|
|
2
|
+
import { appendRunLog, currentRunLogPath } from './runlog.js';
|
|
3
|
+
import { sanitizeTerminal } from './sanitize.js';
|
|
4
|
+
// Wire tracing (E11-6, #105): one line per request and response for every
|
|
5
|
+
// first-party API call. Two sinks with different rules:
|
|
6
|
+
//
|
|
7
|
+
// run log — always receives every line (context + order for post-mortems)
|
|
8
|
+
// stderr — only when enabled (FLIPSTREAM_LOG=debug or --verbose), which is
|
|
9
|
+
// also what fixes #92: trace lines are emitted AS THEY HAPPEN, so
|
|
10
|
+
// the failure path has diagnostics too, not just the happy path.
|
|
11
|
+
//
|
|
12
|
+
// Bodies and headers are OMITTED by default. FLIPSTREAM_LOG_SANITIZE=false
|
|
13
|
+
// includes bodies — still through redact(), which is the floor that never
|
|
14
|
+
// comes off (tokens never reach any sink).
|
|
15
|
+
let stderrEnabled = false;
|
|
16
|
+
// Called once per command from BaseCommand.init (flag) — the env var works
|
|
17
|
+
// even before that, for code paths that run pre-parse.
|
|
18
|
+
export function enableStderrTrace() {
|
|
19
|
+
stderrEnabled = true;
|
|
20
|
+
}
|
|
21
|
+
// Test seam.
|
|
22
|
+
export function resetTrace() {
|
|
23
|
+
stderrEnabled = false;
|
|
24
|
+
bodyNoticeShown = false;
|
|
25
|
+
}
|
|
26
|
+
function debugEnv() {
|
|
27
|
+
return process.env.FLIPSTREAM_LOG === 'debug';
|
|
28
|
+
}
|
|
29
|
+
function includeBodies() {
|
|
30
|
+
return process.env.FLIPSTREAM_LOG_SANITIZE === 'false';
|
|
31
|
+
}
|
|
32
|
+
let bodyNoticeShown = false;
|
|
33
|
+
function emit(line) {
|
|
34
|
+
// The run log is a file — it keeps the byte-faithful line for post-mortem.
|
|
35
|
+
// stderr is a terminal, so a body line under FLIPSTREAM_LOG_SANITIZE=false
|
|
36
|
+
// could carry cursor/erase escapes that rewrite the display; sanitize it
|
|
37
|
+
// there (every other human-facing writer already does).
|
|
38
|
+
appendRunLog(line);
|
|
39
|
+
if (stderrEnabled || debugEnv())
|
|
40
|
+
process.stderr.write(`${sanitizeTerminal(line)}\n`);
|
|
41
|
+
}
|
|
42
|
+
// FLIPSTREAM_LOG_SANITIZE=false writes full bodies to the run log even with no
|
|
43
|
+
// on-screen tracing — so the operator learns, once, that turning off
|
|
44
|
+
// sanitization also turned on persistence (E11-6 review).
|
|
45
|
+
function noticeBodiesPersist() {
|
|
46
|
+
if (bodyNoticeShown || stderrEnabled || debugEnv())
|
|
47
|
+
return;
|
|
48
|
+
bodyNoticeShown = true;
|
|
49
|
+
const path = currentRunLogPath();
|
|
50
|
+
process.stderr.write(`FLIPSTREAM_LOG_SANITIZE=false: unsanitized request/response bodies are being written to ${path ?? 'the run log'}\n`);
|
|
51
|
+
}
|
|
52
|
+
export function traceRequest(method, url, body) {
|
|
53
|
+
emit(`-- REQUEST ${method} ${redact(url)}`);
|
|
54
|
+
if (body === undefined)
|
|
55
|
+
return;
|
|
56
|
+
if (includeBodies()) {
|
|
57
|
+
noticeBodiesPersist();
|
|
58
|
+
emit(` body: ${JSON.stringify(redact(body))}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
emit(' body: omitted; set FLIPSTREAM_LOG_SANITIZE=false to include redacted data');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function traceFailure(method, url, reason, ms) {
|
|
65
|
+
emit(`-- FAILED ${method} ${redact(url)} (${ms} ms): ${redact(reason)}`);
|
|
66
|
+
}
|
|
67
|
+
// A body STRING gets structural redaction: parsed as JSON when possible so
|
|
68
|
+
// secret-NAMED keys are masked (string-level redaction only catches
|
|
69
|
+
// param-style and JWT-shaped values, not `{"access_token": "..."}`).
|
|
70
|
+
function redactBodyText(bodyText) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(redact(JSON.parse(bodyText)));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return redact(bodyText);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function traceResponse(response) {
|
|
79
|
+
emit(`-- RESPONSE ${response.status} ${response.method} ${redact(response.url)} (${response.ms} ms)`);
|
|
80
|
+
if (response.bodyText === undefined || response.bodyText.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
if (includeBodies()) {
|
|
83
|
+
noticeBodiesPersist();
|
|
84
|
+
emit(` body: ${redactBodyText(response.bodyText)}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
emit(' body: omitted; set FLIPSTREAM_LOG_SANITIZE=false to include redacted data');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface CatalogItem {
|
|
2
|
+
description: string;
|
|
3
|
+
label: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CatalogSource {
|
|
8
|
+
customDimensionsMax?: number;
|
|
9
|
+
description: string;
|
|
10
|
+
dimensions: CatalogItem[];
|
|
11
|
+
label: string;
|
|
12
|
+
metrics: CatalogItem[];
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
export interface CatalogSourceSummary {
|
|
16
|
+
dimensions: number;
|
|
17
|
+
label: string;
|
|
18
|
+
metrics: number;
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CatalogIndex {
|
|
22
|
+
defaultSource?: string;
|
|
23
|
+
sources: CatalogSourceSummary[];
|
|
24
|
+
}
|
|
25
|
+
export declare function parseCatalogIndex(payload: unknown): CatalogIndex;
|
|
26
|
+
export declare function parseCatalogSource(payload: unknown): CatalogSource;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Narrowing for the catalog documents the planner serves. The wire payload is
|
|
2
|
+
// untrusted, so it arrives as `unknown` and every field is proven here rather
|
|
3
|
+
// than asserted — the repo rule for API boundaries (see api/http.ts).
|
|
4
|
+
//
|
|
5
|
+
// What the planner publishes is an ALLOWLIST projection built by data-model:
|
|
6
|
+
// name, label, description, format and nothing else. `field`, `agg_func`,
|
|
7
|
+
// `table`, `grain`, `tier` and `routing` are absent by construction, not by
|
|
8
|
+
// filtering. We deliberately do not re-filter here — inventing a second
|
|
9
|
+
// allowlist would hide the day the first one breaks.
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
12
|
+
? value
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
function str(value, fallback = '') {
|
|
16
|
+
return typeof value === 'string' ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
function arr(value) {
|
|
19
|
+
return Array.isArray(value) ? value : [];
|
|
20
|
+
}
|
|
21
|
+
function parseItem(value) {
|
|
22
|
+
const record = asRecord(value) ?? {};
|
|
23
|
+
// `format` may be absent or null; its `type` is what the human table shows.
|
|
24
|
+
const format = asRecord(record.format) ?? {};
|
|
25
|
+
return {
|
|
26
|
+
description: str(record.description),
|
|
27
|
+
label: str(record.label),
|
|
28
|
+
name: str(record.name),
|
|
29
|
+
type: str(format.type),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// GET /catalog — the index of sources.
|
|
33
|
+
export function parseCatalogIndex(payload) {
|
|
34
|
+
const record = asRecord(payload) ?? {};
|
|
35
|
+
const sources = arr(record.sources).map((entry) => {
|
|
36
|
+
const source = asRecord(entry) ?? {};
|
|
37
|
+
return {
|
|
38
|
+
dimensions: arr(source.dimensions).length,
|
|
39
|
+
label: str(source.label),
|
|
40
|
+
metrics: arr(source.metrics).length,
|
|
41
|
+
name: str(source.name),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
const defaultSource = typeof record.defaultSource === 'string' ? record.defaultSource : undefined;
|
|
45
|
+
return { defaultSource, sources };
|
|
46
|
+
}
|
|
47
|
+
// GET /catalog/<source> — one source's full vocabulary.
|
|
48
|
+
export function parseCatalogSource(payload) {
|
|
49
|
+
const record = asRecord(payload) ?? {};
|
|
50
|
+
const custom = asRecord(record.customDimensions);
|
|
51
|
+
const max = custom !== undefined && typeof custom.max === 'number' ? custom.max : undefined;
|
|
52
|
+
return {
|
|
53
|
+
customDimensionsMax: max,
|
|
54
|
+
description: str(record.description),
|
|
55
|
+
dimensions: arr(record.dimensions).map((item) => parseItem(item)),
|
|
56
|
+
label: str(record.label),
|
|
57
|
+
metrics: arr(record.metrics).map((item) => parseItem(item)),
|
|
58
|
+
name: str(record.name),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RefreshDeps } from '../api/retry.js';
|
|
2
|
+
export interface PlannerCallOptions {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface PlannerClient {
|
|
6
|
+
getCatalog(source?: string, options?: PlannerCallOptions): Promise<unknown>;
|
|
7
|
+
postQuery(body: unknown, options?: PlannerCallOptions): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface AuthedPlannerClientDeps extends RefreshDeps {
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
url?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function createAuthedPlannerClient(deps: AuthedPlannerClientDeps): PlannerClient;
|
|
14
|
+
export declare function getPlannerHealth(url: string, options?: PlannerCallOptions): Promise<unknown>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { requestJson } from '../api/http.js';
|
|
2
|
+
import { withFreshToken } from '../api/retry.js';
|
|
3
|
+
import { PLANNER_HOST } from '../config/constants.js';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
function rstrip(host) {
|
|
6
|
+
return host.replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
// The authed QUERY-PLANNER client. Transport only: it knows the two paths and
|
|
9
|
+
// nothing about what a catalog document or a LogicalRequest contains.
|
|
10
|
+
//
|
|
11
|
+
// Wrapped in the SAME withFreshToken 401->refresh->retry-once used by the data and
|
|
12
|
+
// admin clients, and refresh always targets the AUTH issuer (deps.authHost) rather
|
|
13
|
+
// than the planner — the planner issues nothing, it only verifies and forwards.
|
|
14
|
+
export function createAuthedPlannerClient(deps) {
|
|
15
|
+
const base = rstrip(deps.url ?? PLANNER_HOST);
|
|
16
|
+
const defaultTimeout = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
17
|
+
return {
|
|
18
|
+
getCatalog(source, options = {}) {
|
|
19
|
+
// encodeURIComponent so a source name can never escape the path segment.
|
|
20
|
+
const path = source === undefined ? '/catalog' : `/catalog/${encodeURIComponent(source)}`;
|
|
21
|
+
return withFreshToken(deps, (token) => requestJson({
|
|
22
|
+
method: 'GET',
|
|
23
|
+
timeoutMs: options.timeoutMs ?? defaultTimeout,
|
|
24
|
+
token,
|
|
25
|
+
url: `${base}${path}`,
|
|
26
|
+
}));
|
|
27
|
+
},
|
|
28
|
+
postQuery(body, options = {}) {
|
|
29
|
+
return withFreshToken(deps, (token) => requestJson({
|
|
30
|
+
body,
|
|
31
|
+
method: 'POST',
|
|
32
|
+
timeoutMs: options.timeoutMs ?? defaultTimeout,
|
|
33
|
+
token,
|
|
34
|
+
url: `${base}/query`,
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// GET /health — the one endpoint that takes NO token (it is open on the planner),
|
|
40
|
+
// so it answers "is it me or is it them" before any auth debugging starts.
|
|
41
|
+
export async function getPlannerHealth(url, options = {}) {
|
|
42
|
+
return requestJson({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
45
|
+
url: `${rstrip(url)}/health`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AdminClient } from '../api/admin-client.js';
|
|
2
|
+
export interface ResolveOptions {
|
|
3
|
+
note?: (message: string) => void;
|
|
4
|
+
}
|
|
5
|
+
export interface ResolvedConnection {
|
|
6
|
+
active: boolean;
|
|
7
|
+
clientId: string;
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function connectionsUrl(clientId: string): string;
|
|
12
|
+
export declare function assertConnectionActive(connection: ResolvedConnection): void;
|
|
13
|
+
export declare function resolveConnection(client: AdminClient, value: string, options?: ResolveOptions): Promise<ResolvedConnection>;
|
|
14
|
+
export declare function connectionIsVisible(client: AdminClient, connectionId: string): Promise<boolean | undefined>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { fetchConnections } from '../api/connections.js';
|
|
2
|
+
import { isUuid } from '../api/ids.js';
|
|
3
|
+
import { projectConnection } from '../api/projections.js';
|
|
4
|
+
import { uuidToShort } from '../api/short-uuid.js';
|
|
5
|
+
import { PULSE_FE_HOST } from '../config/constants.js';
|
|
6
|
+
import { CliError, UsageError } from '../errors.js';
|
|
7
|
+
import { ExitCode } from '../exit-codes.js';
|
|
8
|
+
const MAX_LISTED = 10;
|
|
9
|
+
// Read a string field, falling back into the projected `raw` passthrough.
|
|
10
|
+
// projectConnection() promotes only its core fields (name, type, active, …), so
|
|
11
|
+
// `slug` — which the admin API does carry — survives ONLY inside `raw`. Reading
|
|
12
|
+
// just the top level would make slug matching silently never fire.
|
|
13
|
+
// Candidate connections as " <id> <name>" lines, capped, so a refusal names what
|
|
14
|
+
// the search found and the retry is a copy-paste rather than a second guess.
|
|
15
|
+
function listed(candidates) {
|
|
16
|
+
return candidates
|
|
17
|
+
.slice(0, MAX_LISTED)
|
|
18
|
+
.map((record) => ` ${label(record, 'id')} ${label(record, 'name')}`)
|
|
19
|
+
.join('\n');
|
|
20
|
+
}
|
|
21
|
+
function label(record, key) {
|
|
22
|
+
const direct = record[key];
|
|
23
|
+
if (typeof direct === 'string')
|
|
24
|
+
return direct;
|
|
25
|
+
const raw = record.raw;
|
|
26
|
+
const nested = raw?.[key];
|
|
27
|
+
return typeof nested === 'string' ? nested : '';
|
|
28
|
+
}
|
|
29
|
+
// A projected admin record → the few fields the query path cares about.
|
|
30
|
+
// `active` defaults to TRUE when the field is missing: this check is a
|
|
31
|
+
// convenience over the server's, and inventing "inactive" from an absent field
|
|
32
|
+
// would block a working connection on a schema change. The server (pulse-data#49)
|
|
33
|
+
// is what actually enforces this.
|
|
34
|
+
function toResolved(record) {
|
|
35
|
+
const active = record.active ?? record.raw?.active;
|
|
36
|
+
return {
|
|
37
|
+
active: active !== false,
|
|
38
|
+
clientId: label(record, 'client_id'),
|
|
39
|
+
id: label(record, 'id'),
|
|
40
|
+
name: label(record, 'name'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Where a human goes to switch a connection back on. The workspace segment is the
|
|
44
|
+
// client_id in pulse-fe's short-UUID encoding — derived from the record we already
|
|
45
|
+
// hold, so building this costs no extra request.
|
|
46
|
+
export function connectionsUrl(clientId) {
|
|
47
|
+
if (!isUuid(clientId))
|
|
48
|
+
return `${PULSE_FE_HOST}/client-space`;
|
|
49
|
+
return `${PULSE_FE_HOST}/client-space/${uuidToShort(clientId)}/connections`;
|
|
50
|
+
}
|
|
51
|
+
// Refuse a query against an inactive connection.
|
|
52
|
+
//
|
|
53
|
+
// This is a CONVENIENCE, not the control. It saves a round trip and turns an
|
|
54
|
+
// opaque engine failure into an instruction, but the authoritative refusal lives
|
|
55
|
+
// in pulse-data (#49) — a check on this side of the wire can be edited out by
|
|
56
|
+
// anyone running a modified client, so it must never be the only thing standing
|
|
57
|
+
// between an inactive connector and its data.
|
|
58
|
+
export function assertConnectionActive(connection) {
|
|
59
|
+
if (connection.active)
|
|
60
|
+
return;
|
|
61
|
+
const where = connection.name.length > 0 ? `'${connection.name}'` : connection.id;
|
|
62
|
+
throw new CliError(`Connection ${where} is inactive, so it cannot be queried. Reactivate it at ` +
|
|
63
|
+
`${connectionsUrl(connection.clientId)}`, 'connection_inactive', ExitCode.USAGE);
|
|
64
|
+
}
|
|
65
|
+
// A UUID passes through; anything else is looked up by name or slug.
|
|
66
|
+
//
|
|
67
|
+
// Ambiguity is REFUSED rather than resolved to the first hit. Querying the wrong
|
|
68
|
+
// connection returns numbers rather than an error, and numbers are the one
|
|
69
|
+
// failure nobody notices — so a name that matches two connections is a usage
|
|
70
|
+
// error listing both, not a coin flip.
|
|
71
|
+
//
|
|
72
|
+
// The lookup reads the same pulse-admin list that pulse-data resolves against,
|
|
73
|
+
// with the same token and the same connections:read scope, so what resolves here
|
|
74
|
+
// is exactly what the query can reach and nothing more.
|
|
75
|
+
export async function resolveConnection(client, value, options = {}) {
|
|
76
|
+
// A UUID still costs one GET. It used to pass straight through, but the caller
|
|
77
|
+
// needs `active`, and skipping the lookup for the id form would make "use the
|
|
78
|
+
// UUID instead of the name" a one-word bypass of the inactive check.
|
|
79
|
+
if (isUuid(value)) {
|
|
80
|
+
const raw = await client.get(`/connections/${value}`);
|
|
81
|
+
return toResolved(projectConnection(raw));
|
|
82
|
+
}
|
|
83
|
+
const { records } = await fetchConnections(client, { all: true, limit: 50, offset: 0, q: value });
|
|
84
|
+
// Only an EXACT name/slug hit is an identity. The server's free-text results are
|
|
85
|
+
// SUGGESTIONS: 'acme' matching the single connection 'acme-staging' is not the
|
|
86
|
+
// user naming it, and resolving to it would query a connection they never asked
|
|
87
|
+
// for — which returns numbers rather than an error, and numbers are the one
|
|
88
|
+
// failure nobody notices. Refusing costs one retry; guessing costs a decision
|
|
89
|
+
// made on the wrong client's data.
|
|
90
|
+
const folded = value.toLowerCase();
|
|
91
|
+
const exact = records.filter((record) => label(record, 'name').toLowerCase() === folded || label(record, 'slug').toLowerCase() === folded);
|
|
92
|
+
if (exact.length === 0) {
|
|
93
|
+
if (records.length === 0) {
|
|
94
|
+
throw new UsageError(`No connection matches '${value}' — run \`flipstream connections list\` to see yours.`);
|
|
95
|
+
}
|
|
96
|
+
// Name what the search DID find, so the retry is a copy-paste rather than a
|
|
97
|
+
// second guess.
|
|
98
|
+
throw new UsageError(`No connection is named exactly '${value}'. Did you mean:\n${listed(records)}`);
|
|
99
|
+
}
|
|
100
|
+
if (exact.length > 1) {
|
|
101
|
+
throw new UsageError(`'${value}' matches ${exact.length} connections:\n${listed(exact)}`);
|
|
102
|
+
}
|
|
103
|
+
const found = exact[0] ?? {};
|
|
104
|
+
const resolved = toResolved(found);
|
|
105
|
+
if (resolved.id.length === 0)
|
|
106
|
+
throw new UsageError(`Connection '${value}' resolved to a record with no id.`);
|
|
107
|
+
options.note?.(`connection '${value}' → ${resolved.id} (${resolved.name})`);
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
// Can this token resolve that connection? `undefined` when we could not find out.
|
|
111
|
+
//
|
|
112
|
+
// Only ever used to EXPLAIN a failure that already happened. pulse-data resolves
|
|
113
|
+
// the connection through pulse-admin as the caller, so a connection belonging to
|
|
114
|
+
// another account fails there — and the planner is right not to say which, since
|
|
115
|
+
// the engine's own words name tables. This asks the same question from the
|
|
116
|
+
// outside, where the answer is the caller's to know.
|
|
117
|
+
export async function connectionIsVisible(client, connectionId) {
|
|
118
|
+
// This id can come from an opaque --body, so it is caller-controlled and goes
|
|
119
|
+
// straight into a path. `../../oauth/token` normalises out of /connections/
|
|
120
|
+
// entirely, which would point an authenticated GET at another admin route. Same
|
|
121
|
+
// origin and the caller's own token, so the blast radius is small — but this
|
|
122
|
+
// CLI is driven by agents that may be working from untrusted content, and a
|
|
123
|
+
// non-UUID is not a connection id in any case. Undefined is also the honest
|
|
124
|
+
// answer: we cannot determine visibility for something that cannot be one.
|
|
125
|
+
if (!isUuid(connectionId))
|
|
126
|
+
return undefined;
|
|
127
|
+
try {
|
|
128
|
+
await client.get(`/connections/${connectionId}`);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const { status } = error;
|
|
133
|
+
if (status === 404)
|
|
134
|
+
return false;
|
|
135
|
+
// 403, 5xx, a timeout: we genuinely do not know, and guessing here would put
|
|
136
|
+
// a confident wrong hint in front of a real failure.
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CliError } from '../errors.js';
|
|
2
|
+
export interface DiagnoseOptions {
|
|
3
|
+
bin: string;
|
|
4
|
+
body: unknown;
|
|
5
|
+
fetchCatalog: (source?: string) => Promise<unknown>;
|
|
6
|
+
onSkip?: (reason: string) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function attachVocabularyDiagnosis(error: CliError, options: DiagnoseOptions): Promise<void>;
|