@marktrue/dsh-mobile-remote 3.2.1-alpha.3 → 3.2.1-rc.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.
@@ -1,8 +1,12 @@
1
1
  // Admission owns the durable transaction and its lease; the worker consumes exactly the accepted release.
2
2
  import { homedir } from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
5
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
3
6
  import { acquireLock, newOperationId, readTransaction, releaseLock, serverOperationPath, finishOwnedTerminal,
4
- transactionFile, updateLockPath, validOperationId, writeOwnedTransaction, convergeStaleTransaction } from './plugin-update-transaction.js';
5
- import { checkForUpdate, runSelfUpdate, isTerminalPhase, resolveManifestSource, UPDATE_SOURCE_KIND } from './plugin-self-update.js';
7
+ transactionFile, updateLockPath, validOperationId, writeOwnedTransaction, convergeStaleTransaction, workRoot } from './plugin-update-transaction.js';
8
+ import { checkForUpdate, prepareNpmArtifacts, runSelfUpdate, isTerminalPhase, resolvePluginUpdateSource, UPDATE_SOURCE_KIND } from './plugin-self-update.js';
9
+ import { createOperationLog } from './plugin-update-log.js';
6
10
 
7
11
  /// 客户端可达视图:只改写属于**本机回环主机源**的 artifact 路由,外部发布源保持原样(F1)。
