@quonfig/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -77
- package/dist/agent/skill-bundle.generated.d.ts +1 -1
- package/dist/agent/skill-bundle.generated.js +4 -4
- package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
- package/dist/codegen/schema-extractor.d.ts +1 -1
- package/dist/commands/config-schema.js +9 -2
- package/dist/commands/create.d.ts +3 -1
- package/dist/commands/create.js +93 -20
- package/dist/commands/environments.d.ts +8 -0
- package/dist/commands/environments.js +32 -0
- package/dist/commands/get.d.ts +1 -0
- package/dist/commands/get.js +40 -3
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +69 -32
- package/dist/commands/set-default.d.ts +2 -0
- package/dist/commands/set-default.js +98 -35
- package/dist/commands/set-rollout.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +35 -3
- package/dist/interactive-prompt.d.ts +18 -0
- package/dist/interactive-prompt.js +11 -2
- package/dist/migrate/quonfig-target/operators.js +4 -2
- package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
- package/dist/migrate/quonfig-target/ruleset.js +132 -8
- package/dist/migrate/sources/flagsmith/translate.js +11 -0
- package/dist/migrate/sources/launchdarkly/translate.js +19 -16
- package/dist/push/git-pack.d.ts +12 -0
- package/dist/push/git-pack.js +27 -3
- package/dist/push/run-push.d.ts +2 -1
- package/dist/push/run-push.js +1 -2
- package/dist/ui/get-confirmation.d.ts +10 -1
- package/dist/ui/get-confirmation.js +13 -4
- package/dist/ui/get-string.d.ts +18 -1
- package/dist/ui/get-string.js +20 -5
- package/dist/ui/get-value.d.ts +3 -1
- package/dist/ui/get-value.js +7 -7
- package/dist/util/clone-and-stack-push.js +8 -3
- package/dist/util/encryption.js +11 -20
- package/dist/util/git-ops.d.ts +4 -2
- package/dist/util/git-ops.js +4 -2
- package/dist/util/redact.d.ts +56 -0
- package/dist/util/redact.js +98 -0
- package/dist/util/value-source.d.ts +30 -0
- package/dist/util/value-source.js +81 -0
- package/dist/verify/regex-dialect.d.ts +39 -0
- package/dist/verify/regex-dialect.js +278 -0
- package/dist/verify/standalone.d.ts +3 -1
- package/dist/verify/standalone.js +4 -19
- package/dist/verify/validate.js +20 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +218 -97
- package/package.json +18 -16
|
@@ -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
|
|
111
|
-
if (
|
|
213
|
+
const mapped = clauseToCriteria(clause, ctx);
|
|
214
|
+
if (mapped === null)
|
|
112
215
|
return null;
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 {
|
|
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') {
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
+
return confirm({ default: false, message }, context);
|
|
11
20
|
};
|
|
12
21
|
export default getConfirmation;
|
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);
|