sideby 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +24 -18
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +11 -1
  4. package/dist/src/cli/commands.js +144 -7
  5. package/dist/src/cli/json-schemas.js +56 -1
  6. package/dist/src/cli/main.js +119 -48
  7. package/dist/src/core/app-profiles.d.ts +50 -0
  8. package/dist/src/core/app-profiles.js +206 -0
  9. package/dist/src/core/brand-mark.d.ts +83 -0
  10. package/dist/src/core/brand-mark.js +88 -0
  11. package/dist/src/core/config.d.ts +16 -0
  12. package/dist/src/core/config.js +17 -2
  13. package/dist/src/core/desktop-app.d.ts +18 -1
  14. package/dist/src/core/desktop-app.js +22 -12
  15. package/dist/src/core/handoff-chain.d.ts +16 -2
  16. package/dist/src/core/handoff-chain.js +19 -6
  17. package/dist/src/core/handoff-run.js +43 -18
  18. package/dist/src/core/handoff-select.d.ts +4 -0
  19. package/dist/src/core/handoff-select.js +3 -2
  20. package/dist/src/core/update.d.ts +209 -0
  21. package/dist/src/core/update.js +631 -0
  22. package/dist/src/desktop/icon.js +293 -62
  23. package/dist/src/families/claude/handoff.js +10 -0
  24. package/dist/src/families/desktop.d.ts +25 -0
  25. package/dist/src/families/desktop.js +8 -2
  26. package/dist/src/families/grok/bot.d.ts +8 -0
  27. package/dist/src/families/grok/bot.js +18 -0
  28. package/dist/src/handoff/hook.js +21 -4
  29. package/dist/src/handoff/messages.d.ts +4 -0
  30. package/dist/src/handoff/messages.js +12 -0
  31. package/dist/src/panel/background.d.ts +2 -0
  32. package/dist/src/panel/background.js +7 -0
  33. package/dist/src/panel/handler.d.ts +35 -0
  34. package/dist/src/panel/handler.js +132 -6
  35. package/dist/src/panel/i18n.d.ts +59 -0
  36. package/dist/src/panel/i18n.js +118 -0
  37. package/dist/src/panel/page-script.js +282 -6
  38. package/dist/src/panel/page-styles.js +21 -2
  39. package/dist/src/panel/page.d.ts +2 -0
  40. package/dist/src/panel/page.js +6 -3
  41. package/dist/src/runtime.d.ts +3 -1
  42. package/dist/src/runtime.js +7 -0
  43. package/dist/src/types.d.ts +5 -0
  44. package/dist/testing/index.js +2 -0
  45. package/package.json +2 -2
  46. package/schemas/config.json +15 -0
  47. package/schemas/desktop-remove.json +33 -0
  48. package/schemas/desktop.json +2 -1
  49. package/schemas/handoff-ready.json +4 -0
  50. package/schemas/list.json +47 -0
  51. package/schemas/update.json +115 -0
  52. package/skills/sideby/SKILL.md +7 -1
  53. package/skills/sideby/references/handoff.md +1 -1
