@quonfig/cli 0.0.65 → 0.0.67

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.
@@ -1,4 +1,5 @@
1
1
  import { zodToJsonSchema } from './zod-to-json-schema.js';
2
+ import { resolveKey } from '../../key-rewriter.js';
2
3
  /**
3
4
  * Thrown by transformConfig when the source config is structurally invalid
4
5
  * (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
@@ -24,13 +25,29 @@ export function normalizeLogLevelKey(key) {
24
25
  return key.startsWith('log-level.') ? key : `log-level.${key}`;
25
26
  }
26
27
  /**
27
- * qfg-qhk1: Quonfig keys cannot contain `/` — keys are stored as flat dotted
28
- * filenames, and a `/` in a source key would create a nested directory tree
29
- * (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
30
- * destination is always flat.
28
+ * qfg-qhk1 + qfg-6na9.3: resolve keys through the per-run key rewriter so the
29
+ * destination is always flat AND 100% Policy-A conformant (charset + FS-floor,
30
+ * with collision disambiguation). Launch keys are dotted identifiers, so this is
31
+ * a no-op in the common case; an odd key still gets made safe, and the matching
32
+ * IN_SEG reference (resolveSegmentRef, below) resolves the same source string so
33
+ * a rewritten segment key never dangles its rules.
31
34
  */
32
35
  export function normalizeKey(key) {
33
- return key.replaceAll('/', '.');
36
+ return resolveKey(key);
37
+ }
38
+ /**
39
+ * qfg-6na9.3: an IN_SEG/NOT_IN_SEG criterion's `valueToMatch` holds a referenced
40
+ * segment KEY (not an arbitrary value) — resolve it through the same rewriter as
41
+ * the segment's own key definition so the two stay in sync.
42
+ */
43
+ function resolveSegmentRef(valueToMatch) {
44
+ if (!valueToMatch || typeof valueToMatch !== 'object')
45
+ return;
46
+ const v = valueToMatch;
47
+ if (typeof v.value === 'string')
48
+ v.value = resolveKey(v.value);
49
+ else if (Array.isArray(v.value))
50
+ v.value = v.value.map((x) => (typeof x === 'string' ? resolveKey(x) : x));
34
51
  }
35
52
  /**
36
53
  * qfg-l18w / qfg-0q1f: Operators Quonfig accepts in rule criteria. Mirrors
@@ -202,6 +219,9 @@ function normalizeRules(rules, configKey, path) {
202
219
  const c = crit;
203
220
  if (c.valueToMatch) {
204
221
  normalizeLaunchValue(c.valueToMatch, configKey, `${path}[${i}].criteria[${j}].valueToMatch`);
222
+ if (c.operator === 'IN_SEG' || c.operator === 'NOT_IN_SEG') {
223
+ resolveSegmentRef(c.valueToMatch);
224
+ }
205
225
  }
206
226
  }
207
227
  }
@@ -8,16 +8,19 @@
8
8
  * variants, per-environment state → `environments[]`, the gap dispositions of
9
9
  * plan §5.4.
10
10
  */
11
+ import { resolveKey } from '../../key-rewriter.js';
11
12
  import { clauseToCriterion, collapseEnvironment, } from '../../quonfig-target/ruleset.js';
12
13
  import { inferFlagValueType, toQuonfigValue } from '../../quonfig-target/values.js';
13
14
  const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
14
15
  /**
15
- * LaunchDarkly keys are already flat and URL-safe, but normalize `/` → `.`
16
- * defensively so a key can never create a nested directory tree (matches the
17
- * flat-path contract `local-write.ts` enforces).
16
+ * qfg-6na9.3: resolve keys through the per-run key rewriter for Policy A
17
+ * conformance. LD keys are already `[A-Za-z0-9._-]`, so this is a strict no-op
18
+ * in practice — wired for uniformity + future-proofing (an odd imported key
19
+ * would still be made safe, and stay in sync with any IN_SEG reference to it,
20
+ * which resolves through `ruleset.ts`).
18
21
  */
19
22
  function normalizeKey(key) {
20
- return key.replaceAll('/', '.');
23
+ return resolveKey(key);
21
24
  }
22
25
  /** Environment keys are LD slugs already; lowercase + hyphenate to be safe. */
23
26
  function slugifyEnvKey(key) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Policy A key rule, HARD-enforced at `qfg create` time client-side (qfg-6na9.2)
