@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.
- package/README.md +157 -75
- 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/get.d.ts +1 -0
- package/dist/commands/get.js +40 -3
- package/dist/commands/info.js +10 -1
- package/dist/commands/set-default.d.ts +2 -0
- package/dist/commands/set-default.js +70 -18
- 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-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 +236 -192
- 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 {
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
|
235
|
-
// Reuse the clause-mapping
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
}
|
package/dist/push/git-pack.d.ts
CHANGED
|
@@ -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
|
package/dist/push/git-pack.js
CHANGED
|
@@ -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
|
-
//
|
|
125
|
-
|
|
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
|
+
}
|
package/dist/push/run-push.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/push/run-push.js
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/dist/ui/get-string.d.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
|
|
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;
|
package/dist/ui/get-string.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
import { password } from '@inquirer/prompts';
|
|
1
2
|
import { ux } from '@oclif/core';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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;
|
package/dist/ui/get-value.d.ts
CHANGED
|
@@ -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: {
|
package/dist/ui/get-value.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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
|
+
};
|