@krapwoo/ds-viewer 0.8.0 → 0.9.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/dist/cli/dependencyBumpProof.js +216 -0
- package/dist/cli/dev.js +49 -5
- package/dist/cli/update.js +20 -1
- package/dist/cli/updatePlan.js +67 -11
- package/dist/cli/workspace.js +37 -1
- package/native/catalog/UpdatePanel.tsx +507 -49
- package/native/catalog/updatePanelState.ts +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// cli/dependencyBumpProof.ts
|
|
2
|
+
/** Conservative proof that a dirty `package.json` or npm `package-lock.json` is *solely* a
|
|
3
|
+
* viewer-only dependency bump to the version already installed — i.e. a previously-started
|
|
4
|
+
* update that the running process itself already finished. Every check here fails closed:
|
|
5
|
+
* malformed JSON, an unsupported lockfile shape, a missing side, or any other difference at all
|
|
6
|
+
* leaves the file classified unsafe (still dirty). Pure functions only — `updatePlan.ts` owns
|
|
7
|
+
* reading `HEAD` and the working tree. Every comparison here is against *parsed* JSON, not raw
|
|
8
|
+
* text: formatting-only and key-ordering differences are never treated as a difference, and
|
|
9
|
+
* duplicate keys in either side collapse to their last occurrence, exactly as `JSON.parse` does. */
|
|
10
|
+
import { compareVersions, parseVersion } from "./semver.js";
|
|
11
|
+
const PACKAGE_NAME = '@krapwoo/ds-viewer';
|
|
12
|
+
function isPlainObject(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function deepEqual(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return true;
|
|
18
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
19
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
20
|
+
}
|
|
21
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
22
|
+
const aKeys = Object.keys(a);
|
|
23
|
+
return aKeys.length === Object.keys(b).length && aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/** Every leaf (or missing-key) difference between two parsed JSON values, each named by its full
|
|
28
|
+
* key path. An empty result means the two values are deeply equal. */
|
|
29
|
+
function diffJson(head, working, path = []) {
|
|
30
|
+
if (deepEqual(head, working))
|
|
31
|
+
return [];
|
|
32
|
+
if (isPlainObject(head) && isPlainObject(working)) {
|
|
33
|
+
const diffs = [];
|
|
34
|
+
for (const key of new Set([...Object.keys(head), ...Object.keys(working)])) {
|
|
35
|
+
const inHead = Object.prototype.hasOwnProperty.call(head, key);
|
|
36
|
+
const inWorking = Object.prototype.hasOwnProperty.call(working, key);
|
|
37
|
+
if (inHead && !inWorking)
|
|
38
|
+
diffs.push({ path: [...path, key], type: 'removed' });
|
|
39
|
+
else if (!inHead && inWorking)
|
|
40
|
+
diffs.push({ path: [...path, key], type: 'added', workingValue: working[key] });
|
|
41
|
+
else
|
|
42
|
+
diffs.push(...diffJson(head[key], working[key], [...path, key]));
|
|
43
|
+
}
|
|
44
|
+
return diffs;
|
|
45
|
+
}
|
|
46
|
+
return [{ path, type: 'changed', workingValue: working }];
|
|
47
|
+
}
|
|
48
|
+
/** The three registry forms `update` (and a prior run of it) ever writes for an exact target
|
|
49
|
+
* version — never a local/path/workspace spec, which `isLocalSpec` elsewhere already refuses to
|
|
50
|
+
* update over in the first place. */
|
|
51
|
+
function isAcceptedRegistryForm(spec, currentVersion) {
|
|
52
|
+
return spec === currentVersion || spec === `^${currentVersion}` || spec === `~${currentVersion}`;
|
|
53
|
+
}
|
|
54
|
+
/** Whether a HEAD spec names a version strictly older than `currentVersion` — the proof that the
|
|
55
|
+
* working spec is a genuine forward bump, not a same-version range edit (e.g. HEAD `"^0.4.0"` →
|
|
56
|
+
* working `"0.4.0"` at `currentVersion` `"0.4.0"`, which names the same version both sides).
|
|
57
|
+
* Fails closed on anything other than a parseable exact/caret/tilde registry form. */
|
|
58
|
+
function isOlderRegistryForm(spec, currentVersion) {
|
|
59
|
+
if (typeof spec !== 'string')
|
|
60
|
+
return false;
|
|
61
|
+
const exact = spec.replace(/^[\^~]/, '');
|
|
62
|
+
try {
|
|
63
|
+
return compareVersions(parseVersion(exact), parseVersion(currentVersion)) === -1;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function headValueAtPath(head, path) {
|
|
70
|
+
let cursor = head;
|
|
71
|
+
for (const key of path) {
|
|
72
|
+
if (!isPlainObject(cursor))
|
|
73
|
+
return undefined;
|
|
74
|
+
cursor = cursor[key];
|
|
75
|
+
}
|
|
76
|
+
return cursor;
|
|
77
|
+
}
|
|
78
|
+
/** Safe only when `workingRaw` differs from `headRaw` solely at one existing `dependencies` or
|
|
79
|
+
* `devDependencies` entry for `@krapwoo/ds-viewer`, and that entry's working value names
|
|
80
|
+
* `currentVersion` in an accepted registry form. */
|
|
81
|
+
export function isSafePackageJsonBump(headRaw, workingRaw, currentVersion) {
|
|
82
|
+
let head;
|
|
83
|
+
let working;
|
|
84
|
+
try {
|
|
85
|
+
head = JSON.parse(headRaw);
|
|
86
|
+
working = JSON.parse(workingRaw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!isPlainObject(head) || !isPlainObject(working))
|
|
92
|
+
return false;
|
|
93
|
+
const diffs = diffJson(head, working);
|
|
94
|
+
if (diffs.length !== 1)
|
|
95
|
+
return false;
|
|
96
|
+
const [diff] = diffs;
|
|
97
|
+
if (diff.type !== 'changed' || diff.path.length !== 2)
|
|
98
|
+
return false;
|
|
99
|
+
const [section, name] = diff.path;
|
|
100
|
+
if (name !== PACKAGE_NAME)
|
|
101
|
+
return false;
|
|
102
|
+
if (section !== 'dependencies' && section !== 'devDependencies')
|
|
103
|
+
return false;
|
|
104
|
+
if (!isOlderRegistryForm(headValueAtPath(head, diff.path), currentVersion))
|
|
105
|
+
return false;
|
|
106
|
+
return isAcceptedRegistryForm(diff.workingValue, currentVersion);
|
|
107
|
+
}
|
|
108
|
+
function redactKey(value, keyPath) {
|
|
109
|
+
const clone = JSON.parse(JSON.stringify(value));
|
|
110
|
+
let cursor = clone;
|
|
111
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
112
|
+
const next = cursor[keyPath[i]];
|
|
113
|
+
if (!isPlainObject(next))
|
|
114
|
+
return clone;
|
|
115
|
+
cursor = next;
|
|
116
|
+
}
|
|
117
|
+
delete cursor[keyPath[keyPath.length - 1]];
|
|
118
|
+
return clone;
|
|
119
|
+
}
|
|
120
|
+
/** Which section (`dependencies`/`devDependencies`) the lockfile root (`packages[""]`, design's
|
|
121
|
+
* own npm v7+ shape) declares `@krapwoo/ds-viewer` in, and that its working value is an accepted
|
|
122
|
+
* registry form of `currentVersion` — `undefined` for any ambiguity (declared in neither or both
|
|
123
|
+
* sides, mismatched sections, or anything other than a plain string on either side). */
|
|
124
|
+
function safeRootDeclarationSection(headRoot, workingRoot, currentVersion) {
|
|
125
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
126
|
+
const other = section === 'dependencies' ? 'devDependencies' : 'dependencies';
|
|
127
|
+
const headValue = isPlainObject(headRoot[section]) ? headRoot[section][PACKAGE_NAME] : undefined;
|
|
128
|
+
const workingValue = isPlainObject(workingRoot[section]) ? workingRoot[section][PACKAGE_NAME] : undefined;
|
|
129
|
+
if (headValue === undefined && workingValue === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
if (typeof headValue !== 'string' || typeof workingValue !== 'string')
|
|
132
|
+
return undefined;
|
|
133
|
+
const headOther = isPlainObject(headRoot[other]) ? headRoot[other][PACKAGE_NAME] : undefined;
|
|
134
|
+
const workingOther = isPlainObject(workingRoot[other]) ? workingRoot[other][PACKAGE_NAME] : undefined;
|
|
135
|
+
if (headOther !== undefined || workingOther !== undefined)
|
|
136
|
+
return undefined; // declared in both sections somewhere — ambiguous.
|
|
137
|
+
return isAcceptedRegistryForm(workingValue, currentVersion) && isOlderRegistryForm(headValue, currentVersion) ? section : undefined;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
function isSafeLockfileV1Bump(head, working, currentVersion) {
|
|
142
|
+
const headDeps = head.dependencies;
|
|
143
|
+
const workingDeps = working.dependencies;
|
|
144
|
+
if (!isPlainObject(headDeps) || !isPlainObject(workingDeps))
|
|
145
|
+
return false;
|
|
146
|
+
const headEntry = headDeps[PACKAGE_NAME];
|
|
147
|
+
const workingEntry = workingDeps[PACKAGE_NAME];
|
|
148
|
+
if (!isPlainObject(headEntry) || !isPlainObject(workingEntry))
|
|
149
|
+
return false;
|
|
150
|
+
if (workingEntry.version !== currentVersion)
|
|
151
|
+
return false;
|
|
152
|
+
const redactedHead = redactKey(head, ['dependencies', PACKAGE_NAME]);
|
|
153
|
+
const redactedWorking = redactKey(working, ['dependencies', PACKAGE_NAME]);
|
|
154
|
+
return deepEqual(redactedHead, redactedWorking);
|
|
155
|
+
}
|
|
156
|
+
function isSafeLockfileV2Or3Bump(head, working, currentVersion) {
|
|
157
|
+
const nodeModulesKey = `node_modules/${PACKAGE_NAME}`;
|
|
158
|
+
const headPackages = head.packages;
|
|
159
|
+
const workingPackages = working.packages;
|
|
160
|
+
if (!isPlainObject(headPackages) || !isPlainObject(workingPackages))
|
|
161
|
+
return false;
|
|
162
|
+
const headEntry = headPackages[nodeModulesKey];
|
|
163
|
+
const workingEntry = workingPackages[nodeModulesKey];
|
|
164
|
+
if (!isPlainObject(headEntry) || !isPlainObject(workingEntry))
|
|
165
|
+
return false;
|
|
166
|
+
if (workingEntry.version !== currentVersion)
|
|
167
|
+
return false;
|
|
168
|
+
const headRoot = headPackages[''];
|
|
169
|
+
const workingRoot = workingPackages[''];
|
|
170
|
+
if (!isPlainObject(headRoot) || !isPlainObject(workingRoot))
|
|
171
|
+
return false;
|
|
172
|
+
const section = safeRootDeclarationSection(headRoot, workingRoot, currentVersion);
|
|
173
|
+
if (!section)
|
|
174
|
+
return false;
|
|
175
|
+
// npm v7/v8's legacy mirror tree, kept alongside `packages` for backward-compat tooling — absent
|
|
176
|
+
// in v9+'s v3 lockfiles. Unlike the two checks above, redacting this unconditionally (without
|
|
177
|
+
// first proving its own working value) would let an untracked edit to this entry hide behind an
|
|
178
|
+
// otherwise-safe bump.
|
|
179
|
+
const workingLegacy = isPlainObject(working.dependencies) ? working.dependencies[PACKAGE_NAME] : undefined;
|
|
180
|
+
if (workingLegacy !== undefined && (!isPlainObject(workingLegacy) || workingLegacy.version !== currentVersion))
|
|
181
|
+
return false;
|
|
182
|
+
let redactedHead = redactKey(head, ['packages', nodeModulesKey]);
|
|
183
|
+
let redactedWorking = redactKey(working, ['packages', nodeModulesKey]);
|
|
184
|
+
redactedHead = redactKey(redactedHead, ['packages', '', section, PACKAGE_NAME]);
|
|
185
|
+
redactedWorking = redactKey(redactedWorking, ['packages', '', section, PACKAGE_NAME]);
|
|
186
|
+
redactedHead = redactKey(redactedHead, ['dependencies', PACKAGE_NAME]);
|
|
187
|
+
redactedWorking = redactKey(redactedWorking, ['dependencies', PACKAGE_NAME]);
|
|
188
|
+
return deepEqual(redactedHead, redactedWorking);
|
|
189
|
+
}
|
|
190
|
+
/** Safe only when valid JSON differs from `HEAD` solely in the root declaration for
|
|
191
|
+
* `@krapwoo/ds-viewer` and the package's own lock entry, both resolving `currentVersion`.
|
|
192
|
+
* Supports lockfileVersion 1 (the single `dependencies` entry doubles as both) and 2/3 (the
|
|
193
|
+
* `packages[""]` root declaration plus the `packages["node_modules/..."]` entry). Any other
|
|
194
|
+
* `lockfileVersion`, or a mismatch between `HEAD` and the working tree, is an unsupported shape —
|
|
195
|
+
* fails closed. */
|
|
196
|
+
export function isSafeNpmLockfileBump(headRaw, workingRaw, currentVersion) {
|
|
197
|
+
let head;
|
|
198
|
+
let working;
|
|
199
|
+
try {
|
|
200
|
+
head = JSON.parse(headRaw);
|
|
201
|
+
working = JSON.parse(workingRaw);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (!isPlainObject(head) || !isPlainObject(working))
|
|
207
|
+
return false;
|
|
208
|
+
const lockfileVersion = working.lockfileVersion;
|
|
209
|
+
if (head.lockfileVersion !== lockfileVersion)
|
|
210
|
+
return false;
|
|
211
|
+
if (lockfileVersion === 1)
|
|
212
|
+
return isSafeLockfileV1Bump(head, working, currentVersion);
|
|
213
|
+
if (lockfileVersion === 2 || lockfileVersion === 3)
|
|
214
|
+
return isSafeLockfileV2Or3Bump(head, working, currentVersion);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
package/dist/cli/dev.js
CHANGED
|
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
|
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { resolveConfig } from "./config.js";
|
|
8
8
|
import { sync } from "./sync.js";
|
|
9
|
-
import { clearDevLock, liveDevLock, missingWorkspaceFiles, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
|
|
9
|
+
import { clearDevLock, liveDevLock, missingWorkspaceFiles, readEndpointRecord, workspacePath, writeDevLock, writeEndpointRecord, writeWorkspace } from "./workspace.js";
|
|
10
10
|
import { findFreePort } from "./port.js";
|
|
11
11
|
import { globBaseFolder } from "./glob.js";
|
|
12
12
|
import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
|
|
@@ -362,6 +362,44 @@ export function repairWorkspaceIfMissing(deps) {
|
|
|
362
362
|
sync(config);
|
|
363
363
|
return { missing, repaired: true };
|
|
364
364
|
}
|
|
365
|
+
function listenOnce(server, port) {
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
const onError = (error) => {
|
|
368
|
+
server.removeListener('listening', onListening);
|
|
369
|
+
reject(error);
|
|
370
|
+
};
|
|
371
|
+
const onListening = () => {
|
|
372
|
+
server.removeListener('error', onError);
|
|
373
|
+
resolve(server.address().port);
|
|
374
|
+
};
|
|
375
|
+
server.once('error', onError);
|
|
376
|
+
server.once('listening', onListening);
|
|
377
|
+
server.listen(port, '127.0.0.1');
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** Resolves the update endpoint's real port: the stored record's port when it's actually free, a
|
|
381
|
+
* fresh OS-assigned one otherwise. Tries the real bind directly, once — a separate "is this free?"
|
|
382
|
+
* probe followed by a real bind would leave a window for something else to take the port in
|
|
383
|
+
* between, and could never tell "a foreign process holds it" from "nothing does" anyway, which
|
|
384
|
+
* Part 1 says this must never try to resolve: a bind failure for any reason falls back the same
|
|
385
|
+
* way, never killing, probing, or assuming ownership of whatever holds it. Mutates
|
|
386
|
+
* `secretHolder.secret` to a fresh value only on that fallback path — reusing the stored *port*
|
|
387
|
+
* without the *secret* minted for it would pair a stale secret with an address nobody recorded. */
|
|
388
|
+
export async function bindEndpointServer(server, secretHolder, storedPort) {
|
|
389
|
+
if (storedPort !== undefined) {
|
|
390
|
+
try {
|
|
391
|
+
const port = await listenOnce(server, storedPort);
|
|
392
|
+
return { port, reused: true };
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Taken — by a foreign process, a second catalog, anything. Fall through to a fresh port and
|
|
396
|
+
// secret below; never retry the same port or investigate what's holding it.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
secretHolder.secret = randomBytes(24).toString('hex');
|
|
400
|
+
const port = await listenOnce(server, 0);
|
|
401
|
+
return { port, reused: false };
|
|
402
|
+
}
|
|
365
403
|
export async function dev(initialConfig, devOptions = {}) {
|
|
366
404
|
assertLocalInstall(initialConfig.projectRoot);
|
|
367
405
|
const ownVersion = readOwnVersion(import.meta.dirname);
|
|
@@ -480,14 +518,20 @@ export async function dev(initialConfig, devOptions = {}) {
|
|
|
480
518
|
let updateEndpoint;
|
|
481
519
|
let endpointServer;
|
|
482
520
|
if (!devOptions.noUpdateEndpoint) {
|
|
521
|
+
// Reused across restarts so an already-open page's baked-in endpoint keeps working: the
|
|
522
|
+
// stored record's secret is the candidate here, replaced by `bindEndpointServer` itself only
|
|
523
|
+
// if the stored port turns out to be taken.
|
|
524
|
+
const stored = readEndpointRecord(config);
|
|
525
|
+
endpointDeps.secret = stored?.secret ?? secret;
|
|
483
526
|
endpointServer = createUpdateEndpoint(endpointDeps);
|
|
484
|
-
|
|
485
|
-
|
|
527
|
+
const binding = await bindEndpointServer(endpointServer, endpointDeps, stored?.port);
|
|
528
|
+
if (!binding.reused)
|
|
529
|
+
writeEndpointRecord(config, { port: binding.port, secret: endpointDeps.secret });
|
|
486
530
|
// Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
|
|
487
531
|
// verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
|
|
488
532
|
// itself is never printed; the endpoint rejects anything that doesn't already have it.
|
|
489
|
-
console.log(`Update endpoint listening on 127.0.0.1:${
|
|
490
|
-
updateEndpoint = { baseUrl: `http://127.0.0.1:${
|
|
533
|
+
console.log(`Update endpoint listening on 127.0.0.1:${binding.port}`);
|
|
534
|
+
updateEndpoint = { baseUrl: `http://127.0.0.1:${binding.port}`, secret: endpointDeps.secret };
|
|
491
535
|
}
|
|
492
536
|
else {
|
|
493
537
|
console.log('Update endpoint disabled for this run (--no-update-endpoint).');
|
package/dist/cli/update.js
CHANGED
|
@@ -51,7 +51,8 @@ export function localInstallRefusal(spec, latest) {
|
|
|
51
51
|
/** Design §5 "`npx ds-viewer update`", steps 1-6. */
|
|
52
52
|
export async function runUpdate(config, options = {}) {
|
|
53
53
|
const currentVersion = options.currentVersion ?? readOwnVersion(path.dirname(fileURLToPath(import.meta.url)));
|
|
54
|
-
const
|
|
54
|
+
const buildPlan = options.buildPlan ?? buildUpdatePlan;
|
|
55
|
+
let plan = await buildPlan(config, currentVersion);
|
|
55
56
|
if ('error' in plan)
|
|
56
57
|
return { output: plan.error, exitCode: isAlreadyUpToDate(plan.error) ? 0 : 1 };
|
|
57
58
|
const planText = formatPlanHuman(plan);
|
|
@@ -78,6 +79,24 @@ export async function runUpdate(config, options = {}) {
|
|
|
78
79
|
const proceed = await (options.confirm ?? defaultConfirm)(`${planText}\n\nUpdate to ${plan.latest}? [y/N] `);
|
|
79
80
|
if (!proceed)
|
|
80
81
|
return { output: 'Aborted: nothing was changed.', exitCode: 0 };
|
|
82
|
+
// The prompt can sit open for a while — a file could have changed, or a newer version could
|
|
83
|
+
// have appeared, in the meantime. Revalidate immediately before the one side effect (install)
|
|
84
|
+
// this function ever runs, rather than trusting the plan the user approved. `--yes` has no
|
|
85
|
+
// prompt to sit open behind, so it deliberately skips this and uses the plan already built
|
|
86
|
+
// above (frozen scope: "may use the initial immediately preceding plan").
|
|
87
|
+
const revalidated = await buildPlan(config, currentVersion);
|
|
88
|
+
if ('error' in revalidated) {
|
|
89
|
+
if (isAlreadyUpToDate(revalidated.error))
|
|
90
|
+
return { output: revalidated.error, exitCode: 0 };
|
|
91
|
+
return { output: "Couldn't re-check the plan before installing. Nothing was changed.", exitCode: 1 };
|
|
92
|
+
}
|
|
93
|
+
if (revalidated.latest !== plan.latest || (!options.force && revalidated.dirtyFiles.length > 0)) {
|
|
94
|
+
return {
|
|
95
|
+
output: 'The plan changed while confirming — a file changed, or a newer version appeared. Nothing was changed. Run update again to see the new plan.',
|
|
96
|
+
exitCode: 1,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
plan = revalidated;
|
|
81
100
|
}
|
|
82
101
|
const execImpl = options.execImpl ?? ((cmd, args, opts) => {
|
|
83
102
|
const run = platformCommand(cmd, args);
|
package/dist/cli/updatePlan.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// cli/updatePlan.ts
|
|
2
2
|
import { execFile, execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { detectPackageManager, platformCommand } from "./packageManager.js";
|
|
8
|
+
import { isSafeNpmLockfileBump, isSafePackageJsonBump } from "./dependencyBumpProof.js";
|
|
8
9
|
import { listKitFileDiffs } from "./kitDiff.js";
|
|
9
10
|
import { extractSummaryBullets, fetchWithTimeout } from "./updateCheck.js";
|
|
10
11
|
import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
|
|
@@ -20,20 +21,56 @@ function lockfileName(pm) {
|
|
|
20
21
|
return 'yarn.lock';
|
|
21
22
|
return 'package-lock.json';
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* this plan's own `files` list names), never the old one (Important finding, Fable correction
|
|
26
|
-
* pass: `R old -> new` was previously read as the single literal path `"old -> new"`, which
|
|
27
|
-
* could never match a planned file). */
|
|
28
|
-
function parseDirtyPaths(porcelain) {
|
|
29
|
-
return new Set(porcelain
|
|
24
|
+
function parseDirtyEntries(porcelain) {
|
|
25
|
+
return porcelain
|
|
30
26
|
.split('\n')
|
|
31
27
|
.filter((line) => line.length > 3)
|
|
32
28
|
.map((line) => {
|
|
29
|
+
const code = line.slice(0, 2);
|
|
33
30
|
const raw = line.slice(3).trim();
|
|
34
31
|
const arrow = raw.indexOf(' -> ');
|
|
35
|
-
return arrow === -1 ? raw : raw.slice(arrow + 4);
|
|
36
|
-
})
|
|
32
|
+
return { path: arrow === -1 ? raw : raw.slice(arrow + 4), code };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Only a plain modification, staged or not (`" M"`, `"M "`, `"MM"`) — never an add, delete,
|
|
36
|
+
* rename, or untracked file (`"A "`, `"D "`, `"R "`, `"??"`, …), which the content-only proof
|
|
37
|
+
* below can't always distinguish from a genuine edit on its own. */
|
|
38
|
+
function isPlainModification(code) {
|
|
39
|
+
return code !== ' ' && /^[ M]{2}$/.test(code);
|
|
40
|
+
}
|
|
41
|
+
// Exported for the integration test only (verifies the real git/filesystem behavior for a
|
|
42
|
+
// subdirectory project); every other caller goes through `BuildPlanOptions`.
|
|
43
|
+
export function defaultGitShow(projectRoot, filePath) {
|
|
44
|
+
try {
|
|
45
|
+
// `<rev>:<path>` is resolved relative to the *repository root* — only a `./`-prefixed path is
|
|
46
|
+
// resolved relative to `cwd` (`projectRoot`), which is what `filePath` (already stripped to be
|
|
47
|
+
// project-relative) needs for any project in a subdirectory.
|
|
48
|
+
return execFileSync('git', ['show', `HEAD:./${filePath}`], { cwd: projectRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function defaultReadWorkingFile(projectRoot, filePath) {
|
|
55
|
+
try {
|
|
56
|
+
return readFileSync(path.join(projectRoot, filePath), 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Frozen scope's one safe case: a dirty `package.json`/npm `package-lock.json` that provably
|
|
63
|
+
* differs from `HEAD` solely by a prior, already-finished bump to `currentVersion` (the viewer
|
|
64
|
+
* version this very process is running). Every other package manager's lockfile stays
|
|
65
|
+
* conservative — unproven, and so left dirty. */
|
|
66
|
+
function isSafeSupersededDependencyFile(filePath, pm, currentVersion, headRaw, workingRaw) {
|
|
67
|
+
if (headRaw === undefined || workingRaw === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
if (filePath === 'package.json')
|
|
70
|
+
return isSafePackageJsonBump(headRaw, workingRaw, currentVersion);
|
|
71
|
+
if (pm === 'npm' && filePath === lockfileName(pm))
|
|
72
|
+
return isSafeNpmLockfileBump(headRaw, workingRaw, currentVersion);
|
|
73
|
+
return false;
|
|
37
74
|
}
|
|
38
75
|
/** Strips the repository-root prefix `git rev-parse --show-prefix` reports from every path in a
|
|
39
76
|
* `git status --porcelain` listing (both sides of a rename), so the result reads relative to
|
|
@@ -106,6 +143,8 @@ export async function buildUpdatePlan(config, currentVersion, options = {}) {
|
|
|
106
143
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
107
144
|
const tmpDirImpl = options.tmpDirImpl ?? (() => mkdtempSync(path.join(tmpdir(), 'ds-viewer-update-')));
|
|
108
145
|
const gitStatusImpl = options.gitStatusImpl ?? defaultGitStatus;
|
|
146
|
+
const gitShowImpl = options.gitShowImpl ?? defaultGitShow;
|
|
147
|
+
const readWorkingFileImpl = options.readWorkingFileImpl ?? defaultReadWorkingFile;
|
|
109
148
|
// Important finding, Fable correction pass: a real, unreachable registry otherwise hangs `npm
|
|
110
149
|
// view`/`npm pack` for minutes (observed: "Checking…" sat for 140s, with `/update/status` not
|
|
111
150
|
// answering at all in that time — this whole function runs inside the endpoint's own request
|
|
@@ -180,9 +219,26 @@ export async function buildUpdatePlan(config, currentVersion, options = {}) {
|
|
|
180
219
|
return { error: `Couldn't check for uncommitted changes (git): ${error.message}` };
|
|
181
220
|
}
|
|
182
221
|
const outsideGitRepo = porcelain === undefined;
|
|
183
|
-
const
|
|
222
|
+
const dirtyEntries = porcelain === undefined ? [] : parseDirtyEntries(porcelain);
|
|
223
|
+
const dirtyPaths = new Set(dirtyEntries.map((entry) => entry.path));
|
|
184
224
|
for (const file of files)
|
|
185
225
|
file.dirty = dirtyPaths.has(file.path);
|
|
226
|
+
// Frozen scope: a dirty `package.json`/npm lockfile is resolved back to clean only when it
|
|
227
|
+
// provably differs from `HEAD` solely by a prior, already-finished bump to `currentVersion` —
|
|
228
|
+
// never for an add, delete, rename, or untracked file, which `isPlainModification` rules out
|
|
229
|
+
// before the content proof is even attempted.
|
|
230
|
+
const codeByPath = new Map(dirtyEntries.map((entry) => [entry.path, entry.code]));
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
if (!file.dirty || (file.path !== 'package.json' && file.path !== lockfileName(pm)))
|
|
233
|
+
continue;
|
|
234
|
+
const code = codeByPath.get(file.path);
|
|
235
|
+
if (!code || !isPlainModification(code))
|
|
236
|
+
continue;
|
|
237
|
+
const headRaw = gitShowImpl(config.projectRoot, file.path);
|
|
238
|
+
const workingRaw = readWorkingFileImpl(config.projectRoot, file.path);
|
|
239
|
+
if (isSafeSupersededDependencyFile(file.path, pm, currentVersion, headRaw, workingRaw))
|
|
240
|
+
file.dirty = false;
|
|
241
|
+
}
|
|
186
242
|
let summary = [];
|
|
187
243
|
let releasedAt;
|
|
188
244
|
try {
|
package/dist/cli/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { globBaseFolder } from "./glob.js";
|
|
4
4
|
import { DEFAULT_WORKSPACE_DIR } from "./types.js";
|
|
@@ -151,6 +151,42 @@ export function liveDevLock(config, ownPid = process.pid) {
|
|
|
151
151
|
return undefined;
|
|
152
152
|
return lock;
|
|
153
153
|
}
|
|
154
|
+
const ENDPOINT_RECORD_FILE = 'endpoint.json';
|
|
155
|
+
/** `bindEndpointServer` never writes a secret shorter than this (`randomBytes(24).toString('hex')`
|
|
156
|
+
* is 48 characters), so a shorter one can only come from a hand-written record — but a
|
|
157
|
+
* hand-written `{"secret":""}` is reused verbatim and `secretMatches` in `cli/endpoint.ts`
|
|
158
|
+
* compares two zero-length buffers as equal, authenticating every request. Minor finding,
|
|
159
|
+
* correction pass 4: treated the same as any other invalid record field below. */
|
|
160
|
+
const MIN_SECRET_LENGTH = 32;
|
|
161
|
+
/** The endpoint address a previous `dev` run bound, so a restart can reuse it instead of minting a
|
|
162
|
+
* fresh one — otherwise an already-open page's baked-in endpoint goes stale on every restart.
|
|
163
|
+
* Missing, corrupt, or partially-written is all treated the same: absent, so the caller falls
|
|
164
|
+
* back to generating a fresh pair exactly as if `dev` had never run before. */
|
|
165
|
+
export function readEndpointRecord(config) {
|
|
166
|
+
const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
|
|
167
|
+
if (!existsSync(recordPath))
|
|
168
|
+
return undefined;
|
|
169
|
+
try {
|
|
170
|
+
const record = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
171
|
+
if (typeof record.port !== 'number' || record.port <= 0 || typeof record.secret !== 'string' || record.secret.length < MIN_SECRET_LENGTH)
|
|
172
|
+
return undefined;
|
|
173
|
+
return { port: record.port, secret: record.secret };
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined; // A partial write from a process that crashed mid-write owns nothing.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Written with mode 0600 — not a new exposure (the same secret already lands in cleartext inside
|
|
180
|
+
* the generated `entry.tsx`, see `writeWorkspace`), but it must not be any more exposed than
|
|
181
|
+
* that. `chmodSync` runs in addition to `writeFileSync`'s own `mode` option: that option only
|
|
182
|
+
* applies when the file is newly created, so a pre-existing record left with looser permissions
|
|
183
|
+
* would otherwise keep them across every later rewrite. */
|
|
184
|
+
export function writeEndpointRecord(config, record) {
|
|
185
|
+
mkdirSync(workspacePath(config), { recursive: true });
|
|
186
|
+
const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
|
|
187
|
+
writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
188
|
+
chmodSync(recordPath, 0o600);
|
|
189
|
+
}
|
|
154
190
|
/** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
|
|
155
191
|
* `babel.config.js` is conditional and the update files are written separately, so neither counts
|
|
156
192
|
* as missing here. */
|