3
+ * so a bad key fails fast before the network round-trip. This is a deliberate,
4
+ * hand-kept mirror of app-quonfig `src/lib/domain/config-schemas.ts`
5
+ * `PolicyAKeySchema` — the two MUST move together (see
6
+ * project/plans/26-06-tighter-naming.md, "Decision: Policy A"):
7
+ *
8
+ * - charset `^[A-Za-z0-9._-]+$` (uppercase IS allowed; case-insensitive
9
+ * collision is a separate verify/create-handler check, not here),
10
+ * - 1..200 chars (ASCII-only, so char length == byte length),
11
+ * - not the reserved key `"new"`,
12
+ * - no leading dot (a `.beta.json` config is silently skipped by the loader
13
+ * and `qfg verify`).
14
+ *
15
+ * NOTE the layering: this is the create boundary (hard). The general charset is
16
+ * a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
17
+ * warn->error soak, and the FS-safety floor there is already hard (qfg-6na9.4).
18
+ * Keep the messages aligned with app-quonfig's for a consistent UX.
19
+ */
20
+ export declare const POLICY_A_KEY_RE: RegExp;
21
+ /**
22
+ * Returns a human-readable error message for a key that violates Policy A, or
23
+ * `null` if the key is valid. First failing rule wins (matches the order Zod
24
+ * evaluates the refinements in app-quonfig).
25
+ */
26
+ export declare function policyAKeyError(key: string): string | null;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Policy A key rule, HARD-enforced at `qfg create` time client-side (qfg-6na9.2)
3
+ * so a bad key fails fast before the network round-trip. This is a deliberate,
4
+ * hand-kept mirror of app-quonfig `src/lib/domain/config-schemas.ts`
5
+ * `PolicyAKeySchema` — the two MUST move together (see
6
+ * project/plans/26-06-tighter-naming.md, "Decision: Policy A"):
7
+ *
8
+ * - charset `^[A-Za-z0-9._-]+$` (uppercase IS allowed; case-insensitive
9
+ * collision is a separate verify/create-handler check, not here),
10
+ * - 1..200 chars (ASCII-only, so char length == byte length),
11
+ * - not the reserved key `"new"`,
12
+ * - no leading dot (a `.beta.json` config is silently skipped by the loader
13
+ * and `qfg verify`).
14
+ *
15
+ * NOTE the layering: this is the create boundary (hard). The general charset is
16
+ * a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
17
+ * warn->error soak, and the FS-safety floor there is already hard (qfg-6na9.4).
18
+ * Keep the messages aligned with app-quonfig's for a consistent UX.
19
+ */
20
+ // Kept as the explicit char class (not the `[\w.-]` the linter suggests) so it
21
+ // is a byte-for-byte mirror of app-quonfig's PolicyAKeySchema and greppable
22
+ // across both sources of truth — see the module doc above.
23
+ // eslint-disable-next-line unicorn/better-regex
24
+ export const POLICY_A_KEY_RE = /^[A-Za-z0-9._-]+$/;
25
+ /**
26
+ * Returns a human-readable error message for a key that violates Policy A, or
27
+ * `null` if the key is valid. First failing rule wins (matches the order Zod
28
+ * evaluates the refinements in app-quonfig).
29
+ */
30
+ export function policyAKeyError(key) {
31
+ if (key.length === 0)
32
+ return 'Key is required';
33
+ if (key.length > 200)
34
+ return 'Key must be 200 characters or fewer';
35
+ if (key === 'new')
36
+ return 'Key cannot be "new"';
37
+ if (!POLICY_A_KEY_RE.test(key))
38
+ return 'Key can only contain letters, numbers, dots, dashes, and underscores';
39
+ if (key.startsWith('.'))
40
+ return 'Key cannot start with a dot';
41
+ return null;
42
+ }
@@ -10,4 +10,16 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
- export {};
13
+ /**
14
+ * Read all JSON config files from a git commit using `git show`.
15
+ * Works in bare repos (no working tree needed).
16
+ *
17
+ * qfg-6na9.6: uses execFileSync (never a shell) and `ls-tree -z` (NUL-delimited,
18
+ * disables git's C-quoting of "unusual" paths). The old string-interpolated
19
+ * execSync + default ls-tree output silently SKIPPED any filename containing a
20
+ * space or non-ASCII char — exactly the Policy-A-violating keys the hook
21
+ * exists to catch (verified live on staging: a `configs/bad charset key.json`
22
+ * push was accepted unvalidated). A listed-but-unreadable file is now a hard
23
+ * failure (fail closed), not a silent skip.
24
+ */
25
+ export declare function readFilesFromCommit(commitOid: string, cwd?: string): Map<string, string>;
@@ -10,7 +10,7 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
- import { execSync } from 'node:child_process';
13
+ import { execFileSync } from 'node:child_process';
14
14
  import * as readline from 'node:readline';
