@quonfig/cli 0.0.69 → 0.0.70
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 +50 -50
- package/dist/codegen/code-generators/base-generator.d.ts +8 -1
- package/dist/codegen/code-generators/base-generator.js +3 -1
- package/dist/codegen/code-generators/base-typescript-generator.d.ts +34 -1
- package/dist/codegen/code-generators/base-typescript-generator.js +78 -2
- package/dist/codegen/code-generators/node-typescript-generator.js +7 -12
- package/dist/codegen/code-generators/react-typescript-generator.js +9 -12
- package/dist/commands/generate.js +4 -2
- package/dist/init/schema.js +11 -2
- package/dist/migrate/import-state.d.ts +3 -2
- package/dist/migrate/import-state.js +20 -7
- package/dist/migrate/key-rewriter.d.ts +70 -14
- package/dist/migrate/key-rewriter.js +186 -38
- package/dist/migrate/local-write.d.ts +10 -0
- package/dist/migrate/local-write.js +27 -1
- package/dist/migrate/push-to-cloud.js +24 -12
- package/dist/migrate/quonfig-target/ruleset.js +4 -1
- package/dist/migrate/source.d.ts +9 -0
- package/dist/migrate/sources/launch/translate.js +81 -1
- package/dist/migrate/sources/launchdarkly/translate.js +12 -2
- package/dist/migrate/sources/launchdarkly.js +15 -2
- package/dist/util/policy-a-key.d.ts +15 -1
- package/dist/util/policy-a-key.js +25 -1
- package/dist/verify/standalone.d.ts +7 -0
- package/dist/verify/standalone.js +15 -9
- package/dist/verify/validate.js +93 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
|
|
4
|
-
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
|
|
4
|
+
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, collectExistingWorkspaceKeys, writeQuonfigFiles, } from './local-write.js';
|
|
5
5
|
import { getFullKeyPlan, getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
|
|
6
6
|
import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
|
|
7
7
|
import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
|
|
@@ -60,10 +60,14 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
60
60
|
// not per-commit) so key-def and reference sites resolve identically, then
|
|
61
61
|
// enforce --strict-keys before any clone/commit/push. Mappings persisted by a
|
|
62
62
|
// previous run (.qf/key-plan.json in the reused clone) are authoritative so a
|
|
63
|
-
// delta run cannot replan a subset and land on different finals.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
63
|
+
// delta run cannot replan a subset and land on different finals. qfg-hbuy.12:
|
|
64
|
+
// keys already in the workspace seed the rewriter so a case-variant collision
|
|
65
|
+
// (incoming `Foo`, existing `foo`) renames instead of aborting, while a
|
|
66
|
+
// byte-equal match keeps the silent-overwrite re-migration path. (When the
|
|
67
|
+
// clone is fresh, both the plan and the existing keys only come down WITH the
|
|
68
|
+
// clone — the post-clone re-plan below repeats this once it can read them.)
|
|
66
69
|
preflightKeyRewrites(opts.changes, {
|
|
70
|
+
existingKeys: collectExistingWorkspaceKeys(opts.localDir),
|
|
67
71
|
persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
|
|
68
72
|
strict: opts.strictKeys,
|
|
69
73
|
});
|
|
@@ -79,8 +83,23 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
79
83
|
mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
|
|
80
84
|
}
|
|
81
85
|
};
|
|
86
|
+
// Re-plan once the clone exists: a FRESH clone of a live workspace delivers
|
|
87
|
+
// .qf/key-plan.json AND the existing key files only at this point (the
|
|
88
|
+
// pre-clone preflight above couldn't see either), and both must win before
|
|
89
|
+
// any file is written. The collapsed path runs this at the top of its single
|
|
90
|
+
// commit; audit-log mode prepends it as a no-op commit spec (its apply()
|
|
91
|
+
// stages nothing, so stackCommits silently skips the commit) so it runs
|
|
92
|
+
// BEFORE the first per-change commit writes files (qfg-hbuy.12).
|
|
93
|
+
const replanAgainstClone = (dir) => {
|
|
94
|
+
preflightKeyRewrites(opts.changes, {
|
|
95
|
+
existingKeys: collectExistingWorkspaceKeys(dir),
|
|
96
|
+
persistedKeys: readKeyPlan(dir) ?? undefined,
|
|
97
|
+
strict: opts.strictKeys,
|
|
98
|
+
});
|
|
99
|
+
};
|
|
82
100
|
const commits = opts.fullHistory
|
|
83
101
|
? [
|
|
102
|
+
{ apply: replanAgainstClone, message: 'migrator: replan keys against the cloned workspace (no-op)' },
|
|
84
103
|
...buildAuditPerChangeCommits(opts.changes, opts.source),
|
|
85
104
|
buildAuditFinalCommit({
|
|
86
105
|
changes: opts.changes,
|
|
@@ -97,14 +116,7 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
97
116
|
{
|
|
98
117
|
message: () => computedCommitMessage,
|
|
99
118
|
async apply(dir) {
|
|
100
|
-
|
|
101
|
-
// migrated workspace delivers .qf/key-plan.json only at this point
|
|
102
|
-
// (the pre-clone preflight above couldn't see it), and the
|
|
103
|
-
// persisted mappings must win before any file is written.
|
|
104
|
-
preflightKeyRewrites(opts.changes, {
|
|
105
|
-
persistedKeys: readKeyPlan(dir) ?? undefined,
|
|
106
|
-
strict: opts.strictKeys,
|
|
107
|
-
});
|
|
119
|
+
replanAgainstClone(dir);
|
|
108
120
|
mergeEnvsIfPresent(dir);
|
|
109
121
|
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
110
122
|
removeQfFromGitignore(dir);
|
|
@@ -53,7 +53,10 @@ function valueToMatchFor(operator, clause, ctx) {
|
|
|
53
53
|
// qfg-6na9.3: this value is a referenced segment KEY — resolve it through
|
|
54
54
|
// the per-run rewriter so a sanitized/disambiguated segment key stays in
|
|
55
55
|
// sync with the IN_SEG rules pointing at it (no dangling targeting).
|
|
56
|
-
|
|
56
|
+
// qfg-hbuy.10: resolve in the SEGMENT namespace, so a segment renamed to
|
|
57
|
+
// dodge a same-keyed flag keeps its references (falls back to the default
|
|
58
|
+
// namespace for non-colliding segments).
|
|
59
|
+
return { type: 'string', value: resolveKey(String(clause.values[0] ?? ''), 'segment') };
|
|
57
60
|
}
|
|
58
61
|
if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
|
|
59
62
|
if (clause.values.length > 1) {
|
package/dist/migrate/source.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { ConversionNote } from './quonfig-target/report.js';
|
|
|
2
2
|
export interface LegacyChange {
|
|
3
3
|
changedAt?: number;
|
|
4
4
|
key?: string;
|
|
5
|
+
/**
|
|
6
|
+
* qfg-hbuy.10: source-side key namespace. LaunchDarkly flags and segments
|
|
7
|
+
* are SEPARATE key namespaces, so a flag and a segment can legitimately
|
|
8
|
+
* share one source key. Sources that model segments as their own namespace
|
|
9
|
+
* tag segment changes `'segment'` so the key rewriter can disambiguate the
|
|
10
|
+
* collision (the flag keeps the clean name; the segment gets a
|
|
11
|
+
* deterministic `-segment` suffix). Absent = the default namespace.
|
|
12
|
+
*/
|
|
13
|
+
keyNamespace?: 'segment';
|
|
5
14
|
raw: unknown;
|
|
6
15
|
source: string;
|
|
7
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
|
|
2
2
|
import { zodToJsonSchema } from './zod-to-json-schema.js';
|
|
3
|
-
import { resolveKey } from '../../key-rewriter.js';
|
|
3
|
+
import { resolveKey, resolveMappedKey } from '../../key-rewriter.js';
|
|
4
4
|
/**
|
|
5
5
|
* Thrown by transformConfig when the source config is structurally invalid
|
|
6
6
|
* (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
|
|
@@ -50,6 +50,74 @@ function resolveSegmentRef(valueToMatch) {
|
|
|
50
50
|
else if (Array.isArray(v.value))
|
|
51
51
|
v.value = v.value.map((x) => (typeof x === 'string' ? resolveKey(x) : x));
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* qfg-hbuy.11: a value's `decryptWith` names the CONFIG KEY holding its
|
|
55
|
+
* AES-GCM key — a by-key reference, exactly like an IN_SEG segment ref. When
|
|
56
|
+
* the referenced config was renamed by the key rewriter the reference must
|
|
57
|
+
* follow, or the config can never decrypt. A key absent from the map points
|
|
58
|
+
* at a pre-existing workspace config the import does not own — left
|
|
59
|
+
* untouched. Recurses into weighted_values so encrypted variant splits are
|
|
60
|
+
* covered too.
|
|
61
|
+
*/
|
|
62
|
+
function rewriteValueKeyRefs(value) {
|
|
63
|
+
if (!value || typeof value !== 'object')
|
|
64
|
+
return;
|
|
65
|
+
const v = value;
|
|
66
|
+
if (typeof v.decryptWith === 'string') {
|
|
67
|
+
const mapped = resolveMappedKey(v.decryptWith);
|
|
68
|
+
if (mapped !== null)
|
|
69
|
+
v.decryptWith = mapped;
|
|
70
|
+
}
|
|
71
|
+
if (v.type === 'weighted_values') {
|
|
72
|
+
const inner = v.value;
|
|
73
|
+
let entries = [];
|
|
74
|
+
if (Array.isArray(inner)) {
|
|
75
|
+
entries = inner;
|
|
76
|
+
}
|
|
77
|
+
else if (inner && typeof inner === 'object') {
|
|
78
|
+
const wvs = inner.weightedValues;
|
|
79
|
+
if (Array.isArray(wvs))
|
|
80
|
+
entries = wvs;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry && typeof entry === 'object' && entry.value) {
|
|
84
|
+
rewriteValueKeyRefs(entry.value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* qfg-hbuy.11: walk every value position that can carry `decryptWith`
|
|
91
|
+
* (default rules, per-environment rules, variants) and rewrite the reference
|
|
92
|
+
* through the final key map. Mirrors normalizeJsonValuesInConfig's traversal.
|
|
93
|
+
*/
|
|
94
|
+
function rewriteKeyReferencesInConfig(out) {
|
|
95
|
+
const walkRules = (rules) => {
|
|
96
|
+
if (!Array.isArray(rules))
|
|
97
|
+
return;
|
|
98
|
+
for (const rule of rules) {
|
|
99
|
+
if (!rule || typeof rule !== 'object')
|
|
100
|
+
continue;
|
|
101
|
+
const r = rule;
|
|
102
|
+
if (r.value)
|
|
103
|
+
rewriteValueKeyRefs(r.value);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const defaultSection = out.default;
|
|
107
|
+
if (defaultSection)
|
|
108
|
+
walkRules(defaultSection.rules);
|
|
109
|
+
if (Array.isArray(out.environments)) {
|
|
110
|
+
for (const env of out.environments) {
|
|
111
|
+
walkRules(env.rules);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(out.variants)) {
|
|
115
|
+
for (const variant of out.variants) {
|
|
116
|
+
if (variant && variant.value)
|
|
117
|
+
rewriteValueKeyRefs(variant.value);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
53
121
|
/**
|
|
54
122
|
* qfg-l18w / qfg-0q1f: Operators Quonfig accepts in rule criteria. Mirrors
|
|
55
123
|
* the `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
|
|
@@ -328,6 +396,15 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
|
|
|
328
396
|
if (typeof out.key === 'string') {
|
|
329
397
|
out.key = normalizeKey(out.key);
|
|
330
398
|
}
|
|
399
|
+
// qfg-hbuy.11: `schemaKey` references ANOTHER config's key (the schema this
|
|
400
|
+
// config validates against). If that schema was renamed by the key rewriter
|
|
401
|
+
// the reference must follow; a schemaKey absent from the map references a
|
|
402
|
+
// pre-existing workspace schema and is left untouched.
|
|
403
|
+
if (typeof out.schemaKey === 'string') {
|
|
404
|
+
const mapped = resolveMappedKey(out.schemaKey);
|
|
405
|
+
if (mapped !== null)
|
|
406
|
+
out.schemaKey = mapped;
|
|
407
|
+
}
|
|
331
408
|
delete out.changedBy;
|
|
332
409
|
delete out.modifiedAt;
|
|
333
410
|
delete out.createdAt;
|
|
@@ -424,6 +501,9 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
|
|
|
424
501
|
}
|
|
425
502
|
}
|
|
426
503
|
normalizeJsonValuesInConfig(out);
|
|
504
|
+
// qfg-hbuy.11: rewrite `decryptWith` references AFTER json-normalization so
|
|
505
|
+
// the walker sees the final value shapes.
|
|
506
|
+
rewriteKeyReferencesInConfig(out);
|
|
427
507
|
normalizeWeightsInConfig(out, onNormalizedWeights);
|
|
428
508
|
validateOperators(out);
|
|
429
509
|
return out;
|
|
@@ -22,6 +22,16 @@ const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
|
22
22
|
function normalizeKey(key) {
|
|
23
23
|
return resolveKey(key);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* qfg-hbuy.10: LD segments are their OWN key namespace — a project can carry a
|
|
27
|
+
* flag and a segment both keyed `checkout`. Segment key definitions (and the
|
|
28
|
+
* IN_SEG references in `ruleset.ts`) resolve through the segment namespace so
|
|
29
|
+
* the collision case gets the deterministic `-segment` suffix while the flag
|
|
30
|
+
* keeps the clean name. Non-colliding segments resolve exactly as before.
|
|
31
|
+
*/
|
|
32
|
+
function normalizeSegmentKey(key) {
|
|
33
|
+
return resolveKey(key, 'segment');
|
|
34
|
+
}
|
|
25
35
|
/** Environment keys are LD slugs already; lowercase + hyphenate to be safe. */
|
|
26
36
|
function slugifyEnvKey(key) {
|
|
27
37
|
return key
|
|
@@ -53,7 +63,7 @@ export function flagOutputPath(key) {
|
|
|
53
63
|
return `feature-flags/${normalizeKey(key)}.json`;
|
|
54
64
|
}
|
|
55
65
|
export function segmentOutputPath(key) {
|
|
56
|
-
return `segments/${
|
|
66
|
+
return `segments/${normalizeSegmentKey(key)}.json`;
|
|
57
67
|
}
|
|
58
68
|
/**
|
|
59
69
|
* Translate one LaunchDarkly flag into a Quonfig feature-flag file.
|
|
@@ -199,7 +209,7 @@ export function translateSegment(segment, report) {
|
|
|
199
209
|
// Trailing catch-all: anyone not matched above is not in the segment.
|
|
200
210
|
rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
|
|
201
211
|
const out = {
|
|
202
|
-
key:
|
|
212
|
+
key: normalizeSegmentKey(segment.key),
|
|
203
213
|
type: 'segment',
|
|
204
214
|
valueType: 'bool',
|
|
205
215
|
default: { rules },
|
|
@@ -164,7 +164,14 @@ async function* fetchChangesImpl() {
|
|
|
164
164
|
yield { key: flag.key, raw: { data: flag, kind: 'flag' }, source: SOURCE_NAME };
|
|
165
165
|
}
|
|
166
166
|
for (const segment of snapshot.segments) {
|
|
167
|
-
|
|
167
|
+
// qfg-hbuy.10: LD segments are their own key namespace — tag them so the
|
|
168
|
+
// key rewriter can disambiguate a flag and a segment sharing one key.
|
|
169
|
+
yield {
|
|
170
|
+
key: segment.key,
|
|
171
|
+
keyNamespace: 'segment',
|
|
172
|
+
raw: { data: segment, kind: 'segment' },
|
|
173
|
+
source: SOURCE_NAME,
|
|
174
|
+
};
|
|
168
175
|
}
|
|
169
176
|
}
|
|
170
177
|
/** Path to this run's audit-walk resume cursor (always under os.tmpdir; see filename comment). */
|
|
@@ -235,7 +242,13 @@ async function* fetchAuditHistory(apiKey) {
|
|
|
235
242
|
}
|
|
236
243
|
}
|
|
237
244
|
for (const segment of segmentsByKey.values()) {
|
|
238
|
-
|
|
245
|
+
// qfg-hbuy.10: see the matching tag in fetchChangesImpl.
|
|
246
|
+
yield {
|
|
247
|
+
key: segment.key,
|
|
248
|
+
keyNamespace: 'segment',
|
|
249
|
+
raw: { data: segment, kind: 'segment' },
|
|
250
|
+
source: SOURCE_NAME,
|
|
251
|
+
};
|
|
239
252
|
}
|
|
240
253
|
}
|
|
241
254
|
function translateImpl(change) {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - 1..200 chars (ASCII-only, so char length == byte length),
|
|
11
11
|
* - not the reserved key `"new"`,
|
|
12
12
|
* - no leading dot (a `.beta.json` config is silently skipped by the loader
|
|
13
|
-
* and `qfg verify`)
|
|
13
|
+
* and `qfg verify`),
|
|
14
|
+
* - no trailing dot or space (silently stripped on Windows), and
|
|
15
|
+
* - the first dot-segment is not a Windows reserved device name (qfg-hbuy.6).
|
|
14
16
|
*
|
|
15
17
|
* NOTE the layering: this is the create boundary (hard). The general charset is
|
|
16
18
|
* a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
|
|
@@ -18,6 +20,18 @@
|
|
|
18
20
|
* Keep the messages aligned with app-quonfig's for a consistent UX.
|
|
19
21
|
*/
|
|
20
22
|
export declare const POLICY_A_KEY_RE: RegExp;
|
|
23
|
+
/**
|
|
24
|
+
* Windows reserved device names (qfg-hbuy.6). Byte-identical mirror of
|
|
25
|
+
* app-quonfig `src/lib/domain/config-schemas.ts` `WINDOWS_RESERVED_DEVICE_NAME_RE`
|
|
26
|
+
* and the qfg-verify hook's FS-safety floor (`src/verify/validate.ts`
|
|
27
|
+
* `FS_SAFETY_FLOOR_CHECKS`). Matched case-insensitively against the FIRST
|
|
28
|
+
* dot-segment of the key (`k.split('.')[0]`), because Windows treats
|
|
29
|
+
* `con.anything` as the `con` device. So `con`, `CON`, and `com3.foo` are
|
|
30
|
+
* rejected while `foo.con`, `com10`, and `console` are fine. The audit
|
|
31
|
+
* (project/plans/26-07-policy-a-audit-findings.md) specifically wants these
|
|
32
|
+
* checks byte-identical across enforcement points — keep the regex in sync.
|
|
33
|
+
*/
|
|
34
|
+
export declare const WINDOWS_RESERVED_DEVICE_NAME_RE: RegExp;
|
|
21
35
|
/**
|
|
22
36
|
* Returns a human-readable error message for a key that violates Policy A, or
|
|
23
37
|
* `null` if the key is valid. First failing rule wins (matches the order Zod
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - 1..200 chars (ASCII-only, so char length == byte length),
|
|
11
11
|
* - not the reserved key `"new"`,
|
|
12
12
|
* - no leading dot (a `.beta.json` config is silently skipped by the loader
|
|
13
|
-
* and `qfg verify`)
|
|
13
|
+
* and `qfg verify`),
|
|
14
|
+
* - no trailing dot or space (silently stripped on Windows), and
|
|
15
|
+
* - the first dot-segment is not a Windows reserved device name (qfg-hbuy.6).
|
|
14
16
|
*
|
|
15
17
|
* NOTE the layering: this is the create boundary (hard). The general charset is
|
|
16
18
|
* a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
|
|
@@ -22,6 +24,18 @@
|
|
|
22
24
|
// across both sources of truth — see the module doc above.
|
|
23
25
|
// eslint-disable-next-line unicorn/better-regex
|
|
24
26
|
export const POLICY_A_KEY_RE = /^[A-Za-z0-9._-]+$/;
|
|
27
|
+
/**
|
|
28
|
+
* Windows reserved device names (qfg-hbuy.6). Byte-identical mirror of
|
|
29
|
+
* app-quonfig `src/lib/domain/config-schemas.ts` `WINDOWS_RESERVED_DEVICE_NAME_RE`
|
|
30
|
+
* and the qfg-verify hook's FS-safety floor (`src/verify/validate.ts`
|
|
31
|
+
* `FS_SAFETY_FLOOR_CHECKS`). Matched case-insensitively against the FIRST
|
|
32
|
+
* dot-segment of the key (`k.split('.')[0]`), because Windows treats
|
|
33
|
+
* `con.anything` as the `con` device. So `con`, `CON`, and `com3.foo` are
|
|
34
|
+
* rejected while `foo.con`, `com10`, and `console` are fine. The audit
|
|
35
|
+
* (project/plans/26-07-policy-a-audit-findings.md) specifically wants these
|
|
36
|
+
* checks byte-identical across enforcement points — keep the regex in sync.
|
|
37
|
+
*/
|
|
38
|
+
export const WINDOWS_RESERVED_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
25
39
|
/**
|
|
26
40
|
* Returns a human-readable error message for a key that violates Policy A, or
|
|
27
41
|
* `null` if the key is valid. First failing rule wins (matches the order Zod
|
|
@@ -38,5 +52,15 @@ export function policyAKeyError(key) {
|
|
|
38
52
|
return 'Key can only contain letters, numbers, dots, dashes, and underscores';
|
|
39
53
|
if (key.startsWith('.'))
|
|
40
54
|
return 'Key cannot start with a dot';
|
|
55
|
+
// ── FS-safety floor parity with app-quonfig PolicyAKeySchema (qfg-hbuy.6) ──
|
|
56
|
+
// The charset above already covers the hook's control-char and
|
|
57
|
+
// Windows-reserved-char floor items; these two are the only floor items the
|
|
58
|
+
// charset alone permits. Without them the UI/create boundary accepts the key
|
|
59
|
+
// and the user gets a confusing 422 at push time instead of a clean error.
|
|
60
|
+
if (/[ .]$/.test(key))
|
|
61
|
+
return 'Key cannot end with a dot or space';
|
|
62
|
+
if (WINDOWS_RESERVED_DEVICE_NAME_RE.test(key.split('.')[0])) {
|
|
63
|
+
return 'Key cannot use a Windows reserved device name (con, prn, aux, nul, com1-9, lpt1-9) before the first dot';
|
|
64
|
+
}
|
|
41
65
|
return null;
|
|
42
66
|
}
|
|
@@ -21,5 +21,12 @@
|
|
|
21
21
|
* exists to catch (verified live on staging: a `configs/bad charset key.json`
|
|
22
22
|
* push was accepted unvalidated). A listed-but-unreadable file is now a hard
|
|
23
23
|
* failure (fail closed), not a silent skip.
|
|
24
|
+
*
|
|
25
|
+
* qfg-hbuy.4: enumerates RECURSIVELY (`-r`) and with NO name filtering. The
|
|
26
|
+
* old non-recursive listing plus `endsWith('.json') && !includes('/.')` filter
|
|
27
|
+
* meant dotfiles, nested paths (configs/sub/x.json), and case-variant
|
|
28
|
+
* extensions (FOO.JSON) never reached validation at all — ghost files that
|
|
29
|
+
* push fine but no loader reads. The hook must see EVERYTHING under the
|
|
30
|
+
* validated dirs; validateFileMap is the layer that decides what is an error.
|
|
24
31
|
*/
|
|
25
32
|
export declare function readFilesFromCommit(commitOid: string, cwd?: string): Map<string, string>;
|
|
@@ -99,26 +99,32 @@ async function runGitHook() {
|
|
|
99
99
|
* exists to catch (verified live on staging: a `configs/bad charset key.json`
|
|
100
100
|
* push was accepted unvalidated). A listed-but-unreadable file is now a hard
|
|
101
101
|
* failure (fail closed), not a silent skip.
|
|
102
|
+
*
|
|
103
|
+
* qfg-hbuy.4: enumerates RECURSIVELY (`-r`) and with NO name filtering. The
|
|
104
|
+
* old non-recursive listing plus `endsWith('.json') && !includes('/.')` filter
|
|
105
|
+
* meant dotfiles, nested paths (configs/sub/x.json), and case-variant
|
|
106
|
+
* extensions (FOO.JSON) never reached validation at all — ghost files that
|
|
107
|
+
* push fine but no loader reads. The hook must see EVERYTHING under the
|
|
108
|
+
* validated dirs; validateFileMap is the layer that decides what is an error.
|
|
102
109
|
*/
|
|
103
110
|
export function readFilesFromCommit(commitOid, cwd) {
|
|
104
111
|
const files = new Map();
|
|
105
112
|
const dirs = ['configs', 'feature-flags', 'segments', 'log-levels', 'schemas', 'schemas-protected'];
|
|
106
113
|
for (const dir of dirs) {
|
|
107
|
-
// List
|
|
108
|
-
// doesn't exist yields an empty listing (exit 0); a
|
|
109
|
-
// throws — fail closed, the hook rejects the push.
|
|
110
|
-
const listing = execFileSync('git', ['ls-tree', '-z', '--name-only', commitOid, `${dir}/`], {
|
|
114
|
+
// List every leaf entry under this directory at the given commit. A
|
|
115
|
+
// directory that doesn't exist yields an empty listing (exit 0); a
|
|
116
|
+
// bad/unreadable OID throws — fail closed, the hook rejects the push.
|
|
117
|
+
const listing = execFileSync('git', ['ls-tree', '-z', '-r', '--name-only', commitOid, `${dir}/`], {
|
|
111
118
|
cwd,
|
|
112
119
|
encoding: 'utf8',
|
|
113
120
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
121
|
});
|
|
115
122
|
const filenames = listing.split('\0').filter(Boolean);
|
|
116
123
|
for (const filePath of filenames) {
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// hook's per-ref handler, which rejects the push.
|
|
124
|
+
// Fail closed: if a listed entry can't be read (bad object, submodule
|
|
125
|
+
// gitlink, ...), the push must not be accepted with that entry
|
|
126
|
+
// unvalidated — let the error propagate to the hook's per-ref handler,
|
|
127
|
+
// which rejects the push.
|
|
122
128
|
const content = execFileSync('git', ['show', `${commitOid}:${filePath}`], {
|
|
123
129
|
cwd,
|
|
124
130
|
encoding: 'utf8',
|
package/dist/verify/validate.js
CHANGED
|
@@ -160,6 +160,86 @@ const DIR_TO_TYPE = {
|
|
|
160
160
|
const CONFIG_DIRS = new Set(Object.keys(DIR_TO_TYPE));
|
|
161
161
|
const SCHEMA_DIRS = new Set(['schemas', 'schemas-protected']);
|
|
162
162
|
const KNOWN_DIRS = new Set([...CONFIG_DIRS, ...SCHEMA_DIRS]);
|
|
163
|
+
// ── Ghost-file prevention (qfg-hbuy.4) ──────────────────────────────────
|
|
164
|
+
// Within the validated content dirs (KNOWN_DIRS), every entry must be a
|
|
165
|
+
// top-level, non-dot, lowercase-`.json` file. Anything else used to be
|
|
166
|
+
// silently SKIPPED by the enumeration filters — a "ghost" file that pushes
|
|
167
|
+
// fine but is invisible to the hook and to every loader, and a mixed-case
|
|
168
|
+
// FOO.JSON can collide with foo.json on a case-insensitive customer clone
|
|
169
|
+
// (the exact clonability failure Policy A exists to prevent). A skipped
|
|
170
|
+
// entry is a bypass; an error is a rejected push. Paths OUTSIDE the
|
|
171
|
+
// validated dirs (quonfig.json, README.md, .qf/, ...) are not this rule's
|
|
172
|
+
// business and stay untouched.
|
|
173
|
+
function ghostEntryIssue(dir, relPath, name, nested) {
|
|
174
|
+
if (nested) {
|
|
175
|
+
return {
|
|
176
|
+
file: relPath,
|
|
177
|
+
message: `Subdirectories are not allowed inside "${dir}/" — nested files are never loaded (ghost file)`,
|
|
178
|
+
severity: 'error',
|
|
179
|
+
suggestion: `Move config files to the top level: "${dir}/<key>.json"`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (name.startsWith('.')) {
|
|
183
|
+
return {
|
|
184
|
+
file: relPath,
|
|
185
|
+
message: `Dotfile "${name}" is not allowed inside "${dir}/" — hidden files are never loaded (ghost file)`,
|
|
186
|
+
severity: 'error',
|
|
187
|
+
suggestion: `Remove the file or rename it without the leading dot`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (!name.endsWith('.json')) {
|
|
191
|
+
if (name.toLowerCase().endsWith('.json')) {
|
|
192
|
+
return {
|
|
193
|
+
file: relPath,
|
|
194
|
+
message: `File extension must be lowercase ".json" (found "${name}") — a case-variant extension is never loaded and can collide with another file on a case-insensitive filesystem`,
|
|
195
|
+
severity: 'error',
|
|
196
|
+
suggestion: `Rename to "${name.slice(0, -5)}.json"`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
file: relPath,
|
|
201
|
+
message: `Only ".json" files are allowed inside "${dir}/" (found "${name}")`,
|
|
202
|
+
severity: 'error',
|
|
203
|
+
suggestion: `Remove the file or rename it to "<key>.json"`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Disk-walk variant of the ghost rule (validateWorkspace). Returns the list
|
|
210
|
+
* of names that should proceed to content validation, pushing an issue for
|
|
211
|
+
* every ghost entry.
|
|
212
|
+
*
|
|
213
|
+
* Dotfiles are the one place the disk rule is softer than the committed-tree
|
|
214
|
+
* rule: `qfg push` never sends dotfiles (collectFiles skips them), so a local
|
|
215
|
+
* `configs/.DS_Store` is inert OS junk — erroring on it would break every
|
|
216
|
+
* macOS user. A `.json`-looking dotfile is almost certainly a mistake, so it
|
|
217
|
+
* gets a WARNING; other dotfiles are skipped silently.
|
|
218
|
+
*/
|
|
219
|
+
function listValidatedDirEntries(dirPath, dir, issues) {
|
|
220
|
+
const names = [];
|
|
221
|
+
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
222
|
+
const relPath = `${dir}/${entry.name}`;
|
|
223
|
+
if (entry.name.startsWith('.')) {
|
|
224
|
+
if (entry.name.toLowerCase().endsWith('.json')) {
|
|
225
|
+
issues.push({
|
|
226
|
+
file: relPath,
|
|
227
|
+
message: `Dotfile "${entry.name}" in "${dir}/" is ignored — it is never validated, pushed, or loaded`,
|
|
228
|
+
severity: 'warning',
|
|
229
|
+
suggestion: `Remove the file or rename it without the leading dot`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const ghost = ghostEntryIssue(dir, relPath, entry.name, entry.isDirectory());
|
|
235
|
+
if (ghost) {
|
|
236
|
+
issues.push(ghost);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
names.push(entry.name);
|
|
240
|
+
}
|
|
241
|
+
return names;
|
|
242
|
+
}
|
|
163
243
|
// Operators that reference segments
|
|
164
244
|
const SEGMENT_OPERATORS = new Set(['IN_SEG', 'NOT_IN_SEG']);
|
|
165
245
|
// Operators that require a propertyName
|
|
@@ -318,12 +398,13 @@ export function validateWorkspace(workspaceDir) {
|
|
|
318
398
|
suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
|
|
319
399
|
});
|
|
320
400
|
}
|
|
321
|
-
// First pass: parse all files and collect keys
|
|
401
|
+
// First pass: parse all files and collect keys. Ghost entries (dotfiles,
|
|
402
|
+
// subdirectories, non-lowercase-.json names) are reported here (qfg-hbuy.4).
|
|
322
403
|
for (const dir of KNOWN_DIRS) {
|
|
323
404
|
const dirPath = path.join(workspaceDir, dir);
|
|
324
405
|
if (!fs.existsSync(dirPath))
|
|
325
406
|
continue;
|
|
326
|
-
const files =
|
|
407
|
+
const files = listValidatedDirEntries(dirPath, dir, issues);
|
|
327
408
|
for (const file of files) {
|
|
328
409
|
const filePath = path.join(dirPath, file);
|
|
329
410
|
const relPath = `${dir}/${file}`;
|
|
@@ -482,7 +563,8 @@ export function validateWorkspace(workspaceDir) {
|
|
|
482
563
|
allConfigs.push({ key: config.key, type: config.type, dir, file: relPath });
|
|
483
564
|
}
|
|
484
565
|
}
|
|
485
|
-
// Second pass: referential integrity
|
|
566
|
+
// Second pass: referential integrity. Keep the plain skip-filter here —
|
|
567
|
+
// ghost entries were already reported (once) by the first pass.
|
|
486
568
|
for (const dir of KNOWN_DIRS) {
|
|
487
569
|
const dirPath = path.join(workspaceDir, dir);
|
|
488
570
|
if (!fs.existsSync(dirPath))
|
|
@@ -629,8 +711,15 @@ export function validateFileMap(files) {
|
|
|
629
711
|
const file = parts.at(-1);
|
|
630
712
|
if (!KNOWN_DIRS.has(dir))
|
|
631
713
|
continue;
|
|
632
|
-
if (!file
|
|
714
|
+
if (!file)
|
|
633
715
|
continue;
|
|
716
|
+
// qfg-hbuy.4: a dotfile, nested path, or non-lowercase-.json entry inside
|
|
717
|
+
// a validated dir is a hard error (rejected push), not a silent skip.
|
|
718
|
+
const ghost = ghostEntryIssue(dir, relPath, file, parts.length > 2);
|
|
719
|
+
if (ghost) {
|
|
720
|
+
issues.push(ghost);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
634
723
|
filesChecked++;
|
|
635
724
|
let parsed;
|
|
636
725
|
try {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.0.
|
|
1
|
+
declare const _default: "0.0.70";
|
|
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.70';
|
package/oclif.manifest.json
CHANGED
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.70",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/quonfig/cli/issues"
|
|
7
7
|
},
|