@quonfig/cli 0.4.0 → 0.5.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.
Files changed (47) hide show
  1. package/README.md +157 -75
  2. package/dist/agent/skill-bundle.generated.d.ts +1 -1
  3. package/dist/agent/skill-bundle.generated.js +4 -4
  4. package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
  5. package/dist/codegen/schema-extractor.d.ts +1 -1
  6. package/dist/commands/config-schema.js +9 -2
  7. package/dist/commands/create.d.ts +3 -1
  8. package/dist/commands/create.js +93 -20
  9. package/dist/commands/get.d.ts +1 -0
  10. package/dist/commands/get.js +40 -3
  11. package/dist/commands/info.js +10 -1
  12. package/dist/commands/set-default.d.ts +2 -0
  13. package/dist/commands/set-default.js +70 -18
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +35 -3
  16. package/dist/interactive-prompt.d.ts +18 -0
  17. package/dist/interactive-prompt.js +11 -2
  18. package/dist/migrate/quonfig-target/operators.js +4 -2
  19. package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
  20. package/dist/migrate/quonfig-target/ruleset.js +132 -8
  21. package/dist/migrate/sources/flagsmith/translate.js +11 -0
  22. package/dist/migrate/sources/launchdarkly/translate.js +19 -16
  23. package/dist/push/git-pack.d.ts +12 -0
  24. package/dist/push/git-pack.js +27 -3
  25. package/dist/push/run-push.d.ts +2 -1
  26. package/dist/push/run-push.js +1 -2
  27. package/dist/ui/get-string.d.ts +18 -1
  28. package/dist/ui/get-string.js +20 -5
  29. package/dist/ui/get-value.d.ts +3 -1
  30. package/dist/ui/get-value.js +7 -7
  31. package/dist/util/clone-and-stack-push.js +8 -3
  32. package/dist/util/encryption.js +11 -20
  33. package/dist/util/git-ops.d.ts +4 -2
  34. package/dist/util/git-ops.js +4 -2
  35. package/dist/util/redact.d.ts +56 -0
  36. package/dist/util/redact.js +98 -0
  37. package/dist/util/value-source.d.ts +30 -0
  38. package/dist/util/value-source.js +81 -0
  39. package/dist/verify/regex-dialect.d.ts +39 -0
  40. package/dist/verify/regex-dialect.js +278 -0
  41. package/dist/verify/standalone.d.ts +3 -1
  42. package/dist/verify/standalone.js +4 -19
  43. package/dist/verify/validate.js +20 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/oclif.manifest.json +236 -192
  47. package/package.json +18 -16
@@ -9,7 +9,7 @@
9
9
  * plan §5.4.
10
10
  */
11
11
  import { resolveKey } from '../../key-rewriter.js';
12
- import { clauseToCriterion, collapseEnvironment, } from '../../quonfig-target/ruleset.js';
12
+ import { clausesToCriteria, collapseEnvironment, expandClauses, } from '../../quonfig-target/ruleset.js';
13
13
  import { inferFlagValueType, toQuonfigValue } from '../../quonfig-target/values.js';
14
14
  const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
15
15
  /**
@@ -202,9 +202,7 @@ export function translateSegment(segment, report) {
202
202
  rules.push(keyListRule(ic.contextKind, ic.values, true));
203
203
  }
204
204
  for (const rule of segment.rules ?? []) {
205
- const built = buildSegmentRule(rule, ctx);
206
- if (built !== null)
207
- rules.push(built);
205
+ rules.push(...buildSegmentRules(rule, ctx));
208
206
  }
209
207
  // Trailing catch-all: anyone not matched above is not in the segment.
210
208
  rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
@@ -225,21 +223,26 @@ export function translateSegment(segment, report) {
225
223
  return out;
226
224
  }
227
225
  /**
228
- * Build a Quonfig rule from a LaunchDarkly segment rule. A segment rule's
226
+ * Build the Quonfig rules for a LaunchDarkly segment rule. A segment rule's
229
227
  * clauses AND together and the rule means "in the segment". A rule-level
230
228
  * `weight` is a percentage of matching contexts — converted to a
231
- * `weighted_values` bool split (and reported as re-bucketing). Returns `null`
229
+ * `weighted_values` bool split (and reported as re-bucketing). Usually one
230
+ * rule; several when a multi-pattern `matches` is split (qfg-ktkr.6); empty
232
231
  * when a clause cannot be mapped (the whole rule is dropped — see `buildRule`).
233
232
  */