8
12
  export function clientReleaseView(release, { clientOrigin = '', basePath = '/m', loopback = false } = {}) {
@@ -15,28 +19,144 @@ export function clientReleaseView(release, { clientOrigin = '', basePath = '/m',
15
19
  plugin: { ...release.plugin, url: `${clientOrigin}${mount}/api/update/plugin` },
16
20
  };
17
21
  }
22
+ function safeUpdateReason(reason) {
23
+ const value = typeof reason === 'string' ? reason : '';
24
+ return /^[a-z0-9][a-z0-9._-]{0,96}$/i.test(value) ? value : 'update-failed';
25
+ }
26
+
18
27
  export function publicTransaction(tx, view = {}) {
19
28
  if (!tx) return null;
20
- const { opId, phase, targetVersion, targetContentSha256, rolledBack, attempts, result, startedAt, release } = tx;
21
- return { opId, phase, targetVersion, targetContentSha256, rolledBack: !!rolledBack, attempts: attempts ?? 0, result: result ?? null, startedAt,
29
+ const { opId, phase, targetVersion, targetContentSha256, targetAppVersion, rolledBack, attempts, result, startedAt, release } = tx;
30
+ const publicResult = result ? {
31
+ ok: !!result.ok,
32
+ ...(result.reason ? { reason: safeUpdateReason(result.reason) } : {}),
33
+ ...(result.at ? { at: result.at } : {}),
34
+ ...(result.matched ? { matched: result.matched } : {}),
35
+ } : null;
36
+ return { opId, phase, targetVersion, targetContentSha256, targetAppVersion: targetAppVersion ?? null,
37
+ rolledBack: !!rolledBack, attempts: attempts ?? 0, result: publicResult, startedAt,
22
38
  release: release ? clientReleaseView(release, view) : null };
23
39
  }
24
40
  export function createPluginUpdateController(options) {
25
- const { home = homedir(), configValue, profileDir, capabilities, localVersion, localContentSha256,
41
+ const { home = homedir(), configValue, profileDir, packageName = '', npmPackageView, capabilities, localVersion, localContentSha256,
26
42
  status, log = () => {}, fetchImpl = fetch, auth = {}, basePath = '/m', workerOptions = {},
27
- schedule = fn => setImmediate(fn), runSelfUpdate: runner = runSelfUpdate, persist = writeOwnedTransaction } = options;
43
+ schedule = fn => setImmediate(fn), runSelfUpdate: runner = runSelfUpdate, prepareNpmArtifacts: prepareRunner = prepareNpmArtifacts,
44
+ persist = writeOwnedTransaction } = options;
28
45
  const currentFile = transactionFile(home), lockPath = updateLockPath(home);
29
- const checking = () => checkForUpdate({ configValue, localVersion: typeof localVersion === 'function' ? localVersion() : localVersion, localContentSha256,
46
+ const preparedUpdates = new Map();
47
+ const preparedTtlMs = 30 * 60 * 1000;
48
+ const safePreparedWorkRoot = resolve(workRoot(home));
49
+ const removePreparedWorkDir = workDir => {
50
+ if (!workDir || !existsSync(safePreparedWorkRoot)) return false;
51
+ try {
52
+ const rootReal = realpathSync(safePreparedWorkRoot);
53
+ const targetReal = realpathSync(workDir);
54
+ const rel = relative(rootReal, targetReal);
55
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
56
+ rmSync(targetReal, { recursive: true, force: true });
57
+ return true;
58
+ } catch { return false; }
59
+ };
60
+ const cleanupPreparedUpdates = () => {
61
+ const now = Date.now();
62
+ for (const [key, entry] of preparedUpdates) {
63
+ if (entry.expiresAt <= now) {
64
+ clearTimeout(entry.expireTimer);
65
+ removePreparedWorkDir(entry.workDir);
66
+ preparedUpdates.delete(key);
67
+ }
68
+ }
69
+ try {
70
+ if (!existsSync(safePreparedWorkRoot)) return;
71
+ const rootReal = realpathSync(safePreparedWorkRoot);
72
+ const active = new Set([...preparedUpdates.values()].map(entry => resolve(entry.workDir)));
73
+ const activeTransaction = readTransaction(currentFile);
74
+ const activePreparedDir = activeTransaction && !isTerminalPhase(activeTransaction.phase) && activeTransaction.preparedId
75
+ ? `prepare-${activeTransaction.preparedId}` : "";
76
+ for (const item of readdirSync(rootReal, { withFileTypes: true })) {
77
+ if (!item.isDirectory() || !item.name.startsWith('prepare-') || item.name === activePreparedDir) continue;
78
+ const target = join(rootReal, item.name);
79
+ if (active.has(resolve(target))) continue;
80
+ if (now - statSync(target).mtimeMs > preparedTtlMs) removePreparedWorkDir(target);
81
+ }
82
+ } catch { /* cleanup is best-effort; never disrupt update checks */ }
83
+ };
84
+ cleanupPreparedUpdates();
85
+ const checking = () => checkForUpdate({ configValue, packageName, npmCwd: profileDir, npmPackageView,
86
+ localVersion: typeof localVersion === 'function' ? localVersion() : localVersion, localContentSha256,
30
87
  capabilities: capabilities(), fetchImpl, auth });
31
88
  /// 手机视图:**只用于 HTTP 出口**,绝不写回内部事务(F1)。
32
- /// 内部 worker 必须继续用回环 locator 读受保护产物;手机看到的是自己可达的路由。
89
+ /// 内部 worker 必须继续用受信定位;当前 NPM 来源不会把 registry URL 投影给客户端。
33
90
  const clientView = clientOrigin => ({
34
- clientOrigin, basePath, loopback: resolveManifestSource(configValue).loopback === true,
91
+ clientOrigin, basePath, loopback: resolvePluginUpdateSource(configValue).loopback === true,
35
92
  });
93
+ async function prepare(body = {}) {
94
+ if (!body || typeof body !== 'object' || Array.isArray(body) || ('opId' in body && !validOperationId(body.opId))) {
95
+ return { status: 400, body: { error: 'invalid-operation-id' } };
96
+ }
97
+ const source = resolvePluginUpdateSource(configValue);
98
+ if (source.kind === UPDATE_SOURCE_KIND.OFF) return { status: 503, body: { error: 'update-source-off' } };
99
+ if (source.kind === UPDATE_SOURCE_KIND.INVALID) return { status: 400, body: { error: 'update-source-invalid' } };
100
+ if (!profileDir) return { status: 409, body: { error: 'profile-not-found' } };
101
+ if (!capabilities().canSelfUpdate) return { status: 409, body: { error: 'restart-unsupported' } };
102
+ const opId = body.opId ?? newOperationId();
103
+ cleanupPreparedUpdates();
104
+ const current = preparedUpdates.get(opId);
105
+ if (current && current.expiresAt > Date.now()) return { status: 200, body: {
106
+ ok: true, action: 'prepared', opId, prepareId: current.prepareId, candidate: current.candidate,
107
+ } };
108
+ const old = readTransaction(currentFile);
109
+ if (old && !isTerminalPhase(old.phase)) return { status: 409, body: { error: 'update-busy', transaction: publicTransaction(old) } };
110
+ const checked = await checking(); Object.assign(status, checked);
111
+ if (checked.state !== 'update-available') return { status: 200, body: { ok: true, action: 'none', check: checked } };
112
+ const candidate = checked.candidate;
113
+ if (!candidate?.carrier?.legacyMetadataMissing) {
114
+ return { status: 200, body: { ok: true, action: 'confirmation-required', opId, candidate, check: checked } };
115
+ }
116
+ const prepareId = newOperationId();
117
+ const workDir = join(safePreparedWorkRoot, `prepare-${prepareId}`);
118
+ mkdirSync(workDir, { recursive: true, mode: 0o700 });
119
+ let artifacts;
120
+ try {
121
+ artifacts = await prepareRunner({ npmCandidate: candidate, packageName, profileDir, workDir });
122
+ if (!artifacts?.ok || !artifacts.carrierAppVersion || !artifacts.pluginContentSha256) {
123
+ removePreparedWorkDir(workDir);
124
+ return { status: 502, body: { error: 'candidate-preparation-failed', reason: artifacts?.error ?? 'npm-preparation-incomplete' } };
125
+ }
126
+ } catch {
127
+ removePreparedWorkDir(workDir);
128
+ return { status: 502, body: { error: 'candidate-preparation-failed', reason: 'npm-preparation-failed' } };
129
+ }
130
+ const preparedCandidate = {
131
+ ...candidate,
132
+ plugin: { ...candidate.plugin, contentSha256: artifacts.pluginContentSha256 },
133
+ carrier: { ...candidate.carrier, appVersion: artifacts.carrierAppVersion,
134
+ pluginContentSha256: artifacts.pluginContentSha256, legacyMetadataMissing: false },
135
+ };
136
+ const preparedCheck = {
137
+ ...checked,
138
+ candidate: preparedCandidate,
139
+ remote: { ...checked.remote, contentSha256: artifacts.pluginContentSha256,
140
+ appVersion: artifacts.carrierAppVersion, legacyCarrierMetadata: false },
141
+ };
142
+ const entry = { prepareId, opId, candidate: preparedCandidate, artifacts, workDir, expiresAt: Date.now() + preparedTtlMs };
143
+ entry.expireTimer = setTimeout(() => {
144
+ const current = preparedUpdates.get(opId);
145
+ if (current?.prepareId === prepareId) {
146
+ removePreparedWorkDir(current.workDir);
147
+ preparedUpdates.delete(opId);
148
+ }
149
+ }, preparedTtlMs);
150
+ entry.expireTimer.unref?.();
151
+ preparedUpdates.set(opId, entry);
152
+ Object.assign(status, preparedCheck);
153
+ return { status: 200, body: { ok: true, action: 'prepared', opId, prepareId, candidate: preparedCandidate, check: preparedCheck } };
154
+ }
36
155
  async function submit(body = {}, { clientOrigin = '' } = {}) {
37
156
  if (!body || typeof body !== 'object' || Array.isArray(body) ||
38
157
  ('opId' in body && !validOperationId(body.opId))) return { status: 400, body: { error: 'invalid-operation-id' } };
39
- const source = resolveManifestSource(configValue);
158
+ if ('targetVersion' in body && typeof body.targetVersion !== 'string') return { status: 400, body: { error: 'invalid-target-version' } };
159
+ const source = resolvePluginUpdateSource(configValue);
40
160
  if (source.kind === UPDATE_SOURCE_KIND.OFF) return { status: 503, body: { error: 'update-source-off' } };
41
161
  if (source.kind === UPDATE_SOURCE_KIND.INVALID) return { status: 400, body: { error: 'update-source-invalid' } };
42
162
  if (!profileDir) return { status: 409, body: { error: 'profile-not-found' } };
@@ -56,40 +176,99 @@ export function createPluginUpdateController(options) {
56
176
  return { status: 200, body: { ok: true, action: 'none', check: { ...checked, release: clientReleaseView(checked.release, view) } } };
57
177
  }
58
178
  const opId = body.opId ?? newOperationId();
179
+ let effectiveCheck = checked;
180
+ let npmCandidate = checked.candidate ?? null;
181
+ let preparedNpmArtifacts = null;
182
+ if (npmCandidate?.carrier?.legacyMetadataMissing) {
183
+ cleanupPreparedUpdates();
184
+ const prepared = preparedUpdates.get(opId);
185
+ if (!prepared || prepared.expiresAt <= Date.now() || prepared.prepareId !== body.prepareId) {
186
+ return { status: 200, body: { ok: true, action: 'prepare-required', opId, check: checked } };
187
+ }
188
+ if (prepared.candidate.plugin.version !== npmCandidate.plugin.version
189
+ || prepared.candidate.carrier.version !== npmCandidate.carrier.version
190
+ || prepared.candidate.plugin.integrity !== npmCandidate.plugin.integrity
191
+ || prepared.candidate.carrier.integrity !== npmCandidate.carrier.integrity) {
192
+ return { status: 409, body: { error: 'prepared-candidate-stale', check: checked } };
193
+ }
194
+ npmCandidate = prepared.candidate;
195
+ preparedNpmArtifacts = prepared.artifacts;
196
+ effectiveCheck = { ...checked, candidate: npmCandidate,
197
+ remote: { ...checked.remote, contentSha256: npmCandidate.plugin.contentSha256,
198
+ appVersion: npmCandidate.carrier.appVersion, legacyCarrierMetadata: false } };
199
+ Object.assign(status, effectiveCheck);
200
+ } else if (body.prepareId) {
201
+ return { status: 409, body: { error: 'prepare-not-required', check: checked } };
202
+ }
203
+ const targetPlugin = checked.release?.plugin ?? npmCandidate?.plugin;
204
+ if (!targetPlugin?.version) return { status: 503, body: { error: 'update-candidate-incomplete' } };
205
+ if (typeof body.targetVersion === 'string' && body.targetVersion !== targetPlugin.version) {
206
+ return { status: 409, body: { error: 'candidate-changed', check: effectiveCheck } };
207
+ }
59
208
  const lock = acquireLock(lockPath, { opId, stateFile: currentFile });
60
209
  if (!lock.ok) return { status: 409, body: { error: lock.error, transaction: publicTransaction(readTransaction(currentFile)) } };
61
210
  const lease = lock.lease, stateFile = serverOperationPath(home, lease.generation);
211
+ const operationLog = createOperationLog({ home, generation: lease.generation,
212
+ redactPaths: [home, profileDir, workerOptions.pluginRoot, workerOptions.installedRoot] });
213
+ operationLog.setStage('staging', 'NPM plugin update accepted');
62
214
  let tx;
63
215
  try {
64
216
  tx = persist(stateFile, { ...workerOptions, opId, phase: 'staging', startedAt: new Date().toISOString(),
65
- profileDir, release: checked.release, manifestUrl: checked.release.manifestUrl,
66
- targetVersion: checked.release.plugin.version, targetContentSha256: checked.release.plugin.contentSha256,
217
+ profileDir, release: checked.release ?? null, npmCandidate,
218
+ manifestUrl: checked.release?.manifestUrl ?? '',
219
+ targetVersion: targetPlugin.version, targetContentSha256: targetPlugin.contentSha256 ?? '',
220
+ targetAppVersion: npmCandidate?.carrier?.appVersion ?? checked.release?.app?.version ?? null,
221
+ preparedId: body.prepareId ?? null,
67
222
  currentFile, lockPath, lease, attempts: 0 }, { lockPath, lease, currentFile });
68
223
  } catch(e) {
69
224
  releaseLock(lockPath, lease, { stateFile: currentFile });
70
- log(`update admission persistence failed: ${e.message}`);
225
+ operationLog.setStage('failed', 'Could not persist update admission state.');
226
+ operationLog.flush();
227
+ log('plugin update admission state write failed');
71
228
  return { status: 500, body: { error: 'update-state-write-failed' } };
72
229
  }
230
+ // Consume the in-memory preflight only after the durable transaction accepts the same opId.
231
+ if (preparedNpmArtifacts) {
232
+ clearTimeout(preparedUpdates.get(opId)?.expireTimer);
233
+ preparedUpdates.delete(opId);
234
+ }
73
235
  // Only this lease schedules this worker. Same request retries return the saved operation above.
74
236
  schedule(async () => {
75
237
  try {
76
238
  const run = await runner({ ...workerOptions, home, profileDir, fetchImpl, auth, opId,
77
- release: tx.release, plugin: tx.release.plugin, manifestUrl: tx.manifestUrl,
78
- stateFile, currentFile, lockPath, lease, initialTransaction: tx, log });
79
- if (run.ok) Object.assign(status, { state: 'restarting', reason: 'update-staged', opId });
80
- else Object.assign(status, { state: run.transaction?.phase === 'rolling-back' ? 'rolling-back' : 'update-failed', reason: run.error, opId });
239
+ release: tx.release ?? null, npmCandidate: tx.npmCandidate ?? null, preparedNpmArtifacts,
240
+ plugin: tx.release?.plugin ?? tx.npmCandidate?.plugin, manifestUrl: tx.manifestUrl,
241
+ onStep: step => { operationLog.setStage(step); workerOptions.onStep?.(step); },
242
+ onOutput: (stream, text) => operationLog.appendChunk(stream, text),
243
+ stateFile, currentFile, lockPath, lease, initialTransaction: tx,
244
+ log: message => { operationLog.appendStatus(message, 'worker'); log('plugin update progress recorded'); } });
245
+ operationLog.flush();
246
+ if (run.ok) {
247
+ operationLog.setStage('restart', 'Plugin install finished; waiting for host restart and health confirmation.');
248
+ Object.assign(status, { state: 'restarting', reason: 'update-staged', opId });
249
+ } else {
250
+ const reason = safeUpdateReason(run.error);
251
+ // 失败原因必须进日志:详情页要能直接看到,而不是只有一句“看安装输出”。
252
+ operationLog.setStage('failed', `Plugin update failed: ${reason}`);
253
+ Object.assign(status, { state: run.transaction?.phase === 'rolling-back' ? 'rolling-back' : 'update-failed', reason, opId });
254
+ }
81
255
  } catch(e) {
82
256
  // Never clear an install/recovery lease as a generic catch. The armed supervisor owns recovery.
83
257
  const latest = readTransaction(stateFile);
258
+ const reason = safeUpdateReason(e.message);
84
259
  if (latest && latest.lease?.generation === lease.generation && latest.phase === 'staging') {
85
- try { finishOwnedTerminal(stateFile, { phase: 'failed', result: { ok: false, reason: e.message }, expectedPhases: ['staging'] }, { lockPath, lease, currentFile }); }
260
+ try { finishOwnedTerminal(stateFile, { phase: 'failed', result: { ok: false, reason }, expectedPhases: ['staging'] }, { lockPath, lease, currentFile }); }
86
261
  catch { /* lost lease: do not overwrite a newer operation */ }
87
262
  }
88
- Object.assign(status, { state: 'update-failed', reason: e.message, opId }); log(`update worker failed: ${e.message}`);
263
+ operationLog.setStage('failed', `Update worker failed: ${reason}`);
264
+ operationLog.flush();
265
+ Object.assign(status, { state: 'update-failed', reason, opId }); log('plugin update worker failed');
266
+ } finally {
267
+ if (preparedNpmArtifacts?.workDir) removePreparedWorkDir(preparedNpmArtifacts.workDir);
89
268
  }
90
269
  });
91
270
  return { status: 200, body: { ok: true, action: 'accepted', opId,
92
- transaction: publicTransaction(tx, view), check: { ...checked, release: clientReleaseView(checked.release, view) } } };
271
+ transaction: publicTransaction(tx, view), check: { ...effectiveCheck, release: clientReleaseView(effectiveCheck.release, view) } } };
93
272
  }
94
- return { submit, check: checking };
273
+ return { submit, check: checking, prepare };
95
274
  }
