flipstream 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +659 -0
  3. package/bin/dev.js +9 -0
  4. package/bin/run.js +7 -0
  5. package/dist/commands/auth/clear-all.d.ts +10 -0
  6. package/dist/commands/auth/clear-all.js +50 -0
  7. package/dist/commands/auth/login.d.ts +11 -0
  8. package/dist/commands/auth/login.js +49 -0
  9. package/dist/commands/auth/logout.d.ts +10 -0
  10. package/dist/commands/auth/logout.js +51 -0
  11. package/dist/commands/auth/status.d.ts +8 -0
  12. package/dist/commands/auth/status.js +47 -0
  13. package/dist/commands/connections/list.d.ts +19 -0
  14. package/dist/commands/connections/list.js +43 -0
  15. package/dist/commands/query.d.ts +13 -0
  16. package/dist/commands/query.js +76 -0
  17. package/dist/commands/version.d.ts +15 -0
  18. package/dist/commands/version.js +17 -0
  19. package/dist/commands/workspaces/connections.d.ts +21 -0
  20. package/dist/commands/workspaces/connections.js +41 -0
  21. package/dist/commands/workspaces/get.d.ts +15 -0
  22. package/dist/commands/workspaces/get.js +65 -0
  23. package/dist/commands/workspaces/list.d.ts +20 -0
  24. package/dist/commands/workspaces/list.js +87 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/lib/api/admin-client.d.ts +19 -0
  28. package/dist/lib/api/admin-client.js +44 -0
  29. package/dist/lib/api/client.d.ts +13 -0
  30. package/dist/lib/api/client.js +21 -0
  31. package/dist/lib/api/connections.d.ts +14 -0
  32. package/dist/lib/api/connections.js +44 -0
  33. package/dist/lib/api/errors.d.ts +7 -0
  34. package/dist/lib/api/errors.js +68 -0
  35. package/dist/lib/api/http.d.ts +9 -0
  36. package/dist/lib/api/http.js +55 -0
  37. package/dist/lib/api/ids.d.ts +1 -0
  38. package/dist/lib/api/ids.js +8 -0
  39. package/dist/lib/api/list.d.ts +12 -0
  40. package/dist/lib/api/list.js +31 -0
  41. package/dist/lib/api/projections.d.ts +2 -0
  42. package/dist/lib/api/projections.js +34 -0
  43. package/dist/lib/api/retry.d.ts +13 -0
  44. package/dist/lib/api/retry.js +37 -0
  45. package/dist/lib/auth/claims.d.ts +3 -0
  46. package/dist/lib/auth/claims.js +20 -0
  47. package/dist/lib/auth/discovery.d.ts +13 -0
  48. package/dist/lib/auth/discovery.js +62 -0
  49. package/dist/lib/auth/flow.d.ts +22 -0
  50. package/dist/lib/auth/flow.js +135 -0
  51. package/dist/lib/auth/headless.d.ts +12 -0
  52. package/dist/lib/auth/headless.js +61 -0
  53. package/dist/lib/auth/loopback.d.ts +15 -0
  54. package/dist/lib/auth/loopback.js +198 -0
  55. package/dist/lib/auth/pkce.d.ts +6 -0
  56. package/dist/lib/auth/pkce.js +17 -0
  57. package/dist/lib/auth/provider.d.ts +15 -0
  58. package/dist/lib/auth/provider.js +23 -0
  59. package/dist/lib/auth/refresh.d.ts +6 -0
  60. package/dist/lib/auth/refresh.js +69 -0
  61. package/dist/lib/auth/register.d.ts +4 -0
  62. package/dist/lib/auth/register.js +43 -0
  63. package/dist/lib/auth/revoke.d.ts +8 -0
  64. package/dist/lib/auth/revoke.js +28 -0
  65. package/dist/lib/auth/session.d.ts +6 -0
  66. package/dist/lib/auth/session.js +13 -0
  67. package/dist/lib/command/admin.d.ts +4 -0
  68. package/dist/lib/command/admin.js +30 -0
  69. package/dist/lib/command/base.d.ts +28 -0
  70. package/dist/lib/command/base.js +75 -0
  71. package/dist/lib/command/crash.d.ts +2 -0
  72. package/dist/lib/command/crash.js +37 -0
  73. package/dist/lib/command/flags.d.ts +7 -0
  74. package/dist/lib/command/flags.js +10 -0
  75. package/dist/lib/config/constants.d.ts +5 -0
  76. package/dist/lib/config/constants.js +20 -0
  77. package/dist/lib/config/xdg.d.ts +21 -0
  78. package/dist/lib/config/xdg.js +97 -0
  79. package/dist/lib/errors.d.ts +38 -0
  80. package/dist/lib/errors.js +122 -0
  81. package/dist/lib/exit-codes.d.ts +10 -0
  82. package/dist/lib/exit-codes.js +13 -0
  83. package/dist/lib/help.d.ts +4 -0
  84. package/dist/lib/help.js +14 -0
  85. package/dist/lib/output/ndjson.d.ts +1 -0
  86. package/dist/lib/output/ndjson.js +16 -0
  87. package/dist/lib/output/redact.d.ts +1 -0
  88. package/dist/lib/output/redact.js +47 -0
  89. package/dist/lib/output/render.d.ts +2 -0
  90. package/dist/lib/output/render.js +8 -0
  91. package/dist/lib/output/table.d.ts +3 -0
  92. package/dist/lib/output/table.js +19 -0
  93. package/dist/lib/store/credentials.d.ts +12 -0
  94. package/dist/lib/store/credentials.js +40 -0
  95. package/dist/lib/store/index.d.ts +5 -0
  96. package/dist/lib/store/index.js +21 -0
  97. package/dist/lib/store/keyring.d.ts +16 -0
  98. package/dist/lib/store/keyring.js +45 -0
  99. package/dist/lib/store/memory-store.d.ts +10 -0
  100. package/dist/lib/store/memory-store.js +21 -0
  101. package/oclif.manifest.json +851 -0
  102. package/package.json +79 -0
