@golden-frijoles/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +125 -0
  2. package/dist/api.d.ts +28 -0
  3. package/dist/api.js +104 -0
  4. package/dist/args.d.ts +26 -0
  5. package/dist/args.js +156 -0
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +18 -0
  8. package/dist/command.d.ts +75 -0
  9. package/dist/command.js +34 -0
  10. package/dist/commands/auth.d.ts +4 -0
  11. package/dist/commands/auth.js +214 -0
  12. package/dist/commands/doctor.d.ts +9 -0
  13. package/dist/commands/doctor.js +239 -0
  14. package/dist/commands/flags-history.d.ts +3 -0
  15. package/dist/commands/flags-history.js +183 -0
  16. package/dist/commands/flags-read.d.ts +71 -0
  17. package/dist/commands/flags-read.js +124 -0
  18. package/dist/commands/flags-sync.d.ts +2 -0
  19. package/dist/commands/flags-sync.js +129 -0
  20. package/dist/commands/flags-write.d.ts +6 -0
  21. package/dist/commands/flags-write.js +311 -0
  22. package/dist/commands/index.d.ts +2 -0
  23. package/dist/commands/index.js +40 -0
  24. package/dist/commands/init.d.ts +33 -0
  25. package/dist/commands/init.js +458 -0
  26. package/dist/commands/keys.d.ts +4 -0
  27. package/dist/commands/keys.js +177 -0
  28. package/dist/commands/projects.d.ts +4 -0
  29. package/dist/commands/projects.js +114 -0
  30. package/dist/credentials.d.ts +60 -0
  31. package/dist/credentials.js +120 -0
  32. package/dist/exit-codes.d.ts +24 -0
  33. package/dist/exit-codes.js +70 -0
  34. package/dist/help.d.ts +49 -0
  35. package/dist/help.js +118 -0
  36. package/dist/index.d.ts +7 -0
  37. package/dist/index.js +28 -0
  38. package/dist/output.d.ts +26 -0
  39. package/dist/output.js +68 -0
  40. package/dist/run.d.ts +13 -0
  41. package/dist/run.js +135 -0
  42. package/dist/version.d.ts +1 -0
  43. package/dist/version.js +13 -0
  44. package/package.json +40 -0
@@ -0,0 +1,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,2 @@
1
+ import type { Command } from '../command';
2
+ export declare const flagsSyncCommand: Command;
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ import type { Command } from '../command';
2
+ export declare const COMMANDS: readonly Command[];