@@ -0,0 +1,631 @@
1
+ // Update Check, Update Notice, Install Method, Update and Auto Update (spec §3.23, ADR-0021). The Update Check is the
2
+ // only network request sideby makes on its own: one GET for the `latest` dist-tag, carrying nothing about the user.
3
+ // Everything it learns goes to one state file shared by the CLI and every Panel.
4
+ import { spawn } from 'node:child_process';
5
+ import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat } from 'node:fs/promises';
6
+ import { dirname, join, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { writeFileAtomic } from './fs-safe.js';
9
+ import { packageVersion } from './version.js';
10
+ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
11
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
+ export const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+ /** After a failed check (offline, a mirror without the endpoint) the next one waits this long. */
14
+ export const RETRY_INTERVAL_MS = 60 * 60 * 1000;
15
+ /** A failed Auto Update of one version is tried again after this long (a mirror may not have it yet). */
16
+ export const AUTO_RETRY_MS = 24 * 60 * 60 * 1000;
17
+ const FETCH_TIMEOUT_MS = 5000;
18
+ export const RELEASE_NOTES = (v) => `https://github.com/Adonis0123/sideby/releases/tag/v${v}`;
19
+ /** The folder of the sideby package this module belongs to: two levels above src/, three above dist/src/. */
20
+ async function ownPackageDir() {
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ for (const up of ['../..', '../../..']) {
23
+ const dir = join(here, up);
24
+ try {
25
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
26
+ if (pkg.name === 'sideby')
27
+ return await realpath(dir);
28
+ }
29
+ catch {
30
+ // try the next layout
31
+ }
32
+ }
33
+ return realpath(join(here, '../..'));
34
+ }
35
+ const realDeps = {
36
+ fetch: (...args) => fetch(...args),
37
+ now: () => new Date(),
38
+ packageDir: ownPackageDir,
39
+ writable: (dir) => access(dir, constants.W_OK).then(() => true, () => false),
40
+ alive(pid) {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ }
45
+ catch (err) {
46
+ return err.code === 'EPERM';
47
+ }
48
+ },
49
+ exec: (file, args, opts) => new Promise((resolve) => {
50
+ let output = '';
51
+ const child = spawn(file, args, {
52
+ env: opts.env,
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ });
55
+ const take = (b) => {
56
+ const text = b.toString('utf8');
57
+ output += text;
58
+ opts.onOutput?.(text);
59
+ };
60
+ child.stdout.on('data', take);
61
+ child.stderr.on('data', take);
62
+ child.once('error', (err) => resolve({ code: 127, output: `${output}${err.message}\n` }));
63
+ child.once('close', (code) => resolve({ code: code ?? 1, output }));
64
+ }),
65
+ restartPanel: (env, port) => new Promise((resolve) => {
66
+ // By name, so the new code runs even where the install moved (pnpm keeps each version in its own folder). The
67
+ // new process replaces the old Panel itself: it stops a background Panel of another build before it starts.
68
+ const child = spawn('sideby', ['ui', '--background', '--no-open', '--port', String(port)], {
69
+ env: env,
70
+ detached: true,
71
+ stdio: 'ignore',
72
+ });
73
+ child.once('spawn', () => {
74
+ child.unref();
75
+ resolve(true);
76
+ });
77
+ child.once('error', () => resolve(false));
78
+ }),
79
+ };
80
+ let override = {};
81
+ /** Internal, for tests: replaces parts of the machine. Not part of the plugin or Portal contract. */
82
+ export function bindUpdateDeps(deps) {
83
+ const before = override;
84
+ override = { ...override, ...deps };
85
+ return () => {
86
+ override = before;
87
+ };
88
+ }
89
+ const deps = () => ({ ...realDeps, ...override });
90
+ // Build metadata is limited to what semver allows, so a version never carries a terminal escape into the notice.
91
+ const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
92
+ function parseVersion(v) {
93
+ const m = VERSION.exec(v.trim());
94
+ return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split('.') : [] } : null;
95
+ }
96
+ /** Semver precedence of two prerelease identifiers: numbers compare as numbers and sort before words. */
97
+ function comparePre(a, b) {
98
+ const na = /^\d+$/.test(a);
99
+ const nb = /^\d+$/.test(b);
100
+ if (na && nb)
101
+ return Math.sign(Number(a) - Number(b));
102
+ if (na !== nb)
103
+ return na ? -1 : 1;
104
+ return a < b ? -1 : a > b ? 1 : 0;
105
+ }
106
+ /** -1, 0 or 1 like a sort comparator; a prerelease sorts before its release. Null when either is not a version. */
107
+ export function compareVersions(a, b) {
108
+ const pa = parseVersion(a);
109
+ const pb = parseVersion(b);
110
+ if (!pa || !pb)
111
+ return null;
112
+ for (let i = 0; i < 3; i++)
113
+ if (pa.core[i] !== pb.core[i])
114
+ return pa.core[i] < pb.core[i] ? -1 : 1;
115
+ if (!pa.pre.length || !pb.pre.length)
116
+ return Math.sign(pb.pre.length - pa.pre.length);
117
+ for (let i = 0; i < Math.min(pa.pre.length, pb.pre.length); i++) {
118
+ const c = comparePre(pa.pre[i], pb.pre[i]);
119
+ if (c)
120
+ return c;
121
+ }
122
+ return Math.sign(pa.pre.length - pb.pre.length);
123
+ }
124
+ /** `latest` is newer than `current`. */
125
+ export const isNewer = (latest, current) => latest !== undefined && compareVersions(latest, current) === 1;
126
+ /**
127
+ * The jump keeps the major version (0.11 → 0.12, 1.2 → 1.9). Auto Update only takes these; 0.x → 1.0 or 1.x → 2.0
128
+ * only gets a notice.
129
+ */
130
+ export function sameLine(from, to) {
131
+ const a = parseVersion(from)?.core;
132
+ const b = parseVersion(to)?.core;
133
+ return Boolean(a && b && a[0] === b[0]);
134
+ }
135
+ // ---- switches
136
+ const set = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
137
+ /** True in CI, where a notice is noise and a check is a request nobody reads. */
138
+ export const inCi = (env) => set(env.CI) || set(env.CONTINUOUS_INTEGRATION) || set(env.BUILD_NUMBER);
139
+ /** Why Update Checks are off, or null when they are on (spec §3.23). */
140
+ export function checksOff(env, config) {
141
+ if (set(env.SIDEBY_NO_UPDATE_NOTIFIER))
142
+ return 'SIDEBY_NO_UPDATE_NOTIFIER is set';
143
+ if (set(env.NO_UPDATE_NOTIFIER))
144
+ return 'NO_UPDATE_NOTIFIER is set';
145
+ if (config.update?.check === false)
146
+ return 'config update.check is false';
147
+ if (inCi(env))
148
+ return 'running in CI';
149
+ return null;
150
+ }
151
+ // ---- registry
152
+ /** The registry checks and installs use: `SIDEBY_UPDATE_REGISTRY` (http or https only), else npm's. */
153
+ function registryOf(env) {
154
+ const base = (env.SIDEBY_UPDATE_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, '');
155
+ if (!/^https?:\/\//i.test(base))
156
+ throw new Error('SIDEBY_UPDATE_REGISTRY must be an http or https URL, such as https://registry.npmmirror.com');
157
+ return base;
158
+ }
159
+ /** The registry as messages show it: without a user name or password a mirror URL may carry. */
160
+ function shownRegistry(base) {
161
+ try {
162
+ const u = new URL(base);
163
+ u.username = '';
164
+ u.password = '';
165
+ return u.href.replace(/\/+$/, '');
166
+ }
167
+ catch {
168
+ return 'the registry in SIDEBY_UPDATE_REGISTRY';
169
+ }
170
+ }
171
+ // ---- cache
172
+ const cacheFile = (p) => join(p.stateDir, 'update.json');
173
+ const str = (v) => typeof v === 'string';
174
+ export async function readUpdateCache(paths) {
175
+ try {
176
+ const data = JSON.parse(await readFile(cacheFile(paths), 'utf8'));
177
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
178
+ return {};
179
+ const d = data;
180
+ const out = {};
181
+ if (str(d.checkedAt))
182
+ out.checkedAt = d.checkedAt;
183
+ if (str(d.triedAt))
184
+ out.triedAt = d.triedAt;
185
+ if (str(d.autoTriedAt))
186
+ out.autoTriedAt = d.autoTriedAt;
187
+ if (str(d.latest) && parseVersion(d.latest))
188
+ out.latest = d.latest;
189
+ const n = d.notified;
190
+ if (n && str(n.version) && str(n.at))
191
+ out.notified = { version: n.version, at: n.at };
192
+ const a = d.auto;
193
+ if (a && str(a.from) && str(a.to) && str(a.at) && typeof a.ok === 'boolean' && str(a.message))
194
+ out.auto = {
195
+ from: a.from,
196
+ to: a.to,
197
+ at: a.at,
198
+ ok: a.ok,
199
+ message: a.message,
200
+ ...(a.shown ? { shown: true } : {}),
201
+ };
202
+ return out;
203
+ }
204
+ catch {
205
+ return {};
206
+ }
207
+ }
208
+ /**
209
+ * Merges a change into the cache file. The file is read again first, and `change` may be a function of what is there
210
+ * now, so a writer that decides from an older read (the CLI marking an Auto Update shown) never overwrites a newer
211
+ * field another process wrote meanwhile.
212
+ */
213
+ export async function updateUpdateCache(paths, change) {
214
+ const cur = await readUpdateCache(paths);
215
+ const next = { ...cur, ...(typeof change === 'function' ? change(cur) : change) };
216
+ await mkdir(paths.stateDir, { recursive: true, mode: 0o700 });
217
+ await writeFileAtomic(cacheFile(paths), `${JSON.stringify(next, null, 2)}\n`, 0o600);
218
+ return next;
219
+ }
220
+ const age = (iso, now) => (iso ? now.getTime() - Date.parse(iso) : Number.NaN);
221
+ /** A check is due: no answer within a day, and no attempt within the hour (so being offline costs one try an hour). */
222
+ export function checkDue(cache, now) {
223
+ return !(age(cache.checkedAt, now) < CHECK_INTERVAL_MS) && !(age(cache.triedAt, now) < RETRY_INTERVAL_MS);
224
+ }
225
+ /**
226
+ * Auto Update is on and the cache names a newer version it has not installed: never tried, or a failed try a day
227
+ * ago. Lets the CLI start a background check even when a Panel already ran today's check.
228
+ */
229
+ export function autoPending(cache, current, config, now) {
230
+ if (config.update?.auto !== true || !isNewer(cache.latest, current))
231
+ return false;
232
+ // Waiting for a running session: look again within the hour, not after every command.
233
+ if (age(cache.autoTriedAt, now) < RETRY_INTERVAL_MS)
234
+ return false;
235
+ const a = cache.auto;
236
+ if (!a || a.to !== cache.latest)
237
+ return true;
238
+ return !a.ok && !(age(a.at, now) < AUTO_RETRY_MS);
239
+ }
240
+ // ---- the check
241
+ /** Asks the registry for the `latest` dist-tag. Throws with a message that names the registry and what to do. */
242
+ export async function fetchLatest(env) {
243
+ const base = registryOf(env);
244
+ const shown = shownRegistry(base);
245
+ let res;
246
+ try {
247
+ res = await deps().fetch(`${base}/-/package/sideby/dist-tags`, {
248
+ headers: { accept: 'application/json' },
249
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
250
+ });
251
+ }
252
+ catch (err) {
253
+ const why = err.name === 'TimeoutError' ? 'timed out' : err.message;
254
+ throw new Error(`could not reach ${shown} (${why}); check the connection, or set SIDEBY_UPDATE_REGISTRY to a mirror`);
255
+ }
256
+ if (!res.ok)
257
+ throw new Error(`${shown} answered ${res.status} for sideby's versions; try again later, or set SIDEBY_UPDATE_REGISTRY`);
258
+ const body = (await res.json().catch(() => null));
259
+ const latest = body?.latest;
260
+ if (typeof latest !== 'string' || !parseVersion(latest))
261
+ throw new Error(`${shown} did not name a latest sideby version; set SIDEBY_UPDATE_REGISTRY to the npm registry`);
262
+ return latest;
263
+ }
264
+ /** Runs one Update Check and records it, answered or not. Never throws. */
265
+ export async function refreshUpdateCheck(paths, env) {
266
+ const at = deps().now().toISOString();
267
+ try {
268
+ const latest = await fetchLatest(env);
269
+ return await updateUpdateCache(paths, { checkedAt: at, triedAt: at, latest });
270
+ }
271
+ catch {
272
+ return updateUpdateCache(paths, { triedAt: at }).catch(() => readUpdateCache(paths));
273
+ }
274
+ }
275
+ /** A lock older than this has outlived any install; its pid may since belong to another process. */
276
+ const LOCK_MAX_MS = 60 * 60 * 1000;
277
+ /** A lock file another process has just created and not yet written to. */
278
+ const LOCK_WRITE_GRACE_MS = 60 * 1000;
279
+ /**
280
+ * An exclusive lock file in the state directory holding `<pid> <ms since epoch>`. It is held while that pid runs and
281
+ * the lock is under an hour old, however long an install takes on a slow mirror; an empty or unreadable lock is held
282
+ * for a minute (its owner may still be writing it). Null when held: the second caller fails at once instead of
283
+ * waiting. Release removes the file only while it still names this process.
284
+ */
285
+ async function takeLock(paths, name) {
286
+ const file = join(paths.stateDir, name);
287
+ const d = deps();
288
+ const me = `${process.pid} ${d.now().getTime()}`;
289
+ await mkdir(paths.stateDir, { recursive: true, mode: 0o700 });
290
+ for (let attempt = 0; attempt < 2; attempt++) {
291
+ try {
292
+ const fh = await open(file, 'wx', 0o600);
293
+ await fh.writeFile(me);
294
+ await fh.close();
295
+ return async () => {
296
+ if ((await readFile(file, 'utf8').catch(() => '')) === me)
297
+ await rm(file, { force: true });
298
+ };
299
+ }
300
+ catch (err) {
301
+ if (err.code !== 'EEXIST')
302
+ return null;
303
+ const text = await readFile(file, 'utf8').catch(() => '');
304
+ const m = /^(\d+) (\d+)$/.exec(text);
305
+ const held = m
306
+ ? d.alive(Number(m[1])) && d.now().getTime() - Number(m[2]) < LOCK_MAX_MS
307
+ : d.now().getTime() - ((await stat(file).catch(() => null))?.mtimeMs ?? 0) < LOCK_WRITE_GRACE_MS;
308
+ if (held)
309
+ return null;
310
+ await rm(file, { force: true });
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+ // ---- the notice
316
+ /**
317
+ * The Update Notice line the CLI may print now, or null. Once per new version per day; `current` is the running
318
+ * version.
319
+ */
320
+ export function noticeLine(cache, current, now) {
321
+ if (!isNewer(cache.latest, current))
322
+ return null;
323
+ const n = cache.notified;
324
+ if (n && n.version === cache.latest && age(n.at, now) < NOTICE_INTERVAL_MS)
325
+ return null;
326
+ return `sideby ${cache.latest} is available (you have ${current}) · run \`sideby update\``;
327
+ }
328
+ /** What the CLI says once about the last Auto Update, or null. */
329
+ export function autoUpdateLine(cache) {
330
+ const a = cache.auto;
331
+ if (!a || a.shown)
332
+ return null;
333
+ return a.ok
334
+ ? `sideby updated itself from ${a.from} to ${a.to} (${RELEASE_NOTES(a.to)})`
335
+ : `sideby could not update itself to ${a.to}: ${a.message}`;
336
+ }
337
+ /**
338
+ * What the CLI does after a command (spec §3.23), decided from the cache: the lines to print (the Auto Update result,
339
+ * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
340
+ * writes `record` with `updateUpdateCache` and starts the check; nothing here touches the machine.
341
+ */
342
+ export function afterCommandPlan(cache, current, config, env, now) {
343
+ if (checksOff(env, config))
344
+ return { lines: [], record: null, startCheck: false };
345
+ const lines = [];
346
+ const auto = autoUpdateLine(cache);
347
+ const notice = noticeLine(cache, current, now);
348
+ if (auto)
349
+ lines.push(auto);
350
+ if (notice)
351
+ lines.push(notice);
352
+ const shownAuto = cache.auto;
353
+ const record = lines.length
354
+ ? (cur) => ({
355
+ // Mark only the Auto Update that was printed; a newer one written meanwhile stays to be said.
356
+ ...(auto && cur.auto && cur.auto.at === shownAuto?.at && cur.auto.to === shownAuto.to
357
+ ? { auto: { ...cur.auto, shown: true } }
358
+ : {}),
359
+ ...(notice ? { notified: { version: cache.latest, at: now.toISOString() } } : {}),
360
+ })
361
+ : null;
362
+ return { lines, record, startCheck: checkDue(cache, now) || autoPending(cache, current, config, now) };
363
+ }
364
+ // ---- Install Method
365
+ /** Where the running copy comes from (spec §3.23 table); reads only paths. */
366
+ export async function installMethod(env) {
367
+ const d = deps();
368
+ const packageDir = await d.packageDir();
369
+ const p = packageDir.split(sep).join('/');
370
+ const at = (kind, extra = {}) => ({
371
+ kind,
372
+ packageDir,
373
+ canUpdate: false,
374
+ ...extra,
375
+ });
376
+ if (p.includes('/_npx/'))
377
+ return at('npx', { reason: 'it runs from the npx cache, which fetches a version per call' });
378
+ // Volta keeps a global package under its own npm prefix; installing there behind its back breaks its records.
379
+ if (p.includes('/.volta/'))
380
+ return at('volta', { reason: 'Volta manages this install' });
381
+ const npm = /^(.*)\/lib\/node_modules\/sideby$/.exec(p);
382
+ if (npm) {
383
+ const prefix = npm[1];
384
+ const blocked = [];
385
+ for (const dir of [dirname(packageDir), join(prefix, 'bin')])
386
+ if (!(await d.writable(dir)))
387
+ blocked.push(dir);
388
+ return at('npm', {
389
+ prefix,
390
+ canUpdate: !blocked.length,
391
+ ...(blocked.length
392
+ ? { reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you` }
393
+ : {}),
394
+ });
395
+ }
396
+ const pnpmHome = env.PNPM_HOME?.split(sep).join('/');
397
+ if (p.includes('/pnpm/global/') || (pnpmHome && p.startsWith(`${pnpmHome.replace(/\/+$/, '')}/`)))
398
+ return at('pnpm', { canUpdate: true });
399
+ if (p.includes('/.bun/install/global/'))
400
+ return at('bun', { canUpdate: true });
401
+ if (p.includes('/node_modules/'))
402
+ return at('project', { reason: 'it is a dependency of a project' });
403
+ return at('source', { reason: 'it runs from a source checkout' });
404
+ }
405
+ /**
406
+ * The command an Update runs (or that the user runs) for `version`; `run` is set only when sideby may run it. With
407
+ * `SIDEBY_UPDATE_REGISTRY`, installs use that registry too, so the version the check found is the one fetched.
408
+ */
409
+ export function updateCommand(method, version, env = {}) {
410
+ const spec = `sideby@${version}`;
411
+ const reg = env.SIDEBY_UPDATE_REGISTRY ? ['--registry', registryOf(env)] : [];
412
+ const shownReg = reg.length ? ` --registry ${shownRegistry(registryOf(env))}` : '';
413
+ switch (method.kind) {
414
+ case 'npm':
415
+ return {
416
+ text: `npm install -g ${spec}${shownReg}`,
417
+ ...(method.canUpdate
418
+ ? { run: { file: 'npm', args: ['install', '-g', '--prefix', method.prefix, ...reg, spec] } }
419
+ : {}),
420
+ };
421
+ case 'pnpm':
422
+ return {
423
+ text: `pnpm add -g ${spec}${shownReg}`,
424
+ run: { file: 'pnpm', args: ['add', '-g', ...reg, spec] },
425
+ };
426
+ case 'bun':
427
+ return {
428
+ text: `bun add -g ${spec}${shownReg}`,
429
+ run: { file: 'bun', args: ['add', '-g', ...reg, spec] },
430
+ };
431
+ case 'volta':
432
+ return { text: `volta install ${spec}` };
433
+ case 'npx':
434
+ return { text: 'npx sideby@latest' };
435
+ case 'project':
436
+ return { text: `npm install ${spec}` };
437
+ case 'source':
438
+ return { text: 'git pull && pnpm install' };
439
+ }
440
+ }
441
+ // ---- running sessions
442
+ const runningDir = (p) => join(p.stateDir, 'running');
443
+ /**
444
+ * Records that this process runs a Host (`run`, `login`, `next`, `resume`) so an Auto Update waits for it; returns the
445
+ * function that removes the record. Failing to write the record never stops a Launch.
446
+ */
447
+ export async function markRunning(paths, pid = process.pid) {
448
+ const file = join(runningDir(paths), String(pid));
449
+ try {
450
+ await mkdir(runningDir(paths), { recursive: true, mode: 0o700 });
451
+ await writeFileAtomic(file, `${deps().now().toISOString()}\n`, 0o600);
452
+ }
453
+ catch {
454
+ return async () => { };
455
+ }
456
+ return () => rm(file, { force: true });
457
+ }
458
+ /** Pids of sideby processes that run a Host now; records of processes that ended are removed. */
459
+ export async function runningSessions(paths) {
460
+ let names;
461
+ try {
462
+ names = await readdir(runningDir(paths));
463
+ }
464
+ catch {
465
+ return [];
466
+ }
467
+ const out = [];
468
+ for (const name of names) {
469
+ const pid = Number(name);
470
+ if (!Number.isInteger(pid) || pid <= 0)
471
+ continue;
472
+ if (deps().alive(pid))
473
+ out.push(pid);
474
+ else
475
+ await rm(join(runningDir(paths), name), { force: true });
476
+ }
477
+ return out;
478
+ }
479
+ export function planFrom(method, current, latest, env = {}) {
480
+ const cmd = updateCommand(method, latest, env);
481
+ return {
482
+ current,
483
+ latest,
484
+ available: isNewer(latest, current),
485
+ method,
486
+ command: cmd.text,
487
+ canRun: Boolean(cmd.run),
488
+ releaseNotes: RELEASE_NOTES(latest),
489
+ };
490
+ }
491
+ /** The Install Method as outputs show it: no folder of this machine. */
492
+ export const shownMethod = (m) => ({
493
+ kind: m.kind,
494
+ canUpdate: m.canUpdate,
495
+ ...(m.reason ? { reason: m.reason } : {}),
496
+ });
497
+ /** The plan as `sideby update --json` and the Panel show it (schemas/update.json). */
498
+ export function shownPlan(plan) {
499
+ return {
500
+ current: plan.current,
501
+ latest: plan.latest,
502
+ available: plan.available,
503
+ method: shownMethod(plan.method),
504
+ command: plan.command,
505
+ canRun: plan.canRun,
506
+ releaseNotes: plan.releaseNotes,
507
+ };
508
+ }
509
+ /** Asks the registry now (whatever the switches say: the user asked) and returns what an Update would do. */
510
+ export async function planUpdate(paths, env) {
511
+ const latest = await fetchLatest(env);
512
+ const at = deps().now().toISOString();
513
+ await updateUpdateCache(paths, { checkedAt: at, triedAt: at, latest }).catch(() => { });
514
+ return planFrom(await installMethod(env), packageVersion(), latest, env);
515
+ }
516
+ /**
517
+ * Runs the Update the plan names, then starts a running background Panel again from the new code. The caller has
518
+ * shown the plan and got a confirmation (or this is an Auto Update). One Update runs at a time on this machine (the
519
+ * CLI, a Panel and an Auto Update share the lock). `panelPort` gives the port of the background Panel to restart, or
520
+ * null when none runs. A successful Update is recorded as said, so no notice for it follows.
521
+ */
522
+ export async function applyUpdate(paths, plan, env, opts = {}) {
523
+ const cmd = updateCommand(plan.method, plan.latest, env);
524
+ const fail = (message, extra = {}) => ({
525
+ ok: false,
526
+ output: '',
527
+ message,
528
+ restartedPanel: false,
529
+ ...extra,
530
+ });
531
+ if (!cmd.run)
532
+ return fail(`sideby cannot update itself: ${plan.method.reason ?? 'unknown install'}; run \`${cmd.text}\` yourself`);
533
+ const release = await takeLock(paths, 'update-install.lock').catch(() => null);
534
+ if (!release)
535
+ return fail('another sideby update is running; wait for it to finish, then run `sideby update` again', {
536
+ busy: true,
537
+ });
538
+ try {
539
+ const d = deps();
540
+ const r = await d.exec(cmd.run.file, cmd.run.args, {
541
+ env,
542
+ ...(opts.onOutput ? { onOutput: opts.onOutput } : {}),
543
+ });
544
+ if (r.code !== 0)
545
+ return fail(`\`${cmd.text}\` failed (exit ${r.code}); nothing else was changed. Run it yourself to see why${env.SIDEBY_UPDATE_REGISTRY ? ' (a mirror may not have this version yet)' : ''}`, { code: r.code, output: r.output });
546
+ await updateUpdateCache(paths, {
547
+ notified: { version: plan.latest, at: d.now().toISOString() },
548
+ }).catch(() => { });
549
+ const port = opts.panelPort ? await opts.panelPort().catch(() => null) : null;
550
+ const restarted = port !== null && (await d.restartPanel(env, port).catch(() => false));
551
+ return {
552
+ ok: true,
553
+ code: 0,
554
+ output: r.output,
555
+ message: `updated sideby from ${plan.current} to ${plan.latest}`,
556
+ restartedPanel: restarted,
557
+ };
558
+ }
559
+ finally {
560
+ await release().catch(() => { });
561
+ }
562
+ }
563
+ /** What keeps an Auto Update from installing `plan` until the user acts (an install it cannot update, a major version). */
564
+ export function lastingBlocker(plan) {
565
+ if (!plan.available)
566
+ return 'already up to date';
567
+ if (!plan.canRun)
568
+ return `${plan.method.reason ?? 'this install cannot update itself'}; run \`${plan.command}\` yourself`;
569
+ if (!sameLine(plan.current, plan.latest))
570
+ return `${plan.latest} is a new major version; update by hand with \`sideby update\``;
571
+ return null;
572
+ }
573
+ /**
574
+ * Whether an Auto Update may install `plan` now (spec §3.23), and if not, why. Running sessions are looked up
575
+ * last because that removes records of ended processes.
576
+ */
577
+ export async function autoUpdateBlocker(paths, plan) {
578
+ const lasting = lastingBlocker(plan);
579
+ if (lasting)
580
+ return lasting;
581
+ const running = await runningSessions(paths);
582
+ if (running.length)
583
+ return `a session is running (pid ${running.join(', ')})`;
584
+ return null;
585
+ }
586
+ /**
587
+ * Body of the detached `sideby update-check`: an Update Check when one is due, then an Auto Update when config
588
+ * `update.auto` is on and nothing blocks it. Only one runs at a time; it never throws.
589
+ */
590
+ export async function backgroundCheck(paths, env, config, panelPort) {
591
+ if (checksOff(env, config))
592
+ return;
593
+ const release = await takeLock(paths, 'update.lock').catch(() => null);
594
+ if (!release)
595
+ return;
596
+ try {
597
+ const now = deps().now();
598
+ let cache = await readUpdateCache(paths);
599
+ if (checkDue(cache, now))
600
+ cache = await refreshUpdateCheck(paths, env);
601
+ const current = packageVersion();
602
+ if (!autoPending(cache, current, config, now))
603
+ return;
604
+ const plan = planFrom(await installMethod(env), current, cache.latest, env);
605
+ await updateUpdateCache(paths, { autoTriedAt: now.toISOString() });
606
+ // A blocker only the user can lift is said once and looked at again a day later, like a failed install; a
607
+ // running session only delays it (the next look is within the hour).
608
+ const lasting = lastingBlocker(plan);
609
+ if (lasting) {
610
+ await updateUpdateCache(paths, {
611
+ auto: { from: current, to: plan.latest, at: now.toISOString(), ok: false, message: lasting },
612
+ });
613
+ return;
614
+ }
615
+ if (await autoUpdateBlocker(paths, plan))
616
+ return;
617
+ const r = await applyUpdate(paths, plan, env, { panelPort });
618
+ // A manual Update holding the lock is not a failed Auto Update; the next look comes within the hour.
619
+ if (r.busy)
620
+ return;
621
+ await updateUpdateCache(paths, {
622
+ auto: { from: current, to: plan.latest, at: deps().now().toISOString(), ok: r.ok, message: r.message },
623
+ });
624
+ }
625
+ catch {
626
+ // a background check is best effort
627
+ }
628
+ finally {
629
+ await release().catch(() => { });
630
+ }
631
+ }