@nebulr-group/bridge-cli 0.1.4 → 0.4.0-beta.1
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 +26 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/app.command.d.ts.map +1 -1
- package/dist/commands/app.command.js +2 -0
- package/dist/commands/app.command.js.map +1 -1
- package/dist/commands/flag-init.command.d.ts +13 -0
- package/dist/commands/flag-init.command.d.ts.map +1 -0
- package/dist/commands/flag-init.command.js +353 -0
- package/dist/commands/flag-init.command.js.map +1 -0
- package/dist/commands/flag.command.d.ts +92 -0
- package/dist/commands/flag.command.d.ts.map +1 -1
- package/dist/commands/flag.command.js +784 -25
- package/dist/commands/flag.command.js.map +1 -1
- package/dist/commands/guide.command.d.ts +21 -0
- package/dist/commands/guide.command.d.ts.map +1 -1
- package/dist/commands/guide.command.js +290 -23
- package/dist/commands/guide.command.js.map +1 -1
- package/dist/commands/integrate.command.js +3 -3
- package/dist/commands/integrate.command.js.map +1 -1
- package/dist/commands/ops.command.d.ts +17 -0
- package/dist/commands/ops.command.d.ts.map +1 -0
- package/dist/commands/ops.command.js +129 -0
- package/dist/commands/ops.command.js.map +1 -0
- package/dist/commands/plan.command.d.ts +31 -0
- package/dist/commands/plan.command.d.ts.map +1 -1
- package/dist/commands/plan.command.js +182 -1
- package/dist/commands/plan.command.js.map +1 -1
- package/dist/commands/runtime-dir.d.ts +16 -0
- package/dist/commands/runtime-dir.d.ts.map +1 -0
- package/dist/commands/runtime-dir.js +18 -0
- package/dist/commands/runtime-dir.js.map +1 -0
- package/dist/commands/stripe.command.d.ts +3 -0
- package/dist/commands/stripe.command.d.ts.map +1 -0
- package/dist/commands/stripe.command.js +43 -0
- package/dist/commands/stripe.command.js.map +1 -0
- package/dist/output.d.ts +8 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +10 -0
- package/dist/output.js.map +1 -1
- package/dist/prompts/architecture.md +560 -0
- package/dist/prompts/auth-master-integration-prompt.md +251 -0
- package/dist/prompts/billing/master.md +269 -0
- package/dist/prompts/flags/master.md +215 -0
- package/dist/prompts/integration-success.md +107 -0
- package/package.json +4 -3
|
@@ -1,66 +1,197 @@
|
|
|
1
|
+
// TBP-192 — Flag CLI parity for FF 2.0.
|
|
2
|
+
//
|
|
3
|
+
// The CLI's `flag` subcommand exposes the 2.0 mental model:
|
|
4
|
+
// - Three-state model: off | on | on-with-rule
|
|
5
|
+
// - Multi-type values: boolean | string | number | json
|
|
6
|
+
// - Inline rules (branches + conditions + rolloutPct)
|
|
7
|
+
// - Scheduling (data-only; the runner is TBP-189 — we just set/clear the field)
|
|
8
|
+
// - Local eval helper for debugging
|
|
9
|
+
//
|
|
10
|
+
// Types are now first-class via @nebulr-group/bridge-auth-core 0.2.0-wt3.0:
|
|
11
|
+
// `FlagResponse`, `CreateFlagInput`, `UpdateFlagInput`, `FlagSchedule`,
|
|
12
|
+
// `FlagState`, and `FlagValueType` all describe the FF 2.0 management surface
|
|
13
|
+
// directly, so the SDK-boundary `as never` / `as unknown as Record<string,
|
|
14
|
+
// unknown>[]` casts from the 0.1.x era are gone. The local CLI rule shape
|
|
15
|
+
// (`Condition`, `Branch`, `Rule` below) is intentionally kept distinct from
|
|
16
|
+
// the package's `Rule`/`Condition` — see the note above the local interfaces.
|
|
17
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
1
18
|
import { getManagementClient } from '../config.js';
|
|
2
19
|
import { outputSuccess, outputError } from '../output.js';
|
|
20
|
+
import { registerFlagInitCommand } from './flag-init.command.js';
|
|
21
|
+
const FLAG_STATES = ['off', 'on', 'on-with-rule'];
|
|
22
|
+
const FLAG_VALUE_TYPES = ['boolean', 'string', 'number', 'json'];
|
|
23
|
+
// ── Public registration ─────────────────────────────────────────────────────
|
|
3
24
|
export function registerFlagCommands(program) {
|
|
4
|
-
const flag = program
|
|
5
|
-
|
|
6
|
-
.description(
|
|
25
|
+
const flag = program
|
|
26
|
+
.command('flag')
|
|
27
|
+
.description([
|
|
28
|
+
'Manage feature flags (Feature Flags 2.0 mental model)',
|
|
29
|
+
'',
|
|
30
|
+
' state off | on | on-with-rule',
|
|
31
|
+
' values boolean | string | number | json (typed at flag level)',
|
|
32
|
+
' rule branches[] + otherwiseValue + rolloutPct (first-match-wins)',
|
|
33
|
+
].join('\n'));
|
|
34
|
+
registerList(flag);
|
|
35
|
+
registerGet(flag);
|
|
36
|
+
registerCreate(flag);
|
|
37
|
+
registerUpdate(flag);
|
|
38
|
+
registerToggle(flag);
|
|
39
|
+
registerDelete(flag);
|
|
40
|
+
registerEval(flag);
|
|
41
|
+
registerSchedule(flag);
|
|
42
|
+
registerExport(flag);
|
|
43
|
+
registerImport(flag);
|
|
44
|
+
registerFlagInitCommand(flag);
|
|
45
|
+
}
|
|
46
|
+
// ── list ────────────────────────────────────────────────────────────────────
|
|
47
|
+
function registerList(flag) {
|
|
48
|
+
flag
|
|
49
|
+
.command('list')
|
|
50
|
+
.description('List feature flags (shows state + value type)')
|
|
7
51
|
.action(async () => {
|
|
8
52
|
try {
|
|
9
|
-
|
|
53
|
+
const flags = await getManagementClient().flags.list();
|
|
54
|
+
// Normalize so callers always see the same shape even if the API
|
|
55
|
+
// returns the legacy fields only.
|
|
56
|
+
const rows = flags.map((f) => ({
|
|
57
|
+
id: f.id,
|
|
58
|
+
key: f.key,
|
|
59
|
+
description: f.description,
|
|
60
|
+
state: f.state ?? deriveState(f),
|
|
61
|
+
valueType: f.valueType ?? 'boolean',
|
|
62
|
+
enabled: f.enabled,
|
|
63
|
+
}));
|
|
64
|
+
outputSuccess(rows);
|
|
10
65
|
}
|
|
11
66
|
catch (err) {
|
|
12
67
|
outputError(err);
|
|
13
68
|
}
|
|
14
69
|
});
|
|
15
|
-
|
|
16
|
-
|
|
70
|
+
}
|
|
71
|
+
// ── get ─────────────────────────────────────────────────────────────────────
|
|
72
|
+
function registerGet(flag) {
|
|
73
|
+
flag
|
|
74
|
+
.command('get')
|
|
75
|
+
.argument('<key>', 'Flag key')
|
|
76
|
+
.description('Get a flag (rich 2.0 shape: state, value type, rule, observability)')
|
|
77
|
+
.action(async (key) => {
|
|
78
|
+
try {
|
|
79
|
+
const all = await getManagementClient().flags.list();
|
|
80
|
+
const found = all.find((f) => f.key === key);
|
|
81
|
+
if (!found) {
|
|
82
|
+
outputError(new Error(`Flag not found: ${key}`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
outputSuccess({
|
|
86
|
+
id: found.id,
|
|
87
|
+
key: found.key,
|
|
88
|
+
description: found.description,
|
|
89
|
+
state: found.state ?? deriveState(found),
|
|
90
|
+
valueType: found.valueType ?? 'boolean',
|
|
91
|
+
offValue: found.offValue,
|
|
92
|
+
onValue: found.onValue,
|
|
93
|
+
rule: found.rule,
|
|
94
|
+
schedule: found.schedule,
|
|
95
|
+
observability: {
|
|
96
|
+
evalCount: found.evalCount,
|
|
97
|
+
lastEvalAt: found.lastEvalAt,
|
|
98
|
+
},
|
|
99
|
+
// Echo the legacy fields too — useful while 1.0 admin clients exist.
|
|
100
|
+
legacy: {
|
|
101
|
+
enabled: found.enabled,
|
|
102
|
+
defaultValue: found.defaultValue,
|
|
103
|
+
targetValue: found.targetValue,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
outputError(err);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// ── create ──────────────────────────────────────────────────────────────────
|
|
113
|
+
function registerCreate(flag) {
|
|
114
|
+
flag
|
|
115
|
+
.command('create')
|
|
116
|
+
.description('Create a new feature flag (2.0 fields)')
|
|
17
117
|
.requiredOption('--key <key>', 'Flag key')
|
|
18
118
|
.option('--description <desc>', 'Description')
|
|
19
|
-
.option('--
|
|
20
|
-
.option('--
|
|
119
|
+
.option('--state <state>', `Three-state model: ${FLAG_STATES.join(' | ')} (default: off)`)
|
|
120
|
+
.option('--value-type <type>', `Flag value type: ${FLAG_VALUE_TYPES.join(' | ')} (default: boolean)`)
|
|
121
|
+
.option('--on-value <value>', 'Value returned when state=on (parsed per --value-type; JSON string for json type)')
|
|
122
|
+
.option('--off-value <value>', 'Value returned when state=off (parsed per --value-type)')
|
|
123
|
+
.option('--rule <json>', 'Rule as a JSON string: {"branches":[...],"otherwiseValue":...,"rolloutPct":100}')
|
|
124
|
+
// Legacy 1.0 fields kept so existing scripts don't break.
|
|
125
|
+
.option('--enabled', 'Legacy: enable the flag', false)
|
|
126
|
+
.option('--default-value', 'Legacy: default value when no segment matches', false)
|
|
21
127
|
.action(async (opts) => {
|
|
22
128
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
129
|
+
// `--key` is a requiredOption above, so `key` is always present at
|
|
130
|
+
// runtime even though buildFlagWritePayload's generic return type
|
|
131
|
+
// can't express that. The `unknown` step is just to convince TS the
|
|
132
|
+
// intent is deliberate.
|
|
133
|
+
const payload = buildFlagWritePayload(opts);
|
|
134
|
+
const flag = await getManagementClient().flags.create(payload);
|
|
135
|
+
outputSuccess(flag);
|
|
29
136
|
}
|
|
30
137
|
catch (err) {
|
|
31
138
|
outputError(err);
|
|
32
139
|
}
|
|
33
140
|
});
|
|
34
|
-
|
|
35
|
-
|
|
141
|
+
}
|
|
142
|
+
// ── update ──────────────────────────────────────────────────────────────────
|
|
143
|
+
function registerUpdate(flag) {
|
|
144
|
+
flag
|
|
145
|
+
.command('update')
|
|
146
|
+
.description('Update a feature flag (2.0 fields)')
|
|
36
147
|
.requiredOption('--id <id>', 'Flag ID')
|
|
37
148
|
.option('--key <key>', 'Flag key')
|
|
38
149
|
.option('--description <desc>', 'Description')
|
|
39
|
-
.option('--
|
|
40
|
-
.option('--
|
|
150
|
+
.option('--state <state>', `Three-state model: ${FLAG_STATES.join(' | ')}`)
|
|
151
|
+
.option('--value-type <type>', `Flag value type: ${FLAG_VALUE_TYPES.join(' | ')}`)
|
|
152
|
+
.option('--on-value <value>', 'Value returned when state=on (parsed per --value-type)')
|
|
153
|
+
.option('--off-value <value>', 'Value returned when state=off (parsed per --value-type)')
|
|
154
|
+
.option('--rule <json>', 'Rule as a JSON string (must validate via validateRule)')
|
|
155
|
+
.option('--clear-rule', 'Remove the existing rule')
|
|
156
|
+
// Legacy 1.0
|
|
157
|
+
.option('--enabled <bool>', 'Legacy: enable/disable', (v) => v === 'true')
|
|
158
|
+
.option('--default-value <bool>', 'Legacy: default value', (v) => v === 'true')
|
|
41
159
|
.action(async (opts) => {
|
|
42
160
|
try {
|
|
43
|
-
const { id
|
|
44
|
-
const
|
|
45
|
-
|
|
161
|
+
const { id } = opts;
|
|
162
|
+
const payload = buildFlagWritePayload(opts, { partial: true });
|
|
163
|
+
if (opts.clearRule) {
|
|
164
|
+
payload.rule = null;
|
|
165
|
+
}
|
|
166
|
+
const flag = await getManagementClient().flags.update(id, payload);
|
|
167
|
+
outputSuccess(flag);
|
|
46
168
|
}
|
|
47
169
|
catch (err) {
|
|
48
170
|
outputError(err);
|
|
49
171
|
}
|
|
50
172
|
});
|
|
51
|
-
|
|
52
|
-
|
|
173
|
+
}
|
|
174
|
+
// ── toggle (kept from 1.0; convenience for state on/off without typing JSON) ─
|
|
175
|
+
function registerToggle(flag) {
|
|
176
|
+
flag
|
|
177
|
+
.command('toggle')
|
|
178
|
+
.description('Quick toggle a flag on or off (does not affect rule)')
|
|
53
179
|
.requiredOption('--id <id>', 'Flag ID')
|
|
54
180
|
.requiredOption('--enabled <bool>', 'true or false', (v) => v === 'true')
|
|
55
181
|
.action(async (opts) => {
|
|
56
182
|
try {
|
|
57
|
-
|
|
183
|
+
const result = await getManagementClient().flags.toggle(opts.id, opts.enabled);
|
|
184
|
+
outputSuccess(result);
|
|
58
185
|
}
|
|
59
186
|
catch (err) {
|
|
60
187
|
outputError(err);
|
|
61
188
|
}
|
|
62
189
|
});
|
|
63
|
-
|
|
190
|
+
}
|
|
191
|
+
// ── delete ──────────────────────────────────────────────────────────────────
|
|
192
|
+
function registerDelete(flag) {
|
|
193
|
+
flag
|
|
194
|
+
.command('delete')
|
|
64
195
|
.description('Delete a feature flag')
|
|
65
196
|
.requiredOption('--id <id>', 'Flag ID')
|
|
66
197
|
.action(async (opts) => {
|
|
@@ -73,4 +204,632 @@ export function registerFlagCommands(program) {
|
|
|
73
204
|
}
|
|
74
205
|
});
|
|
75
206
|
}
|
|
207
|
+
// ── eval (local SDK-style evaluation, for debugging) ────────────────────────
|
|
208
|
+
function registerEval(flag) {
|
|
209
|
+
flag
|
|
210
|
+
.command('eval')
|
|
211
|
+
.argument('<key>', 'Flag key')
|
|
212
|
+
.description('Locally evaluate a flag against a synthetic context (debug helper)')
|
|
213
|
+
.option('--identity <id>', 'Eval identity (required when rolloutPct < 100)')
|
|
214
|
+
.option('--attribute <kv...>', 'One or more key=value attributes (use multiple flags or space-separate)')
|
|
215
|
+
.action(async (key, opts) => {
|
|
216
|
+
try {
|
|
217
|
+
const all = await getManagementClient().flags.list();
|
|
218
|
+
const found = all.find((f) => f.key === key);
|
|
219
|
+
if (!found) {
|
|
220
|
+
outputError(new Error(`Flag not found: ${key}`));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const attributes = parseAttributes(opts.attribute ?? []);
|
|
224
|
+
const ctx = { identity: opts.identity, attributes };
|
|
225
|
+
const cached = {
|
|
226
|
+
key: found.key,
|
|
227
|
+
state: found.state ?? deriveState(found),
|
|
228
|
+
valueType: found.valueType ?? 'boolean',
|
|
229
|
+
offValue: found.offValue ?? false,
|
|
230
|
+
onValue: found.onValue ?? true,
|
|
231
|
+
// Bridge from auth-core's package `Rule` shape (plural `values`) to
|
|
232
|
+
// the CLI-local `Rule` shape (singular `value`). See note above the
|
|
233
|
+
// local interfaces.
|
|
234
|
+
rule: found.rule ?? undefined,
|
|
235
|
+
};
|
|
236
|
+
const result = evaluateLocally(cached, ctx);
|
|
237
|
+
outputSuccess({
|
|
238
|
+
flag: cached.key,
|
|
239
|
+
state: cached.state,
|
|
240
|
+
context: ctx,
|
|
241
|
+
result,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
outputError(err);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
// ── schedule ────────────────────────────────────────────────────────────────
|
|
250
|
+
function registerSchedule(flag) {
|
|
251
|
+
const schedule = flag
|
|
252
|
+
.command('schedule')
|
|
253
|
+
.description('Manage scheduled state changes (TBP-189 — runner not yet active)');
|
|
254
|
+
schedule
|
|
255
|
+
.command('set')
|
|
256
|
+
.argument('<key>', 'Flag key')
|
|
257
|
+
.description('Schedule a state transition at an ISO timestamp')
|
|
258
|
+
.requiredOption('--at <iso>', 'ISO-8601 timestamp (e.g. 2026-05-20T09:00:00Z)')
|
|
259
|
+
.requiredOption('--state <state>', `Target state: ${FLAG_STATES.join(' | ')}`)
|
|
260
|
+
.action(async (key, opts) => {
|
|
261
|
+
try {
|
|
262
|
+
if (!FLAG_STATES.includes(opts.state)) {
|
|
263
|
+
throw new Error(`Invalid --state: ${opts.state}. Must be one of ${FLAG_STATES.join(', ')}.`);
|
|
264
|
+
}
|
|
265
|
+
const at = new Date(opts.at);
|
|
266
|
+
if (Number.isNaN(at.getTime())) {
|
|
267
|
+
throw new Error(`Invalid --at: ${opts.at} is not a valid ISO-8601 timestamp.`);
|
|
268
|
+
}
|
|
269
|
+
const id = await resolveFlagId(key);
|
|
270
|
+
const schedule = { at: at.toISOString(), state: opts.state };
|
|
271
|
+
const update = { schedule };
|
|
272
|
+
const result = await getManagementClient().flags.update(id, update);
|
|
273
|
+
outputSuccess(result);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
outputError(err);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
schedule
|
|
280
|
+
.command('clear')
|
|
281
|
+
.argument('<key>', 'Flag key')
|
|
282
|
+
.description('Clear an existing schedule')
|
|
283
|
+
.action(async (key) => {
|
|
284
|
+
try {
|
|
285
|
+
const id = await resolveFlagId(key);
|
|
286
|
+
const update = { schedule: null };
|
|
287
|
+
const result = await getManagementClient().flags.update(id, update);
|
|
288
|
+
outputSuccess(result);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
outputError(err);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/** Build the canonical, portable doc from a remote flag record. Pure. */
|
|
296
|
+
export function toFlagDoc(f) {
|
|
297
|
+
return {
|
|
298
|
+
key: f.key,
|
|
299
|
+
description: f.description,
|
|
300
|
+
state: f.state ?? deriveState(f),
|
|
301
|
+
valueType: f.valueType ?? 'boolean',
|
|
302
|
+
offValue: f.offValue,
|
|
303
|
+
onValue: f.onValue,
|
|
304
|
+
rule: f.rule,
|
|
305
|
+
schedule: f.schedule,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/** Build a create/update write payload from a doc. Pure. */
|
|
309
|
+
export function buildWritePayloadFromDoc(doc) {
|
|
310
|
+
const payload = {
|
|
311
|
+
key: doc.key,
|
|
312
|
+
state: doc.state,
|
|
313
|
+
valueType: doc.valueType,
|
|
314
|
+
};
|
|
315
|
+
if (doc.description !== undefined)
|
|
316
|
+
payload.description = doc.description;
|
|
317
|
+
if (doc.offValue !== undefined)
|
|
318
|
+
payload.offValue = doc.offValue;
|
|
319
|
+
if (doc.onValue !== undefined)
|
|
320
|
+
payload.onValue = doc.onValue;
|
|
321
|
+
if (doc.rule !== undefined)
|
|
322
|
+
payload.rule = doc.rule;
|
|
323
|
+
if (doc.schedule !== undefined)
|
|
324
|
+
payload.schedule = doc.schedule;
|
|
325
|
+
return payload;
|
|
326
|
+
}
|
|
327
|
+
/** Validate + normalize a doc parsed from a file. Throws on the first problem. */
|
|
328
|
+
export function validateFlagDoc(input, index) {
|
|
329
|
+
if (!input || typeof input !== 'object') {
|
|
330
|
+
throw new Error(`flags[${index}] must be an object.`);
|
|
331
|
+
}
|
|
332
|
+
const d = input;
|
|
333
|
+
if (typeof d.key !== 'string' || d.key.length === 0) {
|
|
334
|
+
throw new Error(`flags[${index}] is missing a non-empty "key".`);
|
|
335
|
+
}
|
|
336
|
+
const state = d.state ?? 'off';
|
|
337
|
+
if (!FLAG_STATES.includes(state)) {
|
|
338
|
+
throw new Error(`flags[${index}] ("${d.key}") invalid state "${String(d.state)}". One of ${FLAG_STATES.join(', ')}.`);
|
|
339
|
+
}
|
|
340
|
+
const valueType = d.valueType ?? 'boolean';
|
|
341
|
+
if (!FLAG_VALUE_TYPES.includes(valueType)) {
|
|
342
|
+
throw new Error(`flags[${index}] ("${d.key}") invalid valueType "${String(d.valueType)}". One of ${FLAG_VALUE_TYPES.join(', ')}.`);
|
|
343
|
+
}
|
|
344
|
+
if (d.rule !== undefined && d.rule !== null) {
|
|
345
|
+
const errs = validateRule(d.rule);
|
|
346
|
+
if (errs.length > 0) {
|
|
347
|
+
throw new Error(`flags[${index}] ("${d.key}") rule failed validation:\n${errs.map((e) => ` - ${e.message}`).join('\n')}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
key: d.key,
|
|
352
|
+
description: d.description,
|
|
353
|
+
state,
|
|
354
|
+
valueType,
|
|
355
|
+
offValue: d.offValue,
|
|
356
|
+
onValue: d.onValue,
|
|
357
|
+
rule: d.rule,
|
|
358
|
+
schedule: d.schedule,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const DOC_FIELDS = [
|
|
362
|
+
'description',
|
|
363
|
+
'state',
|
|
364
|
+
'valueType',
|
|
365
|
+
'offValue',
|
|
366
|
+
'onValue',
|
|
367
|
+
'rule',
|
|
368
|
+
'schedule',
|
|
369
|
+
];
|
|
370
|
+
function docChanges(a, b) {
|
|
371
|
+
return DOC_FIELDS.filter((f) => JSON.stringify(a[f] ?? null) !== JSON.stringify(b[f] ?? null));
|
|
372
|
+
}
|
|
373
|
+
/** Diff file docs against remote flags (matched by key). Pure. */
|
|
374
|
+
export function diffFlags(remote, docs, opts = {}) {
|
|
375
|
+
const byKey = new Map(remote.map((f) => [f.key, f]));
|
|
376
|
+
const fileKeys = new Set(docs.map((d) => d.key));
|
|
377
|
+
const diff = { create: [], update: [], prune: [], unchanged: [] };
|
|
378
|
+
for (const doc of docs) {
|
|
379
|
+
const r = byKey.get(doc.key);
|
|
380
|
+
if (!r) {
|
|
381
|
+
diff.create.push(doc);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const changes = docChanges(toFlagDoc(r), doc);
|
|
385
|
+
if (changes.length === 0)
|
|
386
|
+
diff.unchanged.push(doc.key);
|
|
387
|
+
else
|
|
388
|
+
diff.update.push({ id: r.id, doc, changes });
|
|
389
|
+
}
|
|
390
|
+
if (opts.prune) {
|
|
391
|
+
for (const r of remote) {
|
|
392
|
+
if (!fileKeys.has(r.key)) {
|
|
393
|
+
diff.prune.push({ id: r.id, key: r.key });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return diff;
|
|
398
|
+
}
|
|
399
|
+
function registerExport(flag) {
|
|
400
|
+
flag
|
|
401
|
+
.command('export')
|
|
402
|
+
.description('Export all feature flags as code (canonical 2.0 JSON)')
|
|
403
|
+
.option('--out <file>', 'Write to a file instead of stdout')
|
|
404
|
+
.action(async (opts) => {
|
|
405
|
+
try {
|
|
406
|
+
const all = (await getManagementClient().flags.list());
|
|
407
|
+
const flags = all.map(toFlagDoc).sort((a, b) => a.key.localeCompare(b.key));
|
|
408
|
+
const bundle = { version: 1, flags };
|
|
409
|
+
if (opts.out) {
|
|
410
|
+
writeFileSync(opts.out, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
|
|
411
|
+
outputSuccess({ written: opts.out, count: flags.length });
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
outputSuccess(bundle);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
outputError(err);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function registerImport(flag) {
|
|
423
|
+
flag
|
|
424
|
+
.command('import')
|
|
425
|
+
.argument('<file>', 'Path to a flags JSON file (from `flag export`)')
|
|
426
|
+
.description('Apply feature flags from a file (create + update; --prune deletes extras)')
|
|
427
|
+
.option('--dry-run', 'Show the diff without applying any changes', false)
|
|
428
|
+
.option('--prune', 'Delete remote flags not present in the file', false)
|
|
429
|
+
.action(async (file, opts) => {
|
|
430
|
+
try {
|
|
431
|
+
let raw;
|
|
432
|
+
try {
|
|
433
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
throw new Error(`Could not read/parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
437
|
+
}
|
|
438
|
+
const list = Array.isArray(raw) ? raw : raw.flags;
|
|
439
|
+
if (!Array.isArray(list)) {
|
|
440
|
+
throw new Error('File must be an array of flags or an object with a "flags" array.');
|
|
441
|
+
}
|
|
442
|
+
const docs = list.map((d, i) => validateFlagDoc(d, i));
|
|
443
|
+
const remote = (await getManagementClient().flags.list());
|
|
444
|
+
const diff = diffFlags(remote, docs, { prune: opts.prune });
|
|
445
|
+
if (opts.dryRun) {
|
|
446
|
+
outputSuccess({
|
|
447
|
+
dryRun: true,
|
|
448
|
+
create: diff.create.map((d) => d.key),
|
|
449
|
+
update: diff.update.map((u) => ({ key: u.doc.key, changes: u.changes })),
|
|
450
|
+
prune: diff.prune.map((p) => p.key),
|
|
451
|
+
unchanged: diff.unchanged,
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const mgmt = getManagementClient();
|
|
456
|
+
for (const doc of diff.create) {
|
|
457
|
+
await mgmt.flags.create(buildWritePayloadFromDoc(doc));
|
|
458
|
+
}
|
|
459
|
+
for (const u of diff.update) {
|
|
460
|
+
await mgmt.flags.update(u.id, buildWritePayloadFromDoc(u.doc));
|
|
461
|
+
}
|
|
462
|
+
for (const p of diff.prune) {
|
|
463
|
+
await mgmt.flags.delete(p.id);
|
|
464
|
+
}
|
|
465
|
+
outputSuccess({
|
|
466
|
+
created: diff.create.map((d) => d.key),
|
|
467
|
+
updated: diff.update.map((u) => u.doc.key),
|
|
468
|
+
pruned: diff.prune.map((p) => p.key),
|
|
469
|
+
unchanged: diff.unchanged,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
outputError(err);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
export function buildFlagWritePayload(opts, { partial = false } = {}) {
|
|
478
|
+
const payload = {};
|
|
479
|
+
if (opts.key !== undefined)
|
|
480
|
+
payload.key = opts.key;
|
|
481
|
+
if (opts.description !== undefined)
|
|
482
|
+
payload.description = opts.description;
|
|
483
|
+
if (opts.state !== undefined) {
|
|
484
|
+
if (!FLAG_STATES.includes(opts.state)) {
|
|
485
|
+
throw new Error(`Invalid --state: ${opts.state}. Must be one of ${FLAG_STATES.join(', ')}.`);
|
|
486
|
+
}
|
|
487
|
+
payload.state = opts.state;
|
|
488
|
+
}
|
|
489
|
+
let valueType;
|
|
490
|
+
if (opts.valueType !== undefined) {
|
|
491
|
+
if (!FLAG_VALUE_TYPES.includes(opts.valueType)) {
|
|
492
|
+
throw new Error(`Invalid --value-type: ${opts.valueType}. Must be one of ${FLAG_VALUE_TYPES.join(', ')}.`);
|
|
493
|
+
}
|
|
494
|
+
valueType = opts.valueType;
|
|
495
|
+
payload.valueType = valueType;
|
|
496
|
+
}
|
|
497
|
+
if (opts.onValue !== undefined) {
|
|
498
|
+
payload.onValue = coerceValue(opts.onValue, valueType ?? 'boolean');
|
|
499
|
+
}
|
|
500
|
+
if (opts.offValue !== undefined) {
|
|
501
|
+
payload.offValue = coerceValue(opts.offValue, valueType ?? 'boolean');
|
|
502
|
+
}
|
|
503
|
+
if (opts.rule !== undefined) {
|
|
504
|
+
const parsed = parseRuleArg(opts.rule);
|
|
505
|
+
payload.rule = parsed;
|
|
506
|
+
}
|
|
507
|
+
// Legacy fields. In partial (update) mode, only include if explicitly set —
|
|
508
|
+
// commander returns `false` as default for boolean flags, which would
|
|
509
|
+
// otherwise clobber server state on every update.
|
|
510
|
+
if (!partial || opts.enabled !== undefined) {
|
|
511
|
+
if (opts.enabled !== undefined)
|
|
512
|
+
payload.enabled = opts.enabled;
|
|
513
|
+
}
|
|
514
|
+
if (!partial || opts.defaultValue !== undefined) {
|
|
515
|
+
if (opts.defaultValue !== undefined)
|
|
516
|
+
payload.defaultValue = opts.defaultValue;
|
|
517
|
+
}
|
|
518
|
+
return payload;
|
|
519
|
+
}
|
|
520
|
+
export function coerceValue(raw, type) {
|
|
521
|
+
switch (type) {
|
|
522
|
+
case 'boolean':
|
|
523
|
+
if (raw === 'true')
|
|
524
|
+
return true;
|
|
525
|
+
if (raw === 'false')
|
|
526
|
+
return false;
|
|
527
|
+
throw new Error(`Invalid boolean value "${raw}" — expected "true" or "false".`);
|
|
528
|
+
case 'number': {
|
|
529
|
+
const n = Number(raw);
|
|
530
|
+
if (!Number.isFinite(n))
|
|
531
|
+
throw new Error(`Invalid number value "${raw}".`);
|
|
532
|
+
return n;
|
|
533
|
+
}
|
|
534
|
+
case 'string':
|
|
535
|
+
return raw;
|
|
536
|
+
case 'json':
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(raw);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
throw new Error(`Invalid JSON value: ${err instanceof Error ? err.message : String(err)}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
export function parseRuleArg(raw) {
|
|
546
|
+
let parsed;
|
|
547
|
+
try {
|
|
548
|
+
parsed = JSON.parse(raw);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
throw new Error(`--rule must be valid JSON. Parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
552
|
+
}
|
|
553
|
+
const errors = validateRule(parsed);
|
|
554
|
+
if (errors.length > 0) {
|
|
555
|
+
throw new Error(`--rule failed validation:\n${errors.map((e) => ` - ${e.message}`).join('\n')}`);
|
|
556
|
+
}
|
|
557
|
+
return parsed;
|
|
558
|
+
}
|
|
559
|
+
export function parseAttributes(pairs) {
|
|
560
|
+
const out = {};
|
|
561
|
+
for (const pair of pairs) {
|
|
562
|
+
const idx = pair.indexOf('=');
|
|
563
|
+
if (idx === -1)
|
|
564
|
+
throw new Error(`Invalid --attribute "${pair}" — expected key=value.`);
|
|
565
|
+
const key = pair.slice(0, idx);
|
|
566
|
+
const raw = pair.slice(idx + 1);
|
|
567
|
+
// Try to parse as JSON first (covers numbers, booleans, structured); fall
|
|
568
|
+
// back to the raw string.
|
|
569
|
+
try {
|
|
570
|
+
out[key] = JSON.parse(raw);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
out[key] = raw;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
async function resolveFlagId(key) {
|
|
579
|
+
const all = await getManagementClient().flags.list();
|
|
580
|
+
const found = all.find((f) => f.key === key);
|
|
581
|
+
if (!found)
|
|
582
|
+
throw new Error(`Flag not found: ${key}`);
|
|
583
|
+
return found.id;
|
|
584
|
+
}
|
|
585
|
+
const RULE_CONDITIONS_HARD_MAX = 50;
|
|
586
|
+
export function validateRule(rule) {
|
|
587
|
+
const errors = [];
|
|
588
|
+
if (!rule || typeof rule !== 'object') {
|
|
589
|
+
return [{ branchIndex: -1, conditionIndex: -1, message: 'Rule must be an object.' }];
|
|
590
|
+
}
|
|
591
|
+
const r = rule;
|
|
592
|
+
const branches = r.branches ?? [];
|
|
593
|
+
if (!Array.isArray(branches)) {
|
|
594
|
+
return [{ branchIndex: -1, conditionIndex: -1, message: '`branches` must be an array.' }];
|
|
595
|
+
}
|
|
596
|
+
let totalConditions = 0;
|
|
597
|
+
branches.forEach((branch, bi) => {
|
|
598
|
+
if (!branch || typeof branch !== 'object') {
|
|
599
|
+
errors.push({
|
|
600
|
+
branchIndex: bi,
|
|
601
|
+
conditionIndex: -1,
|
|
602
|
+
message: `Branch ${bi} must be an object.`,
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (!('returnValue' in branch)) {
|
|
607
|
+
errors.push({
|
|
608
|
+
branchIndex: bi,
|
|
609
|
+
conditionIndex: -1,
|
|
610
|
+
message: `Branch ${bi} is missing returnValue.`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const conds = branch.conditions ?? [];
|
|
614
|
+
if (!Array.isArray(conds) || conds.length === 0) {
|
|
615
|
+
errors.push({
|
|
616
|
+
branchIndex: bi,
|
|
617
|
+
conditionIndex: -1,
|
|
618
|
+
message: `Branch ${bi} has no conditions.`,
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
conds.forEach((c, ci) => {
|
|
623
|
+
totalConditions++;
|
|
624
|
+
if (!c || typeof c !== 'object') {
|
|
625
|
+
errors.push({
|
|
626
|
+
branchIndex: bi,
|
|
627
|
+
conditionIndex: ci,
|
|
628
|
+
message: `Branch ${bi} condition ${ci} must be an object.`,
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (typeof c.attribute !== 'string' || c.attribute.length === 0) {
|
|
633
|
+
errors.push({
|
|
634
|
+
branchIndex: bi,
|
|
635
|
+
conditionIndex: ci,
|
|
636
|
+
message: `Branch ${bi} condition ${ci} missing 'attribute'.`,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (typeof c.operator !== 'string' || c.operator.length === 0) {
|
|
640
|
+
errors.push({
|
|
641
|
+
branchIndex: bi,
|
|
642
|
+
conditionIndex: ci,
|
|
643
|
+
message: `Branch ${bi} condition ${ci} missing 'operator'.`,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
if (totalConditions > RULE_CONDITIONS_HARD_MAX) {
|
|
649
|
+
errors.push({
|
|
650
|
+
branchIndex: -1,
|
|
651
|
+
conditionIndex: -1,
|
|
652
|
+
message: `Rule has ${totalConditions} conditions; max is ${RULE_CONDITIONS_HARD_MAX}.`,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
if (r.rolloutPct !== undefined) {
|
|
656
|
+
if (typeof r.rolloutPct !== 'number' || r.rolloutPct < 0 || r.rolloutPct > 100) {
|
|
657
|
+
errors.push({
|
|
658
|
+
branchIndex: -1,
|
|
659
|
+
conditionIndex: -1,
|
|
660
|
+
message: `rolloutPct must be a number in [0, 100], got ${r.rolloutPct}.`,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!('otherwiseValue' in r)) {
|
|
665
|
+
errors.push({
|
|
666
|
+
branchIndex: -1,
|
|
667
|
+
conditionIndex: -1,
|
|
668
|
+
message: 'Rule missing otherwiseValue.',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
if (r.groupRef && branches.length > 0) {
|
|
672
|
+
errors.push({
|
|
673
|
+
branchIndex: -1,
|
|
674
|
+
conditionIndex: -1,
|
|
675
|
+
message: 'Rule cannot have both inline branches and a groupRef.',
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return errors;
|
|
679
|
+
}
|
|
680
|
+
export function evaluateLocally(cached, ctx) {
|
|
681
|
+
switch (cached.state) {
|
|
682
|
+
case 'off':
|
|
683
|
+
return {
|
|
684
|
+
value: cached.offValue,
|
|
685
|
+
variantIndex: -1,
|
|
686
|
+
matched: false,
|
|
687
|
+
excludedByRollout: false,
|
|
688
|
+
};
|
|
689
|
+
case 'on':
|
|
690
|
+
return {
|
|
691
|
+
value: cached.onValue,
|
|
692
|
+
variantIndex: 0,
|
|
693
|
+
matched: true,
|
|
694
|
+
excludedByRollout: false,
|
|
695
|
+
};
|
|
696
|
+
case 'on-with-rule':
|
|
697
|
+
if (!cached.rule) {
|
|
698
|
+
return {
|
|
699
|
+
value: cached.onValue,
|
|
700
|
+
variantIndex: -1,
|
|
701
|
+
matched: false,
|
|
702
|
+
excludedByRollout: false,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
return evaluateRule(cached.rule, cached.key, ctx);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function evaluateRule(rule, flagKey, ctx) {
|
|
709
|
+
const rolloutPct = clampPct(rule.rolloutPct ?? 100);
|
|
710
|
+
if (rolloutPct < 100) {
|
|
711
|
+
if (!ctx.identity) {
|
|
712
|
+
return {
|
|
713
|
+
value: rule.otherwiseValue,
|
|
714
|
+
variantIndex: -1,
|
|
715
|
+
matched: false,
|
|
716
|
+
excludedByRollout: true,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (bucket(flagKey, ctx.identity) >= rolloutPct) {
|
|
720
|
+
return {
|
|
721
|
+
value: rule.otherwiseValue,
|
|
722
|
+
variantIndex: -1,
|
|
723
|
+
matched: false,
|
|
724
|
+
excludedByRollout: true,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const branches = rule.branches ?? [];
|
|
729
|
+
for (let i = 0; i < branches.length; i++) {
|
|
730
|
+
if (evaluateBranch(branches[i], ctx)) {
|
|
731
|
+
return {
|
|
732
|
+
value: branches[i].returnValue,
|
|
733
|
+
variantIndex: i,
|
|
734
|
+
matched: true,
|
|
735
|
+
excludedByRollout: false,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
value: rule.otherwiseValue,
|
|
741
|
+
variantIndex: -1,
|
|
742
|
+
matched: false,
|
|
743
|
+
excludedByRollout: false,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function evaluateBranch(branch, ctx) {
|
|
747
|
+
if (!branch.conditions || branch.conditions.length === 0)
|
|
748
|
+
return false;
|
|
749
|
+
return branch.conditions.every((c) => evaluateCondition(c, resolveAttribute(ctx, c.attribute)));
|
|
750
|
+
}
|
|
751
|
+
function resolveAttribute(ctx, attribute) {
|
|
752
|
+
if (Object.prototype.hasOwnProperty.call(ctx.attributes, attribute)) {
|
|
753
|
+
return ctx.attributes[attribute];
|
|
754
|
+
}
|
|
755
|
+
const parts = attribute.split('.');
|
|
756
|
+
let current = ctx.attributes;
|
|
757
|
+
for (const part of parts) {
|
|
758
|
+
if (current === undefined || current === null)
|
|
759
|
+
return undefined;
|
|
760
|
+
if (typeof current !== 'object')
|
|
761
|
+
return undefined;
|
|
762
|
+
current = current[part];
|
|
763
|
+
}
|
|
764
|
+
return current;
|
|
765
|
+
}
|
|
766
|
+
function evaluateCondition(c, actual) {
|
|
767
|
+
const expected = c.value;
|
|
768
|
+
switch (c.operator) {
|
|
769
|
+
case 'equals':
|
|
770
|
+
case 'eq':
|
|
771
|
+
return actual === expected;
|
|
772
|
+
case 'not_equals':
|
|
773
|
+
case 'neq':
|
|
774
|
+
return actual !== expected;
|
|
775
|
+
case 'in':
|
|
776
|
+
return Array.isArray(expected) && expected.includes(actual);
|
|
777
|
+
case 'not_in':
|
|
778
|
+
return Array.isArray(expected) && !expected.includes(actual);
|
|
779
|
+
case 'contains':
|
|
780
|
+
return typeof actual === 'string' && typeof expected === 'string' && actual.includes(expected);
|
|
781
|
+
case 'starts_with':
|
|
782
|
+
return (typeof actual === 'string' && typeof expected === 'string' && actual.startsWith(expected));
|
|
783
|
+
case 'ends_with':
|
|
784
|
+
return (typeof actual === 'string' && typeof expected === 'string' && actual.endsWith(expected));
|
|
785
|
+
case 'gt':
|
|
786
|
+
return typeof actual === 'number' && typeof expected === 'number' && actual > expected;
|
|
787
|
+
case 'gte':
|
|
788
|
+
return typeof actual === 'number' && typeof expected === 'number' && actual >= expected;
|
|
789
|
+
case 'lt':
|
|
790
|
+
return typeof actual === 'number' && typeof expected === 'number' && actual < expected;
|
|
791
|
+
case 'lte':
|
|
792
|
+
return typeof actual === 'number' && typeof expected === 'number' && actual <= expected;
|
|
793
|
+
case 'exists':
|
|
794
|
+
return actual !== undefined && actual !== null;
|
|
795
|
+
case 'not_exists':
|
|
796
|
+
return actual === undefined || actual === null;
|
|
797
|
+
default:
|
|
798
|
+
// Unknown operator → no match (defensive; validateRule should catch).
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function bucket(flagKey, identity) {
|
|
803
|
+
const input = `${flagKey}|${identity}`;
|
|
804
|
+
let hash = 0x811c9dc5;
|
|
805
|
+
for (let i = 0; i < input.length; i++) {
|
|
806
|
+
hash ^= input.charCodeAt(i);
|
|
807
|
+
hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
|
|
808
|
+
}
|
|
809
|
+
return hash % 100;
|
|
810
|
+
}
|
|
811
|
+
function clampPct(p) {
|
|
812
|
+
if (!Number.isFinite(p))
|
|
813
|
+
return 100;
|
|
814
|
+
if (p < 0)
|
|
815
|
+
return 0;
|
|
816
|
+
if (p > 100)
|
|
817
|
+
return 100;
|
|
818
|
+
return p;
|
|
819
|
+
}
|
|
820
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
821
|
+
/**
|
|
822
|
+
* Derive a 2.0 state from a legacy 1.0 flag shape. Used so `flag list` /
|
|
823
|
+
* `flag get` always show a state column, even against an older bridge-api
|
|
824
|
+
* that has not yet rolled out 2.0 fields.
|
|
825
|
+
*/
|
|
826
|
+
function deriveState(f) {
|
|
827
|
+
if (typeof f.state === 'string' && FLAG_STATES.includes(f.state)) {
|
|
828
|
+
return f.state;
|
|
829
|
+
}
|
|
830
|
+
if (f.enabled === false)
|
|
831
|
+
return 'off';
|
|
832
|
+
const segs = f.segments ?? [];
|
|
833
|
+
return segs.length > 0 ? 'on-with-rule' : 'on';
|
|
834
|
+
}
|
|
76
835
|
//# sourceMappingURL=flag.command.js.map
|