@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
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ // golden-frijoles-cli · Sprint 2, Story 2.6 — `gf keys ls | create | revoke`.
3
+ //
4
+ // ── The three kinds are NOT flattened, and the CLI is where that is easiest to get wrong ──────
5
+ // `lib/credential-inventory.ts` models three blast radii and the seed says the CLI must not
6
+ // collapse them: `ingest` sends events into a project, `flag_read` reads ONE environment's
7
+ // snapshot, `flag_sync` writes definitions from an outside catalog. So `--type` is REQUIRED — there
8
+ // is no default kind — and each kind's own argument is required too. A defaulted `--env` would mint
9
+ // a credential pointed at an environment the caller did not choose, which is the kind of quiet
10
+ // wrong that only shows up when the wrong thing is served.
11
+ //
12
+ // ── The plaintext is printed ONCE and never saved ─────────────────────────────────────────────
13
+ // It goes to stdout and nowhere else. It is not written to the credentials file, not echoed by
14
+ // `gf doctor`, not returned by `gf keys ls`. Only the hash was stored, so there is nothing to
15
+ // re-show and the CLI says so rather than implying it could.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.keysRevokeCommand = exports.keysCreateCommand = exports.keysLsCommand = void 0;
18
+ const args_1 = require("../args");
19
+ const exit_codes_1 = require("../exit-codes");
20
+ const output_1 = require("../output");
21
+ const flags_read_1 = require("./flags-read");
22
+ const KEY_TYPES = ['ingest', 'flag_read', 'flag_sync'];
23
+ exports.keysLsCommand = {
24
+ path: ['keys', 'ls'],
25
+ summary: 'every credential that can reach this project',
26
+ usage: 'gf keys ls [--project <slug>] [--json]',
27
+ needsAuth: true,
28
+ detail: `Owner-only, exactly as the console's Keys page is — an ordinary member can read the
29
+ dashboards but must not enumerate the credentials production runs on.
30
+
31
+ ⚠️ It does NOT list connector URLs, share links or your CLI tokens. Those are managed on
32
+ their own surfaces and are not project-scoped credentials; saying so is better than a list
33
+ that implies completeness it does not have.`,
34
+ flags: [{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' }],
35
+ async run(context) {
36
+ const project = (0, flags_read_1.resolveProject)(context);
37
+ if (!project)
38
+ return (0, flags_read_1.missingProject)(context);
39
+ const result = await context.api.get('api/v1/cli/keys', { project });
40
+ if (result.kind === 'network') {
41
+ context.emit.fail('server_error', result.message);
42
+ return exit_codes_1.EXIT.SERVER;
43
+ }
44
+ if (result.kind === 'error') {
45
+ context.emit.fail(result.code, result.message);
46
+ return (0, exit_codes_1.exitForServerCode)(result.code);
47
+ }
48
+ const { keys } = result.body;
49
+ context.emit.ok({ project, keys }, keys.length === 0
50
+ ? `No credentials in ${project} yet.`
51
+ : (0, output_1.table)(['ID', 'TYPE', 'LABEL', 'SCOPE', 'EXPIRES', 'STATE'], keys.map((key) => [
52
+ key.id,
53
+ key.type,
54
+ key.label || 'untitled',
55
+ // "Everywhere" for a kind with no scope, never a blank: a blank cell reads as missing
56
+ // data, and "this kind has no environment" is a fact.
57
+ key.scope ?? 'everywhere',
58
+ key.expiresAt ?? 'no expiry',
59
+ key.revokedAt ? 'revoked' : 'active',
60
+ ])));
61
+ return exit_codes_1.EXIT.OK;
62
+ },
63
+ };
64
+ exports.keysCreateCommand = {
65
+ path: ['keys', 'create'],
66
+ summary: 'mint a credential — the kind is required, never guessed',
67
+ usage: 'gf keys create --type flag_read --env production --label "my app"',
68
+ needsAuth: true,
69
+ detail: `--type is required and has no default:
70
+
71
+ ingest sends events into this project, and reads its funnels through the SDK
72
+ flag_read reads ONE environment's flag snapshot — needs --env
73
+ flag_sync registers flag definitions from an outside catalog — needs --source
74
+
75
+ These have different blast radii and the product models them separately; a CLI that
76
+ flattened them into "a key" would hand out the widest one by accident.
77
+
78
+ The value is printed ONCE. Only its hash is stored, so it cannot be shown again.`,
79
+ flags: [
80
+ { name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' },
81
+ { name: 'type', value: '<kind>', describe: `${KEY_TYPES.join(' | ')} — required` },
82
+ { name: 'label', value: '<text>', describe: 'what holds it — required' },
83
+ { name: 'env', value: '<environment>', describe: 'required for flag_read' },
84
+ { name: 'source', value: '<name>', describe: 'required for flag_sync' },
85
+ ],
86
+ async run(context) {
87
+ const project = (0, flags_read_1.resolveProject)(context);
88
+ if (!project)
89
+ return (0, flags_read_1.missingProject)(context);
90
+ const type = (0, args_1.flagValue)(context.args, 'type');
91
+ if (!type || !KEY_TYPES.includes(type)) {
92
+ context.emit.fail('invalid', `--type is required, and must be one of: ${KEY_TYPES.join(', ')}.`);
93
+ return exit_codes_1.EXIT.USAGE;
94
+ }
95
+ const label = (0, args_1.flagValue)(context.args, 'label')?.trim();
96
+ if (!label) {
97
+ context.emit.fail('invalid', '--label is required. Name what will hold this credential.');
98
+ return exit_codes_1.EXIT.USAGE;
99
+ }
100
+ // Refused HERE as well as at the server, so a caller learns what is missing without spending a
101
+ // round-trip on it — and so the message names the kind they actually asked for.
102
+ if (type === 'flag_read' && !(0, args_1.flagValue)(context.args, 'env')) {
103
+ context.emit.fail('invalid', 'A flag_read key reads ONE environment. Pass --env.');
104
+ return exit_codes_1.EXIT.USAGE;
105
+ }
106
+ if (type === 'flag_sync' && !(0, args_1.flagValue)(context.args, 'source')) {
107
+ context.emit.fail('invalid', 'A flag_sync key is attributed to a source catalog. Pass --source.');
108
+ return exit_codes_1.EXIT.USAGE;
109
+ }
110
+ const result = await context.api.post('api/v1/cli/keys', {
111
+ project,
112
+ type,
113
+ label,
114
+ environment: (0, args_1.flagValue)(context.args, 'env'),
115
+ source: (0, args_1.flagValue)(context.args, 'source'),
116
+ });
117
+ if (result.kind === 'network') {
118
+ context.emit.fail('server_error', result.message);
119
+ return exit_codes_1.EXIT.SERVER;
120
+ }
121
+ if (result.kind === 'error') {
122
+ context.emit.fail(result.code, result.message);
123
+ return (0, exit_codes_1.exitForServerCode)(result.code);
124
+ }
125
+ const minted = result.body;
126
+ context.emit.ok(minted, [
127
+ `${minted.type} credential for ${project}${minted.scope ? ` (${minted.scope})` : ''}`,
128
+ '',
129
+ minted.key,
130
+ '',
131
+ 'This is the only time it is shown — only a hash was stored.',
132
+ minted.expiresAt
133
+ ? `It expires ${minted.expiresAt}.`
134
+ : 'It does not expire; revoke it when you are done.',
135
+ ].join('\n'));
136
+ return exit_codes_1.EXIT.OK;
137
+ },
138
+ };
139
+ exports.keysRevokeCommand = {
140
+ path: ['keys', 'revoke'],
141
+ summary: 'kill a credential, immediately and permanently',
142
+ usage: 'gf keys revoke <id> --type ingest',
143
+ needsAuth: true,
144
+ detail: `--type is required, and it is not bureaucracy: each kind is revoked through its own
145
+ path so the audit trail records what actually happened. A trail whose label can be chosen
146
+ by picking an endpoint is worse than no trail — an operator asking "why did ingest stop?"
147
+ must not find the answer filed under something else.
148
+
149
+ Idempotent. A credential that is already revoked, never existed, or belongs to another
150
+ project is one answer: not found.`,
151
+ flags: [
152
+ { name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' },
153
+ { name: 'type', value: '<kind>', describe: `${KEY_TYPES.join(' | ')} — required` },
154
+ ],
155
+ async run(context) {
156
+ const project = (0, flags_read_1.resolveProject)(context);
157
+ if (!project)
158
+ return (0, flags_read_1.missingProject)(context);
159
+ const id = context.args.positionals[0];
160
+ const type = (0, args_1.flagValue)(context.args, 'type');
161
+ if (!id || !type || !KEY_TYPES.includes(type)) {
162
+ context.emit.fail('invalid', `Usage: \`gf keys revoke <id> --type ${KEY_TYPES.join('|')}\`.`);
163
+ return exit_codes_1.EXIT.USAGE;
164
+ }
165
+ const result = await context.api.del('api/v1/cli/keys', { project, type, id });
166
+ if (result.kind === 'network') {
167
+ context.emit.fail('server_error', result.message);
168
+ return exit_codes_1.EXIT.SERVER;
169
+ }
170
+ if (result.kind === 'error') {
171
+ context.emit.fail(result.code, result.message);
172
+ return (0, exit_codes_1.exitForServerCode)(result.code);
173
+ }
174
+ context.emit.ok({ project, type, id, revoked: true }, `Revoked ${type} credential ${id}.`);
175
+ return exit_codes_1.EXIT.OK;
176
+ },
177
+ };
@@ -0,0 +1,4 @@
1
+ import type { Command } from '../command';
2
+ export declare const projectsLsCommand: Command;
3
+ export declare const projectsCreateCommand: Command;
4
+ export declare const projectsUseCommand: Command;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ // golden-frijoles-cli · Sprint 1, Story 1.3 — `gf projects ls | create | use`.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.projectsUseCommand = exports.projectsCreateCommand = exports.projectsLsCommand = void 0;
5
+ const credentials_1 = require("../credentials");
6
+ const exit_codes_1 = require("../exit-codes");
7
+ const output_1 = require("../output");
8
+ exports.projectsLsCommand = {
9
+ path: ['projects', 'ls'],
10
+ summary: 'the projects this credential reaches',
11
+ usage: 'gf projects ls [--json]',
12
+ needsAuth: true,
13
+ flags: [],
14
+ async run(context) {
15
+ const result = await context.api.get('api/v1/cli/projects');
16
+ if (result.kind === 'network') {
17
+ context.emit.fail('server_error', result.message);
18
+ return exit_codes_1.EXIT.SERVER;
19
+ }
20
+ if (result.kind === 'error') {
21
+ context.emit.fail(result.code, result.message);
22
+ return (0, exit_codes_1.exitForServerCode)(result.code);
23
+ }
24
+ const { projects } = result.body;
25
+ context.emit.ok({ projects, activeProject: context.auth.activeProject }, projects.length === 0
26
+ ? 'This account belongs to no project yet. Run `gf projects create`.'
27
+ : (0, output_1.table)(['PROJECT', 'ROLE', ''], projects.map((project) => [
28
+ project.slug,
29
+ project.role,
30
+ project.slug === context.auth.activeProject ? 'active' : '',
31
+ ])));
32
+ return exit_codes_1.EXIT.OK;
33
+ },
34
+ };
35
+ exports.projectsCreateCommand = {
36
+ path: ['projects', 'create'],
37
+ summary: 'make sure this account has a project (idempotent)',
38
+ usage: 'gf projects create [--json]',
39
+ needsAuth: true,
40
+ detail: `⚠️ An ENSURE, not a second project.
41
+
42
+ The database enforces one self-serve project per account — a partial unique index that
43
+ closes a signup race, not a plan limit. So this reports the project you already have,
44
+ with created: false, and never mints a second. Use it in a script that wants to be sure
45
+ before \`gf init\`, without parsing \`gf projects ls\`.
46
+
47
+ It cannot rescue an account that has NO project: holding a CLI token already means you
48
+ have one. If you are in that state, open /app in a browser — it retries provisioning for
49
+ you.
50
+
51
+ It also never returns an ingest key. Use \`gf keys create --type ingest\` for that, where
52
+ the credential gets its own confirmation and its own audit row.`,
53
+ flags: [],
54
+ async run(context) {
55
+ const result = await context.api.post('api/v1/cli/projects', {});
56
+ if (result.kind === 'network') {
57
+ context.emit.fail('server_error', result.message);
58
+ return exit_codes_1.EXIT.SERVER;
59
+ }
60
+ if (result.kind === 'error') {
61
+ context.emit.fail(result.code, result.message);
62
+ return (0, exit_codes_1.exitForServerCode)(result.code);
63
+ }
64
+ const { created, slug } = result.body;
65
+ context.emit.ok({ created, slug }, created ? `Created project ${slug}.` : `This account already has project ${slug}. Nothing changed.`);
66
+ return exit_codes_1.EXIT.OK;
67
+ },
68
+ };
69
+ exports.projectsUseCommand = {
70
+ path: ['projects', 'use'],
71
+ summary: 'remember which project the other commands act on',
72
+ usage: 'gf projects use <slug>',
73
+ needsAuth: true,
74
+ detail: `Writes the choice into the saved credentials file. --project on any command
75
+ overrides it for that one run, and GOLDEN_FRIJOLES_PROJECT overrides both — which is
76
+ what CI should set, since CI has no credentials file to write.`,
77
+ flags: [],
78
+ async run(context) {
79
+ const slug = context.args.positionals[0];
80
+ if (!slug) {
81
+ context.emit.fail('invalid', 'Name a project: `gf projects use <slug>`.');
82
+ return exit_codes_1.EXIT.USAGE;
83
+ }
84
+ // ⚠️ VERIFIED against the server before it is saved. Writing an unchecked slug produces a file
85
+ // that makes every later command fail with that command's error — "no flag `x` in `y`" when the
86
+ // real answer is "`y` is not a project you can see" — which is the class of misdirection
87
+ // `gf doctor` exists to end, so it must not be created here in the first place.
88
+ const result = await context.api.get('api/v1/cli/projects');
89
+ if (result.kind === 'network') {
90
+ context.emit.fail('server_error', result.message);
91
+ return exit_codes_1.EXIT.SERVER;
92
+ }
93
+ if (result.kind === 'error') {
94
+ context.emit.fail(result.code, result.message);
95
+ return (0, exit_codes_1.exitForServerCode)(result.code);
96
+ }
97
+ if (!result.body.projects.some((project) => project.slug === slug)) {
98
+ // Not-a-member and does-not-exist are ONE answer, here as at the server (AGENTS #10).
99
+ context.emit.fail('not_found', `No project \`${slug}\` is available to this account.`);
100
+ return exit_codes_1.EXIT.NOT_FOUND;
101
+ }
102
+ const existing = (0, credentials_1.readCredentials)(context.env);
103
+ if (!existing) {
104
+ // Reachable with GOLDEN_FRIJOLES_TOKEN and no saved file — CI. There is nothing to remember
105
+ // into, and inventing a credentials file to hold a preference would write a token to a CI
106
+ // runner's home directory, which is precisely what the env-var path exists to avoid.
107
+ context.emit.fail('invalid', 'There is no saved credential to record this in. Set GOLDEN_FRIJOLES_PROJECT, or run `gf login` first.');
108
+ return exit_codes_1.EXIT.USAGE;
109
+ }
110
+ (0, credentials_1.writeCredentials)({ ...existing, activeProject: slug }, context.env);
111
+ context.emit.ok({ activeProject: slug }, `Now using ${slug}.`);
112
+ return exit_codes_1.EXIT.OK;
113
+ },
114
+ };
@@ -0,0 +1,60 @@
1
+ export declare const DEFAULT_API_URL = "https://goldenfrijoles.com";
2
+ /**
3
+ * What a CLI token looks like.
4
+ *
5
+ * ⚠️ **A SECOND copy of a regex that also lives in `apps/web/lib/cli-tokens.ts`, and the duplication
6
+ * is deliberate.** This package is published to npm and cannot import from the app; the alternative
7
+ * — no local check at all — costs a network round-trip to tell someone their paste was truncated,
8
+ * which is `gf doctor`'s single most useful answer.
9
+ *
10
+ * It is safe to duplicate because of what it is used FOR on each side. Here it is a HINT: a
11
+ * malformed token is reported early, and a token this rejects would have been rejected by the
12
+ * server anyway. There it is the lookup's own shape guard. The failure mode of drift is that this
13
+ * copy becomes stricter than the server (a valid token refused locally, which `gf --token` bypasses
14
+ * and `doctor` names) — never that an invalid one is accepted, because this side grants nothing.
15
+ */
16
+ export declare const CLI_TOKEN_FORMAT: RegExp;
17
+ export type Credentials = {
18
+ token: string;
19
+ /** The deployment this token belongs to. A token is not portable between deployments. */
20
+ apiUrl: string;
21
+ /** `gf projects use` writes this. Absent until someone chooses. */
22
+ activeProject?: string;
23
+ };
24
+ /**
25
+ * `$XDG_CONFIG_HOME/golden-frijoles/credentials.json`, falling back to `~/.config/…`.
26
+ *
27
+ * XDG first because a machine that sets it means it — putting the file in `~/.config` anyway is how
28
+ * a credential ends up outside whatever the operator has arranged to protect or to exclude from
29
+ * backups.
30
+ */
31
+ export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
32
+ export declare function readCredentials(env?: NodeJS.ProcessEnv): Credentials | null;
33
+ export declare function writeCredentials(next: Credentials, env?: NodeJS.ProcessEnv): string;
34
+ export type ResolvedAuth = {
35
+ token: string | null;
36
+ apiUrl: string;
37
+ activeProject: string | null;
38
+ /** Where the token came from — `gf doctor` and `gf whoami` say so, and never print the token. */
39
+ source: 'env' | 'flag' | 'file' | 'none';
40
+ };
41
+ /**
42
+ * The single resolution every command uses.
43
+ *
44
+ * Returns `token: null` rather than throwing, because "not logged in" is a normal state with its own
45
+ * exit code and its own sentence — and because `gf doctor` has to be able to describe it rather than
46
+ * die of it.
47
+ */
48
+ export declare function resolveAuth(options: {
49
+ tokenFlag?: string;
50
+ apiFlag?: string;
51
+ env?: NodeJS.ProcessEnv;
52
+ }): ResolvedAuth;
53
+ /**
54
+ * Trim a trailing slash and add a scheme if one is missing.
55
+ *
56
+ * A bare `localhost:3000` becomes `http://localhost:3000` and anything else becomes `https://…`.
57
+ * Guessing `http` for a public hostname would downgrade a credential-bearing request to plaintext,
58
+ * so the guess only goes that way for a loopback address.
59
+ */
60
+ export declare function normalizeApiUrl(raw: string): string;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ // golden-frijoles-cli · Sprint 1, Story 1.2 — where the token lives on disk.
3
+ //
4
+ // ── `0600`, and the directory `0700` ──────────────────────────────────────────────────────────
5
+ // This file holds a credential that signs its holder in to every project they belong to. The
6
+ // shaping named the failure: "key material on disk — 0600, never in the repo, never echoed by
7
+ // doctor". The mode is applied at WRITE time on every write, not only at creation, so a file whose
8
+ // permissions were loosened by hand is tightened the next time `gf login` runs.
9
+ //
10
+ // ── Precedence: env var, then flag, then file ─────────────────────────────────────────────────
11
+ // `GOLDEN_FRIJOLES_TOKEN` wins, because CI has no credentials file and must not be made to write
12
+ // one — a CI job that writes a token to `$HOME` leaves it in whatever the runner caches. The file
13
+ // is the interactive path.
14
+ //
15
+ // ⚠️ `--token` on the command line is accepted and deliberately NOT documented as the way to log
16
+ // in: `argv` is visible to every process on the machine and lands in shell history. `gf login` with
17
+ // no flag reads stdin instead, which does neither.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.CLI_TOKEN_FORMAT = exports.DEFAULT_API_URL = void 0;
20
+ exports.credentialsPath = credentialsPath;
21
+ exports.readCredentials = readCredentials;
22
+ exports.writeCredentials = writeCredentials;
23
+ exports.resolveAuth = resolveAuth;
24
+ exports.normalizeApiUrl = normalizeApiUrl;
25
+ const node_fs_1 = require("node:fs");
26
+ const node_os_1 = require("node:os");
27
+ const node_path_1 = require("node:path");
28
+ exports.DEFAULT_API_URL = 'https://goldenfrijoles.com';
29
+ /**
30
+ * What a CLI token looks like.
31
+ *
32
+ * ⚠️ **A SECOND copy of a regex that also lives in `apps/web/lib/cli-tokens.ts`, and the duplication
33
+ * is deliberate.** This package is published to npm and cannot import from the app; the alternative
34
+ * — no local check at all — costs a network round-trip to tell someone their paste was truncated,
35
+ * which is `gf doctor`'s single most useful answer.
36
+ *
37
+ * It is safe to duplicate because of what it is used FOR on each side. Here it is a HINT: a
38
+ * malformed token is reported early, and a token this rejects would have been rejected by the
39
+ * server anyway. There it is the lookup's own shape guard. The failure mode of drift is that this
40
+ * copy becomes stricter than the server (a valid token refused locally, which `gf --token` bypasses
41
+ * and `doctor` names) — never that an invalid one is accepted, because this side grants nothing.
42
+ */
43
+ exports.CLI_TOKEN_FORMAT = /^gf_pat_[A-Za-z0-9_-]{20,64}$/;
44
+ /**
45
+ * `$XDG_CONFIG_HOME/golden-frijoles/credentials.json`, falling back to `~/.config/…`.
46
+ *
47
+ * XDG first because a machine that sets it means it — putting the file in `~/.config` anyway is how
48
+ * a credential ends up outside whatever the operator has arranged to protect or to exclude from
49
+ * backups.
50
+ */
51
+ function credentialsPath(env = process.env) {
52
+ const base = env.XDG_CONFIG_HOME?.trim() || (0, node_path_1.join)(env.HOME || (0, node_os_1.homedir)(), '.config');
53
+ return (0, node_path_1.join)(base, 'golden-frijoles', 'credentials.json');
54
+ }
55
+ function readCredentials(env = process.env) {
56
+ const path = credentialsPath(env);
57
+ if (!(0, node_fs_1.existsSync)(path))
58
+ return null;
59
+ try {
60
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
61
+ if (typeof parsed.token !== 'string' || parsed.token === '')
62
+ return null;
63
+ return {
64
+ token: parsed.token,
65
+ apiUrl: typeof parsed.apiUrl === 'string' && parsed.apiUrl !== '' ? parsed.apiUrl : exports.DEFAULT_API_URL,
66
+ activeProject: typeof parsed.activeProject === 'string' ? parsed.activeProject : undefined,
67
+ };
68
+ }
69
+ catch {
70
+ // A corrupt file reads as "not logged in" rather than throwing. `gf doctor` is the verb that
71
+ // explains WHY — it re-reads the file itself and reports it as unreadable — so the ordinary
72
+ // commands can treat every "no usable credential" the same way and print one remedy.
73
+ return null;
74
+ }
75
+ }
76
+ function writeCredentials(next, env = process.env) {
77
+ const path = credentialsPath(env);
78
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
79
+ (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
80
+ // ⚠️ chmod AFTER the write, every time. `writeFileSync`'s `mode` is only applied when the file is
81
+ // CREATED — an existing file keeps whatever mode it had, so a re-login into a world-readable file
82
+ // left it world-readable. This is the line that makes the 0600 claim true on the second run.
83
+ (0, node_fs_1.chmodSync)(path, 0o600);
84
+ return path;
85
+ }
86
+ /**
87
+ * The single resolution every command uses.
88
+ *
89
+ * Returns `token: null` rather than throwing, because "not logged in" is a normal state with its own
90
+ * exit code and its own sentence — and because `gf doctor` has to be able to describe it rather than
91
+ * die of it.
92
+ */
93
+ function resolveAuth(options) {
94
+ const env = options.env ?? process.env;
95
+ const file = readCredentials(env);
96
+ const envToken = env.GOLDEN_FRIJOLES_TOKEN?.trim();
97
+ const apiUrl = normalizeApiUrl(options.apiFlag?.trim() || env.GOLDEN_FRIJOLES_URL?.trim() || file?.apiUrl || exports.DEFAULT_API_URL);
98
+ const activeProject = env.GOLDEN_FRIJOLES_PROJECT?.trim() || file?.activeProject || null;
99
+ if (envToken)
100
+ return { token: envToken, apiUrl, activeProject, source: 'env' };
101
+ if (options.tokenFlag?.trim())
102
+ return { token: options.tokenFlag.trim(), apiUrl, activeProject, source: 'flag' };
103
+ if (file)
104
+ return { token: file.token, apiUrl, activeProject, source: 'file' };
105
+ return { token: null, apiUrl, activeProject, source: 'none' };
106
+ }
107
+ /**
108
+ * Trim a trailing slash and add a scheme if one is missing.
109
+ *
110
+ * A bare `localhost:3000` becomes `http://localhost:3000` and anything else becomes `https://…`.
111
+ * Guessing `http` for a public hostname would downgrade a credential-bearing request to plaintext,
112
+ * so the guess only goes that way for a loopback address.
113
+ */
114
+ function normalizeApiUrl(raw) {
115
+ const trimmed = raw.trim().replace(/\/+$/, '');
116
+ if (/^https?:\/\//i.test(trimmed))
117
+ return trimmed;
118
+ const loopback = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(trimmed);
119
+ return `${loopback ? 'http' : 'https'}://${trimmed}`;
120
+ }
@@ -0,0 +1,24 @@
1
+ export declare const EXIT: {
2
+ readonly OK: 0;
3
+ readonly USAGE: 1;
4
+ readonly AUTH: 2;
5
+ readonly NOT_FOUND: 3;
6
+ readonly CONFLICT: 4;
7
+ readonly PARTIAL: 5;
8
+ readonly SERVER: 6;
9
+ };
10
+ export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
11
+ /**
12
+ * The server's `code` → this CLI's exit code.
13
+ *
14
+ * `lib/cli-auth.ts` carries the other half of this mapping. They are two files in two packages and
15
+ * cannot share a type, so the pairing is asserted by an e2e spec rather than assumed — an API that
16
+ * grew a seventh code would otherwise fall silently into `SERVER` here.
17
+ */
18
+ export declare function exitForServerCode(code: string | undefined): ExitCode;
19
+ /** Every code, for `--help` and for the golden file. Ordered by value so the list cannot reshuffle. */
20
+ export declare const EXIT_CODE_TABLE: ReadonlyArray<{
21
+ code: ExitCode;
22
+ name: string;
23
+ means: string;
24
+ }>;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXIT_CODE_TABLE = exports.EXIT = void 0;
4
+ exports.exitForServerCode = exitForServerCode;
5
+ // golden-frijoles-cli · Sprint 1, Story 1.1 — the exit codes, as named constants.
6
+ //
7
+ // ── Why an enum and not `process.exit(1)` at forty call sites ─────────────────────────────────
8
+ // The point of this CLI is that an agent drives it. An agent branches on the exit code, and a shell
9
+ // script branches on nothing else at all. So the codes are a CONTRACT — pinned by a golden file
10
+ // (D5), printed by `gf --help`, and named here once so a new verb cannot invent a meaning for `4`
11
+ // that disagrees with every other verb's `4`.
12
+ //
13
+ // ── Why these seven, and no more ──────────────────────────────────────────────────────────────
14
+ // Each one exists because a caller would do something DIFFERENT about it. That is the test for
15
+ // adding an eighth: if the remedy is the same, it is the same code.
16
+ //
17
+ // USAGE — fix the command. Nothing was sent.
18
+ // AUTH — log in again. The credential is not accepted.
19
+ // NOT_FOUND — the thing is not there, or is not yours. Deliberately one code: "not yours" and
20
+ // "not there" are indistinguishable by design (AGENTS #10), so an exit code that
21
+ // told them apart would leak what the API refuses to.
22
+ // CONFLICT — someone else moved it. Re-read and retry. This is the one an agent can RESOLVE by
23
+ // itself, which is why it must not collapse into SERVER.
24
+ // PARTIAL — D2. Some environments changed and some did not. Not a success and not a clean
25
+ // failure, and a CLI with no code for it forces the caller to parse prose.
26
+ // SERVER — the server or the network is unwell. RETRY; do not mint anything new.
27
+ exports.EXIT = {
28
+ OK: 0,
29
+ USAGE: 1,
30
+ AUTH: 2,
31
+ NOT_FOUND: 3,
32
+ CONFLICT: 4,
33
+ PARTIAL: 5,
34
+ SERVER: 6,
35
+ };
36
+ /**
37
+ * The server's `code` → this CLI's exit code.
38
+ *
39
+ * `lib/cli-auth.ts` carries the other half of this mapping. They are two files in two packages and
40
+ * cannot share a type, so the pairing is asserted by an e2e spec rather than assumed — an API that
41
+ * grew a seventh code would otherwise fall silently into `SERVER` here.
42
+ */
43
+ function exitForServerCode(code) {
44
+ switch (code) {
45
+ case 'unauthorized':
46
+ return exports.EXIT.AUTH;
47
+ case 'not_found':
48
+ return exports.EXIT.NOT_FOUND;
49
+ case 'invalid':
50
+ return exports.EXIT.USAGE;
51
+ case 'conflict':
52
+ return exports.EXIT.CONFLICT;
53
+ case 'disabled':
54
+ // The CLI API is switched off on this deployment. Not an auth problem and not the caller's
55
+ // mistake — there is nothing to fix in the command, so it reads as a server-side condition.
56
+ return exports.EXIT.SERVER;
57
+ default:
58
+ return exports.EXIT.SERVER;
59
+ }
60
+ }
61
+ /** Every code, for `--help` and for the golden file. Ordered by value so the list cannot reshuffle. */
62
+ exports.EXIT_CODE_TABLE = [
63
+ { code: exports.EXIT.OK, name: 'ok', means: 'it worked' },
64
+ { code: exports.EXIT.USAGE, name: 'usage', means: 'the command is wrong — nothing was sent' },
65
+ { code: exports.EXIT.AUTH, name: 'auth', means: 'the credential is not accepted — run `gf login`' },
66
+ { code: exports.EXIT.NOT_FOUND, name: 'not-found', means: 'no such thing, or not yours' },
67
+ { code: exports.EXIT.CONFLICT, name: 'conflict', means: 'someone else changed it — re-read and retry' },
68
+ { code: exports.EXIT.PARTIAL, name: 'partial', means: 'some environments changed and some did not' },
69
+ { code: exports.EXIT.SERVER, name: 'server', means: 'the server or the network is unwell — retry' },
70
+ ];
package/dist/help.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { Command } from './command';
2
+ /**
3
+ * `--help` as DATA, for `--json`.
4
+ *
5
+ * ⚠️ **This exists because `gf --json --help` printed the plain-text help to stdout** (cross-family
6
+ * review, Codex, round 2). `output.ts` states the contract in one line — under `--json`, stdout
7
+ * carries exactly one JSON document and nothing else — and the help path wrote straight to the
8
+ * writer, bypassing the emitter entirely. An agent that piped `gf --json --help` into a parser got
9
+ * a wall of prose.
10
+ *
11
+ * It is a STRUCTURED shape rather than `{ help: "<the same text>" }`, because the reason an agent
12
+ * asks for help is to learn the verbs and their flags — and a string forces it to parse the layout
13
+ * of a table it did not write. Both forms are pinned by golden files; changing either is a change
14
+ * to what every agent believes this tool can do.
15
+ */
16
+ export declare function helpAsData(commands: readonly Command[]): {
17
+ usage: string;
18
+ commands: {
19
+ command: string;
20
+ summary: string;
21
+ usage: string;
22
+ needsAuth: boolean;
23
+ flags: {
24
+ flag: string;
25
+ takesValue: boolean;
26
+ describe: string;
27
+ }[];
28
+ }[];
29
+ globalFlags: string[];
30
+ environment: string[];
31
+ exitCodes: {
32
+ code: import("./exit-codes").ExitCode;
33
+ name: string;
34
+ means: string;
35
+ }[];
36
+ };
37
+ export declare function commandAsData(command: Command): {
38
+ command: string;
39
+ summary: string;
40
+ usage: string;
41
+ needsAuth: boolean;
42
+ flags: {
43
+ flag: string;
44
+ takesValue: boolean;
45
+ describe: string;
46
+ }[];
47
+ };
48
+ export declare function renderRootHelp(commands: readonly Command[]): string;
49
+ export declare function renderCommandHelp(command: Command): string;