@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.
Files changed (53) hide show
  1. package/README.md +229 -77
  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/environments.d.ts +8 -0
  10. package/dist/commands/environments.js +32 -0
  11. package/dist/commands/get.d.ts +1 -0
  12. package/dist/commands/get.js +40 -3
  13. package/dist/commands/info.d.ts +1 -0
  14. package/dist/commands/info.js +69 -32
  15. package/dist/commands/set-default.d.ts +2 -0
  16. package/dist/commands/set-default.js +98 -35
  17. package/dist/commands/set-rollout.js +2 -2
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +35 -3
  20. package/dist/interactive-prompt.d.ts +18 -0
  21. package/dist/interactive-prompt.js +11 -2
  22. package/dist/migrate/quonfig-target/operators.js +4 -2
  23. package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
  24. package/dist/migrate/quonfig-target/ruleset.js +132 -8
  25. package/dist/migrate/sources/flagsmith/translate.js +11 -0
  26. package/dist/migrate/sources/launchdarkly/translate.js +19 -16
  27. package/dist/push/git-pack.d.ts +12 -0
  28. package/dist/push/git-pack.js +27 -3
  29. package/dist/push/run-push.d.ts +2 -1
  30. package/dist/push/run-push.js +1 -2
  31. package/dist/ui/get-confirmation.d.ts +10 -1
  32. package/dist/ui/get-confirmation.js +13 -4
  33. package/dist/ui/get-string.d.ts +18 -1
  34. package/dist/ui/get-string.js +20 -5
  35. package/dist/ui/get-value.d.ts +3 -1
  36. package/dist/ui/get-value.js +7 -7
  37. package/dist/util/clone-and-stack-push.js +8 -3
  38. package/dist/util/encryption.js +11 -20
  39. package/dist/util/git-ops.d.ts +4 -2
  40. package/dist/util/git-ops.js +4 -2
  41. package/dist/util/redact.d.ts +56 -0
  42. package/dist/util/redact.js +98 -0
  43. package/dist/util/value-source.d.ts +30 -0
  44. package/dist/util/value-source.js +81 -0
  45. package/dist/verify/regex-dialect.d.ts +39 -0
  46. package/dist/verify/regex-dialect.js +278 -0
  47. package/dist/verify/standalone.d.ts +3 -1
  48. package/dist/verify/standalone.js +4 -19
  49. package/dist/verify/validate.js +20 -0
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/oclif.manifest.json +218 -97
  53. package/package.json +18 -16
