@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,785 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/compose-bindings.cts — the bindings composer (milestone v1.11.0, Phase 34-03).
|
|
4
|
+
*
|
|
5
|
+
* ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
|
|
6
|
+
* compiles to the git-ignored `pp-core/bin/lib/compose-bindings.cjs`; do NOT
|
|
7
|
+
* hand-edit the compiled output. Behaviour is locked by
|
|
8
|
+
* `tests/compose-bindings.test.cjs`.
|
|
9
|
+
*
|
|
10
|
+
* This module reads the D-01 seed section an org profile package may carry
|
|
11
|
+
* (`bindings.*`, read via `resolveOrgProfileDocument` — never
|
|
12
|
+
* `readProfileValue`, whose allowlist gate deliberately excludes the seed
|
|
13
|
+
* namespace) plus the project answers `/pp-init` already captured in
|
|
14
|
+
* `.planning/config.json`, composes a `.platform-bindings.json` candidate,
|
|
15
|
+
* delegates every shape rule to `validateBindings` (never a second regex for
|
|
16
|
+
* `platform.repo`/`client.vaultRepo`/`client.slug`/`project.slug`/`platform.tag`),
|
|
17
|
+
* pins `platform.tag` to the seeded platform's latest stable tag over the
|
|
18
|
+
* resolved host, merges absent keys only against any existing file, and
|
|
19
|
+
* writes only when explicitly told to (`opts.write === true`). Dry-run is the
|
|
20
|
+
* no-flag default (D-10) — `composeBindings` and `cmdComposeBindings` share
|
|
21
|
+
* this exact code path with no second implementation for the `/pp-init` call
|
|
22
|
+
* site.
|
|
23
|
+
*
|
|
24
|
+
* D-05 puts this composition in a CLI arm rather than `init.md` shell: the
|
|
25
|
+
* CLI-writer + `.md` shell-glue seam is a known blindspot in this repo —
|
|
26
|
+
* `node:test` covers the writer while the heredoc JSON and the parsed-key
|
|
27
|
+
* round-trip go untested, and that is exactly how the
|
|
28
|
+
* `generate-client-context` CR-02 bug shipped. A file that governs the vault
|
|
29
|
+
* write guard must not be assembled where the suite cannot reach it. Every
|
|
30
|
+
* JSON object this module writes is built in compiled code via
|
|
31
|
+
* `JSON.stringify`/object literals — never by `.md` shell string
|
|
32
|
+
* concatenation.
|
|
33
|
+
*
|
|
34
|
+
* This module sits at the top of the CommonJS graph — nothing imports it —
|
|
35
|
+
* so its top-level imports of `./org-profile.cjs`, `./platform-bindings.cjs`,
|
|
36
|
+
* `./git-provider.cjs`, `./vault-coordinates.cjs`, `./io.cjs`,
|
|
37
|
+
* `./project-root.cjs`, `./package-identity.cjs` and
|
|
38
|
+
* `./shell-command-projection.cjs` create no require cycle: none of those
|
|
39
|
+
* modules imports this one, at load time or lazily.
|
|
40
|
+
*
|
|
41
|
+
* Like `src/org-profile.cts`, this file's own prose and code deliberately
|
|
42
|
+
* never name a concrete client or a concrete org profile package.
|
|
43
|
+
*/
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.COMPOSE_BINDINGS_FLAGS = exports.SEED_VALUE_MAX_LENGTH = void 0;
|
|
49
|
+
exports._resetPlatformTagMemoForTests = _resetPlatformTagMemoForTests;
|
|
50
|
+
exports.composeBindings = composeBindings;
|
|
51
|
+
exports.cmdComposeBindings = cmdComposeBindings;
|
|
52
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
53
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
54
|
+
const node_child_process_1 = __importDefault(require("node:child_process"));
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
|
+
const io = require("./io.cjs");
|
|
57
|
+
const { output } = io;
|
|
58
|
+
const project_root_cjs_1 = require("./project-root.cjs");
|
|
59
|
+
const package_identity_cjs_1 = require("./package-identity.cjs");
|
|
60
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
61
|
+
const semver_compare_cjs_1 = require("./semver-compare.cjs");
|
|
62
|
+
const org_profile_cjs_1 = require("./org-profile.cjs");
|
|
63
|
+
const platform_bindings_cjs_1 = require("./platform-bindings.cjs");
|
|
64
|
+
const git_provider_cjs_1 = require("./git-provider.cjs");
|
|
65
|
+
const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
|
|
66
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
67
|
+
/**
|
|
68
|
+
* The maximum accepted length of a seed value. Rejecting a value past this
|
|
69
|
+
* bound is preferred to truncating it: a truncated coordinate that still got
|
|
70
|
+
* written would be a silently wrong clone target.
|
|
71
|
+
*/
|
|
72
|
+
exports.SEED_VALUE_MAX_LENGTH = 200;
|
|
73
|
+
/**
|
|
74
|
+
* D-03's required core: any of these failing means compose nothing. Every
|
|
75
|
+
* other field `validateBindings` might reject is OPTIONAL — dropped with a
|
|
76
|
+
* warning rather than blocking the whole compose.
|
|
77
|
+
*/
|
|
78
|
+
const REQUIRED_ERROR_PREFIXES = Object.freeze(['root', 'schemaVersion', 'platform', 'platform.repo']);
|
|
79
|
+
/** Maps each D-01 seed key (fully dotted, `ORG_PROFILE_SEED_SECTION`-prefixed)
|
|
80
|
+
* to the short name this module carries it under internally. */
|
|
81
|
+
const SEED_KEY_SHORT_NAMES = Object.freeze({
|
|
82
|
+
[`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.platform.repo`]: 'platformRepo',
|
|
83
|
+
[`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRepo`]: 'clientVaultRepo',
|
|
84
|
+
[`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRef`]: 'clientVaultRef',
|
|
85
|
+
[`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultHost`]: 'clientVaultHost',
|
|
86
|
+
[`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`]: 'provisioningGroup',
|
|
87
|
+
});
|
|
88
|
+
// Compiled once at module load, without the global flag (a global regex
|
|
89
|
+
// carries `lastIndex` state across `test` calls) — the same convention
|
|
90
|
+
// `src/git-provider.cts` and `src/org-profile.cts` both use for their own
|
|
91
|
+
// copies of this array. Imported, never re-typed (T-34-21).
|
|
92
|
+
const compiledRejectedCloneTargetPatterns = org_profile_cjs_1.PROFILE_REJECTED_CLONE_TARGET_PATTERNS.map((source) => new RegExp(source));
|
|
93
|
+
/** The recognized flag set for the `compose-bindings` CLI arm (34-04's
|
|
94
|
+
* fail-fast unrecognized-flag guard reads this). */
|
|
95
|
+
exports.COMPOSE_BINDINGS_FLAGS = Object.freeze(['--write', '--offline']);
|
|
96
|
+
// ─── Private helpers (deliberate per-module duplication, canonical originals
|
|
97
|
+
// named in each comment) ──────────────────────────────────────────────────────
|
|
98
|
+
function isObject(v) {
|
|
99
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
100
|
+
}
|
|
101
|
+
function isUsableString(v) {
|
|
102
|
+
return typeof v === 'string' && v.trim() !== '';
|
|
103
|
+
}
|
|
104
|
+
/** Byte-for-byte mirror of `sanitizeForWarning` in `src/git-provider.cts`
|
|
105
|
+
* (lines 98-100): strip CR/LF, collapse internal whitespace, trim, truncate
|
|
106
|
+
* to 40 characters. */
|
|
107
|
+
function sanitizeForWarning(value) {
|
|
108
|
+
return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
109
|
+
}
|
|
110
|
+
/** Bound a whole warning string to 200 characters, mirroring `bound` in
|
|
111
|
+
* `src/org-profile.cts` (line 196-249). */
|
|
112
|
+
function bound(warning) {
|
|
113
|
+
return warning.slice(0, 200);
|
|
114
|
+
}
|
|
115
|
+
/** Byte-for-byte mirror of `cfgGet` in `src/git-provider.cts` (lines 78-89).
|
|
116
|
+
* Written inline rather than imported for the same require-cycle reason
|
|
117
|
+
* every sibling seam module inlines this reader. */
|
|
118
|
+
function cfgGet(cwd, key, fallback) {
|
|
119
|
+
try {
|
|
120
|
+
const cfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8'));
|
|
121
|
+
const v = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cfg);
|
|
122
|
+
return v === undefined ? fallback : v;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Append only strings not already present in `target` — mirrors
|
|
129
|
+
* `pushDeduped` in `src/vault-coordinates.cts` (lines 200-204). This module
|
|
130
|
+
* can consult the org profile and the host resolver multiple times per call;
|
|
131
|
+
* D-09 promises one bounded warning per rejected thing, not several copies. */
|
|
132
|
+
function pushDeduped(target, incoming) {
|
|
133
|
+
for (const w of incoming) {
|
|
134
|
+
if (!target.includes(w))
|
|
135
|
+
target.push(w);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ─── Dotted-path object helpers (bounded, depth matches the schema's own
|
|
139
|
+
// shallow shape — top-level namespace, one leaf level) ────────────────────────
|
|
140
|
+
function hasNestedOwn(obj, dottedPath) {
|
|
141
|
+
if (!obj)
|
|
142
|
+
return false;
|
|
143
|
+
const parts = dottedPath.split('.');
|
|
144
|
+
let cur = obj;
|
|
145
|
+
for (const p of parts) {
|
|
146
|
+
if (!isObject(cur) || !Object.prototype.hasOwnProperty.call(cur, p))
|
|
147
|
+
return false;
|
|
148
|
+
cur = cur[p];
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
function getNested(obj, dottedPath) {
|
|
153
|
+
if (!obj)
|
|
154
|
+
return undefined;
|
|
155
|
+
const parts = dottedPath.split('.');
|
|
156
|
+
let cur = obj;
|
|
157
|
+
for (const p of parts) {
|
|
158
|
+
if (!isObject(cur))
|
|
159
|
+
return undefined;
|
|
160
|
+
cur = cur[p];
|
|
161
|
+
}
|
|
162
|
+
return cur;
|
|
163
|
+
}
|
|
164
|
+
function setNested(obj, dottedPath, value) {
|
|
165
|
+
const parts = dottedPath.split('.');
|
|
166
|
+
let cur = obj;
|
|
167
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
168
|
+
const key = parts[i];
|
|
169
|
+
if (!isObject(cur[key]))
|
|
170
|
+
cur[key] = {};
|
|
171
|
+
cur = cur[key];
|
|
172
|
+
}
|
|
173
|
+
cur[parts[parts.length - 1]] = value;
|
|
174
|
+
}
|
|
175
|
+
function deleteDottedField(obj, dottedPath) {
|
|
176
|
+
const parts = dottedPath.split('.');
|
|
177
|
+
if (parts.length === 1) {
|
|
178
|
+
delete obj[parts[0]];
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const parent = obj[parts[0]];
|
|
182
|
+
if (isObject(parent)) {
|
|
183
|
+
delete parent[parts[1]];
|
|
184
|
+
if (Object.keys(parent).length === 0)
|
|
185
|
+
delete obj[parts[0]];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function deepCloneJsonSafe(value) {
|
|
189
|
+
return JSON.parse(JSON.stringify(value));
|
|
190
|
+
}
|
|
191
|
+
function valuesEqual(a, b) {
|
|
192
|
+
if (a === b)
|
|
193
|
+
return true;
|
|
194
|
+
try {
|
|
195
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Flatten a plain object to dotted leaf paths, bounded at depth 6 (the
|
|
202
|
+
* schema's own real depth is 2; the bound exists only so a pathological
|
|
203
|
+
* hand-edited file cannot recurse unbounded). */
|
|
204
|
+
function walkLeaves(obj, prefix, depthCap, out) {
|
|
205
|
+
if (depthCap <= 0)
|
|
206
|
+
return;
|
|
207
|
+
for (const k of Object.keys(obj)) {
|
|
208
|
+
const dotted = prefix ? `${prefix}.${k}` : k;
|
|
209
|
+
const v = obj[k];
|
|
210
|
+
if (isObject(v)) {
|
|
211
|
+
walkLeaves(v, dotted, depthCap - 1, out);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
out.push({ path: dotted, value: v });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function hasAnyNewKey(composed, existing) {
|
|
219
|
+
const leaves = [];
|
|
220
|
+
walkLeaves(composed, '', 6, leaves);
|
|
221
|
+
return leaves.some((leaf) => !hasNestedOwn(existing, leaf.path));
|
|
222
|
+
}
|
|
223
|
+
// ─── dispositionSeedValue (D-03 seed guard) ──────────────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Guard a single D-01 seed value, never throwing. Scalar guard first
|
|
226
|
+
* (mirroring `profileValueDisposition` in `src/org-profile.cts`): must be a
|
|
227
|
+
* string; the trimmed form non-empty; the UNTRIMMED value carries no ASCII
|
|
228
|
+
* control character; the TRIMMED value carries no whitespace; the trimmed
|
|
229
|
+
* value does not begin with `-`; the trimmed length is at most
|
|
230
|
+
* `SEED_VALUE_MAX_LENGTH`. Then a per-key disposition built entirely from
|
|
231
|
+
* imported constants — this function authors no new pattern strings.
|
|
232
|
+
*
|
|
233
|
+
* `seedKey` is the FULLY DOTTED key (`ORG_PROFILE_SEED_SECTION`-prefixed,
|
|
234
|
+
* e.g. `bindings.client.vaultHost`), matching the entries of
|
|
235
|
+
* `ORG_PROFILE_SEED_KEYS`.
|
|
236
|
+
*/
|
|
237
|
+
function dispositionSeedValue(seedKey, value) {
|
|
238
|
+
try {
|
|
239
|
+
if (typeof value !== 'string') {
|
|
240
|
+
return { ok: false, reason: 'value must be a string' };
|
|
241
|
+
}
|
|
242
|
+
const trimmed = value.trim();
|
|
243
|
+
if (trimmed === '') {
|
|
244
|
+
return { ok: false, reason: 'value must not be blank' };
|
|
245
|
+
}
|
|
246
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
247
|
+
return { ok: false, reason: 'value must not contain control characters' };
|
|
248
|
+
}
|
|
249
|
+
if (/\s/.test(trimmed)) {
|
|
250
|
+
return { ok: false, reason: 'value must not contain whitespace' };
|
|
251
|
+
}
|
|
252
|
+
if (trimmed.startsWith('-')) {
|
|
253
|
+
return { ok: false, reason: 'value must not begin with "-"' };
|
|
254
|
+
}
|
|
255
|
+
if (trimmed.length > exports.SEED_VALUE_MAX_LENGTH) {
|
|
256
|
+
return { ok: false, reason: `value exceeds the maximum accepted length of ${exports.SEED_VALUE_MAX_LENGTH} characters` };
|
|
257
|
+
}
|
|
258
|
+
if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultHost`) {
|
|
259
|
+
// Authority-escape guard (T-34-20) — genuinely new surface:
|
|
260
|
+
// `profileValueDisposition` only ever runs inside the allowlist-gated
|
|
261
|
+
// walk and gives a seed key nothing for free.
|
|
262
|
+
for (const ch of org_profile_cjs_1.HOST_FORBIDDEN_CHARS) {
|
|
263
|
+
if (trimmed.includes(ch)) {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: `a host must not contain ${ch} — it must be a bare host, optionally with a port`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { ok: true, value: trimmed };
|
|
271
|
+
}
|
|
272
|
+
if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.platform.repo` ||
|
|
273
|
+
seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRepo` ||
|
|
274
|
+
seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`) {
|
|
275
|
+
// T-34-21: a machine-specific clone target is not accepted from a
|
|
276
|
+
// package — a human chose one deliberately and a package did not.
|
|
277
|
+
if (compiledRejectedCloneTargetPatterns.some((re) => re.test(trimmed))) {
|
|
278
|
+
return { ok: false, reason: 'a machine-specific path is not accepted from an org profile' };
|
|
279
|
+
}
|
|
280
|
+
if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`) {
|
|
281
|
+
const segments = trimmed.split('/');
|
|
282
|
+
if (trimmed.startsWith('/') || segments.includes('..')) {
|
|
283
|
+
return { ok: false, reason: 'value must not begin with "/" or contain a ".." path segment' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { ok: true, value: trimmed };
|
|
287
|
+
}
|
|
288
|
+
// bindings.client.vaultRef: the scalar guard alone.
|
|
289
|
+
return { ok: true, value: trimmed };
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return { ok: false, reason: 'value could not be validated' };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Read the D-01 seed section through `resolveOrgProfileDocument` — never
|
|
297
|
+
* `readProfileValue`, whose allowlist gate would return `undefined` with no
|
|
298
|
+
* filesystem access for a seed key and would misrepresent the boundary.
|
|
299
|
+
* Walks exactly the members of `ORG_PROFILE_SEED_KEYS` (never an open-ended
|
|
300
|
+
* walk), running each present value through `dispositionSeedValue`.
|
|
301
|
+
*/
|
|
302
|
+
function readSeed(cwd, opts) {
|
|
303
|
+
const doc = (0, org_profile_cjs_1.resolveOrgProfileDocument)(cwd, opts);
|
|
304
|
+
const profile = {
|
|
305
|
+
resolved: doc.resolved,
|
|
306
|
+
packageName: doc.packageName,
|
|
307
|
+
version: doc.version,
|
|
308
|
+
source: doc.source,
|
|
309
|
+
dataPath: doc.dataPath,
|
|
310
|
+
};
|
|
311
|
+
const warnings = [...doc.warnings];
|
|
312
|
+
const values = {};
|
|
313
|
+
const seedSectionRaw = doc.document[org_profile_cjs_1.ORG_PROFILE_SEED_SECTION];
|
|
314
|
+
if (!doc.resolved) {
|
|
315
|
+
return { values, profile, warnings };
|
|
316
|
+
}
|
|
317
|
+
if (!isObject(seedSectionRaw)) {
|
|
318
|
+
if (seedSectionRaw !== undefined) {
|
|
319
|
+
warnings.push(bound(`org profile "${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}" section is not a JSON object — no coordinates seeded.`));
|
|
320
|
+
}
|
|
321
|
+
return { values, profile, warnings };
|
|
322
|
+
}
|
|
323
|
+
for (const dottedKey of org_profile_cjs_1.ORG_PROFILE_SEED_KEYS) {
|
|
324
|
+
const relPath = dottedKey.slice(org_profile_cjs_1.ORG_PROFILE_SEED_SECTION.length + 1);
|
|
325
|
+
if (!hasNestedOwn(seedSectionRaw, relPath))
|
|
326
|
+
continue;
|
|
327
|
+
const rawValue = getNested(seedSectionRaw, relPath);
|
|
328
|
+
const disposition = dispositionSeedValue(dottedKey, rawValue);
|
|
329
|
+
const shortName = SEED_KEY_SHORT_NAMES[dottedKey];
|
|
330
|
+
if (disposition.ok) {
|
|
331
|
+
values[shortName] = disposition.value;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
warnings.push(bound(`org profile seed key "${dottedKey}" rejected — ${disposition.reason}.`));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { values, profile, warnings };
|
|
338
|
+
}
|
|
339
|
+
// ─── resolveLatestPlatformTag (D-04) ─────────────────────────────────────────
|
|
340
|
+
/**
|
|
341
|
+
* Per-process memo for the D-04 tag lookup, keyed by the resolved URL, holding
|
|
342
|
+
* the full `{ tag, warnings }` verdict — successes AND failures. One real
|
|
343
|
+
* `pp-tools init` run calls `composeBindings` twice (the dry-run preview in
|
|
344
|
+
* `computeCurrentStep`, then the write-mode call after `compose_confirm`), so
|
|
345
|
+
* without this memo a decline costs a network round-trip for nothing and a
|
|
346
|
+
* failed lookup would print its bounded warning twice.
|
|
347
|
+
*/
|
|
348
|
+
const platformTagMemo = new Map();
|
|
349
|
+
/** Test-only seam: clear the per-process platform.tag lookup memo so a test
|
|
350
|
+
* suite that shares a resolved URL across cases sees an empty memo each time
|
|
351
|
+
* (`init-tag-lookup-raw-git-error`). Mirrors the reset-seam convention at
|
|
352
|
+
* `src/active-workstream-store.cts`'s controlling-TTY probe cache. */
|
|
353
|
+
function _resetPlatformTagMemoForTests() {
|
|
354
|
+
platformTagMemo.clear();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Resolve the latest STABLE tag for `repo` over the resolved host, never
|
|
358
|
+
* shell-eval'd (T-34-25): `git ls-remote` is spawned with an argv array via
|
|
359
|
+
* `childProcess.execFileSync`, bounded by `timeout`/`maxBuffer`/`windowsHide`
|
|
360
|
+
* (T-34-26). The host is `resolvePlatformHost(cwd).host` when it resolves,
|
|
361
|
+
* else `defaultHostForProvider(provider)` — the host is authoritative and
|
|
362
|
+
* never derived from the repo string (Phase 31 D-09).
|
|
363
|
+
*
|
|
364
|
+
* The child's stderr is piped into the parent's buffer rather than inherited
|
|
365
|
+
* — an explicit `stdio: ['ignore', 'pipe', 'pipe']` — and deliberately
|
|
366
|
+
* discarded rather than logged: the bounded warning this function returns on
|
|
367
|
+
* failure is the single user-visible surface for a lookup failure. Inheriting
|
|
368
|
+
* the child's stream let a raw git `fatal:` line reach the terminal during an
|
|
369
|
+
* otherwise successful first-run experience (`init-tag-lookup-raw-git-error`).
|
|
370
|
+
*
|
|
371
|
+
* Every candidate ref is filtered with `isStableTripletSemver` and ordered
|
|
372
|
+
* with `compareSemverCore` — the single blessed comparison source, never
|
|
373
|
+
* reimplemented. An empty result degrades to `{ tag: null, warnings: [] }`
|
|
374
|
+
* (tagless is valid, D-01/D-02); any thrown fault (offline, unreachable,
|
|
375
|
+
* DNS failure, non-zero git exit) degrades to `{ tag: null, warnings: [one
|
|
376
|
+
* bounded warning] }` — offline, unreachable and tagless all resolve to the
|
|
377
|
+
* platform being unpinned, never a compose failure.
|
|
378
|
+
*/
|
|
379
|
+
function resolveLatestPlatformTag(cwd, repo, provider, opts) {
|
|
380
|
+
let url;
|
|
381
|
+
try {
|
|
382
|
+
const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
|
|
383
|
+
const host = hostRes.host ?? (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider);
|
|
384
|
+
url = `https://${host}/${repo}`;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return {
|
|
388
|
+
tag: null,
|
|
389
|
+
warnings: [
|
|
390
|
+
bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const cached = platformTagMemo.get(url);
|
|
395
|
+
if (cached)
|
|
396
|
+
return cached;
|
|
397
|
+
let result;
|
|
398
|
+
try {
|
|
399
|
+
const lsOutput = typeof opts._lsRemote === 'function'
|
|
400
|
+
? opts._lsRemote(url)
|
|
401
|
+
: node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], {
|
|
402
|
+
encoding: 'utf-8',
|
|
403
|
+
timeout: 10_000,
|
|
404
|
+
maxBuffer: 512 * 1024,
|
|
405
|
+
windowsHide: true,
|
|
406
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
407
|
+
});
|
|
408
|
+
const tags = lsOutput
|
|
409
|
+
.split('\n')
|
|
410
|
+
.map((line) => {
|
|
411
|
+
const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
|
|
412
|
+
return m ? m[1].trim() : '';
|
|
413
|
+
})
|
|
414
|
+
.filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
|
|
415
|
+
if (tags.length === 0) {
|
|
416
|
+
result = { tag: null, warnings: [] };
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
let latest = tags[0];
|
|
420
|
+
for (const t of tags) {
|
|
421
|
+
if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
|
|
422
|
+
latest = t;
|
|
423
|
+
}
|
|
424
|
+
result = { tag: latest, warnings: [] };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
result = {
|
|
429
|
+
tag: null,
|
|
430
|
+
warnings: [
|
|
431
|
+
bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
platformTagMemo.set(url, result);
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Read the first readable, semver-shaped `VERSION` file among the installed
|
|
440
|
+
* `pp-core/VERSION` location and this repo's own root `VERSION`, returning
|
|
441
|
+
* `<binName>@<version>`; when neither is readable, `binName` alone.
|
|
442
|
+
*/
|
|
443
|
+
function resolveCreatedWith() {
|
|
444
|
+
const candidates = [node_path_1.default.join(__dirname, '..', 'VERSION'), node_path_1.default.join(__dirname, '..', '..', '..', 'VERSION')];
|
|
445
|
+
for (const candidate of candidates) {
|
|
446
|
+
try {
|
|
447
|
+
const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
|
|
448
|
+
if (/^\d+\.\d+\.\d+/.test(raw)) {
|
|
449
|
+
return `${package_identity_cjs_1.binName}@${raw}`;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// try the next candidate
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return package_identity_cjs_1.binName;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Build the composed candidate object from the seed, the project answers
|
|
460
|
+
* `/pp-init` already captured, the resolved platform host, the D-04 tag pin,
|
|
461
|
+
* the package identity and the contract version. Omits any field whose
|
|
462
|
+
* source value is absent, blank or non-string — never writes an empty
|
|
463
|
+
* string. Every emitted field is one the schema's `additionalProperties:
|
|
464
|
+
* false` property sets already allow.
|
|
465
|
+
*/
|
|
466
|
+
function composeCandidate(cwd, seed, provider, opts) {
|
|
467
|
+
const candidate = {};
|
|
468
|
+
const provenance = {};
|
|
469
|
+
const warnings = [];
|
|
470
|
+
const setField = (field, value, source) => {
|
|
471
|
+
if (!isUsableString(value))
|
|
472
|
+
return;
|
|
473
|
+
setNested(candidate, field, value);
|
|
474
|
+
provenance[field] = source;
|
|
475
|
+
};
|
|
476
|
+
// schemaVersion (contract) — always present, never a second '1.1.0' literal.
|
|
477
|
+
candidate.schemaVersion = platform_bindings_cjs_1.CONTRACT_SCHEMA_VERSION;
|
|
478
|
+
provenance.schemaVersion = 'contract';
|
|
479
|
+
// platform.repo (profile-seed)
|
|
480
|
+
setField('platform.repo', seed.platformRepo, 'profile-seed');
|
|
481
|
+
// platform.host (resolved-host) — one of the five runtime-allowlisted keys;
|
|
482
|
+
// sourced from the runtime resolver rather than duplicated into the seed
|
|
483
|
+
// section.
|
|
484
|
+
const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
|
|
485
|
+
pushDeduped(warnings, hostRes.warnings);
|
|
486
|
+
setField('platform.host', hostRes.host ?? undefined, 'resolved-host');
|
|
487
|
+
// platform.tag (resolved-tag, D-04) — only attempted when platform.repo
|
|
488
|
+
// composed and the caller did not opt out via offline.
|
|
489
|
+
const platformObj = candidate.platform;
|
|
490
|
+
const platformRepoValue = isObject(platformObj) ? platformObj.repo : undefined;
|
|
491
|
+
if (isUsableString(platformRepoValue) && opts.offline !== true) {
|
|
492
|
+
const tagResult = resolveLatestPlatformTag(cwd, platformRepoValue, provider, opts);
|
|
493
|
+
pushDeduped(warnings, tagResult.warnings);
|
|
494
|
+
if (tagResult.tag) {
|
|
495
|
+
setField('platform.tag', tagResult.tag, 'resolved-tag');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// client.name / client.slug (project-answer)
|
|
499
|
+
setField('client.name', cfgGet(cwd, 'project.client_name', undefined), 'project-answer');
|
|
500
|
+
setField('client.slug', cfgGet(cwd, 'project.client_slug', undefined), 'project-answer');
|
|
501
|
+
// client.vaultRepo / vaultRef / vaultHost (profile-seed)
|
|
502
|
+
setField('client.vaultRepo', seed.clientVaultRepo, 'profile-seed');
|
|
503
|
+
setField('client.vaultRef', seed.clientVaultRef, 'profile-seed');
|
|
504
|
+
setField('client.vaultHost', seed.clientVaultHost, 'profile-seed');
|
|
505
|
+
// project.name / project.slug (project-answer)
|
|
506
|
+
setField('project.name', cfgGet(cwd, 'project.name', undefined), 'project-answer');
|
|
507
|
+
setField('project.slug', cfgGet(cwd, 'project.slug', undefined), 'project-answer');
|
|
508
|
+
// project.createdWith (package-identity)
|
|
509
|
+
setField('project.createdWith', resolveCreatedWith(), 'package-identity');
|
|
510
|
+
return { candidate, provenance, warnings };
|
|
511
|
+
}
|
|
512
|
+
// ─── validateAndDispose (D-03) ────────────────────────────────────────────────
|
|
513
|
+
function leadingToken(errorMsg) {
|
|
514
|
+
const idx = errorMsg.indexOf(':');
|
|
515
|
+
return idx === -1 ? errorMsg : errorMsg.slice(0, idx);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Implement D-03 exactly. Every shape rule comes from `validateBindings`;
|
|
519
|
+
* this function contains no second regex for any binding field.
|
|
520
|
+
*
|
|
521
|
+
* - Valid as-is: composable, unchanged.
|
|
522
|
+
* - Any REQUIRED field failing (`root`, `schemaVersion`, `platform`,
|
|
523
|
+
* `platform.repo`): compose nothing. This asymmetry is load-bearing — the
|
|
524
|
+
* presence of `.platform-bindings.json` is itself the gate that flips
|
|
525
|
+
* `/pp-init` and `execute-phase` into Pareto mode, so writing a file with
|
|
526
|
+
* an unusable `platform.repo` would flip that gate into a state nothing
|
|
527
|
+
* downstream can satisfy.
|
|
528
|
+
* - Otherwise (only OPTIONAL fields failing): a field whose provenance is
|
|
529
|
+
* `existing-file` is NEVER deleted (T-30-08) — when only such fields fail,
|
|
530
|
+
* report `composable: false` naming them. Otherwise delete each failing
|
|
531
|
+
* optional field (and its parent when it becomes empty), warn once per
|
|
532
|
+
* field, and re-validate ONCE. If still invalid, `composable: false`.
|
|
533
|
+
*/
|
|
534
|
+
function validateAndDispose(candidate, provenanceByField) {
|
|
535
|
+
const first = (0, platform_bindings_cjs_1.validateBindings)(candidate);
|
|
536
|
+
if (first.valid) {
|
|
537
|
+
return { composable: true, composed: candidate, warnings: [] };
|
|
538
|
+
}
|
|
539
|
+
const failingFields = first.errors.map(leadingToken);
|
|
540
|
+
const requiredFailures = failingFields.filter((f) => REQUIRED_ERROR_PREFIXES.includes(f));
|
|
541
|
+
if (requiredFailures.length > 0) {
|
|
542
|
+
const requiredErrorText = first.errors
|
|
543
|
+
.filter((e) => REQUIRED_ERROR_PREFIXES.includes(leadingToken(e)))
|
|
544
|
+
.join('; ');
|
|
545
|
+
return {
|
|
546
|
+
composable: false,
|
|
547
|
+
composed: null,
|
|
548
|
+
warnings: [bound(`binding could not be composed — required field(s) invalid: ${requiredErrorText}`)],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const uneditableFields = failingFields.filter((f) => provenanceByField[f] === 'existing-file');
|
|
552
|
+
if (uneditableFields.length > 0) {
|
|
553
|
+
return {
|
|
554
|
+
composable: false,
|
|
555
|
+
composed: null,
|
|
556
|
+
warnings: [
|
|
557
|
+
bound(`binding could not be composed — the existing .platform-bindings.json has invalid value(s) for ${sanitizeForWarning(uneditableFields.join(', '))}; fix the committed file directly.`),
|
|
558
|
+
],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const mutated = deepCloneJsonSafe(candidate);
|
|
562
|
+
const warnings = [];
|
|
563
|
+
for (let i = 0; i < first.errors.length; i++) {
|
|
564
|
+
const field = failingFields[i];
|
|
565
|
+
warnings.push(bound(`${field} dropped from composed binding — ${first.errors[i]}`));
|
|
566
|
+
deleteDottedField(mutated, field);
|
|
567
|
+
}
|
|
568
|
+
const second = (0, platform_bindings_cjs_1.validateBindings)(mutated);
|
|
569
|
+
if (!second.valid) {
|
|
570
|
+
return {
|
|
571
|
+
composable: false,
|
|
572
|
+
composed: null,
|
|
573
|
+
warnings: [...warnings, bound('binding still invalid after dropping optional fields — composing nothing.')],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
return { composable: true, composed: mutated, warnings };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Read `<cwd>/.platform-bindings.json`. Absent → `present: false`. Present
|
|
580
|
+
* and a parseable JSON object → `present: true, malformed: false`. Present
|
|
581
|
+
* but malformed JSON or not a JSON object → `present: true, malformed: true`
|
|
582
|
+
* with one bounded warning; this deliberately does NOT adopt
|
|
583
|
+
* `cmdPlatformBindings`' hard-error-on-malformed-JSON behaviour — this arm
|
|
584
|
+
* reports a verdict as data on exit 0, and an unreadable file is a reason to
|
|
585
|
+
* do nothing rather than a reason to crash a `/pp-init` run.
|
|
586
|
+
*/
|
|
587
|
+
function readExistingBindings(cwd) {
|
|
588
|
+
const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
|
|
589
|
+
if (!node_fs_1.default.existsSync(bindingsPath)) {
|
|
590
|
+
return { present: false, malformed: false, data: {}, warnings: [] };
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const raw = node_fs_1.default.readFileSync(bindingsPath, 'utf-8');
|
|
594
|
+
const parsed = JSON.parse(raw);
|
|
595
|
+
if (!isObject(parsed)) {
|
|
596
|
+
return {
|
|
597
|
+
present: true,
|
|
598
|
+
malformed: true,
|
|
599
|
+
data: {},
|
|
600
|
+
warnings: [bound('existing .platform-bindings.json does not contain a JSON object — left untouched.')],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { present: true, malformed: false, data: parsed, warnings: [] };
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return {
|
|
607
|
+
present: true,
|
|
608
|
+
malformed: true,
|
|
609
|
+
data: {},
|
|
610
|
+
warnings: [bound('existing .platform-bindings.json could not be parsed — left untouched.')],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* D-08's absent-only merge. `merged` starts as a deep clone of `existing` —
|
|
616
|
+
* every field the existing file already carries (including the provisional
|
|
617
|
+
* `modules`/`ci` namespaces the composer never produces) is preserved
|
|
618
|
+
* untouched. For each field the composer actually composed (the keys of
|
|
619
|
+
* `candidateProvenance`): if `existing` lacks that key, fill it in from the
|
|
620
|
+
* candidate; if `existing` already has it, the merged value stays the
|
|
621
|
+
* existing one (never overwritten) and — when the candidate's value differs
|
|
622
|
+
* — one drift entry is recorded. A present `(repo, host)` pair can therefore
|
|
623
|
+
* never be redirected by a package; Phase 33 D-05's threat stays closed by
|
|
624
|
+
* construction, not by policy.
|
|
625
|
+
*/
|
|
626
|
+
function mergeAbsentOnly(candidate, candidateProvenance, existing) {
|
|
627
|
+
const merged = deepCloneJsonSafe(existing);
|
|
628
|
+
const provenance = [];
|
|
629
|
+
const provenanceByField = {};
|
|
630
|
+
const drift = [];
|
|
631
|
+
const managedFields = Object.keys(candidateProvenance);
|
|
632
|
+
for (const field of managedFields) {
|
|
633
|
+
const candidateValue = getNested(candidate, field);
|
|
634
|
+
if (hasNestedOwn(existing, field)) {
|
|
635
|
+
const existingValue = getNested(existing, field);
|
|
636
|
+
provenance.push({ field, value: existingValue, source: 'existing-file' });
|
|
637
|
+
provenanceByField[field] = 'existing-file';
|
|
638
|
+
if (!valuesEqual(candidateValue, existingValue)) {
|
|
639
|
+
drift.push({ key: field, profileValue: candidateValue, effectiveValue: existingValue });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
setNested(merged, field, candidateValue);
|
|
644
|
+
const source = candidateProvenance[field];
|
|
645
|
+
provenance.push({ field, value: candidateValue, source });
|
|
646
|
+
provenanceByField[field] = source;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Provenance-tag (never overwrite) every leaf the existing file carries
|
|
650
|
+
// that the composer does not manage at all.
|
|
651
|
+
const managedSet = new Set(managedFields);
|
|
652
|
+
const existingLeaves = [];
|
|
653
|
+
walkLeaves(existing, '', 6, existingLeaves);
|
|
654
|
+
for (const leaf of existingLeaves) {
|
|
655
|
+
if (managedSet.has(leaf.path))
|
|
656
|
+
continue;
|
|
657
|
+
provenance.push({ field: leaf.path, value: leaf.value, source: 'existing-file' });
|
|
658
|
+
provenanceByField[leaf.path] = 'existing-file';
|
|
659
|
+
}
|
|
660
|
+
return { merged, provenance, provenanceByField, drift };
|
|
661
|
+
}
|
|
662
|
+
// ─── composeBindings ──────────────────────────────────────────────────────────
|
|
663
|
+
const ABSENT_PROFILE_META = Object.freeze({
|
|
664
|
+
resolved: false,
|
|
665
|
+
packageName: null,
|
|
666
|
+
version: null,
|
|
667
|
+
source: 'none',
|
|
668
|
+
dataPath: null,
|
|
669
|
+
});
|
|
670
|
+
/**
|
|
671
|
+
* Compose a `.platform-bindings.json` candidate for the project at `cwd`,
|
|
672
|
+
* validate it, merge it against any existing file, and write only when
|
|
673
|
+
* `opts.write === true` and the result is composable. Never throws and
|
|
674
|
+
* never exits non-zero — every failure mode produces a bounded warning and
|
|
675
|
+
* continues (D-09).
|
|
676
|
+
*/
|
|
677
|
+
function composeBindings(cwd, opts = {}) {
|
|
678
|
+
const warnings = [];
|
|
679
|
+
try {
|
|
680
|
+
const { provider, warnings: providerWarnings } = (0, git_provider_cjs_1.resolveProvider)(cwd);
|
|
681
|
+
pushDeduped(warnings, providerWarnings);
|
|
682
|
+
const seedResult = readSeed(cwd, { _globalRoot: opts._globalRoot });
|
|
683
|
+
pushDeduped(warnings, seedResult.warnings);
|
|
684
|
+
const candidateResult = composeCandidate(cwd, seedResult.values, provider, opts);
|
|
685
|
+
pushDeduped(warnings, candidateResult.warnings);
|
|
686
|
+
const existing = readExistingBindings(cwd);
|
|
687
|
+
pushDeduped(warnings, existing.warnings);
|
|
688
|
+
if (existing.malformed) {
|
|
689
|
+
return {
|
|
690
|
+
profile: seedResult.profile,
|
|
691
|
+
provider,
|
|
692
|
+
seed: seedResult.values,
|
|
693
|
+
present: existing.present,
|
|
694
|
+
composable: false,
|
|
695
|
+
composed: null,
|
|
696
|
+
provenance: [],
|
|
697
|
+
drift: [],
|
|
698
|
+
action: 'none',
|
|
699
|
+
wrote: false,
|
|
700
|
+
warnings,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const mergeResult = mergeAbsentOnly(candidateResult.candidate, candidateResult.provenance, existing.data);
|
|
704
|
+
const disposeResult = validateAndDispose(mergeResult.merged, mergeResult.provenanceByField);
|
|
705
|
+
pushDeduped(warnings, disposeResult.warnings);
|
|
706
|
+
if (!disposeResult.composable || disposeResult.composed === null) {
|
|
707
|
+
return {
|
|
708
|
+
profile: seedResult.profile,
|
|
709
|
+
provider,
|
|
710
|
+
seed: seedResult.values,
|
|
711
|
+
present: existing.present,
|
|
712
|
+
composable: false,
|
|
713
|
+
composed: null,
|
|
714
|
+
provenance: mergeResult.provenance,
|
|
715
|
+
drift: mergeResult.drift,
|
|
716
|
+
action: 'none',
|
|
717
|
+
wrote: false,
|
|
718
|
+
warnings,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const composed = disposeResult.composed;
|
|
722
|
+
let action;
|
|
723
|
+
if (!existing.present) {
|
|
724
|
+
action = 'create';
|
|
725
|
+
}
|
|
726
|
+
else if (hasAnyNewKey(composed, existing.data)) {
|
|
727
|
+
action = 'update';
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
action = 'unchanged';
|
|
731
|
+
}
|
|
732
|
+
let wrote = false;
|
|
733
|
+
if (opts.write === true && action !== 'unchanged') {
|
|
734
|
+
const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
|
|
735
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(bindingsPath, JSON.stringify(composed, null, 2) + '\n');
|
|
736
|
+
wrote = true;
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
profile: seedResult.profile,
|
|
740
|
+
provider,
|
|
741
|
+
seed: seedResult.values,
|
|
742
|
+
present: existing.present,
|
|
743
|
+
composable: true,
|
|
744
|
+
composed,
|
|
745
|
+
provenance: mergeResult.provenance,
|
|
746
|
+
drift: mergeResult.drift,
|
|
747
|
+
action,
|
|
748
|
+
wrote,
|
|
749
|
+
warnings,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return {
|
|
754
|
+
profile: ABSENT_PROFILE_META,
|
|
755
|
+
provider: git_provider_cjs_1.DEFAULT_PROVIDER,
|
|
756
|
+
seed: {},
|
|
757
|
+
present: false,
|
|
758
|
+
composable: false,
|
|
759
|
+
composed: null,
|
|
760
|
+
provenance: [],
|
|
761
|
+
drift: [],
|
|
762
|
+
action: 'none',
|
|
763
|
+
wrote: false,
|
|
764
|
+
warnings: [...warnings, 'binding composition failed unexpectedly.'],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// ─── cmdComposeBindings ───────────────────────────────────────────────────────
|
|
769
|
+
/**
|
|
770
|
+
* CLI adapter for `pp-tools query compose-bindings`. Resolves the project
|
|
771
|
+
* root via `findProjectRoot(cwd) || cwd`, exactly as `cmdPlatformBindings`
|
|
772
|
+
* does, then calls `composeBindings` and emits the result as data — a
|
|
773
|
+
* verdict is never a process error.
|
|
774
|
+
*/
|
|
775
|
+
function cmdComposeBindings(cwd, options, raw) {
|
|
776
|
+
let root;
|
|
777
|
+
try {
|
|
778
|
+
root = (0, project_root_cjs_1.findProjectRoot)(cwd) || cwd;
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
root = cwd;
|
|
782
|
+
}
|
|
783
|
+
const result = composeBindings(root, { write: options.write === true, offline: options.offline === true });
|
|
784
|
+
output(result, raw);
|
|
785
|
+
}
|