@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,71 @@
|
|
|
1
|
+
import type { Command, CommandContext } from '../command';
|
|
2
|
+
import { type ExitCode } from '../exit-codes';
|
|
3
|
+
export type CliFlagEnvironment = {
|
|
4
|
+
environment: string;
|
|
5
|
+
state: 'on' | 'off' | 'never';
|
|
6
|
+
version: number | null;
|
|
7
|
+
serving: unknown;
|
|
8
|
+
readable: boolean;
|
|
9
|
+
updatedAt: string | null;
|
|
10
|
+
};
|
|
11
|
+
export type CliFlag = {
|
|
12
|
+
key: string;
|
|
13
|
+
valueType: string | null;
|
|
14
|
+
description: string | null;
|
|
15
|
+
latestVersion: number | null;
|
|
16
|
+
environments: CliFlagEnvironment[];
|
|
17
|
+
};
|
|
18
|
+
export type FlagsListBody = {
|
|
19
|
+
project: string;
|
|
20
|
+
flags: CliFlag[];
|
|
21
|
+
environments: Array<{
|
|
22
|
+
environment: string;
|
|
23
|
+
snapshotVersion: number;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
export type FlagDetailBody = {
|
|
28
|
+
project: string;
|
|
29
|
+
flag: CliFlag & {
|
|
30
|
+
versions: Array<{
|
|
31
|
+
version: number;
|
|
32
|
+
versionId: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
definition: unknown;
|
|
35
|
+
servedBy: string[];
|
|
36
|
+
}>;
|
|
37
|
+
audit: Array<{
|
|
38
|
+
action: string;
|
|
39
|
+
environment: string | null;
|
|
40
|
+
reason: string;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
actor: string;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
environments: Array<{
|
|
46
|
+
environment: string;
|
|
47
|
+
snapshotVersion: number;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The project this command acts on: `--project`, else the remembered one.
|
|
53
|
+
*
|
|
54
|
+
* Returns null rather than guessing. A CLI that picked "the first project" when none was chosen
|
|
55
|
+
* would act on a tenant the caller did not name, which is the one mistake a multi-tenant tool must
|
|
56
|
+
* not make quietly.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveProject(context: CommandContext): string | null;
|
|
59
|
+
export declare function missingProject(context: CommandContext): ExitCode;
|
|
60
|
+
/**
|
|
61
|
+
* What an environment is serving, as ONE cell of text.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ Three outcomes, and collapsing any two of them would be the defect this whole area of the
|
|
64
|
+
* product exists to stop making:
|
|
65
|
+
* • `never` / `off` — nothing is served here. The consumer falls back to its own literal.
|
|
66
|
+
* • a value — what a context with no attributes actually gets, from the SDK's own evaluator.
|
|
67
|
+
* • `unreadable` — the stored row disagrees with the parser that wrote it. Never a guess.
|
|
68
|
+
*/
|
|
69
|
+
export declare function describeServing(row: CliFlagEnvironment): string;
|
|
70
|
+
export declare const flagsLsCommand: Command;
|
|
71
|
+
export declare const flagsGetCommand: Command;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 1, Story 1.5 — `gf flags ls` and `gf flags get`.
|
|
3
|
+
//
|
|
4
|
+
// Read-only. Every write verb lands in Sprint 2 and goes through the shared command core; nothing
|
|
5
|
+
// in this file plans or posts anything.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.flagsGetCommand = exports.flagsLsCommand = void 0;
|
|
8
|
+
exports.resolveProject = resolveProject;
|
|
9
|
+
exports.missingProject = missingProject;
|
|
10
|
+
exports.describeServing = describeServing;
|
|
11
|
+
const args_1 = require("../args");
|
|
12
|
+
const exit_codes_1 = require("../exit-codes");
|
|
13
|
+
const output_1 = require("../output");
|
|
14
|
+
/**
|
|
15
|
+
* The project this command acts on: `--project`, else the remembered one.
|
|
16
|
+
*
|
|
17
|
+
* Returns null rather than guessing. A CLI that picked "the first project" when none was chosen
|
|
18
|
+
* would act on a tenant the caller did not name, which is the one mistake a multi-tenant tool must
|
|
19
|
+
* not make quietly.
|
|
20
|
+
*/
|
|
21
|
+
function resolveProject(context) {
|
|
22
|
+
return (0, args_1.flagValue)(context.args, 'project')?.trim() || context.auth.activeProject || null;
|
|
23
|
+
}
|
|
24
|
+
function missingProject(context) {
|
|
25
|
+
context.emit.fail('invalid', 'No project chosen. Pass --project <slug>, or run `gf projects use <slug>`.');
|
|
26
|
+
return exit_codes_1.EXIT.USAGE;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What an environment is serving, as ONE cell of text.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ Three outcomes, and collapsing any two of them would be the defect this whole area of the
|
|
32
|
+
* product exists to stop making:
|
|
33
|
+
* • `never` / `off` — nothing is served here. The consumer falls back to its own literal.
|
|
34
|
+
* • a value — what a context with no attributes actually gets, from the SDK's own evaluator.
|
|
35
|
+
* • `unreadable` — the stored row disagrees with the parser that wrote it. Never a guess.
|
|
36
|
+
*/
|
|
37
|
+
function describeServing(row) {
|
|
38
|
+
if (row.state === 'never')
|
|
39
|
+
return '—';
|
|
40
|
+
if (row.state === 'off')
|
|
41
|
+
return 'off (nothing served)';
|
|
42
|
+
if (!row.readable)
|
|
43
|
+
return 'unreadable';
|
|
44
|
+
return `${JSON.stringify(row.serving)} v${row.version}`;
|
|
45
|
+
}
|
|
46
|
+
exports.flagsLsCommand = {
|
|
47
|
+
path: ['flags', 'ls'],
|
|
48
|
+
summary: 'every flag, and what each environment serves',
|
|
49
|
+
usage: 'gf flags ls [--project <slug>] [--json]',
|
|
50
|
+
needsAuth: true,
|
|
51
|
+
detail: `The "serving" column is what a context with no attributes actually GETS — the
|
|
52
|
+
answer the SDK's own evaluator gives, which is the answer production gives.
|
|
53
|
+
|
|
54
|
+
⚠️ It is NOT the same as "a version is activated here". A definition whose default
|
|
55
|
+
variant is false is activated AND serves false; reporting those as one fact is how a
|
|
56
|
+
console once labelled 34 of 42 flags the wrong way round.`,
|
|
57
|
+
flags: [{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' }],
|
|
58
|
+
async run(context) {
|
|
59
|
+
const project = resolveProject(context);
|
|
60
|
+
if (!project)
|
|
61
|
+
return missingProject(context);
|
|
62
|
+
const result = await context.api.get('api/v1/cli/flags', { project });
|
|
63
|
+
if (result.kind === 'network') {
|
|
64
|
+
context.emit.fail('server_error', result.message);
|
|
65
|
+
return exit_codes_1.EXIT.SERVER;
|
|
66
|
+
}
|
|
67
|
+
if (result.kind === 'error') {
|
|
68
|
+
context.emit.fail(result.code, result.message);
|
|
69
|
+
return (0, exit_codes_1.exitForServerCode)(result.code);
|
|
70
|
+
}
|
|
71
|
+
const { flags, environments } = result.body;
|
|
72
|
+
context.emit.ok({ project, flags, environments }, flags.length === 0
|
|
73
|
+
? `No flags in ${project} yet. Create one with \`gf flags create <key> --kill-switch --all-envs\`.`
|
|
74
|
+
: (0, output_1.table)(['FLAG', 'TYPE', 'DEVELOPMENT', 'PREVIEW', 'PRODUCTION'], flags.map((flag) => [
|
|
75
|
+
flag.key,
|
|
76
|
+
flag.valueType ?? '—',
|
|
77
|
+
...['development', 'preview', 'production'].map((environment) => {
|
|
78
|
+
const row = flag.environments.find((candidate) => candidate.environment === environment);
|
|
79
|
+
return row ? describeServing(row) : '—';
|
|
80
|
+
}),
|
|
81
|
+
])));
|
|
82
|
+
return exit_codes_1.EXIT.OK;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
exports.flagsGetCommand = {
|
|
86
|
+
path: ['flags', 'get'],
|
|
87
|
+
summary: 'one flag: its definition, its versions and who changed it',
|
|
88
|
+
usage: 'gf flags get <key> [--project <slug>] [--json]',
|
|
89
|
+
needsAuth: true,
|
|
90
|
+
flags: [{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' }],
|
|
91
|
+
async run(context) {
|
|
92
|
+
const key = context.args.positionals[0];
|
|
93
|
+
if (!key) {
|
|
94
|
+
context.emit.fail('invalid', 'Name a flag: `gf flags get <key>`.');
|
|
95
|
+
return exit_codes_1.EXIT.USAGE;
|
|
96
|
+
}
|
|
97
|
+
const project = resolveProject(context);
|
|
98
|
+
if (!project)
|
|
99
|
+
return missingProject(context);
|
|
100
|
+
const result = await context.api.get('api/v1/cli/flags', { project, key });
|
|
101
|
+
if (result.kind === 'network') {
|
|
102
|
+
context.emit.fail('server_error', result.message);
|
|
103
|
+
return exit_codes_1.EXIT.SERVER;
|
|
104
|
+
}
|
|
105
|
+
if (result.kind === 'error') {
|
|
106
|
+
context.emit.fail(result.code, result.message);
|
|
107
|
+
return (0, exit_codes_1.exitForServerCode)(result.code);
|
|
108
|
+
}
|
|
109
|
+
const { flag } = result.body;
|
|
110
|
+
context.emit.ok({ project, flag }, [
|
|
111
|
+
`${flag.key} (${flag.valueType ?? 'unknown type'})`,
|
|
112
|
+
flag.description ?? '',
|
|
113
|
+
'',
|
|
114
|
+
(0, output_1.table)(['ENVIRONMENT', 'SERVING', 'SINCE'], flag.environments.map((row) => [row.environment, describeServing(row), row.updatedAt ?? '—'])),
|
|
115
|
+
'',
|
|
116
|
+
(0, output_1.table)(['VERSION', 'CREATED', 'SERVED BY'], flag.versions.map((version) => [
|
|
117
|
+
`v${version.version}`,
|
|
118
|
+
version.createdAt,
|
|
119
|
+
version.servedBy.join(', ') || '—',
|
|
120
|
+
])),
|
|
121
|
+
].join('\n'));
|
|
122
|
+
return exit_codes_1.EXIT.OK;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 2, Story 2.5 — `gf flags sync`, the catalog bridge.
|
|
3
|
+
//
|
|
4
|
+
// ── It uses a DIFFERENT credential, and that is the whole design of this verb ─────────────────
|
|
5
|
+
// Every other write verb rides the CLI's PAT. This one rides a `flag_sync` key, because the thing
|
|
6
|
+
// it is bridging is a CI job publishing a catalog out of source control — a job that must not hold
|
|
7
|
+
// a credential that can also roll out or kill a flag. `lib/credential-inventory.ts` models exactly
|
|
8
|
+
// that difference: "create flag definitions from an outside catalog; CANNOT turn a flag on or off
|
|
9
|
+
// in any environment."
|
|
10
|
+
//
|
|
11
|
+
// So the key comes from `--sync-key` or `GOLDEN_FRIJOLES_FLAG_SYNC_KEY`, and the PAT is not a
|
|
12
|
+
// fallback. Falling back would quietly hand a pipeline the wider credential the moment someone
|
|
13
|
+
// forgot to set the narrower one — which is the failure mode the split exists to prevent.
|
|
14
|
+
//
|
|
15
|
+
// ── It wraps the SHIPPED client, it does not reimplement the protocol ─────────────────────────
|
|
16
|
+
// `createFlagDefinitionSyncClient` from `@golden-frijoles/sdk` owns the request shape, the entry
|
|
17
|
+
// cap and the body limit. A second implementation here would drift from the route the first time
|
|
18
|
+
// either changed.
|
|
19
|
+
//
|
|
20
|
+
// ── Sync NEVER activates, and this verb says so ───────────────────────────────────────────────
|
|
21
|
+
// The route does not activate, by design — a catalog import creates immutable definition versions
|
|
22
|
+
// and nothing else. A CLI that printed "synced" and left the reader assuming production had changed
|
|
23
|
+
// would be papering over that, so the output names it.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.flagsSyncCommand = void 0;
|
|
26
|
+
const node_fs_1 = require("node:fs");
|
|
27
|
+
const sdk_1 = require("@golden-frijoles/sdk");
|
|
28
|
+
const args_1 = require("../args");
|
|
29
|
+
const exit_codes_1 = require("../exit-codes");
|
|
30
|
+
const output_1 = require("../output");
|
|
31
|
+
exports.flagsSyncCommand = {
|
|
32
|
+
path: ['flags', 'sync'],
|
|
33
|
+
summary: 'publish a flag catalog from source control',
|
|
34
|
+
usage: 'gf flags sync --file flags.json [--dry-run]',
|
|
35
|
+
needsAuth: false,
|
|
36
|
+
detail: `Uses a flag_sync key — NOT your CLI token — from --sync-key or
|
|
37
|
+
GOLDEN_FRIJOLES_FLAG_SYNC_KEY. There is deliberately no fallback to the CLI token: a
|
|
38
|
+
pipeline that forgot to set the narrow credential must fail, not quietly get the wide one.
|
|
39
|
+
|
|
40
|
+
Mint one with: gf keys create --type flag_sync --source <name> --label <text>
|
|
41
|
+
|
|
42
|
+
⚠️ Sync NEVER activates, deactivates or deletes a flag. It creates immutable definition
|
|
43
|
+
versions. A key that already exists with an identical definition reports created: false;
|
|
44
|
+
semantic drift from an existing version is a conflict for an owner to resolve through the
|
|
45
|
+
normal version lifecycle.`,
|
|
46
|
+
flags: [
|
|
47
|
+
{ name: 'file', value: '<path>', describe: 'a JSON array of { key, definition } entries' },
|
|
48
|
+
{ name: 'sync-key', value: '<key>', describe: 'the flag_sync credential (or set the env var)' },
|
|
49
|
+
{ name: 'dry-run', describe: 'parse and report, send nothing' },
|
|
50
|
+
],
|
|
51
|
+
async run(context) {
|
|
52
|
+
const path = (0, args_1.flagValue)(context.args, 'file');
|
|
53
|
+
if (!path) {
|
|
54
|
+
context.emit.fail('invalid', 'Usage: `gf flags sync --file flags.json`.');
|
|
55
|
+
return exit_codes_1.EXIT.USAGE;
|
|
56
|
+
}
|
|
57
|
+
let catalog;
|
|
58
|
+
try {
|
|
59
|
+
catalog = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
context.emit.fail('invalid', `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
63
|
+
return exit_codes_1.EXIT.USAGE;
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(catalog)) {
|
|
66
|
+
context.emit.fail('invalid', `${path} must contain a JSON ARRAY of { key, definition } entries.`);
|
|
67
|
+
return exit_codes_1.EXIT.USAGE;
|
|
68
|
+
}
|
|
69
|
+
if ((0, args_1.boolFlag)(context.args, 'dry-run')) {
|
|
70
|
+
// Reports what WOULD be sent and sends nothing — including no credential, which is why the
|
|
71
|
+
// key check is below this branch: a dry run is useful precisely when you have not wired the
|
|
72
|
+
// credential up yet.
|
|
73
|
+
context.emit.ok({
|
|
74
|
+
dryRun: true,
|
|
75
|
+
file: path,
|
|
76
|
+
entries: catalog.length,
|
|
77
|
+
keys: catalog.map((entry) => entry.key ?? null),
|
|
78
|
+
}, `${catalog.length} entr${catalog.length === 1 ? 'y' : 'ies'} in ${path}. Nothing was sent.`);
|
|
79
|
+
return exit_codes_1.EXIT.OK;
|
|
80
|
+
}
|
|
81
|
+
const syncKey = (0, args_1.flagValue)(context.args, 'sync-key')?.trim() || context.env.GOLDEN_FRIJOLES_FLAG_SYNC_KEY?.trim();
|
|
82
|
+
if (!syncKey) {
|
|
83
|
+
context.emit.fail('unauthorized', 'A flag_sync credential is required — pass --sync-key or set GOLDEN_FRIJOLES_FLAG_SYNC_KEY. ' +
|
|
84
|
+
'Your CLI token is deliberately NOT accepted here: catalog publishing gets the narrower key.');
|
|
85
|
+
return exit_codes_1.EXIT.AUTH;
|
|
86
|
+
}
|
|
87
|
+
const client = (0, sdk_1.createFlagDefinitionSyncClient)({ baseUrl: context.auth.apiUrl, flagSyncKey: syncKey });
|
|
88
|
+
const result = await client.syncFlagDefinitions(catalog);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
// `kind` is the SDK's own vocabulary for what went wrong, and it is deliberately NOT the same
|
|
91
|
+
// vocabulary as this CLI's exit codes — mapping between them is this file's job.
|
|
92
|
+
//
|
|
93
|
+
// The mapping turns on the HTTP status for `kind: 'http'`, because that is where the two
|
|
94
|
+
// outcomes an agent can act on differently live: a 409 is catalog DRIFT (an existing immutable
|
|
95
|
+
// version disagrees with the file — resolvable by the caller, exit 4), and a 401 is the sync
|
|
96
|
+
// credential (exit 2). Everything else is exit 6. `validation` is the caller's file and never
|
|
97
|
+
// reached the network at all, so it is a usage error.
|
|
98
|
+
const code = result.kind === 'validation'
|
|
99
|
+
? exit_codes_1.EXIT.USAGE
|
|
100
|
+
: result.kind === 'http' && result.status === 409
|
|
101
|
+
? exit_codes_1.EXIT.CONFLICT
|
|
102
|
+
: result.kind === 'http' && result.status === 401
|
|
103
|
+
? exit_codes_1.EXIT.AUTH
|
|
104
|
+
: exit_codes_1.EXIT.SERVER;
|
|
105
|
+
context.emit.fail(result.kind, result.error,
|
|
106
|
+
// The parser's own messages, verbatim, when it has any. An agent branches on these; folding
|
|
107
|
+
// them into the sentence would turn a list into prose.
|
|
108
|
+
'issues' in result && result.issues !== undefined ? { issues: result.issues } : undefined);
|
|
109
|
+
return code;
|
|
110
|
+
}
|
|
111
|
+
const created = result.entries.filter((entry) => entry.created);
|
|
112
|
+
context.emit.ok({ file: path, entries: result.entries }, [
|
|
113
|
+
`${created.length} new definition version${created.length === 1 ? '' : 's'} from ${result.entries.length} entr${result.entries.length === 1 ? 'y' : 'ies'}.`,
|
|
114
|
+
'',
|
|
115
|
+
(0, output_1.table)(['FLAG', 'VERSION', 'RESULT'], result.entries.map((entry) => [
|
|
116
|
+
entry.key,
|
|
117
|
+
`v${entry.definitionVersion}`,
|
|
118
|
+
// `created: false` is an identical no-op, NOT a failure — the route is idempotent by
|
|
119
|
+
// design and re-running a pipeline must not read as "nothing worked".
|
|
120
|
+
entry.created ? 'created' : 'unchanged',
|
|
121
|
+
])),
|
|
122
|
+
'',
|
|
123
|
+
// Said every time, not only when something changed. The absence of this line is how someone
|
|
124
|
+
// concludes a sync turned a flag on.
|
|
125
|
+
'Sync creates definitions only. Nothing was activated — use `gf flags set` or `gf flags create --all-envs` for that.',
|
|
126
|
+
].join('\n'));
|
|
127
|
+
return exit_codes_1.EXIT.OK;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Command } from '../command';
|
|
2
|
+
export declare const flagsCreateCommand: Command;
|
|
3
|
+
export declare const flagsSetCommand: Command;
|
|
4
|
+
export declare const flagsRolloutCommand: Command;
|
|
5
|
+
export declare const flagsRulesCommand: Command;
|
|
6
|
+
export declare const flagsKillCommand: Command;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// golden-frijoles-cli · Sprint 2 — the write verbs.
|
|
3
|
+
//
|
|
4
|
+
// ── Every one of these is a thin shell ────────────────────────────────────────────────────────
|
|
5
|
+
// Parse flags, post one request, render the D2 report. The DECISIONS — what a kill-switch serves,
|
|
6
|
+
// what killing clears, whether 150 is a percent — all live in `@golden-frijoles/sdk`'s planners and
|
|
7
|
+
// run server-side. That is D4: this file and the MCP write tools are two callers of one brain, so
|
|
8
|
+
// they cannot disagree, and there is nothing here for them to disagree with.
|
|
9
|
+
//
|
|
10
|
+
// ── The exit code comes from the BODY, not the status ─────────────────────────────────────────
|
|
11
|
+
// A partial answers 200, because the definition version was created and some environments did
|
|
12
|
+
// change — a 4xx would tell a caller nothing happened when something did, and a caller who retried
|
|
13
|
+
// on that basis would create a second version. So `outcome: 'partial'` is what produces EXIT.PARTIAL.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.flagsKillCommand = exports.flagsRulesCommand = exports.flagsRolloutCommand = exports.flagsSetCommand = exports.flagsCreateCommand = void 0;
|
|
16
|
+
const node_fs_1 = require("node:fs");
|
|
17
|
+
const sdk_1 = require("@golden-frijoles/sdk");
|
|
18
|
+
const args_1 = require("../args");
|
|
19
|
+
const exit_codes_1 = require("../exit-codes");
|
|
20
|
+
const output_1 = require("../output");
|
|
21
|
+
const flags_read_1 = require("./flags-read");
|
|
22
|
+
/** `--env` (repeatable) and `--all-envs`, shared by every write verb. */
|
|
23
|
+
const ENVIRONMENT_FLAGS = [
|
|
24
|
+
{ name: 'env', value: '<environment>', describe: 'development | preview | production (repeatable)' },
|
|
25
|
+
{ name: 'all-envs', describe: 'all three environments, under the partial-failure contract' },
|
|
26
|
+
{ name: 'project', value: '<slug>', describe: 'the project (default: the remembered one)' },
|
|
27
|
+
{ name: 'reason', value: '<text>', describe: 'why — it goes in the audit trail (default: "via gf")' },
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* The environments a write names.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ `--all-envs` and `--env` together is a USAGE ERROR, not a union. A caller who typed both meant
|
|
33
|
+
* one of them, and picking either silently is how a change reaches an environment nobody named.
|
|
34
|
+
*/
|
|
35
|
+
function resolveEnvironments(context) {
|
|
36
|
+
const named = (0, args_1.flagValues)(context.args, 'env');
|
|
37
|
+
const all = (0, args_1.boolFlag)(context.args, 'all-envs');
|
|
38
|
+
if (all && named.length > 0)
|
|
39
|
+
return { error: '--all-envs and --env say different things. Pass one of them, not both.' };
|
|
40
|
+
if (all)
|
|
41
|
+
return { allEnvs: true };
|
|
42
|
+
if (named.length === 0)
|
|
43
|
+
return {
|
|
44
|
+
error: `Name an environment with --env (${sdk_1.FLAG_ENVIRONMENTS.join(' | ')}), or use --all-envs.`,
|
|
45
|
+
};
|
|
46
|
+
const invalid = named.filter((value) => !sdk_1.FLAG_ENVIRONMENTS.includes(value));
|
|
47
|
+
if (invalid.length > 0)
|
|
48
|
+
return { error: `Not an environment: ${invalid.join(', ')}. Use ${sdk_1.FLAG_ENVIRONMENTS.join(', ')}.` };
|
|
49
|
+
return { environments: named };
|
|
50
|
+
}
|
|
51
|
+
/** Post a write and render the D2 report. The one place a write verb talks to the server. */
|
|
52
|
+
async function submit(context, key, payload, humanVerb) {
|
|
53
|
+
const project = (0, flags_read_1.resolveProject)(context);
|
|
54
|
+
if (!project)
|
|
55
|
+
return (0, flags_read_1.missingProject)(context);
|
|
56
|
+
const environments = resolveEnvironments(context);
|
|
57
|
+
if ('error' in environments) {
|
|
58
|
+
context.emit.fail('invalid', environments.error);
|
|
59
|
+
return exit_codes_1.EXIT.USAGE;
|
|
60
|
+
}
|
|
61
|
+
const result = await context.api.post('api/v1/cli/flags/write', {
|
|
62
|
+
project,
|
|
63
|
+
key,
|
|
64
|
+
reason: (0, args_1.flagValue)(context.args, 'reason')?.trim() || 'via gf',
|
|
65
|
+
...environments,
|
|
66
|
+
...payload,
|
|
67
|
+
});
|
|
68
|
+
if (result.kind === 'network') {
|
|
69
|
+
context.emit.fail('server_error', result.message);
|
|
70
|
+
return exit_codes_1.EXIT.SERVER;
|
|
71
|
+
}
|
|
72
|
+
if (result.kind === 'error') {
|
|
73
|
+
context.emit.fail(result.code, result.message, result.body.issues ? { issues: result.body.issues } : undefined);
|
|
74
|
+
return (0, exit_codes_1.exitForServerCode)(result.code);
|
|
75
|
+
}
|
|
76
|
+
const body = result.body;
|
|
77
|
+
context.emit.ok({ project, ...body }, [
|
|
78
|
+
`${humanVerb} ${body.flagKey} — v${body.version}, serving ${JSON.stringify(body.serving)}`,
|
|
79
|
+
'',
|
|
80
|
+
(0, output_1.table)(['ENVIRONMENT', 'RESULT', 'SNAPSHOT'], body.environments.map((row) => [
|
|
81
|
+
row.environment,
|
|
82
|
+
row.error ? `${row.status}: ${row.error}` : row.status,
|
|
83
|
+
row.snapshotVersion === null ? '—' : String(row.snapshotVersion),
|
|
84
|
+
])),
|
|
85
|
+
].join('\n'));
|
|
86
|
+
// ⚠️ D2. `partial` is neither a success nor a clean failure — some environments changed and some
|
|
87
|
+
// did not — and a CLI with no code for it forces the caller to parse prose. The version WAS
|
|
88
|
+
// created in every case, which is why this is never EXIT.OK.
|
|
89
|
+
return body.outcome === 'partial' ? exit_codes_1.EXIT.PARTIAL : exit_codes_1.EXIT.OK;
|
|
90
|
+
}
|
|
91
|
+
exports.flagsCreateCommand = {
|
|
92
|
+
path: ['flags', 'create'],
|
|
93
|
+
summary: 'create a flag, with a polarity, in the environments you name',
|
|
94
|
+
usage: 'gf flags create <key> --kill-switch|--enablement --all-envs',
|
|
95
|
+
needsAuth: true,
|
|
96
|
+
detail: `Polarity decides what the flag serves on the day it is born, and the CLI derives the
|
|
97
|
+
default variant AND the activation from it — so the wrong combination cannot be typed:
|
|
98
|
+
|
|
99
|
+
--kill-switch default "on", serves TRUE everywhere (it is on until you kill it)
|
|
100
|
+
--enablement default "off", serves FALSE everywhere (you open it deliberately later)
|
|
101
|
+
|
|
102
|
+
⚠️ BOTH polarities ACTIVATE. A flag that is not activated is absent from the environment's
|
|
103
|
+
snapshot, so your app falls back to its own literal and the flag is invisible in the
|
|
104
|
+
provider — which is the failure this tool exists to end. "Disabled" means serving false.
|
|
105
|
+
|
|
106
|
+
For a non-boolean flag use --type with --variants and --default; polarity does not apply,
|
|
107
|
+
because "which way is off?" has no answer for a string.`,
|
|
108
|
+
flags: [
|
|
109
|
+
...ENVIRONMENT_FLAGS,
|
|
110
|
+
{ name: 'kill-switch', describe: 'born serving true — the incident switch' },
|
|
111
|
+
{ name: 'enablement', describe: 'born serving false — the gate you open later' },
|
|
112
|
+
{ name: 'description', value: '<text>', describe: 'what the flag is for' },
|
|
113
|
+
{ name: 'type', value: '<type>', describe: 'string | number | json (omit for a boolean)' },
|
|
114
|
+
{ name: 'variants', value: '<json>', describe: 'a JSON array of {key,value} — with --type' },
|
|
115
|
+
{ name: 'default', value: '<key>', describe: 'which variant is served by default — with --type' },
|
|
116
|
+
],
|
|
117
|
+
async run(context) {
|
|
118
|
+
const key = context.args.positionals[0];
|
|
119
|
+
if (!key) {
|
|
120
|
+
context.emit.fail('invalid', 'Name a flag: `gf flags create <key> --kill-switch --all-envs`.');
|
|
121
|
+
return exit_codes_1.EXIT.USAGE;
|
|
122
|
+
}
|
|
123
|
+
const killSwitch = (0, args_1.boolFlag)(context.args, 'kill-switch');
|
|
124
|
+
const enablement = (0, args_1.boolFlag)(context.args, 'enablement');
|
|
125
|
+
const type = (0, args_1.flagValue)(context.args, 'type');
|
|
126
|
+
if (killSwitch && enablement) {
|
|
127
|
+
// The one combination the planner's input type cannot forbid, because the command line can
|
|
128
|
+
// carry both words. Refused here so it never reaches a planner that would have to pick.
|
|
129
|
+
context.emit.fail('invalid', 'A flag is a kill-switch or an enablement, not both.');
|
|
130
|
+
return exit_codes_1.EXIT.USAGE;
|
|
131
|
+
}
|
|
132
|
+
if (type !== undefined) {
|
|
133
|
+
if (killSwitch || enablement) {
|
|
134
|
+
context.emit.fail('invalid', 'Polarity is a boolean concept — "which way is off?" has no answer for a ' +
|
|
135
|
+
`${type} flag. Use --variants and --default instead.`);
|
|
136
|
+
return exit_codes_1.EXIT.USAGE;
|
|
137
|
+
}
|
|
138
|
+
const rawVariants = (0, args_1.flagValue)(context.args, 'variants');
|
|
139
|
+
const defaultVariantKey = (0, args_1.flagValue)(context.args, 'default');
|
|
140
|
+
if (!rawVariants || !defaultVariantKey) {
|
|
141
|
+
context.emit.fail('invalid', '--type needs --variants \'[{"key":"a","value":1}]\' and --default <key>.');
|
|
142
|
+
return exit_codes_1.EXIT.USAGE;
|
|
143
|
+
}
|
|
144
|
+
let variants;
|
|
145
|
+
try {
|
|
146
|
+
variants = JSON.parse(rawVariants);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
context.emit.fail('invalid', '--variants must be valid JSON.');
|
|
150
|
+
return exit_codes_1.EXIT.USAGE;
|
|
151
|
+
}
|
|
152
|
+
return submit(context, key, {
|
|
153
|
+
command: 'create',
|
|
154
|
+
valueType: type,
|
|
155
|
+
variants,
|
|
156
|
+
defaultVariantKey,
|
|
157
|
+
description: (0, args_1.flagValue)(context.args, 'description') ?? '',
|
|
158
|
+
}, 'Created');
|
|
159
|
+
}
|
|
160
|
+
if (!killSwitch && !enablement) {
|
|
161
|
+
// No default polarity, on purpose. Guessing "enablement" would create a flag serving `false`
|
|
162
|
+
// for someone who meant a kill-switch, and the two are opposites on the day they matter.
|
|
163
|
+
context.emit.fail('invalid', 'Say which kind of flag this is: --kill-switch (born on) or --enablement (born off).');
|
|
164
|
+
return exit_codes_1.EXIT.USAGE;
|
|
165
|
+
}
|
|
166
|
+
return submit(context, key, {
|
|
167
|
+
command: 'create',
|
|
168
|
+
polarity: killSwitch ? 'kill-switch' : 'enablement',
|
|
169
|
+
description: (0, args_1.flagValue)(context.args, 'description') ?? '',
|
|
170
|
+
}, 'Created');
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
exports.flagsSetCommand = {
|
|
174
|
+
path: ['flags', 'set'],
|
|
175
|
+
summary: 'change which variant a flag serves by default',
|
|
176
|
+
usage: 'gf flags set <key> --value true|false --env production',
|
|
177
|
+
needsAuth: true,
|
|
178
|
+
detail: `--value true|false is shorthand for the "on" / "off" variants every flag \`gf flags
|
|
179
|
+
create\` makes. For a flag created elsewhere, or a non-boolean one, name the variant with
|
|
180
|
+
--variant; the server lists the real variant keys if the one you name is not there.
|
|
181
|
+
|
|
182
|
+
Rules are carried across untouched. A set that quietly dropped a targeting rule would be
|
|
183
|
+
the worst kind of surprise on a flag someone is mid-rollout on — use \`gf flags kill\` when
|
|
184
|
+
clearing the rules is what you mean.`,
|
|
185
|
+
flags: [
|
|
186
|
+
...ENVIRONMENT_FLAGS,
|
|
187
|
+
{ name: 'value', value: 'true|false', describe: 'shorthand for the on / off variant' },
|
|
188
|
+
{ name: 'variant', value: '<key>', describe: 'the variant to serve, by name' },
|
|
189
|
+
],
|
|
190
|
+
async run(context) {
|
|
191
|
+
const key = context.args.positionals[0];
|
|
192
|
+
if (!key) {
|
|
193
|
+
context.emit.fail('invalid', 'Name a flag: `gf flags set <key> --value true --env production`.');
|
|
194
|
+
return exit_codes_1.EXIT.USAGE;
|
|
195
|
+
}
|
|
196
|
+
const variant = (0, args_1.flagValue)(context.args, 'variant');
|
|
197
|
+
const value = (0, args_1.flagValue)(context.args, 'value');
|
|
198
|
+
if (variant && value) {
|
|
199
|
+
context.emit.fail('invalid', '--value and --variant say the same thing two ways. Pass one.');
|
|
200
|
+
return exit_codes_1.EXIT.USAGE;
|
|
201
|
+
}
|
|
202
|
+
let variantKey = variant;
|
|
203
|
+
if (value !== undefined) {
|
|
204
|
+
const normalized = value.trim().toLowerCase();
|
|
205
|
+
if (normalized !== 'true' && normalized !== 'false') {
|
|
206
|
+
context.emit.fail('invalid', '--value takes true or false. For anything else use --variant <key>.');
|
|
207
|
+
return exit_codes_1.EXIT.USAGE;
|
|
208
|
+
}
|
|
209
|
+
// The SDK's own constants, not the literals 'on'/'off' — `create` writes these exact keys, so
|
|
210
|
+
// a typo in either place would produce a `set` that silently misses.
|
|
211
|
+
variantKey = normalized === 'true' ? sdk_1.ON_VARIANT_KEY : sdk_1.OFF_VARIANT_KEY;
|
|
212
|
+
}
|
|
213
|
+
if (!variantKey) {
|
|
214
|
+
context.emit.fail('invalid', 'Say what to serve: --value true|false, or --variant <key>.');
|
|
215
|
+
return exit_codes_1.EXIT.USAGE;
|
|
216
|
+
}
|
|
217
|
+
return submit(context, key, { command: 'set', variantKey }, 'Set');
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
exports.flagsRolloutCommand = {
|
|
221
|
+
path: ['flags', 'rollout'],
|
|
222
|
+
summary: 'serve a flag to a percentage of contexts',
|
|
223
|
+
usage: 'gf flags rollout <key> --percent 25 --env production',
|
|
224
|
+
needsAuth: true,
|
|
225
|
+
detail: `⚠️ REPLACES the rule list with one unconditional rollout rule, and says so here rather
|
|
226
|
+
than surprising you. An unconditional rollout beside existing clause rules is ambiguous
|
|
227
|
+
about which one wins at a glance, and "ambiguous at a glance" is what an operator reaches
|
|
228
|
+
for at 3am. Use \`gf flags rules --rules-file\` to compose several.
|
|
229
|
+
|
|
230
|
+
--percent is rejected, never clamped: 150 is a typo, and agreeing with a typo is worse
|
|
231
|
+
than refusing it. 0 and 100 are exactly expressible.`,
|
|
232
|
+
flags: [
|
|
233
|
+
...ENVIRONMENT_FLAGS,
|
|
234
|
+
{ name: 'percent', value: '<0-100>', describe: 'the share of matching contexts served' },
|
|
235
|
+
{ name: 'variant', value: '<key>', describe: 'which variant to roll out (default: on)' },
|
|
236
|
+
],
|
|
237
|
+
async run(context) {
|
|
238
|
+
const key = context.args.positionals[0];
|
|
239
|
+
const raw = (0, args_1.flagValue)(context.args, 'percent');
|
|
240
|
+
if (!key || raw === undefined) {
|
|
241
|
+
context.emit.fail('invalid', 'Usage: `gf flags rollout <key> --percent <0-100> --env production`.');
|
|
242
|
+
return exit_codes_1.EXIT.USAGE;
|
|
243
|
+
}
|
|
244
|
+
const percent = Number(raw);
|
|
245
|
+
if (!Number.isFinite(percent)) {
|
|
246
|
+
context.emit.fail('invalid', '--percent takes a number from 0 to 100.');
|
|
247
|
+
return exit_codes_1.EXIT.USAGE;
|
|
248
|
+
}
|
|
249
|
+
return submit(context, key, { command: 'rollout', percent, variantKey: (0, args_1.flagValue)(context.args, 'variant') }, 'Rolled out');
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
exports.flagsRulesCommand = {
|
|
253
|
+
path: ['flags', 'rules'],
|
|
254
|
+
summary: 'replace a flag’s targeting rules from a file',
|
|
255
|
+
usage: 'gf flags rules <key> --rules-file rules.json --env production',
|
|
256
|
+
needsAuth: true,
|
|
257
|
+
detail: `A FILE, never a command-line expression language. A rule DSL is the appetite trap
|
|
258
|
+
this epic named, and a file has a property that matters more: rules live in source
|
|
259
|
+
control, reviewed, beside the code they target.
|
|
260
|
+
|
|
261
|
+
The file is a JSON array of rules. The caps come from the SDK's own constants, so the CLI
|
|
262
|
+
and the parser cannot disagree about how many rules a flag may have.`,
|
|
263
|
+
flags: [...ENVIRONMENT_FLAGS, { name: 'rules-file', value: '<path>', describe: 'a JSON array of rules' }],
|
|
264
|
+
async run(context) {
|
|
265
|
+
const key = context.args.positionals[0];
|
|
266
|
+
const path = (0, args_1.flagValue)(context.args, 'rules-file');
|
|
267
|
+
if (!key || !path) {
|
|
268
|
+
context.emit.fail('invalid', 'Usage: `gf flags rules <key> --rules-file rules.json --env production`.');
|
|
269
|
+
return exit_codes_1.EXIT.USAGE;
|
|
270
|
+
}
|
|
271
|
+
let rules;
|
|
272
|
+
try {
|
|
273
|
+
rules = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
// The path and the reason, both. "Could not read rules" sends someone looking at the rules.
|
|
277
|
+
context.emit.fail('invalid', `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
+
return exit_codes_1.EXIT.USAGE;
|
|
279
|
+
}
|
|
280
|
+
if (!Array.isArray(rules)) {
|
|
281
|
+
context.emit.fail('invalid', `${path} must contain a JSON ARRAY of rules.`);
|
|
282
|
+
return exit_codes_1.EXIT.USAGE;
|
|
283
|
+
}
|
|
284
|
+
return submit(context, key, { command: 'rules', rules }, 'Replaced the rules of');
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
exports.flagsKillCommand = {
|
|
288
|
+
path: ['flags', 'kill'],
|
|
289
|
+
summary: 'the 3am verb — serve false, and clear every rule',
|
|
290
|
+
usage: 'gf flags kill <key> --env production',
|
|
291
|
+
needsAuth: true,
|
|
292
|
+
detail: `Two things happen, and the second is the one a hand-composed "set --value false"
|
|
293
|
+
forgets:
|
|
294
|
+
|
|
295
|
+
1. the default variant becomes the one whose value is false, AND
|
|
296
|
+
2. EVERY RULE IS CLEARED.
|
|
297
|
+
|
|
298
|
+
Without (2) a flag reads as "off" on the flags page while a 10% rollout is still serving
|
|
299
|
+
true to one caller in ten — which is exactly the state you are killing the flag to escape.
|
|
300
|
+
|
|
301
|
+
Refuses a non-boolean flag rather than guessing: there is no defensible "off" for a string.`,
|
|
302
|
+
flags: ENVIRONMENT_FLAGS,
|
|
303
|
+
async run(context) {
|
|
304
|
+
const key = context.args.positionals[0];
|
|
305
|
+
if (!key) {
|
|
306
|
+
context.emit.fail('invalid', 'Name a flag: `gf flags kill <key> --env production`.');
|
|
307
|
+
return exit_codes_1.EXIT.USAGE;
|
|
308
|
+
}
|
|
309
|
+
return submit(context, key, { command: 'kill' }, 'Killed');
|
|
310
|
+
},
|
|
311
|
+
};
|