@@ -0,0 +1,164 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { serverOperationPath } from "./plugin-update-transaction.js";
5
+
6
+ export const UPDATE_LOG_MAX_LINES = 400;
7
+ export const UPDATE_LOG_MAX_BYTES = 128 * 1024;
8
+ export const UPDATE_LOG_MAX_OPERATIONS = 20;
9
+ const MAX_LINE_BYTES = 4096;
10
+ const ANSI = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
11
+ const SENSITIVE_KEY = /\b(_authToken|_auth|npm_config_[A-Za-z0-9_-]+|password|token|access_token|refresh_token|api[_-]?key|secret)\s*[:=]\s*([^\s&"']+)/gi;
12
+ const URL_USERINFO = /(https?:\/\/)[^/\s@]+@/gi;
13
+ const URL_SECRET_QUERY = /([?&](?:token|auth|password|key|secret|signature|access_token|refresh_token)=)[^&#\s]*/gi;
14
+
15
+ function logPath(home, generation) {
16
+ return `${serverOperationPath(home, generation)}.log.jsonl`;
17
+ }
18
+
19
+ /** Keep the active/new log plus a bounded tail of completed operation logs. */
20
+ export function pruneOperationLogs({ home, generation, maxOperations = UPDATE_LOG_MAX_OPERATIONS } = {}) {
21
+ const currentPath = logPath(home, generation);
22
+ const dir = dirname(currentPath);
23
+ if (!existsSync(dir)) return 0;
24
+ const limit = Number.isSafeInteger(maxOperations) && maxOperations > 0 ? maxOperations : UPDATE_LOG_MAX_OPERATIONS;
25
+ try {
26
+ const root = realpathSync(dir);
27
+ const currentName = basename(currentPath);
28
+ const logs = readdirSync(root, { withFileTypes: true })
29
+ .filter((entry) => entry.isFile() && /^[a-f0-9-]{36}\.json\.log\.jsonl$/.test(entry.name))
30
+ .map((entry) => ({ name: entry.name, mtime: statSync(join(root, entry.name)).mtimeMs }))
31
+ .sort((a, b) => b.mtime - a.mtime);
32
+ const keep = new Set([currentName, ...logs.map((item) => item.name).filter((name) => name !== currentName).slice(0, limit - 1)]);
33
+ let removed = 0;
34
+ for (const item of logs) {
35
+ if (keep.has(item.name)) continue;
36
+ const target = join(root, item.name);
37
+ const resolved = realpathSync(target);
38
+ const rel = relative(root, resolved);
39
+ if (rel !== item.name || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
40
+ rmSync(resolved, { force: true });
41
+ removed++;
42
+ }
43
+ return removed;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ function readEvents(filePath) {
50
+ try {
51
+ return readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
52
+ .filter((event) => Number.isSafeInteger(event?.seq) && typeof event?.text === "string");
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ function sanitize(text, redactPaths = []) {
59
+ let value = String(text ?? "").replace(ANSI, "");
60
+ value = value.replace(/\b(authorization\s*:\s*)(?:basic|bearer)\s+[^\s]+/gi, "$1[REDACTED]");
61
+ value = value.replace(/\b(basic|bearer)\s+[^\s]+/gi, "$1 [REDACTED]");
62
+ value = value.replace(URL_USERINFO, "$1[REDACTED]@");
63
+ value = value.replace(URL_SECRET_QUERY, "$1[REDACTED]");
64
+ value = value.replace(SENSITIVE_KEY, "$1=[REDACTED]");
65
+ for (const path of [...redactPaths].filter((item) => typeof item === "string" && item !== "").sort((a, b) => b.length - a.length)) {
66
+ value = value.split(path).join("[host-path]");
67
+ }
68
+ if (Buffer.byteLength(value) > MAX_LINE_BYTES) {
69
+ let end = Math.min(value.length, MAX_LINE_BYTES);
70
+ while (end > 0 && Buffer.byteLength(value.slice(0, end)) > MAX_LINE_BYTES - 16) end -= 64;
71
+ value = `${value.slice(0, Math.max(0, end))}…[line truncated]`;
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function persistEvents(filePath, events, { maxLines = UPDATE_LOG_MAX_LINES, maxBytes = UPDATE_LOG_MAX_BYTES } = {}) {
77
+ let kept = events.slice(-Math.max(1, maxLines));
78
+ const encoded = () => kept.map((event) => JSON.stringify(event)).join("\n") + (kept.length ? "\n" : "");
79
+ while (kept.length > 0 && Buffer.byteLength(encoded()) > maxBytes) kept = kept.slice(1);
80
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
81
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
82
+ try {
83
+ writeFileSync(tmp, encoded(), { encoding: "utf8", mode: 0o600 });
84
+ renameSync(tmp, filePath);
85
+ try { chmodSync(filePath, 0o600); } catch { /* best-effort on platforms without POSIX modes */ }
86
+ } finally {
87
+ if (existsSync(tmp)) rmSync(tmp, { force: true });
88
+ }
89
+ return kept;
90
+ }
91
+
92
+ function appendEvents(filePath, items, options = {}) {
93
+ if (!Array.isArray(items) || items.length === 0) return readEvents(filePath);
94
+ const events = readEvents(filePath);
95
+ let seq = events.length ? events[events.length - 1].seq + 1 : 1;
96
+ const next = items.map((item) => ({
97
+ seq: seq++,
98
+ at: new Date().toISOString(),
99
+ stage: String(item.stage ?? "unknown"),
100
+ stream: item.stream === "stdout" || item.stream === "stderr" ? item.stream : "status",
101
+ text: sanitize(item.text, options.redactPaths),
102
+ }));
103
+ return persistEvents(filePath, [...events, ...next], options);
104
+ }
105
+
106
+ /** Create a bounded append/replay view keyed only by the server-generated operation generation. */
107
+ export function createOperationLog({ home, generation, maxLines = UPDATE_LOG_MAX_LINES, maxBytes = UPDATE_LOG_MAX_BYTES,
108
+ maxOperations = UPDATE_LOG_MAX_OPERATIONS, redactPaths = [] } = {}) {
109
+ const filePath = logPath(home, generation);
110
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
111
+ if (!existsSync(filePath)) writeFileSync(filePath, "", { encoding: "utf8", mode: 0o600 });
112
+ pruneOperationLogs({ home, generation, maxOperations });
113
+ let stage = "staging";
114
+ const pending = { stdout: "", stderr: "" };
115
+ const options = { maxLines, maxBytes, redactPaths };
116
+ return {
117
+ filePath,
118
+ setStage(nextStage, text = "") {
119
+ stage = String(nextStage || "unknown");
120
+ return appendEvents(filePath, [{ stage, stream: "status", text: text || stage }], options);
121
+ },
122
+ appendStatus(text, stageOverride = stage) {
123
+ return appendEvents(filePath, [{ stage: stageOverride, stream: "status", text }], options);
124
+ },
125
+ appendChunk(stream, text) {
126
+ if (stream !== "stdout" && stream !== "stderr") return;
127
+ const merged = `${pending[stream]}${String(text ?? "")}`.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
128
+ const lines = merged.split("\n");
129
+ pending[stream] = lines.pop() ?? "";
130
+ const items = lines.map((line) => ({ stage, stream, text: line }));
131
+ if (items.length) appendEvents(filePath, items, options);
132
+ },
133
+ flush() {
134
+ const items = [];
135
+ for (const stream of ["stdout", "stderr"]) {
136
+ if (pending[stream] !== "") items.push({ stage, stream, text: pending[stream] });
137
+ pending[stream] = "";
138
+ }
139
+ if (items.length) appendEvents(filePath, items, options);
140
+ },
141
+ read(afterSeq = 0) {
142
+ return readOperationLog({ home, generation, afterSeq, maxLines });
143
+ },
144
+ };
145
+ }
146
+
147
+ /** Append one sanitized status event from a separate supervisor process. */
148
+ export function appendOperationLog({ home, generation, stage = "supervisor", stream = "status", text = "", maxLines = UPDATE_LOG_MAX_LINES, maxBytes = UPDATE_LOG_MAX_BYTES, redactPaths = [] } = {}) {
149
+ return appendEvents(logPath(home, generation), [{ stage, stream, text }], { maxLines, maxBytes, redactPaths });
150
+ }
151
+
152
+ /** Read only a bounded, sanitized page using a server-owned operation generation. */
153
+ export function readOperationLog({ home, generation, afterSeq = 0, maxLines = UPDATE_LOG_MAX_LINES } = {}) {
154
+ const events = readEvents(logPath(home, generation));
155
+ const firstSeq = events[0]?.seq ?? 0;
156
+ const lastSeq = events.at(-1)?.seq ?? 0;
157
+ const cursor = Number.isSafeInteger(afterSeq) && afterSeq >= 0 ? afterSeq : 0;
158
+ return {
159
+ events: events.filter((event) => event.seq > cursor).slice(-Math.max(1, maxLines)),
160
+ firstSeq,
161
+ lastSeq,
162
+ truncated: firstSeq > 0 && (firstSeq > 1 || cursor > 0 && cursor < firstSeq - 1),
163
+ };
164
+ }
@@ -233,11 +233,11 @@ export function finishOwnedTerminal(stateFile, { phase, result, expectedPhases }
233
233
  if (!r.ok) throw new Error(r.error);
234
234
  return r.transaction;
235
235
  }
236
- export function restoreOwnedProfile({ profileDir, snapshotDir, snapshot, key, lockPath, lease } = {}) {
237
- return arbitrate(lockPath, { operation: 'restore-profile', profileDir, snapshotDir, snapshot, key, lease });
236
+ export function restoreOwnedProfile({ profileDir, snapshotDir, snapshot, key, preserveSpecifier = false, lockPath, lease } = {}) {
237
+ return arbitrate(lockPath, { operation: 'restore-profile', profileDir, snapshotDir, snapshot, key, preserveSpecifier, lease });
238
238
  }
239
- export function publishOwnedSnapshotLink({ profileDir, snapshotDir, lockPath, lease } = {}) {
240
- return arbitrate(lockPath, { operation: 'publish-snapshot-link', profileDir, snapshotDir, lease });
239
+ export function publishOwnedSnapshotLink({ profileDir, snapshotDir, key, lockPath, lease } = {}) {
240
+ return arbitrate(lockPath, { operation: 'publish-snapshot-link', profileDir, snapshotDir, key, lease });
241
241
  }
242
242
  export function validOperationId(opId) {
243
243
  return typeof opId === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(opId);
@@ -0,0 +1,20 @@
1
+ // 跨平台子进程调用辅助(issue #39 Windows 修复)。
2
+ //
3
+ // Windows 上 npm/pnpm 是 `npm.cmd`/`pnpm.cmd`:Node 在 `shell: false` 下用 CreateProcess
4
+ // 无法解析 `.cmd`(只会自动补 `.exe`),直接 spawn 裸命令名会 ENOENT。因此在 win32 上
5
+ // 必须经 shell 调用,并对含空白的参数(典型是 profile/临时目录路径)做 cmd 引用。
6
+ //
7
+ // 本模块保持零依赖,避免 npm-update-source ↔ plugin-self-update 的循环引用。
8
+
9
+ /** 仅 Windows 需要 `shell: true`;POSIX 保持直接 spawn(更可控、无 shell 注入面)。 */
10
+ export function shellOptions(platform = process.platform) {
11
+ return platform === "win32" ? { shell: true } : {};
12
+ }
13
+
14
+ /** Windows shell 下给含空白/元字符的参数加引号;POSIX 原样返回。 */
15
+ export function quoteShellArgument(value, platform = process.platform) {
16
+ const text = String(value ?? "");
17
+ if (platform !== "win32" || text === "") return text;
18
+ if (!/[\s"&|<>^]/.test(text)) return text;
19
+ return `"${text.replace(/"/g, '""')}"`;
20
+ }
@@ -1,6 +1,6 @@
1
1
  // Runs only under an OS flock on a persistent inode. No lock-directory unlink/rename races.
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
 
6
6
  function read(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
@@ -89,7 +89,12 @@ function execute(input) {
89
89
  const doc = input.snapshot?.packageJson ? JSON.parse(input.snapshot.packageJson) : read(file);
90
90
  if (!doc?.dependencies) throw new Error('profile-dependencies-missing');
91
91
  const key = input.key || 'dsh-mobile-remote';
92
- doc.dependencies[key] = `file:${input.snapshotDir}`; atomic(file, doc);
92
+ if (input.preserveSpecifier === true) {
93
+ if (typeof doc.dependencies[key] !== 'string' || doc.dependencies[key] === '') throw new Error('profile-dependency-missing');
94
+ } else {
95
+ doc.dependencies[key] = `file:${input.snapshotDir}`;
96
+ }
97
+ atomic(file, doc);
93
98
  const lockfile = join(input.profileDir, 'pnpm-lock.yaml');
94
99
  if (typeof input.snapshot?.pnpmLock === 'string') writeFileSync(lockfile, input.snapshot.pnpmLock, { mode: 0o600 });
95
100
  else if (input.snapshot?.pnpmLock === null && existsSync(lockfile)) rmSync(lockfile);
@@ -100,7 +105,14 @@ function execute(input) {
100
105
  const modules = join(input.profileDir, 'node_modules'); mkdirSync(modules, { recursive: true });
101
106
  // Never traverse an unexpected node_modules symlink into another profile.
102
107
  if (realpathSync(modules) !== join(realpathSync(input.profileDir), 'node_modules')) throw new Error('profile-modules-outside-root');
103
- const target = join(modules, 'dsh-mobile-remote');
108
+ const key = input.key || 'dsh-mobile-remote';
109
+ if (!/^(?:@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/.test(key)) throw new Error('dependency-key-invalid');
110
+ const segments = key.split('/');
111
+ const parent = join(modules, ...segments.slice(0, -1));
112
+ mkdirSync(parent, { recursive: true });
113
+ const relativeParent = relative(realpathSync(modules), realpathSync(parent));
114
+ if (relativeParent === '..' || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) throw new Error('profile-package-scope-outside-root');
115
+ const target = join(parent, segments.at(-1));
104
116
  const staged = `${target}.restore-${randomUUID()}`, previous = `${target}.previous-${randomUUID()}`;
105
117
  symlinkSync(realpathSync(input.snapshotDir), staged, 'dir');
106
118
  let moved = false;