@quonfig/cli 0.0.72 → 0.0.74
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 +100 -75
- package/dist/commands/cleanup/status.js +5 -1
- package/dist/commands/config-schema.js +8 -3
- package/dist/commands/info.js +9 -15
- package/dist/commands/log-level.js +7 -10
- package/dist/commands/override.js +7 -3
- package/dist/commands/serve.js +6 -3
- package/dist/commands/set-default.d.ts +1 -0
- package/dist/commands/set-default.js +106 -71
- package/dist/commands/set-rollout.d.ts +1 -0
- package/dist/commands/set-rollout.js +65 -18
- package/dist/index.d.ts +30 -0
- package/dist/index.js +105 -8
- package/dist/init/templates.js +43 -0
- package/dist/util/rules.d.ts +98 -0
- package/dist/util/rules.js +131 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +21 -7
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,27 @@ import { Command } from '@oclif/core';
|
|
|
3
3
|
import { Client } from '@quonfig/node';
|
|
4
4
|
import type { ProjectEnvId } from '@quonfig/node';
|
|
5
5
|
import { JsonObj, Result } from './result.js';
|
|
6
|
+
/**
|
|
7
|
+
* The exit code oclif actually recorded on a thrown error.
|
|
8
|
+
*
|
|
9
|
+
* `Errors.CLIError` — what `this.error(msg, {exit: N})` throws — stores N on
|
|
10
|
+
* `oclif.exit` and has NO `exitCode` property at all. Reading `err.exitCode`
|
|
11
|
+
* therefore collapsed every non-default code (401 on the auth failures in
|
|
12
|
+
* `resolve-workspace.ts` / `get-client.ts`, for one) down to 1. Exported so the
|
|
13
|
+
* behaviour is unit-testable. See qfg-hzmb.
|
|
14
|
+
*/
|
|
15
|
+
export declare function errorExitCode(err: unknown): number;
|
|
16
|
+
/**
|
|
17
|
+
* The code to actually terminate the process with.
|
|
18
|
+
*
|
|
19
|
+
* A POSIX exit status is one byte. Several call sites pass an HTTP-flavoured
|
|
20
|
+
* code — `{exit: 401}` for the auth failures in `get-client.ts` and
|
|
21
|
+
* `resolve-workspace.ts` — and `process.exit(401)` truncates to 145, which a
|
|
22
|
+
* shell reads as "killed by signal 17". Anything outside 1-255 therefore
|
|
23
|
+
* terminates with the plain failure code; the caller's real intent is still
|
|
24
|
+
* reported verbatim as `error.exitCode` in the `--json` envelope.
|
|
25
|
+
*/
|
|
26
|
+
export declare function processExitCode(err: unknown): number;
|
|
6
27
|
export declare abstract class BaseCommand extends Command {
|
|
7
28
|
static baseFlags: {
|
|
8
29
|
interactive: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
@@ -20,6 +41,15 @@ export declare abstract class BaseCommand extends Command {
|
|
|
20
41
|
code?: string;
|
|
21
42
|
} & Error): Promise<void>;
|
|
22
43
|
init(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* oclif's default is `{error: err}`, but `message`, `code` and `stack` are
|
|
46
|
+
* non-enumerable on an Error, so `JSON.stringify` flattens a thrown Error all
|
|
47
|
+
* the way down to `{}`. Re-attach the useful fields explicitly and keep the
|
|
48
|
+
* caller's payload alongside them. See qfg-hzmb.
|
|
49
|
+
*/
|
|
50
|
+
protected toErrorJson(err: unknown): {
|
|
51
|
+
error: JsonObj;
|
|
52
|
+
};
|
|
23
53
|
}
|
|
24
54
|
export declare abstract class APICommand extends BaseCommand {
|
|
25
55
|
static baseFlags: {
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { run } from '@oclif/core';
|
|
2
|
-
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { Command, Errors, Flags } from '@oclif/core';
|
|
3
3
|
import rawGetClient, { unwrapRequest } from './util/get-client.js';
|
|
4
4
|
const globalFlags = {
|
|
5
5
|
interactive: Flags.boolean({
|
|
@@ -20,19 +20,71 @@ const globalFlags = {
|
|
|
20
20
|
helpGroup: 'GLOBAL',
|
|
21
21
|
}),
|
|
22
22
|
};
|
|
23
|
+
const DEFAULT_EXIT_CODE = 1;
|
|
24
|
+
/**
|
|
25
|
+
* The exit code oclif actually recorded on a thrown error.
|
|
26
|
+
*
|
|
27
|
+
* `Errors.CLIError` — what `this.error(msg, {exit: N})` throws — stores N on
|
|
28
|
+
* `oclif.exit` and has NO `exitCode` property at all. Reading `err.exitCode`
|
|
29
|
+
* therefore collapsed every non-default code (401 on the auth failures in
|
|
30
|
+
* `resolve-workspace.ts` / `get-client.ts`, for one) down to 1. Exported so the
|
|
31
|
+
* behaviour is unit-testable. See qfg-hzmb.
|
|
32
|
+
*/
|
|
33
|
+
export function errorExitCode(err) {
|
|
34
|
+
const candidate = err;
|
|
35
|
+
if (typeof candidate?.oclif?.exit === 'number')
|
|
36
|
+
return candidate.oclif.exit;
|
|
37
|
+
if (typeof candidate?.exitCode === 'number')
|
|
38
|
+
return candidate.exitCode;
|
|
39
|
+
return DEFAULT_EXIT_CODE;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The code to actually terminate the process with.
|
|
43
|
+
*
|
|
44
|
+
* A POSIX exit status is one byte. Several call sites pass an HTTP-flavoured
|
|
45
|
+
* code — `{exit: 401}` for the auth failures in `get-client.ts` and
|
|
46
|
+
* `resolve-workspace.ts` — and `process.exit(401)` truncates to 145, which a
|
|
47
|
+
* shell reads as "killed by signal 17". Anything outside 1-255 therefore
|
|
48
|
+
* terminates with the plain failure code; the caller's real intent is still
|
|
49
|
+
* reported verbatim as `error.exitCode` in the `--json` envelope.
|
|
50
|
+
*/
|
|
51
|
+
export function processExitCode(err) {
|
|
52
|
+
const intended = errorExitCode(err);
|
|
53
|
+
return Number.isInteger(intended) && intended >= 1 && intended <= 255 ? intended : DEFAULT_EXIT_CODE;
|
|
54
|
+
}
|
|
55
|
+
/** Best available human-readable message for anything that can be thrown or passed to `err()`. */
|
|
56
|
+
function errorMessage(error) {
|
|
57
|
+
if (typeof error === 'string')
|
|
58
|
+
return error;
|
|
59
|
+
if (error instanceof Error)
|
|
60
|
+
return error.message;
|
|
61
|
+
if (typeof error !== 'object' || error === null)
|
|
62
|
+
return String(error);
|
|
63
|
+
const record = error;
|
|
64
|
+
if (typeof record.message === 'string')
|
|
65
|
+
return record.message;
|
|
66
|
+
if (typeof record.error === 'string')
|
|
67
|
+
return record.error;
|
|
68
|
+
return JSON.stringify(error);
|
|
69
|
+
}
|
|
23
70
|
export class BaseCommand extends Command {
|
|
24
71
|
static baseFlags = {
|
|
25
72
|
...globalFlags,
|
|
26
73
|
};
|
|
27
74
|
static enableJsonFlag = true;
|
|
28
75
|
err = (error, json) => {
|
|
76
|
+
const message = errorMessage(error);
|
|
29
77
|
if (this.jsonEnabled()) {
|
|
30
|
-
throw
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
78
|
+
// qfg-hzmb: throw a real CLIError instead of a bare object, so the message
|
|
79
|
+
// and the exit code survive to catch() and reach stdout. The caller's
|
|
80
|
+
// structured payload is copied onto it, keeping the older contract that
|
|
81
|
+
// callers (and tests) read fields such as `missingEnvVar` straight off the
|
|
82
|
+
// thrown value.
|
|
83
|
+
const cliError = new Errors.CLIError(message, { code: 'ERR', exit: DEFAULT_EXIT_CODE });
|
|
84
|
+
Object.assign(cliError, json ?? (typeof error === 'object' && error !== null ? error : {}));
|
|
85
|
+
throw cliError;
|
|
34
86
|
}
|
|
35
|
-
this.error(
|
|
87
|
+
return this.error(message, { code: 'ERR', exit: DEFAULT_EXIT_CODE });
|
|
36
88
|
};
|
|
37
89
|
isVerbose;
|
|
38
90
|
ok = (message, json) => {
|
|
@@ -67,19 +119,64 @@ export class BaseCommand extends Command {
|
|
|
67
119
|
async catch(err) {
|
|
68
120
|
// Override oclif's default error handling to suppress stack traces in production
|
|
69
121
|
// but preserve error messages for tests
|
|
122
|
+
// `this.exit(n)` throws an ExitError: a control-flow signal, not a failure to
|
|
123
|
+
// report. `qfg verify --json` and `qfg migrate doctor --json` deliberately
|
|
124
|
+
// print their payload and *then* exit non-zero (qfg-ez47), so reporting the
|
|
125
|
+
// ExitError here would append a second JSON document to stdout. Hand it
|
|
126
|
+
// straight to oclif, which prints nothing and honours `oclif.exit`.
|
|
127
|
+
if (err instanceof Errors.ExitError || err.code === 'EEXIT') {
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
// qfg-hzmb: `this.log()` is a documented no-op whenever jsonEnabled(), so
|
|
131
|
+
// this override used to print NOTHING for a failing `--json` run — empty
|
|
132
|
+
// stdout was indistinguishable from an empty success. Emit the structured
|
|
133
|
+
// envelope on stdout, exactly as oclif's own catch() does.
|
|
134
|
+
if (this.jsonEnabled()) {
|
|
135
|
+
this.logJson(this.toErrorJson(err));
|
|
136
|
+
}
|
|
70
137
|
// In test environment, just re-throw the error
|
|
71
138
|
if (process.env.NODE_ENV === 'test') {
|
|
72
139
|
throw err;
|
|
73
140
|
}
|
|
74
141
|
// In production, log the error message without stack trace
|
|
75
|
-
this.
|
|
76
|
-
|
|
142
|
+
if (!this.jsonEnabled()) {
|
|
143
|
+
this.log(err.message);
|
|
144
|
+
}
|
|
145
|
+
this.exit(processExitCode(err));
|
|
77
146
|
}
|
|
78
147
|
async init() {
|
|
79
148
|
await super.init();
|
|
80
149
|
const { flags } = await this.parse();
|
|
81
150
|
this.isVerbose = flags.verbose;
|
|
82
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* oclif's default is `{error: err}`, but `message`, `code` and `stack` are
|
|
154
|
+
* non-enumerable on an Error, so `JSON.stringify` flattens a thrown Error all
|
|
155
|
+
* the way down to `{}`. Re-attach the useful fields explicitly and keep the
|
|
156
|
+
* caller's payload alongside them. See qfg-hzmb.
|
|
157
|
+
*/
|
|
158
|
+
toErrorJson(err) {
|
|
159
|
+
if (typeof err !== 'object' || err === null) {
|
|
160
|
+
return { error: { code: 'ERR', exitCode: errorExitCode(err), message: errorMessage(err) } };
|
|
161
|
+
}
|
|
162
|
+
// An Error's own *enumerable* properties are the structured payload handed
|
|
163
|
+
// to `err()` — keep those. `oclif` is internal bookkeeping (surfaced as
|
|
164
|
+
// `exitCode`), and `stack` is dropped on purpose (oclif.stacktrace=false).
|
|
165
|
+
const payload = {};
|
|
166
|
+
for (const [key, value] of Object.entries(err)) {
|
|
167
|
+
if (key !== 'oclif' && key !== 'stack')
|
|
168
|
+
payload[key] = value;
|
|
169
|
+
}
|
|
170
|
+
const { code } = err;
|
|
171
|
+
return {
|
|
172
|
+
error: {
|
|
173
|
+
...payload,
|
|
174
|
+
code: typeof code === 'string' ? code : 'ERR',
|
|
175
|
+
exitCode: errorExitCode(err),
|
|
176
|
+
message: errorMessage(err),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
83
180
|
}
|
|
84
181
|
export class APICommand extends BaseCommand {
|
|
85
182
|
static baseFlags = {
|
package/dist/init/templates.js
CHANGED
|
@@ -195,6 +195,49 @@ This is a Quonfig workspace repository. See \`CLAUDE.md\` for the complete schem
|
|
|
195
195
|
4. Filename must match the \`key\` field (e.g. \`my.flag.json\` has \`"key": "my.flag"\`).
|
|
196
196
|
5. Files must be in the correct directory for their \`type\`.
|
|
197
197
|
6. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
|
|
198
|
+
|
|
199
|
+
## \`qfg\` or the Quonfig MCP?
|
|
200
|
+
|
|
201
|
+
Only relevant if this workspace is *also* hosted on a Quonfig account. On the
|
|
202
|
+
fully-local path, editing the JSON in this repo is the whole story.
|
|
203
|
+
|
|
204
|
+
- **This repo is checked out and you have a shell** -> use \`qfg\`. The files are
|
|
205
|
+
already on disk, so reading them costs nothing.
|
|
206
|
+
- **Slack, claude.ai, or anywhere without a checkout** -> use the Quonfig MCP
|
|
207
|
+
server. It reads and writes the hosted workspace directly. Do not try to shell
|
|
208
|
+
out to \`qfg\` from a surface that has no repo.
|
|
209
|
+
|
|
210
|
+
Both surfaces cover the same everyday loop — create a flag or config, change what
|
|
211
|
+
an environment serves, set a log level. (\`qfg\` has all of it today; the MCP's
|
|
212
|
+
create/config/log-level verbs land with its v2 write surface.)
|
|
213
|
+
|
|
214
|
+
## Setting a value KEEPS that environment's targeting rules
|
|
215
|
+
|
|
216
|
+
Both surfaces write the same way, so learn it once:
|
|
217
|
+
|
|
218
|
+
\`qfg set-default\` (alias \`toggle\`), \`qfg set-rollout\`, and the MCP
|
|
219
|
+
\`set_flag\`/\`set_config\` verbs replace exactly ONE rule — the environment's
|
|
220
|
+
**fallback**, the unconditional rule at the end of its rule list that decides
|
|
221
|
+
what users receive when no targeting rule matches. Every targeting rule above
|
|
222
|
+
it is kept, and both surfaces report how many. An environment with no rules of
|
|
223
|
+
its own has them copied from default first, so inherited targeting is kept too.
|
|
224
|
+
|
|
225
|
+
| Surface | By default | To set the value for EVERYONE |
|
|
226
|
+
|---------|------------|-------------------------------|
|
|
227
|
+
| \`qfg set-default\` / \`qfg set-rollout\` | Keeps targeting; reports "Kept N targeting rule(s)" | add \`--replace-targeting\` |
|
|
228
|
+
| MCP \`set_flag\` / \`set_config\` | Keeps targeting; reports the kept count in its result | send \`replaceTargeting: true\` |
|
|
229
|
+
|
|
230
|
+
Setting the value for everyone DELETES that environment's targeting rules. They
|
|
231
|
+
stay in git history, and the write returns a \`previousCommitSha\` to restore from.
|
|
232
|
+
|
|
233
|
+
**Still read before you write.** \`qfg info <key>\` (or MCP \`get_flag\`) shows the
|
|
234
|
+
rules your value is about to sit next to. For anything a fallback cannot express —
|
|
235
|
+
multi-rule targeting, reordering, editing one targeting rule — edit the JSON
|
|
236
|
+
directly or use MCP \`set_document\`.
|
|
237
|
+
|
|
238
|
+
\`qfg log-level --target ...\` and MCP \`set_log_level\` are **surgical** in the same
|
|
239
|
+
way: they upsert the level for the loggers you name and leave every other rule in
|
|
240
|
+
place.
|
|
198
241
|
`;
|
|
199
242
|
}
|
|
200
243
|
// ── Pre-commit hook ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule helpers shared by the CLI's writers (`set-default`, `set-rollout`,
|
|
3
|
+
* `log-level`) and readers (`info`, `cleanup status`) — beads qfg-qjdm and
|
|
4
|
+
* qfg-9kr8.
|
|
5
|
+
*
|
|
6
|
+
* The CLI cannot import app-quonfig code, so this file is a deliberate,
|
|
7
|
+
* small mirror of the server's rulebook. Keep the semantics byte-identical
|
|
8
|
+
* with:
|
|
9
|
+
* - app-quonfig src/lib/domain/rule-display.ts → isCatchAllRule
|
|
10
|
+
* - app-quonfig src/lib/public-api/write-scope.ts → countTargetingRules
|
|
11
|
+
* - app-quonfig src/lib/public-api/log-level-write.ts → upsertFallbackRule
|
|
12
|
+
* Both surfaces write the same documents; a second, subtly different
|
|
13
|
+
* rulebook is a correctness hazard, not a style problem.
|
|
14
|
+
*/
|
|
15
|
+
export type Criterion = {
|
|
16
|
+
operator?: unknown;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type Rule = {
|
|
20
|
+
criteria?: Criterion[];
|
|
21
|
+
value?: unknown;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
export type EnvRules = {
|
|
25
|
+
id: string;
|
|
26
|
+
rules?: Rule[];
|
|
27
|
+
};
|
|
28
|
+
export type ScopedDocument = {
|
|
29
|
+
default?: {
|
|
30
|
+
rules?: Rule[];
|
|
31
|
+
};
|
|
32
|
+
environments?: EnvRules[];
|
|
33
|
+
};
|
|
34
|
+
/** A fresh unconditional rule carrying `value`. */
|
|
35
|
+
export declare function catchAllRule(value: unknown): Rule;
|
|
36
|
+
/**
|
|
37
|
+
* THE ONE DEFINITION of "is this rule unconditional" (qfg-gv54).
|
|
38
|
+
*
|
|
39
|
+
* A catch-all — the base-case rule that serves everyone in its scope — has
|
|
40
|
+
* two live JSON spellings, and every SDK evaluator treats them identically:
|
|
41
|
+
*
|
|
42
|
+
* {criteria: [{operator: 'ALWAYS_TRUE'}]} ← UI, API, MCP, `qfg create`
|
|
43
|
+
* {criteria: []} ← older `qfg set-default` writes
|
|
44
|
+
*
|
|
45
|
+
* Both are in production data and neither is being backfilled (Jeff,
|
|
46
|
+
* 2026-09-02), so every READER accepts both. Criteria are ANDed, so one
|
|
47
|
+
* conditional criterion makes the whole rule conditional.
|
|
48
|
+
*
|
|
49
|
+
* Fails CLOSED: anything unreadable as a rule (no `criteria` key, not an
|
|
50
|
+
* object) is NOT a catch-all — the same choice app-quonfig's
|
|
51
|
+
* `isCatchAllRuleUnknown` makes for raw parsed JSON. On the write path that
|
|
52
|
+
* means a malformed rule is left alone and a fresh fallback is appended,
|
|
53
|
+
* rather than a malformed rule being silently overwritten.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isCatchAllRule(rule: unknown): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* How many real TARGETING rules a scope holds.
|
|
58
|
+
*
|
|
59
|
+
* A targeted scope is conventionally `[…targeting rules, catch-all]`: the
|
|
60
|
+
* trailing unconditional rule is the scope's fallback, not a rule that
|
|
61
|
+
* targets anyone, so it is discounted. ONLY a trailing catch-all is — an
|
|
62
|
+
* unconditional rule anywhere earlier shadows every rule after it, which is
|
|
63
|
+
* a targeting decision in its own right and stays counted.
|
|
64
|
+
*/
|
|
65
|
+
export declare function countTargetingRules(rules: Rule[]): number;
|
|
66
|
+
/**
|
|
67
|
+
* Set the scope's fallback value WITHOUT disturbing its targeting rules:
|
|
68
|
+
* the first catch-all's value is replaced in place (criteria untouched — an
|
|
69
|
+
* existing bare `criteria: []` fallback keeps its spelling, exactly as the
|
|
70
|
+
* server does), and if the scope has no catch-all at all a fresh
|
|
71
|
+
* ALWAYS_TRUE rule is appended at the end. Never mutates `rules`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function upsertFallbackRule(rules: Rule[], value: unknown): Rule[];
|
|
74
|
+
/**
|
|
75
|
+
* The rules a write should start from for one scope.
|
|
76
|
+
*
|
|
77
|
+
* `envName` undefined (or empty) means the document's `default` block, which
|
|
78
|
+
* always exists and is never seeded. A named environment with no block of
|
|
79
|
+
* its own inherits `default.rules` at evaluation time, so writing it starts
|
|
80
|
+
* from a DEEP CLONE of those rules — the same shape the UI produces when an
|
|
81
|
+
* environment stops inheriting. Starting from `[]` instead would silently
|
|
82
|
+
* drop the inherited targeting for that environment.
|
|
83
|
+
*
|
|
84
|
+
* `seeded` is true only when rules were actually copied, so callers do not
|
|
85
|
+
* report a copy that moved nothing.
|
|
86
|
+
*/
|
|
87
|
+
export declare function seedScopeRules(config: ScopedDocument, envName?: string): {
|
|
88
|
+
rules: Rule[];
|
|
89
|
+
seeded: boolean;
|
|
90
|
+
};
|
|
91
|
+
/** The environments array with `envName`'s rules replaced, appending the entry when it has none. */
|
|
92
|
+
export declare function upsertEnvRules(envs: EnvRules[], envName: string, rules: Rule[]): EnvRules[];
|
|
93
|
+
/** '<env> had no rules of its own; copied them from default.' */
|
|
94
|
+
export declare function seededFromDefaultNote(envName: string): string;
|
|
95
|
+
/** What a surgical write kept. Empty when the scope held no targeting rules. */
|
|
96
|
+
export declare function keptTargetingNote(count: number): string;
|
|
97
|
+
/** What a --replace-targeting write destroyed, and where to find it again. */
|
|
98
|
+
export declare function replacedTargetingNote(count: number, previousCommitSha: string): string;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule helpers shared by the CLI's writers (`set-default`, `set-rollout`,
|
|
3
|
+
* `log-level`) and readers (`info`, `cleanup status`) — beads qfg-qjdm and
|
|
4
|
+
* qfg-9kr8.
|
|
5
|
+
*
|
|
6
|
+
* The CLI cannot import app-quonfig code, so this file is a deliberate,
|
|
7
|
+
* small mirror of the server's rulebook. Keep the semantics byte-identical
|
|
8
|
+
* with:
|
|
9
|
+
* - app-quonfig src/lib/domain/rule-display.ts → isCatchAllRule
|
|
10
|
+
* - app-quonfig src/lib/public-api/write-scope.ts → countTargetingRules
|
|
11
|
+
* - app-quonfig src/lib/public-api/log-level-write.ts → upsertFallbackRule
|
|
12
|
+
* Both surfaces write the same documents; a second, subtly different
|
|
13
|
+
* rulebook is a correctness hazard, not a style problem.
|
|
14
|
+
*/
|
|
15
|
+
/** A fresh unconditional rule carrying `value`. */
|
|
16
|
+
export function catchAllRule(value) {
|
|
17
|
+
return { criteria: [{ operator: 'ALWAYS_TRUE' }], value };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* THE ONE DEFINITION of "is this rule unconditional" (qfg-gv54).
|
|
21
|
+
*
|
|
22
|
+
* A catch-all — the base-case rule that serves everyone in its scope — has
|
|
23
|
+
* two live JSON spellings, and every SDK evaluator treats them identically:
|
|
24
|
+
*
|
|
25
|
+
* {criteria: [{operator: 'ALWAYS_TRUE'}]} ← UI, API, MCP, `qfg create`
|
|
26
|
+
* {criteria: []} ← older `qfg set-default` writes
|
|
27
|
+
*
|
|
28
|
+
* Both are in production data and neither is being backfilled (Jeff,
|
|
29
|
+
* 2026-09-02), so every READER accepts both. Criteria are ANDed, so one
|
|
30
|
+
* conditional criterion makes the whole rule conditional.
|
|
31
|
+
*
|
|
32
|
+
* Fails CLOSED: anything unreadable as a rule (no `criteria` key, not an
|
|
33
|
+
* object) is NOT a catch-all — the same choice app-quonfig's
|
|
34
|
+
* `isCatchAllRuleUnknown` makes for raw parsed JSON. On the write path that
|
|
35
|
+
* means a malformed rule is left alone and a fresh fallback is appended,
|
|
36
|
+
* rather than a malformed rule being silently overwritten.
|
|
37
|
+
*/
|
|
38
|
+
export function isCatchAllRule(rule) {
|
|
39
|
+
if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
|
|
40
|
+
return false;
|
|
41
|
+
const { criteria } = rule;
|
|
42
|
+
if (!Array.isArray(criteria))
|
|
43
|
+
return false;
|
|
44
|
+
// An empty list satisfies `every` vacuously — that IS the `criteria: []`
|
|
45
|
+
// spelling — so this one expression covers both shapes.
|
|
46
|
+
return criteria.every((criterion) => typeof criterion === 'object' &&
|
|
47
|
+
criterion !== null &&
|
|
48
|
+
criterion.operator === 'ALWAYS_TRUE');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* How many real TARGETING rules a scope holds.
|
|
52
|
+
*
|
|
53
|
+
* A targeted scope is conventionally `[…targeting rules, catch-all]`: the
|
|
54
|
+
* trailing unconditional rule is the scope's fallback, not a rule that
|
|
55
|
+
* targets anyone, so it is discounted. ONLY a trailing catch-all is — an
|
|
56
|
+
* unconditional rule anywhere earlier shadows every rule after it, which is
|
|
57
|
+
* a targeting decision in its own right and stays counted.
|
|
58
|
+
*/
|
|
59
|
+
export function countTargetingRules(rules) {
|
|
60
|
+
const last = rules.at(-1);
|
|
61
|
+
return last !== undefined && isCatchAllRule(last) ? rules.length - 1 : rules.length;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Set the scope's fallback value WITHOUT disturbing its targeting rules:
|
|
65
|
+
* the first catch-all's value is replaced in place (criteria untouched — an
|
|
66
|
+
* existing bare `criteria: []` fallback keeps its spelling, exactly as the
|
|
67
|
+
* server does), and if the scope has no catch-all at all a fresh
|
|
68
|
+
* ALWAYS_TRUE rule is appended at the end. Never mutates `rules`.
|
|
69
|
+
*/
|
|
70
|
+
export function upsertFallbackRule(rules, value) {
|
|
71
|
+
const index = rules.findIndex((rule) => isCatchAllRule(rule));
|
|
72
|
+
if (index === -1)
|
|
73
|
+
return [...rules, catchAllRule(value)];
|
|
74
|
+
const next = [...rules];
|
|
75
|
+
next[index] = { ...next[index], value };
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The rules a write should start from for one scope.
|
|
80
|
+
*
|
|
81
|
+
* `envName` undefined (or empty) means the document's `default` block, which
|
|
82
|
+
* always exists and is never seeded. A named environment with no block of
|
|
83
|
+
* its own inherits `default.rules` at evaluation time, so writing it starts
|
|
84
|
+
* from a DEEP CLONE of those rules — the same shape the UI produces when an
|
|
85
|
+
* environment stops inheriting. Starting from `[]` instead would silently
|
|
86
|
+
* drop the inherited targeting for that environment.
|
|
87
|
+
*
|
|
88
|
+
* `seeded` is true only when rules were actually copied, so callers do not
|
|
89
|
+
* report a copy that moved nothing.
|
|
90
|
+
*/
|
|
91
|
+
export function seedScopeRules(config, envName) {
|
|
92
|
+
const defaultRules = config.default?.rules ?? [];
|
|
93
|
+
if (!envName)
|
|
94
|
+
return { rules: defaultRules, seeded: false };
|
|
95
|
+
const env = (config.environments ?? []).find((e) => e.id === envName);
|
|
96
|
+
if (env)
|
|
97
|
+
return { rules: env.rules ?? [], seeded: false };
|
|
98
|
+
const cloned = structuredClone(defaultRules);
|
|
99
|
+
return { rules: cloned, seeded: cloned.length > 0 };
|
|
100
|
+
}
|
|
101
|
+
/** The environments array with `envName`'s rules replaced, appending the entry when it has none. */
|
|
102
|
+
export function upsertEnvRules(envs, envName, rules) {
|
|
103
|
+
const index = envs.findIndex((e) => e.id === envName);
|
|
104
|
+
if (index === -1)
|
|
105
|
+
return [...envs, { id: envName, rules }];
|
|
106
|
+
const next = [...envs];
|
|
107
|
+
next[index] = { ...next[index], rules };
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
// ── Reporting ────────────────────────────────────────────────────────
|
|
111
|
+
//
|
|
112
|
+
// One wording, used by every verb that writes a fallback, so `set-default`,
|
|
113
|
+
// `set-rollout` and the public API cannot drift apart in what they claim to
|
|
114
|
+
// have done.
|
|
115
|
+
/** '<env> had no rules of its own; copied them from default.' */
|
|
116
|
+
export function seededFromDefaultNote(envName) {
|
|
117
|
+
return `${envName} had no rules of its own; copied them from default.`;
|
|
118
|
+
}
|
|
119
|
+
/** What a surgical write kept. Empty when the scope held no targeting rules. */
|
|
120
|
+
export function keptTargetingNote(count) {
|
|
121
|
+
if (count === 0)
|
|
122
|
+
return '';
|
|
123
|
+
return (`Kept ${count} targeting rule(s); matched users still receive their targeted value. ` +
|
|
124
|
+
`To set for everyone, add --replace-targeting.`);
|
|
125
|
+
}
|
|
126
|
+
/** What a --replace-targeting write destroyed, and where to find it again. */
|
|
127
|
+
export function replacedTargetingNote(count, previousCommitSha) {
|
|
128
|
+
if (count === 0)
|
|
129
|
+
return `Previous version ${previousCommitSha}.`;
|
|
130
|
+
return `Replaced ${count} targeting rule(s); previous version ${previousCommitSha}.`;
|
|
131
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.0.
|
|
1
|
+
declare const _default: "0.0.74";
|
|
2
2
|
export default _default;
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
|
|
2
|
-
export default '0.0.
|
|
2
|
+
export default '0.0.74';
|
package/oclif.manifest.json
CHANGED
|
@@ -1600,7 +1600,7 @@
|
|
|
1600
1600
|
"type": "boolean"
|
|
1601
1601
|
},
|
|
1602
1602
|
"env": {
|
|
1603
|
-
"description": "environment to operate in (
|
|
1603
|
+
"description": "environment to operate in (required; QUONFIG_ENVIRONMENT is never read)",
|
|
1604
1604
|
"name": "env",
|
|
1605
1605
|
"hasDynamicHelp": false,
|
|
1606
1606
|
"multiple": false,
|
|
@@ -2176,7 +2176,8 @@
|
|
|
2176
2176
|
"type": "option"
|
|
2177
2177
|
},
|
|
2178
2178
|
"environment": {
|
|
2179
|
-
"description": "Which environment slug to evaluate.
|
|
2179
|
+
"description": "Which environment slug to evaluate. Flag wins, then QUONFIG_ENVIRONMENT (serve hosts a service, so the var is its identity), then development.",
|
|
2180
|
+
"env": "QUONFIG_ENVIRONMENT",
|
|
2180
2181
|
"name": "environment",
|
|
2181
2182
|
"default": "development",
|
|
2182
2183
|
"hasDynamicHelp": false,
|
|
@@ -2254,11 +2255,11 @@
|
|
|
2254
2255
|
"name": "name"
|
|
2255
2256
|
}
|
|
2256
2257
|
},
|
|
2257
|
-
"description": "Set the
|
|
2258
|
+
"description": "Set the fallback value for a flag or config in one environment.\n\nThe fallback is the unconditional rule at the end of the environment's rule\nlist: what users receive when no targeting rule matches. Targeting rules and\npercentage rollouts above it are kept, and the command tells you how many.\nIf the environment has no rules of its own yet, they are copied from the\nflag's default rules first, so inherited targeting is kept too.\n\nTo turn a flag on or off for EVERYONE, including users matched by targeting\nrules, add --replace-targeting. This deletes the environment's targeting\nrules (they stay in git history).\n\nTo set a percentage rollout (gradual rollout / A/B test / canary deploy) instead:\n qfg set-rollout my.flag --environment production --true-percent 20\n\nTo see all current values and rules for a flag:\n qfg info my.flag",
|
|
2258
2259
|
"examples": [
|
|
2259
2260
|
"<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env",
|
|
2260
2261
|
"<%= config.bin %> <%= command.id %> my.flag.name --value=true --environment=staging",
|
|
2261
|
-
"<%= config.bin %> <%= command.id %> my.flag.name --value=false --environment=production # kill-switch:
|
|
2262
|
+
"<%= config.bin %> <%= command.id %> my.flag.name --value=false --environment=production --replace-targeting # kill-switch: off for everyone",
|
|
2262
2263
|
"<%= config.bin %> <%= command.id %> my.flag.name --value=true --secret",
|
|
2263
2264
|
"<%= config.bin %> <%= command.id %> my.config.name --env-var=MY_ENV_VAR_NAME --environment=production",
|
|
2264
2265
|
"# For a percentage rollout, use set-rollout instead:",
|
|
@@ -2337,6 +2338,12 @@
|
|
|
2337
2338
|
"multiple": false,
|
|
2338
2339
|
"type": "option"
|
|
2339
2340
|
},
|
|
2341
|
+
"replace-targeting": {
|
|
2342
|
+
"description": "set the value for EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
|
|
2343
|
+
"name": "replace-targeting",
|
|
2344
|
+
"allowNo": false,
|
|
2345
|
+
"type": "boolean"
|
|
2346
|
+
},
|
|
2340
2347
|
"value": {
|
|
2341
2348
|
"description": "new default value",
|
|
2342
2349
|
"name": "value",
|
|
@@ -2388,12 +2395,13 @@
|
|
|
2388
2395
|
"name": "name"
|
|
2389
2396
|
}
|
|
2390
2397
|
},
|
|
2391
|
-
"description": "Configure a percentage rollout (gradual rollout / A/B test / canary deploy) for a flag.\n\nThe flag must already exist. Create it first if needed:\n qfg create my.flag --type boolean-flag\n\nWeights are specified as whole-number percentages (0–100). They must sum to 100.\n\nFor a boolean flag, use --true-percent to set the percentage of users that receive\ntrue (the remainder automatically receive false):\n qfg set-rollout my.flag --environment production --true-percent 20\n → 20% of users get true, 80% get false\n\nFor multi-value flags or custom splits, use --weights with a comma-separated list:\n qfg set-rollout my.flag --environment production --weights \"red:33,green:33,blue:34\"\n\nHashing: users are bucketed by the value of hashByPropertyName (default: \"user.key\").\nUse --hash-by to change which property drives the bucket assignment:\n qfg set-rollout my.flag --environment production --true-percent 10 --hash-by user.id\n\nFor scripted / agent use, add --confirm to skip the interactive confirmation prompt.\n\nTo combine a rollout with segment targeting (e.g. beta ON, holdout OFF, everyone else 50/50),\nyou need to edit the JSON config file directly:\n qfg config-schema # operator reference + worked multi-rule example\n qfg pull --dir ./config # clone workspace to edit\n\nTo revert to a single value for everyone, use:\n qfg set-default my.flag --environment production --value false\n\nTo inspect the current rollout:\n qfg info my.flag",
|
|
2398
|
+
"description": "Configure a percentage rollout (gradual rollout / A/B test / canary deploy) for a flag.\n\nThe flag must already exist. Create it first if needed:\n qfg create my.flag --type boolean-flag\n\nWeights are specified as whole-number percentages (0–100). They must sum to 100.\n\nFor a boolean flag, use --true-percent to set the percentage of users that receive\ntrue (the remainder automatically receive false):\n qfg set-rollout my.flag --environment production --true-percent 20\n → 20% of users get true, 80% get false\n\nFor multi-value flags or custom splits, use --weights with a comma-separated list:\n qfg set-rollout my.flag --environment production --weights \"red:33,green:33,blue:34\"\n\nHashing: users are bucketed by the value of hashByPropertyName (default: \"user.key\").\nUse --hash-by to change which property drives the bucket assignment:\n qfg set-rollout my.flag --environment production --true-percent 10 --hash-by user.id\n\nThe rollout becomes the environment's fallback: the unconditional rule at the end\nof its rule list, what users receive when no targeting rule matches. Targeting\nrules above it are kept, and the command tells you how many. If the environment\nhas no rules of its own yet, they are copied from the flag's default rules first,\nso inherited targeting is kept too.\n\nTo roll out to EVERYONE, including users matched by targeting rules, add\n--replace-targeting. This deletes the environment's targeting rules (they stay in\ngit history).\n\nFor scripted / agent use, add --confirm to skip the interactive confirmation prompt.\n\nTo combine a rollout with segment targeting (e.g. beta ON, holdout OFF, everyone else 50/50),\nyou need to edit the JSON config file directly:\n qfg config-schema # operator reference + worked multi-rule example\n qfg pull --dir ./config # clone workspace to edit\n\nTo revert to a single value for everyone, use:\n qfg set-default my.flag --environment production --value false --replace-targeting\n\nTo inspect the current rollout:\n qfg info my.flag",
|
|
2392
2399
|
"examples": [
|
|
2393
2400
|
"<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 20",
|
|
2394
2401
|
"<%= config.bin %> <%= command.id %> my.feature.flag --environment staging --true-percent 50",
|
|
2395
2402
|
"<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 100 # full rollout",
|
|
2396
|
-
"
|
|
2403
|
+
"# For a kill switch — false for EVERYONE, including targeted users — use set-default:",
|
|
2404
|
+
"<%= config.bin %> set-default my.feature.flag --value=false --environment=production --replace-targeting",
|
|
2397
2405
|
"<%= config.bin %> <%= command.id %> my.variant.flag --environment production --weights \"control:50,treatment:50\"",
|
|
2398
2406
|
"<%= config.bin %> <%= command.id %> my.variant.flag --environment production --weights \"a:33,b:33,c:34\" --hash-by user.id"
|
|
2399
2407
|
],
|
|
@@ -2470,6 +2478,12 @@
|
|
|
2470
2478
|
"multiple": false,
|
|
2471
2479
|
"type": "option"
|
|
2472
2480
|
},
|
|
2481
|
+
"replace-targeting": {
|
|
2482
|
+
"description": "roll out to EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
|
|
2483
|
+
"name": "replace-targeting",
|
|
2484
|
+
"allowNo": false,
|
|
2485
|
+
"type": "boolean"
|
|
2486
|
+
},
|
|
2473
2487
|
"weights": {
|
|
2474
2488
|
"description": "comma-separated \"value:percent\" pairs that must sum to 100 (e.g. \"true:20,false:80\" or \"red:33,green:33,blue:34\")",
|
|
2475
2489
|
"name": "weights",
|
|
@@ -4136,5 +4150,5 @@
|
|
|
4136
4150
|
]
|
|
4137
4151
|
}
|
|
4138
4152
|
},
|
|
4139
|
-
"version": "0.0.
|
|
4153
|
+
"version": "0.0.74"
|
|
4140
4154
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
|
|
3
3
|
"name": "@quonfig/cli",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.74",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/quonfig/cli/issues"
|
|
7
7
|
},
|