@quonfig/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -77
- package/dist/agent/skill-bundle.generated.d.ts +1 -1
- package/dist/agent/skill-bundle.generated.js +4 -4
- package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
- package/dist/codegen/schema-extractor.d.ts +1 -1
- package/dist/commands/config-schema.js +9 -2
- package/dist/commands/create.d.ts +3 -1
- package/dist/commands/create.js +93 -20
- package/dist/commands/environments.d.ts +8 -0
- package/dist/commands/environments.js +32 -0
- package/dist/commands/get.d.ts +1 -0
- package/dist/commands/get.js +40 -3
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +69 -32
- package/dist/commands/set-default.d.ts +2 -0
- package/dist/commands/set-default.js +98 -35
- package/dist/commands/set-rollout.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +35 -3
- package/dist/interactive-prompt.d.ts +18 -0
- package/dist/interactive-prompt.js +11 -2
- package/dist/migrate/quonfig-target/operators.js +4 -2
- package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
- package/dist/migrate/quonfig-target/ruleset.js +132 -8
- package/dist/migrate/sources/flagsmith/translate.js +11 -0
- package/dist/migrate/sources/launchdarkly/translate.js +19 -16
- package/dist/push/git-pack.d.ts +12 -0
- package/dist/push/git-pack.js +27 -3
- package/dist/push/run-push.d.ts +2 -1
- package/dist/push/run-push.js +1 -2
- package/dist/ui/get-confirmation.d.ts +10 -1
- package/dist/ui/get-confirmation.js +13 -4
- package/dist/ui/get-string.d.ts +18 -1
- package/dist/ui/get-string.js +20 -5
- package/dist/ui/get-value.d.ts +3 -1
- package/dist/ui/get-value.js +7 -7
- package/dist/util/clone-and-stack-push.js +8 -3
- package/dist/util/encryption.js +11 -20
- package/dist/util/git-ops.d.ts +4 -2
- package/dist/util/git-ops.js +4 -2
- package/dist/util/redact.d.ts +56 -0
- package/dist/util/redact.js +98 -0
- package/dist/util/value-source.d.ts +30 -0
- package/dist/util/value-source.js +81 -0
- package/dist/verify/regex-dialect.d.ts +39 -0
- package/dist/verify/regex-dialect.js +278 -0
- package/dist/verify/standalone.d.ts +3 -1
- package/dist/verify/standalone.js +4 -19
- package/dist/verify/validate.js +20 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +218 -97
- package/package.json +18 -16
package/dist/util/encryption.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { encryption } from '@quonfig/node';
|
|
2
|
-
import
|
|
2
|
+
import { createDecipheriv } from 'node:crypto';
|
|
3
3
|
import { failure, success } from '../result.js';
|
|
4
4
|
const SEPARATOR = '--';
|
|
5
5
|
const KEY_LENGTH = 32; // 32 bytes for AES-256
|
|
@@ -28,27 +28,18 @@ export function decrypt(encryptedString, keyStringHex) {
|
|
|
28
28
|
if (encryptedDataPart === '') {
|
|
29
29
|
return '';
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
iv: ivBytes,
|
|
42
|
-
tag: forge.util.createBuffer(tagBytes),
|
|
43
|
-
tagLength: TAG_LENGTH * 8, // tagLength is in bits
|
|
44
|
-
});
|
|
45
|
-
// Decrypt the data
|
|
46
|
-
decipher.update(forge.util.createBuffer(encryptedBytes));
|
|
47
|
-
const success = decipher.finish();
|
|
48
|
-
if (!success) {
|
|
31
|
+
const key = Buffer.from(keyStringHex, 'hex');
|
|
32
|
+
const encryptedData = Buffer.from(encryptedDataPart, 'hex');
|
|
33
|
+
const iv = Buffer.from(ivPart, 'hex');
|
|
34
|
+
const authTag = Buffer.from(authTagPart, 'hex');
|
|
35
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_LENGTH });
|
|
36
|
+
try {
|
|
37
|
+
decipher.setAuthTag(authTag);
|
|
38
|
+
return Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
49
41
|
throw new Error('Authentication tag verification failed');
|
|
50
42
|
}
|
|
51
|
-
return decipher.output.toString();
|
|
52
43
|
}
|
|
53
44
|
/**
|
|
54
45
|
* Creates an encrypted ConfigValue using the new v1 API endpoints
|
package/dist/util/git-ops.d.ts
CHANGED
|
@@ -11,9 +11,11 @@ export declare const GIT_SAFE_ARGS: readonly string[];
|
|
|
11
11
|
*/
|
|
12
12
|
export declare const GIT_SAFE_ENV: Readonly<NodeJS.ProcessEnv>;
|
|
13
13
|
/**
|
|
14
|
-
* Redact
|
|
14
|
+
* Redact the credential from every http(s) URL in a string (a URL, or git's
|
|
15
|
+
* stderr) so it is safe to display to users. Handles both `user:token@`
|
|
16
|
+
* (keeps the user) and a bare `token@` (qfg-9iu0).
|
|
15
17
|
*/
|
|
16
|
-
export declare const redactToken: (
|
|
18
|
+
export declare const redactToken: (text: string) => string;
|
|
17
19
|
export interface RunGitOptions {
|
|
18
20
|
cwd?: string;
|
|
19
21
|
/** Extra env vars merged on top of `process.env` and `GIT_SAFE_ENV`. */
|
package/dist/util/git-ops.js
CHANGED
|
@@ -17,9 +17,11 @@ export const GIT_SAFE_ENV = {
|
|
|
17
17
|
GCM_INTERACTIVE: 'Never',
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
20
|
-
* Redact
|
|
20
|
+
* Redact the credential from every http(s) URL in a string (a URL, or git's
|
|
21
|
+
* stderr) so it is safe to display to users. Handles both `user:token@`
|
|
22
|
+
* (keeps the user) and a bare `token@` (qfg-9iu0).
|
|
21
23
|
*/
|
|
22
|
-
export const redactToken = (
|
|
24
|
+
export const redactToken = (text) => text.replaceAll(/(https?:\/\/)(?:([^\s/:@]*):)?[^\s/@]+@/gi, (_match, scheme, user) => user === undefined ? `${scheme}***@` : `${scheme}${user}:***@`);
|
|
23
25
|
/**
|
|
24
26
|
* Canonical entry point for shelling out to git from the CLI. Always prepends
|
|
25
27
|
* `GIT_SAFE_ARGS` and merges `GIT_SAFE_ENV` so credential prompts never leak
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Plaintext shorter than this never has its tail shown. */
|
|
2
|
+
export declare const LAST4_MIN_LENGTH = 12;
|
|
3
|
+
export type RedactedSummary = {
|
|
4
|
+
/** true for `decryptWith` values, false for confidential-only values */
|
|
5
|
+
encrypted: boolean;
|
|
6
|
+
/** `*****<first 5 hex of md5>` — absent only before the value is encrypted */
|
|
7
|
+
fingerprint?: string;
|
|
8
|
+
/** last 4 chars of the plaintext, only when the plaintext is >= 12 chars */
|
|
9
|
+
last4?: string;
|
|
10
|
+
/** plaintext length — absent only when the plaintext is not known */
|
|
11
|
+
length?: number;
|
|
12
|
+
redacted: true;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The SDK telemetry fingerprint: `*****` + the first 5 hex chars of the md5.
|
|
16
|
+
* Identical to sdk-node resolver.ts makeConfidential (and sdk-ruby), so the
|
|
17
|
+
* value the CLI shows matches what telemetry and the UI report.
|
|
18
|
+
*/
|
|
19
|
+
export declare const fingerprint: (value: string) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Build the redacted summary for a value.
|
|
22
|
+
*
|
|
23
|
+
* - `ciphertext` given: an encrypted value. Like the SDK resolver, the
|
|
24
|
+
* fingerprint hashes the CIPHERTEXT (that is what telemetry sees).
|
|
25
|
+
* - `encrypted: true` without ciphertext: about to be encrypted (e.g. a confirm
|
|
26
|
+
* prompt before the write) — no fingerprint yet.
|
|
27
|
+
* - neither: a confidential-but-unencrypted value; the fingerprint hashes the
|
|
28
|
+
* plaintext, again matching the SDK.
|
|
29
|
+
*
|
|
30
|
+
* `plaintext` may be omitted when it is not known (e.g. ciphertext that could
|
|
31
|
+
* not be decrypted); length and last4 are then left out.
|
|
32
|
+
*/
|
|
33
|
+
export declare const redactedSummary: ({ ciphertext, encrypted, plaintext, }: {
|
|
34
|
+
ciphertext?: string;
|
|
35
|
+
encrypted?: boolean;
|
|
36
|
+
plaintext?: string;
|
|
37
|
+
}) => RedactedSummary;
|
|
38
|
+
/**
|
|
39
|
+
* One-line human display, e.g.
|
|
40
|
+
* [encrypted *****3fa9c, 51 chars, ends …a1b2]
|
|
41
|
+
* [encrypted, 10 chars] (before encryption, no fingerprint)
|
|
42
|
+
* [confidential *****1c2d3, 10 chars]
|
|
43
|
+
*/
|
|
44
|
+
export declare const redactedDisplay: (summary: RedactedSummary) => string;
|
|
45
|
+
/**
|
|
46
|
+
* Deep-copy a stored config document, replacing the plaintext of every
|
|
47
|
+
* confidential-but-UNencrypted rule value with its redacted display. Encrypted
|
|
48
|
+
* values (`decryptWith`) are ciphertext and are left as they are.
|
|
49
|
+
*/
|
|
50
|
+
export declare const scrubConfidentialValues: <T>(doc: T) => T;
|
|
51
|
+
/**
|
|
52
|
+
* Scrub anything headed for a --verbose / debug log line (qfg-38lp): objects
|
|
53
|
+
* are deep-scrubbed, and a string that is itself JSON (e.g. a pre-stringified
|
|
54
|
+
* request body) is parsed, scrubbed and re-serialised.
|
|
55
|
+
*/
|
|
56
|
+
export declare const scrubForLog: (message: unknown) => unknown;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Shared secret-redaction helper (qfg-38lp). Every CLI path that would show a
|
|
4
|
+
* secret (or `confidential`) value goes through here instead of printing the
|
|
5
|
+
* plaintext. Plaintext is only ever printed by `qfg get --reveal`.
|
|
6
|
+
*/
|
|
7
|
+
const FINGERPRINT_PREFIX = '*****';
|
|
8
|
+
/** Plaintext shorter than this never has its tail shown. */
|
|
9
|
+
export const LAST4_MIN_LENGTH = 12;
|
|
10
|
+
/**
|
|
11
|
+
* The SDK telemetry fingerprint: `*****` + the first 5 hex chars of the md5.
|
|
12
|
+
* Identical to sdk-node resolver.ts makeConfidential (and sdk-ruby), so the
|
|
13
|
+
* value the CLI shows matches what telemetry and the UI report.
|
|
14
|
+
*/
|
|
15
|
+
export const fingerprint = (value) => `${FINGERPRINT_PREFIX}${createHash('md5').update(value).digest('hex').slice(0, 5)}`;
|
|
16
|
+
/**
|
|
17
|
+
* Build the redacted summary for a value.
|
|
18
|
+
*
|
|
19
|
+
* - `ciphertext` given: an encrypted value. Like the SDK resolver, the
|
|
20
|
+
* fingerprint hashes the CIPHERTEXT (that is what telemetry sees).
|
|
21
|
+
* - `encrypted: true` without ciphertext: about to be encrypted (e.g. a confirm
|
|
22
|
+
* prompt before the write) — no fingerprint yet.
|
|
23
|
+
* - neither: a confidential-but-unencrypted value; the fingerprint hashes the
|
|
24
|
+
* plaintext, again matching the SDK.
|
|
25
|
+
*
|
|
26
|
+
* `plaintext` may be omitted when it is not known (e.g. ciphertext that could
|
|
27
|
+
* not be decrypted); length and last4 are then left out.
|
|
28
|
+
*/
|
|
29
|
+
export const redactedSummary = ({ ciphertext, encrypted, plaintext, }) => {
|
|
30
|
+
const isEncrypted = ciphertext !== undefined || encrypted === true;
|
|
31
|
+
const hashed = ciphertext ?? (isEncrypted ? undefined : plaintext);
|
|
32
|
+
const summary = { encrypted: isEncrypted, redacted: true };
|
|
33
|
+
if (hashed !== undefined)
|
|
34
|
+
summary.fingerprint = fingerprint(hashed);
|
|
35
|
+
if (plaintext !== undefined)
|
|
36
|
+
summary.length = plaintext.length;
|
|
37
|
+
if (plaintext !== undefined && plaintext.length >= LAST4_MIN_LENGTH)
|
|
38
|
+
summary.last4 = plaintext.slice(-4);
|
|
39
|
+
return summary;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One-line human display, e.g.
|
|
43
|
+
* [encrypted *****3fa9c, 51 chars, ends …a1b2]
|
|
44
|
+
* [encrypted, 10 chars] (before encryption, no fingerprint)
|
|
45
|
+
* [confidential *****1c2d3, 10 chars]
|
|
46
|
+
*/
|
|
47
|
+
export const redactedDisplay = (summary) => {
|
|
48
|
+
const label = summary.encrypted ? 'encrypted' : 'confidential';
|
|
49
|
+
const head = summary.fingerprint ? `${label} ${summary.fingerprint}` : label;
|
|
50
|
+
const parts = [head];
|
|
51
|
+
if (typeof summary.length === 'number')
|
|
52
|
+
parts.push(`${summary.length} chars`);
|
|
53
|
+
if (summary.last4)
|
|
54
|
+
parts.push(`ends …${summary.last4}`);
|
|
55
|
+
return `[${parts.join(', ')}]`;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Deep-copy a stored config document, replacing the plaintext of every
|
|
59
|
+
* confidential-but-UNencrypted rule value with its redacted display. Encrypted
|
|
60
|
+
* values (`decryptWith`) are ciphertext and are left as they are.
|
|
61
|
+
*/
|
|
62
|
+
export const scrubConfidentialValues = (doc) => {
|
|
63
|
+
const walk = (node) => {
|
|
64
|
+
if (Array.isArray(node))
|
|
65
|
+
return node.map((item) => walk(item));
|
|
66
|
+
if (node === null || typeof node !== 'object')
|
|
67
|
+
return node;
|
|
68
|
+
const record = node;
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [k, v] of Object.entries(record))
|
|
71
|
+
out[k] = walk(v);
|
|
72
|
+
if (record.confidential === true && !record.decryptWith && record.value !== undefined && record.value !== null) {
|
|
73
|
+
const plaintext = typeof record.value === 'string' ? record.value : JSON.stringify(record.value);
|
|
74
|
+
out.value = redactedDisplay(redactedSummary({ plaintext }));
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
return walk(doc);
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Scrub anything headed for a --verbose / debug log line (qfg-38lp): objects
|
|
82
|
+
* are deep-scrubbed, and a string that is itself JSON (e.g. a pre-stringified
|
|
83
|
+
* request body) is parsed, scrubbed and re-serialised.
|
|
84
|
+
*/
|
|
85
|
+
export const scrubForLog = (message) => {
|
|
86
|
+
if (typeof message === 'string') {
|
|
87
|
+
const trimmed = message.trim();
|
|
88
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
|
|
89
|
+
return message;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.stringify(scrubConfidentialValues(JSON.parse(trimmed)), null, 2);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return message;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return scrubConfidentialValues(message);
|
|
98
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Result } from '../result.js';
|
|
2
|
+
/**
|
|
3
|
+
* qfg-38lp: ways to hand the CLI a value WITHOUT putting it in argv (where it
|
|
4
|
+
* lands in shell history, `ps`, CI logs and agent transcripts).
|
|
5
|
+
*
|
|
6
|
+
* --value-stdin read the value from stdin
|
|
7
|
+
* --value-from-env <VAR> read the value from a LOCAL env var, right now
|
|
8
|
+
*
|
|
9
|
+
* Not to be confused with --env-var, which stores no value at all: it records
|
|
10
|
+
* that the SDK reads the value from that env var at RUNTIME.
|
|
11
|
+
*/
|
|
12
|
+
export declare const valueSourceFlags: {
|
|
13
|
+
'value-from-env': import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
14
|
+
'value-stdin': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
15
|
+
};
|
|
16
|
+
type ValueSourceInput = {
|
|
17
|
+
'env-var'?: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
'value-from-env'?: string;
|
|
20
|
+
'value-stdin'?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** Strip exactly one trailing newline (`\n` or `\r\n`), as `echo`/heredocs add one. */
|
|
23
|
+
export declare const stripOneTrailingNewline: (s: string) => string;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the value from --value / --value-stdin / --value-from-env, enforcing
|
|
26
|
+
* that at most one of those (or --env-var) is given. Returns `undefined` when
|
|
27
|
+
* none was given (the caller may then prompt or use --env-var).
|
|
28
|
+
*/
|
|
29
|
+
export declare const resolveValueSource: (flags: ValueSourceInput) => Promise<Result<string | undefined>>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { failure, success } from '../result.js';
|
|
3
|
+
import getString from '../ui/get-string.js';
|
|
4
|
+
/**
|
|
5
|
+
* qfg-38lp: ways to hand the CLI a value WITHOUT putting it in argv (where it
|
|
6
|
+
* lands in shell history, `ps`, CI logs and agent transcripts).
|
|
7
|
+
*
|
|
8
|
+
* --value-stdin read the value from stdin
|
|
9
|
+
* --value-from-env <VAR> read the value from a LOCAL env var, right now
|
|
10
|
+
*
|
|
11
|
+
* Not to be confused with --env-var, which stores no value at all: it records
|
|
12
|
+
* that the SDK reads the value from that env var at RUNTIME.
|
|
13
|
+
*/
|
|
14
|
+
export const valueSourceFlags = {
|
|
15
|
+
'value-from-env': Flags.string({
|
|
16
|
+
description: 'read the value to store from this LOCAL env var now (keeps it out of argv/shell history). Not the same as --env-var',
|
|
17
|
+
helpValue: 'VAR',
|
|
18
|
+
}),
|
|
19
|
+
'value-stdin': Flags.boolean({
|
|
20
|
+
default: false,
|
|
21
|
+
description: 'read the value to store from stdin (one trailing newline is stripped). Keeps it out of argv',
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
/** Read all of stdin (tests swap `process.stdin` for a Readable). */
|
|
25
|
+
const readStdin = async () => {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of process.stdin) {
|
|
28
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
29
|
+
}
|
|
30
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
31
|
+
};
|
|
32
|
+
/** Strip exactly one trailing newline (`\n` or `\r\n`), as `echo`/heredocs add one. */
|
|
33
|
+
export const stripOneTrailingNewline = (s) => {
|
|
34
|
+
if (s.endsWith('\r\n'))
|
|
35
|
+
return s.slice(0, -2);
|
|
36
|
+
if (s.endsWith('\n'))
|
|
37
|
+
return s.slice(0, -1);
|
|
38
|
+
return s;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the value from --value / --value-stdin / --value-from-env, enforcing
|
|
42
|
+
* that at most one of those (or --env-var) is given. Returns `undefined` when
|
|
43
|
+
* none was given (the caller may then prompt or use --env-var).
|
|
44
|
+
*/
|
|
45
|
+
export const resolveValueSource = async (flags) => {
|
|
46
|
+
const given = [
|
|
47
|
+
flags.value === undefined ? undefined : '--value',
|
|
48
|
+
flags['value-stdin'] ? '--value-stdin' : undefined,
|
|
49
|
+
flags['value-from-env'] === undefined ? undefined : '--value-from-env',
|
|
50
|
+
flags['env-var'] === undefined ? undefined : '--env-var',
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
if (given.length > 1) {
|
|
53
|
+
return failure(`Pass only one of --value, --value-stdin, --value-from-env or --env-var (got ${given.join(' and ')}). ` +
|
|
54
|
+
'--env-var stores no value: it tells the SDK to read the value from that env var at runtime.');
|
|
55
|
+
}
|
|
56
|
+
if (flags['value-stdin']) {
|
|
57
|
+
// On a terminal, a hand-typed value would be echoed; read it through the
|
|
58
|
+
// hidden prompt instead (one line, nothing echoed).
|
|
59
|
+
const value = process.stdin.isTTY
|
|
60
|
+
? ((await getString({ allowBlank: true, mask: true, message: 'Value (input is hidden)' })) ?? '')
|
|
61
|
+
: stripOneTrailingNewline(await readStdin());
|
|
62
|
+
if (value === '') {
|
|
63
|
+
return failure('--value-stdin read an empty value from stdin. Pipe the value in, e.g. `pbpaste | qfg ... --value-stdin`.');
|
|
64
|
+
}
|
|
65
|
+
return success(value);
|
|
66
|
+
}
|
|
67
|
+
const envName = flags['value-from-env'];
|
|
68
|
+
if (envName !== undefined) {
|
|
69
|
+
const value = process.env[envName];
|
|
70
|
+
if (value === undefined) {
|
|
71
|
+
return failure(`--value-from-env: environment variable ${envName} is not set in this shell. ` +
|
|
72
|
+
`If you set it without \`export\`, run \`export ${envName}\` (or load a .env file with ` +
|
|
73
|
+
'`set -a; source ./file.env; set +a`).');
|
|
74
|
+
}
|
|
75
|
+
if (value === '') {
|
|
76
|
+
return failure(`--value-from-env: environment variable ${envName} is empty.`);
|
|
77
|
+
}
|
|
78
|
+
return success(value);
|
|
79
|
+
}
|
|
80
|
+
return success(flags.value);
|
|
81
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Quonfig regex v1": the regex dialect of PROP_MATCHES and PROP_DOES_NOT_MATCH
|
|
3
|
+
* (qfg-ktkr.2). Spec: project/plans/2026-10-12-regex-write-time-check.md
|
|
4
|
+
* section 2, approved 2026-10-16 (qfg-goi1.10.10). Fixture of record:
|
|
5
|
+
* integration-test-data tests/regex/dialect.yaml (tag v2026.10.22.1; \_, \v
|
|
6
|
+
* and \x80-\xFF rejected per qfg-ktkr.7), which
|
|
7
|
+
* test/verify/regex-dialect.test.ts holds this file to, row by row.
|
|
8
|
+
*
|
|
9
|
+
* The dialect is the intersection of what every SDK's engine (go RE2, node
|
|
10
|
+
* RegExp, python re, ruby Onigmo, java.util.regex, .NET Regex) accepts and
|
|
11
|
+
* reads the same way, plus a ReDoS safety rule. SDKs do not validate; the
|
|
12
|
+
* writers (this check, app-quonfig's copy, and the Git hook built from this
|
|
13
|
+
* repo) are the only gate.
|
|
14
|
+
*
|
|
15
|
+
* No imports: src/verify/ is bundled into the standalone qfg-verify hook.
|
|
16
|
+
*/
|
|
17
|
+
export declare const REGEX_V1_LIMITS: {
|
|
18
|
+
readonly maxLength: 256;
|
|
19
|
+
readonly maxRepeat: 1000;
|
|
20
|
+
readonly maxUnboundedQuantifiers: 2;
|
|
21
|
+
};
|
|
22
|
+
/** The canonical docs section the error links to (qfg-ktkr.5 owns the page). */
|
|
23
|
+
export declare const REGEX_V1_DOCS_URL = "https://docs.quonfig.com/docs/explanations/features/rules-and-segmentation#quonfig-regex-v1";
|
|
24
|
+
export type RegexV1Reason = 'backref' | 'bad-repeat' | 'class-syntax' | 'group-extension' | 'inline-flag' | 'lookaround' | 'named-group' | 'nested-quantifier' | 'octal' | 'possessive' | 'syntax' | 'too-long' | 'too-many-unbounded' | 'unicode-shorthand' | 'unsupported-escape';
|
|
25
|
+
/** Fix-it hints, verbatim from the fixture's `hints` map. */
|
|
26
|
+
export declare const REGEX_V1_HINTS: Record<RegexV1Reason, string>;
|
|
27
|
+
export interface RegexV1Violation {
|
|
28
|
+
hint: string;
|
|
29
|
+
reason: RegexV1Reason;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check one pattern against Quonfig regex v1. Returns null when the pattern is
|
|
33
|
+
* in the dialect, or the reason and fix-it hint for the first violation.
|
|
34
|
+
*
|
|
35
|
+
* Precedence (fixture header): too-long first; then one left-to-right scan
|
|
36
|
+
* reports the first offending construct (an unclosed ( or [ is found at the
|
|
37
|
+
* end); then too-many-unbounded, which needs the whole pattern.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkRegexV1(pattern: string): null | RegexV1Violation;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Quonfig regex v1": the regex dialect of PROP_MATCHES and PROP_DOES_NOT_MATCH
|
|
3
|
+
* (qfg-ktkr.2). Spec: project/plans/2026-10-12-regex-write-time-check.md
|
|
4
|
+
* section 2, approved 2026-10-16 (qfg-goi1.10.10). Fixture of record:
|
|
5
|
+
* integration-test-data tests/regex/dialect.yaml (tag v2026.10.22.1; \_, \v
|
|
6
|
+
* and \x80-\xFF rejected per qfg-ktkr.7), which
|
|
7
|
+
* test/verify/regex-dialect.test.ts holds this file to, row by row.
|
|
8
|
+
*
|
|
9
|
+
* The dialect is the intersection of what every SDK's engine (go RE2, node
|
|
10
|
+
* RegExp, python re, ruby Onigmo, java.util.regex, .NET Regex) accepts and
|
|
11
|
+
* reads the same way, plus a ReDoS safety rule. SDKs do not validate; the
|
|
12
|
+
* writers (this check, app-quonfig's copy, and the Git hook built from this
|
|
13
|
+
* repo) are the only gate.
|
|
14
|
+
*
|
|
15
|
+
* No imports: src/verify/ is bundled into the standalone qfg-verify hook.
|
|
16
|
+
*/
|
|
17
|
+
export const REGEX_V1_LIMITS = { maxLength: 256, maxRepeat: 1000, maxUnboundedQuantifiers: 2 };
|
|
18
|
+
/** The canonical docs section the error links to (qfg-ktkr.5 owns the page). */
|
|
19
|
+
export const REGEX_V1_DOCS_URL = 'https://docs.quonfig.com/docs/explanations/features/rules-and-segmentation#quonfig-regex-v1';
|
|
20
|
+
/** Fix-it hints, verbatim from the fixture's `hints` map. */
|
|
21
|
+
export const REGEX_V1_HINTS = {
|
|
22
|
+
'too-long': 'Regex patterns are limited to 256 characters.',
|
|
23
|
+
syntax: 'This is not a valid regular expression. Check for an unbalanced ( ) or [ ], a trailing \\, a reversed range such as [z-a], or a quantifier with nothing before it (a quantifier cannot follow ^, $, |, ( or another quantifier).',
|
|
24
|
+
lookaround: 'Lookahead and lookbehind ((?=, (?!, (?<=, (?<!) are not supported. Rewrite the pattern without them, or split the rule into two criteria.',
|
|
25
|
+
'named-group': "Named groups ((?<name>...), (?P<name>...), (?'name'...)) are not supported. Use a plain group ( ) or (?: ).",
|
|
26
|
+
'inline-flag': 'Inline flags such as (?i) are not supported. Spell out both cases with a class, for example [Hh]ello.',
|
|
27
|
+
'group-extension': 'Only ( ) and (?: ) groups are supported. Atomic groups (?>...), comments (?#...), conditionals and other (? forms are not.',
|
|
28
|
+
backref: 'Backreferences (\\1 to \\9, \\k<name>) are not supported. Repeat the text explicitly instead.',
|
|
29
|
+
octal: 'Octal escapes and \\0 are not supported. Write the character as \\xHH, for example \\x00.',
|
|
30
|
+
possessive: 'Possessive quantifiers (*+, ++, ?+, {n}+) are not supported. Use the plain form (a+) or the lazy form (a+?).',
|
|
31
|
+
'bad-repeat': 'Write repeat counts as {n}, {n,} or {n,m} with n <= m <= 1000. {,m} is not portable: write {0,m}. Escape a literal brace as \\{.',
|
|
32
|
+
'class-syntax': 'Inside [...] use only literals, ranges and escapes. POSIX classes such as [:digit:], a nested [, &&, --, ~~ and || are not supported. Escape a literal [ or ] as \\[ or \\], and do not start a class with ].',
|
|
33
|
+
'unicode-shorthand': '\\d, \\w, \\s, \\b and their negations match different characters in different SDKs. Use [0-9], [A-Za-z0-9_] or [ \\t\\r\\n\\f\\x0B].',
|
|
34
|
+
'unsupported-escape': 'This escape is not supported. The supported escapes are \\ followed by ASCII punctuation other than _, \\t \\n \\r \\f, and \\xHH with exactly two hex digits from \\x00 to \\x7F. Write _ unescaped instead of \\_, \\x0B instead of \\v, and a character above \\x7F as the character itself.',
|
|
35
|
+
'nested-quantifier': 'A quantifier cannot apply to a group that contains a quantifier or a |, as in (a+)+ or (a|b)*. Such patterns can take exponential time. Quantify the inner part only, or use a class: [ab]* instead of (a|b)*.',
|
|
36
|
+
'too-many-unbounded': 'A pattern can have at most 2 unbounded quantifiers (*, +, {n,}). Bound the extra ones with {n,m}, or split the rule into two criteria.',
|
|
37
|
+
};
|
|
38
|
+
// qfg-ktkr.7: no \_ (.NET throws), no \v (java reads a vertical-whitespace
|
|
39
|
+
// class), and \xHH only up to \x7F (ruby rejects \x80-\xFF).
|
|
40
|
+
const ASCII_PUNCT = '!"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~';
|
|
41
|
+
const SIMPLE_ESCAPES = { f: 0x0c, n: 0x0a, r: 0x0d, t: 0x09 };
|
|
42
|
+
const MAX_HEX_ESCAPE = 0x7f;
|
|
43
|
+
const SHORTHANDS = 'dDwWsSbB';
|
|
44
|
+
const HEX = /^[\dA-Fa-f]{2}$/;
|
|
45
|
+
class Violation {
|
|
46
|
+
reason;
|
|
47
|
+
constructor(reason) {
|
|
48
|
+
this.reason = reason;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const fail = (reason) => {
|
|
52
|
+
throw new Violation(reason);
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Check one pattern against Quonfig regex v1. Returns null when the pattern is
|
|
56
|
+
* in the dialect, or the reason and fix-it hint for the first violation.
|
|
57
|
+
*
|
|
58
|
+
* Precedence (fixture header): too-long first; then one left-to-right scan
|
|
59
|
+
* reports the first offending construct (an unclosed ( or [ is found at the
|
|
60
|
+
* end); then too-many-unbounded, which needs the whole pattern.
|
|
61
|
+
*/
|
|
62
|
+
export function checkRegexV1(pattern) {
|
|
63
|
+
const cps = [...pattern];
|
|
64
|
+
try {
|
|
65
|
+
if (cps.length > REGEX_V1_LIMITS.maxLength)
|
|
66
|
+
fail('too-long');
|
|
67
|
+
const unbounded = scan(cps);
|
|
68
|
+
if (unbounded > REGEX_V1_LIMITS.maxUnboundedQuantifiers)
|
|
69
|
+
fail('too-many-unbounded');
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof Violation)
|
|
74
|
+
return { hint: REGEX_V1_HINTS[error.reason], reason: error.reason };
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Scan the pattern; returns the unbounded-quantifier count or throws a Violation. */
|
|
79
|
+
function scan(p) {
|
|
80
|
+
// One frame per open group: does it (transitively) contain a quantifier or |?
|
|
81
|
+
const groups = [];
|
|
82
|
+
let last = 'none';
|
|
83
|
+
let lastGroupRisky = false;
|
|
84
|
+
let unbounded = 0;
|
|
85
|
+
let i = 0;
|
|
86
|
+
const markRisky = () => {
|
|
87
|
+
if (groups.length > 0)
|
|
88
|
+
groups[groups.length - 1] = true;
|
|
89
|
+
};
|
|
90
|
+
/** At p[i] === '{': parse a repeat. Returns [end index, unbounded] or throws. */
|
|
91
|
+
const parseRepeat = () => {
|
|
92
|
+
let j = i + 1;
|
|
93
|
+
let n = '';
|
|
94
|
+
while (j < p.length && p[j] >= '0' && p[j] <= '9')
|
|
95
|
+
n += p[j++];
|
|
96
|
+
let m;
|
|
97
|
+
let comma = false;
|
|
98
|
+
if (p[j] === ',') {
|
|
99
|
+
comma = true;
|
|
100
|
+
j++;
|
|
101
|
+
m = '';
|
|
102
|
+
while (j < p.length && p[j] >= '0' && p[j] <= '9')
|
|
103
|
+
m += p[j++];
|
|
104
|
+
}
|
|
105
|
+
if (p[j] !== '}' || n === '')
|
|
106
|
+
fail('bad-repeat'); // {,m}, a bare {, x{foo}
|
|
107
|
+
const lo = Number(n);
|
|
108
|
+
const hi = comma && m !== '' ? Number(m) : undefined;
|
|
109
|
+
if (lo > REGEX_V1_LIMITS.maxRepeat || (hi !== undefined && (hi > REGEX_V1_LIMITS.maxRepeat || hi < lo)))
|
|
110
|
+
fail('bad-repeat');
|
|
111
|
+
return [j, comma && m === ''];
|
|
112
|
+
};
|
|
113
|
+
/** At p[i] === '\\': validate one escape, return its code point (or -1 for a non-literal). */
|
|
114
|
+
const parseEscape = () => {
|
|
115
|
+
const c = p[i + 1];
|
|
116
|
+
if (c === undefined)
|
|
117
|
+
fail('syntax');
|
|
118
|
+
if (c >= '1' && c <= '9')
|
|
119
|
+
fail('backref');
|
|
120
|
+
if (c === 'k')
|
|
121
|
+
fail('backref');
|
|
122
|
+
if (c === '0')
|
|
123
|
+
fail('octal');
|
|
124
|
+
if (SHORTHANDS.includes(c))
|
|
125
|
+
fail('unicode-shorthand');
|
|
126
|
+
if (c in SIMPLE_ESCAPES) {
|
|
127
|
+
i += 2;
|
|
128
|
+
return SIMPLE_ESCAPES[c];
|
|
129
|
+
}
|
|
130
|
+
if (c === 'x') {
|
|
131
|
+
const hex = (p[i + 2] ?? '') + (p[i + 3] ?? '');
|
|
132
|
+
if (!HEX.test(hex))
|
|
133
|
+
fail('unsupported-escape');
|
|
134
|
+
const cp = Number.parseInt(hex, 16);
|
|
135
|
+
if (cp > MAX_HEX_ESCAPE)
|
|
136
|
+
fail('unsupported-escape');
|
|
137
|
+
i += 4;
|
|
138
|
+
return cp;
|
|
139
|
+
}
|
|
140
|
+
if (c.length === 1 && ASCII_PUNCT.includes(c)) {
|
|
141
|
+
i += 2;
|
|
142
|
+
return c.codePointAt(0);
|
|
143
|
+
}
|
|
144
|
+
return fail('unsupported-escape');
|
|
145
|
+
};
|
|
146
|
+
const parseClass = () => {
|
|
147
|
+
// p[i] === '['
|
|
148
|
+
i++;
|
|
149
|
+
if (p[i] === '^')
|
|
150
|
+
i++;
|
|
151
|
+
if (p[i] === ']')
|
|
152
|
+
fail('class-syntax'); // []a] / [^]a] read differently in node
|
|
153
|
+
const start = i;
|
|
154
|
+
let prev; // code point of the previous single literal, for ranges
|
|
155
|
+
for (;;) {
|
|
156
|
+
const c = p[i];
|
|
157
|
+
if (c === undefined)
|
|
158
|
+
fail('syntax'); // unclosed [
|
|
159
|
+
if (c === ']') {
|
|
160
|
+
const body = p.slice(start, i);
|
|
161
|
+
if (body.length >= 2 && body[0] === ':' && body.at(-1) === ':')
|
|
162
|
+
fail('class-syntax'); // [:alpha:]
|
|
163
|
+
i++;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (c === '[')
|
|
167
|
+
fail('class-syntax'); // nested [ and [[:x:]]
|
|
168
|
+
if ('&-~|'.includes(c) && p[i + 1] === c)
|
|
169
|
+
fail('class-syntax'); // && -- ~~ ||
|
|
170
|
+
if (c === '-' && prev !== undefined && p[i + 1] !== ']' && p[i + 1] !== undefined) {
|
|
171
|
+
i++;
|
|
172
|
+
if (p[i] === '[')
|
|
173
|
+
fail('class-syntax');
|
|
174
|
+
const hi = p[i] === '\\' ? parseEscape() : p[i++].codePointAt(0);
|
|
175
|
+
if (hi < prev)
|
|
176
|
+
fail('syntax'); // [z-a]
|
|
177
|
+
prev = undefined;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
prev = c === '\\' ? parseEscape() : p[i++].codePointAt(0);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
while (i < p.length) {
|
|
184
|
+
const c = p[i];
|
|
185
|
+
switch (c) {
|
|
186
|
+
case '\\': {
|
|
187
|
+
parseEscape();
|
|
188
|
+
last = 'atom';
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
case '[': {
|
|
192
|
+
parseClass();
|
|
193
|
+
last = 'atom';
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
case '(': {
|
|
197
|
+
if (p[i + 1] === '?') {
|
|
198
|
+
const a = p[i + 2];
|
|
199
|
+
const b = p[i + 3];
|
|
200
|
+
if (a === ':') {
|
|
201
|
+
i += 3;
|
|
202
|
+
}
|
|
203
|
+
else if (a === '=' || a === '!' || (a === '<' && (b === '=' || b === '!'))) {
|
|
204
|
+
fail('lookaround');
|
|
205
|
+
}
|
|
206
|
+
else if (a === '<' || a === 'P' || a === "'") {
|
|
207
|
+
fail('named-group');
|
|
208
|
+
}
|
|
209
|
+
else if (a !== undefined && /[A-Za-z-]/.test(a)) {
|
|
210
|
+
fail('inline-flag');
|
|
211
|
+
}
|
|
212
|
+
else if (a === undefined) {
|
|
213
|
+
fail('syntax');
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
fail('group-extension');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
i++;
|
|
221
|
+
}
|
|
222
|
+
groups.push(false);
|
|
223
|
+
last = 'none';
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
case ')': {
|
|
227
|
+
if (groups.length === 0)
|
|
228
|
+
fail('syntax');
|
|
229
|
+
lastGroupRisky = groups.pop();
|
|
230
|
+
if (lastGroupRisky)
|
|
231
|
+
markRisky(); // "contains" is transitive
|
|
232
|
+
last = 'group';
|
|
233
|
+
i++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
case '|': {
|
|
237
|
+
markRisky();
|
|
238
|
+
last = 'none';
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case '^':
|
|
242
|
+
case '$': {
|
|
243
|
+
last = 'none';
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case '*':
|
|
247
|
+
case '+':
|
|
248
|
+
case '?':
|
|
249
|
+
case '{': {
|
|
250
|
+
let end = i;
|
|
251
|
+
let isUnbounded = c === '*' || c === '+';
|
|
252
|
+
if (c === '{')
|
|
253
|
+
[end, isUnbounded] = parseRepeat();
|
|
254
|
+
if (last === 'none' || last === 'quantifier')
|
|
255
|
+
fail('syntax');
|
|
256
|
+
if (last === 'group' && lastGroupRisky)
|
|
257
|
+
fail('nested-quantifier');
|
|
258
|
+
markRisky();
|
|
259
|
+
if (isUnbounded)
|
|
260
|
+
unbounded++;
|
|
261
|
+
i = end + 1;
|
|
262
|
+
if (p[i] === '?')
|
|
263
|
+
i++; // lazy
|
|
264
|
+
else if (p[i] === '+')
|
|
265
|
+
fail('possessive');
|
|
266
|
+
last = 'quantifier';
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
default: {
|
|
270
|
+
last = 'atom';
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
i++;
|
|
274
|
+
}
|
|
275
|
+
if (groups.length > 0)
|
|
276
|
+
fail('syntax'); // unclosed (
|
|
277
|
+
return unbounded;
|
|
278
|
+
}
|