@vincentt-xr/cli 0.15.1 → 0.16.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/dist/cli/args.js +1 -0
- package/dist/cli/index.js +177 -4
- package/dist/cli/usage-text.js +27 -0
- package/dist/cli/verb-surface.d.ts +3 -1
- package/dist/cli/verb-surface.js +12 -0
- package/dist/domain/api.d.ts +51 -0
- package/dist/domain/api.js +145 -0
- package/dist/domain/command.d.ts +75 -0
- package/dist/domain/command.js +467 -0
- package/dist/domain/copy.d.ts +334 -0
- package/dist/domain/copy.js +952 -0
- package/dist/domain/hostname.d.ts +57 -0
- package/dist/domain/hostname.js +116 -0
- package/dist/domain/names.d.ts +78 -0
- package/dist/domain/names.js +113 -0
- package/dist/domain/routedVerbs.d.ts +10 -0
- package/dist/domain/routedVerbs.js +30 -0
- package/dist/domain/wire.d.ts +140 -0
- package/dist/domain/wire.js +174 -0
- package/dist/dx/clusters.js +10 -1
- package/dist/dx/command.d.ts +13 -4
- package/dist/dx/command.js +26 -5
- package/dist/dx/report.d.ts +39 -7
- package/dist/dx/report.js +71 -20
- package/dist/dx/send.d.ts +22 -4
- package/dist/dx/send.js +29 -8
- package/dist/dx/skeleton.js +19 -0
- package/dist/dx/unbiddenVerbs.d.ts +25 -0
- package/dist/dx/unbiddenVerbs.js +39 -0
- package/dist/lib/exitCodes.d.ts +24 -0
- package/dist/lib/exitCodes.js +28 -0
- package/dist/lib/initCommand.d.ts +3 -1
- package/dist/lib/initCommand.js +10 -0
- package/dist/lib/initFrom.js +12 -2
- package/dist/lib/lifecycle.d.ts +5 -1
- package/dist/lib/projects.d.ts +8 -1
- package/dist/lib/projects.js +21 -4
- package/dist/platform/allowlist.d.ts +36 -0
- package/dist/platform/allowlist.js +34 -0
- package/dist/platform/behind.d.ts +41 -0
- package/dist/platform/behind.js +82 -0
- package/dist/platform/command.d.ts +16 -0
- package/dist/platform/command.js +90 -0
- package/dist/platform/footer.d.ts +60 -0
- package/dist/platform/footer.js +101 -0
- package/dist/platform/notice.d.ts +29 -0
- package/dist/platform/notice.js +66 -0
- package/dist/platform/offSwitch.d.ts +19 -0
- package/dist/platform/offSwitch.js +21 -0
- package/dist/platform/outdated.d.ts +25 -0
- package/dist/platform/outdated.js +246 -0
- package/dist/platform/releaseCache.d.ts +69 -0
- package/dist/platform/releaseCache.js +140 -0
- package/dist/platform/releaseFetch.d.ts +51 -0
- package/dist/platform/releaseFetch.js +150 -0
- package/dist/platform/resolve.d.ts +30 -0
- package/dist/platform/resolve.js +128 -0
- package/dist/platform/templateUpgrade.d.ts +56 -0
- package/dist/platform/templateUpgrade.js +476 -0
- package/dist/platform/upgradeReport.d.ts +132 -0
- package/dist/platform/upgradeReport.js +258 -0
- package/dist/platform/versionSummary.d.ts +82 -0
- package/dist/platform/versionSummary.js +181 -0
- package/dist/platform/zonePath.d.ts +154 -0
- package/dist/platform/zonePath.js +286 -0
- package/dist/platform/zones.d.ts +64 -0
- package/dist/platform/zones.js +79 -0
- package/dist/publish/api.d.ts +15 -4
- package/dist/publish/api.js +19 -5
- package/dist/publish/command.d.ts +10 -0
- package/dist/publish/command.js +7 -1
- package/dist/publish/copy.d.ts +14 -1
- package/dist/publish/copy.js +32 -3
- package/dist/scaffold/index.d.ts +50 -3
- package/dist/scaffold/index.js +61 -1
- package/dist/shared/config.d.ts +16 -0
- package/dist/shared/config.js +9 -1
- package/package.json +1 -1
- package/dist/dx/qa.f7.fixtures.d.ts +0 -73
- package/dist/dx/qa.f7.fixtures.js +0 -58
- package/dist/publish/qa.f4.fixtures.d.ts +0 -25
- package/dist/publish/qa.f4.fixtures.js +0 -140
- package/dist/relay/qa.f12.fixtures.d.ts +0 -154
- package/dist/relay/qa.f12.fixtures.js +0 -358
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template half of an upgrade: fetch two tags, classify, replace the
|
|
3
|
+
* plumbing, and report.
|
|
4
|
+
*
|
|
5
|
+
* THE ORDERING IS THE MOST LIKELY SILENT DEFECT IN THIS FEATURE, so it is stated
|
|
6
|
+
* here rather than left to a caller:
|
|
7
|
+
*
|
|
8
|
+
* 1. The template half runs ONLY AFTER the dependency half has reported
|
|
9
|
+
* completion for every platform package. Replacing plumbing that expects a
|
|
10
|
+
* new SDK, over a project still on the old one, produces a project that
|
|
11
|
+
* compiles against NEITHER — worse than either half alone, and the plumbing
|
|
12
|
+
* is precisely the code that breaks when the SDK contract moves. (The
|
|
13
|
+
* fail-forward rule governs WITHIN the dependency half, package to package.
|
|
14
|
+
* The template half is not a fourth package and does not inherit it.)
|
|
15
|
+
* 2. THE BASELINE IS WRITTEN LAST — after the file operations AND after the
|
|
16
|
+
* report is rendered. If the template half succeeded and a dependency bump
|
|
17
|
+
* then failed, a baseline written early means the NEXT upgrade compares
|
|
18
|
+
* against the new baseline and reports every file the creator had customized
|
|
19
|
+
* as "unchanged by you". The destructive half loses its only trace, it is
|
|
20
|
+
* invisible in a diff, and the symptom appears one upgrade later.
|
|
21
|
+
* 3. Because 1 and 2 are unverifiable at run time — the procedure may not read
|
|
22
|
+
* exit codes — the precondition is ORDERING PLUS AN HONEST REPORT LINE, never
|
|
23
|
+
* a detected gate. The procedure reads its own report.
|
|
24
|
+
*
|
|
25
|
+
* THE FROM REF IS UNTRUSTED INPUT. `.vincentt/project.json` travels with a
|
|
26
|
+
* repository and anyone who can land a commit can set it. Four controls, all
|
|
27
|
+
* here: semver-shaped validation before it reaches git; the remote is a module
|
|
28
|
+
* constant; a fully-qualified tag ref rather than a bare branch argument; and a
|
|
29
|
+
* ref that resolves but whose tree lacks the manifest is NO BASELINE.
|
|
30
|
+
*
|
|
31
|
+
* NO BACKUP COPY, NO PRE-FLIGHT CHECK, NO PLATFORM-SIDE REVERT. All three were
|
|
32
|
+
* ruled out deliberately: the creator's version control is the recovery
|
|
33
|
+
* mechanism and it is better than ours, and a `.bak` is the platform inventing a
|
|
34
|
+
* recovery mechanism in a repository it does not own. Do not add a safety
|
|
35
|
+
* measure the design refused.
|
|
36
|
+
*/
|
|
37
|
+
import { execFile } from "node:child_process";
|
|
38
|
+
import { promises as fs } from "node:fs";
|
|
39
|
+
import os from "node:os";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
import { promisify } from "node:util";
|
|
42
|
+
import { PLATFORM_PACKAGES } from "./allowlist.js";
|
|
43
|
+
import { isDeniedByName, validateZonePath } from "./zonePath.js";
|
|
44
|
+
import { parseZoneManifest } from "./zones.js";
|
|
45
|
+
import { renderBlockedReport, renderDegradedReport, renderDependencyIncompleteReport, renderUpgradeReport, } from "./upgradeReport.js";
|
|
46
|
+
import { loadProjectBinding, writeProjectBinding } from "../shared/config.js";
|
|
47
|
+
const execFileAsync = promisify(execFile);
|
|
48
|
+
/** THE REMOTE IS A MODULE CONSTANT. Never read from the binding — see the header. */
|
|
49
|
+
const TEMPLATE_REMOTE = "https://github.com/vincentt-xr/v2-template.git";
|
|
50
|
+
/** The manifest file, read-only: it arrives with the TO tree and is never in its own overwrite set. */
|
|
51
|
+
export const MANIFEST_FILE = ".vincentt-template.json";
|
|
52
|
+
/** Anchored at both ends. A branch named `1.4.3` does not satisfy `refs/tags/1.4.3`. */
|
|
53
|
+
const CONCRETE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
54
|
+
/** The first template version carrying a manifest, for the blocked report's copy. */
|
|
55
|
+
export const FIRST_MANIFEST_VERSION = "1.5.0";
|
|
56
|
+
const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
|
|
57
|
+
const realGit = async (args) => {
|
|
58
|
+
const { stdout } = await execFileAsync("git", args);
|
|
59
|
+
return stdout;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Validate a recorded FROM ref BEFORE it reaches git.
|
|
63
|
+
*
|
|
64
|
+
* Anything not exactly `MAJOR.MINOR.PATCH` is NO BASELINE — not an error, and
|
|
65
|
+
* not a bare attempt. `../../etc`, `refs/heads/1.4.3`, `1.4.3; rm -rf` and
|
|
66
|
+
* `latest` all resolve to "no baseline" without git being invoked at all.
|
|
67
|
+
*/
|
|
68
|
+
export function validateFromRef(raw) {
|
|
69
|
+
if (raw === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
return CONCRETE_VERSION.test(raw) ? raw : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run the template half.
|
|
75
|
+
*
|
|
76
|
+
* Every early return renders a report and writes nothing. There is no path
|
|
77
|
+
* through this function that overwrites a file without the report naming it.
|
|
78
|
+
*/
|
|
79
|
+
export async function upgradeTemplate(deps) {
|
|
80
|
+
// ORDERING, PART 1. Before any tag is fetched and before any file is read.
|
|
81
|
+
if (deps.dependencyOutcome.outstanding.length > 0) {
|
|
82
|
+
return {
|
|
83
|
+
report: renderDependencyIncompleteReport({
|
|
84
|
+
completed: deps.dependencyOutcome.completed,
|
|
85
|
+
outstanding: deps.dependencyOutcome.outstanding,
|
|
86
|
+
}),
|
|
87
|
+
templateAttempted: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const binding = await loadProjectBinding(deps.projectDir).catch(() => undefined);
|
|
91
|
+
const from = validateFromRef(binding?.template?.version);
|
|
92
|
+
const fetchTag = deps.fetchTag ?? ((ref) => cloneTag(ref, deps.runGit ?? realGit));
|
|
93
|
+
const toDir = await fetchTag(deps.to);
|
|
94
|
+
if (toDir === undefined) {
|
|
95
|
+
return {
|
|
96
|
+
report: renderBlockedReport({
|
|
97
|
+
dependencies: deps.dependencyOutcome.completed,
|
|
98
|
+
from,
|
|
99
|
+
firstManifestVersion: FIRST_MANIFEST_VERSION,
|
|
100
|
+
}),
|
|
101
|
+
templateAttempted: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// THE FLOOR RUNS BEFORE ANY FILE IS READ. One failure refuses the whole
|
|
105
|
+
// manifest and the template half reports not attempted.
|
|
106
|
+
const toZones = await readManifest(toDir);
|
|
107
|
+
if (!toZones.ok) {
|
|
108
|
+
return {
|
|
109
|
+
report: blockedFor(toZones.refusal, deps.dependencyOutcome.completed, from, deps.to),
|
|
110
|
+
templateAttempted: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// A ref that resolves but whose tree lacks the manifest is NO BASELINE — this
|
|
114
|
+
// closes the fallback-to-empty-manifest hole, where an attacker-chosen FROM
|
|
115
|
+
// with no manifest would otherwise degrade to "compare against nothing".
|
|
116
|
+
//
|
|
117
|
+
// The three values are one object rather than three variables, so "we have a
|
|
118
|
+
// baseline" is a single check the type system enforces. Held separately, a
|
|
119
|
+
// later edit can test one and read another, which is precisely how a
|
|
120
|
+
// no-baseline run would slip into the destructive path.
|
|
121
|
+
const baseline = await resolveBaseline(from, fetchTag);
|
|
122
|
+
const trackedStateOf = deps.trackedStateOf ?? gitTrackedState;
|
|
123
|
+
// Every manifest entry is permitted BY CONSTRUCTION: the whole manifest was
|
|
124
|
+
// floor-validated above, and one failure refuses all of it. So splitting the
|
|
125
|
+
// manifest by the floor cannot populate the not-permitted set, and an earlier
|
|
126
|
+
// version of this loop left that report section permanently empty — which is
|
|
127
|
+
// the opposite of what it exists for.
|
|
128
|
+
//
|
|
129
|
+
// The not-permitted set is the template's own plumbing that the manifest CANNOT
|
|
130
|
+
// name because the floor would refuse it: `scripts/*.mjs` execute on the
|
|
131
|
+
// creator's machine, and the template's lint config is a dot-leading root file.
|
|
132
|
+
// Both are therefore never upgraded, and the report NAMES them rather than
|
|
133
|
+
// letting them go silently stale.
|
|
134
|
+
const permitted = [...toZones.zones.platform];
|
|
135
|
+
const notPermitted = await unpermittedTemplatePaths(toDir, toZones.zones);
|
|
136
|
+
if (baseline === undefined) {
|
|
137
|
+
// THE DEGRADED PATH: reports, does not overwrite. Files already matching TO
|
|
138
|
+
// are verified or restored; anything differing is NAMED AND LEFT ALONE.
|
|
139
|
+
// Overwriting while unable to say what was overwritten is the destructive
|
|
140
|
+
// act with its only compensating control removed — and this path is
|
|
141
|
+
// reachable by deleting one gitignored file, which is invisible in a diff.
|
|
142
|
+
const matching = [];
|
|
143
|
+
const differing = [];
|
|
144
|
+
const restored = [];
|
|
145
|
+
for (const rel of permitted) {
|
|
146
|
+
const want = await readTemplateFile(toDir, rel);
|
|
147
|
+
if (want === undefined)
|
|
148
|
+
continue;
|
|
149
|
+
const have = await readProjectFile(deps.projectDir, rel);
|
|
150
|
+
if (have === undefined) {
|
|
151
|
+
await writeProjectFile(deps.projectDir, rel, want);
|
|
152
|
+
restored.push(rel);
|
|
153
|
+
}
|
|
154
|
+
else if (have.equals(want)) {
|
|
155
|
+
matching.push(rel);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
differing.push(rel);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const verified = [...matching, ...restored];
|
|
162
|
+
const report = renderDegradedReport({
|
|
163
|
+
dependencies: deps.dependencyOutcome.completed,
|
|
164
|
+
to: deps.to,
|
|
165
|
+
matching: verified.filter((p) => !TEST_FILE.test(p)),
|
|
166
|
+
matchingTestCount: verified.filter((p) => TEST_FILE.test(p)).length,
|
|
167
|
+
differing,
|
|
168
|
+
deletions: [],
|
|
169
|
+
baselineRecorded: true,
|
|
170
|
+
});
|
|
171
|
+
// BASELINE LAST — after the file operations and after the report renders.
|
|
172
|
+
await recordBaseline(deps.projectDir, deps.to);
|
|
173
|
+
return { report, templateAttempted: true, baselineWritten: deps.to };
|
|
174
|
+
}
|
|
175
|
+
// THE DESTRUCTIVE PATH. Classify against FROM's original, then replace.
|
|
176
|
+
const changed = [];
|
|
177
|
+
const unchanged = [];
|
|
178
|
+
let unchangedTestCount = 0;
|
|
179
|
+
for (const rel of permitted) {
|
|
180
|
+
const want = await readTemplateFile(toDir, rel);
|
|
181
|
+
if (want === undefined)
|
|
182
|
+
continue;
|
|
183
|
+
const original = await readTemplateFile(baseline.dir, rel);
|
|
184
|
+
const have = await readProjectFile(deps.projectDir, rel);
|
|
185
|
+
// Tracked-ness is read BEFORE the write, because after it the file is
|
|
186
|
+
// modified and `diff-index` would answer about the new contents.
|
|
187
|
+
const isChanged = have !== undefined && original !== undefined && !have.equals(original);
|
|
188
|
+
const tracked = isChanged ? await trackedStateOf(deps.projectDir, rel) : "tracked";
|
|
189
|
+
await writeProjectFile(deps.projectDir, rel, want);
|
|
190
|
+
if (isChanged) {
|
|
191
|
+
changed.push({ path: rel, tracked });
|
|
192
|
+
}
|
|
193
|
+
else if (TEST_FILE.test(rel)) {
|
|
194
|
+
unchangedTestCount++;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
unchanged.push(rel);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Deletions are REPORTED, NEVER REMOVED. Removal is a step past overwriting,
|
|
201
|
+
// and the ruling that permits overwriting does not reach it.
|
|
202
|
+
const toSet = new Set(toZones.zones.platform);
|
|
203
|
+
const deletions = baseline.zones.platform.filter((p) => !toSet.has(p));
|
|
204
|
+
const report = renderUpgradeReport({
|
|
205
|
+
dependencies: deps.dependencyOutcome.completed,
|
|
206
|
+
comparedAgainst: baseline.ref,
|
|
207
|
+
to: deps.to,
|
|
208
|
+
changed,
|
|
209
|
+
unchanged,
|
|
210
|
+
unchangedTestCount,
|
|
211
|
+
deletions,
|
|
212
|
+
notPermitted,
|
|
213
|
+
});
|
|
214
|
+
// BASELINE LAST. See the header — this ordering is the whole defence against
|
|
215
|
+
// the next upgrade laundering the creator's work.
|
|
216
|
+
await recordBaseline(deps.projectDir, deps.to);
|
|
217
|
+
return { report, templateAttempted: true, baselineWritten: deps.to };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the baseline, or `undefined` for NO BASELINE.
|
|
221
|
+
*
|
|
222
|
+
* Every failure reduces rather than widens: an unvalidatable ref, an
|
|
223
|
+
* unreachable tag, a tree with no manifest and a manifest the floor refuses all
|
|
224
|
+
* return `undefined`, which routes to the reporting path where nothing is
|
|
225
|
+
* replaced. No fingerprinting and no guessed FROM — inference on a destructive
|
|
226
|
+
* path means confidently reporting "you had not changed this" about a file just
|
|
227
|
+
* destroyed.
|
|
228
|
+
*/
|
|
229
|
+
async function resolveBaseline(from, fetchTag) {
|
|
230
|
+
if (from === undefined)
|
|
231
|
+
return undefined;
|
|
232
|
+
const dir = await fetchTag(from);
|
|
233
|
+
if (dir === undefined)
|
|
234
|
+
return undefined;
|
|
235
|
+
const parsed = await readManifest(dir);
|
|
236
|
+
if (!parsed.ok)
|
|
237
|
+
return undefined;
|
|
238
|
+
return { ref: from, dir, zones: parsed.zones };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Every refusal produces the same OUTCOME — nothing read, nothing written — but
|
|
242
|
+
* not the same EXPLANATION.
|
|
243
|
+
*
|
|
244
|
+
* `unparseable` here means the manifest file was absent or unreadable, which is
|
|
245
|
+
* the day-one case: the tag predates the manifest. Every other refusal means a
|
|
246
|
+
* manifest was present and the floor rejected it, which is a fault in the
|
|
247
|
+
* template version rather than in the project's age. The report must not tell
|
|
248
|
+
* someone their old template lacks a file when the real cause is a refused one.
|
|
249
|
+
*/
|
|
250
|
+
function blockedFor(refusal, dependencies, from, to) {
|
|
251
|
+
return renderBlockedReport({
|
|
252
|
+
dependencies,
|
|
253
|
+
from,
|
|
254
|
+
to,
|
|
255
|
+
firstManifestVersion: FIRST_MANIFEST_VERSION,
|
|
256
|
+
reason: refusal.kind === "unparseable" ? "missing" : "refused",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* `git clone --depth 1` at a fully-qualified TAG ref, read-only, no credential,
|
|
261
|
+
* remote from the module constant. Tags are immutable, so FROM is reproducible
|
|
262
|
+
* forever.
|
|
263
|
+
*/
|
|
264
|
+
async function cloneTag(ref, runGit) {
|
|
265
|
+
if (validateFromRef(ref) === undefined)
|
|
266
|
+
return undefined;
|
|
267
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-up-"));
|
|
268
|
+
try {
|
|
269
|
+
await runGit([
|
|
270
|
+
"clone",
|
|
271
|
+
"--depth",
|
|
272
|
+
"1",
|
|
273
|
+
// `refs/tags/<v>`, never a bare branch argument: a branch named `1.4.3`
|
|
274
|
+
// does not satisfy this and resolves to nothing rather than to a tree
|
|
275
|
+
// someone else controls.
|
|
276
|
+
"--branch",
|
|
277
|
+
`refs/tags/${ref}`,
|
|
278
|
+
TEMPLATE_REMOTE,
|
|
279
|
+
tmp,
|
|
280
|
+
]);
|
|
281
|
+
return tmp;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The template's own plumbing that the manifest CANNOT name, because the floor
|
|
289
|
+
* would refuse the path.
|
|
290
|
+
*
|
|
291
|
+
* This is NOT "entries the floor rejected" — every manifest entry is permitted by
|
|
292
|
+
* construction, since one refusal rejects the whole manifest. It is the opposite
|
|
293
|
+
* direction: files the template genuinely ships, in neither zone array, whose
|
|
294
|
+
* paths the floor does not admit. `scripts/*.mjs` execute on the creator's
|
|
295
|
+
* machine and a dot-leading root file is refused by the segment rule, so neither
|
|
296
|
+
* can ever be in an overwrite set.
|
|
297
|
+
*
|
|
298
|
+
* They are NAMED IN THE REPORT rather than left silently stale, which is the
|
|
299
|
+
* whole point of the not-permitted section. Derived from the tree so a template
|
|
300
|
+
* that adds another such file is reported without a code change here.
|
|
301
|
+
*/
|
|
302
|
+
async function unpermittedTemplatePaths(toDir, zones) {
|
|
303
|
+
const listed = new Set([...zones.platform, ...zones.creator]);
|
|
304
|
+
const out = [];
|
|
305
|
+
const walk = async (rel) => {
|
|
306
|
+
const entries = await fs.readdir(path.join(toDir, rel || "."), {
|
|
307
|
+
withFileTypes: true,
|
|
308
|
+
});
|
|
309
|
+
for (const e of entries) {
|
|
310
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
311
|
+
// `.git` is the clone's own plumbing, not the template's content.
|
|
312
|
+
if (e.name === ".git")
|
|
313
|
+
continue;
|
|
314
|
+
if (e.isDirectory()) {
|
|
315
|
+
if (e.name === "node_modules")
|
|
316
|
+
continue;
|
|
317
|
+
await walk(child);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (listed.has(child))
|
|
321
|
+
continue;
|
|
322
|
+
if (child === MANIFEST_FILE)
|
|
323
|
+
continue;
|
|
324
|
+
// Denied-by-name files are refused, but they are the CREATOR's or the
|
|
325
|
+
// grounding contract — never "plumbing we could not upgrade".
|
|
326
|
+
if (isDeniedByName(child))
|
|
327
|
+
continue;
|
|
328
|
+
if (!validateZonePath(child).ok)
|
|
329
|
+
out.push(child);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
try {
|
|
333
|
+
await walk("");
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// The tree is a temp clone we just read a manifest out of; if it became
|
|
337
|
+
// unreadable the report simply omits this section rather than failing an
|
|
338
|
+
// upgrade that already succeeded.
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
return out.sort();
|
|
342
|
+
}
|
|
343
|
+
async function readManifest(dir) {
|
|
344
|
+
try {
|
|
345
|
+
return parseZoneManifest(await fs.readFile(path.join(dir, MANIFEST_FILE), "utf8"));
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return { ok: false, refusal: { kind: "unparseable" } };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Read one file from a fetched template tree.
|
|
353
|
+
*
|
|
354
|
+
* The path has already passed the floor, and it is re-checked here anyway: this
|
|
355
|
+
* is the last point before a read, and a caller assembling a path from a
|
|
356
|
+
* different source must not be able to reach the filesystem through this
|
|
357
|
+
* function.
|
|
358
|
+
*/
|
|
359
|
+
async function readTemplateFile(dir, rel) {
|
|
360
|
+
if (!validateZonePath(rel).ok)
|
|
361
|
+
return undefined;
|
|
362
|
+
try {
|
|
363
|
+
return await fs.readFile(path.join(dir, rel));
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function readProjectFile(projectDir, rel) {
|
|
370
|
+
if (!validateZonePath(rel).ok)
|
|
371
|
+
return undefined;
|
|
372
|
+
const target = await safeTarget(projectDir, rel);
|
|
373
|
+
if (target === undefined)
|
|
374
|
+
return undefined;
|
|
375
|
+
try {
|
|
376
|
+
return await fs.readFile(target);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function writeProjectFile(projectDir, rel, contents) {
|
|
383
|
+
if (!validateZonePath(rel).ok)
|
|
384
|
+
return;
|
|
385
|
+
if (isDeniedByName(rel))
|
|
386
|
+
return;
|
|
387
|
+
const target = await safeTarget(projectDir, rel);
|
|
388
|
+
if (target === undefined)
|
|
389
|
+
return;
|
|
390
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
391
|
+
await fs.writeFile(target, contents);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Resolve a write destination inside the project root, refusing a symlink.
|
|
395
|
+
*
|
|
396
|
+
* Belt-and-braces against a symlink planted in the creator's tree, which the
|
|
397
|
+
* source-side floor structurally cannot see: the manifest names an innocuous
|
|
398
|
+
* relative path and the creator's own tree redirects it. `realpath` on the
|
|
399
|
+
* PARENT (the file itself may legitimately not exist yet, on the restore path),
|
|
400
|
+
* plus an lstat refusing a symlink at the leaf.
|
|
401
|
+
*/
|
|
402
|
+
async function safeTarget(projectDir, rel) {
|
|
403
|
+
const root = await fs.realpath(projectDir).catch(() => undefined);
|
|
404
|
+
if (root === undefined)
|
|
405
|
+
return undefined;
|
|
406
|
+
const target = path.resolve(root, rel);
|
|
407
|
+
const parent = path.dirname(target);
|
|
408
|
+
const realParent = await fs.realpath(parent).catch(() => parent);
|
|
409
|
+
if (realParent !== root && !realParent.startsWith(root + path.sep))
|
|
410
|
+
return undefined;
|
|
411
|
+
const stat = await fs.lstat(target).catch(() => undefined);
|
|
412
|
+
if (stat?.isSymbolicLink())
|
|
413
|
+
return undefined;
|
|
414
|
+
return path.join(realParent, path.basename(target));
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* How version control saw this file. A statement, NEVER a gate — nothing is
|
|
418
|
+
* refused, delayed, or required on the strength of it, so this does not
|
|
419
|
+
* reintroduce the refused pre-flight check as a courtesy.
|
|
420
|
+
*/
|
|
421
|
+
async function gitTrackedState(projectDir, rel) {
|
|
422
|
+
try {
|
|
423
|
+
await execFileAsync("git", ["-C", projectDir, "rev-parse", "--git-dir"]);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return "no_repository";
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
await execFileAsync("git", ["-C", projectDir, "ls-files", "--error-unmatch", rel]);
|
|
430
|
+
return "tracked";
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// Untracked: ignored is the narrower cause and a creator can act on the
|
|
434
|
+
// difference, so it is distinguished rather than folded in.
|
|
435
|
+
try {
|
|
436
|
+
const { stdout } = await execFileAsync("git", [
|
|
437
|
+
"-C",
|
|
438
|
+
projectDir,
|
|
439
|
+
"check-ignore",
|
|
440
|
+
rel,
|
|
441
|
+
]);
|
|
442
|
+
return stdout.trim() === "" ? "untracked" : "ignored";
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return "untracked";
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* THE ONE PLACE the platform writes `.vincentt/project.json` after create.
|
|
451
|
+
*
|
|
452
|
+
* Surfaced rather than absorbed: it is the CLI's own binding record and not the
|
|
453
|
+
* creator's source, it happens only on an explicitly-invoked act, it records a
|
|
454
|
+
* fact the creator's act just made true, and without it the no-baseline case
|
|
455
|
+
* never heals.
|
|
456
|
+
*/
|
|
457
|
+
async function recordBaseline(projectDir, version) {
|
|
458
|
+
const binding = await loadProjectBinding(projectDir).catch(() => undefined);
|
|
459
|
+
if (binding === undefined)
|
|
460
|
+
return;
|
|
461
|
+
await writeProjectBinding(projectDir, { ...binding, template: { version } }).catch(() => { });
|
|
462
|
+
}
|
|
463
|
+
/** Which platform packages this project has installed, for the ordering precondition. */
|
|
464
|
+
export async function installedPlatformPackages(projectDir) {
|
|
465
|
+
const found = [];
|
|
466
|
+
for (const pkg of PLATFORM_PACKAGES) {
|
|
467
|
+
try {
|
|
468
|
+
await fs.access(path.join(projectDir, "node_modules", ...pkg.name.split("/"), "package.json"));
|
|
469
|
+
found.push(pkg.name);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// Not installed contributes nothing.
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return found;
|
|
476
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template upgrade report — the ONLY trace of an operation that replaced
|
|
3
|
+
* files in a repository the platform does not own, with no pre-flight check, no
|
|
4
|
+
* backup, and no platform-side revert.
|
|
5
|
+
*
|
|
6
|
+
* READ THIS MODULE AS A SECURITY CONTROL, because that is what it is. Every
|
|
7
|
+
* ordering decision serves one goal: a reader who takes only the first block
|
|
8
|
+
* takes the bad news.
|
|
9
|
+
*
|
|
10
|
+
* Four rules govern every string here, and each exists because a draft broke it:
|
|
11
|
+
*
|
|
12
|
+
* 1. The destructive section is FIRST, above every reassurance and every
|
|
13
|
+
* success framing, and it is NEVER COMPRESSED at any length. Other sections
|
|
14
|
+
* may collapse to middot lists and counts to keep this one above the fold.
|
|
15
|
+
* 2. The unrecoverable caveat PRECEDES the reassurance, with its files named. A
|
|
16
|
+
* reader who takes one sentence of a pair takes the first.
|
|
17
|
+
* 3. `unchanged` is NEVER a bare claim. It always carries the ref it was
|
|
18
|
+
* compared against, in the heading, because the heading is what a skimming
|
|
19
|
+
* reader reads — and the procedure cannot verify that ref, so it must not
|
|
20
|
+
* print a sentence implying it did.
|
|
21
|
+
* 4. NO state-restoring transformation is named anywhere. "Names no command"
|
|
22
|
+
* was proven insufficient: a sentence naming no command can fully specify a
|
|
23
|
+
* hard reset, which is instruction with the imperative removed. On this tree
|
|
24
|
+
* a hard reset or force-clean destroys the project binding, the env file,
|
|
25
|
+
* and every asset the creator dropped in — all gitignored, and a dirty tree
|
|
26
|
+
* is the modal case here.
|
|
27
|
+
*
|
|
28
|
+
* There is no success glyph in this file. A `✓` above a list of destroyed work
|
|
29
|
+
* is the report arguing with itself. And no command appears anywhere — not a
|
|
30
|
+
* build command, not a test command, not a version-control command.
|
|
31
|
+
*/
|
|
32
|
+
/** How version control saw a replaced file. The two causes differ and a creator can act on the difference. */
|
|
33
|
+
export type TrackedState = "tracked" | "untracked" | "ignored" | "no_repository";
|
|
34
|
+
export interface ReplacedFile {
|
|
35
|
+
path: string;
|
|
36
|
+
tracked: TrackedState;
|
|
37
|
+
}
|
|
38
|
+
export interface DependencyChange {
|
|
39
|
+
name: string;
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
}
|
|
43
|
+
export interface UpgradeReport {
|
|
44
|
+
/** Present when the dependency half reported completion. Stated FIRST when the template half did not run. */
|
|
45
|
+
dependencies: DependencyChange[];
|
|
46
|
+
/** The FROM ref the classification rests on. Printed as a premise, never as a verified fact. */
|
|
47
|
+
comparedAgainst?: string;
|
|
48
|
+
to: string;
|
|
49
|
+
/** Replaced AND differing from FROM — the destructive list. */
|
|
50
|
+
changed: ReplacedFile[];
|
|
51
|
+
/** Replaced and byte-identical to FROM. */
|
|
52
|
+
unchanged: string[];
|
|
53
|
+
/** How many of `unchanged` are shipped test files, folded into a count. */
|
|
54
|
+
unchangedTestCount: number;
|
|
55
|
+
/** In FROM's platform set, absent from TO's. Reported, never removed. */
|
|
56
|
+
deletions: string[];
|
|
57
|
+
/** Named by the manifest but refused by the floor's allowlist. */
|
|
58
|
+
notPermitted: string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The full report for a completed upgrade.
|
|
62
|
+
*
|
|
63
|
+
* The header states the transition and nothing else — no duration, no file
|
|
64
|
+
* count, no "upgraded successfully".
|
|
65
|
+
*/
|
|
66
|
+
export declare function renderUpgradeReport(r: UpgradeReport): string[];
|
|
67
|
+
/**
|
|
68
|
+
* The template half BLOCKED — a tag at either end carries no manifest, or its
|
|
69
|
+
* schema is unrecognized. THE MODAL OUTCOME for the first weeks, because every
|
|
70
|
+
* existing template tag predates the manifest.
|
|
71
|
+
*
|
|
72
|
+
* The dependency half's success is stated FIRST, because it did work. The reason
|
|
73
|
+
* is two sentences of plain language, it states what was NOT READ as well as not
|
|
74
|
+
* written, and it says what changes the situation without promising a date or
|
|
75
|
+
* naming a command. The word `zone` appears nowhere: it is internal vocabulary
|
|
76
|
+
* and a creator has never heard it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function renderBlockedReport(r: {
|
|
79
|
+
dependencies: DependencyChange[];
|
|
80
|
+
from?: string;
|
|
81
|
+
firstManifestVersion: string;
|
|
82
|
+
/**
|
|
83
|
+
* `missing` — the tag predates the manifest (the day-one case).
|
|
84
|
+
* `refused` — a manifest IS present and did not pass the floor.
|
|
85
|
+
*
|
|
86
|
+
* The two must not share copy. Telling a creator their old template "does not
|
|
87
|
+
* carry the file" when a manifest was actually present and REFUSED is a
|
|
88
|
+
* misleading explanation on the one path where the explanation is the whole
|
|
89
|
+
* compensating control — and it would send someone upgrading the template to
|
|
90
|
+
* fix the wrong thing.
|
|
91
|
+
*/
|
|
92
|
+
reason?: "missing" | "refused";
|
|
93
|
+
/** The version being upgraded TO, named in the refused-manifest copy. */
|
|
94
|
+
to?: string;
|
|
95
|
+
}): string[];
|
|
96
|
+
/**
|
|
97
|
+
* The DEGRADED report — no recorded baseline, so nothing was replaced.
|
|
98
|
+
*
|
|
99
|
+
* Two lists, and the distinction between them is the entire deliverable: "already
|
|
100
|
+
* matching, byte for byte" is a VERIFIED fact; "differs — left alone" is a fact
|
|
101
|
+
* with an UNKNOWN CAUSE. One combined list of "template files" would collapse
|
|
102
|
+
* them and make the report worthless.
|
|
103
|
+
*
|
|
104
|
+
* No `!` and no `✗`: nothing failed. This is the designed behavior of a project
|
|
105
|
+
* in a known state, and dressing it as a warning teaches creators to fear a safe
|
|
106
|
+
* outcome. The word "revert" does not appear and neither does any restoring
|
|
107
|
+
* phrase — nothing was destroyed here, so there is nothing to say about
|
|
108
|
+
* recovery, and saying it anyway would put the phrase into a transcript an agent
|
|
109
|
+
* may carry into the destructive path's context.
|
|
110
|
+
*/
|
|
111
|
+
export declare function renderDegradedReport(r: {
|
|
112
|
+
dependencies: DependencyChange[];
|
|
113
|
+
to: string;
|
|
114
|
+
matching: string[];
|
|
115
|
+
matchingTestCount: number;
|
|
116
|
+
differing: string[];
|
|
117
|
+
deletions: string[];
|
|
118
|
+
baselineRecorded: boolean;
|
|
119
|
+
}): string[];
|
|
120
|
+
/**
|
|
121
|
+
* The report for the case the ORDERING creates: the dependency half did not
|
|
122
|
+
* report completion, so the template half was not attempted.
|
|
123
|
+
*
|
|
124
|
+
* The precondition cannot be verified at run time — the procedure may not read
|
|
125
|
+
* exit codes — so it is expressed as ordering plus an honest line, never as a
|
|
126
|
+
* detected gate. The capability is DEFERRED, not withdrawn, and saying so is
|
|
127
|
+
* what stops a creator reading it as broken.
|
|
128
|
+
*/
|
|
129
|
+
export declare function renderDependencyIncompleteReport(r: {
|
|
130
|
+
completed: DependencyChange[];
|
|
131
|
+
outstanding: string[];
|
|
132
|
+
}): string[];
|