@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/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 json ?? error;
31
- }
32
- if (typeof error === 'string') {
33
- return this.error(error, { code: 'ERR', exit: 1 });
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(this.toErrorJson(error), { code: 'ERR', exit: 1 });
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.log(err.message);
76
- this.exit(err.exitCode || 1);
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 = {
@@ -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.72";
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.72';
2
+ export default '0.0.74';
@@ -1600,7 +1600,7 @@
1600
1600
  "type": "boolean"
1601
1601
  },
1602
1602
  "env": {
1603
- "description": "environment to operate in (default: $QUONFIG_ENVIRONMENT)",
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. Honors QUONFIG_ENVIRONMENT.",
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 unconditional fallback value for a flag or config in a specific environment.\n\nThis updates the catch-all rule — the value users receive when NO targeting rule matches.\nAny targeting rules or percentage rollouts you have configured are NOT affected; they\ncontinue to fire before this fallback is evaluated.\n\n\"Other rules still apply\" means: if you have rules targeting specific users, segments,\nor properties, those rules still take priority. This command only changes what everyone\nelse sees.\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
+ "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: turn off for everyone",
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
- "<%= config.bin %> <%= command.id %> my.feature.flag --environment production --true-percent 0 # kill switch (all false)",
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.72"
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.72",
4
+ "version": "0.0.74",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },