@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,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli — the command table. `--help` is rendered from it and the dispatcher reads it,
|
|
3
|
+
// so a verb cannot exist undocumented and cannot be documented without existing.
|
|
4
|
+
//
|
|
5
|
+
// ORDER IS THE HELP'S ORDER. It runs roughly in the sequence a new user meets them: sign in, find
|
|
6
|
+
// your project, set the project up, then work with flags.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.COMMANDS = void 0;
|
|
9
|
+
const auth_1 = require("./auth");
|
|
10
|
+
const projects_1 = require("./projects");
|
|
11
|
+
const init_1 = require("./init");
|
|
12
|
+
const flags_read_1 = require("./flags-read");
|
|
13
|
+
const flags_write_1 = require("./flags-write");
|
|
14
|
+
const flags_history_1 = require("./flags-history");
|
|
15
|
+
const flags_sync_1 = require("./flags-sync");
|
|
16
|
+
const keys_1 = require("./keys");
|
|
17
|
+
const doctor_1 = require("./doctor");
|
|
18
|
+
exports.COMMANDS = [
|
|
19
|
+
auth_1.loginCommand,
|
|
20
|
+
auth_1.logoutCommand,
|
|
21
|
+
auth_1.whoamiCommand,
|
|
22
|
+
doctor_1.doctorCommand,
|
|
23
|
+
init_1.initCommand,
|
|
24
|
+
projects_1.projectsLsCommand,
|
|
25
|
+
projects_1.projectsCreateCommand,
|
|
26
|
+
projects_1.projectsUseCommand,
|
|
27
|
+
flags_read_1.flagsLsCommand,
|
|
28
|
+
flags_read_1.flagsGetCommand,
|
|
29
|
+
flags_write_1.flagsCreateCommand,
|
|
30
|
+
flags_write_1.flagsSetCommand,
|
|
31
|
+
flags_write_1.flagsRolloutCommand,
|
|
32
|
+
flags_write_1.flagsRulesCommand,
|
|
33
|
+
flags_write_1.flagsKillCommand,
|
|
34
|
+
flags_history_1.flagsDiffCommand,
|
|
35
|
+
flags_history_1.flagsHistoryCommand,
|
|
36
|
+
flags_sync_1.flagsSyncCommand,
|
|
37
|
+
keys_1.keysLsCommand,
|
|
38
|
+
keys_1.keysCreateCommand,
|
|
39
|
+
keys_1.keysRevokeCommand,
|
|
40
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Command } from '../command';
|
|
2
|
+
/** The env-var names `gf init` writes AND the snippet reads. One definition (D6). */
|
|
3
|
+
export declare const ENV_KEYS: {
|
|
4
|
+
readonly url: "GOLDEN_FRIJOLES_URL";
|
|
5
|
+
readonly flagRead: "GOLDEN_FRIJOLES_FLAG_READ_KEY";
|
|
6
|
+
readonly environment: "GOLDEN_FRIJOLES_ENVIRONMENT";
|
|
7
|
+
};
|
|
8
|
+
/** Does `.gitignore` already cover `.env.local`? */
|
|
9
|
+
export declare function gitignoreCovers(contents: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The value of `name` in a dotenv file, or null. Quotes stripped; no interpolation, deliberately.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **The LAST assignment wins, and this returned the FIRST** (cross-family review, Codex,
|
|
14
|
+
* round 3). `dotenv` assigns in file order, so a later line overrides an earlier one — which meant
|
|
15
|
+
* `gf init` could probe and rewrite one key while the generated app actually resolved a different
|
|
16
|
+
* one. Two duplicate lines is not exotic: it is what a hand-edit plus a re-run produces.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readEnvValue(contents: string, name: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Set `name` to `value`, leaving EXACTLY ONE assignment of it in the file.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **Rewritten to remove the first/last ambiguity rather than to pick a side** (cross-family
|
|
23
|
+
* review, Codex, round 3). The previous version replaced the FIRST match and left any later
|
|
24
|
+
* duplicate in place — and since `dotenv` resolves the LAST one, the file could end up saying
|
|
25
|
+
* something this function believed it had just changed.
|
|
26
|
+
*
|
|
27
|
+
* Every existing assignment is dropped and one is written where the first of them was (or appended
|
|
28
|
+
* if there were none). The failure is then unrepresentable rather than handled: there is no second
|
|
29
|
+
* occurrence for the two functions to disagree about (CODE-QUALITY #2).
|
|
30
|
+
*/
|
|
31
|
+
export declare function upsertEnvValue(contents: string, name: string, value: string): string;
|
|
32
|
+
export declare function snippetFor(environment: string): string;
|
|
33
|
+
export declare const initCommand: Command;
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 1, Story 1.4 — `gf init`. The whole onboarding in one verb.
|
|
3
|
+
//
|
|
4
|
+
// ── What it does, in order, and why that order ────────────────────────────────────────────────
|
|
5
|
+
// 1. make sure the account has a project (idempotent — D9)
|
|
6
|
+
// 2. make sure `.env.local` is ignored by git, or REFUSE
|
|
7
|
+
// 3. mint a `flag_read` key for the chosen environment, unless the file already has a live one
|
|
8
|
+
// 4. write `.env.local` at 0600
|
|
9
|
+
// 5. print the snippet that reads exactly the names it just wrote
|
|
10
|
+
//
|
|
11
|
+
// Step 2 comes before step 3 on purpose. Minting first and then discovering the file would be
|
|
12
|
+
// committed leaves a live credential on disk in a tracked file, and "we minted it but could not
|
|
13
|
+
// protect it" is not a state a tool should be able to reach. The shaping named this as a rabbit
|
|
14
|
+
// hole: *"`init` must add the env file to `.gitignore` or refuse."*
|
|
15
|
+
//
|
|
16
|
+
// ── D6: the names are `GOLDEN_FRIJOLES_*`, and the snippet is generated WITH the file ─────────
|
|
17
|
+
// The SDK reads no environment variable at all — `createFlagProvider` takes `flagReadKey` as an
|
|
18
|
+
// argument, and the `GOLDEN_BEANS_*` names appear only in README examples. They are caller-owned
|
|
19
|
+
// addresses, so there is no compatibility to preserve and nothing in shipped code resolves either
|
|
20
|
+
// name. What matters is that the file and its reader agree, so both come out of `ENV_KEYS` below.
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.initCommand = exports.ENV_KEYS = void 0;
|
|
23
|
+
exports.gitignoreCovers = gitignoreCovers;
|
|
24
|
+
exports.readEnvValue = readEnvValue;
|
|
25
|
+
exports.upsertEnvValue = upsertEnvValue;
|
|
26
|
+
exports.snippetFor = snippetFor;
|
|
27
|
+
const node_child_process_1 = require("node:child_process");
|
|
28
|
+
const node_fs_1 = require("node:fs");
|
|
29
|
+
const node_path_1 = require("node:path");
|
|
30
|
+
const sdk_1 = require("@golden-frijoles/sdk");
|
|
31
|
+
const args_1 = require("../args");
|
|
32
|
+
const exit_codes_1 = require("../exit-codes");
|
|
33
|
+
/** The env-var names `gf init` writes AND the snippet reads. One definition (D6). */
|
|
34
|
+
exports.ENV_KEYS = {
|
|
35
|
+
url: 'GOLDEN_FRIJOLES_URL',
|
|
36
|
+
flagRead: 'GOLDEN_FRIJOLES_FLAG_READ_KEY',
|
|
37
|
+
environment: 'GOLDEN_FRIJOLES_ENVIRONMENT',
|
|
38
|
+
};
|
|
39
|
+
const ENV_FILE = '.env.local';
|
|
40
|
+
const GITIGNORE = '.gitignore';
|
|
41
|
+
/** Does `.gitignore` already cover `.env.local`? */
|
|
42
|
+
function gitignoreCovers(contents) {
|
|
43
|
+
return contents
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((line) => line.trim())
|
|
46
|
+
.some((line) => ['.env.local', '.env*.local', '.env*', '*.local'].includes(line));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The value of `name` in a dotenv file, or null. Quotes stripped; no interpolation, deliberately.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ **The LAST assignment wins, and this returned the FIRST** (cross-family review, Codex,
|
|
52
|
+
* round 3). `dotenv` assigns in file order, so a later line overrides an earlier one — which meant
|
|
53
|
+
* `gf init` could probe and rewrite one key while the generated app actually resolved a different
|
|
54
|
+
* one. Two duplicate lines is not exotic: it is what a hand-edit plus a re-run produces.
|
|
55
|
+
*/
|
|
56
|
+
function readEnvValue(contents, name) {
|
|
57
|
+
let found = null;
|
|
58
|
+
for (const line of contents.split('\n')) {
|
|
59
|
+
const match = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`).exec(line);
|
|
60
|
+
if (!match)
|
|
61
|
+
continue;
|
|
62
|
+
const raw = match[1].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
63
|
+
found = raw === '' ? null : raw;
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Set `name` to `value`, leaving EXACTLY ONE assignment of it in the file.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ **Rewritten to remove the first/last ambiguity rather than to pick a side** (cross-family
|
|
71
|
+
* review, Codex, round 3). The previous version replaced the FIRST match and left any later
|
|
72
|
+
* duplicate in place — and since `dotenv` resolves the LAST one, the file could end up saying
|
|
73
|
+
* something this function believed it had just changed.
|
|
74
|
+
*
|
|
75
|
+
* Every existing assignment is dropped and one is written where the first of them was (or appended
|
|
76
|
+
* if there were none). The failure is then unrepresentable rather than handled: there is no second
|
|
77
|
+
* occurrence for the two functions to disagree about (CODE-QUALITY #2).
|
|
78
|
+
*/
|
|
79
|
+
function upsertEnvValue(contents, name, value) {
|
|
80
|
+
const line = `${name}=${value}`;
|
|
81
|
+
const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=.*$`);
|
|
82
|
+
const lines = contents === '' ? [] : contents.split('\n');
|
|
83
|
+
const firstIndex = lines.findIndex((candidate) => pattern.test(candidate));
|
|
84
|
+
const kept = lines.filter((candidate) => !pattern.test(candidate));
|
|
85
|
+
if (firstIndex === -1) {
|
|
86
|
+
const prefix = contents === '' || contents.endsWith('\n') ? contents : `${contents}\n`;
|
|
87
|
+
return `${prefix}${line}\n`;
|
|
88
|
+
}
|
|
89
|
+
kept.splice(firstIndex, 0, line);
|
|
90
|
+
const rebuilt = kept.join('\n');
|
|
91
|
+
return rebuilt.endsWith('\n') ? rebuilt : `${rebuilt}\n`;
|
|
92
|
+
}
|
|
93
|
+
function snippetFor(environment) {
|
|
94
|
+
// ⚠️ **`environment` is READ from the variable, not inlined as a literal** — and the first version
|
|
95
|
+
// of this function inlined it, which the "every name it writes is read" test caught immediately.
|
|
96
|
+
// An inlined literal means `gf init` writes GOLDEN_FRIJOLES_ENVIRONMENT into the file and then
|
|
97
|
+
// hands over code that ignores it: change the file, and the app keeps resolving the old
|
|
98
|
+
// environment with nothing to say so. That is precisely the file-and-its-reader drift D6 exists
|
|
99
|
+
// to prevent, so the snippet reads every variable the file carries.
|
|
100
|
+
//
|
|
101
|
+
// The parameter survives as the DEFAULT in the fallback, so the snippet still runs unchanged in a
|
|
102
|
+
// process where the variable is missing — and says which environment it would assume.
|
|
103
|
+
return `import { createFlagProvider } from '@golden-frijoles/sdk'
|
|
104
|
+
|
|
105
|
+
const flags = createFlagProvider({
|
|
106
|
+
baseUrl: process.env.${exports.ENV_KEYS.url}!,
|
|
107
|
+
flagReadKey: process.env.${exports.ENV_KEYS.flagRead}!,
|
|
108
|
+
environment: (process.env.${exports.ENV_KEYS.environment} ?? '${environment}') as 'development' | 'preview' | 'production',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
await flags.initialize()
|
|
112
|
+
const enabled = flags.resolveBooleanEvaluation('checkout.demo_enabled', false, {
|
|
113
|
+
targetingKey: 'opaque-subject-id',
|
|
114
|
+
}).value`;
|
|
115
|
+
}
|
|
116
|
+
exports.initCommand = {
|
|
117
|
+
path: ['init'],
|
|
118
|
+
summary: 'project, key, .env.local and the snippet — in one verb',
|
|
119
|
+
usage: 'gf init [--env <environment>] [--json] [--yes]',
|
|
120
|
+
needsAuth: true,
|
|
121
|
+
detail: `Idempotent. Re-running it does not mint a second key when ${ENV_FILE} already
|
|
122
|
+
carries one; it says so and leaves the file alone.
|
|
123
|
+
|
|
124
|
+
⚠️ It REFUSES if it cannot get ${ENV_FILE} into ${GITIGNORE}. A live credential in a
|
|
125
|
+
tracked file is the failure this verb exists to prevent, so it will not create one and
|
|
126
|
+
then warn about it.`,
|
|
127
|
+
flags: [
|
|
128
|
+
{
|
|
129
|
+
name: 'env',
|
|
130
|
+
value: '<environment>',
|
|
131
|
+
describe: 'development | preview | production (default: development)',
|
|
132
|
+
},
|
|
133
|
+
{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' },
|
|
134
|
+
// ⚠️ Accepted and INERT, described as such. This verb never prompts — there is nothing for a
|
|
135
|
+
// --yes to skip — and a flag whose help implies it suppresses a question that does not exist is
|
|
136
|
+
// a small lie in the one document an agent reads to learn the tool. It stays accepted so a
|
|
137
|
+
// script that passes it defensively does not hit "unknown flag".
|
|
138
|
+
{ name: 'yes', describe: 'accepted and ignored — this verb never prompts' },
|
|
139
|
+
],
|
|
140
|
+
async run(context) {
|
|
141
|
+
const environment = ((0, args_1.flagValue)(context.args, 'env') ?? 'development').trim();
|
|
142
|
+
if (!(0, sdk_1.isFlagEnvironment)(environment)) {
|
|
143
|
+
context.emit.fail('invalid', '--env must be development, preview or production.');
|
|
144
|
+
return exit_codes_1.EXIT.USAGE;
|
|
145
|
+
}
|
|
146
|
+
// ── 1. a project ──────────────────────────────────────────────────────────────────────────
|
|
147
|
+
const chosen = (0, args_1.flagValue)(context.args, 'project')?.trim() || context.auth.activeProject || null;
|
|
148
|
+
let project = chosen;
|
|
149
|
+
if (!project) {
|
|
150
|
+
const ensured = await context.api.post('api/v1/cli/projects', {});
|
|
151
|
+
if (ensured.kind === 'network') {
|
|
152
|
+
context.emit.fail('server_error', ensured.message);
|
|
153
|
+
return exit_codes_1.EXIT.SERVER;
|
|
154
|
+
}
|
|
155
|
+
if (ensured.kind === 'error') {
|
|
156
|
+
context.emit.fail(ensured.code, ensured.message);
|
|
157
|
+
return (0, exit_codes_1.exitForServerCode)(ensured.code);
|
|
158
|
+
}
|
|
159
|
+
project = ensured.body.slug;
|
|
160
|
+
}
|
|
161
|
+
// ── 2. the file must be ignorable BEFORE anything is minted ───────────────────────────────
|
|
162
|
+
const gitignorePath = (0, node_path_1.join)(context.cwd, GITIGNORE);
|
|
163
|
+
const envPath = (0, node_path_1.join)(context.cwd, ENV_FILE);
|
|
164
|
+
const symlinkResult = refuseSymlink(envPath, context);
|
|
165
|
+
if (symlinkResult !== null)
|
|
166
|
+
return symlinkResult;
|
|
167
|
+
const ignoreResult = ensureIgnored(gitignorePath, context);
|
|
168
|
+
if (ignoreResult !== null)
|
|
169
|
+
return ignoreResult;
|
|
170
|
+
// ⚠️ **Writability is checked BEFORE anything is minted** (cross-family review, Codex, round 3).
|
|
171
|
+
// Minting first and discovering a read-only `.env.local` afterwards leaves a LIVE credential
|
|
172
|
+
// nobody holds — unrevokable by the caller, because they never saw it — and a retry mints
|
|
173
|
+
// another. Same ordering rule as the `.gitignore` check above, for the same reason: this verb
|
|
174
|
+
// will not create a credential it cannot then protect or hand over.
|
|
175
|
+
const writableResult = ensureWritable(envPath, context);
|
|
176
|
+
if (writableResult !== null)
|
|
177
|
+
return writableResult;
|
|
178
|
+
// ── 3. mint, unless the file already carries a key that STILL WORKS ───────────────────────
|
|
179
|
+
const existingEnv = (0, node_fs_1.existsSync)(envPath) ? (0, node_fs_1.readFileSync)(envPath, 'utf8') : '';
|
|
180
|
+
const existingKey = readEnvValue(existingEnv, exports.ENV_KEYS.flagRead);
|
|
181
|
+
let minted = null;
|
|
182
|
+
// ⚠️ **"There is a key" is not "the key works", and treating them as the same shipped a real
|
|
183
|
+
// defect** (cross-family review, Codex, PR #149). `flag_read` keys are minted with an expiry and
|
|
184
|
+
// can be revoked from the console, so a rerun of `gf init` after either event reported
|
|
185
|
+
// `reusedExistingKey: true` and left the project unable to read a flag — the CLI cheerfully
|
|
186
|
+
// confirming a setup that no longer works, which is worse than not checking at all.
|
|
187
|
+
//
|
|
188
|
+
// So the key is EXERCISED, against the route that actually serves it.
|
|
189
|
+
const existingKeyState = existingKey === null ? 'absent' : await probeFlagReadKey(context, existingKey, environment);
|
|
190
|
+
if (existingKeyState === 'dead') {
|
|
191
|
+
context.emit.note(`The ${exports.ENV_KEYS.flagRead} in ${ENV_FILE} is revoked or expired — minting a replacement.`);
|
|
192
|
+
}
|
|
193
|
+
if (existingKeyState === 'wrong-environment') {
|
|
194
|
+
context.emit.note(`The ${exports.ENV_KEYS.flagRead} in ${ENV_FILE} reads a DIFFERENT environment — minting a ${environment} key.`);
|
|
195
|
+
}
|
|
196
|
+
// ⚠️ **A key is reused ONLY when it is verified live for THIS environment. `unverified` always
|
|
197
|
+
// refuses.** This is the third shape of this rule, and the history is the reason for it:
|
|
198
|
+
//
|
|
199
|
+
// round 1 — "there is a key" was treated as "the key works" (Codex, round 1)
|
|
200
|
+
// round 4 — a live key for the WRONG environment was kept (Codex, round 4)
|
|
201
|
+
// round 6 — unverified + a changed environment was re-pointed (Codex, round 6)
|
|
202
|
+
// round 7 — unverified + NO recorded environment was labelled (Codex, round 7)
|
|
203
|
+
//
|
|
204
|
+
// Each fix covered one more branch of "which unverified cases are safe?", and each left another.
|
|
205
|
+
// The answer is that none of them are: when the probe cannot speak, neither the key's scope NOR
|
|
206
|
+
// the `GOLDEN_FRIJOLES_ENVIRONMENT` line beside it is known to be true — that line is just text a
|
|
207
|
+
// person may have edited. So the rule stopped enumerating cases and became the only one that
|
|
208
|
+
// holds: verified-live → reuse; dead or wrong-environment → mint; unverified → refuse, retryably,
|
|
209
|
+
// with nothing written. The class is unrepresentable rather than patched (CODE-QUALITY #2).
|
|
210
|
+
//
|
|
211
|
+
// The cost is stated, not hidden: on a deployment with flag serving switched off, a re-run of
|
|
212
|
+
// `gf init` refuses instead of passing. That is the honest answer — it cannot check — and the
|
|
213
|
+
// message says how to proceed.
|
|
214
|
+
if (existingKeyState === 'unverified') {
|
|
215
|
+
context.emit.fail('server_error', `${ENV_FILE} already holds a ${exports.ENV_KEYS.flagRead}, and ${context.api.baseUrl} could not confirm ` +
|
|
216
|
+
`which environment it reads. Nothing was changed. Retry when the deployment answers, or ` +
|
|
217
|
+
`remove that line to mint a fresh ${environment} key.`);
|
|
218
|
+
return exit_codes_1.EXIT.SERVER;
|
|
219
|
+
}
|
|
220
|
+
if (existingKey === null || existingKeyState === 'dead' || existingKeyState === 'wrong-environment') {
|
|
221
|
+
const result = await context.api.post('api/v1/cli/keys', { project, type: 'flag_read', label: `gf init (${environment})`, environment });
|
|
222
|
+
if (result.kind === 'network') {
|
|
223
|
+
context.emit.fail('server_error', result.message);
|
|
224
|
+
return exit_codes_1.EXIT.SERVER;
|
|
225
|
+
}
|
|
226
|
+
if (result.kind === 'error') {
|
|
227
|
+
context.emit.fail(result.code, result.message);
|
|
228
|
+
return (0, exit_codes_1.exitForServerCode)(result.code);
|
|
229
|
+
}
|
|
230
|
+
minted = result.body;
|
|
231
|
+
}
|
|
232
|
+
// ── 4. write it ───────────────────────────────────────────────────────────────────────────
|
|
233
|
+
let next = existingEnv;
|
|
234
|
+
next = upsertEnvValue(next, exports.ENV_KEYS.url, context.api.baseUrl);
|
|
235
|
+
next = upsertEnvValue(next, exports.ENV_KEYS.environment, environment);
|
|
236
|
+
if (minted)
|
|
237
|
+
next = upsertEnvValue(next, exports.ENV_KEYS.flagRead, minted.key);
|
|
238
|
+
// 0600 on every write, not only at creation: `writeFileSync`'s mode is ignored for an existing
|
|
239
|
+
// file, which is how a credential file stays world-readable after the second run.
|
|
240
|
+
(0, node_fs_1.writeFileSync)(envPath, next, { mode: 0o600 });
|
|
241
|
+
try {
|
|
242
|
+
// chmod separately for the same reason. Best-effort: a filesystem without POSIX modes (a
|
|
243
|
+
// Windows checkout) must not fail an otherwise-correct init.
|
|
244
|
+
(0, node_fs_1.chmodSync)(envPath, 0o600);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* not every filesystem has modes; the write above is still correct */
|
|
248
|
+
}
|
|
249
|
+
// ── 5. say what happened, and hand over the snippet ───────────────────────────────────────
|
|
250
|
+
context.emit.ok({
|
|
251
|
+
project,
|
|
252
|
+
environment,
|
|
253
|
+
envFile: envPath,
|
|
254
|
+
// ⚠️ The KEY ID, never the key. Under --json this output is captured by CI and by agents,
|
|
255
|
+
// and a credential in a captured stdout is a credential in a log. It is in the file the
|
|
256
|
+
// command just wrote; that is where it belongs.
|
|
257
|
+
mintedKeyId: minted?.id ?? null,
|
|
258
|
+
reusedExistingKey: minted === null,
|
|
259
|
+
// `live`, `unverified` or `absent` — never a bare boolean. "We could not check" and "we
|
|
260
|
+
// checked and it works" lead to different actions, and an agent handed only `true` cannot
|
|
261
|
+
// tell them apart.
|
|
262
|
+
existingKeyState,
|
|
263
|
+
variables: Object.values(exports.ENV_KEYS),
|
|
264
|
+
snippet: snippetFor(environment),
|
|
265
|
+
}, [
|
|
266
|
+
minted
|
|
267
|
+
? `Minted a flag_read key for ${project} (${environment}) and wrote ${ENV_FILE}.`
|
|
268
|
+
: existingKeyState === 'live'
|
|
269
|
+
? `${ENV_FILE} already has a working ${exports.ENV_KEYS.flagRead}; left it alone and refreshed the other variables.`
|
|
270
|
+
: `${ENV_FILE} already has a ${exports.ENV_KEYS.flagRead}. It could not be verified against ${context.api.baseUrl}, so it was left alone rather than replaced — check it if flags do not resolve.`,
|
|
271
|
+
`${ENV_FILE} is ignored by git and set to mode 0600.`,
|
|
272
|
+
'',
|
|
273
|
+
'Read them like this:',
|
|
274
|
+
'',
|
|
275
|
+
snippetFor(environment),
|
|
276
|
+
].join('\n'));
|
|
277
|
+
return exit_codes_1.EXIT.OK;
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Prove `.env.local` can be written, before a credential exists to put in it.
|
|
282
|
+
*
|
|
283
|
+
* The check is a real write — appending nothing to the file, creating it if absent — because that is
|
|
284
|
+
* the only thing that answers the question. A mode check would be a guess about the filesystem, the
|
|
285
|
+
* process's user, ACLs and mount options, and the guess is wrong exactly where it matters.
|
|
286
|
+
*
|
|
287
|
+
* Creating an empty file as a side effect is harmless: `gf init` is about to write this path
|
|
288
|
+
* anyway, and an empty `.env.local` in a directory where init failed is not a hazard. A minted
|
|
289
|
+
* credential nobody holds is.
|
|
290
|
+
*/
|
|
291
|
+
function ensureWritable(envPath, context) {
|
|
292
|
+
try {
|
|
293
|
+
(0, node_fs_1.appendFileSync)(envPath, '', { mode: 0o600 });
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
context.emit.fail('invalid', `Cannot write ${ENV_FILE} (${err instanceof Error ? err.message : String(err)}). ` +
|
|
298
|
+
`Nothing was minted — a credential created now would be live and unheld. Fix the permissions ` +
|
|
299
|
+
`and re-run.`);
|
|
300
|
+
return exit_codes_1.EXIT.USAGE;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Refuse a `.env.local` that is a SYMLINK.
|
|
305
|
+
*
|
|
306
|
+
* ⚠️ **Because the ignore check and the write look at different things** (cross-family review,
|
|
307
|
+
* Codex, round 2). `git check-ignore .env.local` answers about the PATH, and `writeFileSync`
|
|
308
|
+
* FOLLOWS the link — so an ignored `.env.local` pointing at a tracked file elsewhere in the
|
|
309
|
+
* repository passes every check this verb makes and then writes a live credential into a file git
|
|
310
|
+
* is watching. That is the exact outcome `ensureIgnored` exists to prevent, reached around it.
|
|
311
|
+
*
|
|
312
|
+
* `lstatSync`, not `statSync`: `stat` follows the link and would describe the target, which is the
|
|
313
|
+
* very thing being checked for.
|
|
314
|
+
*
|
|
315
|
+
* Refused rather than resolved-and-re-checked. Following the link to check the target would work,
|
|
316
|
+
* and then `gf init` would be a verb that writes credentials to a path the caller did not name —
|
|
317
|
+
* a worse property than the one it fixed.
|
|
318
|
+
*/
|
|
319
|
+
function refuseSymlink(envPath, context) {
|
|
320
|
+
let link = false;
|
|
321
|
+
try {
|
|
322
|
+
link = (0, node_fs_1.lstatSync)(envPath).isSymbolicLink();
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null; // it does not exist yet, which is the ordinary case
|
|
326
|
+
}
|
|
327
|
+
if (!link)
|
|
328
|
+
return null;
|
|
329
|
+
context.emit.fail('invalid', `${ENV_FILE} is a symlink. Writing through it would put a live credential wherever it points — ` +
|
|
330
|
+
`possibly into a tracked file — so nothing was minted and nothing was written. Replace it with ` +
|
|
331
|
+
`a real file and re-run.`);
|
|
332
|
+
return exit_codes_1.EXIT.USAGE;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Ask GIT whether it really ignores the file, rather than trusting the line we just wrote.
|
|
336
|
+
*
|
|
337
|
+
* ⚠️ **A line in `.gitignore` does not mean a file is ignored** (fresh reviewer, PR #149). A
|
|
338
|
+
* `.env.local` that is ALREADY TRACKED ignores `.gitignore` entirely, and a later `!.env.local`
|
|
339
|
+
* negation overrides an earlier match. `gf init` printed "ignored by git" on the strength of having
|
|
340
|
+
* appended a line — a checkable claim, asserted rather than checked, on the one property that stops
|
|
341
|
+
* a live credential reaching a public repository.
|
|
342
|
+
*
|
|
343
|
+
* `git check-ignore` is git's own answer, so there is nothing to reimplement and nothing to get
|
|
344
|
+
* subtly wrong about precedence.
|
|
345
|
+
*
|
|
346
|
+
* Outside a git repository — or with no `git` on PATH — this returns `null` (proceed). There is no
|
|
347
|
+
* index to be tracked in, so there is nothing this check protects against, and refusing to init a
|
|
348
|
+
* plain directory because `git` is missing would be a worse answer than the one it prevents.
|
|
349
|
+
*/
|
|
350
|
+
function gitReallyIgnores(gitignorePath, context) {
|
|
351
|
+
const cwd = (0, node_path_1.dirname)(gitignorePath);
|
|
352
|
+
try {
|
|
353
|
+
(0, node_child_process_1.execFileSync)('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'ignore' });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return null; // not a repository, or no git — nothing to be tracked in
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
(0, node_child_process_1.execFileSync)('git', ['check-ignore', '-q', '--', ENV_FILE], { cwd, stdio: 'ignore' });
|
|
360
|
+
return null; // git agrees it is ignored
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
context.emit.fail('invalid', `git does NOT ignore ${ENV_FILE} here, even though ${GITIGNORE} names it — it is most likely ` +
|
|
364
|
+
`already tracked, or a later rule un-ignores it. Nothing was minted and nothing was written. ` +
|
|
365
|
+
`Run \`git rm --cached ${ENV_FILE}\` (or fix the rule), then re-run.`);
|
|
366
|
+
return exit_codes_1.EXIT.USAGE;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Does this `flag_read` key still resolve a snapshot?
|
|
371
|
+
*
|
|
372
|
+
* Exercised against `/api/v1/flags/snapshot` — the route that actually serves it — because that is
|
|
373
|
+
* the only thing that can answer the question. The key is sent as its own Bearer credential; the
|
|
374
|
+
* CLI's PAT is not involved and must not be, since a PAT authorizes different things entirely.
|
|
375
|
+
*
|
|
376
|
+
* Three answers, and the third is the one that matters:
|
|
377
|
+
* `live` — the snapshot resolved AND names the environment being set up.
|
|
378
|
+
* `dead` — 401. Unknown, revoked or expired; the caller mints a replacement.
|
|
379
|
+
* `wrong-environment` — it resolves, but for a DIFFERENT environment. A `flag_read` key is scoped
|
|
380
|
+
* to one, so keeping it would pair a production config with a development
|
|
381
|
+
* credential and say nothing.
|
|
382
|
+
* `unverified` — anything else: a 404 because flag serving is switched off on this deployment,
|
|
383
|
+
* a network failure, a proxy. **Reported, never guessed at.** Treating an
|
|
384
|
+
* unanswerable question as `dead` would mint a fresh credential on every run of a
|
|
385
|
+
* deployment with serving off, which breaks the idempotency this verb promises;
|
|
386
|
+
* treating it as `live` silently would repeat the defect this check exists to fix.
|
|
387
|
+
*/
|
|
388
|
+
async function probeFlagReadKey(context, key, wanted) {
|
|
389
|
+
// ⚠️ **Through `clientFor`, NOT a bare `fetch`.** The first version called the global `fetch`
|
|
390
|
+
// directly — it was the obvious way to send a different credential — and that quietly opened a
|
|
391
|
+
// second HTTP path in a package whose whole point is that there is one: it skipped the timeout,
|
|
392
|
+
// the status-to-`code` mapping and the injected `fetchImpl`, so two tests reached the real
|
|
393
|
+
// network and the run took half a second per case.
|
|
394
|
+
//
|
|
395
|
+
// `clientFor(key)` is exactly the right seam: same base URL, same timeouts, same error mapping,
|
|
396
|
+
// a DIFFERENT credential. The CLI's PAT is not involved and must not be — a PAT authorizes
|
|
397
|
+
// something else entirely, and sending it here would tell us nothing about the key in the file.
|
|
398
|
+
const result = await context.clientFor(key).get('api/v1/flags/snapshot');
|
|
399
|
+
if (result.kind === 'ok') {
|
|
400
|
+
// ⚠️ **A live key is not necessarily the RIGHT key** (cross-family review, Codex, round 4). A
|
|
401
|
+
// `flag_read` credential is scoped to ONE environment, and the snapshot names which — so a
|
|
402
|
+
// rerun as `gf init --env production` over a file holding a working DEVELOPMENT key found it
|
|
403
|
+
// live, kept it, and then wrote `GOLDEN_FRIJOLES_ENVIRONMENT=production` beside it. The result
|
|
404
|
+
// is an app that believes it is reading production flags and is reading development's, with
|
|
405
|
+
// nothing anywhere saying so. That is the worst shape a flag bug has.
|
|
406
|
+
//
|
|
407
|
+
// The answer is in the response already; it only had to be looked at.
|
|
408
|
+
return result.body.environment === wanted ? 'live' : 'wrong-environment';
|
|
409
|
+
}
|
|
410
|
+
// A network failure, a 404 from a deployment with flag serving switched off, a proxy's HTML —
|
|
411
|
+
// all "could not tell", which is a third answer and not a synonym for either of the others.
|
|
412
|
+
if (result.kind === 'network')
|
|
413
|
+
return 'unverified';
|
|
414
|
+
return result.code === 'unauthorized' ? 'dead' : 'unverified';
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Get `.env.local` into `.gitignore`, or refuse.
|
|
418
|
+
*
|
|
419
|
+
* Returns `null` when it is now covered, or an exit code when the caller must stop. Refusing is the
|
|
420
|
+
* whole point: a credential in a tracked file is worse than no credential, and a warning printed
|
|
421
|
+
* beside one is a warning nobody reads until the repository is public.
|
|
422
|
+
*/
|
|
423
|
+
function ensureIgnored(gitignorePath, context) {
|
|
424
|
+
let contents = null;
|
|
425
|
+
try {
|
|
426
|
+
contents = (0, node_fs_1.existsSync)(gitignorePath) ? (0, node_fs_1.readFileSync)(gitignorePath, 'utf8') : null;
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
// ⚠️ **The READ is inside the try too, and it was not.** `existsSync` is true for a directory
|
|
430
|
+
// named `.gitignore`, and for a file the process cannot read — `readFileSync` then throws out of
|
|
431
|
+
// the handler, `run()` catches it as an unexpected failure, and the caller gets EXIT.SERVER and
|
|
432
|
+
// "gf init failed unexpectedly" for a condition this verb has a precise refusal for. Found by
|
|
433
|
+
// the test that makes `.gitignore` a directory.
|
|
434
|
+
context.emit.fail('invalid', `Could not read ${GITIGNORE} (${err instanceof Error ? err.message : String(err)}). ` +
|
|
435
|
+
`Nothing was minted and nothing was written. Make ${GITIGNORE} a readable file containing ` +
|
|
436
|
+
`${ENV_FILE}, then re-run.`);
|
|
437
|
+
return exit_codes_1.EXIT.USAGE;
|
|
438
|
+
}
|
|
439
|
+
if (contents !== null && gitignoreCovers(contents))
|
|
440
|
+
return gitReallyIgnores(gitignorePath, context);
|
|
441
|
+
try {
|
|
442
|
+
if (contents === null) {
|
|
443
|
+
(0, node_fs_1.writeFileSync)(gitignorePath, `${ENV_FILE}\n`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
const prefix = contents.endsWith('\n') || contents === '' ? '' : '\n';
|
|
447
|
+
(0, node_fs_1.appendFileSync)(gitignorePath, `${prefix}${ENV_FILE}\n`);
|
|
448
|
+
}
|
|
449
|
+
context.emit.note(`Added ${ENV_FILE} to ${GITIGNORE}.`);
|
|
450
|
+
return gitReallyIgnores(gitignorePath, context);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
context.emit.fail('invalid', `Could not add ${ENV_FILE} to ${GITIGNORE} (${err instanceof Error ? err.message : String(err)}). ` +
|
|
454
|
+
`Nothing was minted and nothing was written — a credential in a tracked file is the one ` +
|
|
455
|
+
`outcome this command will not produce. Add the line yourself and re-run.`);
|
|
456
|
+
return exit_codes_1.EXIT.USAGE;
|
|
457
|
+
}
|
|
458
|
+
}
|