@pareto4/pp-core 1.10.0 → 1.11.1
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/.claude-plugin/plugin.json +1 -1
- package/gemini-extension.json +1 -1
- package/package.json +5 -3
- package/pareto-skills/pp-brand/SKILL.md +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +49 -1
- package/pp-core/bin/lib/compose-bindings.cjs +785 -0
- package/pp-core/bin/lib/git-provider.cjs +279 -0
- package/pp-core/bin/lib/init-contract.cjs +1080 -0
- package/pp-core/bin/lib/init-interactive.cjs +202 -0
- package/pp-core/bin/lib/io.cjs +2 -0
- package/pp-core/bin/lib/org-profile.cjs +696 -0
- package/pp-core/bin/lib/platform-bindings.cjs +268 -64
- package/pp-core/bin/lib/vault-command-router.cjs +133 -2
- package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
- package/pp-core/bin/lib/vault-write-back.cjs +178 -92
- package/pp-core/bin/lib/vault.cjs +158 -81
- package/pp-core/bin/pp-tools.cjs +209 -2
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
- package/pp-core/workflows/init.md +137 -76
- package/scripts/gen-platform-bindings-schema.cjs +148 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/git-provider.cts — the provider seam (milestone v1.11.0, Phase 30-01;
|
|
4
|
+
* org-profile layer added Phase 33-03).
|
|
5
|
+
*
|
|
6
|
+
* ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
|
|
7
|
+
* compiles to the git-ignored `pp-core/bin/lib/git-provider.cjs`; do NOT
|
|
8
|
+
* hand-edit the compiled output. Behaviour is locked by
|
|
9
|
+
* `tests/git-provider.test.cjs`.
|
|
10
|
+
*
|
|
11
|
+
* This module is the single place every git-host decision in pp-core routes
|
|
12
|
+
* through: which provider is configured, whether a value is already a literal
|
|
13
|
+
* clone target, how a `group/project` slug composes into a clone URL for that
|
|
14
|
+
* provider, what environment a clone needs, and whether a host CLI (`glab`)
|
|
15
|
+
* may be spawned at all. It imports node builtins plus `./org-profile.cjs` —
|
|
16
|
+
* still never `./vault.cjs`, `./vault-coordinates.cjs`, `./io.cjs` or
|
|
17
|
+
* `./shell-command-projection.cjs` — so that both `src/vault.cts` and
|
|
18
|
+
* `src/vault-write-back.cts` can import it (since 30-02 and 30-03) without
|
|
19
|
+
* forming a require cycle in the CommonJS graph `require()` loads.
|
|
20
|
+
* `./org-profile.cjs` is the one exception: it is itself a zero-import leaf
|
|
21
|
+
* (node builtins only, see its own header), so importing it here creates no
|
|
22
|
+
* load-time cycle even though it lazily requires this module back at
|
|
23
|
+
* call-time for `normalizeProvider`/`PROVIDERS` reuse (D-10).
|
|
24
|
+
*
|
|
25
|
+
* This plan (30-01) produced no behaviour change on its own: nothing called
|
|
26
|
+
* this module at the time. 33-03 is the first plan to change `resolveProvider`
|
|
27
|
+
* itself, adding the org-profile consultation between the config read and
|
|
28
|
+
* `DEFAULT_PROVIDER` (D-07).
|
|
29
|
+
*/
|
|
30
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
31
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
32
|
+
};
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.LITERAL_CLONE_TARGET_PATTERNS = exports.PROVIDER_CONFIG_KEY = exports.DEFAULT_PROVIDER = exports.PROVIDERS = void 0;
|
|
35
|
+
exports.normalizeProvider = normalizeProvider;
|
|
36
|
+
exports.resolveProvider = resolveProvider;
|
|
37
|
+
exports.isLiteralCloneTarget = isLiteralCloneTarget;
|
|
38
|
+
exports.composeCloneTarget = composeCloneTarget;
|
|
39
|
+
exports.cloneEnv = cloneEnv;
|
|
40
|
+
exports.providerUsesHostCli = providerUsesHostCli;
|
|
41
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
42
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
43
|
+
const org_profile_cjs_1 = require("./org-profile.cjs");
|
|
44
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
45
|
+
/** The complete, ordered set of supported provider ids. */
|
|
46
|
+
exports.PROVIDERS = Object.freeze(['gitlab', 'azure']);
|
|
47
|
+
/**
|
|
48
|
+
* Every project that has never heard of this phase resolves to this provider
|
|
49
|
+
* (PROV-04): an absent config key is the normal case, not an error.
|
|
50
|
+
*/
|
|
51
|
+
exports.DEFAULT_PROVIDER = 'gitlab';
|
|
52
|
+
/** The dotted `.planning/config.json` key that selects the provider. */
|
|
53
|
+
exports.PROVIDER_CONFIG_KEY = 'vault.provider';
|
|
54
|
+
// ─── normalizeProvider ───────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Normalize an arbitrary config value into a canonical `ProviderId`, or
|
|
57
|
+
* `null` if it is not an exact (case/whitespace-tolerant) member of
|
|
58
|
+
* `PROVIDERS`.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately takes NO host or repo argument. That absence is what makes
|
|
61
|
+
* PROV-01's "never inferred from a hostname string" structurally true rather
|
|
62
|
+
* than a promise this function merely keeps by convention — it has nothing
|
|
63
|
+
* else to look at. Trimming and case-folding are tolerance for operator
|
|
64
|
+
* typing (`" Azure "`, `"GITLAB"`), not inference: there is no substring or
|
|
65
|
+
* prefix matching, only exact membership after normalization.
|
|
66
|
+
*/
|
|
67
|
+
function normalizeProvider(value) {
|
|
68
|
+
if (typeof value !== 'string')
|
|
69
|
+
return null;
|
|
70
|
+
const normalized = value.trim().toLowerCase();
|
|
71
|
+
return exports.PROVIDERS.includes(normalized) ? normalized : null;
|
|
72
|
+
}
|
|
73
|
+
// ─── resolveProvider ─────────────────────────────────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* Read `.planning/config.json` at `cwd`, returning `fallback` on any
|
|
76
|
+
* miss/unreadable-file/parse-error/non-object. Never throws.
|
|
77
|
+
*
|
|
78
|
+
* This mirrors `cfgGet` in `src/vault.cts` (lines 111-126) exactly, but is
|
|
79
|
+
* written inline rather than imported: `src/vault.cts` will import THIS
|
|
80
|
+
* module in 30-02, so importing `cfgGet` back from `vault.cjs` would create a
|
|
81
|
+
* cycle in the CommonJS graph loaded by `require`. The duplication is about
|
|
82
|
+
* ten lines with no behavioural variance to drift on.
|
|
83
|
+
*/
|
|
84
|
+
function cfgGet(cwd, key, fallback) {
|
|
85
|
+
try {
|
|
86
|
+
const cfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8'));
|
|
87
|
+
const v = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cfg);
|
|
88
|
+
return v === undefined ? fallback : v;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return fallback;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Bound a rejected config value before it is echoed into a warning. The
|
|
96
|
+
* warning is written into `.planning/vault/manifest.json` and printed by
|
|
97
|
+
* `vault status`, so an unbounded or newline-bearing config value would be a
|
|
98
|
+
* log-injection vector (T-30-04): strip CR/LF, collapse internal whitespace,
|
|
99
|
+
* then truncate to 40 characters.
|
|
100
|
+
*/
|
|
101
|
+
function sanitizeForWarning(value) {
|
|
102
|
+
return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the configured provider for the project at `cwd`, in three steps
|
|
106
|
+
* (D-07): `.planning/config.json` → the org profile → `DEFAULT_PROVIDER`.
|
|
107
|
+
*
|
|
108
|
+
* Reads `.planning/config.json`'s `vault.provider` key first — never a host,
|
|
109
|
+
* never a repo shape, never `.platform-bindings.json`. When that value is set
|
|
110
|
+
* to anything other than `undefined`/`null`/`''`, it is the operator's
|
|
111
|
+
* explicit choice and is NEVER overridden by an org profile, even when it is
|
|
112
|
+
* unrecognized (D-05): a recognized value returns with no warnings, and an
|
|
113
|
+
* unrecognized value resolves to `DEFAULT_PROVIDER` with exactly one bounded,
|
|
114
|
+
* single-line warning naming the rejected value and the two supported ids —
|
|
115
|
+
* the org profile is not consulted on this branch at all.
|
|
116
|
+
*
|
|
117
|
+
* When the config key is absent, `null` or `''` — the "not set" branch — the
|
|
118
|
+
* org profile is consulted for `vault.provider` via `readProfileValue`. When
|
|
119
|
+
* it supplies a value, that value is re-normalized here (defensively) before
|
|
120
|
+
* use; a normalized hit is returned, a miss falls through to
|
|
121
|
+
* `DEFAULT_PROVIDER`. Either way the profile layer's own `warnings` are
|
|
122
|
+
* returned unchanged and unfiltered — they are already bounded and already
|
|
123
|
+
* de-duplicated within one `readProfileValue` call. With no profile
|
|
124
|
+
* configured, `readProfileValue` returns `{ value: undefined, warnings: [] }`
|
|
125
|
+
* and this branch returns `DEFAULT_PROVIDER` with `warnings: []` — identical
|
|
126
|
+
* to the pre-33-03 early return (PROV-04 / ORG-02). This function adds no
|
|
127
|
+
* warning of its own for the no-profile case.
|
|
128
|
+
*/
|
|
129
|
+
function resolveProvider(cwd) {
|
|
130
|
+
const raw = cfgGet(cwd, exports.PROVIDER_CONFIG_KEY, undefined);
|
|
131
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
132
|
+
const profile = (0, org_profile_cjs_1.readProfileValue)(cwd, exports.PROVIDER_CONFIG_KEY);
|
|
133
|
+
if (profile.value !== undefined) {
|
|
134
|
+
const normalizedProfileValue = normalizeProvider(profile.value);
|
|
135
|
+
if (normalizedProfileValue !== null) {
|
|
136
|
+
return { provider: normalizedProfileValue, warnings: profile.warnings };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { provider: exports.DEFAULT_PROVIDER, warnings: profile.warnings };
|
|
140
|
+
}
|
|
141
|
+
const normalized = normalizeProvider(raw);
|
|
142
|
+
if (normalized !== null) {
|
|
143
|
+
return { provider: normalized, warnings: [] };
|
|
144
|
+
}
|
|
145
|
+
const rawStr = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
146
|
+
const echoed = sanitizeForWarning(rawStr ?? '');
|
|
147
|
+
const warning = `Unrecognized ${exports.PROVIDER_CONFIG_KEY} value "${echoed}" — supported: ${exports.PROVIDERS.join(', ')}. Falling back to ${exports.DEFAULT_PROVIDER}.`;
|
|
148
|
+
return { provider: exports.DEFAULT_PROVIDER, warnings: [warning.slice(0, 120)] };
|
|
149
|
+
}
|
|
150
|
+
// ─── LITERAL_CLONE_TARGET_PATTERNS ──────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* The single regex-expressible definition of the seven forms
|
|
153
|
+
* `isLiteralCloneTarget` accepts, as anchored regex source strings. Entry
|
|
154
|
+
* order is part of the contract: `scripts/gen-platform-bindings-schema.cjs`
|
|
155
|
+
* (plan 02) concatenates these into one alternation in this order, so a
|
|
156
|
+
* reorder here is a deliberate schema-regenerating edit, not a
|
|
157
|
+
* refactor-neutral one. `tests/git-provider.test.cjs` locks length, order and
|
|
158
|
+
* content.
|
|
159
|
+
*/
|
|
160
|
+
exports.LITERAL_CLONE_TARGET_PATTERNS = Object.freeze([
|
|
161
|
+
'^[A-Za-z][A-Za-z0-9+.-]*:\\/\\/', // scheme URL — e.g. https://, ssh://, file://
|
|
162
|
+
'^[\\w.][\\w.-]*@', // scp-style ssh — user@host:path
|
|
163
|
+
'^\\/', // absolute unix path
|
|
164
|
+
'^\\.\\/', // explicit relative — ./path
|
|
165
|
+
'^\\.\\.\\/', // parent-relative — ../path
|
|
166
|
+
'^~', // home-relative — ~ or ~/path
|
|
167
|
+
'^[A-Za-z]:[\\\\/]', // Windows drive path — C:\ or C:/
|
|
168
|
+
]);
|
|
169
|
+
// Compiled once at module load, without the global flag (a global regex
|
|
170
|
+
// carries `lastIndex` state across `test` calls and would make the predicate
|
|
171
|
+
// answer differently on repeated identical inputs).
|
|
172
|
+
const compiledLiteralCloneTargetPatterns = exports.LITERAL_CLONE_TARGET_PATTERNS.map((source) => new RegExp(source));
|
|
173
|
+
// ─── isLiteralCloneTarget (relocated verbatim from src/vault.cts) ───────────
|
|
174
|
+
/**
|
|
175
|
+
* True when `value` is already something git can clone directly — an
|
|
176
|
+
* absolute path, a scheme-qualified URL, or scp-style `user@host:path` —
|
|
177
|
+
* rather than a `group/project` slug that must be composed onto a host.
|
|
178
|
+
*
|
|
179
|
+
* This is now the SINGLE definition. `src/vault.cts` and
|
|
180
|
+
* `src/vault-write-back.cts` import it from here (30-02, 30-03) rather than
|
|
181
|
+
* each keeping a copy — a read/write divergence in this predicate was defect
|
|
182
|
+
* P-1 in Phase 29 (the write path used the value verbatim while the read path
|
|
183
|
+
* always composed a URL, so a slug broke writing and a path broke reading).
|
|
184
|
+
*
|
|
185
|
+
* Security (T-29-26): a value git would read as an OPTION must never take
|
|
186
|
+
* the verbatim branch. The scheme test is anchored at the START — an earlier
|
|
187
|
+
* version used `value.includes('://')`, so `--upload-pack=…#x://` passed
|
|
188
|
+
* through as a flag — and the scp-style test may not begin with `-`. This
|
|
189
|
+
* function is also the newer half of the pair: before it existed, this path
|
|
190
|
+
* ALWAYS composed a URL, so a flag-shaped repo could not reach git here at
|
|
191
|
+
* all. Callers pass `--` before the target as the second layer.
|
|
192
|
+
*
|
|
193
|
+
* The seven forms now live in the exported `LITERAL_CLONE_TARGET_PATTERNS`
|
|
194
|
+
* array so artifacts that cannot call this function — specifically the JSON
|
|
195
|
+
* Schema `pattern` in `schemas/platform-bindings.schema.json` — can be
|
|
196
|
+
* generated from the same definition rather than hand-transcribing it. The
|
|
197
|
+
* array is consumed by `src/platform-bindings.cts` and, through it, by
|
|
198
|
+
* `scripts/gen-platform-bindings-schema.cjs` (plan 02). The leading-dash
|
|
199
|
+
* refusal below is deliberately not one of the seven entries.
|
|
200
|
+
*/
|
|
201
|
+
function isLiteralCloneTarget(value) {
|
|
202
|
+
if (value.startsWith('-'))
|
|
203
|
+
return false;
|
|
204
|
+
return compiledLiteralCloneTargetPatterns.some((re) => re.test(value));
|
|
205
|
+
}
|
|
206
|
+
// ─── composeCloneTarget ──────────────────────────────────────────────────────
|
|
207
|
+
/**
|
|
208
|
+
* Compose `repo` into something `git clone` can reach for `provider` at
|
|
209
|
+
* `host`, or pass it through verbatim when it is already a literal clone
|
|
210
|
+
* target (PROV-02's final sentence — this is what keeps the working Azure
|
|
211
|
+
* SSH form `git@ssh.dev.azure.com:v3/{org}/{project}/{repo}` working
|
|
212
|
+
* unchanged).
|
|
213
|
+
*
|
|
214
|
+
* `gitlab`: `https://{host}/{slug}.git` — byte-identical to the template
|
|
215
|
+
* being removed from `src/vault.cts` in 30-02, because PROV-04 depends on
|
|
216
|
+
* this string not moving.
|
|
217
|
+
*
|
|
218
|
+
* `azure`: `https://{host}/{slug}` — no `.git` suffix appended. Azure DevOps
|
|
219
|
+
* rejects a `.git`-suffixed path (`TF401019`); this is the entire reason
|
|
220
|
+
* PROV-02 exists.
|
|
221
|
+
*
|
|
222
|
+
* When `provider` is `azure` and the trimmed slug already ends in `.git`, the
|
|
223
|
+
* suffix is NOT stripped — an operator-configured coordinate is data, not
|
|
224
|
+
* something this function rewrites — but exactly one warning is returned
|
|
225
|
+
* naming `TF401019` and recommending the suffix be removed from the
|
|
226
|
+
* configured value. Composition still never appends; the suffix in that case
|
|
227
|
+
* is the operator's own (T-30-08, accepted risk: rewriting a configured
|
|
228
|
+
* coordinate is a worse failure mode than a clear diagnostic).
|
|
229
|
+
*/
|
|
230
|
+
function composeCloneTarget(provider, repo, host) {
|
|
231
|
+
const trimmed = repo.trim();
|
|
232
|
+
if (isLiteralCloneTarget(trimmed)) {
|
|
233
|
+
return { url: trimmed, warnings: [] };
|
|
234
|
+
}
|
|
235
|
+
if (provider === 'azure') {
|
|
236
|
+
const url = `https://${host}/${trimmed}`;
|
|
237
|
+
if (trimmed.endsWith('.git')) {
|
|
238
|
+
return {
|
|
239
|
+
url,
|
|
240
|
+
warnings: [
|
|
241
|
+
'Azure DevOps rejects a .git-suffixed path (TF401019) — remove the .git suffix from the configured vault repo value.',
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return { url, warnings: [] };
|
|
246
|
+
}
|
|
247
|
+
// gitlab (and, structurally, any future provider that composes the same
|
|
248
|
+
// shape) — byte-identical to the template this replaces.
|
|
249
|
+
return { url: `https://${host}/${trimmed}.git`, warnings: [] };
|
|
250
|
+
}
|
|
251
|
+
// ─── cloneEnv ────────────────────────────────────────────────────────────────
|
|
252
|
+
/**
|
|
253
|
+
* Build the environment additions a clone needs for `provider` at `host`.
|
|
254
|
+
*
|
|
255
|
+
* `gitlab` needs `GITLAB_HOST` (consumed by the `glab repo clone` flow).
|
|
256
|
+
* `azure` needs nothing — an empty object.
|
|
257
|
+
*
|
|
258
|
+
* `execGit`/`execTool` (`src/shell-command-projection.cts` lines 441-446 and
|
|
259
|
+
* 473) MERGE this object over `process.env`, so the empty object for `azure`
|
|
260
|
+
* is a genuine no-op: it preserves PATH, the ambient credential helper, and
|
|
261
|
+
* the non-interactive defaults Azure authentication relies on. It does not
|
|
262
|
+
* strip anything.
|
|
263
|
+
*/
|
|
264
|
+
function cloneEnv(provider, host) {
|
|
265
|
+
if (provider === 'gitlab')
|
|
266
|
+
return { GITLAB_HOST: host };
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
// ─── providerUsesHostCli ─────────────────────────────────────────────────────
|
|
270
|
+
/**
|
|
271
|
+
* True when `provider` has a first-class host CLI available (`glab`, for
|
|
272
|
+
* `gitlab`). Returns a boolean rather than a program name: no caller should
|
|
273
|
+
* ever build a subprocess program name out of a config-derived string, and
|
|
274
|
+
* returning a boolean makes that impossible rather than merely discouraged
|
|
275
|
+
* (T-30-03).
|
|
276
|
+
*/
|
|
277
|
+
function providerUsesHostCli(provider) {
|
|
278
|
+
return provider === 'gitlab';
|
|
279
|
+
}
|