@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 1, Story 1.2 — `gf login`, `gf logout`, `gf whoami`.
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.whoamiCommand = exports.logoutCommand = exports.loginCommand = void 0;
|
|
38
|
+
const args_1 = require("../args");
|
|
39
|
+
const credentials_1 = require("../credentials");
|
|
40
|
+
const exit_codes_1 = require("../exit-codes");
|
|
41
|
+
const output_1 = require("../output");
|
|
42
|
+
/**
|
|
43
|
+
* Read a token from stdin when `--token` was not given.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ **stdin, not `argv`, and not a prompt with echo.** A token in `argv` is readable by every
|
|
46
|
+
* process on the machine (`ps`) and lands in shell history; the shaping listed key material on disk
|
|
47
|
+
* as a rabbit hole and this is the same hazard one step earlier. Piping (`… | gf login`) is the CI
|
|
48
|
+
* shape and works identically.
|
|
49
|
+
*/
|
|
50
|
+
async function readTokenFromStdin(context) {
|
|
51
|
+
// ⚠️ **The `--json` branch used to only SUPPRESS the prompt, and then read stdin anyway** (fresh
|
|
52
|
+
// reviewer, PR #149, graded Blocking). On an interactive terminal `gf login --json` printed
|
|
53
|
+
// nothing at all and blocked until the user guessed at Ctrl-D — strictly worse than the
|
|
54
|
+
// non-`--json` path it was meant to improve on, and on the credential-entry path. The comment
|
|
55
|
+
// above it claimed it returned a usage error. It did not.
|
|
56
|
+
//
|
|
57
|
+
// Now the two conditions are separate facts and both are acted on:
|
|
58
|
+
// • nothing is piped AND there is no one to ask (`--json`) ⇒ return null, the caller exits 1
|
|
59
|
+
// • nothing is piped and there IS someone to ask ⇒ prompt, then read
|
|
60
|
+
// • something is piped ⇒ read it, prompt or not. This is the CI shape and must never block.
|
|
61
|
+
if (process.stdin.isTTY) {
|
|
62
|
+
if (context.emit.json)
|
|
63
|
+
return null;
|
|
64
|
+
context.emit.note('Paste your CLI token and press Enter:');
|
|
65
|
+
// ⚠️ **ONE LINE, not "read to EOF"** (cross-family review, Codex, round 3, graded Blocking).
|
|
66
|
+
// `for await (const chunk of process.stdin)` ends at EOF, and pressing Enter on a terminal does
|
|
67
|
+
// NOT close stdin — so the documented happy path, `gf login` with no flags, printed the prompt,
|
|
68
|
+
// accepted the paste, and then hung until the user guessed at Ctrl-D. The verb every new user
|
|
69
|
+
// runs first, unusable, under a prompt that said it was waiting for Enter.
|
|
70
|
+
//
|
|
71
|
+
// `readline` is the thing that knows what a line is. The interface is closed either way, so the
|
|
72
|
+
// process does not stay alive holding the TTY open.
|
|
73
|
+
const readline = await Promise.resolve().then(() => __importStar(require('node:readline/promises')));
|
|
74
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
75
|
+
try {
|
|
76
|
+
const line = await rl[Symbol.asyncIterator]().next();
|
|
77
|
+
const value = typeof line.value === 'string' ? line.value.trim() : '';
|
|
78
|
+
return value === '' ? null : value;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
rl.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// PIPED. Read to EOF, which is exactly right here and is the CI shape: `echo $TOKEN | gf login`
|
|
85
|
+
// closes stdin, and a token that arrives in several chunks is reassembled.
|
|
86
|
+
const chunks = [];
|
|
87
|
+
for await (const chunk of process.stdin)
|
|
88
|
+
chunks.push(Buffer.from(chunk));
|
|
89
|
+
const value = Buffer.concat(chunks).toString('utf8').trim();
|
|
90
|
+
return value === '' ? null : value;
|
|
91
|
+
}
|
|
92
|
+
exports.loginCommand = {
|
|
93
|
+
path: ['login'],
|
|
94
|
+
summary: 'save a CLI token for this machine',
|
|
95
|
+
usage: 'gf login [--token <token>] [--api <url>]',
|
|
96
|
+
needsAuth: false,
|
|
97
|
+
detail: `Mint a token at /app/setup/cli in the console, then paste it here.
|
|
98
|
+
|
|
99
|
+
With no --token, the token is read from STDIN — so it never appears in your shell
|
|
100
|
+
history or in \`ps\`. For CI, set GOLDEN_FRIJOLES_TOKEN instead and skip this verb;
|
|
101
|
+
nothing is written to disk in that case.`,
|
|
102
|
+
flags: [
|
|
103
|
+
{ name: 'token', value: '<token>', describe: 'the token, instead of reading stdin' },
|
|
104
|
+
{ name: 'api', value: '<url>', describe: `the deployment (default: ${credentials_1.DEFAULT_API_URL})` },
|
|
105
|
+
],
|
|
106
|
+
async run(context) {
|
|
107
|
+
const token = (0, args_1.flagValue)(context.args, 'token')?.trim() || (await readTokenFromStdin(context));
|
|
108
|
+
if (!token) {
|
|
109
|
+
context.emit.fail('invalid', 'No token supplied. Pass --token, or pipe one into `gf login`.');
|
|
110
|
+
return exit_codes_1.EXIT.USAGE;
|
|
111
|
+
}
|
|
112
|
+
const apiUrl = (0, credentials_1.normalizeApiUrl)((0, args_1.flagValue)(context.args, 'api')?.trim() || context.env.GOLDEN_FRIJOLES_URL?.trim() || credentials_1.DEFAULT_API_URL);
|
|
113
|
+
// ⚠️ VERIFY before saving. Writing an unverified token produces a credentials file that looks
|
|
114
|
+
// fine and fails on every later command with an error about that command — which is how someone
|
|
115
|
+
// spends an afternoon debugging `gf flags ls` when the real answer is "that paste was truncated".
|
|
116
|
+
const probe = await context.clientFor(token).get('api/v1/cli/whoami');
|
|
117
|
+
if (probe.kind === 'network') {
|
|
118
|
+
context.emit.fail('server_error', probe.message);
|
|
119
|
+
return exit_codes_1.EXIT.SERVER;
|
|
120
|
+
}
|
|
121
|
+
if (probe.kind === 'error') {
|
|
122
|
+
context.emit.fail(probe.code, probe.message);
|
|
123
|
+
return (0, exit_codes_1.exitForServerCode)(probe.code);
|
|
124
|
+
}
|
|
125
|
+
const existing = (0, credentials_1.readCredentials)(context.env);
|
|
126
|
+
const path = (0, credentials_1.writeCredentials)({
|
|
127
|
+
token,
|
|
128
|
+
apiUrl,
|
|
129
|
+
// Keep the active project only if it is still one this account can reach. A token swapped
|
|
130
|
+
// for a different account would otherwise leave `gf flags ls` pointed at a project the new
|
|
131
|
+
// credential 404s on, and the error would name the flag rather than the stale selection.
|
|
132
|
+
activeProject: probe.body.projects.some((project) => project.slug === existing?.activeProject)
|
|
133
|
+
? existing?.activeProject
|
|
134
|
+
: probe.body.projects[0]?.slug,
|
|
135
|
+
}, context.env);
|
|
136
|
+
context.emit.ok({
|
|
137
|
+
account: probe.body.account,
|
|
138
|
+
apiUrl,
|
|
139
|
+
credentialsPath: path,
|
|
140
|
+
projects: probe.body.projects,
|
|
141
|
+
}, `Signed in as ${probe.body.account.email ?? probe.body.account.userId} on ${apiUrl}.\n` +
|
|
142
|
+
`Saved to ${path} (mode 0600).`);
|
|
143
|
+
return exit_codes_1.EXIT.OK;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
exports.logoutCommand = {
|
|
147
|
+
path: ['logout'],
|
|
148
|
+
summary: 'forget the saved token on this machine',
|
|
149
|
+
usage: 'gf logout',
|
|
150
|
+
needsAuth: false,
|
|
151
|
+
detail: `Removes the local credential only. It does NOT revoke the token — anything else
|
|
152
|
+
holding it still works. Revoke at /app/setup/cli when that is what you mean.`,
|
|
153
|
+
flags: [],
|
|
154
|
+
async run(context) {
|
|
155
|
+
const path = (0, credentials_1.credentialsPath)(context.env);
|
|
156
|
+
if (!(0, credentials_1.readCredentials)(context.env)) {
|
|
157
|
+
context.emit.ok({ removed: false, credentialsPath: path }, 'No saved credential to remove.');
|
|
158
|
+
return exit_codes_1.EXIT.OK;
|
|
159
|
+
}
|
|
160
|
+
// Overwritten with an empty token rather than unlinked: `readCredentials` treats it as "not
|
|
161
|
+
// logged in", the file keeps its 0600 mode, and `gf doctor` can still report where it looked.
|
|
162
|
+
(0, credentials_1.writeCredentials)({ token: '', apiUrl: credentials_1.DEFAULT_API_URL }, context.env);
|
|
163
|
+
context.emit.ok({ removed: true, credentialsPath: path }, `Removed the saved credential from ${path}. The token itself is NOT revoked — revoke it at /app/setup/cli.`);
|
|
164
|
+
return exit_codes_1.EXIT.OK;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
exports.whoamiCommand = {
|
|
168
|
+
path: ['whoami'],
|
|
169
|
+
summary: 'the account, the credential and the projects it reaches',
|
|
170
|
+
usage: 'gf whoami [--json]',
|
|
171
|
+
needsAuth: true,
|
|
172
|
+
flags: [],
|
|
173
|
+
async run(context) {
|
|
174
|
+
const result = await context.api.get('api/v1/cli/whoami');
|
|
175
|
+
if (result.kind === 'network') {
|
|
176
|
+
context.emit.fail('server_error', result.message);
|
|
177
|
+
return exit_codes_1.EXIT.SERVER;
|
|
178
|
+
}
|
|
179
|
+
if (result.kind === 'error') {
|
|
180
|
+
context.emit.fail(result.code, result.message);
|
|
181
|
+
return (0, exit_codes_1.exitForServerCode)(result.code);
|
|
182
|
+
}
|
|
183
|
+
const { account, credential, projects } = result.body;
|
|
184
|
+
context.emit.ok({
|
|
185
|
+
account,
|
|
186
|
+
// The credential's id and LABEL. Never the token — `gf doctor`'s rule applies here too, and
|
|
187
|
+
// `whoami` is the command most likely to be pasted into an issue.
|
|
188
|
+
credential,
|
|
189
|
+
apiUrl: context.api.baseUrl,
|
|
190
|
+
tokenSource: context.auth.source,
|
|
191
|
+
activeProject: context.auth.activeProject,
|
|
192
|
+
projects,
|
|
193
|
+
}, [
|
|
194
|
+
`${account.email ?? account.userId} on ${context.api.baseUrl}`,
|
|
195
|
+
`credential: ${credential.label} (from ${describeSource(context.auth.source)})`,
|
|
196
|
+
`active project: ${context.auth.activeProject ?? 'none — run `gf projects use <slug>`'}`,
|
|
197
|
+
'',
|
|
198
|
+
(0, output_1.table)(['PROJECT', 'ROLE'], projects.map((project) => [
|
|
199
|
+
project.slug + (project.slug === context.auth.activeProject ? ' *' : ''),
|
|
200
|
+
project.role,
|
|
201
|
+
])),
|
|
202
|
+
].join('\n'));
|
|
203
|
+
return exit_codes_1.EXIT.OK;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
function describeSource(source) {
|
|
207
|
+
if (source === 'env')
|
|
208
|
+
return 'GOLDEN_FRIJOLES_TOKEN';
|
|
209
|
+
if (source === 'flag')
|
|
210
|
+
return '--token';
|
|
211
|
+
if (source === 'file')
|
|
212
|
+
return 'the saved credentials file';
|
|
213
|
+
return 'nowhere';
|
|
214
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from '../command';
|
|
2
|
+
export type CheckStatus = 'ok' | 'fail' | 'warn' | 'skipped';
|
|
3
|
+
export type Check = {
|
|
4
|
+
id: string;
|
|
5
|
+
status: CheckStatus;
|
|
6
|
+
/** One sentence a person can act on. Never "something went wrong". */
|
|
7
|
+
detail: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const doctorCommand: Command;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 1, Story 1.5 — `gf doctor`.
|
|
3
|
+
//
|
|
4
|
+
// ── What this verb is for ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
// The seed's words: *"an agent that can't tell WHY it's unauthenticated burns a whole session
|
|
6
|
+
// guessing."* So `doctor` runs the checks in dependency order and reports every one of them —
|
|
7
|
+
// including the ones it could not run, and why — rather than stopping at the first failure with a
|
|
8
|
+
// single sentence.
|
|
9
|
+
//
|
|
10
|
+
// ── It requires no credential, and that is load-bearing ───────────────────────────────────────
|
|
11
|
+
// `needsAuth: false`. The dispatcher refusing this verb for want of a credential would make the
|
|
12
|
+
// tool useless exactly when it is needed: diagnosing a missing credential.
|
|
13
|
+
//
|
|
14
|
+
// ── It NEVER prints key material ──────────────────────────────────────────────────────────────
|
|
15
|
+
// Not the token, not a prefix, not a length. `doctor` output is the thing people paste into an
|
|
16
|
+
// issue, and `cli.test.ts`'s "gf doctor never prints key material, in either mode" asserts that a
|
|
17
|
+
// known token string appears nowhere in its output — a claim about a security property gets an
|
|
18
|
+
// assertion, not a comment. (That citation named a `doctor.test.ts` which does not exist; a
|
|
19
|
+
// pointer to a missing guard is how the next reader concludes there isn't one.)
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.doctorCommand = void 0;
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const args_1 = require("../args");
|
|
24
|
+
const credentials_1 = require("../credentials");
|
|
25
|
+
const exit_codes_1 = require("../exit-codes");
|
|
26
|
+
const output_1 = require("../output");
|
|
27
|
+
const version_1 = require("../version");
|
|
28
|
+
exports.doctorCommand = {
|
|
29
|
+
path: ['doctor'],
|
|
30
|
+
summary: 'why is it not working — every check, in order',
|
|
31
|
+
usage: 'gf doctor [--json]',
|
|
32
|
+
needsAuth: false,
|
|
33
|
+
detail: `Runs whether or not you are logged in — diagnosing a missing credential is the
|
|
34
|
+
point. Prints no key material in either mode.
|
|
35
|
+
|
|
36
|
+
Exits 0 when every check that could run passed, and non-zero naming the first that did not.`,
|
|
37
|
+
flags: [{ name: 'project', value: '<slug>', describe: 'check this project instead of the remembered one' }],
|
|
38
|
+
async run(context) {
|
|
39
|
+
const checks = [];
|
|
40
|
+
const path = (0, credentials_1.credentialsPath)(context.env);
|
|
41
|
+
// ── 1. is there a credential at all, and where did it come from ───────────────────────────
|
|
42
|
+
const fileExists = (0, node_fs_1.existsSync)(path);
|
|
43
|
+
const parsedFile = (0, credentials_1.readCredentials)(context.env);
|
|
44
|
+
if (fileExists && parsedFile === null) {
|
|
45
|
+
// The one case where "no credential" has a different remedy: the file is there and unusable.
|
|
46
|
+
// Collapsing it into "not logged in" sends someone to `gf login` when the answer may be a
|
|
47
|
+
// half-written file from an interrupted paste.
|
|
48
|
+
checks.push({
|
|
49
|
+
id: 'credentials-file',
|
|
50
|
+
status: 'fail',
|
|
51
|
+
detail: `${path} exists but could not be read as a credential. Delete it and run \`gf login\`.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (fileExists) {
|
|
55
|
+
checks.push({ id: 'credentials-file', status: 'ok', detail: `Readable at ${path}.` });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
checks.push({
|
|
59
|
+
id: 'credentials-file',
|
|
60
|
+
status: context.auth.source === 'env' ? 'skipped' : 'warn',
|
|
61
|
+
detail: context.auth.source === 'env'
|
|
62
|
+
? `No file at ${path} — GOLDEN_FRIJOLES_TOKEN is in use, which is the CI path.`
|
|
63
|
+
: `No file at ${path}. Run \`gf login\`.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (context.auth.token === null) {
|
|
67
|
+
checks.push({
|
|
68
|
+
id: 'credential',
|
|
69
|
+
status: 'fail',
|
|
70
|
+
detail: 'No credential. Run `gf login`, or set GOLDEN_FRIJOLES_TOKEN.',
|
|
71
|
+
});
|
|
72
|
+
return report(context, checks);
|
|
73
|
+
}
|
|
74
|
+
checks.push({
|
|
75
|
+
id: 'credential',
|
|
76
|
+
status: 'ok',
|
|
77
|
+
// The SOURCE, never the value.
|
|
78
|
+
detail: `Found, from ${describeSource(context.auth.source)}.`,
|
|
79
|
+
});
|
|
80
|
+
// ── 2. does it even look like one ─────────────────────────────────────────────────────────
|
|
81
|
+
// A local shape check, before the network, so a truncated paste is named as a truncated paste
|
|
82
|
+
// rather than as a rejected credential — different remedies, and the server cannot tell them
|
|
83
|
+
// apart because it deliberately answers both the same way.
|
|
84
|
+
if (!credentials_1.CLI_TOKEN_FORMAT.test(context.auth.token)) {
|
|
85
|
+
checks.push({
|
|
86
|
+
id: 'credential-shape',
|
|
87
|
+
status: 'fail',
|
|
88
|
+
detail: 'That credential is not shaped like a CLI token (`gf_pat_…`). It may have been truncated.',
|
|
89
|
+
});
|
|
90
|
+
return report(context, checks);
|
|
91
|
+
}
|
|
92
|
+
checks.push({ id: 'credential-shape', status: 'ok', detail: 'Shaped like a CLI token.' });
|
|
93
|
+
// ── 3. is the deployment reachable, and does it have the CLI API ──────────────────────────
|
|
94
|
+
const whoami = await context.api.get('api/v1/cli/whoami');
|
|
95
|
+
if (whoami.kind === 'network') {
|
|
96
|
+
checks.push({ id: 'api-reachable', status: 'fail', detail: whoami.message });
|
|
97
|
+
return report(context, checks);
|
|
98
|
+
}
|
|
99
|
+
if (whoami.kind === 'error' && whoami.code === 'disabled') {
|
|
100
|
+
checks.push({
|
|
101
|
+
id: 'api-reachable',
|
|
102
|
+
status: 'fail',
|
|
103
|
+
detail: `${context.api.baseUrl} answers, but its CLI API is switched off (CLI_WRITE_API_ENABLED=false).`,
|
|
104
|
+
});
|
|
105
|
+
return report(context, checks);
|
|
106
|
+
}
|
|
107
|
+
if (whoami.kind === 'error' && whoami.code === 'unauthorized') {
|
|
108
|
+
checks.push({ id: 'api-reachable', status: 'ok', detail: `${context.api.baseUrl} answers.` });
|
|
109
|
+
checks.push({
|
|
110
|
+
id: 'credential-accepted',
|
|
111
|
+
status: 'fail',
|
|
112
|
+
// Unknown, revoked and expired are one answer at the server by design, so `doctor` must not
|
|
113
|
+
// invent a distinction it cannot have. It names all three and one remedy that covers them.
|
|
114
|
+
detail: 'The deployment rejected this credential — unknown, revoked or expired. Mint a new one at /app/setup/cli.',
|
|
115
|
+
});
|
|
116
|
+
return report(context, checks);
|
|
117
|
+
}
|
|
118
|
+
if (whoami.kind === 'error') {
|
|
119
|
+
checks.push({
|
|
120
|
+
id: 'api-reachable',
|
|
121
|
+
status: 'fail',
|
|
122
|
+
detail: `${context.api.baseUrl} answered ${whoami.status}: ${whoami.message}`,
|
|
123
|
+
});
|
|
124
|
+
return report(context, checks);
|
|
125
|
+
}
|
|
126
|
+
checks.push({ id: 'api-reachable', status: 'ok', detail: `${context.api.baseUrl} answers.` });
|
|
127
|
+
checks.push({
|
|
128
|
+
id: 'credential-accepted',
|
|
129
|
+
status: 'ok',
|
|
130
|
+
detail: `Signed in as ${whoami.body.account.email ?? whoami.body.account.userId} (${whoami.body.credential.label}).`,
|
|
131
|
+
});
|
|
132
|
+
// ── 4. is the active project one this credential can reach ────────────────────────────────
|
|
133
|
+
const wanted = (0, args_1.flagValue)(context.args, 'project')?.trim() || context.auth.activeProject;
|
|
134
|
+
if (!wanted) {
|
|
135
|
+
checks.push({
|
|
136
|
+
id: 'active-project',
|
|
137
|
+
status: 'warn',
|
|
138
|
+
detail: 'No project chosen. Run `gf projects use <slug>` or pass --project.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else if (whoami.body.projects.some((project) => project.slug === wanted)) {
|
|
142
|
+
checks.push({ id: 'active-project', status: 'ok', detail: `\`${wanted}\` is reachable.` });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
checks.push({
|
|
146
|
+
id: 'active-project',
|
|
147
|
+
status: 'fail',
|
|
148
|
+
detail: `\`${wanted}\` is not a project this credential can reach. ` +
|
|
149
|
+
`Available: ${whoami.body.projects.map((project) => project.slug).join(', ') || 'none'}.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// ── 5. is this CLI current ────────────────────────────────────────────────────────────────
|
|
153
|
+
checks.push(await versionCheck(context.fetchImpl));
|
|
154
|
+
return report(context, checks);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Is a newer `@golden-frijoles/cli` published?
|
|
159
|
+
*
|
|
160
|
+
* ⚠️ It takes the run's `fetchImpl` rather than calling the global `fetch`. A direct call made
|
|
161
|
+
* every `doctor` test reach registry.npmjs.org for real, and left this check unassertable — the
|
|
162
|
+
* same second-HTTP-path defect `init.ts`'s `probeFlagReadKey` records having already made once.
|
|
163
|
+
*
|
|
164
|
+
* ⚠️ A `warn` or a `skipped`, NEVER a `fail`. The registry is a third party: an offline machine, a
|
|
165
|
+
* corporate proxy or an npm outage must not make `gf doctor` report that the CLI is broken. Being
|
|
166
|
+
* unable to check is a different fact from being out of date, and this reports which.
|
|
167
|
+
*/
|
|
168
|
+
async function versionCheck(fetchImpl) {
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetchImpl('https://registry.npmjs.org/@golden-frijoles/cli/latest', {
|
|
171
|
+
headers: { accept: 'application/json' },
|
|
172
|
+
signal: AbortSignal.timeout(5_000),
|
|
173
|
+
});
|
|
174
|
+
if (!response.ok)
|
|
175
|
+
return {
|
|
176
|
+
id: 'cli-version',
|
|
177
|
+
status: 'skipped',
|
|
178
|
+
detail: `Running ${version_1.VERSION}. Could not reach the npm registry to compare.`,
|
|
179
|
+
};
|
|
180
|
+
const body = (await response.json());
|
|
181
|
+
const latest = typeof body.version === 'string' ? body.version : null;
|
|
182
|
+
if (!latest)
|
|
183
|
+
return {
|
|
184
|
+
id: 'cli-version',
|
|
185
|
+
status: 'skipped',
|
|
186
|
+
detail: `Running ${version_1.VERSION}. The registry gave no version to compare.`,
|
|
187
|
+
};
|
|
188
|
+
return latest === version_1.VERSION
|
|
189
|
+
? { id: 'cli-version', status: 'ok', detail: `Running ${version_1.VERSION}, the latest.` }
|
|
190
|
+
: {
|
|
191
|
+
id: 'cli-version',
|
|
192
|
+
status: 'warn',
|
|
193
|
+
detail: `Running ${version_1.VERSION}; ${latest} is published. Update with \`npm i -g @golden-frijoles/cli\`.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return {
|
|
198
|
+
id: 'cli-version',
|
|
199
|
+
status: 'skipped',
|
|
200
|
+
detail: `Running ${version_1.VERSION}. Could not reach the npm registry to compare.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function describeSource(source) {
|
|
205
|
+
if (source === 'env')
|
|
206
|
+
return 'GOLDEN_FRIJOLES_TOKEN';
|
|
207
|
+
if (source === 'flag')
|
|
208
|
+
return '--token';
|
|
209
|
+
return 'the saved credentials file';
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Print every check and choose the exit code.
|
|
213
|
+
*
|
|
214
|
+
* A `warn` does NOT fail. "No project chosen" and "a newer CLI exists" are both things a caller may
|
|
215
|
+
* legitimately be living with, and a doctor that exits non-zero for them is a doctor whose exit code
|
|
216
|
+
* nobody can put in a CI step.
|
|
217
|
+
*/
|
|
218
|
+
function report(context, checks) {
|
|
219
|
+
const failed = checks.find((check) => check.status === 'fail');
|
|
220
|
+
context.emit.ok({ checks, healthy: failed === undefined }, checks.map((check) => `${symbol(check.status)} ${(0, output_1.pad)(check.id, 20)} ${check.detail}`).join('\n'));
|
|
221
|
+
if (!failed)
|
|
222
|
+
return exit_codes_1.EXIT.OK;
|
|
223
|
+
// The failing check decides the code, so a caller branching on it gets the same vocabulary the
|
|
224
|
+
// other verbs use rather than a doctor-specific number.
|
|
225
|
+
if (failed.id === 'api-reachable')
|
|
226
|
+
return exit_codes_1.EXIT.SERVER;
|
|
227
|
+
if (failed.id === 'active-project')
|
|
228
|
+
return exit_codes_1.EXIT.NOT_FOUND;
|
|
229
|
+
return exit_codes_1.EXIT.AUTH;
|
|
230
|
+
}
|
|
231
|
+
function symbol(status) {
|
|
232
|
+
if (status === 'ok')
|
|
233
|
+
return 'ok ';
|
|
234
|
+
if (status === 'fail')
|
|
235
|
+
return 'FAIL';
|
|
236
|
+
if (status === 'warn')
|
|
237
|
+
return 'warn';
|
|
238
|
+
return 'skip';
|
|
239
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 2, Story 2.4 — `gf flags history` and `gf flags diff`.
|
|
3
|
+
//
|
|
4
|
+
// ── READ-ONLY, and that is asserted rather than asserted-about ────────────────────────────────
|
|
5
|
+
// Both verbs are served by the SAME `GET /api/v1/cli/flags?key=…` the read verbs use. There is no
|
|
6
|
+
// POST in this file, no version created, no activation touched, no audit row written — and a spec
|
|
7
|
+
// pins that by counting the requests each one makes. A "read" verb that could write is one someone
|
|
8
|
+
// will run during an incident to find out what happened.
|
|
9
|
+
//
|
|
10
|
+
// ── The diff is SEMANTIC, and it is the console's diff ────────────────────────────────────────
|
|
11
|
+
// `diffFlagDefinitions` comes from `@golden-frijoles/sdk` — the same function `/app/flags` renders
|
|
12
|
+
// its version history with, moved there by D4 precisely so `gf flags diff` and the console produce
|
|
13
|
+
// the SAME sentences. A JSON text diff would have been easier and would have said "two lines
|
|
14
|
+
// changed" where the console says "rollout 10% → 25%".
|
|
15
|
+
//
|
|
16
|
+
// It also ADMITS what it cannot describe. `unexplained` means something changed outside the six
|
|
17
|
+
// parts the differ covers, and the CLI prints the raw JSON rather than a confident sentence that
|
|
18
|
+
// happens to omit it.
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.flagsDiffCommand = exports.flagsHistoryCommand = void 0;
|
|
21
|
+
const sdk_1 = require("@golden-frijoles/sdk");
|
|
22
|
+
const args_1 = require("../args");
|
|
23
|
+
const exit_codes_1 = require("../exit-codes");
|
|
24
|
+
const output_1 = require("../output");
|
|
25
|
+
const flags_read_1 = require("./flags-read");
|
|
26
|
+
async function loadFlag(context, project, key) {
|
|
27
|
+
const result = await context.api.get('api/v1/cli/flags', { project, key });
|
|
28
|
+
if (result.kind === 'network') {
|
|
29
|
+
context.emit.fail('server_error', result.message);
|
|
30
|
+
return { ok: false, code: exit_codes_1.EXIT.SERVER };
|
|
31
|
+
}
|
|
32
|
+
if (result.kind === 'error') {
|
|
33
|
+
context.emit.fail(result.code, result.message);
|
|
34
|
+
return { ok: false, code: (0, exit_codes_1.exitForServerCode)(result.code) };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, body: result.body };
|
|
37
|
+
}
|
|
38
|
+
exports.flagsHistoryCommand = {
|
|
39
|
+
path: ['flags', 'history'],
|
|
40
|
+
summary: 'every version of a flag, and who changed what',
|
|
41
|
+
usage: 'gf flags history <key> [--project <slug>] [--json]',
|
|
42
|
+
needsAuth: true,
|
|
43
|
+
detail: `Read-only: it creates no version, touches no activation and writes no audit row.
|
|
44
|
+
|
|
45
|
+
⚠️ The audit window is CAPPED by the control plane at the most recent entries, so a flag
|
|
46
|
+
with a very long history shows the newest of them rather than all of them. Saying so is
|
|
47
|
+
the point — a list that implies completeness it does not have is worse than a short one.`,
|
|
48
|
+
flags: [{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' }],
|
|
49
|
+
async run(context) {
|
|
50
|
+
const key = context.args.positionals[0];
|
|
51
|
+
if (!key) {
|
|
52
|
+
context.emit.fail('invalid', 'Name a flag: `gf flags history <key>`.');
|
|
53
|
+
return exit_codes_1.EXIT.USAGE;
|
|
54
|
+
}
|
|
55
|
+
const project = (0, flags_read_1.resolveProject)(context);
|
|
56
|
+
if (!project)
|
|
57
|
+
return (0, flags_read_1.missingProject)(context);
|
|
58
|
+
const loaded = await loadFlag(context, project, key);
|
|
59
|
+
if (!loaded.ok)
|
|
60
|
+
return loaded.code;
|
|
61
|
+
const { flag } = loaded.body;
|
|
62
|
+
context.emit.ok({ project, key: flag.key, versions: flag.versions, audit: flag.audit }, [
|
|
63
|
+
(0, output_1.table)(['VERSION', 'CREATED', 'SERVED BY'], flag.versions.map((version) => [
|
|
64
|
+
`v${version.version}`,
|
|
65
|
+
version.createdAt,
|
|
66
|
+
version.servedBy.join(', ') || '—',
|
|
67
|
+
])),
|
|
68
|
+
'',
|
|
69
|
+
flag.audit.length === 0
|
|
70
|
+
? 'No lifecycle events recorded for this flag.'
|
|
71
|
+
: (0, output_1.table)(['WHEN', 'WHAT', 'WHERE', 'WHO', 'WHY'], flag.audit.map((row) => [
|
|
72
|
+
row.createdAt,
|
|
73
|
+
row.action,
|
|
74
|
+
row.environment ?? '—',
|
|
75
|
+
row.actor,
|
|
76
|
+
row.reason,
|
|
77
|
+
])),
|
|
78
|
+
].join('\n'));
|
|
79
|
+
return exit_codes_1.EXIT.OK;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
exports.flagsDiffCommand = {
|
|
83
|
+
path: ['flags', 'diff'],
|
|
84
|
+
summary: 'what changed between two versions, in words',
|
|
85
|
+
usage: 'gf flags diff <key> --from 3 --to 4 · gf flags diff <key> --env preview --env production',
|
|
86
|
+
needsAuth: true,
|
|
87
|
+
detail: `Two ways to ask:
|
|
88
|
+
--from <n> --to <n> compare two version numbers
|
|
89
|
+
--env <a> --env <b> compare what two environments are serving
|
|
90
|
+
|
|
91
|
+
The sentences are the console's own — one implementation, so the terminal and the page
|
|
92
|
+
cannot describe the same change differently. Anything outside the six parts the differ
|
|
93
|
+
covers is reported as such, with the raw JSON, rather than silently omitted.`,
|
|
94
|
+
flags: [
|
|
95
|
+
{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' },
|
|
96
|
+
{ name: 'from', value: '<version>', describe: 'the version to compare FROM' },
|
|
97
|
+
{ name: 'to', value: '<version>', describe: 'the version to compare TO' },
|
|
98
|
+
{ name: 'env', value: '<environment>', describe: 'pass twice to compare two environments' },
|
|
99
|
+
],
|
|
100
|
+
async run(context) {
|
|
101
|
+
const key = context.args.positionals[0];
|
|
102
|
+
if (!key) {
|
|
103
|
+
context.emit.fail('invalid', 'Name a flag: `gf flags diff <key> --from 3 --to 4`.');
|
|
104
|
+
return exit_codes_1.EXIT.USAGE;
|
|
105
|
+
}
|
|
106
|
+
const project = (0, flags_read_1.resolveProject)(context);
|
|
107
|
+
if (!project)
|
|
108
|
+
return (0, flags_read_1.missingProject)(context);
|
|
109
|
+
const environments = (0, args_1.flagValues)(context.args, 'env');
|
|
110
|
+
const from = (0, args_1.flagValue)(context.args, 'from');
|
|
111
|
+
const to = (0, args_1.flagValue)(context.args, 'to');
|
|
112
|
+
const byVersion = from !== undefined && to !== undefined;
|
|
113
|
+
const byEnvironment = environments.length === 2;
|
|
114
|
+
if (byVersion === byEnvironment) {
|
|
115
|
+
// Both, or neither. Both is ambiguous and neither is incomplete; guessing which the caller
|
|
116
|
+
// meant would produce a confident answer to a question they did not ask.
|
|
117
|
+
context.emit.fail('invalid', 'Pass EITHER --from <version> --to <version>, OR --env twice. Not both, and not neither.');
|
|
118
|
+
return exit_codes_1.EXIT.USAGE;
|
|
119
|
+
}
|
|
120
|
+
const loaded = await loadFlag(context, project, key);
|
|
121
|
+
if (!loaded.ok)
|
|
122
|
+
return loaded.code;
|
|
123
|
+
const { flag } = loaded.body;
|
|
124
|
+
let before = null;
|
|
125
|
+
let after = null;
|
|
126
|
+
if (byVersion) {
|
|
127
|
+
const find = (raw) => flag.versions.find((version) => version.version === Number(raw));
|
|
128
|
+
const left = find(from);
|
|
129
|
+
const right = find(to);
|
|
130
|
+
if (!left || !right) {
|
|
131
|
+
const known = flag.versions.map((version) => `v${version.version}`).join(', ');
|
|
132
|
+
context.emit.fail('not_found', `No such version. ${key} has: ${known || 'none'}.`);
|
|
133
|
+
return exit_codes_1.EXIT.NOT_FOUND;
|
|
134
|
+
}
|
|
135
|
+
before = { label: `v${left.version}`, definition: left.definition };
|
|
136
|
+
after = { label: `v${right.version}`, definition: right.definition };
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const resolveEnvironment = (name) => {
|
|
140
|
+
const row = flag.environments.find((candidate) => candidate.environment === name);
|
|
141
|
+
if (!row || row.version === null)
|
|
142
|
+
return null;
|
|
143
|
+
const version = flag.versions.find((candidate) => candidate.version === row.version);
|
|
144
|
+
return version
|
|
145
|
+
? { label: `${name} (v${version.version})`, definition: version.definition }
|
|
146
|
+
: null;
|
|
147
|
+
};
|
|
148
|
+
before = resolveEnvironment(environments[0]);
|
|
149
|
+
after = resolveEnvironment(environments[1]);
|
|
150
|
+
if (!before || !after) {
|
|
151
|
+
// Naming WHICH one, because "one of them serves nothing" sends the reader to check both.
|
|
152
|
+
const empty = [before ? null : environments[0], after ? null : environments[1]].filter(Boolean);
|
|
153
|
+
context.emit.fail('not_found', `${empty.join(' and ')} ${empty.length === 1 ? 'is' : 'are'} serving nothing, so there is nothing to compare.`);
|
|
154
|
+
return exit_codes_1.EXIT.NOT_FOUND;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const diff = (0, sdk_1.diffFlagDefinitions)(before.definition, after.definition);
|
|
158
|
+
context.emit.ok({
|
|
159
|
+
project,
|
|
160
|
+
key: flag.key,
|
|
161
|
+
from: before.label,
|
|
162
|
+
to: after.label,
|
|
163
|
+
changes: diff.changes,
|
|
164
|
+
unexplained: diff.unexplained,
|
|
165
|
+
// The raw definitions travel in --json ONLY when the differ could not explain everything,
|
|
166
|
+
// so an agent has what it needs exactly when the sentences are insufficient.
|
|
167
|
+
...(diff.unexplained ? { definitions: { from: before.definition, to: after.definition } } : {}),
|
|
168
|
+
}, [
|
|
169
|
+
`${before.label} → ${after.label}`,
|
|
170
|
+
...(diff.changes.length === 0 && !diff.unexplained
|
|
171
|
+
? ['Nothing changed in the parts this compares.']
|
|
172
|
+
: []),
|
|
173
|
+
...diff.changes.map((change) => ` · ${change}`),
|
|
174
|
+
...(diff.unexplained
|
|
175
|
+
? [
|
|
176
|
+
' · something changed outside what this can describe — the JSON:',
|
|
177
|
+
JSON.stringify(after.definition, null, 2),
|
|
178
|
+
]
|
|
179
|
+
: []),
|
|
180
|
+
].join('\n'));
|
|
181
|
+
return exit_codes_1.EXIT.OK;
|
|
182
|
+
},
|
|
183
|
+
};
|