234
- function buildSegmentRule(rule, ctx) {
235
- // Reuse the clause-mapping verb via a tiny inline AND loop so segment rules
236
- // get the exact same operator handling (and whole-rule-drop) as flag rules.
237
- const criteria = [];
238
- for (const clause of rule.clauses) {
239
- const criterion = clauseToCriterion(clause, ctx);
240
- if (criterion === null)
241
- return null;
242
- criteria.push(criterion);
233
+ function buildSegmentRules(rule, ctx) {
234
+ // Reuse the clause-mapping verbs so segment rules get the exact same operator
235
+ // handling (whole-rule-drop, multi-pattern `matches` expansion — qfg-ktkr.6)
236
+ // as flag rules. Each alternative is its own "in the segment" rule.
237
+ const alternatives = expandClauses(rule.clauses, ctx);
238
+ if (alternatives === null)
239
+ return [];
240
+ const criteriaSets = [];
241
+ for (const alt of alternatives) {
242
+ const criteria = clausesToCriteria(alt, ctx);
243
+ if (criteria === null)
244
+ return [];
245
+ criteriaSets.push(criteria);
243
246
  }
244
247
  const value = rule.weight === undefined
245
248
  ? { type: 'bool', value: true }
@@ -258,5 +261,5 @@ function buildSegmentRule(rule, ctx) {
258
261
  if (rule.weight !== undefined) {
259
262
  ctx.report.add('rebucketed-rollout', ctx.sourceKey, `segment rule has a ${rule.weight / 1000}% weight — converted to a weighted bool split; users will be re-bucketed`);
260
263
  }
261
- return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
264
+ return criteriaSets.map((criteria) => ({ criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value }));
262
265
  }
@@ -17,6 +17,8 @@
17
17
  * stand up real tmp-dir git repos rather than mocking the shell — the
18
18
  * pack format is too fiddly to fake convincingly.
19
19
  */
20
+ /** `expectedSha` for a branch the server does not have yet. */
21
+ export declare const ZERO_OID = "0000000000000000000000000000000000000000";
20
22
  /** Per §7 open Q #7 of the design plan: pack size cap of 25 MiB. */
21
23
  export declare const MAX_PACK_BYTES: number;
22
24
  /**
@@ -62,6 +64,16 @@ export interface BuildPackOptions {
62
64
  * from expectedSha" recipe. Buffers the entire output to memory so we
63
65
  * can enforce the §7 cap before shipping anything to the server.
64
66
  *
67
+ * For a brand-new branch `expectedSha` is {@link ZERO_OID}, which is not
68
+ * an object git can exclude (qfg-b1eo). Then the pack excludes everything
69
+ * reachable from the `origin/*` remote-tracking refs, i.e. what the server
70
+ * already has, and with no such refs it carries the full history:
71
+ *
72
+ * printf '<newSha>\n^<origin ref sha>\n…' | git pack-objects --revs --stdout
73
+ *
74
+ * (pack-objects' stdin takes revs and `--not` only, not `--remotes`, so
75
+ * the refs are resolved with `git for-each-ref` first.)
76
+ *
65
77
  * Returns an empty `Uint8Array` when `expectedSha === newSha` — there
66
78
  * are no new commits, so `pack-objects` would either error on stdin
67
79
  * with no candidate revs or produce an empty pack. Skipping the spawn
@@ -19,6 +19,8 @@
19
19
  */
20
20
  import { spawn } from 'node:child_process';
21
21
  import { GIT_SAFE_ARGS, GIT_SAFE_ENV, runGit } from '../util/git-ops.js';
22
+ /** `expectedSha` for a branch the server does not have yet. */
23
+ export const ZERO_OID = '0000000000000000000000000000000000000000';
22
24
  /** Per §7 open Q #7 of the design plan: pack size cap of 25 MiB. */
23
25
  export const MAX_PACK_BYTES = 25 * 1024 * 1024;
24
26
  /**
@@ -69,6 +71,16 @@ export class PackTooLargeError extends Error {
69
71
  * from expectedSha" recipe. Buffers the entire output to memory so we
70
72
  * can enforce the §7 cap before shipping anything to the server.
71
73
  *
74
+ * For a brand-new branch `expectedSha` is {@link ZERO_OID}, which is not
75
+ * an object git can exclude (qfg-b1eo). Then the pack excludes everything
76
+ * reachable from the `origin/*` remote-tracking refs, i.e. what the server
77
+ * already has, and with no such refs it carries the full history:
78
+ *
79
+ * printf '<newSha>\n^<origin ref sha>\n…' | git pack-objects --revs --stdout
80
+ *
81
+ * (pack-objects' stdin takes revs and `--not` only, not `--remotes`, so
82
+ * the refs are resolved with `git for-each-ref` first.)
83
+ *
72
84
  * Returns an empty `Uint8Array` when `expectedSha === newSha` — there
73
85
  * are no new commits, so `pack-objects` would either error on stdin
74
86
  * with no candidate revs or produce an empty pack. Skipping the spawn
@@ -79,6 +91,7 @@ export async function buildPack(dir, expectedSha, newSha, options = {}) {
79
91
  return new Uint8Array(0);
80
92
  }
81
93
  const limit = options.maxBytes ?? MAX_PACK_BYTES;
94
+ const excludes = expectedSha === ZERO_OID ? await originRefShas(dir) : [expectedSha];
82
95
  return new Promise((resolve, reject) => {
83
96
  const env = { ...process.env, ...GIT_SAFE_ENV };
84
97
  const args = [...GIT_SAFE_ARGS, '-C', dir, 'pack-objects', '--revs', '--stdout'];
@@ -120,8 +133,19 @@ export async function buildPack(dir, expectedSha, newSha, options = {}) {
120
133
  }
121
134
  resolve(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
122
135
  });
123
- // Feed the rev-list spec on stdin: include newSha, exclude expectedSha.
124
- // `--revs` tells pack-objects to read this as `git rev-list` would.
125
- child.stdin.end(`${newSha}\n^${expectedSha}\n`);
136
+ // Feed the rev-list spec on stdin: include newSha, exclude expectedSha
137
+ // (or, for a new branch, the origin/* refs). `--revs` tells
138
+ // pack-objects to read this as `git rev-list` would.
139
+ child.stdin.end([newSha, ...excludes.map((sha) => `^${sha}`)].map((line) => `${line}\n`).join(''));
126
140
  });
127
141
  }
142
+ /** Commit SHAs of the `origin/*` remote-tracking refs (deduped). */
143
+ async function originRefShas(dir) {
144
+ const res = await runGit(['-C', dir, 'for-each-ref', '--format=%(objectname)', 'refs/remotes/origin']);
145
+ return [
146
+ ...new Set(res.stdout
147
+ .split('\n')
148
+ .map((l) => l.trim())
149
+ .filter((l) => l.length > 0)),
150
+ ];
151
+ }
@@ -19,7 +19,8 @@
19
19
  * confirmation — that prompt is the destructive-change brake.
20
20
  */
21
21
  import { FileDelta } from './diff-summary.js';
22
- import type { CurrentBranchResult } from './git-pack.js';
22
+ import { type CurrentBranchResult } from './git-pack.js';
23
+ /** All-zeros object id, the git-protocol convention for "no such ref". */
23
24
  export interface GiteaTokenMintResult {
24
25
  expiresAt: string | null;
25
26
  repoUrl: string;
@@ -24,8 +24,7 @@ import { readWorkspaceSlug, tryParseWorkspacePin, writeWorkspaceSlug } from '../
24
24
  import { checkIdentity } from './identity-check.js';
25
25
  import { confirmTypedSlug, confirmYesNo } from './confirm.js';
26
26
  import { summarizeDiff } from './diff-summary.js';
27
- /** All-zeros object id, the git-protocol convention for "no such ref". */
28
- const ZERO_OID = '0000000000000000000000000000000000000000';
27
+ import { ZERO_OID } from './git-pack.js';
29
28
  export class PushFatalError extends Error {
30
29
  code;
31
30
  constructor(message, code = 'PUSH_FATAL') {
@@ -1,5 +1,22 @@
1
- declare const getString: ({ allowBlank, message }: {
1
+ /** Streams for the masked prompt; tests pass their own instead of the TTY. */
2
+ export type PromptContext = {
3
+ input?: NodeJS.ReadableStream;
4
+ output?: NodeJS.WritableStream;
5
+ };
6
+ /**
7
+ * Prompt for a string.
8
+ *
9
+ * `mask: true` (secret values, qfg-38lp) never echoes what is typed: each
10
+ * character shows as `*`. It uses @inquirer/prompts' `password`, which reads
11
+ * through readline with a muted output stream. oclif's `ux.prompt({type:
12
+ * 'mask'})` was used before and was not safe: it wrote each typed character
13
+ * and only redrew `*` after Enter (so the plaintext stayed in scrollback and
14
+ * terminal logs), and it hung when a paste delivered "value\r" in one chunk.
15
+ */
16
+ declare const getString: ({ allowBlank, context, mask, message, }: {
2
17
  allowBlank: boolean;
18
+ context?: PromptContext;
19
+ mask?: boolean;
3
20
  message: string;
4
21
  }) => Promise<string>;
5
22
  export default getString;
@@ -1,8 +1,23 @@
1
+ import { password } from '@inquirer/prompts';
1
2
  import { ux } from '@oclif/core';
2
- const getString = async ({ allowBlank, message }) => {
3
- const options = {
4
- required: !allowBlank,
5
- };
6
- return ux.prompt(message, options);
3
+ /**
4
+ * Prompt for a string.
5
+ *
6
+ * `mask: true` (secret values, qfg-38lp) never echoes what is typed: each
7
+ * character shows as `*`. It uses @inquirer/prompts' `password`, which reads
8
+ * through readline with a muted output stream. oclif's `ux.prompt({type:
9
+ * 'mask'})` was used before and was not safe: it wrote each typed character
10
+ * and only redrew `*` after Enter (so the plaintext stayed in scrollback and
11
+ * terminal logs), and it hung when a paste delivered "value\r" in one chunk.
12
+ */
13
+ const getString = async ({ allowBlank, context, mask, message, }) => {
14
+ if (mask) {
15
+ return password({
16
+ mask: '*',
17
+ message,
18
+ validate: (value) => allowBlank || value.length > 0 || 'A value is required',
19
+ }, context);
20
+ }
21
+ return ux.prompt(message, { required: !allowBlank });
7
22
  };
8
23
  export default getString;
@@ -4,8 +4,10 @@ interface Environment {
4
4
  id: string;
5
5
  name: string;
6
6
  }
7
- declare const getValue: ({ allowBlank, desiredValue, environment, flags, key, message, quonfig, }: {
7
+ declare const getValue: ({ allowBlank, desiredValue, environment, flags, key, mask, message, quonfig, }: {
8
8
  allowBlank?: boolean;
9
+ /** mask the prompt input (secret values, qfg-38lp) */
10
+ mask?: boolean;
9
11
  desiredValue: string | undefined;
10
12
  environment?: Environment;
11
13
  flags: {
@@ -3,15 +3,15 @@ import { failure, noop, success } from '../result.js';
3
3
  import isInteractive from '../util/is-interactive.js';
4
4
  import validateValue from '../validations/value.js';
5
5
  import getString from './get-string.js';
6
- const getValue = async ({ allowBlank = true, desiredValue, environment, flags, key, message, quonfig, }) => {
6
+ const getValue = async ({ allowBlank = true, desiredValue, environment, flags, key, mask, message, quonfig, }) => {
7
7
  if (desiredValue === undefined && !isInteractive(flags)) {
8
- return failure(`No value provided for ${key ?? message}. Pass --value=<value> (or use --env-var) when running non-interactively.`);
8
+ return failure(`No value provided for ${key ?? message}. Pass --value=<value>, --value-stdin or --value-from-env=<VAR> (or use --env-var) when running non-interactively.`);
9
9
  }
10
10
  if (!key) {
11
11
  if (desiredValue !== undefined) {
12
12
  return success(desiredValue);
13
13
  }
14
- const value = await promptForValue({ allowBlank, message });
14
+ const value = await promptForValue({ allowBlank, mask, message });
15
15
  if (value === undefined || value === null) {
16
16
  return noop();
17
17
  }
@@ -22,7 +22,7 @@ const getValue = async ({ allowBlank = true, desiredValue, environment, flags, k
22
22
  if (!config) {
23
23
  return failure(`Could not find config named ${key}`);
24
24
  }
25
- const selectedValue = desiredValue ?? (await promptForValue({ allowBlank, config, currentDefault, message }));
25
+ const selectedValue = desiredValue ?? (await promptForValue({ allowBlank, config, currentDefault, mask, message }));
26
26
  if (selectedValue === undefined || selectedValue === null) {
27
27
  return noop();
28
28
  }
@@ -38,11 +38,11 @@ const valueToString = (v) => {
38
38
  return '';
39
39
  return String(v.value);
40
40
  };
41
- const promptForValue = async ({ allowBlank, config: _config, currentDefault, message, }) => {
41
+ const promptForValue = async ({ allowBlank, config: _config, currentDefault, mask, message, }) => {
42
42
  // In the new quonfig format, allowable values aren't stored at the config level.
43
43
  // Just prompt for a string input.
44
- if (!currentDefault) {
45
- return getString({ allowBlank, message });
44
+ if (!currentDefault || mask) {
45
+ return getString({ allowBlank, mask, message });
46
46
  }
47
47
  const autoCompleteMessage = `The current default is \`${valueToString(currentDefault)}\`. Enter your new default.`;
48
48
  return getString({ allowBlank, message: autoCompleteMessage });
@@ -1,8 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { MIGRATOR_IDENTITY, runGit as runGitSafe, spawnGit } from './git-ops.js';
3
+ import { MIGRATOR_IDENTITY, redactToken, runGit as runGitSafe, spawnGit } from './git-ops.js';
4
4
  export { MIGRATOR_IDENTITY } from './git-ops.js';
5
- const redactToken = (s) => s.replaceAll(/:([^\s/@]+)@/g, ':***@');
6
5
  export class PushConflictError extends Error {
7
6
  constructor(message) {
8
7
  super(message);
@@ -54,7 +53,13 @@ const getOriginUrl = async (dir) => {
54
53
  return null;
55
54
  }
56
55
  };
57
- const normalizeRemoteUrl = (url) => redactToken(url.replace(/\.git$/, '').trim());
56
+ // Compare repos without credentials: the same repo with a different (or no)
57
+ // username/token is still the same repo. ensureCloneOrReuse re-points origin
58
+ // at the fresh remoteUrl after a match.
59
+ const normalizeRemoteUrl = (url) => url
60
+ .trim()
61
+ .replace(/\.git$/, '')
62
+ .replace(/^(https?:\/\/)[^/@]*@/i, '$1');
58
63
  const ensureCloneOrReuse = async (remoteUrl, localDir, branch) => {
59
64
  if (fs.existsSync(localDir) && (await isGitRepo(localDir))) {
60
65
  const origin = await getOriginUrl(localDir);
@@ -1,5 +1,5 @@
1
1
  import { encryption } from '@quonfig/node';
2
- import forge from 'node-forge';
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
- // Convert hex strings to bytes
32
- const keyBytes = forge.util.hexToBytes(keyStringHex);
33
- const encryptedBytes = forge.util.hexToBytes(encryptedDataPart);
34
- const ivBytes = forge.util.hexToBytes(ivPart);
35
- const tagBytes = forge.util.hexToBytes(authTagPart);
36
- // Create decipher. node-forge AES-GCM with the IV passed to start() below, not
37
- // Node's deprecated IV-less crypto.createDecipher that this rule targets.
38
- // nosemgrep: javascript.node-crypto.security.create-de-cipher-no-iv.create-de-cipher-no-iv
39
- const decipher = forge.cipher.createDecipher('AES-GCM', keyBytes);
40
- decipher.start({
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
@@ -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 a token from a URL string so it is safe to display to users.
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: (url: string) => string;
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`. */
@@ -17,9 +17,11 @@ export const GIT_SAFE_ENV = {
17
17
  GCM_INTERACTIVE: 'Never',
18
18
  };
19
19
  /**
20
- * Redact a token from a URL string so it is safe to display to users.
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 = (url) => url.replace(/:([^/@]+)@/, ':***@');
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
+ };