@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.
- package/README.md +125 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +104 -0
- package/dist/args.d.ts +26 -0
- package/dist/args.js +156 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +18 -0
- package/dist/command.d.ts +75 -0
- package/dist/command.js +34 -0
- package/dist/commands/auth.d.ts +4 -0
- package/dist/commands/auth.js +214 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +239 -0
- package/dist/commands/flags-history.d.ts +3 -0
- package/dist/commands/flags-history.js +183 -0
- package/dist/commands/flags-read.d.ts +71 -0
- package/dist/commands/flags-read.js +124 -0
- package/dist/commands/flags-sync.d.ts +2 -0
- package/dist/commands/flags-sync.js +129 -0
- package/dist/commands/flags-write.d.ts +6 -0
- package/dist/commands/flags-write.js +311 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +40 -0
- package/dist/commands/init.d.ts +33 -0
- package/dist/commands/init.js +458 -0
- package/dist/commands/keys.d.ts +4 -0
- package/dist/commands/keys.js +177 -0
- package/dist/commands/projects.d.ts +4 -0
- package/dist/commands/projects.js +114 -0
- package/dist/credentials.d.ts +60 -0
- package/dist/credentials.js +120 -0
- package/dist/exit-codes.d.ts +24 -0
- package/dist/exit-codes.js +70 -0
- package/dist/help.d.ts +49 -0
- package/dist/help.js +118 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +28 -0
- package/dist/output.d.ts +26 -0
- package/dist/output.js +68 -0
- package/dist/run.d.ts +13 -0
- package/dist/run.js +135 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +13 -0
- 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
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;
|
package/dist/command.js
ADDED
|
@@ -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
|
+
}
|