@@ -0,0 +1,8 @@
1
+ export interface RevokeParams {
2
+ clientId: string;
3
+ revocationEndpoint: string;
4
+ timeoutMs?: number;
5
+ token: string;
6
+ tokenTypeHint?: 'access_token' | 'refresh_token';
7
+ }
8
+ export declare function revokeToken(params: RevokeParams): Promise<boolean>;
@@ -0,0 +1,28 @@
1
+ // Best-effort RFC 7009 token revocation. Revoking the refresh token invalidates
2
+ // its associated access tokens server-side. Returns whether the server confirmed
3
+ // the revocation (HTTP 2xx) — never throws, so logout can always proceed to clear
4
+ // local credentials regardless of network/server state.
5
+ export async function revokeToken(params) {
6
+ const body = new URLSearchParams({ client_id: params.clientId, token: params.token });
7
+ if (params.tokenTypeHint)
8
+ body.set('token_type_hint', params.tokenTypeHint);
9
+ const controller = new AbortController();
10
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 10_000);
11
+ try {
12
+ const response = await fetch(params.revocationEndpoint, {
13
+ body: body.toString(),
14
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
15
+ method: 'POST',
16
+ signal: controller.signal,
17
+ });
18
+ // RFC 7009: the server returns 200 for both a successful revocation and an
19
+ // already-invalid/unknown token, so 2xx is the success signal either way.
20
+ return response.ok;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
@@ -0,0 +1,6 @@
1
+ import type { TokenStore } from '../store/keyring.js';
2
+ export interface SessionOptions {
3
+ store?: TokenStore;
4
+ timeoutMs?: number;
5
+ }
6
+ export declare function getFreshAccessToken(host: string, options?: SessionOptions): Promise<null | string>;
@@ -0,0 +1,13 @@
1
+ import { createStore } from '../store/index.js';
2
+ import { refresh } from './refresh.js';
3
+ // The access token a caller should use for host: the cached token when still
4
+ // fresh (no HTTP), otherwise a transparently silent-refreshed one. Returns null
5
+ // when not logged in or the refresh failed. This is the single seam the data
6
+ // client (E4) uses to obtain a bearer token.
7
+ export async function getFreshAccessToken(host, options = {}) {
8
+ const store = options.store ?? createStore();
9
+ const cached = store.accessTokenIfFresh(host);
10
+ if (cached)
11
+ return cached;
12
+ return refresh(host, { store, timeoutMs: options.timeoutMs });
13
+ }
@@ -0,0 +1,4 @@
1
+ import { type AdminClient } from '../api/admin-client.js';
2
+ import { type TokenStore } from '../store/keyring.js';
3
+ export declare function authedAdminClient(host: string, store: TokenStore, timeoutMs?: number): AdminClient;
4
+ export declare function renderConnectionsTable(records: Array<Record<string, unknown>>): void;
@@ -0,0 +1,30 @@
1
+ import { createAuthedAdminClient } from '../api/admin-client.js';
2
+ import { refresh } from '../auth/refresh.js';
3
+ import { renderTable } from '../output/table.js';
4
+ // Build the authed admin client for a command. The admin host == the OAuth issuer
5
+ // (where creds live), so one host serves both creds/refresh and the admin calls;
6
+ // reuses the single withFreshToken 401->refresh->retry wrapper.
7
+ export function authedAdminClient(host, store, timeoutMs) {
8
+ return createAuthedAdminClient({
9
+ accessTokenIfFresh: (authHost) => store.accessTokenIfFresh(authHost),
10
+ authHost: host,
11
+ host,
12
+ refresh: (authHost) => refresh(authHost, { store, timeoutMs }),
13
+ timeoutMs,
14
+ });
15
+ }
16
+ // The connections human table — shared by `connections list` and `ws connections`.
17
+ // Falls back to the workspace id when its name couldn't be resolved.
18
+ export function renderConnectionsTable(records) {
19
+ const rows = records.map((record) => ({
20
+ ...record,
21
+ client_name: record.client_name ?? record.client_id ?? '—',
22
+ }));
23
+ renderTable(rows, [
24
+ { key: 'client_name', name: 'Workspace' },
25
+ { key: 'name', name: 'Connection' },
26
+ { key: 'type', name: 'Type' },
27
+ { key: 'active', name: 'Active' },
28
+ { key: 'id', name: 'ID' },
29
+ ], 'No connections.');
30
+ }
@@ -0,0 +1,28 @@
1
+ import { Command, type Interfaces } from '@oclif/core';
2
+ export type BaseFlags<T extends typeof Command> = Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & T['flags']>;
3
+ export type BaseArgs<T extends typeof Command> = Interfaces.InferredArgs<T['args']>;
4
+ export declare abstract class BaseCommand<T extends typeof Command> extends Command {
5
+ static baseFlags: {
6
+ host: Interfaces.OptionFlag<string | undefined, Interfaces.CustomOptions>;
7
+ ndjson: Interfaces.BooleanFlag<boolean>;
8
+ timeout: Interfaces.OptionFlag<number | undefined, Interfaces.CustomOptions>;
9
+ verbose: Interfaces.BooleanFlag<boolean>;
10
+ };
11
+ static enableJsonFlag: boolean;
12
+ protected args: BaseArgs<T>;
13
+ protected flags: BaseFlags<T>;
14
+ protected catch(error: Error & {
15
+ exitCode?: number;
16
+ }): Promise<never>;
17
+ init(): Promise<void>;
18
+ protected resolvedHost(): string;
19
+ protected respond<D>(data: D, human: (data: D) => void): D;
20
+ protected respondList<R>(envelope: {
21
+ count: number;
22
+ records: R[];
23
+ }, human: (records: R[]) => void): {
24
+ count: number;
25
+ records: R[];
26
+ };
27
+ protected verboseLog(message: string): void;
28
+ }
@@ -0,0 +1,75 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { resolveHost } from '../config/xdg.js';
3
+ import { renderError, UsageError } from '../errors.js';
4
+ import { renderNdjson } from '../output/ndjson.js';
5
+ import { redact } from '../output/redact.js';
6
+ // Repo-wide clig.dev conventions in one place (E3-5). Every command extends this:
7
+ // - stdout = data, stderr = help/prompts/progress/diagnostics
8
+ // - --json (machine JSON, oclif-serialized) / --ndjson (one object per line),
9
+ // mutually exclusive
10
+ // - shared --host/-H, --verbose/--debug (redacted, stderr-only), --timeout
11
+ // - errors routed through the redacting renderError
12
+ export class BaseCommand extends Command {
13
+ static baseFlags = {
14
+ host: Flags.string({ char: 'H', description: 'OAuth provider host (defaults to the prod issuer).' }),
15
+ ndjson: Flags.boolean({ description: 'Stream output as NDJSON (one compact JSON object per line).' }),
16
+ timeout: Flags.integer({ description: 'Network timeout in milliseconds.' }),
17
+ verbose: Flags.boolean({ aliases: ['debug'], description: 'Print redacted diagnostics to stderr.' }),
18
+ };
19
+ static enableJsonFlag = true;
20
+ args;
21
+ flags;
22
+ // Centralized error rendering: redacted, machine {error:{code,message}} under
23
+ // --json/--ndjson, a human line otherwise. Replaces oclif's default reporter.
24
+ async catch(error) {
25
+ const machine = this.jsonEnabled() || Boolean(this.flags?.ndjson);
26
+ this.exit(renderError(error, { json: machine }));
27
+ }
28
+ async init() {
29
+ await super.init();
30
+ const { args, flags } = await this.parse({
31
+ args: this.ctor.args,
32
+ baseFlags: super.ctor.baseFlags,
33
+ enableJsonFlag: this.ctor.enableJsonFlag,
34
+ flags: this.ctor.flags,
35
+ strict: this.ctor.strict,
36
+ });
37
+ this.flags = flags;
38
+ this.args = args;
39
+ // --json and --ndjson both claim stdout; refuse the ambiguous combination.
40
+ // UsageError carries exit 2 (oclif's {exit:2} would map to GENERIC instead).
41
+ if (this.jsonEnabled() && this.flags.ndjson) {
42
+ throw new UsageError('--json and --ndjson are mutually exclusive.');
43
+ }
44
+ }
45
+ // The resolved active host (flag > config.defaultHost > DEFAULT_HOST).
46
+ resolvedHost() {
47
+ return resolveHost({ hostFlag: this.flags.host });
48
+ }
49
+ // Emit `data` in the active output mode. Under --json oclif auto-serializes the
50
+ // run() return value (so this only returns); under --ndjson it streams; else it
51
+ // invokes the human formatter. Always returns `data` for the oclif --json path.
52
+ respond(data, human) {
53
+ if (this.flags.ndjson)
54
+ renderNdjson(data);
55
+ else if (!this.jsonEnabled())
56
+ human(data);
57
+ return data;
58
+ }
59
+ // List output: under --ndjson stream the RECORDS (one per line); under --json
60
+ // oclif returns the full {count, records} envelope; else invoke the human
61
+ // formatter. Returns the envelope for the oclif --json path.
62
+ respondList(envelope, human) {
63
+ if (this.flags.ndjson)
64
+ renderNdjson(envelope.records);
65
+ else if (!this.jsonEnabled())
66
+ human(envelope.records);
67
+ return envelope;
68
+ }
69
+ // Verbose diagnostics: stderr only (never stdout — protects --json/NDJSON) and
70
+ // always redacted (never leaks a token).
71
+ verboseLog(message) {
72
+ if (this.flags.verbose)
73
+ process.stderr.write(`${redact(message)}\n`);
74
+ }
75
+ }
@@ -0,0 +1,2 @@
1
+ export declare function formatCrash(value: unknown): string;
2
+ export declare function installCrashHandlers(): void;
@@ -0,0 +1,37 @@
1
+ import { redact } from '../output/redact.js';
2
+ let installed = false;
3
+ // Redact an arbitrary thrown/rejected value for safe emit. Strings, Error stacks,
4
+ // and objects (e.g. a fetch request object carrying an Authorization header) all
5
+ // pass through the JSON-aware redactor so no token reaches stderr.
6
+ export function formatCrash(value) {
7
+ if (typeof value === 'string')
8
+ return redact(value);
9
+ if (value instanceof Error)
10
+ return redact(value.stack ?? value.message);
11
+ try {
12
+ return JSON.stringify(redact(value));
13
+ }
14
+ catch {
15
+ return redact(String(value));
16
+ }
17
+ }
18
+ // Last-resort safety net for tokens that would otherwise reach stderr via an
19
+ // unhandled rejection / uncaught exception (e.g. a stack embedding a request
20
+ // object with an Authorization header). Redacts, emits, then exits non-zero.
21
+ // Installed only by the real CLI entrypoints (bin/*) — NOT in-process under the
22
+ // test runner, where a global process.exit handler would be hazardous. Idempotent.
23
+ export function installCrashHandlers() {
24
+ if (installed)
25
+ return;
26
+ installed = true;
27
+ process.on('uncaughtException', (error) => {
28
+ process.stderr.write(`${formatCrash(error)}\n`);
29
+ // eslint-disable-next-line n/no-process-exit -- last-resort safety net, must hard-exit
30
+ process.exit(1);
31
+ });
32
+ process.on('unhandledRejection', (reason) => {
33
+ process.stderr.write(`${formatCrash(reason)}\n`);
34
+ // eslint-disable-next-line n/no-process-exit -- last-resort safety net, must hard-exit
35
+ process.exit(1);
36
+ });
37
+ }
@@ -0,0 +1,7 @@
1
+ export declare const paginationFlags: {
2
+ all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
3
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
4
+ offset: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
5
+ q: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ sort: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ };
@@ -0,0 +1,10 @@
1
+ import { Flags } from '@oclif/core';
2
+ // Shared pagination/filter flags for admin list commands (E7-1b). `limit`
3
+ // defaults to 50 to force the {count, records} shape; `--all` drains every page.
4
+ export const paginationFlags = {
5
+ all: Flags.boolean({ description: 'Fetch every page (drains pagination).' }),
6
+ limit: Flags.integer({ default: 50, description: 'Page size.' }),
7
+ offset: Flags.integer({ default: 0, description: 'Starting offset.' }),
8
+ q: Flags.string({ description: 'Free-text filter.' }),
9
+ sort: Flags.string({ description: 'Sort field.' }),
10
+ };
@@ -0,0 +1,5 @@
1
+ export declare const DEFAULT_HOST = "https://pulse.admin.api.flipstream.io";
2
+ export declare const DATA_HOST = "https://pulse.data.query.api.flipstream.io";
3
+ export declare const ADMIN_HOST = "https://pulse.admin.api.flipstream.io";
4
+ export declare const OAUTH_CLI_CLIENT_ID = "a975d9be-5ad6-44a5-8c1f-b95b995f3f70";
5
+ export declare const CONTRACT_VERSION = "1";
@@ -0,0 +1,20 @@
1
+ // The OAuth provider / issuer (prod-only). Right-trim of trailing slash is the
2
+ // canonical form; resolveHost() guarantees it.
3
+ export const DEFAULT_HOST = 'https://pulse.admin.api.flipstream.io';
4
+ // The data API host (the /query path is appended by the data client in E4).
5
+ export const DATA_HOST = 'https://pulse.data.query.api.flipstream.io';
6
+ // The admin API host (E7). Today it is the SAME origin as the AUTH issuer
7
+ // (unified auth — the stored bearer is accepted here), so it aliases DEFAULT_HOST;
8
+ // kept as its own constant in case the admin API splits to a distinct origin later.
9
+ export const ADMIN_HOST = DEFAULT_HOST;
10
+ // The CLI's fixed first-party public OAuth client_id (RFC 8252 native app).
11
+ // NON-SECRET: a public client has no secret — PKCE (S256) is the security
12
+ // boundary — so this is safe to ship in source. Every login reuses this ONE
13
+ // pre-provisioned first-party client (no per-login Dynamic Client Registration,
14
+ // hence nothing accumulates server-side). The provider pins the same value via
15
+ // its `ensure-first-party-client` provisioning. Prod value; override for non-prod
16
+ // environments with FLIPSTREAM_OAUTH_CLIENT_ID.
17
+ export const OAUTH_CLI_CLIENT_ID = 'a975d9be-5ad6-44a5-8c1f-b95b995f3f70';
18
+ // The agent-facing output/exit-code contract version. Bumped when the JSON shape,
19
+ // exit codes, or flags change in a way agents must notice. Surfaced by `version`.
20
+ export const CONTRACT_VERSION = '1';
@@ -0,0 +1,21 @@
1
+ export interface HostConfig {
2
+ clientId?: string;
3
+ expiresAt?: number;
4
+ redirectUri?: string;
5
+ scopes?: string[];
6
+ }
7
+ export interface Config {
8
+ defaultHost?: string;
9
+ hosts?: Record<string, HostConfig>;
10
+ }
11
+ export declare function configDir(): string;
12
+ export declare function configPath(): string;
13
+ export declare function readConfig(): Config;
14
+ export declare function writeConfig(partial: Partial<Config>): Config;
15
+ export declare function rememberHost(host: string, meta?: HostConfig): void;
16
+ export declare function forgetHost(host: string): void;
17
+ export declare function knownHosts(): string[];
18
+ export declare function removeConfigDir(): boolean;
19
+ export declare function resolveHost(options?: {
20
+ hostFlag?: string;
21
+ }): string;
@@ -0,0 +1,97 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { DEFAULT_HOST } from './constants.js';
5
+ // Defense-in-depth: any key that looks like a credential is stripped before a
6
+ // write, so a token can never be persisted to the (non-secret) config file.
7
+ const SECRET_KEY_PATTERN = /token|secret|verifier|code|authorization/i;
8
+ export function configDir() {
9
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
10
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
11
+ return join(base, 'flipstream-cli');
12
+ }
13
+ export function configPath() {
14
+ return join(configDir(), 'config.json');
15
+ }
16
+ // Read the config; returns a default object (never throws) on a missing file or
17
+ // invalid JSON.
18
+ export function readConfig() {
19
+ try {
20
+ return JSON.parse(readFileSync(configPath(), 'utf8'));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function stripSecrets(value) {
27
+ if (Array.isArray(value))
28
+ return value.map((item) => stripSecrets(item));
29
+ if (value && typeof value === 'object') {
30
+ const out = {};
31
+ for (const [key, val] of Object.entries(value)) {
32
+ if (SECRET_KEY_PATTERN.test(key))
33
+ continue;
34
+ out[key] = stripSecrets(val);
35
+ }
36
+ return out;
37
+ }
38
+ return value;
39
+ }
40
+ function rstrip(host) {
41
+ return host.replace(/\/+$/, '');
42
+ }
43
+ // Write the full config atomically (tmp + rename), dir 0700 / file 0600. Secret-
44
+ // named keys are stripped, never written.
45
+ function persist(config) {
46
+ const safe = stripSecrets(config);
47
+ mkdirSync(configDir(), { recursive: true });
48
+ chmodSync(configDir(), 0o700);
49
+ const tmp = `${configPath()}.tmp`;
50
+ writeFileSync(tmp, `${JSON.stringify(safe, null, 2)}\n`, { mode: 0o600 });
51
+ renameSync(tmp, configPath());
52
+ chmodSync(configPath(), 0o600);
53
+ return safe;
54
+ }
55
+ // Merge `partial` into the existing config and persist. `hosts` is merged by key.
56
+ export function writeConfig(partial) {
57
+ const current = readConfig();
58
+ const merged = { ...current, ...partial };
59
+ if (current.hosts || partial.hosts) {
60
+ merged.hosts = { ...current.hosts, ...partial.hosts };
61
+ }
62
+ return persist(merged);
63
+ }
64
+ // Record a host in the NON-secret known-hosts index. The keychain API is
65
+ // account-keyed (no enumerate-by-service), so this index is how logout/clear-all
66
+ // learn which hosts exist. `meta` carries only non-secret fields (HostConfig).
67
+ export function rememberHost(host, meta = {}) {
68
+ writeConfig({ hosts: { [rstrip(host)]: meta } });
69
+ }
70
+ // Remove a host from the known-hosts index (no-op if absent).
71
+ export function forgetHost(host) {
72
+ const config = readConfig();
73
+ const key = rstrip(host);
74
+ if (!config.hosts || !(key in config.hosts))
75
+ return;
76
+ const hosts = { ...config.hosts };
77
+ delete hosts[key];
78
+ persist({ ...config, hosts });
79
+ }
80
+ // Every host in the known-hosts index.
81
+ export function knownHosts() {
82
+ return Object.keys(readConfig().hosts ?? {});
83
+ }
84
+ // Delete the entire XDG config dir. Returns whether it existed (for idempotency).
85
+ export function removeConfigDir() {
86
+ const dir = configDir();
87
+ if (!existsSync(dir))
88
+ return false;
89
+ rmSync(dir, { force: true, recursive: true });
90
+ return true;
91
+ }
92
+ // Resolve the active host: --host/-H flag > config.defaultHost > DEFAULT_HOST,
93
+ // always right-trimmed of trailing slashes.
94
+ export function resolveHost(options = {}) {
95
+ const raw = options.hostFlag ?? readConfig().defaultHost ?? DEFAULT_HOST;
96
+ return raw.replace(/\/+$/, '');
97
+ }
@@ -0,0 +1,38 @@
1
+ export declare class CliError extends Error {
2
+ readonly code: string;
3
+ readonly exitCode: number;
4
+ constructor(message: string, code: string, exitCode: number);
5
+ }
6
+ export declare class AuthRequiredError extends CliError {
7
+ constructor(message?: string, code?: 'not_logged_in' | 'session_expired');
8
+ static notLoggedIn(message?: string): AuthRequiredError;
9
+ static sessionExpired(message?: string): AuthRequiredError;
10
+ }
11
+ export declare class AuthFailedError extends CliError {
12
+ constructor(message: string, code?: string);
13
+ }
14
+ export declare class NetworkError extends CliError {
15
+ constructor(message: string, code?: string);
16
+ }
17
+ export declare class TimeoutError extends CliError {
18
+ constructor(message: string, code?: string);
19
+ }
20
+ export declare class UsageError extends CliError {
21
+ constructor(message: string, code?: string);
22
+ }
23
+ export declare class DiscoveryError extends NetworkError {
24
+ constructor(message: string);
25
+ }
26
+ export declare class LoopbackError extends CliError {
27
+ constructor(message: string, code?: string, exitCode?: number);
28
+ static timeout(message?: string): LoopbackError;
29
+ }
30
+ export declare class DataHttpError extends CliError {
31
+ readonly bodyText: string;
32
+ readonly status: number;
33
+ constructor(status: number, bodyText: string);
34
+ }
35
+ export declare function toExitCode(error: unknown): number;
36
+ export declare function renderError(error: unknown, options?: {
37
+ json?: boolean;
38
+ }): number;
@@ -0,0 +1,122 @@
1
+ import { ExitCode } from './exit-codes.js';
2
+ import { redact } from './output/redact.js';
3
+ // Base class for every CLI error. Carries a stable string `code` (for agent
4
+ // branching under --json) and the deterministic `exitCode` from ExitCode.
5
+ export class CliError extends Error {
6
+ code;
7
+ exitCode;
8
+ constructor(message, code, exitCode) {
9
+ super(message);
10
+ this.name = 'CliError';
11
+ this.code = code;
12
+ this.exitCode = exitCode;
13
+ }
14
+ }
15
+ // Not authenticated (or session expired) — exit 4. Two distinct codes let an
16
+ // agent tell "never logged in" apart from "token expired, re-login".
17
+ export class AuthRequiredError extends CliError {
18
+ constructor(message = 'Not logged in', code = 'not_logged_in') {
19
+ super(message, code, ExitCode.AUTH_REQUIRED);
20
+ this.name = 'AuthRequiredError';
21
+ }
22
+ static notLoggedIn(message = 'Not logged in. Run `flipstream auth login`.') {
23
+ return new AuthRequiredError(message, 'not_logged_in');
24
+ }
25
+ static sessionExpired(message = 'Session expired. Run `flipstream auth login`.') {
26
+ return new AuthRequiredError(message, 'session_expired');
27
+ }
28
+ }
29
+ // Authentication attempt failed (bad state/iss, token exchange rejected) — exit 5.
30
+ export class AuthFailedError extends CliError {
31
+ constructor(message, code = 'auth_failed') {
32
+ super(message, code, ExitCode.AUTH_FAILED);
33
+ this.name = 'AuthFailedError';
34
+ }
35
+ }
36
+ // Network/transport failure (incl. discovery) — exit 7.
37
+ export class NetworkError extends CliError {
38
+ constructor(message, code = 'network_error') {
39
+ super(message, code, ExitCode.NETWORK);
40
+ this.name = 'NetworkError';
41
+ }
42
+ }
43
+ // Operation timed out (e.g. user never finished the browser flow) — exit 8.
44
+ export class TimeoutError extends CliError {
45
+ constructor(message, code = 'timeout') {
46
+ super(message, code, ExitCode.TIMEOUT);
47
+ this.name = 'TimeoutError';
48
+ }
49
+ }
50
+ // Bad invocation — exit 2 (aligns with oclif's usage exit).
51
+ export class UsageError extends CliError {
52
+ constructor(message, code = 'usage') {
53
+ super(message, code, ExitCode.USAGE);
54
+ this.name = 'UsageError';
55
+ }
56
+ }
57
+ // Discovery (RFC 8414) failure — a NetworkError with a faithful name (ported
58
+ // from the reference flow's DiscoveryError).
59
+ export class DiscoveryError extends NetworkError {
60
+ constructor(message) {
61
+ super(message, 'discovery_failed');
62
+ this.name = 'DiscoveryError';
63
+ }
64
+ }
65
+ // Loopback callback failure. Exit code varies: a timeout is TIMEOUT(8); a
66
+ // state/iss/code validation failure is AUTH_FAILED(5).
67
+ export class LoopbackError extends CliError {
68
+ constructor(message, code = 'loopback_failed', exitCode = ExitCode.AUTH_FAILED) {
69
+ super(message, code, exitCode);
70
+ this.name = 'LoopbackError';
71
+ }
72
+ static timeout(message = 'Timed out waiting for the browser authorization to complete.') {
73
+ return new LoopbackError(message, 'loopback_timeout', ExitCode.TIMEOUT);
74
+ }
75
+ }
76
+ // A non-2xx response from the DATA host. Transport-only carrier (E4-1): it holds
77
+ // the status + raw body so the E4-2 retry wrapper can detect 401 and the E4-3
78
+ // mapper can assign a deterministic exit code. Default exit is GENERIC until E4-3.
79
+ export class DataHttpError extends CliError {
80
+ bodyText;
81
+ status;
82
+ constructor(status, bodyText) {
83
+ super(`Data request failed: HTTP ${status}`, 'data_http_error', ExitCode.GENERIC);
84
+ this.name = 'DataHttpError';
85
+ this.status = status;
86
+ this.bodyText = bodyText;
87
+ }
88
+ }
89
+ // Map any thrown value to a deterministic exit code. Known CliErrors carry their
90
+ // own; ported errors that don't extend CliError are matched by name for
91
+ // forward-compatibility; everything else is GENERIC(1).
92
+ export function toExitCode(error) {
93
+ if (error instanceof CliError)
94
+ return error.exitCode;
95
+ if (error instanceof Error) {
96
+ if (error.name === 'DiscoveryError')
97
+ return ExitCode.NETWORK;
98
+ if (error.name === 'LoopbackError') {
99
+ return /tim(e|ed) ?out/i.test(error.message) ? ExitCode.TIMEOUT : ExitCode.AUTH_FAILED;
100
+ }
101
+ }
102
+ return ExitCode.GENERIC;
103
+ }
104
+ // Render an error (redacted) and return its exit code. AGENT CONTRACT: under
105
+ // --json/--ndjson the error envelope IS the machine output, so a single
106
+ // {error:{code,message,exit}} JSON document is written to STDOUT — this is what
107
+ // makes `<cmd> --json 2>/dev/null | jq .` work on failure paths too. Human mode
108
+ // writes a line to STDERR. Both routes pass the message through redaction so
109
+ // tokens never leak.
110
+ export function renderError(error, options = {}) {
111
+ const exitCode = toExitCode(error);
112
+ const code = error instanceof CliError ? error.code : error instanceof Error ? error.name : 'error';
113
+ const rawMessage = error instanceof Error ? error.message : String(error);
114
+ const message = redact(rawMessage);
115
+ if (options.json) {
116
+ process.stdout.write(`${JSON.stringify({ error: { code, exit: exitCode, message } })}\n`);
117
+ }
118
+ else {
119
+ process.stderr.write(`Error: ${message}\n`);
120
+ }
121
+ return exitCode;
122
+ }
@@ -0,0 +1,10 @@
1
+ export declare const ExitCode: {
2
+ readonly SUCCESS: 0;
3
+ readonly GENERIC: 1;
4
+ readonly USAGE: 2;
5
+ readonly AUTH_REQUIRED: 4;
6
+ readonly AUTH_FAILED: 5;
7
+ readonly NETWORK: 7;
8
+ readonly TIMEOUT: 8;
9
+ };
10
+ export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode];
@@ -0,0 +1,13 @@
1
+ // Single source of truth for CLI exit codes. Agents branch on these, so they
2
+ // are a stable contract across every command (E1/E2/E4 import this, never
3
+ // redefine it). oclif reserves exit 2 for flag-parse/usage errors, so our
4
+ // custom codes start at 4 to avoid collision.
5
+ export const ExitCode = {
6
+ SUCCESS: 0,
7
+ GENERIC: 1,
8
+ USAGE: 2,
9
+ AUTH_REQUIRED: 4,
10
+ AUTH_FAILED: 5,
11
+ NETWORK: 7,
12
+ TIMEOUT: 8,
13
+ };
@@ -0,0 +1,4 @@
1
+ import { Help } from '@oclif/core';
2
+ export default class FlipstreamHelp extends Help {
3
+ protected showRootHelp(): Promise<void>;
4
+ }
@@ -0,0 +1,14 @@
1
+ import { Help } from '@oclif/core';
2
+ // Custom root help: append an agent-discovery hint so a coding agent reading
3
+ // `flipstream --help` is pointed straight at the machine-readable catalog. Only
4
+ // the ROOT help is extended — per-command help (and `oclif readme`) is unchanged.
5
+ export default class FlipstreamHelp extends Help {
6
+ async showRootHelp() {
7
+ await super.showRootHelp();
8
+ this.log('');
9
+ this.log('FOR AGENTS');
10
+ this.log(' Run `flipstream commands --json` for the full machine-readable command');
11
+ this.log(' catalog (ids, summaries, flags, args, aliases) — one call, the whole surface.');
12
+ this.log(' Output/exit-code contract: docs/AGENT-CONTRACT.md.');
13
+ }
14
+ }
@@ -0,0 +1 @@
1
+ export declare function renderNdjson(value: unknown): void;
@@ -0,0 +1,16 @@
1
+ // Write NDJSON (newline-delimited JSON): one compact JSON object per line, no
2
+ // enclosing array, no blank lines. An array/iterable emits one line per item; a
3
+ // single object emits exactly one line. stdout = data only (RFC-ish streaming).
4
+ export function renderNdjson(value) {
5
+ if (value === null || value === undefined)
6
+ return;
7
+ const rows = isIterable(value) ? value : [value];
8
+ let out = '';
9
+ for (const row of rows)
10
+ out += `${JSON.stringify(row)}\n`;
11
+ process.stdout.write(out);
12
+ }
13
+ function isIterable(value) {
14
+ return (Array.isArray(value) ||
15
+ (typeof value === 'object' && value !== null && typeof value[Symbol.iterator] === 'function'));
16
+ }
@@ -0,0 +1 @@
1
+ export declare function redact(value: unknown): unknown;