@nebulr-group/bridge-cli 0.6.5 → 1.0.0-alpha.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 +93 -460
- package/dist/api/accounts.d.ts +69 -0
- package/dist/api/accounts.d.ts.map +1 -0
- package/dist/api/accounts.js +42 -0
- package/dist/api/accounts.js.map +1 -0
- package/dist/api/apps.d.ts +50 -0
- package/dist/api/apps.d.ts.map +1 -0
- package/dist/api/apps.js +62 -0
- package/dist/api/apps.js.map +1 -0
- package/dist/api/billing.d.ts +131 -0
- package/dist/api/billing.d.ts.map +1 -0
- package/dist/api/billing.js +42 -0
- package/dist/api/billing.js.map +1 -0
- package/dist/api/flags.d.ts +73 -0
- package/dist/api/flags.d.ts.map +1 -0
- package/dist/api/flags.js +108 -0
- package/dist/api/flags.js.map +1 -0
- package/dist/api/keys.d.ts +48 -0
- package/dist/api/keys.d.ts.map +1 -0
- package/dist/api/keys.js +87 -0
- package/dist/api/keys.js.map +1 -0
- package/dist/auth/browser-open.d.ts +21 -4
- package/dist/auth/browser-open.d.ts.map +1 -1
- package/dist/auth/browser-open.js +45 -65
- package/dist/auth/browser-open.js.map +1 -1
- package/dist/auth/lock.d.ts +8 -0
- package/dist/auth/lock.d.ts.map +1 -0
- package/dist/auth/lock.js +43 -0
- package/dist/auth/lock.js.map +1 -0
- package/dist/auth/loopback.d.ts +8 -12
- package/dist/auth/loopback.d.ts.map +1 -1
- package/dist/auth/loopback.js +40 -45
- package/dist/auth/loopback.js.map +1 -1
- package/dist/auth/session.d.ts +61 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +142 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/sign-in.d.ts +67 -0
- package/dist/auth/sign-in.d.ts.map +1 -0
- package/dist/auth/sign-in.js +113 -0
- package/dist/auth/sign-in.js.map +1 -0
- package/dist/auth/store.d.ts +89 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +214 -0
- package/dist/auth/store.js.map +1 -0
- package/dist/bin.js +0 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts +1 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -17
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +67 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/accounts.command.d.ts +4 -0
- package/dist/commands/accounts.command.d.ts.map +1 -0
- package/dist/commands/accounts.command.js +116 -0
- package/dist/commands/accounts.command.js.map +1 -0
- package/dist/commands/app.command.d.ts +3 -2
- package/dist/commands/app.command.d.ts.map +1 -1
- package/dist/commands/app.command.js +132 -233
- package/dist/commands/app.command.js.map +1 -1
- package/dist/commands/auth.command.d.ts +2 -12
- package/dist/commands/auth.command.d.ts.map +1 -1
- package/dist/commands/auth.command.js +147 -20
- package/dist/commands/auth.command.js.map +1 -1
- package/dist/commands/billing.command.d.ts +4 -0
- package/dist/commands/billing.command.d.ts.map +1 -0
- package/dist/commands/billing.command.js +185 -0
- package/dist/commands/billing.command.js.map +1 -0
- package/dist/commands/check.command.d.ts +2 -1
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +11 -14
- package/dist/commands/check.command.js.map +1 -1
- package/dist/commands/context.d.ts +58 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +78 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/flag.command.d.ts +3 -62
- package/dist/commands/flag.command.d.ts.map +1 -1
- package/dist/commands/flag.command.js +181 -864
- package/dist/commands/flag.command.js.map +1 -1
- package/dist/commands/key.command.d.ts +4 -0
- package/dist/commands/key.command.d.ts.map +1 -0
- package/dist/commands/key.command.js +44 -0
- package/dist/commands/key.command.js.map +1 -0
- package/dist/commands/plan.command.d.ts +3 -203
- package/dist/commands/plan.command.d.ts.map +1 -1
- package/dist/commands/plan.command.js +200 -759
- package/dist/commands/plan.command.js.map +1 -1
- package/dist/config.d.ts +19 -101
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +51 -364
- package/dist/config.js.map +1 -1
- package/dist/core/discovery.d.ts +27 -0
- package/dist/core/discovery.d.ts.map +1 -0
- package/dist/core/discovery.js +97 -0
- package/dist/core/discovery.js.map +1 -0
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/http.d.ts +23 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +153 -0
- package/dist/core/http.js.map +1 -0
- package/dist/core/private-files.d.ts +6 -0
- package/dist/core/private-files.d.ts.map +1 -0
- package/dist/core/private-files.js +96 -0
- package/dist/core/private-files.js.map +1 -0
- package/dist/core/routes.d.ts +94 -0
- package/dist/core/routes.d.ts.map +1 -0
- package/dist/core/routes.js +91 -0
- package/dist/core/routes.js.map +1 -0
- package/dist/core/urls.d.ts +15 -0
- package/dist/core/urls.d.ts.map +1 -0
- package/dist/core/urls.js +55 -0
- package/dist/core/urls.js.map +1 -0
- package/dist/gate-rules.d.ts +1 -2
- package/dist/gate-rules.d.ts.map +1 -1
- package/dist/gate-rules.js +4 -5
- package/dist/gate-rules.js.map +1 -1
- package/dist/index.d.ts +23 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/io.d.ts +21 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +59 -0
- package/dist/io.js.map +1 -0
- package/dist/money.d.ts +42 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +112 -0
- package/dist/money.js.map +1 -0
- package/dist/program.d.ts +5 -24
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +25 -113
- package/dist/program.js.map +1 -1
- package/package.json +7 -8
- package/dist/auth/api-client.d.ts +0 -50
- package/dist/auth/api-client.d.ts.map +0 -1
- package/dist/auth/api-client.js +0 -109
- package/dist/auth/api-client.js.map +0 -1
- package/dist/auth/relative-time.d.ts +0 -2
- package/dist/auth/relative-time.d.ts.map +0 -1
- package/dist/auth/relative-time.js +0 -27
- package/dist/auth/relative-time.js.map +0 -1
- package/dist/auth/scopes.d.ts +0 -30
- package/dist/auth/scopes.d.ts.map +0 -1
- package/dist/auth/scopes.js +0 -39
- package/dist/auth/scopes.js.map +0 -1
- package/dist/auth/workspace-client.d.ts +0 -70
- package/dist/auth/workspace-client.d.ts.map +0 -1
- package/dist/auth/workspace-client.js +0 -105
- package/dist/auth/workspace-client.js.map +0 -1
- package/dist/commands/auth/config.command.d.ts +0 -8
- package/dist/commands/auth/config.command.d.ts.map +0 -1
- package/dist/commands/auth/config.command.js +0 -60
- package/dist/commands/auth/config.command.js.map +0 -1
- package/dist/commands/auth/login.command.d.ts +0 -46
- package/dist/commands/auth/login.command.d.ts.map +0 -1
- package/dist/commands/auth/login.command.js +0 -166
- package/dist/commands/auth/login.command.js.map +0 -1
- package/dist/commands/auth/logout.command.d.ts +0 -21
- package/dist/commands/auth/logout.command.d.ts.map +0 -1
- package/dist/commands/auth/logout.command.js +0 -97
- package/dist/commands/auth/logout.command.js.map +0 -1
- package/dist/commands/auth/methods.command.d.ts +0 -68
- package/dist/commands/auth/methods.command.d.ts.map +0 -1
- package/dist/commands/auth/methods.command.js +0 -91
- package/dist/commands/auth/methods.command.js.map +0 -1
- package/dist/commands/auth/status.command.d.ts +0 -18
- package/dist/commands/auth/status.command.d.ts.map +0 -1
- package/dist/commands/auth/status.command.js +0 -104
- package/dist/commands/auth/status.command.js.map +0 -1
- package/dist/commands/auth/use.command.d.ts +0 -17
- package/dist/commands/auth/use.command.d.ts.map +0 -1
- package/dist/commands/auth/use.command.js +0 -47
- package/dist/commands/auth/use.command.js.map +0 -1
- package/dist/commands/branding.command.d.ts +0 -3
- package/dist/commands/branding.command.d.ts.map +0 -1
- package/dist/commands/branding.command.js +0 -42
- package/dist/commands/branding.command.js.map +0 -1
- package/dist/commands/event.command.d.ts +0 -3
- package/dist/commands/event.command.d.ts.map +0 -1
- package/dist/commands/event.command.js +0 -27
- package/dist/commands/event.command.js.map +0 -1
- package/dist/commands/flag-init.command.d.ts +0 -13
- package/dist/commands/flag-init.command.d.ts.map +0 -1
- package/dist/commands/flag-init.command.js +0 -463
- package/dist/commands/flag-init.command.js.map +0 -1
- package/dist/commands/guide.command.d.ts +0 -61
- package/dist/commands/guide.command.d.ts.map +0 -1
- package/dist/commands/guide.command.js +0 -655
- package/dist/commands/guide.command.js.map +0 -1
- package/dist/commands/info.command.d.ts +0 -3
- package/dist/commands/info.command.d.ts.map +0 -1
- package/dist/commands/info.command.js +0 -70
- package/dist/commands/info.command.js.map +0 -1
- package/dist/commands/integration.command.d.ts +0 -35
- package/dist/commands/integration.command.d.ts.map +0 -1
- package/dist/commands/integration.command.js +0 -290
- package/dist/commands/integration.command.js.map +0 -1
- package/dist/commands/journeys.d.ts +0 -43
- package/dist/commands/journeys.d.ts.map +0 -1
- package/dist/commands/journeys.js +0 -221
- package/dist/commands/journeys.js.map +0 -1
- package/dist/commands/ops.command.d.ts +0 -17
- package/dist/commands/ops.command.d.ts.map +0 -1
- package/dist/commands/ops.command.js +0 -129
- package/dist/commands/ops.command.js.map +0 -1
- package/dist/commands/privilege.command.d.ts +0 -16
- package/dist/commands/privilege.command.d.ts.map +0 -1
- package/dist/commands/privilege.command.js +0 -86
- package/dist/commands/privilege.command.js.map +0 -1
- package/dist/commands/role.command.d.ts +0 -3
- package/dist/commands/role.command.d.ts.map +0 -1
- package/dist/commands/role.command.js +0 -101
- package/dist/commands/role.command.js.map +0 -1
- package/dist/commands/runtime-dir.d.ts +0 -16
- package/dist/commands/runtime-dir.d.ts.map +0 -1
- package/dist/commands/runtime-dir.js +0 -18
- package/dist/commands/runtime-dir.js.map +0 -1
- package/dist/commands/setup.command.d.ts +0 -63
- package/dist/commands/setup.command.d.ts.map +0 -1
- package/dist/commands/setup.command.js +0 -148
- package/dist/commands/setup.command.js.map +0 -1
- package/dist/commands/stripe.command.d.ts +0 -29
- package/dist/commands/stripe.command.d.ts.map +0 -1
- package/dist/commands/stripe.command.js +0 -99
- package/dist/commands/stripe.command.js.map +0 -1
- package/dist/commands/tenant.command.d.ts +0 -3
- package/dist/commands/tenant.command.d.ts.map +0 -1
- package/dist/commands/tenant.command.js +0 -135
- package/dist/commands/tenant.command.js.map +0 -1
- package/dist/commands/token.command.d.ts +0 -3
- package/dist/commands/token.command.d.ts.map +0 -1
- package/dist/commands/token.command.js +0 -54
- package/dist/commands/token.command.js.map +0 -1
- package/dist/commands/user.command.d.ts +0 -3
- package/dist/commands/user.command.d.ts.map +0 -1
- package/dist/commands/user.command.js +0 -117
- package/dist/commands/user.command.js.map +0 -1
- package/dist/credentials.d.ts +0 -141
- package/dist/credentials.d.ts.map +0 -1
- package/dist/credentials.js +0 -348
- package/dist/credentials.js.map +0 -1
- package/dist/flag-coherence.d.ts +0 -42
- package/dist/flag-coherence.d.ts.map +0 -1
- package/dist/flag-coherence.js +0 -103
- package/dist/flag-coherence.js.map +0 -1
- package/dist/output.d.ts +0 -17
- package/dist/output.d.ts.map +0 -1
- package/dist/output.js +0 -107
- package/dist/output.js.map +0 -1
- package/dist/preview.d.ts +0 -9
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js +0 -63
- package/dist/preview.js.map +0 -1
- package/dist/product-decisions.d.ts +0 -40
- package/dist/product-decisions.d.ts.map +0 -1
- package/dist/product-decisions.js +0 -52
- package/dist/product-decisions.js.map +0 -1
- package/dist/prompt-urls.d.ts +0 -31
- package/dist/prompt-urls.d.ts.map +0 -1
- package/dist/prompt-urls.js +0 -35
- package/dist/prompt-urls.js.map +0 -1
- package/dist/prompts/auth-master-integration-prompt.md +0 -255
- package/dist/prompts/billing/master.md +0 -332
- package/dist/prompts/decisions/feature-control.md +0 -131
- package/dist/prompts/decisions/going-live.md +0 -60
- package/dist/prompts/decisions/login.md +0 -71
- package/dist/prompts/decisions/look-and-feel.md +0 -68
- package/dist/prompts/decisions/payments.md +0 -97
- package/dist/prompts/decisions/roles.md +0 -78
- package/dist/prompts/decisions/teams.md +0 -61
- package/dist/prompts/fit-together.md +0 -97
- package/dist/prompts/flags/master.md +0 -220
- package/dist/prompts/guide-coverage.json +0 -17
- package/dist/prompts/integration-success.md +0 -106
- package/dist/prompts/mcp-tools.json +0 -90
- package/dist/prompts/mechanisms.md +0 -298
- package/dist/prompts/orientation.md +0 -42
- package/dist/prompts/product-decisions.json +0 -338
- package/dist/resolve.d.ts +0 -110
- package/dist/resolve.d.ts.map +0 -1
- package/dist/resolve.js +0 -260
- package/dist/resolve.js.map +0 -1
- package/dist/shape.d.ts +0 -25
- package/dist/shape.d.ts.map +0 -1
- package/dist/shape.js +0 -74
- package/dist/shape.js.map +0 -1
|
@@ -1,874 +1,191 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// `Rule` / `Branch` / `Condition` types and the `evaluateRule` / `validateRule`
|
|
12
|
-
// evaluator are imported from @nebulr-group/bridge-auth-core — there is no
|
|
13
|
-
// CLI-local rule shape anymore. Conditions always carry a plural `values`
|
|
14
|
-
// array on the wire, and operators come from the package's locked vocabulary
|
|
15
|
-
// (`OPERATORS`: eq | neq | contains | not_contains | in | not_in | gt | lt |
|
|
16
|
-
// between | regex | exists | not_exists). Legacy CLI inputs (singular
|
|
17
|
-
// `value`, operator names like `equals` / `starts_with` / `gte`) are migrated
|
|
18
|
-
// at parse time by `normalizeRuleInput()` below, with a deprecation warning
|
|
19
|
-
// on stderr for legacy operator names.
|
|
20
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { OPERATORS, evaluateRule, isOperator, validateRule, } from '@nebulr-group/bridge-auth-core';
|
|
22
|
-
import { getManagementClient } from '../config.js';
|
|
23
|
-
import { warningsForFlags, withFlagWarnings } from '../flag-coherence.js';
|
|
24
|
-
import { dryRunPreview } from '../preview.js';
|
|
25
|
-
import { outputSuccess, outputError } from '../output.js';
|
|
26
|
-
import { resolveFlag, resolveFlagId } from '../resolve.js';
|
|
27
|
-
import { registerFlagInitCommand } from './flag-init.command.js';
|
|
28
|
-
// Re-export the canonical validator so existing consumers/tests keep a single
|
|
29
|
-
// import site for rule validation alongside the CLI parse helpers.
|
|
30
|
-
export { validateRule };
|
|
31
|
-
const FLAG_STATES = ['off', 'on', 'on-with-rule'];
|
|
32
|
-
const FLAG_VALUE_TYPES = ['boolean', 'string', 'number', 'json'];
|
|
33
|
-
// ── Public registration ─────────────────────────────────────────────────────
|
|
34
|
-
export function registerFlagCommands(program) {
|
|
35
|
-
const flag = program
|
|
36
|
-
.command('flag')
|
|
37
|
-
.description([
|
|
38
|
-
'Manage feature flags (Feature Flags 2.0 mental model)',
|
|
39
|
-
'',
|
|
40
|
-
' state off | on | on-with-rule',
|
|
41
|
-
' values boolean | string | number | json (typed at flag level)',
|
|
42
|
-
' rule branches[] + otherwiseValue + rolloutPct (first-match-wins)',
|
|
43
|
-
].join('\n'));
|
|
44
|
-
registerList(flag);
|
|
45
|
-
registerGet(flag);
|
|
46
|
-
registerCreate(flag);
|
|
47
|
-
registerUpdate(flag);
|
|
48
|
-
registerToggle(flag);
|
|
49
|
-
registerDelete(flag);
|
|
50
|
-
registerEval(flag);
|
|
51
|
-
registerSchedule(flag);
|
|
52
|
-
registerExport(flag);
|
|
53
|
-
registerImport(flag);
|
|
54
|
-
registerFlagInitCommand(flag);
|
|
55
|
-
}
|
|
56
|
-
// ── list ────────────────────────────────────────────────────────────────────
|
|
57
|
-
function registerList(flag) {
|
|
58
|
-
flag
|
|
59
|
-
.command('list')
|
|
60
|
-
.description('List feature flags (shows state + value type)')
|
|
61
|
-
.action(async () => {
|
|
62
|
-
try {
|
|
63
|
-
const flags = await getManagementClient().flags.list();
|
|
64
|
-
// Normalize so callers always see the same shape even if the API
|
|
65
|
-
// returns the legacy fields only.
|
|
66
|
-
// TBP-709 — a flag that cannot mean what it looks like carries warnings.
|
|
67
|
-
const warnings = await warningsForFlags(flags.map((f) => ({ ...f, state: f.state ?? deriveState(f) })));
|
|
68
|
-
const rows = flags.map((f, i) => ({
|
|
69
|
-
id: f.id,
|
|
70
|
-
key: f.key,
|
|
71
|
-
description: f.description,
|
|
72
|
-
state: f.state ?? deriveState(f),
|
|
73
|
-
valueType: f.valueType ?? 'boolean',
|
|
74
|
-
enabled: f.enabled,
|
|
75
|
-
...(warnings[i].length ? { warnings: warnings[i] } : {}),
|
|
76
|
-
}));
|
|
77
|
-
outputSuccess(rows);
|
|
78
|
-
}
|
|
79
|
-
catch (err) {
|
|
80
|
-
outputError(err);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
// ── get ─────────────────────────────────────────────────────────────────────
|
|
85
|
-
function registerGet(flag) {
|
|
86
|
-
flag
|
|
87
|
-
.command('get')
|
|
88
|
-
.argument('<key>', 'Flag key')
|
|
89
|
-
.description('Get a flag (rich 2.0 shape: state, value type, rule, observability)')
|
|
90
|
-
.action(async (key) => {
|
|
91
|
-
try {
|
|
92
|
-
const all = await getManagementClient().flags.list();
|
|
93
|
-
const found = all.find((f) => f.key === key);
|
|
94
|
-
if (!found) {
|
|
95
|
-
outputError(new Error(`Flag not found: ${key}`));
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
outputSuccess(await withFlagWarnings({
|
|
99
|
-
id: found.id,
|
|
100
|
-
key: found.key,
|
|
101
|
-
description: found.description,
|
|
102
|
-
state: found.state ?? deriveState(found),
|
|
103
|
-
valueType: found.valueType ?? 'boolean',
|
|
104
|
-
offValue: found.offValue,
|
|
105
|
-
onValue: found.onValue,
|
|
106
|
-
rule: found.rule,
|
|
107
|
-
schedule: found.schedule,
|
|
108
|
-
observability: {
|
|
109
|
-
evalCount: found.evalCount,
|
|
110
|
-
lastEvalAt: found.lastEvalAt,
|
|
111
|
-
},
|
|
112
|
-
// Echo the legacy fields too — useful while 1.0 admin clients exist.
|
|
113
|
-
legacy: {
|
|
114
|
-
enabled: found.enabled,
|
|
115
|
-
defaultValue: found.defaultValue,
|
|
116
|
-
targetValue: found.targetValue,
|
|
117
|
-
},
|
|
118
|
-
}));
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
outputError(err);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
// ── create ──────────────────────────────────────────────────────────────────
|
|
126
|
-
function registerCreate(flag) {
|
|
127
|
-
flag
|
|
128
|
-
.command('create')
|
|
129
|
-
.description('Create a new feature flag (2.0 fields)')
|
|
130
|
-
.requiredOption('--key <key>', 'Flag key')
|
|
131
|
-
.option('--description <desc>', 'Description')
|
|
132
|
-
.option('--state <state>', `Three-state model: ${FLAG_STATES.join(' | ')} (default: off)`)
|
|
133
|
-
.option('--value-type <type>', `Flag value type: ${FLAG_VALUE_TYPES.join(' | ')} (default: boolean)`)
|
|
134
|
-
.option('--on-value <value>', 'Value returned when state=on (parsed per --value-type; JSON string for json type)')
|
|
135
|
-
.option('--off-value <value>', 'Value returned when state=off (parsed per --value-type)')
|
|
136
|
-
.option('--rule <json>', 'Rule as a JSON string: {"branches":[...],"otherwiseValue":...,"rolloutPct":100}')
|
|
137
|
-
// Legacy 1.0 fields kept so existing scripts don't break.
|
|
138
|
-
.option('--enabled', 'Legacy: enable the flag', false)
|
|
139
|
-
.option('--default-value', 'Legacy: default value when no segment matches', false)
|
|
140
|
-
.action(async (opts) => {
|
|
141
|
-
try {
|
|
142
|
-
// `--key` is a requiredOption above, so `key` is always present at
|
|
143
|
-
// runtime even though buildFlagWritePayload's generic return type
|
|
144
|
-
// can't express that. The `unknown` step is just to convince TS the
|
|
145
|
-
// intent is deliberate.
|
|
146
|
-
const payload = buildFlagWritePayload(opts);
|
|
147
|
-
const flag = await getManagementClient().flags.create(payload);
|
|
148
|
-
outputSuccess(await withFlagWarnings(flag));
|
|
149
|
-
}
|
|
150
|
-
catch (err) {
|
|
151
|
-
outputError(err);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
// ── update ──────────────────────────────────────────────────────────────────
|
|
156
|
-
function registerUpdate(flag) {
|
|
157
|
-
flag
|
|
158
|
-
.command('update')
|
|
159
|
-
.description('Update a feature flag by --key or --id (2.0 fields)')
|
|
160
|
-
.option('--key <key>', 'Flag key to address (alternative to --id)')
|
|
161
|
-
.option('--id <id>', 'Flag ID to address (alternative to --key)')
|
|
162
|
-
.option('--new-key <key>', 'Rename the flag to this key')
|
|
163
|
-
.option('--description <desc>', 'Description')
|
|
164
|
-
.option('--state <state>', `Three-state model: ${FLAG_STATES.join(' | ')}`)
|
|
165
|
-
.option('--value-type <type>', `Flag value type: ${FLAG_VALUE_TYPES.join(' | ')}`)
|
|
166
|
-
.option('--on-value <value>', 'Value returned when state=on (parsed per --value-type)')
|
|
167
|
-
.option('--off-value <value>', 'Value returned when state=off (parsed per --value-type)')
|
|
168
|
-
.option('--rule <json>', 'Rule as a JSON string (must validate via validateRule)')
|
|
169
|
-
.option('--clear-rule', 'Remove the existing rule')
|
|
170
|
-
// Legacy 1.0
|
|
171
|
-
.option('--enabled <bool>', 'Legacy: enable/disable', (v) => v === 'true')
|
|
172
|
-
.option('--default-value <bool>', 'Legacy: default value', (v) => v === 'true')
|
|
173
|
-
.action(async (opts) => {
|
|
174
|
-
try {
|
|
175
|
-
// TBP-586. `--key` is overloaded here for backwards compatibility:
|
|
176
|
-
// before key-addressing existed, `flag update --id X --key Y` renamed
|
|
177
|
-
// the flag to Y, and that still works. So `--key` means "address this
|
|
178
|
-
// flag" only when `--id` is absent; alongside `--id` it keeps its old
|
|
179
|
-
// rename meaning. `--new-key` renames in either addressing mode.
|
|
180
|
-
const addressedById = opts.id !== undefined;
|
|
181
|
-
const renameTo = opts.newKey !== undefined
|
|
182
|
-
? opts.newKey
|
|
183
|
-
: (addressedById ? opts.key : undefined);
|
|
184
|
-
const id = await resolveFlagId({
|
|
185
|
-
id: opts.id,
|
|
186
|
-
key: addressedById ? undefined : opts.key,
|
|
187
|
-
});
|
|
188
|
-
const payload = buildFlagWritePayload({ ...opts, key: renameTo }, { partial: true });
|
|
189
|
-
if (opts.clearRule) {
|
|
190
|
-
payload.rule = null;
|
|
191
|
-
}
|
|
192
|
-
const flag = await getManagementClient().flags.update(id, payload);
|
|
193
|
-
outputSuccess(await withFlagWarnings(flag));
|
|
194
|
-
}
|
|
195
|
-
catch (err) {
|
|
196
|
-
outputError(err);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
// ── toggle (kept from 1.0; convenience for state on/off without typing JSON) ─
|
|
201
|
-
//
|
|
202
|
-
// TBP-548. The command used to call `flags.toggle()`, which PUT the FF 1.0
|
|
203
|
-
// `{ enabled }` boolean. FF 2.0 dropped that field from the flag document and
|
|
204
|
-
// evaluates on `state` alone, and the server's write DTO strips undeclared
|
|
205
|
-
// keys — so the body arrived empty, the API answered 200 with the untouched
|
|
206
|
-
// flag, and the CLI printed `success: true` for a flag that had not moved.
|
|
207
|
-
//
|
|
208
|
-
// The mapping below is the one the MCP `toggle_feature_flag` tool settled on
|
|
209
|
-
// (TBP-587), so the two surfaces over the same endpoint agree:
|
|
210
|
-
//
|
|
211
|
-
// --enabled true → state "on" (everyone gets onValue)
|
|
212
|
-
// --enabled false → state "off" (everyone gets offValue)
|
|
213
|
-
//
|
|
214
|
-
// with one refusal: a flag already at "on-with-rule" is not turned ON here.
|
|
215
|
-
// "on" stops the rule being consulted and hands onValue to every caller, which
|
|
216
|
-
// widens the audience instead of flipping a switch — `flag update --state on`
|
|
217
|
-
// is where you say that deliberately. Turning such a flag OFF stays allowed:
|
|
218
|
-
// the rule document is preserved, so it is reversible.
|
|
219
|
-
/** Error carrying a machine-readable `code` for `outputError` to report. */
|
|
220
|
-
class FlagCommandError extends Error {
|
|
221
|
-
code;
|
|
222
|
-
constructor(code, message) {
|
|
223
|
-
super(message);
|
|
224
|
-
this.name = 'FlagCommandError';
|
|
225
|
-
this.code = code;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
function registerToggle(flag) {
|
|
229
|
-
flag
|
|
230
|
-
.command('toggle')
|
|
231
|
-
.description('Quick toggle a flag on or off by --key or --id — sets state=on / state=off (the rule is kept)')
|
|
232
|
-
.option('--key <key>', 'Flag key to address (alternative to --id)')
|
|
233
|
-
.option('--id <id>', 'Flag ID to address (alternative to --key)')
|
|
234
|
-
.requiredOption('--enabled <bool>', 'true = state on, false = state off', (v) => v === 'true')
|
|
235
|
-
.action(async (opts) => {
|
|
236
|
-
try {
|
|
237
|
-
const enabled = opts.enabled;
|
|
238
|
-
const current = await resolveFlag({ id: opts.id, key: opts.key });
|
|
239
|
-
const currentState = current.state ?? deriveState(current);
|
|
240
|
-
if (currentState === 'on-with-rule' && enabled) {
|
|
241
|
-
throw new FlagCommandError('FLAG_HAS_TARGETING', `Flag '${current.key}' is state=on-with-rule: a targeting rule decides who gets it. ` +
|
|
242
|
-
'Turning it fully on would give it to everyone and stop the rule being consulted, ' +
|
|
243
|
-
'so nothing was changed. It is already on for whoever the rule matches. To widen it ' +
|
|
244
|
-
'to everyone deliberately, run `bridge flag update --key ' +
|
|
245
|
-
`${current.key} --state on\` (the rule is preserved and you can set it back to ` +
|
|
246
|
-
'on-with-rule later).');
|
|
247
|
-
}
|
|
248
|
-
const wanted = enabled ? 'on' : 'off';
|
|
249
|
-
const result = await getManagementClient().flags.update(current.id, { state: wanted });
|
|
250
|
-
// Read the write back rather than trusting the 200. The original bug
|
|
251
|
-
// was invisible precisely because nobody checked that it landed.
|
|
252
|
-
const resultingState = result?.state ?? (result ? deriveState(result) : undefined);
|
|
253
|
-
if (resultingState !== wanted) {
|
|
254
|
-
throw new FlagCommandError('TOGGLE_NOT_APPLIED', `Requested state='${wanted}' for '${current.key}' but the flag came back as ` +
|
|
255
|
-
`state='${resultingState ?? 'unknown'}'. The change did NOT take effect. ` +
|
|
256
|
-
'Re-read it with `bridge flag get ' +
|
|
257
|
-
`${current.key}\`, then set the state explicitly with \`bridge flag update\`.`);
|
|
258
|
-
}
|
|
259
|
-
outputSuccess(await withFlagWarnings(result));
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
outputError(err);
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
// ── delete ──────────────────────────────────────────────────────────────────
|
|
267
|
-
function registerDelete(flag) {
|
|
268
|
-
flag
|
|
269
|
-
.command('delete')
|
|
270
|
-
.description('Delete a feature flag by --key or --id')
|
|
271
|
-
.option('--key <key>', 'Flag key to address (alternative to --id)')
|
|
272
|
-
.option('--id <id>', 'Flag ID to address (alternative to --key)')
|
|
273
|
-
.option('--dry-run', 'Show what deleting it would do, and delete nothing')
|
|
274
|
-
.action(async (opts) => {
|
|
275
|
-
try {
|
|
276
|
-
const id = await resolveFlagId({ id: opts.id, key: opts.key });
|
|
277
|
-
if (opts.dryRun) {
|
|
278
|
-
outputSuccess(await dryRunPreview(`/v1/admin/flags/flag/${encodeURIComponent(id)}`));
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
await getManagementClient().flags.delete(id);
|
|
282
|
-
outputSuccess({ deleted: true, id });
|
|
283
|
-
}
|
|
284
|
-
catch (err) {
|
|
285
|
-
outputError(err);
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
// ── eval (local SDK-style evaluation, for debugging) ────────────────────────
|
|
290
|
-
function registerEval(flag) {
|
|
291
|
-
flag
|
|
292
|
-
.command('eval')
|
|
293
|
-
.argument('<key>', 'Flag key')
|
|
294
|
-
.description('Locally evaluate a flag against a synthetic context (debug helper)')
|
|
295
|
-
.option('--identity <id>', 'Eval identity (required when rolloutPct < 100)')
|
|
296
|
-
.option('--attribute <kv...>', 'One or more key=value attributes (use multiple flags or space-separate)')
|
|
297
|
-
.action(async (key, opts) => {
|
|
298
|
-
try {
|
|
299
|
-
const all = await getManagementClient().flags.list();
|
|
300
|
-
const found = all.find((f) => f.key === key);
|
|
301
|
-
if (!found) {
|
|
302
|
-
outputError(new Error(`Flag not found: ${key}`));
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
const attributes = parseAttributes(opts.attribute ?? []);
|
|
306
|
-
const ctx = { identity: opts.identity, attributes };
|
|
307
|
-
const cached = {
|
|
308
|
-
key: found.key,
|
|
309
|
-
state: found.state ?? deriveState(found),
|
|
310
|
-
valueType: found.valueType ?? 'boolean',
|
|
311
|
-
offValue: found.offValue ?? false,
|
|
312
|
-
onValue: found.onValue ?? true,
|
|
313
|
-
// `FlagResponse.rule` is the canonical auth-core `Rule` — no
|
|
314
|
-
// bridging cast needed (TBP-236).
|
|
315
|
-
rule: found.rule ?? undefined,
|
|
316
|
-
};
|
|
317
|
-
const result = evaluateLocally(cached, ctx);
|
|
318
|
-
// TBP-709 — same coherence warnings as the MCP evaluate_feature_flag.
|
|
319
|
-
const [warnings] = await warningsForFlags([cached]);
|
|
320
|
-
outputSuccess({
|
|
321
|
-
flag: cached.key,
|
|
322
|
-
state: cached.state,
|
|
323
|
-
context: ctx,
|
|
324
|
-
result,
|
|
325
|
-
warnings,
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
catch (err) {
|
|
329
|
-
outputError(err);
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
// ── schedule ────────────────────────────────────────────────────────────────
|
|
334
|
-
function registerSchedule(flag) {
|
|
335
|
-
const schedule = flag
|
|
336
|
-
.command('schedule')
|
|
337
|
-
.description('Manage scheduled state changes (TBP-189 — runner not yet active)');
|
|
338
|
-
schedule
|
|
339
|
-
.command('set')
|
|
340
|
-
.argument('<key>', 'Flag key')
|
|
341
|
-
.description('Schedule a state transition at an ISO timestamp')
|
|
342
|
-
.requiredOption('--at <iso>', 'ISO-8601 timestamp (e.g. 2026-05-20T09:00:00Z)')
|
|
343
|
-
.requiredOption('--state <state>', `Target state: ${FLAG_STATES.join(' | ')}`)
|
|
344
|
-
.action(async (key, opts) => {
|
|
345
|
-
try {
|
|
346
|
-
if (!FLAG_STATES.includes(opts.state)) {
|
|
347
|
-
throw new Error(`Invalid --state: ${opts.state}. Must be one of ${FLAG_STATES.join(', ')}.`);
|
|
348
|
-
}
|
|
349
|
-
const at = new Date(opts.at);
|
|
350
|
-
if (Number.isNaN(at.getTime())) {
|
|
351
|
-
throw new Error(`Invalid --at: ${opts.at} is not a valid ISO-8601 timestamp.`);
|
|
352
|
-
}
|
|
353
|
-
const id = await resolveFlagId({ key });
|
|
354
|
-
const schedule = { at: at.toISOString(), state: opts.state };
|
|
355
|
-
const update = { schedule };
|
|
356
|
-
const result = await getManagementClient().flags.update(id, update);
|
|
357
|
-
outputSuccess(result);
|
|
358
|
-
}
|
|
359
|
-
catch (err) {
|
|
360
|
-
outputError(err);
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
schedule
|
|
364
|
-
.command('clear')
|
|
365
|
-
.argument('<key>', 'Flag key')
|
|
366
|
-
.description('Clear an existing schedule')
|
|
367
|
-
.action(async (key) => {
|
|
368
|
-
try {
|
|
369
|
-
const id = await resolveFlagId({ key });
|
|
370
|
-
const update = { schedule: null };
|
|
371
|
-
const result = await getManagementClient().flags.update(id, update);
|
|
372
|
-
outputSuccess(result);
|
|
373
|
-
}
|
|
374
|
-
catch (err) {
|
|
375
|
-
outputError(err);
|
|
376
|
-
}
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
/** Build the canonical, portable doc from a remote flag record. Pure. */
|
|
380
|
-
export function toFlagDoc(f) {
|
|
381
|
-
return {
|
|
382
|
-
key: f.key,
|
|
383
|
-
description: f.description,
|
|
384
|
-
state: f.state ?? deriveState(f),
|
|
385
|
-
valueType: f.valueType ?? 'boolean',
|
|
386
|
-
offValue: f.offValue,
|
|
387
|
-
onValue: f.onValue,
|
|
388
|
-
rule: f.rule,
|
|
389
|
-
schedule: f.schedule,
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
/** Build a create/update write payload from a doc. Pure. */
|
|
393
|
-
export function buildWritePayloadFromDoc(doc) {
|
|
394
|
-
const payload = {
|
|
395
|
-
key: doc.key,
|
|
396
|
-
state: doc.state,
|
|
397
|
-
valueType: doc.valueType,
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { InvalidArgumentError } from 'commander';
|
|
3
|
+
import { FLAG_STATES, VALUE_TYPES, flags, parseCondition, parseRule, parseValue } from '../api/flags.js';
|
|
4
|
+
import { CliError } from '../core/errors.js';
|
|
5
|
+
import { action, collect } from './context.js';
|
|
6
|
+
function oneOf(list, what) {
|
|
7
|
+
return (value) => {
|
|
8
|
+
if (!list.includes(value))
|
|
9
|
+
throw new InvalidArgumentError(`${what} is one of ${list.join(', ')}.`);
|
|
10
|
+
return value;
|
|
398
11
|
};
|
|
399
|
-
if (doc.description !== undefined)
|
|
400
|
-
payload.description = doc.description;
|
|
401
|
-
if (doc.offValue !== undefined)
|
|
402
|
-
payload.offValue = doc.offValue;
|
|
403
|
-
if (doc.onValue !== undefined)
|
|
404
|
-
payload.onValue = doc.onValue;
|
|
405
|
-
if (doc.rule !== undefined)
|
|
406
|
-
payload.rule = doc.rule;
|
|
407
|
-
if (doc.schedule !== undefined)
|
|
408
|
-
payload.schedule = doc.schedule;
|
|
409
|
-
return payload;
|
|
410
12
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (!
|
|
414
|
-
throw new
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (errs.length > 0) {
|
|
439
|
-
throw new Error(`flags[${index}] ("${d.key}") rule failed validation:\n${errs.map((e) => ` - ${e.message}`).join('\n')}`);
|
|
440
|
-
}
|
|
441
|
-
rule = normalized;
|
|
442
|
-
}
|
|
13
|
+
function percent(value) {
|
|
14
|
+
const n = Number(value);
|
|
15
|
+
if (!Number.isFinite(n) || n < 0 || n > 100)
|
|
16
|
+
throw new InvalidArgumentError('A share from 0 to 100.');
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
async function ruleFrom(opts) {
|
|
20
|
+
if (opts.rule !== undefined && opts.ruleFile !== undefined)
|
|
21
|
+
throw new CliError('invalid', 'Give the rule with --rule or --rule-file, not both.');
|
|
22
|
+
if (opts.rule !== undefined)
|
|
23
|
+
return parseRule(opts.rule);
|
|
24
|
+
if (opts.ruleFile !== undefined)
|
|
25
|
+
return parseRule(await readFile(opts.ruleFile, 'utf8'));
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const withFlagOptions = (cmd) => cmd
|
|
29
|
+
.option('--description <text>', 'What the flag gates')
|
|
30
|
+
.option('--on-value <value>', 'The value while it is on (default true)')
|
|
31
|
+
.option('--off-value <value>', 'The value while it is off (default false)')
|
|
32
|
+
.option('--state <state>', `${FLAG_STATES.join(' | ')}`, oneOf(FLAG_STATES, '--state'))
|
|
33
|
+
.option('--rule <json>', 'The rule as JSON: { "branches": [...], "otherwiseValue": … }')
|
|
34
|
+
.option('--rule-file <file>', 'The rule from a JSON file')
|
|
35
|
+
.option('--when <condition>', 'A condition of a one-branch rule, "<attribute> <operator> <value>", such as "privileges contains USER_WRITE"; repeat to AND them', collect)
|
|
36
|
+
.option('--rollout <percent>', 'With --when: only this share of the people it matches', percent)
|
|
37
|
+
.option('--public', 'Visitors who are not signed in may read it');
|
|
38
|
+
/** A one-branch rule from `--when` conditions: matches give the on value, everyone else the off value. */
|
|
39
|
+
function whenRule(conditions, onValue, offValue, rollout) {
|
|
443
40
|
return {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
state,
|
|
447
|
-
valueType,
|
|
448
|
-
offValue: d.offValue,
|
|
449
|
-
onValue: d.onValue,
|
|
450
|
-
rule,
|
|
451
|
-
schedule: d.schedule,
|
|
41
|
+
branches: [{ conditions: conditions.map(parseCondition), returnValue: onValue, ...(rollout === undefined ? {} : { rolloutPct: rollout }) }],
|
|
42
|
+
otherwiseValue: offValue,
|
|
452
43
|
};
|
|
453
44
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const all = (await getManagementClient().flags.list());
|
|
500
|
-
const flags = all.map(toFlagDoc).sort((a, b) => a.key.localeCompare(b.key));
|
|
501
|
-
const bundle = { version: 1, flags };
|
|
502
|
-
if (opts.out) {
|
|
503
|
-
writeFileSync(opts.out, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
|
|
504
|
-
outputSuccess({ written: opts.out, count: flags.length });
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
outputSuccess(bundle);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
catch (err) {
|
|
511
|
-
outputError(err);
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
function registerImport(flag) {
|
|
516
|
-
flag
|
|
517
|
-
.command('import')
|
|
518
|
-
.argument('<file>', 'Path to a flags JSON file (from `flag export`)')
|
|
519
|
-
.description('Apply feature flags from a file (create + update; --prune deletes extras)')
|
|
520
|
-
.option('--dry-run', 'Show the diff without applying any changes', false)
|
|
521
|
-
.option('--prune', 'Delete remote flags not present in the file', false)
|
|
522
|
-
.action(async (file, opts) => {
|
|
523
|
-
try {
|
|
524
|
-
let raw;
|
|
525
|
-
try {
|
|
526
|
-
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
527
|
-
}
|
|
528
|
-
catch (err) {
|
|
529
|
-
throw new Error(`Could not read/parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
530
|
-
}
|
|
531
|
-
const list = Array.isArray(raw) ? raw : raw.flags;
|
|
532
|
-
if (!Array.isArray(list)) {
|
|
533
|
-
throw new Error('File must be an array of flags or an object with a "flags" array.');
|
|
534
|
-
}
|
|
535
|
-
const docs = list.map((d, i) => validateFlagDoc(d, i));
|
|
536
|
-
const remote = (await getManagementClient().flags.list());
|
|
537
|
-
const diff = diffFlags(remote, docs, { prune: opts.prune });
|
|
538
|
-
if (opts.dryRun) {
|
|
539
|
-
outputSuccess({
|
|
540
|
-
dryRun: true,
|
|
541
|
-
create: diff.create.map((d) => d.key),
|
|
542
|
-
update: diff.update.map((u) => ({ key: u.doc.key, changes: u.changes })),
|
|
543
|
-
prune: diff.prune.map((p) => p.key),
|
|
544
|
-
unchanged: diff.unchanged,
|
|
545
|
-
});
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
const mgmt = getManagementClient();
|
|
549
|
-
for (const doc of diff.create) {
|
|
550
|
-
await mgmt.flags.create(buildWritePayloadFromDoc(doc));
|
|
551
|
-
}
|
|
552
|
-
for (const u of diff.update) {
|
|
553
|
-
await mgmt.flags.update(u.id, buildWritePayloadFromDoc(u.doc));
|
|
554
|
-
}
|
|
555
|
-
for (const p of diff.prune) {
|
|
556
|
-
await mgmt.flags.delete(p.id);
|
|
557
|
-
}
|
|
558
|
-
outputSuccess({
|
|
559
|
-
created: diff.create.map((d) => d.key),
|
|
560
|
-
updated: diff.update.map((u) => u.doc.key),
|
|
561
|
-
pruned: diff.prune.map((p) => p.key),
|
|
562
|
-
unchanged: diff.unchanged,
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
catch (err) {
|
|
566
|
-
outputError(err);
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
export function buildFlagWritePayload(opts, { partial = false } = {}) {
|
|
571
|
-
const payload = {};
|
|
572
|
-
if (opts.key !== undefined)
|
|
573
|
-
payload.key = opts.key;
|
|
574
|
-
if (opts.description !== undefined)
|
|
575
|
-
payload.description = opts.description;
|
|
576
|
-
if (opts.state !== undefined) {
|
|
577
|
-
if (!FLAG_STATES.includes(opts.state)) {
|
|
578
|
-
throw new Error(`Invalid --state: ${opts.state}. Must be one of ${FLAG_STATES.join(', ')}.`);
|
|
579
|
-
}
|
|
580
|
-
payload.state = opts.state;
|
|
581
|
-
}
|
|
582
|
-
let valueType;
|
|
583
|
-
if (opts.valueType !== undefined) {
|
|
584
|
-
if (!FLAG_VALUE_TYPES.includes(opts.valueType)) {
|
|
585
|
-
throw new Error(`Invalid --value-type: ${opts.valueType}. Must be one of ${FLAG_VALUE_TYPES.join(', ')}.`);
|
|
586
|
-
}
|
|
587
|
-
valueType = opts.valueType;
|
|
588
|
-
payload.valueType = valueType;
|
|
589
|
-
}
|
|
590
|
-
if (opts.onValue !== undefined) {
|
|
591
|
-
payload.onValue = coerceValue(opts.onValue, valueType ?? 'boolean');
|
|
592
|
-
}
|
|
593
|
-
if (opts.offValue !== undefined) {
|
|
594
|
-
payload.offValue = coerceValue(opts.offValue, valueType ?? 'boolean');
|
|
595
|
-
}
|
|
596
|
-
if (opts.rule !== undefined) {
|
|
597
|
-
const parsed = parseRuleArg(opts.rule);
|
|
598
|
-
payload.rule = parsed;
|
|
599
|
-
}
|
|
600
|
-
// Legacy fields. In partial (update) mode, only include if explicitly set —
|
|
601
|
-
// commander returns `false` as default for boolean flags, which would
|
|
602
|
-
// otherwise clobber server state on every update.
|
|
603
|
-
if (!partial || opts.enabled !== undefined) {
|
|
604
|
-
if (opts.enabled !== undefined)
|
|
605
|
-
payload.enabled = opts.enabled;
|
|
606
|
-
}
|
|
607
|
-
if (!partial || opts.defaultValue !== undefined) {
|
|
608
|
-
if (opts.defaultValue !== undefined)
|
|
609
|
-
payload.defaultValue = opts.defaultValue;
|
|
610
|
-
}
|
|
611
|
-
return payload;
|
|
612
|
-
}
|
|
613
|
-
export function coerceValue(raw, type) {
|
|
614
|
-
switch (type) {
|
|
615
|
-
case 'boolean':
|
|
616
|
-
if (raw === 'true')
|
|
617
|
-
return true;
|
|
618
|
-
if (raw === 'false')
|
|
619
|
-
return false;
|
|
620
|
-
throw new Error(`Invalid boolean value "${raw}" — expected "true" or "false".`);
|
|
621
|
-
case 'number': {
|
|
622
|
-
const n = Number(raw);
|
|
623
|
-
if (!Number.isFinite(n))
|
|
624
|
-
throw new Error(`Invalid number value "${raw}".`);
|
|
625
|
-
return n;
|
|
626
|
-
}
|
|
627
|
-
case 'string':
|
|
628
|
-
return raw;
|
|
629
|
-
case 'json':
|
|
630
|
-
try {
|
|
631
|
-
return JSON.parse(raw);
|
|
632
|
-
}
|
|
633
|
-
catch (err) {
|
|
634
|
-
throw new Error(`Invalid JSON value: ${err instanceof Error ? err.message : String(err)}`);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
export function parseRuleArg(raw) {
|
|
639
|
-
let parsed;
|
|
640
|
-
try {
|
|
641
|
-
parsed = JSON.parse(raw);
|
|
642
|
-
}
|
|
643
|
-
catch (err) {
|
|
644
|
-
throw new Error(`--rule must be valid JSON. Parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
645
|
-
}
|
|
646
|
-
let rule;
|
|
647
|
-
try {
|
|
648
|
-
rule = normalizeRuleInput(parsed);
|
|
649
|
-
}
|
|
650
|
-
catch (err) {
|
|
651
|
-
throw new Error(`--rule failed validation:\n - ${err instanceof Error ? err.message : String(err)}`);
|
|
652
|
-
}
|
|
653
|
-
const errors = validateRule(rule);
|
|
654
|
-
if (errors.length > 0) {
|
|
655
|
-
throw new Error(`--rule failed validation:\n${errors.map((e) => ` - ${e.message}`).join('\n')}`);
|
|
656
|
-
}
|
|
657
|
-
return rule;
|
|
658
|
-
}
|
|
659
|
-
// ── Parse-time rule normalization (TBP-236) ─────────────────────────────────
|
|
660
|
-
//
|
|
661
|
-
// The CLI historically accepted conditions of shape { attribute, operator,
|
|
662
|
-
// value } with operator names like 'equals' / 'starts_with' / 'gte'. The
|
|
663
|
-
// canonical auth-core format is { attribute, operator, values: [...] } with
|
|
664
|
-
// the locked operator vocabulary in `OPERATORS`. `normalizeRuleInput` migrates
|
|
665
|
-
// legacy input to the canonical shape at parse time:
|
|
666
|
-
// - scalar `value` → plural `values` array (`"value": "pro"` → `values: ["pro"]`)
|
|
667
|
-
// - legacy operator names → canonical ones (deprecation warning on stderr)
|
|
668
|
-
// Structurally invalid input (non-object rule, missing otherwiseValue, ...)
|
|
669
|
-
// throws; unknown operators are passed through for `validateRule` to report.
|
|
670
|
-
/** Legacy operator names that map 1:1 onto a canonical operator. */
|
|
671
|
-
const LEGACY_OPERATOR_ALIASES = {
|
|
672
|
-
equals: 'eq',
|
|
673
|
-
equal: 'eq',
|
|
674
|
-
not_equals: 'neq',
|
|
675
|
-
not_equal: 'neq',
|
|
676
|
-
notEquals: 'neq',
|
|
677
|
-
does_not_contain: 'not_contains',
|
|
678
|
-
notContains: 'not_contains',
|
|
679
|
-
greater_than: 'gt',
|
|
680
|
-
greaterThan: 'gt',
|
|
681
|
-
less_than: 'lt',
|
|
682
|
-
lessThan: 'lt',
|
|
683
|
-
matches: 'regex',
|
|
684
|
-
};
|
|
685
|
-
function warnDeprecatedOperator(legacy, canonical, note) {
|
|
686
|
-
process.stderr.write(`Warning: rule operator "${legacy}" is deprecated; mapped to "${canonical}"${note ? ` (${note})` : ''}. ` +
|
|
687
|
-
`Canonical operators: ${OPERATORS.join(', ')}.\n`);
|
|
688
|
-
}
|
|
689
|
-
function escapeRegExp(s) {
|
|
690
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Map a (possibly legacy) operator name + values to the canonical vocabulary.
|
|
694
|
-
* Some legacy operators have no 1:1 canonical equivalent and are rewritten
|
|
695
|
-
* with adjusted values:
|
|
696
|
-
* starts_with x → regex ^x ends_with x → regex x$
|
|
697
|
-
* gte x → between [x, Number.MAX_VALUE]
|
|
698
|
-
* lte x → between [-Number.MAX_VALUE, x]
|
|
699
|
-
* Unknown names are passed through so validateRule reports them.
|
|
700
|
-
*/
|
|
701
|
-
function resolveOperator(op, values) {
|
|
702
|
-
if (isOperator(op))
|
|
703
|
-
return { operator: op, values };
|
|
704
|
-
const alias = LEGACY_OPERATOR_ALIASES[op];
|
|
705
|
-
if (alias) {
|
|
706
|
-
warnDeprecatedOperator(op, alias);
|
|
707
|
-
return { operator: alias, values };
|
|
708
|
-
}
|
|
709
|
-
switch (op) {
|
|
710
|
-
case 'starts_with':
|
|
711
|
-
case 'startsWith':
|
|
712
|
-
case 'begins_with':
|
|
713
|
-
case 'beginsWith':
|
|
714
|
-
warnDeprecatedOperator(op, 'regex', 'value rewritten to an anchored ^… pattern');
|
|
715
|
-
return { operator: 'regex', values: [`^${escapeRegExp(String(values[0] ?? ''))}`] };
|
|
716
|
-
case 'ends_with':
|
|
717
|
-
case 'endsWith':
|
|
718
|
-
warnDeprecatedOperator(op, 'regex', 'value rewritten to an anchored …$ pattern');
|
|
719
|
-
return { operator: 'regex', values: [`${escapeRegExp(String(values[0] ?? ''))}$`] };
|
|
720
|
-
case 'gte':
|
|
721
|
-
case 'greater_than_or_equal':
|
|
722
|
-
case 'greaterThanOrEqual':
|
|
723
|
-
warnDeprecatedOperator(op, 'between', 'rewritten to between [value, Number.MAX_VALUE]');
|
|
724
|
-
return { operator: 'between', values: [values[0] ?? null, Number.MAX_VALUE] };
|
|
725
|
-
case 'lte':
|
|
726
|
-
case 'less_than_or_equal':
|
|
727
|
-
case 'lessThanOrEqual':
|
|
728
|
-
warnDeprecatedOperator(op, 'between', 'rewritten to between [-Number.MAX_VALUE, value]');
|
|
729
|
-
return { operator: 'between', values: [-Number.MAX_VALUE, values[0] ?? null] };
|
|
730
|
-
default:
|
|
731
|
-
// Unknown operator — pass through; validateRule will flag it.
|
|
732
|
-
return { operator: op, values };
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
function normalizeConditionInput(input, bi, ci) {
|
|
736
|
-
if (!input || typeof input !== 'object') {
|
|
737
|
-
throw new Error(`Branch ${bi} condition ${ci} must be an object.`);
|
|
738
|
-
}
|
|
739
|
-
const c = input;
|
|
740
|
-
if (typeof c.attribute !== 'string' || c.attribute.length === 0) {
|
|
741
|
-
throw new Error(`Branch ${bi} condition ${ci} missing 'attribute'.`);
|
|
742
|
-
}
|
|
743
|
-
if (typeof c.operator !== 'string' || c.operator.length === 0) {
|
|
744
|
-
throw new Error(`Branch ${bi} condition ${ci} missing 'operator'.`);
|
|
745
|
-
}
|
|
746
|
-
let values;
|
|
747
|
-
if (c.values !== undefined) {
|
|
748
|
-
if (!Array.isArray(c.values)) {
|
|
749
|
-
throw new Error(`Branch ${bi} condition ${ci} 'values' must be an array.`);
|
|
750
|
-
}
|
|
751
|
-
values = [...c.values];
|
|
752
|
-
}
|
|
753
|
-
else if ('value' in c) {
|
|
754
|
-
// Legacy singular `value` — migrate to plural `values` at parse time.
|
|
755
|
-
const v = c.value;
|
|
756
|
-
values = Array.isArray(v) ? [...v] : [v];
|
|
757
|
-
}
|
|
758
|
-
else {
|
|
759
|
-
values = [];
|
|
760
|
-
}
|
|
761
|
-
const resolved = resolveOperator(c.operator, values);
|
|
762
|
-
return { attribute: c.attribute, operator: resolved.operator, values: resolved.values };
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Normalize untrusted rule input (from --rule JSON or a flags file) to the
|
|
766
|
-
* canonical auth-core `Rule` shape. Throws on structural problems; operator
|
|
767
|
-
* validity is left to `validateRule` on the returned rule.
|
|
768
|
-
*/
|
|
769
|
-
export function normalizeRuleInput(input) {
|
|
770
|
-
if (!input || typeof input !== 'object') {
|
|
771
|
-
throw new Error('Rule must be an object.');
|
|
772
|
-
}
|
|
773
|
-
const r = input;
|
|
774
|
-
if (!('otherwiseValue' in r)) {
|
|
775
|
-
throw new Error('Rule missing otherwiseValue.');
|
|
776
|
-
}
|
|
777
|
-
const rawBranches = r.branches ?? [];
|
|
778
|
-
if (!Array.isArray(rawBranches)) {
|
|
779
|
-
throw new Error('`branches` must be an array.');
|
|
780
|
-
}
|
|
781
|
-
const branches = rawBranches.map((branch, bi) => {
|
|
782
|
-
if (!branch || typeof branch !== 'object') {
|
|
783
|
-
throw new Error(`Branch ${bi} must be an object.`);
|
|
784
|
-
}
|
|
785
|
-
const b = branch;
|
|
786
|
-
if (!('returnValue' in b)) {
|
|
787
|
-
throw new Error(`Branch ${bi} is missing returnValue.`);
|
|
788
|
-
}
|
|
789
|
-
const rawConds = b.conditions ?? [];
|
|
790
|
-
if (!Array.isArray(rawConds)) {
|
|
791
|
-
throw new Error(`Branch ${bi} 'conditions' must be an array.`);
|
|
792
|
-
}
|
|
793
|
-
return {
|
|
794
|
-
conditions: rawConds.map((c, ci) => normalizeConditionInput(c, bi, ci)),
|
|
795
|
-
returnValue: b.returnValue,
|
|
45
|
+
async function ruleOption(opts, onValue, offValue) {
|
|
46
|
+
const given = await ruleFrom(opts);
|
|
47
|
+
if (opts.when !== undefined) {
|
|
48
|
+
if (given !== undefined)
|
|
49
|
+
throw new CliError('invalid', 'Give the rule with --when or with --rule / --rule-file, not both.');
|
|
50
|
+
return whenRule(opts.when, onValue, offValue, opts.rollout);
|
|
51
|
+
}
|
|
52
|
+
if (opts.rollout !== undefined)
|
|
53
|
+
throw new CliError('invalid', '--rollout goes with --when.');
|
|
54
|
+
return given;
|
|
55
|
+
}
|
|
56
|
+
/** The state a rule change leaves the flag in: the rule runs, unless the flag is switched off. */
|
|
57
|
+
function stateWithRule(flag) {
|
|
58
|
+
return flag.state === 'off' ? 'off' : 'on-with-rule';
|
|
59
|
+
}
|
|
60
|
+
function noteIfOff(ctx, flag) {
|
|
61
|
+
if (flag.state === 'off')
|
|
62
|
+
ctx.io.info(`${flag.key} is off, so its rule does not run yet. \`bridge flag restore ${flag.key}\` or \`bridge flag update ${flag.key} --state on-with-rule\` turns it on.`);
|
|
63
|
+
}
|
|
64
|
+
export function registerFlagCommands(program, deps) {
|
|
65
|
+
const flag = program.command('flag').description('Feature flags and their rules: every gate in app code is a flag');
|
|
66
|
+
flag.command('list').description('The app\'s flags').action(action(deps, async (ctx) => {
|
|
67
|
+
const { c, appId } = await ctx.forApp();
|
|
68
|
+
ctx.io.result(await flags.list(c, appId));
|
|
69
|
+
}));
|
|
70
|
+
flag.command('get <key>').description('One flag, with its rule').action(action(deps, async (ctx, key) => {
|
|
71
|
+
const { c, appId } = await ctx.forApp();
|
|
72
|
+
ctx.io.result(await flags.get(c, appId, key));
|
|
73
|
+
}));
|
|
74
|
+
withFlagOptions(flag.command('create <key>').description('Create a flag').option('--type <type>', `The values' type: ${VALUE_TYPES.join(' | ')} (default boolean)`, oneOf(VALUE_TYPES, '--type'))).action(action(deps, async (ctx, key, opts) => {
|
|
75
|
+
const type = opts.type ?? 'boolean';
|
|
76
|
+
const onValue = opts.onValue === undefined ? (type === 'boolean' ? true : undefined) : parseValue(opts.onValue, type);
|
|
77
|
+
const offValue = opts.offValue === undefined ? (type === 'boolean' ? false : undefined) : parseValue(opts.offValue, type);
|
|
78
|
+
if (onValue === undefined || offValue === undefined)
|
|
79
|
+
throw new CliError('invalid', `A ${type} flag needs --on-value and --off-value.`);
|
|
80
|
+
const rule = await ruleOption(opts, onValue, offValue);
|
|
81
|
+
const fields = {
|
|
82
|
+
key,
|
|
83
|
+
valueType: type,
|
|
84
|
+
onValue,
|
|
85
|
+
offValue,
|
|
86
|
+
state: opts.state ?? (rule === undefined ? 'off' : 'on-with-rule'),
|
|
87
|
+
...(rule === undefined ? {} : { rule }),
|
|
88
|
+
...(opts.description === undefined ? {} : { description: opts.description }),
|
|
89
|
+
...(opts.public === undefined ? {} : { public: opts.public }),
|
|
796
90
|
};
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
const raw = pair.slice(idx + 1);
|
|
819
|
-
// Try to parse as JSON first (covers numbers, booleans, structured); fall
|
|
820
|
-
// back to the raw string.
|
|
821
|
-
try {
|
|
822
|
-
out[key] = JSON.parse(raw);
|
|
823
|
-
}
|
|
824
|
-
catch {
|
|
825
|
-
out[key] = raw;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
return out;
|
|
829
|
-
}
|
|
830
|
-
// ── Local evaluator (delegates to auth-core's canonical evaluator) ──────────
|
|
831
|
-
export function evaluateLocally(cached, ctx) {
|
|
832
|
-
switch (cached.state) {
|
|
833
|
-
case 'off':
|
|
834
|
-
return {
|
|
835
|
-
value: cached.offValue,
|
|
836
|
-
variantIndex: -1,
|
|
837
|
-
matched: false,
|
|
838
|
-
excludedByRollout: false,
|
|
91
|
+
const { c, appId } = await ctx.forApp();
|
|
92
|
+
ctx.io.result(await flags.create(c, appId, fields));
|
|
93
|
+
}));
|
|
94
|
+
withFlagOptions(flag.command('update <key>').description('Change a flag').option('--new-key <key>', 'Rename it').option('--private', 'Only signed-in people may read it')).action(action(deps, async (ctx, key, opts) => {
|
|
95
|
+
const given = await ruleFrom(opts);
|
|
96
|
+
if (opts.when !== undefined && given !== undefined)
|
|
97
|
+
throw new CliError('invalid', 'Give the rule with --when or with --rule / --rule-file, not both.');
|
|
98
|
+
if (opts.rollout !== undefined && opts.when === undefined)
|
|
99
|
+
throw new CliError('invalid', '--rollout goes with --when.');
|
|
100
|
+
const { c, appId } = await ctx.forApp();
|
|
101
|
+
const changed = await flags.update(c, appId, key, (current) => {
|
|
102
|
+
const onValue = opts.onValue === undefined ? current.onValue : parseValue(opts.onValue, current.valueType);
|
|
103
|
+
const offValue = opts.offValue === undefined ? current.offValue : parseValue(opts.offValue, current.valueType);
|
|
104
|
+
const rule = opts.when === undefined ? given : whenRule(opts.when, onValue, offValue, opts.rollout);
|
|
105
|
+
const fields = {
|
|
106
|
+
...(opts.newKey === undefined ? {} : { key: opts.newKey }),
|
|
107
|
+
...(opts.description === undefined ? {} : { description: opts.description }),
|
|
108
|
+
...(opts.onValue === undefined ? {} : { onValue }),
|
|
109
|
+
...(opts.offValue === undefined ? {} : { offValue }),
|
|
110
|
+
...(opts.public ? { public: true } : opts.private ? { public: false } : {}),
|
|
111
|
+
...(rule === undefined ? {} : { rule }),
|
|
839
112
|
};
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
113
|
+
const state = opts.state ?? (rule === undefined ? undefined : stateWithRule(current));
|
|
114
|
+
if (state !== undefined)
|
|
115
|
+
fields.state = state;
|
|
116
|
+
if (Object.keys(fields).length === 0)
|
|
117
|
+
throw new CliError('invalid', 'Nothing to change.');
|
|
118
|
+
return fields;
|
|
119
|
+
});
|
|
120
|
+
if (given !== undefined || opts.when !== undefined)
|
|
121
|
+
noteIfOff(ctx, changed);
|
|
122
|
+
ctx.io.result(changed);
|
|
123
|
+
}));
|
|
124
|
+
flag.command('on <key>').description('Everyone gets the on value').action(action(deps, async (ctx, key) => {
|
|
125
|
+
const { c, appId } = await ctx.forApp();
|
|
126
|
+
ctx.io.result(await flags.update(c, appId, key, { state: 'on' }));
|
|
127
|
+
}));
|
|
128
|
+
flag.command('off <key>').description('Switch the flag off (the kill switch). Its rule is kept, and `restore` brings it back').action(action(deps, async (ctx, key) => {
|
|
129
|
+
const { c, appId } = await ctx.forApp();
|
|
130
|
+
ctx.io.result(await flags.kill(c, appId, key));
|
|
131
|
+
}));
|
|
132
|
+
flag.command('restore <key>').description('Undo `off`: the flag goes back to what it was').action(action(deps, async (ctx, key) => {
|
|
133
|
+
const { c, appId } = await ctx.forApp();
|
|
134
|
+
ctx.io.result(await flags.restore(c, appId, key));
|
|
135
|
+
}));
|
|
136
|
+
flag
|
|
137
|
+
.command('delete <key>')
|
|
138
|
+
.requiredOption('--yes', 'Confirm')
|
|
139
|
+
.action(action(deps, async (ctx, key) => {
|
|
140
|
+
const { c, appId } = await ctx.forApp();
|
|
141
|
+
ctx.io.result(await flags.remove(c, appId, key));
|
|
142
|
+
}));
|
|
143
|
+
const rule = flag.command('rule').description('A flag\'s rule: ordered branches of AND-ed conditions, the first match wins');
|
|
144
|
+
rule.command('show <key>').action(action(deps, async (ctx, key) => {
|
|
145
|
+
const { c, appId } = await ctx.forApp();
|
|
146
|
+
const found = await flags.get(c, appId, key);
|
|
147
|
+
ctx.io.result({ key: found.key, state: found.state, rule: found.rule });
|
|
148
|
+
}));
|
|
149
|
+
rule
|
|
150
|
+
.command('set <key>')
|
|
151
|
+
.description('Replace the rule, from --rule or --rule-file')
|
|
152
|
+
.option('--rule <json>', 'The rule as JSON')
|
|
153
|
+
.option('--rule-file <file>', 'The rule from a JSON file')
|
|
154
|
+
.action(action(deps, async (ctx, key, opts) => {
|
|
155
|
+
const given = await ruleFrom(opts);
|
|
156
|
+
if (given === undefined)
|
|
157
|
+
throw new CliError('invalid', 'Give the rule with --rule or --rule-file.');
|
|
158
|
+
const { c, appId } = await ctx.forApp();
|
|
159
|
+
const changed = await flags.update(c, appId, key, (current) => ({ rule: given, state: stateWithRule(current) }));
|
|
160
|
+
noteIfOff(ctx, changed);
|
|
161
|
+
ctx.io.result(changed);
|
|
162
|
+
}));
|
|
163
|
+
rule
|
|
164
|
+
.command('add <key>')
|
|
165
|
+
.description('Add a branch to the rule: people matching every --when condition get --value (default: the on value)')
|
|
166
|
+
.requiredOption('--when <condition>', '"<attribute> <operator> <value>", such as "bridge:billing.entitlement.analytics eq true"; repeat to AND them', collect)
|
|
167
|
+
.option('--value <value>', 'What the branch gives')
|
|
168
|
+
.option('--rollout <percent>', 'Only this share of the people it matches', percent)
|
|
169
|
+
.option('--first', 'Put the branch first instead of last')
|
|
170
|
+
.action(action(deps, async (ctx, key, opts) => {
|
|
171
|
+
const conditions = opts.when.map(parseCondition);
|
|
172
|
+
const { c, appId } = await ctx.forApp();
|
|
173
|
+
const changed = await flags.update(c, appId, key, (current) => {
|
|
174
|
+
const branch = {
|
|
175
|
+
conditions,
|
|
176
|
+
returnValue: opts.value === undefined ? current.onValue : parseValue(opts.value, current.valueType),
|
|
177
|
+
...(opts.rollout === undefined ? {} : { rolloutPct: opts.rollout }),
|
|
846
178
|
};
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
860
|
-
/**
|
|
861
|
-
* Derive a 2.0 state from a legacy 1.0 flag shape. Used so `flag list` /
|
|
862
|
-
* `flag get` always show a state column, even against an older bridge-api
|
|
863
|
-
* that has not yet rolled out 2.0 fields.
|
|
864
|
-
*/
|
|
865
|
-
function deriveState(f) {
|
|
866
|
-
if (typeof f.state === 'string' && FLAG_STATES.includes(f.state)) {
|
|
867
|
-
return f.state;
|
|
868
|
-
}
|
|
869
|
-
if (f.enabled === false)
|
|
870
|
-
return 'off';
|
|
871
|
-
const segs = f.segments ?? [];
|
|
872
|
-
return segs.length > 0 ? 'on-with-rule' : 'on';
|
|
179
|
+
const existing = current.rule ?? { branches: [], otherwiseValue: current.offValue };
|
|
180
|
+
const branches = opts.first ? [branch, ...existing.branches] : [...existing.branches, branch];
|
|
181
|
+
return { rule: { ...existing, branches }, state: stateWithRule(current) };
|
|
182
|
+
});
|
|
183
|
+
noteIfOff(ctx, changed);
|
|
184
|
+
ctx.io.result(changed);
|
|
185
|
+
}));
|
|
186
|
+
rule.command('clear <key>').description('Remove the rule: while on, everyone gets the on value').action(action(deps, async (ctx, key) => {
|
|
187
|
+
const { c, appId } = await ctx.forApp();
|
|
188
|
+
ctx.io.result(await flags.update(c, appId, key, (current) => ({ rule: null, ...(current.state === 'on-with-rule' ? { state: 'on' } : {}) })));
|
|
189
|
+
}));
|
|
873
190
|
}
|
|
874
191
|
//# sourceMappingURL=flag.command.js.map
|