@@ -91,6 +91,27 @@ export interface RulesetContext {
91
91
  * becomes a dotted path (`address.city`).
92
92
  */
93
93
  export declare function normalizeAttribute(contextKind: string | undefined, attribute: string): string;
94
+ /**
95
+ * Join several regex patterns into one alternation `(?:a)|(?:b)` (qfg-ktkr.6).
96
+ * Each alternative is wrapped in a non-capturing group so anchors and inner
97
+ * `|` stay scoped to their own pattern. The join adds no quantified group, so
98
+ * it stays inside the v1 safety rule as long as every pattern is in v1 and
99
+ * the length and unbounded-quantifier caps hold; returns `null` otherwise
100
+ * (qfg-ktkr.2: the full checkRegexV1, not just the caps).
101
+ */
102
+ export declare function joinRegexAlternation(patterns: string[]): null | string;
103
+ /**
104
+ * Expand a provider rule's clauses into the OR-alternatives of clause lists
105
+ * that preserve LaunchDarkly semantics (qfg-ktkr.6). LD ORs a clause's values;
106
+ * Quonfig has no within-rule OR, so a multi-value `matches` clause whose
107
+ * patterns cannot be joined within the v1 caps is:
108
+ * - negated (matches none): split into single-pattern negated clauses ANDed
109
+ * in the same rule;
110
+ * - positive (matches any): fanned out into one alternative per pattern.
111
+ * Alternatives serve the same value, so first-match-wins over them is the OR.
112
+ * Returns `null` (rule dropped + reported) past MAX_SPLIT_RULES alternatives.
113
+ */
114
+ export declare function expandClauses(clauses: SourceClause[], ctx: RulesetContext): null | SourceClause[][];
94
115
  /**
95
116
  * Map one provider clause to a Quonfig criterion, or `null` when the clause's
96
117
  * operator has no v1 mapping (D3 — negated comparisons, applicationVersion…).
@@ -98,12 +119,39 @@ export declare function normalizeAttribute(contextKind: string | undefined, attr
98
119
  * partial AND would silently broaden targeting.
99
120
  */
100
121
  export declare function clauseToCriterion(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion | null;
122
+ /**
123
+ * Map one provider clause to the Quonfig criteria it ANDs into a rule, or
124
+ * `null` when the clause cannot be mapped (see `clauseToCriterion`).
125
+ *
126
+ * A negated property clause (`in`, `contains`, `startsWith`, `endsWith`,
127
+ * `matches` with `negate: true`) gets an `IS_PRESENT` guard on the same
128
+ * property ANDed in front of it (qfg-goi1.5.10, operator-semantics Q6b).
129
+ * LaunchDarkly never matches a context that lacks the clause attribute, negated
130
+ * or not; Quonfig's negated operators do match a missing attribute (Q6a, kept),
131
+ * so without the guard a migrated rule would broaden to every context missing
132
+ * the attribute. Negated `segmentMatch` (NOT_IN_SEG) has no property and is not
133
+ * guarded. This only shapes NEW migrations; already-migrated workspaces are
134
+ * never rewritten.
135
+ */
136
+ export declare function clauseToCriteria(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion[] | null;
101
137
  /**
102
138
  * Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
103
139
  * served value. Returns `null` when any clause cannot be mapped — the rule is
104
140
  * dropped wholesale and reported, rather than silently broadened.
105
141
  */
106
142
  export declare function buildRule(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule | null;
143
+ /**
144
+ * AND a clause list into criteria, or `null` when any clause is unmappable.
145
+ * An IS_PRESENT guard already in the list is not repeated (a split negated
146
+ * multi-pattern clause guards its property once).
147
+ */
148
+ export declare function clausesToCriteria(clauses: SourceClause[], ctx: RulesetContext): null | QuonfigCriterion[];
149
+ /**
150
+ * Build the Quonfig rules for one provider rule: `buildRule` over each
151
+ * alternative from `expandClauses` (qfg-ktkr.6). Usually one rule; empty when
152
+ * the rule is dropped (and reported).
153
+ */
154
+ export declare function buildRules(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule[];
107
155
  /**
108
156
  * Convert a provider rollout to a Quonfig `weighted_values`. The experiment
109
157
  * framing (`kind: "experiment"` + `seed`) is dropped + reported (plan §5.4),
@@ -8,6 +8,7 @@
8
8
  * verbs do the LaunchDarkly collapse; a second provider with a different
9
9
  * topology composes them differently.
10
10
  */
11
+ import { checkRegexV1 } from '../../verify/regex-dialect.js';
11
12
  import { resolveKey } from '../key-rewriter.js';
12
13
  import { mapLaunchDarklyOperator } from './operators.js';
13
14
  import { toQuonfigValue } from './values.js';
@@ -41,6 +42,57 @@ export function normalizeAttribute(contextKind, attribute) {
41
42
  const dotted = attribute.replace(/^\//, '').replaceAll('/', '.');
42
43
  return `${kind}.${dotted}`;
43
44
  }
45
+ /** Upper bound on rules one LD rule may fan out into when patterns are split. */
46
+ const MAX_SPLIT_RULES = 16;
47
+ /**
48
+ * Join several regex patterns into one alternation `(?:a)|(?:b)` (qfg-ktkr.6).
49
+ * Each alternative is wrapped in a non-capturing group so anchors and inner
50
+ * `|` stay scoped to their own pattern. The join adds no quantified group, so
51
+ * it stays inside the v1 safety rule as long as every pattern is in v1 and
52
+ * the length and unbounded-quantifier caps hold; returns `null` otherwise
53
+ * (qfg-ktkr.2: the full checkRegexV1, not just the caps).
54
+ */
55
+ export function joinRegexAlternation(patterns) {
56
+ const joined = patterns.map((p) => `(?:${p})`).join('|');
57
+ return checkRegexV1(joined) === null ? joined : null;
58
+ }
59
+ /** A multi-value `matches` clause whose patterns cannot be joined within v1. */
60
+ function isUnjoinableMatches(clause) {
61
+ return clause.op === 'matches' && clause.values.length > 1 && joinRegexAlternation(clause.values.map(String)) === null;
62
+ }
63
+ /**
64
+ * Expand a provider rule's clauses into the OR-alternatives of clause lists
65
+ * that preserve LaunchDarkly semantics (qfg-ktkr.6). LD ORs a clause's values;
66
+ * Quonfig has no within-rule OR, so a multi-value `matches` clause whose
67
+ * patterns cannot be joined within the v1 caps is:
68
+ * - negated (matches none): split into single-pattern negated clauses ANDed
69
+ * in the same rule;
70
+ * - positive (matches any): fanned out into one alternative per pattern.
71
+ * Alternatives serve the same value, so first-match-wins over them is the OR.
72
+ * Returns `null` (rule dropped + reported) past MAX_SPLIT_RULES alternatives.
73
+ */
74
+ export function expandClauses(clauses, ctx) {
75
+ let alternatives = [[]];
76
+ for (const clause of clauses) {
77
+ if (!isUnjoinableMatches(clause)) {
78
+ for (const alt of alternatives)
79
+ alt.push(clause);
80
+ continue;
81
+ }
82
+ const singles = clause.values.map((v) => ({ ...clause, values: [v] }));
83
+ if (clause.negate) {
84
+ for (const alt of alternatives)
85
+ alt.push(...singles);
86
+ continue;
87
+ }
88
+ alternatives = alternatives.flatMap((alt) => singles.map((single) => [...alt, single]));
89
+ if (alternatives.length > MAX_SPLIT_RULES) {
90
+ ctx.report.add('skipped-rule', ctx.sourceKey, `rule dropped: its regex clauses expand to more than ${MAX_SPLIT_RULES} rules (patterns too long or too complex to join within Quonfig regex v1)`);
91
+ return null;
92
+ }
93
+ }
94
+ return alternatives;
95
+ }
44
96
  /** Build a `valueToMatch` typed value for a non-presence, non-segment clause. */
45
97
  function valueToMatchFor(operator, clause, ctx) {
46
98
  if (STRING_LIST_OPERATORS.has(operator)) {
@@ -60,6 +112,11 @@ function valueToMatchFor(operator, clause, ctx) {
60
112
  }
61
113
  if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
62
114
  if (clause.values.length > 1) {
115
+ // LD ORs the patterns (qfg-ktkr.6). `expandClauses` splits any set too
116
+ // big to join, so this only falls back for a direct caller.
117
+ const joined = joinRegexAlternation(clause.values.map(String));
118
+ if (joined !== null)
119
+ return { type: 'string', value: joined };
63
120
  ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause listed ${clause.values.length} patterns; Quonfig PROP_MATCHES takes one — only the first kept`);
64
121
  }
65
122
  return { type: 'string', value: String(clause.values[0] ?? '') };
@@ -97,23 +154,92 @@ export function clauseToCriterion(clause, ctx) {
97
154
  ? { operator }
98
155
  : { operator, propertyName: normalizeAttribute(clause.contextKind, clause.attribute) };
99
156
  criterion.valueToMatch = valueToMatchFor(operator, clause, ctx);
157
+ // qfg-ktkr.2: a pattern outside Quonfig regex v1 would be refused by
158
+ // `qfg verify` and the Git hook, so drop the rule and report why instead of
159
+ // writing a file nobody can push.
160
+ if ((operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') && criterion.valueToMatch) {
161
+ const pattern = String(criterion.valueToMatch.value);
162
+ const violation = checkRegexV1(pattern);
163
+ if (violation) {
164
+ ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause on "${clause.attribute}" skipped: pattern "${pattern}" is outside Quonfig regex v1 (${violation.reason}). ${violation.hint}`);
165
+ return null;
166
+ }
167
+ }
100
168
  return criterion;
101
169
  }
170
+ /**
171
+ * Map one provider clause to the Quonfig criteria it ANDs into a rule, or
172
+ * `null` when the clause cannot be mapped (see `clauseToCriterion`).
173
+ *
174
+ * A negated property clause (`in`, `contains`, `startsWith`, `endsWith`,
175
+ * `matches` with `negate: true`) gets an `IS_PRESENT` guard on the same
176
+ * property ANDed in front of it (qfg-goi1.5.10, operator-semantics Q6b).
177
+ * LaunchDarkly never matches a context that lacks the clause attribute, negated
178
+ * or not; Quonfig's negated operators do match a missing attribute (Q6a, kept),
179
+ * so without the guard a migrated rule would broaden to every context missing
180
+ * the attribute. Negated `segmentMatch` (NOT_IN_SEG) has no property and is not
181
+ * guarded. This only shapes NEW migrations; already-migrated workspaces are
182
+ * never rewritten.
183
+ */
184
+ export function clauseToCriteria(clause, ctx) {
185
+ const criterion = clauseToCriterion(clause, ctx);
186
+ if (criterion === null)
187
+ return null;
188
+ if (clause.negate && criterion.propertyName !== undefined) {
189
+ return [{ operator: 'IS_PRESENT', propertyName: criterion.propertyName }, criterion];
190
+ }
191
+ return [criterion];
192
+ }
102
193
  /**
103
194
  * Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
104
195
  * served value. Returns `null` when any clause cannot be mapped — the rule is
105
196
  * dropped wholesale and reported, rather than silently broadened.
106
197
  */
107
198
  export function buildRule(clauses, value, ctx) {
199
+ const criteria = clausesToCriteria(clauses, ctx);
200
+ if (criteria === null)
201
+ return null;
202
+ // A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
203
+ return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
204
+ }
205
+ /**
206
+ * AND a clause list into criteria, or `null` when any clause is unmappable.
207
+ * An IS_PRESENT guard already in the list is not repeated (a split negated
208
+ * multi-pattern clause guards its property once).
209
+ */
210
+ export function clausesToCriteria(clauses, ctx) {
108
211
  const criteria = [];
109
212
  for (const clause of clauses) {
110
- const criterion = clauseToCriterion(clause, ctx);
111
- if (criterion === null)
213
+ const mapped = clauseToCriteria(clause, ctx);
214
+ if (mapped === null)
112
215
  return null;
113
- criteria.push(criterion);
216
+ for (const c of mapped) {
217
+ const duplicateGuard = c.operator === 'IS_PRESENT' &&
218
+ criteria.some((e) => e.operator === 'IS_PRESENT' && e.propertyName === c.propertyName);
219
+ if (!duplicateGuard)
220
+ criteria.push(c);
221
+ }
114
222
  }
115
- // A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
116
- return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
223
+ return criteria;
224
+ }
225
+ /**
226
+ * Build the Quonfig rules for one provider rule: `buildRule` over each
227
+ * alternative from `expandClauses` (qfg-ktkr.6). Usually one rule; empty when
228
+ * the rule is dropped (and reported).
229
+ */
230
+ export function buildRules(clauses, value, ctx) {
231
+ const alternatives = expandClauses(clauses, ctx);
232
+ if (alternatives === null)
233
+ return [];
234
+ const rules = [];
235
+ for (const alt of alternatives) {
236
+ const rule = buildRule(alt, value, ctx);
237
+ // An unmappable clause fails every alternative alike — report it once.
238
+ if (rule === null)
239
+ return [];
240
+ rules.push(rule);
241
+ }
242
+ return rules;
117
243
  }
118
244
  /** Resolve a variation index to its typed Quonfig value. */
119
245
  function variationValue(index, ctx) {
@@ -202,9 +328,7 @@ export function collapseEnvironment(env, ctx) {
202
328
  }
203
329
  for (const rule of env.rules ?? []) {
204
330
  const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
205
- const built = buildRule(rule.clauses, value, ctx);
206
- if (built !== null)
207
- rules.push(built);
331
+ rules.push(...buildRules(rule.clauses, value, ctx));
208
332
  }
209
333
  if (env.fallthrough) {
210
334
  rules.push({ criteria: [ALWAYS_TRUE], value: servedValue(env.fallthrough, ctx) });
@@ -15,6 +15,7 @@
15
15
  * mutable state. Report accumulation flows through the `ConversionReport`
16
16
  * argument supplied by the caller (the `flagsmith.ts` source).
17
17
  */
18
+ import { checkRegexV1 } from '../../../verify/regex-dialect.js';
18
19
  import { resolveKey } from '../../key-rewriter.js';
19
20
  import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
20
21
  import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
@@ -270,6 +271,16 @@ export function mapCondition(condition) {
270
271
  };
271
272
  }
272
273
  case 'REGEX': {
274
+ // qfg-ktkr.2: a pattern outside Quonfig regex v1 would be refused by
275
+ // `qfg verify` and the Git hook; drop the rule with the reason instead.
276
+ const pattern = String(value ?? '');
277
+ const violation = checkRegexV1(pattern);
278
+ if (violation) {
279
+ return {
280
+ drop: true,
281
+ reason: `pattern "${pattern}" is outside Quonfig regex v1 (${violation.reason}). ${violation.hint}`,
282
+ };
283
+ }
273
284
  return {
274
285
  criterion: {
275
286
  operator: 'PROP_MATCHES',
@@ -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,7 +1,16 @@
1
+ import type { PromptContext } from './get-string.js';
1
2
  export declare const confirmFlag: {
2
3
  confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
3
4
  };
4
- declare const getConfirmation: ({ flags, message, }: {
5
+ /**
6
+ * Ask a yes/no question; Enter alone answers no.
7
+ *
8
+ * Uses @inquirer/prompts' `confirm`, which reads in raw mode. oclif's
9
+ * `ux.confirm` read a cooked line, so under a pty the terminal echoed a stray
10
+ * `^D` before the success line (qfg-r3a47).
11
+ */
12
+ declare const getConfirmation: ({ context, flags, message, }: {
13
+ context?: PromptContext;
5
14
  flags: {
6
15
  confirm?: boolean;
7
16
  interactive?: boolean;
@@ -1,12 +1,21 @@
1
- import { Flags, ux } from '@oclif/core';
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
2
3
  import isInteractive from '../util/is-interactive.js';
3
4
  export const confirmFlag = { confirm: Flags.boolean({ description: 'confirm without prompt' }) };
4
- const getConfirmation = async ({ flags, message, }) => {
5
+ /**
6
+ * Ask a yes/no question; Enter alone answers no.
7
+ *
8
+ * Uses @inquirer/prompts' `confirm`, which reads in raw mode. oclif's
9
+ * `ux.confirm` read a cooked line, so under a pty the terminal echoed a stray
10
+ * `^D` before the success line (qfg-r3a47).
11
+ */
12
+ const getConfirmation = async ({ context, flags, message, }) => {
5
13
  if (flags.confirm)
6
14
  return true;
7
- if (!isInteractive(flags)) {
15
+ // A caller-supplied context (tests) brings its own streams instead of the TTY.
16
+ if (!context && !isInteractive(flags)) {
8
17
  throw new Error('Cannot prompt for confirmation in a non-interactive context. Pass --confirm to skip the confirmation prompt.');
9
18
  }
10
- return ux.confirm(message);
19
+ return confirm({ default: false, message }, context);
11
20
  };
12
21
  export default getConfirmation;
@@ -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);