@golden-frijoles/cli 0.1.0 → 0.2.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 +19 -0
- package/dist/commands/config.d.ts +23 -0
- package/dist/commands/config.js +258 -0
- package/dist/commands/doctor.js +32 -3
- package/dist/commands/index.js +5 -0
- package/dist/config-core.d.ts +53 -0
- package/dist/config-core.js +84 -0
- package/dist/modules.d.ts +20 -0
- package/dist/modules.js +90 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -103,6 +103,25 @@ could run: the credentials file, the credential, its shape, whether the deployme
|
|
|
103
103
|
it accepts you, whether your active project is reachable, and whether this CLI is current. It never
|
|
104
104
|
prints key material.
|
|
105
105
|
|
|
106
|
+
Then one line per module (Plan, Build, Ship, Measure, Spend, Operate): *configured*, *not
|
|
107
|
+
configured* (with the command that fixes it) or *could not look*. Module lines never change the
|
|
108
|
+
exit code.
|
|
109
|
+
|
|
110
|
+
## Settings: `gf setup` and `gf config`
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
gf setup # three questions, each with a default; --yes takes them all
|
|
114
|
+
gf config list # every setting, and which file it came from
|
|
115
|
+
gf config get review.reviewScope
|
|
116
|
+
gf config set review.reviewScope every-pr
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Settings live in the project's `golden-frijoles.config.json`, the same file the Golden Frijoles
|
|
120
|
+
skills read and write: these verbs use the config core that ships in `@golden-frijoles/kit`, so
|
|
121
|
+
there is one set of rules. `set` refuses anything that looks like a secret (keep those in
|
|
122
|
+
`.env.local`), and legacy files such as `review-config.json` are read but never edited. None of these
|
|
123
|
+
verbs needs a credential.
|
|
124
|
+
|
|
106
125
|
## Environment
|
|
107
126
|
|
|
108
127
|
| Variable | What it does |
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Command, CommandContext } from '../command';
|
|
2
|
+
import { type RegistryEntry } from '../config-core';
|
|
3
|
+
export declare const configListCommand: Command;
|
|
4
|
+
export declare const configGetCommand: Command;
|
|
5
|
+
export declare const configSetCommand: Command;
|
|
6
|
+
/** Pure — the next cursor position for one keypress, or a final answer. Exported for the unit tests. */
|
|
7
|
+
export declare function stepChoice(cursor: number, count: number, key: {
|
|
8
|
+
name?: string;
|
|
9
|
+
ctrl?: boolean;
|
|
10
|
+
}): {
|
|
11
|
+
cursor: number;
|
|
12
|
+
done?: 'chosen' | 'default' | 'abort';
|
|
13
|
+
};
|
|
14
|
+
/** A chooser: given a question, resolves to the chosen value — or `undefined` when setup is aborted. */
|
|
15
|
+
export type Chooser = (entry: RegistryEntry, choices: readonly unknown[]) => Promise<unknown>;
|
|
16
|
+
/** Swapped by the unit tests; production uses the TTY chooser. */
|
|
17
|
+
export declare const setupIo: {
|
|
18
|
+
isInteractive: () => boolean;
|
|
19
|
+
chooser: (context: CommandContext) => Chooser;
|
|
20
|
+
};
|
|
21
|
+
export declare const setupCommand: Command;
|
|
22
|
+
/** Pure — what to do after setup, from the answers. Same rules as the umbrella skill's Stage 2. */
|
|
23
|
+
export declare function nextSteps(answers: Record<string, unknown>, kit: string | null): string[];
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-plugin · S5.2 — `gf config list|get|set` and `gf setup`.
|
|
3
|
+
//
|
|
4
|
+
// ── A thin front end over the kit's config core (D10) ─────────────────────────────────────────
|
|
5
|
+
// Every rule — which file wins, what counts as a secret, where the project root is — lives in
|
|
6
|
+
// `@golden-frijoles/kit/config` (see ../config-core.ts). These verbs parse arguments, call the core
|
|
7
|
+
// and print. A `ConfigError` from the core (a malformed file, a value that looks like a secret, an
|
|
8
|
+
// unknown section) is the caller's to fix, so it is EXIT.USAGE; nothing here decides what is valid.
|
|
9
|
+
//
|
|
10
|
+
// ── Local verbs: no credential, no network ────────────────────────────────────────────────────
|
|
11
|
+
// `needsAuth: false` on all four. They read and write one file in the project; demanding a login
|
|
12
|
+
// to change which reviewers run would make configuration depend on an account nobody needs.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.setupCommand = exports.setupIo = exports.configSetCommand = exports.configGetCommand = exports.configListCommand = void 0;
|
|
15
|
+
exports.stepChoice = stepChoice;
|
|
16
|
+
exports.nextSteps = nextSteps;
|
|
17
|
+
const node_readline_1 = require("node:readline");
|
|
18
|
+
const args_1 = require("../args");
|
|
19
|
+
const config_core_1 = require("../config-core");
|
|
20
|
+
const exit_codes_1 = require("../exit-codes");
|
|
21
|
+
/** Load the core, or say plainly why it could not be loaded. `null` means the failure was already emitted. */
|
|
22
|
+
async function coreOrFail(context) {
|
|
23
|
+
try {
|
|
24
|
+
const core = await (0, config_core_1.loadConfigCore)();
|
|
25
|
+
return { core, root: core.projectRoot({ env: context.env, cwd: context.cwd }) };
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
context.emit.fail('server_error', `Could not load the config core from @golden-frijoles/kit: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
29
|
+
'Reinstall the CLI (`npm i -g @golden-frijoles/cli`).');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** A ConfigError is the caller's to fix (EXIT.USAGE); anything else is a bug and propagates to run()'s catch. */
|
|
34
|
+
function configFailure(context, core, err) {
|
|
35
|
+
if (err instanceof core.ConfigError) {
|
|
36
|
+
context.emit.fail('invalid', err.message);
|
|
37
|
+
return exit_codes_1.EXIT.USAGE;
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const show = (value) => (typeof value === 'string' ? value : JSON.stringify(value));
|
|
42
|
+
exports.configListCommand = {
|
|
43
|
+
path: ['config', 'list'],
|
|
44
|
+
summary: 'every setting in golden-frijoles.config.json and the legacy files, and which files each section came from',
|
|
45
|
+
usage: 'gf config list [--json]',
|
|
46
|
+
needsAuth: false,
|
|
47
|
+
detail: `Reads the project's golden-frijoles.config.json and any legacy config files
|
|
48
|
+
(review-config.json, jev.config.json, …). Where both set a section, the new file wins.`,
|
|
49
|
+
flags: [],
|
|
50
|
+
async run(context) {
|
|
51
|
+
if (context.args.positionals.length > 0) {
|
|
52
|
+
context.emit.fail('invalid', 'Usage: `gf config list` takes no arguments. For one setting: `gf config get <key>`.');
|
|
53
|
+
return exit_codes_1.EXIT.USAGE;
|
|
54
|
+
}
|
|
55
|
+
const loaded = await coreOrFail(context);
|
|
56
|
+
if (!loaded)
|
|
57
|
+
return exit_codes_1.EXIT.SERVER;
|
|
58
|
+
const { core, root } = loaded;
|
|
59
|
+
try {
|
|
60
|
+
const config = core.loadConfig({ root });
|
|
61
|
+
const names = Object.keys(config.sections);
|
|
62
|
+
const human = names.length
|
|
63
|
+
? names
|
|
64
|
+
.map((name) => `${name} (${config.sources[name].map((source) => source.split('/').pop()).join(' + ')})\n` +
|
|
65
|
+
JSON.stringify(config.sections[name], null, 2).replace(/^/gm, ' '))
|
|
66
|
+
.join('\n')
|
|
67
|
+
: `No settings yet: no ${core.CONFIG_FILENAME} and no legacy config files. Run \`gf setup\`.`;
|
|
68
|
+
context.emit.ok({ root, ...config }, human);
|
|
69
|
+
return exit_codes_1.EXIT.OK;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return configFailure(context, core, err);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
exports.configGetCommand = {
|
|
77
|
+
path: ['config', 'get'],
|
|
78
|
+
summary: "one setting's effective value (or its default when nothing sets it)",
|
|
79
|
+
usage: 'gf config get <key> [--json]',
|
|
80
|
+
needsAuth: false,
|
|
81
|
+
flags: [],
|
|
82
|
+
async run(context) {
|
|
83
|
+
const [key, ...extra] = context.args.positionals;
|
|
84
|
+
if (!key || extra.length > 0) {
|
|
85
|
+
context.emit.fail('invalid', 'Usage: `gf config get <key>`, e.g. `gf config get review.reviewScope`.');
|
|
86
|
+
return exit_codes_1.EXIT.USAGE;
|
|
87
|
+
}
|
|
88
|
+
const loaded = await coreOrFail(context);
|
|
89
|
+
if (!loaded)
|
|
90
|
+
return exit_codes_1.EXIT.SERVER;
|
|
91
|
+
const { core, root } = loaded;
|
|
92
|
+
try {
|
|
93
|
+
const value = core.getKey(key, { root });
|
|
94
|
+
context.emit.ok({ key, value: value ?? null }, show(value ?? null));
|
|
95
|
+
return exit_codes_1.EXIT.OK;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return configFailure(context, core, err);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
exports.configSetCommand = {
|
|
103
|
+
path: ['config', 'set'],
|
|
104
|
+
summary: 'change one setting in golden-frijoles.config.json',
|
|
105
|
+
usage: 'gf config set <key> <value> [--json]',
|
|
106
|
+
needsAuth: false,
|
|
107
|
+
detail: `<value> is read as JSON when it is valid JSON (true, 3, ["codex","claude"]), otherwise
|
|
108
|
+
as text. Refuses anything that looks like a secret: keep keys and tokens in .env.local.
|
|
109
|
+
Legacy config files are never edited.`,
|
|
110
|
+
flags: [],
|
|
111
|
+
async run(context) {
|
|
112
|
+
const [key, ...words] = context.args.positionals;
|
|
113
|
+
if (!key || words.length === 0) {
|
|
114
|
+
context.emit.fail('invalid', 'Usage: `gf config set <key> <value>`, e.g. `gf config set review.reviewScope every-pr`.');
|
|
115
|
+
return exit_codes_1.EXIT.USAGE;
|
|
116
|
+
}
|
|
117
|
+
const loaded = await coreOrFail(context);
|
|
118
|
+
if (!loaded)
|
|
119
|
+
return exit_codes_1.EXIT.SERVER;
|
|
120
|
+
const { core, root } = loaded;
|
|
121
|
+
try {
|
|
122
|
+
core.setKey(key, (0, config_core_1.parseValue)(words.join(' ')), { root });
|
|
123
|
+
const value = core.getKey(key, { root });
|
|
124
|
+
context.emit.ok({ key, value: value ?? null }, `${key} = ${JSON.stringify(value ?? null)}`);
|
|
125
|
+
return exit_codes_1.EXIT.OK;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return configFailure(context, core, err);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
// ── gf setup ──────────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
/** Pure — the next cursor position for one keypress, or a final answer. Exported for the unit tests. */
|
|
134
|
+
function stepChoice(cursor, count, key) {
|
|
135
|
+
if (key.ctrl && key.name === 'c')
|
|
136
|
+
return { cursor, done: 'abort' };
|
|
137
|
+
if (key.name === 'up' || key.name === 'k')
|
|
138
|
+
return { cursor: (cursor + count - 1) % count };
|
|
139
|
+
if (key.name === 'down' || key.name === 'j')
|
|
140
|
+
return { cursor: (cursor + 1) % count };
|
|
141
|
+
if (key.name === 'return' || key.name === 'enter')
|
|
142
|
+
return { cursor, done: 'chosen' };
|
|
143
|
+
if (key.name === 'escape' || key.name === 's')
|
|
144
|
+
return { cursor, done: 'default' };
|
|
145
|
+
return { cursor };
|
|
146
|
+
}
|
|
147
|
+
/** Arrow keys to move, Enter to choose, Esc or `s` to skip (the default). Only used on a real TTY. */
|
|
148
|
+
const ttyChooser = (context) => (entry, choices) => new Promise((resolve) => {
|
|
149
|
+
const stdin = process.stdin;
|
|
150
|
+
const defaultIndex = Math.max(0, choices.indexOf(entry.default));
|
|
151
|
+
let cursor = defaultIndex;
|
|
152
|
+
const draw = (first) => {
|
|
153
|
+
if (!first)
|
|
154
|
+
process.stderr.write(`\x1b[${choices.length}A`);
|
|
155
|
+
choices.forEach((choice, index) => process.stderr.write(`\x1b[2K${index === cursor ? '›' : ' '} ${show(choice)}${choice === entry.default ? ' (default)' : ''}\n`));
|
|
156
|
+
};
|
|
157
|
+
context.writer.err(`${entry.question}${entry.required ? '' : ' [Esc skips]'}`);
|
|
158
|
+
draw(true);
|
|
159
|
+
(0, node_readline_1.emitKeypressEvents)(stdin);
|
|
160
|
+
stdin.setRawMode(true);
|
|
161
|
+
stdin.resume();
|
|
162
|
+
const onKey = (_, key) => {
|
|
163
|
+
const next = stepChoice(cursor, choices.length, key ?? {});
|
|
164
|
+
cursor = next.cursor;
|
|
165
|
+
if (!next.done)
|
|
166
|
+
return draw(false);
|
|
167
|
+
stdin.off('keypress', onKey);
|
|
168
|
+
stdin.setRawMode(false);
|
|
169
|
+
stdin.pause();
|
|
170
|
+
if (next.done === 'abort')
|
|
171
|
+
return resolve(undefined);
|
|
172
|
+
resolve(next.done === 'chosen' || entry.required ? choices[cursor] : entry.default);
|
|
173
|
+
};
|
|
174
|
+
stdin.on('keypress', onKey);
|
|
175
|
+
});
|
|
176
|
+
/** Swapped by the unit tests; production uses the TTY chooser. */
|
|
177
|
+
exports.setupIo = {
|
|
178
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
179
|
+
chooser: ttyChooser,
|
|
180
|
+
};
|
|
181
|
+
exports.setupCommand = {
|
|
182
|
+
path: ['setup'],
|
|
183
|
+
summary: 'answer the setup questions (each has a default; only the first is required)',
|
|
184
|
+
usage: 'gf setup [--yes] [--json]',
|
|
185
|
+
needsAuth: false,
|
|
186
|
+
detail: `Asks what you are working on, where you are starting, and whether to connect an
|
|
187
|
+
account now — arrow keys to choose, Esc to take the default. --yes takes every default without
|
|
188
|
+
asking. Answers go to golden-frijoles.config.json; an account is connected with \`gf login\` and
|
|
189
|
+
\`gf init\`, which write .env.local, never the config file.`,
|
|
190
|
+
flags: [{ name: 'yes', describe: 'take every default without asking (required when not on a terminal)' }],
|
|
191
|
+
async run(context) {
|
|
192
|
+
const yes = (0, args_1.boolFlag)(context.args, 'yes');
|
|
193
|
+
if (context.args.positionals.length > 0) {
|
|
194
|
+
context.emit.fail('invalid', 'Usage: `gf setup [--yes]` takes no arguments.');
|
|
195
|
+
return exit_codes_1.EXIT.USAGE;
|
|
196
|
+
}
|
|
197
|
+
if (!yes && !exports.setupIo.isInteractive()) {
|
|
198
|
+
context.emit.fail('invalid', '`gf setup` asks questions on a terminal. Not on one: pass --yes for the defaults, or set each ' +
|
|
199
|
+
'answer with `gf config set <key> <value>`.');
|
|
200
|
+
return exit_codes_1.EXIT.USAGE;
|
|
201
|
+
}
|
|
202
|
+
const loaded = await coreOrFail(context);
|
|
203
|
+
if (!loaded)
|
|
204
|
+
return exit_codes_1.EXIT.SERVER;
|
|
205
|
+
const { core, root } = loaded;
|
|
206
|
+
const choose = yes ? null : exports.setupIo.chooser(context);
|
|
207
|
+
const answers = {};
|
|
208
|
+
try {
|
|
209
|
+
for (const entry of core.REGISTRY.filter((row) => row.askWhen === 'setup')) {
|
|
210
|
+
const choices = entry.choices ?? [entry.default];
|
|
211
|
+
const answer = choose ? await choose(entry, choices) : entry.default;
|
|
212
|
+
if (answer === undefined) {
|
|
213
|
+
context.emit.fail('invalid', 'Setup stopped. Nothing after the last answered question was saved.', {
|
|
214
|
+
answers,
|
|
215
|
+
});
|
|
216
|
+
return exit_codes_1.EXIT.USAGE;
|
|
217
|
+
}
|
|
218
|
+
answers[entry.key] = answer;
|
|
219
|
+
// `store: 'env'` answers are not settings: connecting an account is `gf login` + `gf init`.
|
|
220
|
+
if (entry.store !== 'env')
|
|
221
|
+
core.setKey(entry.key, answer, { root });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
return configFailure(context, core, err);
|
|
226
|
+
}
|
|
227
|
+
const next = nextSteps(answers, (0, config_core_1.kitVersion)());
|
|
228
|
+
const envKeys = new Set(core.REGISTRY.filter((row) => row.store === 'env').map((row) => row.key));
|
|
229
|
+
const saved = Object.entries(answers).filter(([key]) => !envKeys.has(key));
|
|
230
|
+
const notSaved = Object.entries(answers).filter(([key]) => envKeys.has(key));
|
|
231
|
+
context.emit.ok({ root, answers, next }, [
|
|
232
|
+
`Saved to ${core.CONFIG_FILENAME} in ${root}${yes ? ' (--yes: every default)' : ''}:`,
|
|
233
|
+
...saved.map(([key, value]) => ` ${key} = ${show(value)}`),
|
|
234
|
+
...(notSaved.length
|
|
235
|
+
? ['Not saved (an account lives in .env.local, via `gf login` + `gf init`):']
|
|
236
|
+
: []),
|
|
237
|
+
...notSaved.map(([key, value]) => ` ${key} = ${show(value)}`),
|
|
238
|
+
'',
|
|
239
|
+
'Next:',
|
|
240
|
+
...next.map((step) => ` ${step}`),
|
|
241
|
+
'',
|
|
242
|
+
'Change any answer later with `gf config set <key> <value>`.',
|
|
243
|
+
].join('\n'));
|
|
244
|
+
return exit_codes_1.EXIT.OK;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
/** Pure — what to do after setup, from the answers. Same rules as the umbrella skill's Stage 2. */
|
|
248
|
+
function nextSteps(answers, kit) {
|
|
249
|
+
const steps = [];
|
|
250
|
+
if (answers['project.account'] === 'now')
|
|
251
|
+
steps.push('Connect your account: `gf login`, then `gf init`.');
|
|
252
|
+
if (answers['project.mode'] === 'existing' || answers['project.mode'] === 'new')
|
|
253
|
+
steps.push(`Add the Roadmap/ skeleton: \`npx -y @golden-frijoles/kit${kit ? `@${kit}` : ''} init\` (it never overwrites anything).`);
|
|
254
|
+
steps.push(answers['project.startPoint'] === 'building'
|
|
255
|
+
? 'Ask your agent to run the `live-smoke` skill against what you are building.'
|
|
256
|
+
: 'Ask your agent to run the `groom` skill on your first idea.');
|
|
257
|
+
return steps;
|
|
258
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -25,6 +25,8 @@ const credentials_1 = require("../credentials");
|
|
|
25
25
|
const exit_codes_1 = require("../exit-codes");
|
|
26
26
|
const output_1 = require("../output");
|
|
27
27
|
const version_1 = require("../version");
|
|
28
|
+
const config_core_1 = require("../config-core");
|
|
29
|
+
const modules_1 = require("../modules");
|
|
28
30
|
exports.doctorCommand = {
|
|
29
31
|
path: ['doctor'],
|
|
30
32
|
summary: 'why is it not working — every check, in order',
|
|
@@ -33,10 +35,15 @@ exports.doctorCommand = {
|
|
|
33
35
|
detail: `Runs whether or not you are logged in — diagnosing a missing credential is the
|
|
34
36
|
point. Prints no key material in either mode.
|
|
35
37
|
|
|
36
|
-
Exits 0 when every check that could run passed, and non-zero naming the first that did not
|
|
38
|
+
Exits 0 when every check that could run passed, and non-zero naming the first that did not.
|
|
39
|
+
Then one line per module (Plan, Build, Ship, Measure, Spend, Operate): configured, not
|
|
40
|
+
configured (with the command that fixes it) or could not look. Module lines never change the
|
|
41
|
+
exit code.`,
|
|
37
42
|
flags: [{ name: 'project', value: '<slug>', describe: 'check this project instead of the remembered one' }],
|
|
38
43
|
async run(context) {
|
|
39
44
|
const checks = [];
|
|
45
|
+
const modules = await moduleReport(context);
|
|
46
|
+
const report = (ctx, list) => printReport(ctx, list, modules);
|
|
40
47
|
const path = (0, credentials_1.credentialsPath)(context.env);
|
|
41
48
|
// ── 1. is there a credential at all, and where did it come from ───────────────────────────
|
|
42
49
|
const fileExists = (0, node_fs_1.existsSync)(path);
|
|
@@ -215,9 +222,15 @@ function describeSource(source) {
|
|
|
215
222
|
* legitimately be living with, and a doctor that exits non-zero for them is a doctor whose exit code
|
|
216
223
|
* nobody can put in a CI step.
|
|
217
224
|
*/
|
|
218
|
-
function
|
|
225
|
+
function printReport(context, checks, modules) {
|
|
219
226
|
const failed = checks.find((check) => check.status === 'fail');
|
|
220
|
-
context.emit.ok({ checks, healthy: failed === undefined
|
|
227
|
+
context.emit.ok({ checks, healthy: failed === undefined, modules }, [
|
|
228
|
+
...checks.map((check) => `${symbol(check.status)} ${(0, output_1.pad)(check.id, 20)} ${check.detail}`),
|
|
229
|
+
'',
|
|
230
|
+
'modules',
|
|
231
|
+
...modules.map((line) => `${(0, output_1.pad)(line.module, 8)} ${(0, output_1.pad)(line.state.replace(/-/g, ' '), 15)} ${line.detail}${line.fix ? ` Fix: ${line.fix}` : ''}`),
|
|
232
|
+
].join('\n'));
|
|
233
|
+
// D13: `modules` is reported, never scored. The exit code below is the checks' alone.
|
|
221
234
|
if (!failed)
|
|
222
235
|
return exit_codes_1.EXIT.OK;
|
|
223
236
|
// The failing check decides the code, so a caller branching on it gets the same vocabulary the
|
|
@@ -228,6 +241,22 @@ function report(context, checks) {
|
|
|
228
241
|
return exit_codes_1.EXIT.NOT_FOUND;
|
|
229
242
|
return exit_codes_1.EXIT.AUTH;
|
|
230
243
|
}
|
|
244
|
+
/** The module lines for this project. Loading the kit can fail; that is could-not-look, never a thrown error. */
|
|
245
|
+
async function moduleReport(context) {
|
|
246
|
+
const hasCredential = context.auth.token !== null;
|
|
247
|
+
try {
|
|
248
|
+
const core = await (0, config_core_1.loadConfigCore)();
|
|
249
|
+
const root = core.projectRoot({ env: context.env, cwd: context.cwd });
|
|
250
|
+
return (0, modules_1.moduleLines)(core, { root, hasCredential });
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
return (0, modules_1.moduleLines)(null, {
|
|
254
|
+
root: null,
|
|
255
|
+
hasCredential,
|
|
256
|
+
unavailable: `could not load @golden-frijoles/kit: ${err instanceof Error ? err.message : String(err)}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
231
260
|
function symbol(status) {
|
|
232
261
|
if (status === 'ok')
|
|
233
262
|
return 'ok ';
|
package/dist/commands/index.js
CHANGED
|
@@ -15,11 +15,16 @@ const flags_history_1 = require("./flags-history");
|
|
|
15
15
|
const flags_sync_1 = require("./flags-sync");
|
|
16
16
|
const keys_1 = require("./keys");
|
|
17
17
|
const doctor_1 = require("./doctor");
|
|
18
|
+
const config_1 = require("./config");
|
|
18
19
|
exports.COMMANDS = [
|
|
19
20
|
auth_1.loginCommand,
|
|
20
21
|
auth_1.logoutCommand,
|
|
21
22
|
auth_1.whoamiCommand,
|
|
22
23
|
doctor_1.doctorCommand,
|
|
24
|
+
config_1.setupCommand,
|
|
25
|
+
config_1.configListCommand,
|
|
26
|
+
config_1.configGetCommand,
|
|
27
|
+
config_1.configSetCommand,
|
|
23
28
|
init_1.initCommand,
|
|
24
29
|
projects_1.projectsLsCommand,
|
|
25
30
|
projects_1.projectsCreateCommand,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type RegistryEntry = {
|
|
2
|
+
key: string;
|
|
3
|
+
module: 'Plan' | 'Build' | 'Ship' | 'Measure' | 'Spend' | 'Operate';
|
|
4
|
+
askWhen: string;
|
|
5
|
+
default: unknown;
|
|
6
|
+
question: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
choices?: readonly unknown[];
|
|
9
|
+
store?: 'env';
|
|
10
|
+
};
|
|
11
|
+
type Io = {
|
|
12
|
+
root?: string;
|
|
13
|
+
};
|
|
14
|
+
/** The subset of the kit's `lib/config.mjs` this CLI calls. The kit ships the full types (config.d.mts). */
|
|
15
|
+
export type ConfigCore = {
|
|
16
|
+
CONFIG_FILENAME: string;
|
|
17
|
+
LEGACY: Readonly<Record<string, string>>;
|
|
18
|
+
REGISTRY: readonly RegistryEntry[];
|
|
19
|
+
MODULES: readonly RegistryEntry['module'][];
|
|
20
|
+
ConfigError: new (message: string) => Error;
|
|
21
|
+
projectRoot(opts?: {
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
}): string;
|
|
25
|
+
readSection(name: string, opts?: Io): {
|
|
26
|
+
raw: unknown;
|
|
27
|
+
present: boolean;
|
|
28
|
+
sources: string[];
|
|
29
|
+
};
|
|
30
|
+
loadConfig(opts?: Io): {
|
|
31
|
+
sections: Record<string, unknown>;
|
|
32
|
+
sources: Record<string, string[]>;
|
|
33
|
+
duplicates: string[];
|
|
34
|
+
unknown: string[];
|
|
35
|
+
};
|
|
36
|
+
getKey(key: string, opts?: Io): unknown;
|
|
37
|
+
setKey(key: string, value: unknown, opts?: Io): Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
/** The installed kit's version, so a printed `npx @golden-frijoles/kit@<v>` runs the same code this CLI uses. */
|
|
40
|
+
export declare function kitVersion(): string | null;
|
|
41
|
+
/** Load the kit's config core once per process. Rejects when the kit is not installed or not loadable. */
|
|
42
|
+
export declare function loadConfigCore(): Promise<ConfigCore>;
|
|
43
|
+
/** Pure — a command-line value: JSON when it parses (true, 3, ["a"], null), otherwise the literal string. Same rule as `gf-kit config set`. */
|
|
44
|
+
export declare function parseValue(text: string): unknown;
|
|
45
|
+
/**
|
|
46
|
+
* The value a key has in the FILES — `undefined` when nothing sets it, never the registry default.
|
|
47
|
+
*
|
|
48
|
+
* `getKey` answers "what will a rail use", which falls back to the default; doctor and setup need
|
|
49
|
+
* "did anyone answer this", which must not. Same section resolution as the kit's `getKey`
|
|
50
|
+
* (a dotted sub-section with its own legacy file, like `smoke.triage`, is read as that sub-section).
|
|
51
|
+
*/
|
|
52
|
+
export declare function answeredValue(core: ConfigCore, key: string, root: string): unknown;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-plugin · S5.2 (D10) — the ONE config core, loaded from `@golden-frijoles/kit`.
|
|
3
|
+
//
|
|
4
|
+
// ── Why this CLI does not have its own config code ────────────────────────────────────────────
|
|
5
|
+
// `gf config`, `gf setup` and `gf doctor`'s module lines read and write the same
|
|
6
|
+
// golden-frijoles.config.json the kit's `gf-kit config` does, and an agent's skill writes it too.
|
|
7
|
+
// Two implementations of "which file wins, what counts as a secret, where the project root is"
|
|
8
|
+
// would drift, and the drift would show up as a setting one front end saved and the other ignored.
|
|
9
|
+
// So every rule lives in the kit (`@golden-frijoles/kit/config`), and this file only loads it.
|
|
10
|
+
//
|
|
11
|
+
// ── Why `new Function('return import(u)')` and a file URL ─────────────────────────────────────
|
|
12
|
+
// The kit is ESM and this CLI builds to CommonJS. TypeScript compiles a literal `import()` in a
|
|
13
|
+
// CommonJS build into `require()`, which cannot load ESM on every Node this CLI supports
|
|
14
|
+
// (engines >=20). A dynamic import the compiler cannot see survives into the build as a real
|
|
15
|
+
// `import()`. It is handed an ABSOLUTE file URL (resolved through the kit's exported
|
|
16
|
+
// package.json), so where that import resolves from never matters.
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.kitVersion = kitVersion;
|
|
19
|
+
exports.loadConfigCore = loadConfigCore;
|
|
20
|
+
exports.parseValue = parseValue;
|
|
21
|
+
exports.answeredValue = answeredValue;
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const node_module_1 = require("node:module");
|
|
24
|
+
const node_path_1 = require("node:path");
|
|
25
|
+
const node_url_1 = require("node:url");
|
|
26
|
+
const importEsm = new Function('url', 'return import(url)');
|
|
27
|
+
let cached = null;
|
|
28
|
+
/** The kit's package.json, resolved the way Node resolves this CLI's own dependency. */
|
|
29
|
+
function kitPackageJson() {
|
|
30
|
+
// `require` exists in the CommonJS build; the unit tests run this source as ESM, where it does not.
|
|
31
|
+
const req = typeof require === 'function' ? require : (0, node_module_1.createRequire)((0, node_path_1.join)(process.cwd(), 'noop.js'));
|
|
32
|
+
return req.resolve('@golden-frijoles/kit/package.json');
|
|
33
|
+
}
|
|
34
|
+
/** The installed kit's version, so a printed `npx @golden-frijoles/kit@<v>` runs the same code this CLI uses. */
|
|
35
|
+
function kitVersion() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse((0, node_fs_1.readFileSync)(kitPackageJson(), 'utf8')).version ?? null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Load the kit's config core once per process. Rejects when the kit is not installed or not loadable. */
|
|
44
|
+
function loadConfigCore() {
|
|
45
|
+
if (!cached) {
|
|
46
|
+
cached = (async () => {
|
|
47
|
+
const pkg = kitPackageJson();
|
|
48
|
+
return (await importEsm((0, node_url_1.pathToFileURL)((0, node_path_1.join)((0, node_path_1.dirname)(pkg), 'dist', 'lib', 'config.mjs')).href));
|
|
49
|
+
})();
|
|
50
|
+
// A failed load is not cached: the next call tries again rather than repeating a stale error.
|
|
51
|
+
cached.catch(() => {
|
|
52
|
+
cached = null;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return cached;
|
|
56
|
+
}
|
|
57
|
+
/** Pure — a command-line value: JSON when it parses (true, 3, ["a"], null), otherwise the literal string. Same rule as `gf-kit config set`. */
|
|
58
|
+
function parseValue(text) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The value a key has in the FILES — `undefined` when nothing sets it, never the registry default.
|
|
68
|
+
*
|
|
69
|
+
* `getKey` answers "what will a rail use", which falls back to the default; doctor and setup need
|
|
70
|
+
* "did anyone answer this", which must not. Same section resolution as the kit's `getKey`
|
|
71
|
+
* (a dotted sub-section with its own legacy file, like `smoke.triage`, is read as that sub-section).
|
|
72
|
+
*/
|
|
73
|
+
function answeredValue(core, key, root) {
|
|
74
|
+
const [head, ...rest] = key.split('.');
|
|
75
|
+
const section = rest.length > 0 && core.LEGACY[`${head}.${rest[0]}`] ? `${head}.${rest.shift()}` : head;
|
|
76
|
+
const { raw } = core.readSection(section, { root });
|
|
77
|
+
let value = raw ?? undefined;
|
|
78
|
+
for (const part of rest) {
|
|
79
|
+
if (value === null || typeof value !== 'object')
|
|
80
|
+
return undefined;
|
|
81
|
+
value = value[part];
|
|
82
|
+
}
|
|
83
|
+
return value === null ? undefined : value;
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ConfigCore, type RegistryEntry } from './config-core';
|
|
2
|
+
export type ModuleState = 'configured' | 'not-configured' | 'could-not-look';
|
|
3
|
+
export type ModuleLine = {
|
|
4
|
+
module: RegistryEntry['module'];
|
|
5
|
+
state: ModuleState;
|
|
6
|
+
/** One sentence. For not-configured it names what is missing; for could-not-look, why. */
|
|
7
|
+
detail: string;
|
|
8
|
+
/** The command that fixes a not-configured module. `null` in the other two states. */
|
|
9
|
+
fix: string | null;
|
|
10
|
+
};
|
|
11
|
+
export declare const MODULE_ORDER: readonly RegistryEntry['module'][];
|
|
12
|
+
/**
|
|
13
|
+
* Pure given the core — one line per module. `core === null` (or a core that throws while reading)
|
|
14
|
+
* makes every line could-not-look, with the reason.
|
|
15
|
+
*/
|
|
16
|
+
export declare function moduleLines(core: ConfigCore | null, opts: {
|
|
17
|
+
root: string | null;
|
|
18
|
+
hasCredential: boolean;
|
|
19
|
+
unavailable?: string;
|
|
20
|
+
}): ModuleLine[];
|
package/dist/modules.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-plugin · S5.3 (D13) — one line per product module, for `gf doctor`.
|
|
3
|
+
//
|
|
4
|
+
// ── Three states, never two ───────────────────────────────────────────────────────────────────
|
|
5
|
+
// *configured*, *not configured* (with the command that fixes it) and *could not look*. The last is
|
|
6
|
+
// its own state because "the config file is malformed" or "the kit is not installed" is a different
|
|
7
|
+
// fact, with a different remedy, from "you have not answered this yet" (LEARNINGS: could not look is
|
|
8
|
+
// never the failure outcome, and never silently the success one).
|
|
9
|
+
//
|
|
10
|
+
// ── Read from the registry, not a list kept here ──────────────────────────────────────────────
|
|
11
|
+
// The questions a module needs answered are the kit registry's rows for that module, minus the
|
|
12
|
+
// `never-yet` ones (declared, but nothing asks them this release). A new setting in the kit shows up
|
|
13
|
+
// here with no change to this file.
|
|
14
|
+
//
|
|
15
|
+
// ── Never changes doctor's exit code (D13) ────────────────────────────────────────────────────
|
|
16
|
+
// Doctor's exit code answers "can this CLI reach your project". An unanswered setting is normal on a
|
|
17
|
+
// fresh repo; a CI step running `gf doctor` must not start failing because nobody chose reviewers.
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.MODULE_ORDER = void 0;
|
|
20
|
+
exports.moduleLines = moduleLines;
|
|
21
|
+
const node_fs_1 = require("node:fs");
|
|
22
|
+
const node_path_1 = require("node:path");
|
|
23
|
+
const init_1 = require("./commands/init");
|
|
24
|
+
const config_core_1 = require("./config-core");
|
|
25
|
+
exports.MODULE_ORDER = [
|
|
26
|
+
'Plan',
|
|
27
|
+
'Build',
|
|
28
|
+
'Ship',
|
|
29
|
+
'Measure',
|
|
30
|
+
'Spend',
|
|
31
|
+
'Operate',
|
|
32
|
+
];
|
|
33
|
+
/** `.env.local` carries a Golden Frijoles project (what `gf init` writes). Where a `store: 'env'` answer lives. */
|
|
34
|
+
function accountConnected(root, hasCredential) {
|
|
35
|
+
if (hasCredential)
|
|
36
|
+
return true;
|
|
37
|
+
const path = (0, node_path_1.join)(root, '.env.local');
|
|
38
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
39
|
+
return false;
|
|
40
|
+
return (0, init_1.readEnvValue)((0, node_fs_1.readFileSync)(path, 'utf8'), init_1.ENV_KEYS.flagRead) !== null;
|
|
41
|
+
}
|
|
42
|
+
function fixFor(entry) {
|
|
43
|
+
if (entry.store === 'env')
|
|
44
|
+
return '`gf login`, then `gf init`';
|
|
45
|
+
if (entry.askWhen === 'setup')
|
|
46
|
+
return '`gf setup`';
|
|
47
|
+
return `\`gf config set ${entry.key} <value>\``;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pure given the core — one line per module. `core === null` (or a core that throws while reading)
|
|
51
|
+
* makes every line could-not-look, with the reason.
|
|
52
|
+
*/
|
|
53
|
+
function moduleLines(core, opts) {
|
|
54
|
+
const couldNotLook = (why) => exports.MODULE_ORDER.map((module) => ({ module, state: 'could-not-look', detail: why, fix: null }));
|
|
55
|
+
if (!core || !opts.root)
|
|
56
|
+
return couldNotLook(opts.unavailable ?? 'the config core is not available.');
|
|
57
|
+
const root = opts.root;
|
|
58
|
+
try {
|
|
59
|
+
return exports.MODULE_ORDER.map((module) => {
|
|
60
|
+
const asked = core.REGISTRY.filter((row) => row.module === module && row.askWhen !== 'never-yet');
|
|
61
|
+
// Nothing to answer is not "not configured": that state promises a fix, and there is none to give.
|
|
62
|
+
if (asked.length === 0)
|
|
63
|
+
return { module, state: 'configured', detail: 'nothing to set in this release.', fix: null };
|
|
64
|
+
// Missing = needs an answer to work: an account not connected, or a setting that is required or
|
|
65
|
+
// has no safe default (`null`: "unanswered", which a rail treats as its safest choice — jev.egress
|
|
66
|
+
// never sends). A setting with a real default is not missing; the rail already uses the default.
|
|
67
|
+
const missing = asked.filter((entry) => entry.store === 'env'
|
|
68
|
+
? !accountConnected(root, opts.hasCredential)
|
|
69
|
+
: (entry.required || entry.default === null) && (0, config_core_1.answeredValue)(core, entry.key, root) === undefined);
|
|
70
|
+
if (missing.length === 0) {
|
|
71
|
+
const answered = asked.filter((entry) => entry.store !== 'env' && (0, config_core_1.answeredValue)(core, entry.key, root) !== undefined);
|
|
72
|
+
return {
|
|
73
|
+
module,
|
|
74
|
+
state: 'configured',
|
|
75
|
+
detail: `${answered.length} of ${asked.length} settings answered; the rest use their defaults.`,
|
|
76
|
+
fix: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
module,
|
|
81
|
+
state: 'not-configured',
|
|
82
|
+
detail: `not answered: ${missing.map((entry) => entry.key).join(', ')}.`,
|
|
83
|
+
fix: [...new Set(missing.map(fixFor))].join(' · '),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return couldNotLook(`could not read the project's settings: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
package/dist/version.js
CHANGED
|
@@ -10,4 +10,4 @@ exports.VERSION = void 0;
|
|
|
10
10
|
//
|
|
11
11
|
// It is a literal, and `version.test.ts` asserts it equals `package.json`'s — so the two cannot
|
|
12
12
|
// drift, and the drift is caught by the unit gate rather than by someone reading `gf --version`.
|
|
13
|
-
exports.VERSION = '0.
|
|
13
|
+
exports.VERSION = '0.2.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/cli",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "The Golden Frijoles CLI — create, roll out and kill feature flags from a terminal, or from an agent.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"gf": "./dist/bin.js"
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"prepack": "npm run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@golden-frijoles/kit": "0.5.1",
|
|
35
36
|
"@golden-frijoles/sdk": "^0.5.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|