@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
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/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily self-update: check GitHub, install a newer release, report what ran.
|
|
3
|
+
*
|
|
4
|
+
* This module is the policy layer. It composes `update-source.mjs` (version
|
|
5
|
+
* discovery and a verified download) with `update-install.mjs` (backup, pnpm,
|
|
6
|
+
* rollback) and decides *whether* to act. Keeping the policy here rather than
|
|
7
|
+
* inside those modules is deliberate: `isNewer` answers "which version is
|
|
8
|
+
* higher", which is a different question from "may we install it".
|
|
9
|
+
*
|
|
10
|
+
* ## The four things that must not go wrong
|
|
11
|
+
*
|
|
12
|
+
* 1. **Never install downwards, never install a prerelease.** A downgrade is
|
|
13
|
+
* worse than a missed update: it silently reverts fixes. `isNewer` is a
|
|
14
|
+
* strict-greater test, and the `prerelease` flag is honoured here because a
|
|
15
|
+
* release candidate has a higher SemVer triple than the version it precedes.
|
|
16
|
+
* 2. **Never install something that failed verification.** The tarball must
|
|
17
|
+
* match the SHA256SUMS published with the release, and that hash is compared
|
|
18
|
+
* before any write.
|
|
19
|
+
* 3. **Never leave a broken install.** `update-install` backs up the profile and
|
|
20
|
+
* rolls back on failure; this module treats a rolled-back attempt as a
|
|
21
|
+
* failure of the whole run and records it.
|
|
22
|
+
* 4. **Never break a running Harness.** Installing changes what loads *next*
|
|
23
|
+
* start; it does not hot-swap the running process. We say so instead of
|
|
24
|
+
* implying the new code is already live.
|
|
25
|
+
*
|
|
26
|
+
* ## What "run the latest version" can and cannot mean
|
|
27
|
+
*
|
|
28
|
+
* The downloaded plugin is installed into the profile, so the next DSH start
|
|
29
|
+
* uses it. Rewriting the currently-loaded module inside a live Cordis container
|
|
30
|
+
* is not something a plugin may do safely, and pretending otherwise would be a
|
|
31
|
+
* lie in the status output. `restartCommand` therefore only ever *records* an
|
|
32
|
+
* operator-supplied command; nothing here executes a restart.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
38
|
+
import * as urlModule from 'node:url';
|
|
39
|
+
|
|
40
|
+
import { createDailyScheduler, DEFAULT_DAILY_TIMES, DEFAULT_TIME_ZONE } from './schedule.mjs';
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_REPO,
|
|
43
|
+
fetchLatestRelease,
|
|
44
|
+
isNewer,
|
|
45
|
+
requireChecksum,
|
|
46
|
+
} from './update-source.mjs';
|
|
47
|
+
import { installUpdate } from './update-install.mjs';
|
|
48
|
+
|
|
49
|
+
/** Env var holding the installed plugin's version, set by the profile at install time. */
|
|
50
|
+
export const PKG_VERSION_ENV = 'W2M_PLUGIN_VERSION';
|
|
51
|
+
|
|
52
|
+
/** File inside the profile recording the last completed run, for diagnostics. */
|
|
53
|
+
export const STATE_FILE_NAME = 'auto-update-state.json';
|
|
54
|
+
|
|
55
|
+
/** History is bounded so a long-lived profile cannot grow without limit. */
|
|
56
|
+
export const DEFAULT_HISTORY_LIMIT = 50;
|
|
57
|
+
|
|
58
|
+
/** Default interval between two full checks, in days. */
|
|
59
|
+
export const DEFAULT_INTERVAL_DAYS = 1;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the running plugin's version.
|
|
63
|
+
*
|
|
64
|
+
* Uses the compiled-in constant, overridable by `W2M_PLUGIN_VERSION` so tests
|
|
65
|
+
* and packagers can pin it. Returns `null` when unknown -- callers must treat
|
|
66
|
+
* that as "cannot decide", never as "old".
|
|
67
|
+
*
|
|
68
|
+
* @param {string} bundledVersion - Version from the caller's own package.json.
|
|
69
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
70
|
+
* @returns {string|null} The version, or null when unknown.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveCurrentVersion(bundledVersion, env = process.env) {
|
|
73
|
+
const fromEnv = typeof env?.[PKG_VERSION_ENV] === 'string' ? env[PKG_VERSION_ENV].trim() : '';
|
|
74
|
+
if (fromEnv !== '') return fromEnv;
|
|
75
|
+
return typeof bundledVersion === 'string' && bundledVersion.trim() !== '' ? bundledVersion.trim() : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decide whether a discovered release should be installed.
|
|
80
|
+
*
|
|
81
|
+
* Pure and total: every input yields a decision, and the reason is always
|
|
82
|
+
* returned so a status output can explain a skip instead of looking idle.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} input - Decision inputs.
|
|
85
|
+
* @param {{ok: boolean, tag?: string, prerelease?: boolean, version?: object|null}} input.release
|
|
86
|
+
* @param {string|null} input.currentVersion
|
|
87
|
+
* @param {boolean} [input.allowPrerelease] - Opt-in for prerelease installs.
|
|
88
|
+
* @returns {{action: 'install'|'skip', reason: string, tag?: string}} The decision.
|
|
89
|
+
*/
|
|
90
|
+
export function decideUpdate({ release, currentVersion, allowPrerelease = false }) {
|
|
91
|
+
if (!release || release.ok !== true) {
|
|
92
|
+
return { action: 'skip', reason: `release lookup failed: ${release?.error ?? 'no release'}` };
|
|
93
|
+
}
|
|
94
|
+
if (typeof release.tag !== 'string' || release.tag === '') {
|
|
95
|
+
return { action: 'skip', reason: 'release has no tag' };
|
|
96
|
+
}
|
|
97
|
+
if (release.version === null || release.version === undefined) {
|
|
98
|
+
return { action: 'skip', reason: `tag ${release.tag} is not a version we can compare` };
|
|
99
|
+
}
|
|
100
|
+
if (!currentVersion) {
|
|
101
|
+
return { action: 'skip', reason: 'the installed version is unknown, so nothing can be called newer' };
|
|
102
|
+
}
|
|
103
|
+
if (release.prerelease === true && !allowPrerelease) {
|
|
104
|
+
// Not the same question as "is it newer": 0.2.0-rc.1 outranks 0.1.2 by SemVer,
|
|
105
|
+
// and shipping a candidate to a stable install is not what the user asked for.
|
|
106
|
+
return { action: 'skip', reason: `tag ${release.tag} is a prerelease and prereleases are not installed by default` };
|
|
107
|
+
}
|
|
108
|
+
if (!isNewer(release.tag, currentVersion)) {
|
|
109
|
+
return { action: 'skip', reason: `already on ${currentVersion} (latest is ${release.tag})` };
|
|
110
|
+
}
|
|
111
|
+
return { action: 'install', reason: `upgrade ${currentVersion} -> ${release.tag}`, tag: release.tag };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Hash a file with SHA-256.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} file - Path to read.
|
|
118
|
+
* @returns {string} Lowercase hex digest.
|
|
119
|
+
*/
|
|
120
|
+
export function sha256OfFile(file) {
|
|
121
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Write JSON atomically: temp file in the same directory, then rename.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} file - Destination path.
|
|
128
|
+
* @param {unknown} value - JSON-serializable value.
|
|
129
|
+
* @returns {void}
|
|
130
|
+
*/
|
|
131
|
+
function writeJsonAtomic(file, value) {
|
|
132
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
133
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
134
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
135
|
+
try {
|
|
136
|
+
renameSync(tmp, file);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
try {
|
|
139
|
+
rmSync(tmp, { force: true });
|
|
140
|
+
} catch {
|
|
141
|
+
/* the rename failure is the interesting one */
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read persisted auto-update state, tolerating absence and corruption.
|
|
149
|
+
*
|
|
150
|
+
* A corrupt state file must not stop the updater: the file is a record, not a
|
|
151
|
+
* source of truth, and refusing to run because a log is malformed would turn a
|
|
152
|
+
* cosmetic problem into a permanent one.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} file - State file path.
|
|
155
|
+
* @returns {{history: object[], lastCheckMs: number|null, lastInstallMs: number|null, lastError: string|null}}
|
|
156
|
+
*/
|
|
157
|
+
export function loadState(file) {
|
|
158
|
+
const empty = { history: [], lastCheckMs: null, lastInstallMs: null, lastError: null };
|
|
159
|
+
if (!existsSync(file)) return empty;
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
162
|
+
return {
|
|
163
|
+
history: Array.isArray(parsed?.history) ? parsed.history : [],
|
|
164
|
+
lastCheckMs: Number.isFinite(parsed?.lastCheckMs) ? parsed.lastCheckMs : null,
|
|
165
|
+
lastInstallMs: Number.isFinite(parsed?.lastInstallMs) ? parsed.lastInstallMs : null,
|
|
166
|
+
lastError: typeof parsed?.lastError === 'string' ? parsed.lastError : null,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { ...empty, lastError: `state file unreadable: ${error instanceof Error ? error.message : String(error)}` };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find the DSH profile directory that contains this plugin.
|
|
175
|
+
*
|
|
176
|
+
* The updater installs into the profile, so it must name it. Two facts make that reliable: the
|
|
177
|
+
* plugin is loaded from `<profile>/node_modules/@twinsearth/w2m-dsh-plugin`, and the profile root is
|
|
178
|
+
* the directory holding that `node_modules` plus a `package.json`. We walk up and *verify* rather
|
|
179
|
+
* than slicing a fixed number of path segments, because a pnpm layout can insert a `.pnpm` segment.
|
|
180
|
+
*
|
|
181
|
+
* Lives here rather than inside `apply()` on purpose. As a closure it was unreachable from any test,
|
|
182
|
+
* and a missing `existsSync` import in it survived a fully green suite until ESLint's `no-undef`
|
|
183
|
+
* caught it. Exporting it is what makes that class of mistake visible next time.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} input - Resolution input.
|
|
186
|
+
* @param {unknown} [input.explicit] - A configured `profileDir`; used verbatim when non-empty.
|
|
187
|
+
* @param {string} input.moduleUrl - `import.meta.url` of the calling module.
|
|
188
|
+
* @param {(p: string) => boolean} [input.exists] - Existence probe, injectable for tests.
|
|
189
|
+
* @param {number} [input.maxDepth] - Ancestor levels to search.
|
|
190
|
+
* @returns {string|null} Absolute profile directory, or null when there is none.
|
|
191
|
+
*/
|
|
192
|
+
export function findProfileDir({ explicit, moduleUrl, exists = existsSync, maxDepth = 8 } = {}) {
|
|
193
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') {
|
|
194
|
+
return resolve(explicit.trim());
|
|
195
|
+
}
|
|
196
|
+
if (typeof moduleUrl !== 'string' || moduleUrl === '') return null;
|
|
197
|
+
|
|
198
|
+
const { fileURLToPath } = urlModule;
|
|
199
|
+
let dir;
|
|
200
|
+
try {
|
|
201
|
+
dir = dirname(fileURLToPath(moduleUrl));
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
207
|
+
const parent = dirname(dir);
|
|
208
|
+
if (parent === dir) break;
|
|
209
|
+
dir = parent;
|
|
210
|
+
if (basename(dir) !== 'node_modules') continue;
|
|
211
|
+
const root = dirname(dir);
|
|
212
|
+
// A `node_modules` directory is not enough on its own; the profile root must look like a package.
|
|
213
|
+
if (exists(join(root, 'package.json'))) return root;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create the auto-update supervisor.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} options - Supervisor options.
|
|
223
|
+
* @param {string} options.profileDir - The DSH profile directory to install into.
|
|
224
|
+
* @param {string|null} options.currentVersion - The running version.
|
|
225
|
+
* @param {string} [options.repo] - `owner/name` to check.
|
|
226
|
+
* @param {string[]} [options.times] - Daily slots, `HH:mm:ss`.
|
|
227
|
+
* @param {string} [options.timeZone] - IANA zone for the slots.
|
|
228
|
+
* @param {number} [options.catchUpMs] - How late a missed slot may still run.
|
|
229
|
+
* @param {boolean} [options.enabled] - Whether the schedule runs at all.
|
|
230
|
+
* @param {boolean} [options.allowPrerelease] - Opt-in for prerelease installs.
|
|
231
|
+
* @param {boolean} [options.dryRun] - Verify and report, but never install.
|
|
232
|
+
* @param {string|null} [options.restartCommand] - Recorded only; never executed.
|
|
233
|
+
* @param {number} [options.timeoutMs] - Per-request network timeout.
|
|
234
|
+
* @param {string|null} [options.token] - GitHub token; never logged.
|
|
235
|
+
* @param {number} [options.historyLimit] - Maximum retained history entries.
|
|
236
|
+
* @param {string|null} [options.stateDir] - Where to persist state; defaults to the profile.
|
|
237
|
+
* @param {number} [options.intervalDays] - Minimum days between two installs.
|
|
238
|
+
* @param {(...args: any[]) => void} [options.log] - Diagnostic sink.
|
|
239
|
+
* @param {(err: Error) => void} [options.onError] - Error sink for scheduler failures.
|
|
240
|
+
* @param {Function} [options.fetchImpl] - Injected fetch, for tests.
|
|
241
|
+
* @param {Function} [options.installer] - Injected installer, for tests.
|
|
242
|
+
* @param {() => number} [options.nowMs] - Clock, for tests.
|
|
243
|
+
* @param {Function} [options.setTimer] / @param {Function} [options.clearTimer] - Timers, for tests.
|
|
244
|
+
* @returns {object} `{start, stop, check, describe, stateFile}`.
|
|
245
|
+
*/
|
|
246
|
+
export function createAutoUpdater(options) {
|
|
247
|
+
const {
|
|
248
|
+
profileDir,
|
|
249
|
+
currentVersion,
|
|
250
|
+
repo = DEFAULT_REPO,
|
|
251
|
+
times = DEFAULT_DAILY_TIMES,
|
|
252
|
+
timeZone = DEFAULT_TIME_ZONE,
|
|
253
|
+
catchUpMs,
|
|
254
|
+
enabled = true,
|
|
255
|
+
allowPrerelease = false,
|
|
256
|
+
dryRun = false,
|
|
257
|
+
restartCommand = null,
|
|
258
|
+
timeoutMs = 30_000,
|
|
259
|
+
token = null,
|
|
260
|
+
historyLimit = DEFAULT_HISTORY_LIMIT,
|
|
261
|
+
stateDir = null,
|
|
262
|
+
intervalDays = DEFAULT_INTERVAL_DAYS,
|
|
263
|
+
log = () => {},
|
|
264
|
+
onError = () => {},
|
|
265
|
+
fetchImpl,
|
|
266
|
+
installer = installUpdate,
|
|
267
|
+
nowMs = Date.now,
|
|
268
|
+
setTimer,
|
|
269
|
+
clearTimer,
|
|
270
|
+
} = options ?? {};
|
|
271
|
+
|
|
272
|
+
if (typeof profileDir !== 'string' || profileDir === '') {
|
|
273
|
+
// A missing profile is not an error: a plugin run straight from a clone has no profile to
|
|
274
|
+
// install into, and refusing to load would take the whole tool set down over a maintenance
|
|
275
|
+
// feature. The updater reports itself disabled and says why.
|
|
276
|
+
const reason = 'no DSH profile directory was found, so there is nothing to install into';
|
|
277
|
+
return {
|
|
278
|
+
stateFile: null,
|
|
279
|
+
start() {},
|
|
280
|
+
stop() {},
|
|
281
|
+
async check() {
|
|
282
|
+
return { ok: false, outcome: 'disabled', reason };
|
|
283
|
+
},
|
|
284
|
+
describe() {
|
|
285
|
+
return {
|
|
286
|
+
enabled: false,
|
|
287
|
+
disabled_reason: reason,
|
|
288
|
+
dry_run: dryRun,
|
|
289
|
+
allow_prerelease: allowPrerelease,
|
|
290
|
+
repo,
|
|
291
|
+
current_version: currentVersion ?? null,
|
|
292
|
+
profile_dir: null,
|
|
293
|
+
state_file: null,
|
|
294
|
+
restart_required: false,
|
|
295
|
+
restart_command: restartCommand,
|
|
296
|
+
schedule: { time_zone: timeZone, times: [], started: false, stopped: true, next_at: null, runs: 0, failures: 0 },
|
|
297
|
+
last_check: null,
|
|
298
|
+
last_install: null,
|
|
299
|
+
last_check_ms: null,
|
|
300
|
+
last_install_ms: null,
|
|
301
|
+
last_error: null,
|
|
302
|
+
reconciliation_needed: null,
|
|
303
|
+
history: [],
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const stateFile = join(stateDir ?? profileDir, STATE_FILE_NAME);
|
|
310
|
+
let state = loadState(stateFile);
|
|
311
|
+
/** Serializes runs: two checks must never install at once. */
|
|
312
|
+
let inFlight = null;
|
|
313
|
+
/** Set when a failed install left the tree out of step with the restored manifest. */
|
|
314
|
+
let reconciliationNeeded = null;
|
|
315
|
+
|
|
316
|
+
/** Append one run to the bounded history and persist. */
|
|
317
|
+
function record(entry) {
|
|
318
|
+
state = {
|
|
319
|
+
...state,
|
|
320
|
+
history: [...state.history, entry].slice(-historyLimit),
|
|
321
|
+
lastCheckMs: nowMs(),
|
|
322
|
+
lastInstallMs: entry.installed ? nowMs() : state.lastInstallMs,
|
|
323
|
+
lastError: entry.error ?? null,
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
writeJsonAtomic(stateFile, state);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// Losing the record must not fail the update that just succeeded.
|
|
329
|
+
log(`warn: could not persist auto-update state: ${error instanceof Error ? error.message : String(error)}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* One full check-and-install cycle.
|
|
335
|
+
*
|
|
336
|
+
* Never throws: every failure becomes a recorded entry with a reason, because
|
|
337
|
+
* a daily timer that dies on the first bad network day is worse than useless.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} [trigger] - `schedule` or `manual`, for the record.
|
|
340
|
+
* @returns {Promise<object>} A result describing what happened.
|
|
341
|
+
*/
|
|
342
|
+
async function check(trigger = 'schedule') {
|
|
343
|
+
const startedAt = nowMs();
|
|
344
|
+
const base = { trigger, startedAt, startedAtIso: new Date(startedAt).toISOString() };
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const release = await fetchLatestRelease({ repo, fetchImpl, timeoutMs, token });
|
|
348
|
+
const decision = decideUpdate({ release, currentVersion, allowPrerelease });
|
|
349
|
+
|
|
350
|
+
if (decision.action === 'skip') {
|
|
351
|
+
// A lookup failure is not a check that found nothing. Reporting it as
|
|
352
|
+
// "skipped" would leave the status output looking healthy while the
|
|
353
|
+
// updater was unreachable -- the silent failure this whole feature has
|
|
354
|
+
// to avoid. Only a genuine "nothing newer" is a successful check.
|
|
355
|
+
const lookupFailed = release?.ok !== true;
|
|
356
|
+
const entry = {
|
|
357
|
+
...base,
|
|
358
|
+
outcome: lookupFailed ? 'error' : 'skipped',
|
|
359
|
+
reason: decision.reason,
|
|
360
|
+
tag: release?.tag ?? null,
|
|
361
|
+
...(lookupFailed ? { error: decision.reason, code: release?.code ?? null } : {}),
|
|
362
|
+
};
|
|
363
|
+
record(entry);
|
|
364
|
+
log(lookupFailed ? `update check failed: ${decision.reason}` : `no update: ${decision.reason}`);
|
|
365
|
+
return { ...entry, ok: !lookupFailed };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A minimum gap between installs.
|
|
369
|
+
//
|
|
370
|
+
// Without this the only thing preventing a repeat install is the version comparison, which
|
|
371
|
+
// covers the normal case but not two real ones: a relay serving a stale `latest` while a newer
|
|
372
|
+
// version is already installed, and a retry loop that installs successfully and then fails to
|
|
373
|
+
// observe the new version (a test environment, or a profile whose manifest is rewritten by
|
|
374
|
+
// something else). Both would reinstall at every slot. Measured against the last *successful*
|
|
375
|
+
// install, so a failed attempt never delays the next try -- and it short-circuits before the
|
|
376
|
+
// network work, which is the point of having an interval at all.
|
|
377
|
+
if (state.lastInstallMs !== null && intervalDays > 0) {
|
|
378
|
+
const elapsedDays = (nowMs() - state.lastInstallMs) / 86_400_000;
|
|
379
|
+
if (elapsedDays < intervalDays) {
|
|
380
|
+
const entry = {
|
|
381
|
+
...base,
|
|
382
|
+
outcome: 'skipped',
|
|
383
|
+
reason:
|
|
384
|
+
`installed ${elapsedDays.toFixed(3)} day(s) ago and the minimum interval is ` +
|
|
385
|
+
`${intervalDays} day(s), so nothing was checked`,
|
|
386
|
+
tag: null,
|
|
387
|
+
};
|
|
388
|
+
record(entry);
|
|
389
|
+
log(`no update: ${entry.reason}`);
|
|
390
|
+
return { ...entry, ok: true };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const tag = decision.tag;
|
|
395
|
+
const previous = release.tag;
|
|
396
|
+
|
|
397
|
+
// Read the checksum from the release's own SHA256SUMS rather than trusting
|
|
398
|
+
// the tarball. Without this the download is unverified and "verify before
|
|
399
|
+
// install" would be a comment rather than a property.
|
|
400
|
+
const sumsRes = await (fetchImpl ?? globalThis.fetch)(release.assets.sums.url, {
|
|
401
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
402
|
+
});
|
|
403
|
+
if (!sumsRes?.ok) {
|
|
404
|
+
throw new Error(`could not read SHA256SUMS for ${tag}: HTTP ${sumsRes?.status ?? '?'}`);
|
|
405
|
+
}
|
|
406
|
+
const sumsText = await sumsRes.text();
|
|
407
|
+
const expectedSha256 = requireChecksum(sumsText, release.assets.tarball.name);
|
|
408
|
+
|
|
409
|
+
// Download beside the profile so the rename into place is on one filesystem.
|
|
410
|
+
const stagingDir = join(profileDir, '.w2m-update');
|
|
411
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
412
|
+
const tarballPath = join(stagingDir, release.assets.tarball.name);
|
|
413
|
+
const dl = await (await import('./update-source.mjs')).downloadVerified({
|
|
414
|
+
url: release.assets.tarball.url,
|
|
415
|
+
sha256: expectedSha256,
|
|
416
|
+
destPath: tarballPath,
|
|
417
|
+
fetchImpl,
|
|
418
|
+
timeoutMs,
|
|
419
|
+
token,
|
|
420
|
+
expectedBytes: release.assets.tarball.size,
|
|
421
|
+
});
|
|
422
|
+
if (dl.ok !== true) {
|
|
423
|
+
throw new Error(`download failed: ${dl.error ?? 'unknown'}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Re-hash what is on disk. The download verified the stream; this verifies
|
|
427
|
+
// the file, which is what the installer reads.
|
|
428
|
+
const actualSha256 = sha256OfFile(tarballPath);
|
|
429
|
+
if (actualSha256 !== expectedSha256) {
|
|
430
|
+
throw new Error(`staged tarball hashes to ${actualSha256}, expected ${expectedSha256}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (dryRun) {
|
|
434
|
+
// Stop before any write. This is what makes the updater safe to leave on
|
|
435
|
+
// while validating a setup.
|
|
436
|
+
const entry = {
|
|
437
|
+
...base,
|
|
438
|
+
outcome: 'dry-run',
|
|
439
|
+
reason: `would install ${tag}`,
|
|
440
|
+
tag,
|
|
441
|
+
sha256: actualSha256,
|
|
442
|
+
};
|
|
443
|
+
record(entry);
|
|
444
|
+
log(`dry run: would install ${tag}`);
|
|
445
|
+
return { ...entry, ok: true };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const installed = await installer({
|
|
449
|
+
tarballPath,
|
|
450
|
+
profileDir,
|
|
451
|
+
expectedSha256,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (installed?.ok !== true) {
|
|
455
|
+
// `rolledBack` says the manifest and lockfile were restored. `rollbackComplete` says the
|
|
456
|
+
// installed tree matches that restored manifest again -- a strictly stronger claim, and the
|
|
457
|
+
// only one that predicts whether DSH can still start. Both are surfaced because conflating
|
|
458
|
+
// them is how "we rolled back" becomes an untrue reassurance.
|
|
459
|
+
const entry = {
|
|
460
|
+
...base,
|
|
461
|
+
outcome: 'failed',
|
|
462
|
+
reason: `install failed: ${installed?.error ?? 'unknown'}`,
|
|
463
|
+
tag,
|
|
464
|
+
rolledBack: installed?.rolledBack === true,
|
|
465
|
+
rollbackComplete: installed?.rollbackComplete ?? null,
|
|
466
|
+
installedVersionBefore: installed?.installedVersionBefore ?? null,
|
|
467
|
+
installedVersionAfterRollback: installed?.installedVersionAfterRollback ?? null,
|
|
468
|
+
reconciliation: installed?.reconciliation ?? null,
|
|
469
|
+
error: installed?.error ?? 'install failed',
|
|
470
|
+
};
|
|
471
|
+
if (entry.rollbackComplete === false) reconciliationNeeded = entry.reconciliation ?? 'the installed tree does not match the restored manifest';
|
|
472
|
+
record(entry);
|
|
473
|
+
log(
|
|
474
|
+
`update to ${tag} failed: ${installed?.error ?? 'unknown'}` +
|
|
475
|
+
(entry.rollbackComplete === false
|
|
476
|
+
? ' -- the installed tree no longer matches the restored manifest; run the reconciliation command'
|
|
477
|
+
: ''),
|
|
478
|
+
);
|
|
479
|
+
return { ...entry, ok: false };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const entry = {
|
|
483
|
+
...base,
|
|
484
|
+
outcome: 'installed',
|
|
485
|
+
installed: true,
|
|
486
|
+
reason: `installed ${tag}`,
|
|
487
|
+
tag,
|
|
488
|
+
from: currentVersion,
|
|
489
|
+
to: tag,
|
|
490
|
+
sha256: actualSha256,
|
|
491
|
+
installedVia: installed.installedVia ?? null,
|
|
492
|
+
// Recorded, never executed: replacing a running Harness is the
|
|
493
|
+
// operator's decision.
|
|
494
|
+
restartCommand,
|
|
495
|
+
restartRequired: true,
|
|
496
|
+
};
|
|
497
|
+
record(entry);
|
|
498
|
+
log(`installed ${tag}; restart DSH to load it`);
|
|
499
|
+
void previous;
|
|
500
|
+
return { ...entry, ok: true };
|
|
501
|
+
} catch (error) {
|
|
502
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
503
|
+
const entry = { ...base, outcome: 'error', reason: message, error: message };
|
|
504
|
+
record(entry);
|
|
505
|
+
log(`update check failed: ${message}`);
|
|
506
|
+
return { ...entry, ok: false };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const scheduler = createDailyScheduler({
|
|
511
|
+
run: async () => {
|
|
512
|
+
// Serialize: a slow install must not overlap the next slot.
|
|
513
|
+
if (inFlight) {
|
|
514
|
+
log('a check is already running; skipping this slot');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
inFlight = check('schedule').finally(() => {
|
|
518
|
+
inFlight = null;
|
|
519
|
+
});
|
|
520
|
+
await inFlight;
|
|
521
|
+
},
|
|
522
|
+
times,
|
|
523
|
+
timeZone,
|
|
524
|
+
catchUpMs,
|
|
525
|
+
onError,
|
|
526
|
+
nowMs,
|
|
527
|
+
...(setTimer ? { setTimer } : {}),
|
|
528
|
+
...(clearTimer ? { clearTimer } : {}),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
stateFile,
|
|
533
|
+
/** Arm the schedule. A no-op when disabled, so callers need no branch. */
|
|
534
|
+
start() {
|
|
535
|
+
if (!enabled) {
|
|
536
|
+
log('auto-update is disabled');
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
scheduler.start();
|
|
540
|
+
},
|
|
541
|
+
/** Release the timer; idempotent, for `ctx.effect`. */
|
|
542
|
+
stop() {
|
|
543
|
+
scheduler.stop();
|
|
544
|
+
},
|
|
545
|
+
/** Run a check now, bypassing the schedule. Never throws. */
|
|
546
|
+
check,
|
|
547
|
+
/** Diagnostic snapshot; contains no credential. */
|
|
548
|
+
describe() {
|
|
549
|
+
const s = scheduler.describe();
|
|
550
|
+
return {
|
|
551
|
+
enabled,
|
|
552
|
+
dry_run: dryRun,
|
|
553
|
+
allow_prerelease: allowPrerelease,
|
|
554
|
+
repo,
|
|
555
|
+
current_version: currentVersion,
|
|
556
|
+
profile_dir: profileDir,
|
|
557
|
+
state_file: stateFile,
|
|
558
|
+
restart_required: state.history.some((h) => h.outcome === 'installed'),
|
|
559
|
+
restart_command: restartCommand,
|
|
560
|
+
schedule: s,
|
|
561
|
+
last_check_ms: state.lastCheckMs,
|
|
562
|
+
last_check: state.lastCheckMs === null ? null : new Date(state.lastCheckMs).toISOString(),
|
|
563
|
+
last_install_ms: state.lastInstallMs,
|
|
564
|
+
last_install: state.lastInstallMs === null ? null : new Date(state.lastInstallMs).toISOString(),
|
|
565
|
+
last_error: state.lastError,
|
|
566
|
+
// A failed install can leave the tree out of step with the restored manifest, and DSH may
|
|
567
|
+
// then fail to start. That must be the loudest thing in this report, not a buried history
|
|
568
|
+
// entry, because it is the only state here that needs a human.
|
|
569
|
+
reconciliation_needed: reconciliationNeeded,
|
|
570
|
+
history: state.history.slice(-5),
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|