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
package/dist/lib/api/http.js
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import { DataHttpError, NetworkError, TimeoutError } from '../errors.js';
|
|
2
|
+
import { traceFailure, traceRequest, traceResponse } from '../output/trace.js';
|
|
2
3
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
// Ceiling for a server-stated retry wait. A hostile or misconfigured header
|
|
5
|
+
// must never park an agent for hours ("Retry-After: 999999999" is ~31 years);
|
|
6
|
+
// anything above this clamps down, and the contract documents the bound.
|
|
7
|
+
export const MAX_RETRY_AFTER_MS = 3_600_000;
|
|
8
|
+
// Retry-After is either delta-seconds (1*DIGIT — parsed strictly, so no hex,
|
|
9
|
+
// exponents, fractions or signs) or an HTTP-date (RFC 9110 §10.2.3). Returns
|
|
10
|
+
// milliseconds clamped to [0, MAX_RETRY_AFTER_MS], or undefined when
|
|
11
|
+
// absent/unparseable — never a guess, because a wrong wait is worse than none.
|
|
12
|
+
export function parseRetryAfterMs(header) {
|
|
13
|
+
if (header === null)
|
|
14
|
+
return undefined;
|
|
15
|
+
const trimmed = header.trim();
|
|
16
|
+
if (/^\d{1,10}$/.test(trimmed))
|
|
17
|
+
return Math.min(MAX_RETRY_AFTER_MS, Number(trimmed) * 1000);
|
|
18
|
+
// An HTTP-date always carries letters (day/month names, GMT); refusing the
|
|
19
|
+
// rest keeps Date.parse's lenient number handling ('1.5', '0x10') out.
|
|
20
|
+
if (!/[A-Za-z]/.test(trimmed))
|
|
21
|
+
return undefined;
|
|
22
|
+
const dateMs = Date.parse(trimmed);
|
|
23
|
+
if (Number.isNaN(dateMs))
|
|
24
|
+
return undefined;
|
|
25
|
+
return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, dateMs - Date.now()));
|
|
26
|
+
}
|
|
3
27
|
function withQuery(url, query) {
|
|
4
28
|
if (!query)
|
|
5
29
|
return url;
|
|
@@ -24,11 +48,18 @@ export async function requestJson(req) {
|
|
|
24
48
|
headers['content-type'] = 'application/json';
|
|
25
49
|
if (req.token)
|
|
26
50
|
headers.authorization = `Bearer ${req.token}`;
|
|
51
|
+
// Wire tracing (E11-6): emitted AS IT HAPPENS, so the failure path carries
|
|
52
|
+
// diagnostics too (#92) — headers are never traced, bodies only via the
|
|
53
|
+
// explicit sanitize escape hatch. Only first-party API calls route through
|
|
54
|
+
// here; the token-bearing OAuth flows use fetch() directly (lint-enforced).
|
|
55
|
+
const fullUrl = withQuery(req.url, req.query);
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
traceRequest(req.method, fullUrl, req.body);
|
|
27
58
|
const controller = new AbortController();
|
|
28
59
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
60
|
let response;
|
|
30
61
|
try {
|
|
31
|
-
response = await fetch(
|
|
62
|
+
response = await fetch(fullUrl, {
|
|
32
63
|
body: req.body === undefined ? undefined : JSON.stringify(req.body),
|
|
33
64
|
headers,
|
|
34
65
|
method: req.method,
|
|
@@ -36,16 +67,21 @@ export async function requestJson(req) {
|
|
|
36
67
|
});
|
|
37
68
|
}
|
|
38
69
|
catch (error) {
|
|
39
|
-
if (controller.signal.aborted)
|
|
70
|
+
if (controller.signal.aborted) {
|
|
71
|
+
traceFailure(req.method, fullUrl, `timeout after ${timeoutMs}ms`, Date.now() - started);
|
|
40
72
|
throw new TimeoutError(`Request timed out after ${timeoutMs}ms`);
|
|
73
|
+
}
|
|
74
|
+
traceFailure(req.method, fullUrl, error.message, Date.now() - started);
|
|
41
75
|
throw new NetworkError(`Request failed: ${error.message}`);
|
|
42
76
|
}
|
|
43
77
|
finally {
|
|
44
78
|
clearTimeout(timer);
|
|
45
79
|
}
|
|
46
80
|
const text = await response.text();
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
traceResponse({ bodyText: text, method: req.method, ms: Date.now() - started, status: response.status, url: fullUrl });
|
|
82
|
+
if (response.status < 200 || response.status >= 300) {
|
|
83
|
+
throw new DataHttpError(response.status, text, parseRetryAfterMs(response.headers.get('retry-after')));
|
|
84
|
+
}
|
|
49
85
|
try {
|
|
50
86
|
return JSON.parse(text);
|
|
51
87
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type AdminClient } from './admin-client.js';
|
|
2
|
+
export interface HydrateSpec {
|
|
3
|
+
endpoint: string;
|
|
4
|
+
idField: string;
|
|
5
|
+
label: (entity: Record<string, unknown>) => unknown;
|
|
6
|
+
labelField: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const WORKSPACE_NAME: HydrateSpec;
|
|
9
|
+
export declare const CREATOR_EMAIL: HydrateSpec;
|
|
10
|
+
export declare function hydrate(client: AdminClient, records: Array<Record<string, unknown>>, specs: HydrateSpec[]): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { drainPages, normalizeList } from './list.js';
|
|
2
|
+
// The shared workspace-name and author-email specs (used across list commands).
|
|
3
|
+
export const WORKSPACE_NAME = {
|
|
4
|
+
endpoint: '/clients',
|
|
5
|
+
idField: 'client_id',
|
|
6
|
+
label: (entity) => entity.name,
|
|
7
|
+
labelField: 'client_name',
|
|
8
|
+
};
|
|
9
|
+
export const CREATOR_EMAIL = {
|
|
10
|
+
endpoint: '/users',
|
|
11
|
+
idField: 'created_by_id',
|
|
12
|
+
label: (entity) => entity.email ?? entity.name,
|
|
13
|
+
labelField: 'created_by_email',
|
|
14
|
+
};
|
|
15
|
+
async function hydrateOne(client, records, spec) {
|
|
16
|
+
const needs = (record) => record[spec.labelField] === undefined &&
|
|
17
|
+
typeof record[spec.idField] === 'string' &&
|
|
18
|
+
record[spec.idField].length > 0;
|
|
19
|
+
if (!records.some((record) => needs(record)))
|
|
20
|
+
return;
|
|
21
|
+
const byId = new Map();
|
|
22
|
+
try {
|
|
23
|
+
const entities = await drainPages(async (page) => normalizeList(await client.get(spec.endpoint, { query: { limit: page.limit, offset: page.offset } })), { limit: 100, offset: 0 });
|
|
24
|
+
for (const raw of entities.records) {
|
|
25
|
+
const entity = (raw ?? {});
|
|
26
|
+
if (typeof entity.id === 'string') {
|
|
27
|
+
const label = spec.label(entity);
|
|
28
|
+
byId.set(entity.id, typeof label === 'string' ? label : '');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return; // best-effort: leave the label unresolved rather than fail the list
|
|
34
|
+
}
|
|
35
|
+
for (const record of records) {
|
|
36
|
+
if (needs(record))
|
|
37
|
+
record[spec.labelField] = byId.get(record[spec.idField]) ?? null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Hydrate a record set against one or more specs, running each lookup CONCURRENTLY
|
|
41
|
+
// and ONCE. Each spec is skipped when no record needs it, and is best-effort: a
|
|
42
|
+
// forbidden/unavailable lookup leaves its label unresolved rather than failing.
|
|
43
|
+
// Mutates `records` in place.
|
|
44
|
+
export async function hydrate(client, records, specs) {
|
|
45
|
+
await Promise.all(specs.map((spec) => hydrateOne(client, records, spec)));
|
|
46
|
+
}
|
package/dist/lib/api/ids.d.ts
CHANGED
package/dist/lib/api/ids.js
CHANGED
|
@@ -6,3 +6,8 @@ export function assertUuid(id, label = 'id') {
|
|
|
6
6
|
if (!UUID_RE.test(id))
|
|
7
7
|
throw new UsageError(`Invalid ${label} — expected a UUID.`);
|
|
8
8
|
}
|
|
9
|
+
// Is this already a UUID? Used where a flag accepts EITHER a UUID or a name, so
|
|
10
|
+
// only the non-UUID case pays for a lookup.
|
|
11
|
+
export function isUuid(value) {
|
|
12
|
+
return UUID_RE.test(value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AdminClient } from './admin-client.js';
|
|
2
|
+
export interface LogQuery {
|
|
3
|
+
all?: boolean;
|
|
4
|
+
limit: number;
|
|
5
|
+
offset: number;
|
|
6
|
+
q?: string;
|
|
7
|
+
sort?: string;
|
|
8
|
+
workspace: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function fetchLog(client: AdminClient, query: LogQuery): Promise<{
|
|
11
|
+
count: number;
|
|
12
|
+
records: Array<Record<string, unknown>>;
|
|
13
|
+
}>;
|
|
14
|
+
export interface NewLogEntry {
|
|
15
|
+
clientId: string;
|
|
16
|
+
description: string;
|
|
17
|
+
endDate: string;
|
|
18
|
+
organizationId: string;
|
|
19
|
+
startDate: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function addLogEntry(client: AdminClient, entry: NewLogEntry): Promise<Record<string, unknown>>;
|
|
22
|
+
export declare function resolveWorkspaceOrg(client: AdminClient, workspaceId: string): Promise<string>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DataHttpError } from '../errors.js';
|
|
2
|
+
import { mapDataError, notFoundError } from './errors.js';
|
|
3
|
+
import { drainPages, normalizeList } from './list.js';
|
|
4
|
+
import { projectLogEntry, projectWorkspace } from './projections.js';
|
|
5
|
+
// Fetch + project a workspace's log (event-log) entries via the per-client
|
|
6
|
+
// endpoint, draining pages when requested. Errors are mapped to the E3 model.
|
|
7
|
+
export async function fetchLog(client, query) {
|
|
8
|
+
const path = `/clients/${query.workspace}/event-logs`;
|
|
9
|
+
const fetchPage = async (page) => {
|
|
10
|
+
try {
|
|
11
|
+
return normalizeList(await client.get(path, { query: { limit: page.limit, offset: page.offset, q: query.q, sort: query.sort } }));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw mapDataError(error);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const start = { limit: query.limit, offset: query.offset };
|
|
18
|
+
const envelope = query.all ? await drainPages(fetchPage, start) : await fetchPage(start);
|
|
19
|
+
return { count: envelope.count, records: envelope.records.map((record) => projectLogEntry(record)) };
|
|
20
|
+
}
|
|
21
|
+
// Add a log entry: POST /event-logs (the workspace + org go in the body). The
|
|
22
|
+
// body is a structured builder, so future fields (e.g. tags) drop in here.
|
|
23
|
+
// Returns the projected created entry. Errors are mapped to the E3 model.
|
|
24
|
+
export async function addLogEntry(client, entry) {
|
|
25
|
+
const body = {
|
|
26
|
+
client_id: entry.clientId,
|
|
27
|
+
description: entry.description,
|
|
28
|
+
end_date: entry.endDate,
|
|
29
|
+
organization_id: entry.organizationId,
|
|
30
|
+
start_date: entry.startDate,
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
return projectLogEntry(await client.post('/event-logs', { body }));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw mapDataError(error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Derive a workspace's organization_id (required to create an entry) via one
|
|
40
|
+
// workspace lookup. A foreign or missing id collapses to a single not-found
|
|
41
|
+
// message (no existence leak), mirroring `ws get`.
|
|
42
|
+
export async function resolveWorkspaceOrg(client, workspaceId) {
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = await client.get(`/clients/${workspaceId}`);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof DataHttpError && (error.status === 403 || error.status === 404))
|
|
49
|
+
throw notFoundError();
|
|
50
|
+
throw mapDataError(error);
|
|
51
|
+
}
|
|
52
|
+
const org = projectWorkspace(raw).organization_id;
|
|
53
|
+
if (typeof org !== 'string' || org.length === 0)
|
|
54
|
+
throw notFoundError();
|
|
55
|
+
return org;
|
|
56
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { redact } from '../output/redact.js';
|
|
2
2
|
const WORKSPACE_CORE = ['organization_id', 'slug', 'business_type', 'client_main_goal', 'created_on'];
|
|
3
3
|
const CONNECTION_CORE = ['name', 'client_id', 'type', 'active', 'backfill', 'data_refresh_status', 'created_on'];
|
|
4
|
+
const LOG_CORE = ['description', 'start_date', 'end_date', 'client_id', 'organization_id', 'created_on', 'created_by_id'];
|
|
4
5
|
// Project a raw admin Client into a stable "workspace" shape: id + name + present
|
|
5
6
|
// core fields + owner_email (from the nested owner) + a redacted `raw` passthrough
|
|
6
7
|
// so it survives schema drift. Adds/invents no fields.
|
|
@@ -32,3 +33,25 @@ export function projectConnection(raw) {
|
|
|
32
33
|
out.raw = redact(raw);
|
|
33
34
|
return out;
|
|
34
35
|
}
|
|
36
|
+
// Project a raw EventLog (the "log" / logbook entry) into a stable shape: id +
|
|
37
|
+
// present core fields + the workspace name (nested `client`) and author (nested
|
|
38
|
+
// `created_by`) + a redacted `raw` passthrough. New API fields (e.g. `tags`)
|
|
39
|
+
// flow through `raw` automatically under --json; surface them by adding to
|
|
40
|
+
// LOG_CORE + a table column when they ship.
|
|
41
|
+
export function projectLogEntry(raw) {
|
|
42
|
+
const entry = (raw ?? {});
|
|
43
|
+
const out = { id: entry.id };
|
|
44
|
+
for (const key of LOG_CORE)
|
|
45
|
+
if (entry[key] !== undefined)
|
|
46
|
+
out[key] = entry[key];
|
|
47
|
+
const client = entry.client;
|
|
48
|
+
if (client?.name)
|
|
49
|
+
out.client_name = client.name;
|
|
50
|
+
const by = entry.created_by;
|
|
51
|
+
if (by?.name)
|
|
52
|
+
out.created_by_name = by.name;
|
|
53
|
+
if (by?.email)
|
|
54
|
+
out.created_by_email = by.email;
|
|
55
|
+
out.raw = redact(raw);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uuidToShort(uuid: string): string;
|
|
@@ -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/claims.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// MUST NOT be used for any authorization / request-gating decision (the CLI is a
|
|
3
3
|
// thin client; the server validates tokens). A convention test guards this.
|
|
4
4
|
export function decodeJwtClaimsUnverified(accessToken) {
|
|
5
|
-
const
|
|
6
|
-
if (
|
|
5
|
+
const [, payload] = accessToken.split('.');
|
|
6
|
+
if (payload === undefined)
|
|
7
7
|
return null;
|
|
8
8
|
try {
|
|
9
|
-
const padded =
|
|
9
|
+
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
|
|
10
10
|
return JSON.parse(Buffer.from(padded, 'base64url').toString('utf8'));
|
|
11
11
|
}
|
|
12
12
|
catch {
|
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)
|
|
@@ -2,3 +2,4 @@ import { type AdminClient } from '../api/admin-client.js';
|
|
|
2
2
|
import { type TokenStore } from '../store/keyring.js';
|
|
3
3
|
export declare function authedAdminClient(host: string, store: TokenStore, timeoutMs?: number): AdminClient;
|
|
4
4
|
export declare function renderConnectionsTable(records: Array<Record<string, unknown>>): void;
|
|
5
|
+
export declare function renderLogEntriesTable(records: Array<Record<string, unknown>>): void;
|
|
@@ -28,3 +28,24 @@ export function renderConnectionsTable(records) {
|
|
|
28
28
|
{ key: 'id', name: 'ID' },
|
|
29
29
|
], 'No connections.');
|
|
30
30
|
}
|
|
31
|
+
// The log (event-log) human table — shared by `log list`. Date prefers
|
|
32
|
+
// created_on (falls back to start_date); By prefers the author name then email;
|
|
33
|
+
// Workspace falls back to the id when its name couldn't be resolved.
|
|
34
|
+
// First non-empty string among the candidates, else an em-dash placeholder.
|
|
35
|
+
function firstLabel(...values) {
|
|
36
|
+
return values.find((value) => typeof value === 'string' && value.length > 0) ?? '—';
|
|
37
|
+
}
|
|
38
|
+
export function renderLogEntriesTable(records) {
|
|
39
|
+
const rows = records.map((record) => ({
|
|
40
|
+
...record,
|
|
41
|
+
by: firstLabel(record.created_by_name, record.created_by_email, record.created_by_id),
|
|
42
|
+
client_name: firstLabel(record.client_name, record.client_id),
|
|
43
|
+
when: firstLabel(record.created_on, record.start_date),
|
|
44
|
+
}));
|
|
45
|
+
renderTable(rows, [
|
|
46
|
+
{ key: 'when', name: 'Date' },
|
|
47
|
+
{ key: 'client_name', name: 'Workspace' },
|
|
48
|
+
{ key: 'description', name: 'Entry' },
|
|
49
|
+
{ key: 'by', name: 'By' },
|
|
50
|
+
], 'No log entries.');
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|