@golden-frijoles/cli 0.1.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 (44) hide show
  1. package/README.md +125 -0
  2. package/dist/api.d.ts +28 -0
  3. package/dist/api.js +104 -0
  4. package/dist/args.d.ts +26 -0
  5. package/dist/args.js +156 -0
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +18 -0
  8. package/dist/command.d.ts +75 -0
  9. package/dist/command.js +34 -0
  10. package/dist/commands/auth.d.ts +4 -0
  11. package/dist/commands/auth.js +214 -0
  12. package/dist/commands/doctor.d.ts +9 -0
  13. package/dist/commands/doctor.js +239 -0
  14. package/dist/commands/flags-history.d.ts +3 -0
  15. package/dist/commands/flags-history.js +183 -0
  16. package/dist/commands/flags-read.d.ts +71 -0
  17. package/dist/commands/flags-read.js +124 -0
  18. package/dist/commands/flags-sync.d.ts +2 -0
  19. package/dist/commands/flags-sync.js +129 -0
  20. package/dist/commands/flags-write.d.ts +6 -0
  21. package/dist/commands/flags-write.js +311 -0
  22. package/dist/commands/index.d.ts +2 -0
  23. package/dist/commands/index.js +40 -0
  24. package/dist/commands/init.d.ts +33 -0
  25. package/dist/commands/init.js +458 -0
  26. package/dist/commands/keys.d.ts +4 -0
  27. package/dist/commands/keys.js +177 -0
  28. package/dist/commands/projects.d.ts +4 -0
  29. package/dist/commands/projects.js +114 -0
  30. package/dist/credentials.d.ts +60 -0
  31. package/dist/credentials.js +120 -0
  32. package/dist/exit-codes.d.ts +24 -0
  33. package/dist/exit-codes.js +70 -0
  34. package/dist/help.d.ts +49 -0
  35. package/dist/help.js +118 -0
  36. package/dist/index.d.ts +7 -0
  37. package/dist/index.js +28 -0
  38. package/dist/output.d.ts +26 -0
  39. package/dist/output.js +68 -0
  40. package/dist/run.d.ts +13 -0
  41. package/dist/run.js +135 -0
  42. package/dist/version.d.ts +1 -0
  43. package/dist/version.js +13 -0
  44. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @golden-frijoles/cli
