@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.
@@ -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
- await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
485
- const endpointPort = endpointServer.address().port;
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:${endpointPort}`);
490
- updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
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).');
@@ -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 plan = await (options.buildPlan ?? buildUpdatePlan)(config, currentVersion);
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);
@@ -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
- /** `git status --porcelain`'s format is `XY path` (plain) or `XY old -> new` (a rename) — the
24
- * path starts at column 3 either way; a rename's own "dirty" identity is its *new* path (the one
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 dirtyPaths = porcelain === undefined ? new Set() : parseDirtyPaths(porcelain);
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 {
@@ -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. */