15
15
  import { formatResult, validateFileMap, validateWorkspace } from './validate.js';
16
16
  async function main() {
@@ -91,42 +91,50 @@ async function runGitHook() {
91
91
  /**
92
92
  * Read all JSON config files from a git commit using `git show`.
93
93
  * Works in bare repos (no working tree needed).
94
+ *
95
+ * qfg-6na9.6: uses execFileSync (never a shell) and `ls-tree -z` (NUL-delimited,
96
+ * disables git's C-quoting of "unusual" paths). The old string-interpolated
97
+ * execSync + default ls-tree output silently SKIPPED any filename containing a
98
+ * space or non-ASCII char — exactly the Policy-A-violating keys the hook
99
+ * exists to catch (verified live on staging: a `configs/bad charset key.json`
100
+ * push was accepted unvalidated). A listed-but-unreadable file is now a hard
101
+ * failure (fail closed), not a silent skip.
94
102
  */
95
- function readFilesFromCommit(commitOid) {
103
+ export function readFilesFromCommit(commitOid, cwd) {
96
104
  const files = new Map();
97
105
  const dirs = ['configs', 'feature-flags', 'segments', 'log-levels', 'schemas', 'schemas-protected'];
98
106
  for (const dir of dirs) {
99
- // List files in this directory at the given commit
100
- let listing;
101
- try {
102
- listing = execSync(`git ls-tree --name-only ${commitOid} ${dir}/`, {
103
- encoding: 'utf8',
104
- stdio: ['pipe', 'pipe', 'pipe'],
105
- });
106
- }
107
- catch {
108
- // Directory doesn't exist in this commit
109
- continue;
110
- }
111
- const filenames = listing.trim().split('\n').filter(Boolean);
107
+ // List files in this directory at the given commit. A directory that
108
+ // doesn't exist yields an empty listing (exit 0); a bad/unreadable OID
109
+ // throws — fail closed, the hook rejects the push.
110
+ const listing = execFileSync('git', ['ls-tree', '-z', '--name-only', commitOid, `${dir}/`], {
111
+ cwd,
112
+ encoding: 'utf8',
113
+ stdio: ['pipe', 'pipe', 'pipe'],
114
+ });
115
+ const filenames = listing.split('\0').filter(Boolean);
112
116
  for (const filePath of filenames) {
113
117
  if (!filePath.endsWith('.json') || filePath.includes('/.'))
114
118
  continue;
115
- try {
116
- const content = execSync(`git show ${commitOid}:${filePath}`, {
117
- encoding: 'utf8',
118
- stdio: ['pipe', 'pipe', 'pipe'],
119
- });
120
- files.set(filePath, content);
121
- }
122
- catch {
123
- // File can't be read, skip
124
- }
119
+ // Fail closed: if a listed file can't be read, the push must not be
120
+ // accepted with that file unvalidated — let the error propagate to the
121
+ // hook's per-ref handler, which rejects the push.
122
+ const content = execFileSync('git', ['show', `${commitOid}:${filePath}`], {
123
+ cwd,
124
+ encoding: 'utf8',
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ });
127
+ files.set(filePath, content);
125
128
  }
126
129
  }
127
130
  return files;
128
131
  }
129
- main().catch((error) => {
130
- console.error(`qfg-verify: fatal: ${error.message}`);
131
- process.exit(1);
132
- });
132
+ // Only run as a program when compiled/executed as the entry point (Bun sets
133
+ // import.meta.main; under node test imports it is undefined) — this lets tests
134
+ // import readFilesFromCommit without triggering the CLI.
135
+ if (import.meta.main) {
136
+ main().catch((error) => {
137
+ console.error(`qfg-verify: fatal: ${error.message}`);
138
+ process.exit(1);
139
+ });
140
+ }
@@ -808,8 +808,9 @@ function validateEnvironmentIds(environments, file, issues) {
808
808
  // IMPORTANT: this floor must stay conceptually in lockstep with app-quonfig's
809
809
  // src/lib/domain/config-schemas.ts (the charset precedent there is
810
810
  // `SchemaKeySchema`; Policy A is `PolicyAKeySchema`). The general charset check
811
- // (`^[A-Za-z0-9._-]+$`) is deliberately NOT here — it ships separately as a
812
- // warning (bead qfg-6na9.5). This module is the FS-floor + dedup only.
811
+ // (`^[A-Za-z0-9._-]+$`) is deliberately NOT here — it lives in `validateKey`
812
+ // below (warning per qfg-6na9.5, hard error since qfg-6na9.6). This list is the
813
+ // FS-floor only.
813
814
  //
814
815
  // Structured as a table so severity is trivially adjustable later.
815
816
  const FS_SAFETY_FLOOR_CHECKS = [
@@ -827,6 +828,7 @@ const FS_SAFETY_FLOOR_CHECKS = [
827
828
  // pre-filtered by the directory walk before they'd reach the file-map path) can
828
829
  // be unit-tested directly.
829
830
  export function validateKey(key, file, issues) {
831
+ const errorsBefore = issues.length;
830
832
  if (key.length === 0) {
831
833
  issues.push({ file, message: `Key is empty`, severity: 'error' });
832
834
  }
@@ -847,6 +849,23 @@ export function validateKey(key, file, issues) {
847
849
  issues.push({ file, message: check.message, severity: 'error' });
848
850
  }
849
851
  }
852
+ // qfg-6na9.6: the general Policy A charset as a HARD ERROR. Shipped as a
853
+ // warning first (qfg-6na9.5), flipped after a full-corpus enumeration of all
854
+ // prod workspaces verified zero non-conforming keys (2026-07-03; see the bead
855
+ // and project/plans/26-06-tighter-naming.md). The charset `^[A-Za-z0-9._-]+$`
856
+ // is kept in sync with app-quonfig's PolicyAKeySchema (hard at create).
857
+ // Skipped when this key already earned an error above (e.g. a
858
+ // Windows-reserved char is both a floor error AND a charset miss) so we
859
+ // don't double-report the same key.
860
+ // eslint-disable-next-line unicorn/better-regex -- keep the explicit charset in sync with app-quonfig
861
+ if (issues.length === errorsBefore && !/^[A-Za-z0-9._-]+$/.test(key)) {
862
+ issues.push({
863
+ file,
864
+ message: `Key "${key}" has characters outside the allowed set (letters, numbers, ".", "-", "_").`,
865
+ severity: 'error',
866
+ suggestion: `Rename to use only letters, numbers, ".", "-", and "_"`,
867
+ });
868
+ }
850
869
  }
851
870
  // Case-insensitive duplicate-key detection (qfg-6na9.4,
852
871
  // project/plans/26-06-tighter-naming.md "Case-insensitive uniqueness").
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.0.65";
1
+ declare const _default: "0.0.67";
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.65';
2
+ export default '0.0.67';
@@ -1491,6 +1491,12 @@
1491
1491
  "allowNo": false,
1492
1492
  "type": "boolean"
1493
1493
  },
1494
+ "strict-keys": {
1495
+ "description": "Refuse to migrate if any source key would need rewriting to satisfy Quonfig key rules (Policy A), instead of rewriting it. Use when you require byte-identical keys.",
1496
+ "name": "strict-keys",
1497
+ "allowNo": false,
1498
+ "type": "boolean"
1499
+ },
1494
1500
  "workspace": {
1495
1501
  "description": "Quonfig cloud workspace slug to push to. Requires `qfg login` and is typically combined with --push.",
1496
1502
  "name": "workspace",
@@ -4126,5 +4132,5 @@
4126
4132
  ]
4127
4133
  }
4128
4134
  },
4129
- "version": "0.0.65"
4135
+ "version": "0.0.67"
4130
4136
  }
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.65",
4
+ "version": "0.0.67",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },
@@ -21,6 +21,7 @@
21
21
  "@oclif/core": "^3.18.2",
22
22
  "@oclif/plugin-help": "^6",
23
23
  "@oclif/plugin-plugins": "^4",
24
+ "@oclif/plugin-warn-if-update-available": "3.0.16",
24
25
  "@quonfig/node": "^1.0.0",
25
26
  "acorn": "^8.10.0",
26
27
  "acorn-walk": "^8.3.5",
@@ -77,7 +78,12 @@
77
78
  "bin": "qfg",
78
79
  "dirname": "qfg",
79
80
  "commands": "./dist/commands",
80
- "plugins": [],
81
+ "plugins": [
82
+ "@oclif/plugin-warn-if-update-available"
83
+ ],
84
+ "warn-if-update-available": {
85
+ "message": "<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>.\nRun <%= chalk.cyan('npm i -g ' + config.name) %> to upgrade."
86
+ },
81
87
  "topicSeparator": " ",
82
88
  "helpClass": "./dist/help",
83
89
  "stacktrace": false,