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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// UUID → the 22-character short id pulse-fe puts in its URLs.
|
|
2
|
+
//
|
|
3
|
+
// A port of `short-uuid`'s default translator (flickrBase58), which is what
|
|
4
|
+
// front_end/src/utils/routing/urlEncoding.ts uses (`uuidToShort`). Ported rather
|
|
5
|
+
// than depended on: this is a fixed alphabet and one bignum conversion, and a
|
|
6
|
+
// new runtime dependency on a CLI that ships five is a worse trade than 20 lines
|
|
7
|
+
// with a locked-down test.
|
|
8
|
+
//
|
|
9
|
+
// The correctness risk here is specific and quiet: a wrong encoding does not
|
|
10
|
+
// throw, it produces a plausible-looking URL that 404s. So the test carries a
|
|
11
|
+
// vector captured from a REAL pulse-fe URL, not one this code generated.
|
|
12
|
+
//
|
|
13
|
+
// flickrBase58 omits 0/O/I/l — the characters people misread when copying a link
|
|
14
|
+
// out of a terminal, which is the whole reason the alphabet exists.
|
|
15
|
+
const ALPHABET = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
|
|
16
|
+
const SHORT_LENGTH = 22;
|
|
17
|
+
// Left-padded to a fixed 22 chars, matching short-uuid: without the pad, a UUID
|
|
18
|
+
// with leading zero bytes encodes shorter and the route stops resolving.
|
|
19
|
+
export function uuidToShort(uuid) {
|
|
20
|
+
const hex = uuid.replaceAll('-', '');
|
|
21
|
+
let value = BigInt(`0x${hex}`);
|
|
22
|
+
const base = BigInt(ALPHABET.length);
|
|
23
|
+
let out = '';
|
|
24
|
+
while (value > 0n) {
|
|
25
|
+
const digit = Number(value % base);
|
|
26
|
+
out = `${ALPHABET[digit] ?? ''}${out}`;
|
|
27
|
+
value /= base;
|
|
28
|
+
}
|
|
29
|
+
return out.padStart(SHORT_LENGTH, ALPHABET[0] ?? '1');
|
|
30
|
+
}
|
package/dist/lib/auth/flow.js
CHANGED
|
@@ -8,7 +8,14 @@ import { DEFAULT_REDIRECT_TIMEOUT_MS, startLoopback } from './loopback.js';
|
|
|
8
8
|
import { generatePair } from './pkce.js';
|
|
9
9
|
import { buildAuthorizeUrl } from './provider.js';
|
|
10
10
|
export { decodeJwtClaimsUnverified } from './claims.js';
|
|
11
|
-
|
|
11
|
+
// The whole forwarding chain, not just the first hop (E8-0). The query planner
|
|
12
|
+
// presents this token to pulse-data (grid_data:read), and pulse-data presents it
|
|
13
|
+
// onward to pulse-admin to resolve the connection (connections:read). A caller
|
|
14
|
+
// holding only the first gets a 403 raised two services away and wrapped as an
|
|
15
|
+
// opaque PULSE_SERVICE_ERROR, which says nothing about what to fix.
|
|
16
|
+
//
|
|
17
|
+
// `read`/`write` stay for the admin API (workspaces, connections, log).
|
|
18
|
+
const DEFAULT_SCOPES = ['read', 'write', 'grid_data:read', 'connections:read'];
|
|
12
19
|
function tokenErrorMessage(status, body) {
|
|
13
20
|
try {
|
|
14
21
|
const parsed = JSON.parse(body);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createInterface } from 'node:readline';
|
|
2
1
|
import { LoopbackError } from '../errors.js';
|
|
2
|
+
import { readAnswer } from '../output/dialogs.js';
|
|
3
3
|
// True when there is no interactive browser to drive the OAuth redirect: an
|
|
4
4
|
// explicit open() failure, a CI run, an SSH session, or Linux without a display.
|
|
5
5
|
export function detectHeadless(probe = {}) {
|
|
@@ -44,18 +44,22 @@ export function parseRedirect(pasted, expectedState) {
|
|
|
44
44
|
return { code, state };
|
|
45
45
|
}
|
|
46
46
|
// Default manual-mode reader: print the authorize URL + instructions to STDERR
|
|
47
|
-
// (stdout stays clean for the eventual JSON) and read the pasted redirect from
|
|
48
|
-
|
|
47
|
+
// (stdout stays clean for the eventual JSON) and read the pasted redirect from
|
|
48
|
+
// stdin — through dialogs.readAnswer, the ONE prompt chokepoint, so EOF and
|
|
49
|
+
// Ctrl-C settle (prompt_aborted) instead of leaving an unresolved promise
|
|
50
|
+
// (E11-4 review: this file used to carry a second, unhardened readline).
|
|
51
|
+
export async function defaultPromptRedirect(authorizeUrl) {
|
|
49
52
|
process.stderr.write('\nNo browser is available here (headless / SSH / CI).\n' +
|
|
50
53
|
'Open this URL in a browser on any machine and approve access:\n\n' +
|
|
51
54
|
` ${authorizeUrl}\n\n` +
|
|
52
55
|
'Your browser will then try to load a http://127.0.0.1/... address that fails to\n' +
|
|
53
56
|
'connect. Copy that full URL from the address bar and paste it below.\n\n');
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
try {
|
|
58
|
+
return await readAnswer('Paste the redirected URL here: ');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Aborted/EOF: the flow's own empty-input handling produces the right
|
|
62
|
+
// headless_no_input error with its auth exit code.
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
61
65
|
}
|
package/dist/lib/auth/refresh.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { freshAccessToken } from '../store/credentials.js';
|
|
1
2
|
import { createStore } from '../store/index.js';
|
|
2
3
|
import { fetchMetadata } from './discovery.js';
|
|
3
4
|
// Silent refresh via the refresh_token grant. Returns the new access token, or
|
|
@@ -43,7 +44,26 @@ export async function refresh(host, options = {}) {
|
|
|
43
44
|
clearTimeout(timer);
|
|
44
45
|
}
|
|
45
46
|
if (response.status >= 400 && response.status < 500) {
|
|
46
|
-
|
|
47
|
+
// COMPARE-AND-DELETE. A 4xx here usually means invalid_grant, and with refresh-
|
|
48
|
+
// token rotation the most likely reason is that ANOTHER process already
|
|
49
|
+
// refreshed: it exchanged this same token, got a new one, and saved it. Our
|
|
50
|
+
// token is dead — but the one now in the keychain is not, and clearing blindly
|
|
51
|
+
// would delete a working session and log the user out for no reason.
|
|
52
|
+
//
|
|
53
|
+
// Re-read past any cache and only delete if what is stored is still the token
|
|
54
|
+
// we just tried. Note this race predates the read cache: two processes could
|
|
55
|
+
// always interleave a load and a rotation.
|
|
56
|
+
const current = store.reload(host);
|
|
57
|
+
if (current !== null && current.refreshToken !== creds.refreshToken) {
|
|
58
|
+
// Someone else won the race and their session is live. HAND OFF to it rather
|
|
59
|
+
// than reporting failure: returning null here would surface as "Session
|
|
60
|
+
// expired. Run `flipstream auth login`" while perfectly good credentials sit
|
|
61
|
+
// in the keychain — telling the user to fix something that is not broken.
|
|
62
|
+
// May still be null if their access token is also stale; either way we do
|
|
63
|
+
// not clear a session we do not own.
|
|
64
|
+
return freshAccessToken(current);
|
|
65
|
+
}
|
|
66
|
+
store.clear(host); // genuinely invalid_grant / revoked session — force re-login.
|
|
47
67
|
return null;
|
|
48
68
|
}
|
|
49
69
|
if (!response.ok)
|
|
@@ -11,10 +11,14 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
|
|
|
11
11
|
static enableJsonFlag: boolean;
|
|
12
12
|
protected args: BaseArgs<T>;
|
|
13
13
|
protected flags: BaseFlags<T>;
|
|
14
|
+
private startedAtMs;
|
|
14
15
|
protected catch(error: Error & {
|
|
15
16
|
exitCode?: number;
|
|
16
17
|
}): Promise<never>;
|
|
18
|
+
protected finally(error: Error | undefined): Promise<void>;
|
|
19
|
+
protected footer(text: string): void;
|
|
17
20
|
init(): Promise<void>;
|
|
21
|
+
protected note(message: string): void;
|
|
18
22
|
protected resolvedHost(): string;
|
|
19
23
|
protected respond<D>(data: D, human: (data: D) => void): D;
|
|
20
24
|
protected respondList<R>(envelope: {
|
package/dist/lib/command/base.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { CONTRACT_VERSION } from '../config/constants.js';
|
|
2
3
|
import { resolveHost } from '../config/xdg.js';
|
|
3
|
-
import { renderError, UsageError } from '../errors.js';
|
|
4
|
+
import { classifyError, renderError, UsageError } from '../errors.js';
|
|
4
5
|
import { renderNdjson } from '../output/ndjson.js';
|
|
5
6
|
import { redact } from '../output/redact.js';
|
|
7
|
+
import { appendRunLog, currentRunLogPath } from '../output/runlog.js';
|
|
8
|
+
import { sanitizeTerminal } from '../output/sanitize.js';
|
|
9
|
+
import { writeOutputEntry } from '../output/sidecar.js';
|
|
10
|
+
import { enableStderrTrace } from '../output/trace.js';
|
|
11
|
+
// Cap on any single argv element / message written to a persistent file, so a
|
|
12
|
+
// huge --body can neither balloon the log nor tear an NDJSON line under
|
|
13
|
+
// concurrent O_APPEND (E11-6 review).
|
|
14
|
+
const DISK_FIELD_MAX = 2000;
|
|
6
15
|
// Repo-wide clig.dev conventions in one place (E3-5). Every command extends this:
|
|
7
16
|
// - stdout = data, stderr = help/prompts/progress/diagnostics
|
|
8
17
|
// - --json (machine JSON, oclif-serialized) / --ndjson (one object per line),
|
|
@@ -19,11 +28,65 @@ export class BaseCommand extends Command {
|
|
|
19
28
|
static enableJsonFlag = true;
|
|
20
29
|
args;
|
|
21
30
|
flags;
|
|
31
|
+
// Command start, for the sidecar's duration_ms.
|
|
32
|
+
startedAtMs = 0;
|
|
22
33
|
// Centralized error rendering: redacted, machine {error:{code,message}} under
|
|
23
34
|
// --json/--ndjson, a human line otherwise. Replaces oclif's default reporter.
|
|
24
35
|
async catch(error) {
|
|
25
36
|
const machine = this.jsonEnabled() || Boolean(this.flags?.ndjson);
|
|
26
|
-
|
|
37
|
+
// Sidecar + run log see every failure (E11-6): structured for the
|
|
38
|
+
// supervising process, one line for the post-mortem trail. Message is
|
|
39
|
+
// redacted and capped so it can't tear a shared NDJSON line.
|
|
40
|
+
const classified = classifyError(error);
|
|
41
|
+
const message = redact(classified.message).slice(0, DISK_FIELD_MAX);
|
|
42
|
+
appendRunLog(`command-failed ${this.id ?? ''}: [${classified.code}] ${message}`);
|
|
43
|
+
writeOutputEntry({
|
|
44
|
+
command: this.id ?? '',
|
|
45
|
+
error_code: classified.code,
|
|
46
|
+
exit: classified.exitCode,
|
|
47
|
+
log_file_path: currentRunLogPath(),
|
|
48
|
+
message,
|
|
49
|
+
type: 'command-failed',
|
|
50
|
+
version: 1,
|
|
51
|
+
...(classified.details.retryAfterMs === undefined ? {} : { retry_after_ms: classified.details.retryAfterMs }),
|
|
52
|
+
});
|
|
53
|
+
const exitCode = renderError(error, { json: machine });
|
|
54
|
+
// The pointer to the full trace, human mode only (machine consumers get
|
|
55
|
+
// log_file_path in the sidecar entry instead — stderr stays quiet).
|
|
56
|
+
const logPath = currentRunLogPath();
|
|
57
|
+
if (!machine && logPath !== null)
|
|
58
|
+
process.stderr.write(`🪵 Logs were written to ${logPath}\n`);
|
|
59
|
+
this.exit(exitCode);
|
|
60
|
+
}
|
|
61
|
+
// Success bookkeeping for the sidecar. oclif calls finally() with undefined on
|
|
62
|
+
// the clean path (the failure entry is written in catch()), so only a success
|
|
63
|
+
// logs a result here.
|
|
64
|
+
async finally(error) {
|
|
65
|
+
if (error === undefined && this.startedAtMs > 0) {
|
|
66
|
+
writeOutputEntry({
|
|
67
|
+
command: this.id ?? '',
|
|
68
|
+
duration_ms: Date.now() - this.startedAtMs,
|
|
69
|
+
exit: 0,
|
|
70
|
+
type: 'result',
|
|
71
|
+
version: 1,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
await super.finally(error);
|
|
75
|
+
}
|
|
76
|
+
// A multi-line guidance BLOCK on stderr, human mode only (E11-3/E11-6
|
|
77
|
+
// review): shares note()'s redact + sanitize + machine-mode gating, but
|
|
78
|
+
// emits the text verbatim without the `→ ` prefix. This is the one writer
|
|
79
|
+
// for the command footers (catalog example, connections usage, login next),
|
|
80
|
+
// so mode gating no longer depends on each call sitting inside a human
|
|
81
|
+
// closure. Newlines are preserved (it is a block); each line is sanitized.
|
|
82
|
+
footer(text) {
|
|
83
|
+
if (this.jsonEnabled() || Boolean(this.flags?.ndjson))
|
|
84
|
+
return;
|
|
85
|
+
const safe = redact(text)
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map((line) => sanitizeTerminal(line))
|
|
88
|
+
.join('\n');
|
|
89
|
+
process.stderr.write(safe.endsWith('\n') ? safe : `${safe}\n`);
|
|
27
90
|
}
|
|
28
91
|
async init() {
|
|
29
92
|
await super.init();
|
|
@@ -41,6 +104,37 @@ export class BaseCommand extends Command {
|
|
|
41
104
|
if (this.jsonEnabled() && this.flags.ndjson) {
|
|
42
105
|
throw new UsageError('--json and --ndjson are mutually exclusive.');
|
|
43
106
|
}
|
|
107
|
+
// E11-6 wiring: --verbose mirrors the wire trace to stderr (the run log
|
|
108
|
+
// always gets it), and the sidecar records the session before any work.
|
|
109
|
+
// argv is REDACTED (a --body/flag can carry a token) and capped — the run
|
|
110
|
+
// log's own contract is "callers redact before appendRunLog".
|
|
111
|
+
this.startedAtMs = Date.now();
|
|
112
|
+
if (this.flags.verbose)
|
|
113
|
+
enableStderrTrace();
|
|
114
|
+
const safeArgv = redact([this.id ?? '', ...this.argv]).map((part) => part.slice(0, DISK_FIELD_MAX));
|
|
115
|
+
appendRunLog(`session ${this.config.version} contract=${CONTRACT_VERSION} argv=${JSON.stringify(safeArgv)}`);
|
|
116
|
+
writeOutputEntry({
|
|
117
|
+
argv: safeArgv,
|
|
118
|
+
cli_version: this.config.version,
|
|
119
|
+
contract_version: CONTRACT_VERSION,
|
|
120
|
+
log_file_path: currentRunLogPath(),
|
|
121
|
+
type: 'session',
|
|
122
|
+
version: 1,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// Context narration (E11-2, #101): resolved state and environmental inferences,
|
|
126
|
+
// one `→ ` line each, HUMAN MODE ONLY. Under --json/--ndjson these vanish
|
|
127
|
+
// entirely — stdout purity is untouchable and machine consumers branch on the
|
|
128
|
+
// envelope/sidecar, not prose. stderr, redacted, terminal-sanitized.
|
|
129
|
+
note(message) {
|
|
130
|
+
if (this.jsonEnabled() || Boolean(this.flags?.ndjson))
|
|
131
|
+
return;
|
|
132
|
+
// ONE line per note, enforced: a newline smuggled inside an interpolated
|
|
133
|
+
// value (a service-supplied connection name, a filter column) would
|
|
134
|
+
// otherwise forge additional arrow-prefixed lines — misleading context
|
|
135
|
+
// injected into the exact channel agents read (sec review, #109).
|
|
136
|
+
const line = sanitizeTerminal(redact(message)).replaceAll(/[\t\n\r]+/g, ' ');
|
|
137
|
+
process.stderr.write(`→ ${line}\n`);
|
|
44
138
|
}
|
|
45
139
|
// The resolved active host (flag > config.defaultHost > DEFAULT_HOST).
|
|
46
140
|
resolvedHost() {
|
|
@@ -70,6 +164,6 @@ export class BaseCommand extends Command {
|
|
|
70
164
|
// always redacted (never leaks a token).
|
|
71
165
|
verboseLog(message) {
|
|
72
166
|
if (this.flags.verbose)
|
|
73
|
-
process.stderr.write(`${redact(message)}\n`);
|
|
167
|
+
process.stderr.write(`${sanitizeTerminal(redact(message))}\n`);
|
|
74
168
|
}
|
|
75
169
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export declare const plannerFlags: {
|
|
2
|
+
'auth-host': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
3
|
+
url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
4
|
+
};
|
|
1
5
|
export declare const paginationFlags: {
|
|
2
6
|
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
3
7
|
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
|
+
// Shared flags for the commands that target the QUERY PLANNER (E8). `--url` is
|
|
3
|
+
// how you drive a locally-run planner; `--auth-host` names the issuer holding the
|
|
4
|
+
// credentials, which is a DIFFERENT origin from the planner itself.
|
|
5
|
+
export const plannerFlags = {
|
|
6
|
+
'auth-host': Flags.string({
|
|
7
|
+
description: 'OAuth issuer host for credentials/refresh (defaults to the prod issuer).',
|
|
8
|
+
}),
|
|
9
|
+
url: Flags.string({
|
|
10
|
+
description: 'Query-planner base URL (default: the prod planner; env FLIPSTREAM_PLANNER_URL).',
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
2
13
|
// Shared pagination/filter flags for admin list commands (E7-1b). `limit`
|
|
3
14
|
// defaults to 50 to force the {count, records} shape; `--all` drains every page.
|
|
4
15
|
export const paginationFlags = {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PlannerClient } from '../planner/client.js';
|
|
2
|
+
import { type TokenStore } from '../store/keyring.js';
|
|
3
|
+
export interface PlannerClientOptions {
|
|
4
|
+
authHost: string;
|
|
5
|
+
store: TokenStore;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
url: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function authedPlannerClient(options: PlannerClientOptions): PlannerClient;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { refresh } from '../auth/refresh.js';
|
|
2
|
+
import { createAuthedPlannerClient } from '../planner/client.js';
|
|
3
|
+
// Build the authed planner client for a command. Mirrors authedAdminClient, but
|
|
4
|
+
// keeps the two origins explicit — conflating them is how a refresh ends up
|
|
5
|
+
// pointed at a host that cannot issue anything.
|
|
6
|
+
export function authedPlannerClient(options) {
|
|
7
|
+
return createAuthedPlannerClient({
|
|
8
|
+
accessTokenIfFresh: (host) => options.store.accessTokenIfFresh(host),
|
|
9
|
+
authHost: options.authHost,
|
|
10
|
+
refresh: (host) => refresh(host, { store: options.store, timeoutMs: options.timeoutMs }),
|
|
11
|
+
timeoutMs: options.timeoutMs,
|
|
12
|
+
url: options.url,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const DEFAULT_HOST = "https://pulse.admin.api.flipstream.io";
|
|
2
2
|
export declare const DATA_HOST = "https://pulse.data.query.api.flipstream.io";
|
|
3
|
+
export declare const PLANNER_HOST = "https://query.flipstream.io";
|
|
3
4
|
export declare const ADMIN_HOST = "https://pulse.admin.api.flipstream.io";
|
|
5
|
+
export declare const PULSE_FE_HOST = "https://pulse.flipstream.io";
|
|
4
6
|
export declare const OAUTH_CLI_CLIENT_ID = "a975d9be-5ad6-44a5-8c1f-b95b995f3f70";
|
|
5
|
-
export declare const CONTRACT_VERSION = "
|
|
7
|
+
export declare const CONTRACT_VERSION = "3";
|
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
// canonical form; resolveHost() guarantees it.
|
|
3
3
|
export const DEFAULT_HOST = 'https://pulse.admin.api.flipstream.io';
|
|
4
4
|
// The data API host (the /query path is appended by the data client in E4).
|
|
5
|
+
// This is pulse-data, the raw SQL engine. As of E8 the CLI targets the QUERY
|
|
6
|
+
// PLANNER instead, which proxies to this host with the caller's own token.
|
|
5
7
|
export const DATA_HOST = 'https://pulse.data.query.api.flipstream.io';
|
|
8
|
+
// The query-planner host (E8) — resolves a LOGICAL query (source + dimensions +
|
|
9
|
+
// metrics + filters) into a physical request for pulse-data, asking data-model
|
|
10
|
+
// which table answers it. Serves GET /health, GET /catalog, GET /catalog/{source}
|
|
11
|
+
// and POST /query. It has no identity of its own: it verifies the caller's bearer
|
|
12
|
+
// and forwards that same token onward, so a query runs with the caller's
|
|
13
|
+
// authority and can never reach data they could not reach directly.
|
|
14
|
+
export const PLANNER_HOST = 'https://query.flipstream.io';
|
|
6
15
|
// The admin API host (E7). Today it is the SAME origin as the AUTH issuer
|
|
7
16
|
// (unified auth — the stored bearer is accepted here), so it aliases DEFAULT_HOST;
|
|
8
17
|
// kept as its own constant in case the admin API splits to a distinct origin later.
|
|
9
18
|
export const ADMIN_HOST = DEFAULT_HOST;
|
|
19
|
+
// The Pulse web app. The CLI never calls it — it only builds links INTO it, so a
|
|
20
|
+
// refusal can say where to go and not just what went wrong. The workspace segment
|
|
21
|
+
// is the client_id short-UUID encoded (see api/short-uuid.ts).
|
|
22
|
+
export const PULSE_FE_HOST = 'https://pulse.flipstream.io';
|
|
10
23
|
// The CLI's fixed first-party public OAuth client_id (RFC 8252 native app).
|
|
11
24
|
// NON-SECRET: a public client has no secret — PKCE (S256) is the security
|
|
12
25
|
// boundary — so this is safe to ship in source. Every login reuses this ONE
|
|
@@ -17,4 +30,4 @@ export const ADMIN_HOST = DEFAULT_HOST;
|
|
|
17
30
|
export const OAUTH_CLI_CLIENT_ID = 'a975d9be-5ad6-44a5-8c1f-b95b995f3f70';
|
|
18
31
|
// The agent-facing output/exit-code contract version. Bumped when the JSON shape,
|
|
19
32
|
// exit codes, or flags change in a way agents must notice. Surfaced by `version`.
|
|
20
|
-
export const CONTRACT_VERSION = '
|
|
33
|
+
export const CONTRACT_VERSION = '3';
|
package/dist/lib/config/xdg.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface HostConfig {
|
|
|
7
7
|
export interface Config {
|
|
8
8
|
defaultHost?: string;
|
|
9
9
|
hosts?: Record<string, HostConfig>;
|
|
10
|
+
plannerUrl?: string;
|
|
10
11
|
}
|
|
11
12
|
export declare function configDir(): string;
|
|
12
13
|
export declare function configPath(): string;
|
|
@@ -19,3 +20,6 @@ export declare function removeConfigDir(): boolean;
|
|
|
19
20
|
export declare function resolveHost(options?: {
|
|
20
21
|
hostFlag?: string;
|
|
21
22
|
}): string;
|
|
23
|
+
export declare function resolvePlannerUrl(options?: {
|
|
24
|
+
urlFlag?: string;
|
|
25
|
+
}): string;
|
package/dist/lib/config/xdg.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { UsageError } from '../errors.js';
|
|
5
|
+
import { DEFAULT_HOST, PLANNER_HOST } from './constants.js';
|
|
5
6
|
// Defense-in-depth: any key that looks like a credential is stripped before a
|
|
6
7
|
// write, so a token can never be persisted to the (non-secret) config file.
|
|
7
8
|
const SECRET_KEY_PATTERN = /token|secret|verifier|code|authorization/i;
|
|
@@ -95,3 +96,57 @@ export function resolveHost(options = {}) {
|
|
|
95
96
|
const raw = options.hostFlag ?? readConfig().defaultHost ?? DEFAULT_HOST;
|
|
96
97
|
return raw.replace(/\/+$/, '');
|
|
97
98
|
}
|
|
99
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
100
|
+
const TRUSTED_SUFFIX = '.flipstream.io';
|
|
101
|
+
// Is this a host we may present the user's bearer token to?
|
|
102
|
+
//
|
|
103
|
+
// The planner URL is NOT like --host. Pointing --host somewhere else is harmless
|
|
104
|
+
// by accident: credentials are loaded FOR that host, so a wrong one simply has
|
|
105
|
+
// none and the command exits 4 before anything is sent. The planner URL is
|
|
106
|
+
// independent of the issuer — the token comes from the real auth host and is then
|
|
107
|
+
// sent wherever this resolves to. So `--url http://attacker.example` would hand a
|
|
108
|
+
// production token to an attacker, and `http://` would put it on the wire in
|
|
109
|
+
// cleartext.
|
|
110
|
+
//
|
|
111
|
+
// Allowed: loopback on any scheme (your own machine, which is where a locally-run
|
|
112
|
+
// planner lives), and HTTPS on flipstream.io. Everything else must be opted into
|
|
113
|
+
// explicitly, because the cost of being wrong here is a leaked credential.
|
|
114
|
+
function isTrustedPlannerHost(raw) {
|
|
115
|
+
let url;
|
|
116
|
+
try {
|
|
117
|
+
url = new URL(raw);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const host = url.hostname.toLowerCase();
|
|
123
|
+
if (LOOPBACK.has(host))
|
|
124
|
+
return true;
|
|
125
|
+
if (url.protocol !== 'https:')
|
|
126
|
+
return false;
|
|
127
|
+
return host === 'flipstream.io' || host.endsWith(TRUSTED_SUFFIX);
|
|
128
|
+
}
|
|
129
|
+
// Resolve the query-planner base URL: --url flag > FLIPSTREAM_PLANNER_URL >
|
|
130
|
+
// config.plannerUrl > PLANNER_HOST. The env override is what lets you point at a
|
|
131
|
+
// locally-run planner (`uvicorn … --port 8080`) without editing config, mirroring
|
|
132
|
+
// QUERY_PLANNER_URL in the planner repo's own reference client.
|
|
133
|
+
//
|
|
134
|
+
// Every source is checked, not just the flag: an env var or a stale config entry
|
|
135
|
+
// sends the token just as effectively as a typed flag, and the config file is the
|
|
136
|
+
// one a caller is least likely to look at.
|
|
137
|
+
export function resolvePlannerUrl(options = {}) {
|
|
138
|
+
const env = process.env.FLIPSTREAM_PLANNER_URL?.trim();
|
|
139
|
+
const raw = options.urlFlag ?? (env && env.length > 0 ? env : undefined) ?? readConfig().plannerUrl ?? PLANNER_HOST;
|
|
140
|
+
const url = raw.replace(/\/+$/, '');
|
|
141
|
+
// The escape hatch is deliberately awkward and deliberately named: a self-hosted
|
|
142
|
+
// planner is a real case, but it should be a decision someone made on purpose,
|
|
143
|
+
// not something a generated command line falls into.
|
|
144
|
+
if (process.env.FLIPSTREAM_ALLOW_ANY_PLANNER === '1')
|
|
145
|
+
return url;
|
|
146
|
+
if (!isTrustedPlannerHost(url)) {
|
|
147
|
+
throw new UsageError(`Refusing to send your access token to '${url}'. The planner URL must be HTTPS on ` +
|
|
148
|
+
'flipstream.io, or a loopback address for local development. Set ' +
|
|
149
|
+
'FLIPSTREAM_ALLOW_ANY_PLANNER=1 to override this deliberately.', 'untrusted_planner_url');
|
|
150
|
+
}
|
|
151
|
+
return url;
|
|
152
|
+
}
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
export interface CliErrorDetails {
|
|
2
|
+
docs?: string;
|
|
3
|
+
hint?: string;
|
|
4
|
+
next?: string[];
|
|
5
|
+
retryable?: boolean;
|
|
6
|
+
retryAfterMs?: number;
|
|
7
|
+
upstreamCode?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function retryPolicy(retryAfterMs?: number): CliErrorDetails;
|
|
1
10
|
export declare class CliError extends Error {
|
|
11
|
+
#private;
|
|
2
12
|
readonly code: string;
|
|
3
13
|
readonly exitCode: number;
|
|
4
14
|
constructor(message: string, code: string, exitCode: number);
|
|
15
|
+
get details(): Readonly<CliErrorDetails>;
|
|
16
|
+
withDetails(details: CliErrorDetails): this;
|
|
5
17
|
}
|
|
6
18
|
export declare class AuthRequiredError extends CliError {
|
|
7
19
|
constructor(message?: string, code?: 'not_logged_in' | 'session_expired');
|
|
@@ -29,10 +41,17 @@ export declare class LoopbackError extends CliError {
|
|
|
29
41
|
}
|
|
30
42
|
export declare class DataHttpError extends CliError {
|
|
31
43
|
readonly bodyText: string;
|
|
44
|
+
readonly retryAfterMs: number | undefined;
|
|
32
45
|
readonly status: number;
|
|
33
|
-
constructor(status: number, bodyText: string);
|
|
46
|
+
constructor(status: number, bodyText: string, retryAfterMs?: number);
|
|
34
47
|
}
|
|
35
48
|
export declare function toExitCode(error: unknown): number;
|
|
49
|
+
export declare function classifyError(error: unknown): {
|
|
50
|
+
code: string;
|
|
51
|
+
details: CliErrorDetails;
|
|
52
|
+
exitCode: number;
|
|
53
|
+
message: string;
|
|
54
|
+
};
|
|
36
55
|
export declare function renderError(error: unknown, options?: {
|
|
37
56
|
json?: boolean;
|
|
38
57
|
}): number;
|