@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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/hub/entry.mjs +6 -0
- package/src/install/install-legacy.mjs +739 -725
- package/src/install/npx-lifecycle.mjs +16 -2
- package/src/multimodal.mjs +0 -0
- package/src/release-in-use.mjs +110 -0
- package/src/runtime-identity.mjs +1 -1
|
@@ -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) && (
|
|
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
|
-
|
|
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;
|
package/src/multimodal.mjs
CHANGED
|
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
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -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.
|
|
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([
|