@ran-sh/dsh-crew 1.5.0 → 1.5.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.
@@ -42,6 +42,7 @@ import { homedir } from 'node:os';
42
42
  import * as realInstaller from './install.mjs';
43
43
  import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
44
44
  import { crewDshHome, crewProfileDir } from './install.mjs';
45
+ import { liveReleaseClaims } from '../release-in-use.mjs';
45
46
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
46
47
  import {
47
48
  ensureOfficialWebIntegration,
@@ -1085,9 +1086,15 @@ function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1085
1086
  const releasesDir = crewReleasesDir({ home });
1086
1087
  if (!existsSync(releasesDir)) return;
1087
1088
  const removed = [];
1089
+ // A running Hub keeps executing the release it started from and re-reads
1090
+ // several modules from disk on every request, so removing that release breaks
1091
+ // those routes with no recovery but a restart. Protect what live processes
1092
+ // claim, not just what the pointer names.
1093
+ const claimed = liveReleaseClaims({ home }).map((dir) => resolve(dir));
1094
+ const live = new Set([...(protect ? [resolve(protect)] : []), ...claimed]);
1088
1095
  const dirs = readdirSync(releasesDir)
1089
1096
  .map((name) => join(releasesDir, name))
1090
- .filter((dir) => (!pointer || dir !== pointer.path) && (!protect || dir !== protect));
1097
+ .filter((dir) => (!pointer || dir !== pointer.path) && !live.has(resolve(dir)));
1091
1098
  const incomplete = [];
1092
1099
  const complete = [];
1093
1100
  for (const dir of dirs) {
@@ -3434,7 +3441,14 @@ export async function runNpxCli({
3434
3441
  else if (command === 'releases') result = await actions.releases({ args, log });
3435
3442
  else if (command === 'rollback') result = await actions.rollback({ version: args?.[0], args, log });
3436
3443
  else result = await actions[command]({ log });
3437
- return result?.ok === false ? 1 : 0;
3444
+ // Surface why the command failed. Actions return a structured failure rather
3445
+ // than throwing, so without this the CLI exits 1 having printed nothing —
3446
+ // `dsh-crew rollback` with no version was a silent failure.
3447
+ if (result?.ok === false) {
3448
+ error(`dsh-crew ${command} failed: ${result.error ?? result.code ?? 'unknown error'}`);
3449
+ return 1;
3450
+ }
3451
+ return 0;
3438
3452
  } catch (err) {
3439
3453
  error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
3440
3454
  return 1;
Binary file
@@ -0,0 +1,110 @@
1
+ // Release liveness claims.
2
+ //
3
+ // A running Hub keeps executing the release it was started from, but it loads
4
+ // several modules lazily with a cache-busting query (`import('...?t=' + Date.now())`)
5
+ // so that a config edit is visible without a restart. Those lazy reads go to
6
+ // disk every time, which means deleting the release under a live process breaks
7
+ // it permanently: the routes that were already loaded keep working while
8
+ // everything behind a lazy import answers 500.
9
+ //
10
+ // Release retention keeps only the newest few, and it protected the *current*
11
+ // pointer — not the one a running process is actually executing. So a machine
12
+ // could be left with a Hub whose config routes were dead, and an update would
13
+ // not repair it because the damage is to the process, not the files.
14
+ //
15
+ // A process therefore records that it is using a release. Retention skips any
16
+ // release with a live claim. Liveness is decided by the pid, not by the file, so
17
+ // a process that dies without cleaning up cannot pin a release forever.
18
+
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export const IN_USE_DIRNAME = 'in-use';
25
+
26
+ export function releaseInUseDir({ home = homedir() } = {}) {
27
+ return join(home, '.config', 'dsh-crew', 'app', IN_USE_DIRNAME);
28
+ }
29
+
30
+ /** The release root that owns `moduleUrl`, or null when it is outside one. */
31
+ export function releaseRootFor(moduleUrl) {
32
+ let path;
33
+ try { path = fileURLToPath(moduleUrl); } catch { return null; }
34
+ // <release>/src/<...> or <release>/src/hub/entry.mjs → walk up to the release root.
35
+ let dir = dirname(path);
36
+ for (let depth = 0; depth < 6; depth++) {
37
+ if (existsSync(join(dir, 'package.json'))) {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
40
+ if (pkg?.name === '@ran-sh/dsh-crew') return resolve(dir);
41
+ } catch { /* not the payload manifest; keep walking */ }
42
+ }
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function isAlive(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0) return false;
52
+ try { process.kill(pid, 0); return true; }
53
+ catch (error) { return error?.code === 'EPERM'; }
54
+ }
55
+
56
+ /**
57
+ * Record that this process is running `releasePath`. Best effort: failing to
58
+ * claim must never stop the Hub from starting, because the cost of a missed
59
+ * claim is a possible retention of one extra release.
60
+ */
61
+ export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, home = homedir(), pid = process.pid, now = Date.now() } = {}) {
62
+ try {
63
+ const target = releasePath ?? releaseRootFor(moduleUrl);
64
+ if (!target) return null;
65
+ const dir = releaseInUseDir({ home });
66
+ mkdirSync(dir, { recursive: true });
67
+ const file = join(dir, `${pid}.json`);
68
+ writeFileSync(file, JSON.stringify({ pid, release: resolve(target), claimed_at: now }) + '\n');
69
+ return file;
70
+ } catch { return null; }
71
+ }
72
+
73
+ export function releaseClaimFile({ home = homedir(), pid = process.pid } = {}) {
74
+ return join(releaseInUseDir({ home }), `${pid}.json`);
75
+ }
76
+
77
+ export function releaseClaimInUse({ home = homedir(), pid = process.pid } = {}) {
78
+ const file = releaseClaimFile({ home, pid });
79
+ return existsSync(file) || false;
80
+ }
81
+
82
+ export function clearReleaseClaim({ home = homedir(), pid = process.pid } = {}) {
83
+ try { rmSync(releaseClaimFile({ home, pid }), { force: true }); return true; } catch { return false; }
84
+ }
85
+
86
+ /**
87
+ * Releases currently held by a live process. Dead claims are removed as they are
88
+ * encountered, so the directory cannot accumulate stale files.
89
+ */
90
+ export function liveReleaseClaims({ home = homedir(), alive = isAlive } = {}) {
91
+ const dir = releaseInUseDir({ home });
92
+ let names;
93
+ // A state directory that is unreadable — or replaced by a file — must read as
94
+ // "no claims" rather than throwing: this runs inside install, and failing it
95
+ // would block an update over a problem that only affects pruning.
96
+ try { names = readdirSync(dir); } catch { return []; }
97
+ const live = [];
98
+ for (const name of names) {
99
+ if (!name.endsWith('.json')) continue;
100
+ const file = join(dir, name);
101
+ let record;
102
+ try { record = JSON.parse(readFileSync(file, 'utf8')); } catch { record = null; }
103
+ if (record && Number.isInteger(record.pid) && typeof record.release === 'string' && alive(record.pid)) {
104
+ live.push(resolve(record.release));
105
+ continue;
106
+ }
107
+ try { rmSync(file, { force: true }); } catch { /* best effort */ }
108
+ }
109
+ return [...new Set(live)];
110
+ }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.5.0';
39
+ export const RUNTIME_VERSION = '1.5.1';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([