2
+
3
+ Create a feature flag in every environment, roll it out, and kill it — from a terminal, or from an
4
+ agent. No browser, no human click.
5
+
6
+ ```bash
7
+ npx @golden-frijoles/cli --version
8
+ ```
9
+
10
+ ## The one-minute version
11
+
12
+ ```bash
13
+ npm i -g @golden-frijoles/cli # or use npx for everything below
14
+
15
+ gf login # paste a token from /app/setup/cli
16
+ gf init # project + key + .env.local + the snippet
17
+ gf flags create checkout.demo_enabled --kill-switch --all-envs
18
+ gf flags rollout checkout.demo_enabled --env production --percent 25
19
+ gf flags kill checkout.demo_enabled --env production
20
+ ```
21
+
22
+ ## Why this exists
23
+
24
+ Every high-risk change ships behind a flag, and a flag is invisible until it exists **in the
25
+ provider**. Before this, the one line of a release that most needs to be reliable — *create the flag
26
+ in every environment* — was the line that stopped and waited for someone to open a browser.
27
+
28
+ `gf flags create … --all-envs` is that line.
29
+
30
+ ## Signing in
31
+
32
+ Mint a token at **`/app/setup/cli`** in the console, then:
33
+
34
+ ```bash
35
+ gf login # reads the token from stdin — never from argv, never from your history
36
+ gf whoami # who you are, which credential, which projects
37
+ ```
38
+
39
+ In CI, set `GOLDEN_FRIJOLES_TOKEN` and skip `gf login` entirely. Nothing is written to disk on that
40
+ path.
41
+
42
+ A token signs you in as **you**: it can do exactly what your console session can do, across every
43
+ project you are a member of, and nothing more. Revoke it at `/app/setup/cli`.
44
+
45
+ ## Polarity — the thing to get right
46
+
47
+ A flag's polarity decides what it serves the day it is born, and the CLI derives everything else
48
+ from it, so the wrong combination is not expressible:
49
+
50
+ | You type | Default variant | Every environment serves | Reach for it when |
51
+ |---|---|---|---|
52
+ | `--kill-switch` | `on` | `true` | it is **on** until you kill it |
53
+ | `--enablement` | `off` | `false` | you will **open** it deliberately later |
54
+
55
+ Both polarities **activate in every environment you name.** "Created disabled" means *serving
56
+ `false`*, not *absent* — a flag that is not activated is missing from the snapshot, so your app falls
57
+ back to its own literal and the flag is invisible in the provider, which is the failure this tool
58
+ exists to end.
59
+
60
+ ## `--all-envs`, and what happens when one fails
61
+
62
+ Three environments, three writes, no transaction. So:
63
+
64
+ - each environment is written **idempotently**,
65
+ - you get a **per-environment report**, and
66
+ - the exit code is **5** if any environment failed.
67
+
68
+ Never a silent partial.
69
+
70
+ ## `--json` everywhere
71
+
72
+ Every command takes `--json`. Under it, **stdout carries exactly one JSON document and nothing
73
+ else** — no progress lines, no warnings. A failure is a JSON document too, on stdout, with a stable
74
+ `code`:
75
+
76
+ ```json
77
+ { "ok": false, "code": "not_found", "error": "No project `acme` is available to this account." }
78
+ ```
79
+
80
+ `--help` output and these envelopes are pinned by golden-file tests. They do not change on a copy
81
+ edit.
82
+
83
+ ## Exit codes
84
+
85
+ | Code | Name | Means |
86
+ |---|---|---|
87
+ | `0` | ok | it worked |
88
+ | `1` | usage | the command is wrong — nothing was sent |
89
+ | `2` | auth | the credential is not accepted — run `gf login` |
90
+ | `3` | not-found | no such thing, or not yours |
91
+ | `4` | conflict | someone else changed it — re-read and retry |
92
+ | `5` | partial | some environments changed and some did not |
93
+ | `6` | server | the server or the network is unwell — retry |
94
+
95
+ ## When something is wrong
96
+
97
+ ```bash
98
+ gf doctor
99
+ ```
100
+
101
+ It runs without a credential — diagnosing a missing one is the point — and reports every check it
102
+ could run: the credentials file, the credential, its shape, whether the deployment answers, whether
103
+ it accepts you, whether your active project is reachable, and whether this CLI is current. It never
104
+ prints key material.
105
+
106
+ ## Environment
107
+
108
+ | Variable | What it does |
109
+ |---|---|
110
+ | `GOLDEN_FRIJOLES_TOKEN` | a CLI token; wins over the saved credential. The CI path. |
111
+ | `GOLDEN_FRIJOLES_URL` | the deployment to talk to |
112
+ | `GOLDEN_FRIJOLES_PROJECT` | the active project |
113
+
114
+ `gf init` writes `GOLDEN_FRIJOLES_URL`, `GOLDEN_FRIJOLES_FLAG_READ_KEY` and
115
+ `GOLDEN_FRIJOLES_ENVIRONMENT` into `.env.local` (mode `0600`), adds that file to `.gitignore` — or
116
+ refuses — and prints the `@golden-frijoles/sdk` snippet that reads exactly those names.
117
+
118
+ ## What it deliberately does not do
119
+
120
+ - **Send events.** That is the SDK's path (`@golden-frijoles/sdk`), and a second one would be a
121
+ parallel pipeline.
122
+ - **Experiments, journeys, north star, scenarios, destinations, breakers.** Out of v1 on purpose.
123
+ - **A TUI.** Plain output and `--json`.
124
+ - **Plans and quotas.** Every account is unlimited today; `gf` will learn about plans when there is
125
+ a plan to learn about.
package/dist/api.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type ApiSuccess<T> = {
2
+ kind: 'ok';
3
+ status: number;
4
+ body: T;
5
+ };
6
+ export type ApiFailure = {
7
+ kind: 'error';
8
+ status: number;
9
+ code: string;
10
+ message: string;
11
+ body: Record<string, unknown>;
12
+ } | {
13
+ kind: 'network';
14
+ message: string;
15
+ };
16
+ export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
17
+ export type ApiClient = {
18
+ readonly baseUrl: string;
19
+ get<T>(path: string, query?: Record<string, string | undefined>): Promise<ApiResult<T>>;
20
+ post<T>(path: string, body: unknown): Promise<ApiResult<T>>;
21
+ del<T>(path: string, query?: Record<string, string | undefined>): Promise<ApiResult<T>>;
22
+ };
23
+ export declare function createApiClient(options: {
24
+ baseUrl: string;
25
+ token: string;
26
+ userAgent: string;
27
+ fetchImpl?: typeof fetch;
28
+ }): ApiClient;
package/dist/api.js ADDED
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ // golden-frijoles-cli · Sprint 1 — the only place this package speaks HTTP.
3
+ //
4
+ // ── One client, so one place decides what a failure MEANS ─────────────────────────────────────
5
+ // Every command returns `ApiResult`, which carries the server's own `code` (`unauthorized`,
6
+ // `not_found`, `conflict`, `invalid`, `disabled`) rather than an HTTP status. The status is an
7
+ // implementation detail of the transport; the code is the contract `lib/cli-auth.ts` publishes, and
8
+ // `exitForServerCode` is the one mapping from it to an exit code.
9
+ //
10
+ // ── A network failure is NOT a 500 and must not read like one ─────────────────────────────────
11
+ // `fetch` rejecting (DNS, a dropped connection, a timeout) produces `kind: 'network'`, separate
12
+ // from a server that answered badly. The remedy differs: one is "check your connection or the URL",
13
+ // the other is "the deployment is unwell". `gf doctor`'s whole job is telling those apart, and it
14
+ // cannot if the client has already collapsed them.
15
+ //
16
+ // ── A non-JSON body is a failure, not an empty success ────────────────────────────────────────
17
+ // A proxy's HTML error page parses as nothing; treating that as `{}` would let a command report
18
+ // success against a response it never understood (CODE-QUALITY #7).
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.createApiClient = createApiClient;
21
+ /** How long any single request may take. A CLI that hangs is a CI job that hangs. */
22
+ const TIMEOUT_MS = 30_000;
23
+ function createApiClient(options) {
24
+ const doFetch = options.fetchImpl ?? fetch;
25
+ async function request(method, path, init) {
26
+ const url = new URL(path, `${options.baseUrl}/`);
27
+ for (const [key, value] of Object.entries(init.query ?? {})) {
28
+ if (value !== undefined)
29
+ url.searchParams.set(key, value);
30
+ }
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
33
+ let response;
34
+ try {
35
+ response = await doFetch(url.toString(), {
36
+ method,
37
+ headers: {
38
+ // The credential. Never in the URL — a URL travels through history, proxy logs and
39
+ // screenshots, which is the argument the connector-token migration makes at length.
40
+ authorization: `Bearer ${options.token}`,
41
+ accept: 'application/json',
42
+ 'user-agent': options.userAgent,
43
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
44
+ },
45
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
46
+ signal: controller.signal,
47
+ });
48
+ }
49
+ catch (err) {
50
+ const message = err instanceof Error && err.name === 'AbortError'
51
+ ? `No answer from ${options.baseUrl} within ${TIMEOUT_MS / 1000}s.`
52
+ : `Could not reach ${options.baseUrl}: ${err instanceof Error ? err.message : String(err)}`;
53
+ return { kind: 'network', message };
54
+ }
55
+ finally {
56
+ clearTimeout(timer);
57
+ }
58
+ const text = await response.text();
59
+ let parsed;
60
+ try {
61
+ parsed = text === '' ? {} : JSON.parse(text);
62
+ }
63
+ catch {
64
+ // An HTML error page from a proxy, or a 404 from a deployment that does not have these routes.
65
+ // Reported with the STATUS in the sentence, because that is the only thing we actually learned.
66
+ return {
67
+ kind: 'error',
68
+ status: response.status,
69
+ code: 'server_error',
70
+ message: `${options.baseUrl} answered ${response.status} with a body that is not JSON.`,
71
+ body: {},
72
+ };
73
+ }
74
+ const body = (parsed ?? {});
75
+ if (response.ok && body.ok !== false)
76
+ return { kind: 'ok', status: response.status, body: body };
77
+ return {
78
+ kind: 'error',
79
+ status: response.status,
80
+ // The server's own vocabulary, with a status-derived fallback for a response that came from
81
+ // somewhere else in the stack (a Vercel 502, an upstream 404) and carries no `code`.
82
+ code: typeof body.code === 'string' ? body.code : codeFromStatus(response.status),
83
+ message: typeof body.error === 'string' ? body.error : `Request failed with status ${response.status}.`,
84
+ body,
85
+ };
86
+ }
87
+ function codeFromStatus(status) {
88
+ if (status === 401 || status === 403)
89
+ return 'unauthorized';
90
+ if (status === 404)
91
+ return 'not_found';
92
+ if (status === 409)
93
+ return 'conflict';
94
+ if (status === 400 || status === 422)
95
+ return 'invalid';
96
+ return 'server_error';
97
+ }
98
+ return {
99
+ baseUrl: options.baseUrl,
100
+ get: (path, query) => request('GET', path, { query }),
101
+ post: (path, body) => request('POST', path, { body }),
102
+ del: (path, query) => request('DELETE', path, { query }),
103
+ };
104
+ }
package/dist/args.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type ParsedArgs = {
2
+ /** The verb path: `['flags', 'create']` for `gf flags create …`. */
3
+ path: string[];
4
+ /** Everything that was not a flag and not part of the verb path. */
5
+ positionals: string[];
6
+ /** `--flag value`, `--flag=value` and repeated flags (which accumulate). */
7
+ flags: Map<string, string[]>;
8
+ /** `--json` anywhere. Hoisted because every command honours it. */
9
+ json: boolean;
10
+ /** `--help`/`-h` anywhere, including after a verb. */
11
+ help: boolean;
12
+ /** `--version`/`-V` anywhere. */
13
+ version: boolean;
14
+ };
15
+ export declare function parseArgs(argv: readonly string[]): ParsedArgs;
16
+ export declare function flagValue(args: ParsedArgs, name: string): string | undefined;
17
+ export declare function flagValues(args: ParsedArgs, name: string): string[];
18
+ export declare function boolFlag(args: ParsedArgs, name: string): boolean;
19
+ /**
20
+ * Flags the caller passed that this command does not know about.
21
+ *
22
+ * ⚠️ **Reported as a usage error rather than ignored, and that is not pedantry.** An agent that
23
+ * types `--environment` instead of `--env` and is silently ignored gets a flag created in the wrong
24
+ * place with exit 0 — the CLI agreeing with a command nobody wrote. Fail loud (CODE-QUALITY #7).
25
+ */
26
+ export declare function unknownFlags(args: ParsedArgs, known: readonly string[]): string[];
package/dist/args.js ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ // golden-frijoles-cli · Sprint 1, Story 1.1 — the argument parser. PURE, and no dependency.
3
+ //
4
+ // ── Why hand-written and not `commander`/`yargs` ──────────────────────────────────────────────
5
+ // A published CLI's dependency tree is its install time and its supply-chain surface, and this one
6
+ // is meant to be reached with `npx` on a machine that has never seen it. The whole grammar here is
7
+ // "verbs, then `--flag value`", which is forty lines. A parser library would be the largest thing
8
+ // in the package by an order of magnitude, to save those forty lines.
9
+ //
10
+ // ── Pure, so the contract can be asserted without spawning anything ───────────────────────────
11
+ // Every parsing rule below is tested directly (CODE-QUALITY #5). Spawning `gf` to find out whether
12
+ // `--percent` accepts `=` is a test that also exercises the network, the filesystem and a token.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.parseArgs = parseArgs;
15
+ exports.flagValue = flagValue;
16
+ exports.flagValues = flagValues;
17
+ exports.boolFlag = boolFlag;
18
+ exports.unknownFlags = unknownFlags;
19
+ /**
20
+ * Boolean flags — the ones that take NO value.
21
+ *
22
+ * ⚠️ A closed list, and it has to exist. Without it `gf flags create k --all-envs --kill-switch`
23
+ * parses `--kill-switch` as the VALUE of `--all-envs`, and the command then silently creates a flag
24
+ * in one environment with no polarity — a wrong result from a correct-looking command line, which
25
+ * is the worst failure shape a CLI has. Adding a boolean flag means adding it here.
26
+ */
27
+ const BOOLEAN_FLAGS = new Set([
28
+ 'json',
29
+ 'help',
30
+ 'h',
31
+ 'version',
32
+ 'V',
33
+ 'all-envs',
34
+ 'kill-switch',
35
+ 'enablement',
36
+ 'dry-run',
37
+ 'yes',
38
+ 'no-color',
39
+ ]);
40
+ function parseArgs(argv) {
41
+ const path = [];
42
+ const positionals = [];
43
+ const flags = new Map();
44
+ function push(name, value) {
45
+ flags.set(name, [...(flags.get(name) ?? []), value]);
46
+ }
47
+ // ⚠️ **Every bare word goes to `path`, wherever it appears — not only the LEADING run** (cross-
48
+ // family review, Codex, round 8). The first version collected the path only until the first
49
+ // flag, so `gf --json flags ls` parsed as an empty path and printed ROOT HELP with exit 0 — an
50
+ // agent that put its global flags first (which the help calls "global" and the README says work
51
+ // "anywhere") got a success code and none of the output it asked for.
52
+ //
53
+ // The command table decides where the verb ends and the subject begins: `matchCommand` takes the
54
+ // longest known prefix and hands the rest to the verb as positionals. So `path` here is simply
55
+ // "the bare words, in order", and flags may sit anywhere among them. A value-taking flag still
56
+ // consumes its value below, so `--env production` never leaks `production` into the path.
57
+ for (let index = 0; index < argv.length; index++) {
58
+ const token = argv[index];
59
+ if (!token.startsWith('-')) {
60
+ path.push(token);
61
+ continue;
62
+ }
63
+ // `--` ends flag parsing: everything after it is a positional, even if it starts with a dash.
64
+ // A rules file called `--weird.json` is not this CLI's problem to guess about.
65
+ if (token === '--') {
66
+ positionals.push(...argv.slice(index + 1));
67
+ break;
68
+ }
69
+ const body = token.replace(/^--?/, '');
70
+ const equals = body.indexOf('=');
71
+ if (equals !== -1) {
72
+ // `--flag=value` always carries its own value, even for a name in BOOLEAN_FLAGS — `--json=false`
73
+ // is a caller saying something explicit, and swallowing the `=false` would invert it.
74
+ push(body.slice(0, equals), body.slice(equals + 1));
75
+ continue;
76
+ }
77
+ if (BOOLEAN_FLAGS.has(body)) {
78
+ push(body, 'true');
79
+ continue;
80
+ }
81
+ const next = argv[index + 1];
82
+ if (next === undefined || next.startsWith('-')) {
83
+ // A value-taking flag with nothing after it. Recorded as EMPTY rather than as `true`, so the
84
+ // command reports "--env needs a value" instead of treating the flag as a boolean it is not.
85
+ push(body, '');
86
+ continue;
87
+ }
88
+ push(body, next);
89
+ index++;
90
+ }
91
+ return {
92
+ path,
93
+ positionals,
94
+ flags,
95
+ json: readBoolean(flags, 'json'),
96
+ help: readBoolean(flags, 'help') || readBoolean(flags, 'h'),
97
+ version: readBoolean(flags, 'version') || readBoolean(flags, 'V'),
98
+ };
99
+ }
100
+ /**
101
+ * A boolean flag's value.
102
+ *
103
+ * Present with no value ⇒ true. `--flag=false` / `=0` / `=no` ⇒ false, because a caller who typed
104
+ * an explicit value meant it. Anything else present ⇒ true.
105
+ */
106
+ function readBoolean(flags, name) {
107
+ const values = flags.get(name);
108
+ if (values === undefined)
109
+ return false;
110
+ const last = values[values.length - 1];
111
+ return !['false', '0', 'no', 'off'].includes(last.toLowerCase());
112
+ }
113
+ function flagValue(args, name) {
114
+ const values = args.flags.get(name);
115
+ // The LAST wins for a single-valued flag: `--env production --env preview` on a verb that takes
116
+ // one environment is a caller correcting themselves, and taking the first would silently act on
117
+ // the value they replaced.
118
+ return values === undefined ? undefined : values[values.length - 1];
119
+ }
120
+ function flagValues(args, name) {
121
+ return (args.flags.get(name) ?? []).filter((value) => value !== '');
122
+ }
123
+ function boolFlag(args, name) {
124
+ return readBoolean(args.flags, name);
125
+ }
126
+ /**
127
+ * Flags the caller passed that this command does not know about.
128
+ *
129
+ * ⚠️ **Reported as a usage error rather than ignored, and that is not pedantry.** An agent that
130
+ * types `--environment` instead of `--env` and is silently ignored gets a flag created in the wrong
131
+ * place with exit 0 — the CLI agreeing with a command nobody wrote. Fail loud (CODE-QUALITY #7).
132
+ */
133
+ function unknownFlags(args, known) {
134
+ // ⚠️ **`project` belongs in this list, and its absence made `gf --help` lie** (fresh reviewer,
135
+ // PR #149). The help's "Global flags" block documents `--project`, and six verbs — `whoami`,
136
+ // `login`, `logout`, `projects ls|create|use` — rejected it with exit 1 and a JSON error saying
137
+ // the flag it had just been shown does not exist.
138
+ //
139
+ // This is the identical defect `run.ts` records fixing for `--version` in the same review: a flag
140
+ // documented as global is honoured globally, and the alternative is a help text an agent cannot
141
+ // trust, which is the whole of D5. Accepting it on a verb that ignores it costs nothing; the
142
+ // verbs that USE it still declare it so it appears in their own `--help`.
143
+ const allowed = new Set([
144
+ ...known,
145
+ 'json',
146
+ 'help',
147
+ 'h',
148
+ 'version',
149
+ 'V',
150
+ 'no-color',
151
+ 'api',
152
+ 'token',
153
+ 'project',
154
+ ]);
155
+ return [...args.flags.keys()].filter((name) => !allowed.has(name)).sort();
156
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ // The executable. The ONLY place in this package that touches `process.exit` or `process.argv`.
5
+ //
6
+ // Everything else returns an exit code, which is what lets the whole CLI run in-process in a test
7
+ // with a captured writer and an injected `fetch`.
8
+ const run_1 = require("./run");
9
+ (0, run_1.run)({ argv: process.argv.slice(2) })
10
+ .then((code) => {
11
+ process.exitCode = code;
12
+ })
13
+ .catch((err) => {
14
+ // `run()` already catches a handler throwing. Reaching here means the dispatcher itself failed —
15
+ // a broken install, an unreadable module. Say so plainly rather than printing a bare stack.
16
+ process.stderr.write(`gf failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
17
+ process.exitCode = 6;
18
+ });
@@ -0,0 +1,75 @@
1
+ import type { ParsedArgs } from './args';
2
+ import type { ApiClient } from './api';
3
+ import type { Emitter, Writer } from './output';
4
+ import type { ResolvedAuth } from './credentials';
5
+ import type { ExitCode } from './exit-codes';
6
+ export type FlagDoc = {
7
+ /** `--env`, written without the dashes. */
8
+ name: string;
9
+ /** The value's placeholder — `<environment>` — or undefined for a boolean flag. */
10
+ value?: string;
11
+ describe: string;
12
+ };
13
+ export type CommandContext = {
14
+ args: ParsedArgs;
15
+ emit: Emitter;
16
+ writer: Writer;
17
+ auth: ResolvedAuth;
18
+ env: NodeJS.ProcessEnv;
19
+ cwd: string;
20
+ /**
21
+ * The HTTP client, already carrying the resolved token.
22
+ *
23
+ * `null` when there is no credential. A command with `needsAuth: true` never sees `null` — the
24
+ * dispatcher refuses first, with EXIT.AUTH and one sentence — so handlers do not each re-check.
25
+ */
26
+ api: ApiClient | null;
27
+ /** Build a client against an arbitrary token. `gf login` needs one before a token is saved. */
28
+ clientFor(token: string): ApiClient;
29
+ /**
30
+ * The `fetch` this run should use, for the ONE call that is not to this deployment's API.
31
+ *
32
+ * ⚠️ Only `gf doctor`'s npm-registry check needs it, and it exists because the alternative was a
33
+ * bare global `fetch` — which is how `packages/cli` grew a second HTTP path twice in one sprint
34
+ * (`probeFlagReadKey` was the other). A direct global call skips the injected stub, so
35
+ * `npm run test:unit` made a REAL network request to registry.npmjs.org on every doctor test, and
36
+ * the check it performs could not be asserted at all.
37
+ *
38
+ * Everything talking to the deployment goes through `api` / `clientFor` instead — this is not a
39
+ * general escape hatch, and a second consumer should be a reason to ask why.
40
+ */
41
+ fetchImpl: typeof fetch;
42
+ };
43
+ export type Command = {
44
+ /** `['flags', 'create']`. The dispatcher matches the LONGEST path first. */
45
+ path: string[];
46
+ summary: string;
47
+ /** One line, as a person would type it. Rendered into `--help` and pinned by the golden file. */
48
+ usage: string;
49
+ flags: FlagDoc[];
50
+ /**
51
+ * Does this verb need a credential?
52
+ *
53
+ * `false` for `login`, `doctor`, `help` and `version` — and `doctor` is the important one: its job
54
+ * is diagnosing a missing credential, so a dispatcher that refused it for want of one would make
55
+ * the tool useless exactly when it is needed.
56
+ */
57
+ needsAuth: boolean;
58
+ /** Longer prose for `gf <verb> --help`. Optional; the summary carries most verbs. */
59
+ detail?: string;
60
+ run(context: CommandContext): Promise<ExitCode>;
61
+ };
62
+ /**
63
+ * Find the command whose path is the longest prefix of what was typed.
64
+ *
65
+ * Longest-first so `flags create` wins over a hypothetical bare `flags`, and so adding a
66
+ * sub-verb later cannot shadow an existing one by accident.
67
+ *
68
+ * Returns the leftover words as `positionals` — `gf flags get checkout.demo` matches
69
+ * `['flags','get']` and leaves `['checkout.demo']`, which is how a verb receives its subject
70
+ * without the parser having to know the arity of every command.
71
+ */
72
+ export declare function matchCommand(commands: readonly Command[], path: readonly string[]): {
73
+ command: Command;
74
+ rest: string[];
75
+ } | null;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ // golden-frijoles-cli · Sprint 1, Story 1.1 — the command shape, and the context every verb gets.
3
+ //
4
+ // ── One table, and `--help` is RENDERED from it ───────────────────────────────────────────────
5
+ // The help text is not written anywhere. It is generated from the same array the dispatcher reads,
6
+ // so a verb cannot exist without being documented and cannot be documented without existing — and
7
+ // the golden file (D5) then pins the rendered result. The failure this prevents is ordinary and
8
+ // constant: a flag added to a handler and not to its help, which an agent then never learns about.
9
+ //
10
+ // ── Why a handler returns an exit code instead of calling process.exit ────────────────────────
11
+ // So the whole CLI can be run in-process by a test, with a captured writer and an injected `fetch`,
12
+ // and asserted on its exit code and its exact bytes. A handler that exits the process is a handler
13
+ // that can only be tested by spawning one.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.matchCommand = matchCommand;
16
+ /**
17
+ * Find the command whose path is the longest prefix of what was typed.
18
+ *
19
+ * Longest-first so `flags create` wins over a hypothetical bare `flags`, and so adding a
20
+ * sub-verb later cannot shadow an existing one by accident.
21
+ *
22
+ * Returns the leftover words as `positionals` — `gf flags get checkout.demo` matches
23
+ * `['flags','get']` and leaves `['checkout.demo']`, which is how a verb receives its subject
24
+ * without the parser having to know the arity of every command.
25
+ */
26
+ function matchCommand(commands, path) {
27
+ const byLength = [...commands].sort((left, right) => right.path.length - left.path.length);
28
+ for (const command of byLength) {
29
+ if (command.path.every((segment, index) => path[index] === segment)) {
30
+ return { command, rest: path.slice(command.path.length) };
31
+ }
32
+ }
33
+ return null;
34
+ }
@@ -0,0 +1,4 @@
1
+ import type { Command } from '../command';
2
+ export declare const loginCommand: Command;
3
+ export declare const logoutCommand: Command;
4
+ export declare const whoamiCommand: Command;