dsh-m 0.0.3 → 0.1.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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * 市场编排层:registry × profile × 最新版本 → 市场列表 / 已装列表 / outdated /
3
+ * 安装 / 卸载 / 升级。安装语义见 DESIGN.md §3(npm 精确锁定、GitHub 锁 SHA)。
4
+ */
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { addDshPlugin } from './dsh-cli.js';
8
+ import { dshHome, webProfileDir } from './env.js';
9
+ import { listInstalledPlugins, readProfileDeps, removeInstalledPlugin, } from './installed.js';
10
+ import { setLivePluginDisabled } from './live-plugin.js';
11
+ import { loadRegistry } from './registry.js';
12
+ import { githubLatestTag, isNewerVersion, npmLatest } from './versions.js';
13
+ function matchInstalledByEntry(entry, installed) {
14
+ return installed.find((it) => {
15
+ if (entry.npm && it.pkg === entry.npm)
16
+ return true;
17
+ if (entry.npm && it.name === entry.npm)
18
+ return true;
19
+ if (entry.github && it.source === 'github') {
20
+ const m = /^github:([^#]+)/.exec(it.spec);
21
+ if (m && m[1] === entry.github)
22
+ return true;
23
+ }
24
+ return false;
25
+ });
26
+ }
27
+ export async function listMarket(cfg = {}, opts = {}) {
28
+ const [loaded, installed] = await Promise.all([
29
+ loadRegistry(cfg, opts),
30
+ listInstalledPlugins(),
31
+ ]);
32
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
33
+ const items = [];
34
+ for (const entry of loaded.registry.plugins) {
35
+ const inst = matchInstalledByEntry(entry, installed.items);
36
+ const item = {
37
+ ...entry,
38
+ installed: Boolean(inst),
39
+ outdated: false,
40
+ };
41
+ if (inst) {
42
+ item.installedPkg = inst.pkg;
43
+ item.installedVersion = inst.version;
44
+ }
45
+ try {
46
+ if (entry.source === 'npm' && entry.npm) {
47
+ const latest = await npmLatest(entry.npm, timeoutMs);
48
+ item.latestVersion = latest.version;
49
+ if (inst?.version && isNewerVersion(latest.version, inst.version))
50
+ item.outdated = true;
51
+ }
52
+ else if (entry.github) {
53
+ const { tag, sha } = await githubLatestTag(entry.github, timeoutMs);
54
+ item.latestTag = tag;
55
+ item.latestSha = sha;
56
+ if (inst && !inst.spec.includes(sha))
57
+ item.outdated = true;
58
+ }
59
+ }
60
+ catch (err) {
61
+ item.latestError = err instanceof Error ? err.message : String(err);
62
+ }
63
+ items.push(item);
64
+ }
65
+ return {
66
+ items,
67
+ registrySource: loaded.source,
68
+ registryFetchedAt: loaded.fetchedAt,
69
+ registryErrors: loaded.errors,
70
+ };
71
+ }
72
+ export async function listInstalledWithMeta(cfg = {}) {
73
+ const [{ items: installed, others, profileDir }, loaded] = await Promise.all([
74
+ listInstalledPlugins(),
75
+ loadRegistry(cfg),
76
+ ]);
77
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
78
+ const items = [];
79
+ for (const it of installed) {
80
+ const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [it]));
81
+ const item = { ...it, outdated: false };
82
+ if (entry) {
83
+ item.registryId = entry.id;
84
+ item.registryGithub = entry.github ?? null;
85
+ item.registryIcon = entry.icon ?? null;
86
+ }
87
+ try {
88
+ if (!entry && it.source === 'npm') {
89
+ const latest = await npmLatest(it.pkg, timeoutMs);
90
+ item.latestVersion = latest.version;
91
+ item.outdated = isNewerVersion(latest.version, it.version);
92
+ }
93
+ else if (entry?.source === 'npm' && entry.npm) {
94
+ const latest = await npmLatest(entry.npm, timeoutMs);
95
+ item.latestVersion = latest.version;
96
+ item.outdated = isNewerVersion(latest.version, it.version);
97
+ }
98
+ else if (it.source === 'github') {
99
+ const m = /^github:([^#]+)/.exec(it.spec);
100
+ if (m) {
101
+ const { tag, sha } = await githubLatestTag(m[1], timeoutMs);
102
+ item.latestTag = tag;
103
+ item.outdated = !it.spec.includes(sha);
104
+ }
105
+ }
106
+ }
107
+ catch {
108
+ /* 查询失败不阻塞列表,outdated 维持 false */
109
+ }
110
+ items.push(item);
111
+ }
112
+ return { items, others, profileDir };
113
+ }
114
+ /** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
115
+ export async function installFromRegistry(id, cfg = {}, opts = {}) {
116
+ const { registry } = await loadRegistry(cfg);
117
+ const entry = registry.plugins.find((e) => e.id === id);
118
+ if (!entry)
119
+ throw new Error(`registry 中没有该条目: ${id}`);
120
+ return installEntry(entry, cfg, opts);
121
+ }
122
+ export async function installEntry(entry, cfg = {}, opts = {}) {
123
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
124
+ if (entry.source === 'npm' && entry.npm) {
125
+ const version = opts.version && /^\d+\.\d+\.\d+/.test(opts.version) ? opts.version : (await npmLatest(entry.npm, timeoutMs)).version;
126
+ const spec = `${entry.npm}@${version}`;
127
+ const res = await addDshPlugin(spec);
128
+ // 安装后校验:落盘版本必须与意图一致(tarball 完整性由 pnpm 按 lock integrity 保证)
129
+ const deps = await readProfileDeps(webProfileDir());
130
+ const specInProfile = deps[entry.npm];
131
+ if (specInProfile === undefined)
132
+ throw new Error(`安装后未在 profile 依赖中找到 ${entry.npm}`);
133
+ return {
134
+ id: entry.id,
135
+ pkg: entry.npm,
136
+ spec,
137
+ version,
138
+ usedAllowAllBuilds: res.usedAllowAllBuilds,
139
+ needsRestart: true,
140
+ output: res.output.slice(-800),
141
+ };
142
+ }
143
+ if (entry.github) {
144
+ const { tag, sha } = await githubLatestTag(entry.github, timeoutMs);
145
+ const spec = `github:${entry.github}#${sha}`;
146
+ const res = await addDshPlugin(spec);
147
+ const deps = await readProfileDeps(webProfileDir());
148
+ const pkgKey = Object.keys(deps).find((k) => deps[k] === spec || deps[k].startsWith(`github:${entry.github}#`));
149
+ if (!pkgKey)
150
+ throw new Error(`安装后未在 profile 依赖中找到 ${entry.github}`);
151
+ return {
152
+ id: entry.id,
153
+ pkg: pkgKey,
154
+ spec,
155
+ sha,
156
+ usedAllowAllBuilds: res.usedAllowAllBuilds,
157
+ needsRestart: true,
158
+ output: res.output.slice(-800),
159
+ };
160
+ }
161
+ throw new Error(`条目 ${entry.id} 缺少可安装来源`);
162
+ }
163
+ /** 卸载:live-disable → pnpm remove → 报告疑似残留(DESIGN.md §3:删包不删数据)。 */
164
+ export async function uninstallPlugin(pkg, _cfg = {}) {
165
+ const liveDisabled = await setLivePluginDisabled(pkg, true);
166
+ await removeInstalledPlugin(pkg);
167
+ return { pkg, liveDisabled, needsRestart: true, leftovers: leftoverCandidates(pkg) };
168
+ }
169
+ /** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
170
+ export async function upgradePlugin(pkg, cfg = {}) {
171
+ const { registry } = await loadRegistry(cfg);
172
+ const { items: installed } = await listInstalledPlugins();
173
+ const target = installed.find((it) => it.pkg === pkg);
174
+ if (!target)
175
+ throw new Error(`web profile 未安装该插件: ${pkg}`);
176
+ const entry = registry.plugins.find((e) => matchInstalledByEntry(e, [target]));
177
+ if (!entry)
178
+ throw new Error(`「${pkg}」不是经 dsh-m 收录的插件;直接升级请用 dsh plugin update 或先在 registry 收录它`);
179
+ const result = await installEntry(entry, cfg);
180
+ return { ...result, fromVersion: target.version };
181
+ }
182
+ /** 变更互斥:安装/卸载/升级串行执行(skillhub install-lock 同款思路)。 */
183
+ let mutationTail = Promise.resolve();
184
+ export function withMutationLock(task) {
185
+ const next = mutationTail.then(task, task);
186
+ mutationTail = next.catch(() => undefined);
187
+ return next;
188
+ }
189
+ /** 疑似残留路径(存在才列出):删包不删数据,只报告。 */
190
+ export function leftoverCandidates(pkg) {
191
+ const home = dshHome();
192
+ const candidates = [
193
+ join(home, `${pkg}.json`),
194
+ join(home, pkg),
195
+ join(home, `${pkg.replace(/^@[^/]+\//, '')}.json`),
196
+ ];
197
+ return candidates.filter((p) => existsSync(p));
198
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * pnpm `--reporter=ndjson` 进度解析器。移植自 skillhub ndjson.ts(零依赖)。
3
+ * stdout 每行一个 JSON 对象;人类可读的回退行由 spawn 层处理。
4
+ */
5
+ export function emptyProgress() {
6
+ return {
7
+ phase: null,
8
+ done: 0,
9
+ total: null,
10
+ currentPackage: null,
11
+ downloaded: null,
12
+ size: null,
13
+ seen: false,
14
+ error: null,
15
+ };
16
+ }
17
+ export function createProgressTracker() {
18
+ const snap = emptyProgress();
19
+ const seenPackages = new Set();
20
+ function dedupe(packageId) {
21
+ if (typeof packageId !== 'string' || packageId === '')
22
+ return;
23
+ if (!seenPackages.has(packageId)) {
24
+ seenPackages.add(packageId);
25
+ snap.done += 1;
26
+ }
27
+ }
28
+ function feed(line) {
29
+ let event;
30
+ try {
31
+ event = JSON.parse(line);
32
+ }
33
+ catch {
34
+ return;
35
+ }
36
+ if (typeof event !== 'object' || event === null)
37
+ return;
38
+ const msg = event;
39
+ const name = msg.name;
40
+ if (typeof name !== 'string')
41
+ return;
42
+ if (name === 'pnpm:stage') {
43
+ const stage = msg.stage;
44
+ if (stage === 'resolution_started')
45
+ snap.phase = 'resolving';
46
+ else if (stage === 'resolution_done')
47
+ snap.phase = 'downloading';
48
+ else if (stage === 'importing_started' || stage === 'importing_done')
49
+ snap.phase = 'linking';
50
+ snap.seen = true;
51
+ return;
52
+ }
53
+ if (name === 'pnpm:progress') {
54
+ snap.seen = true;
55
+ const status = msg.status;
56
+ if (status === 'resolved') {
57
+ if (snap.phase === null)
58
+ snap.phase = 'resolving';
59
+ dedupe(msg.packageId);
60
+ }
61
+ else if (status === 'fetched' || status === 'found_in_store') {
62
+ snap.phase = 'downloading';
63
+ snap.currentPackage = typeof msg.packageId === 'string' ? msg.packageId : snap.currentPackage;
64
+ dedupe(msg.packageId);
65
+ }
66
+ return;
67
+ }
68
+ if (name === 'pnpm:fetching-progress') {
69
+ snap.seen = true;
70
+ snap.phase = 'downloading';
71
+ if (typeof msg.packageId === 'string')
72
+ snap.currentPackage = msg.packageId;
73
+ if (typeof msg.size === 'number')
74
+ snap.size = msg.size;
75
+ if (typeof msg.downloaded === 'number')
76
+ snap.downloaded = msg.downloaded;
77
+ dedupe(msg.packageId);
78
+ return;
79
+ }
80
+ if (name === 'pnpm:lifecycle') {
81
+ snap.seen = true;
82
+ snap.phase = 'building';
83
+ const wd = typeof msg.wd === 'string' ? msg.wd : '';
84
+ const dep = typeof msg.depPath === 'string' ? msg.depPath : '';
85
+ const base = wd.split(/[\\/]/).filter(Boolean).pop();
86
+ snap.currentPackage = base ?? (dep !== '' ? dep : snap.currentPackage);
87
+ return;
88
+ }
89
+ if (name === 'pnpm:stats') {
90
+ if (msg.added !== undefined || msg.removed !== undefined)
91
+ snap.phase = 'linking';
92
+ snap.seen = true;
93
+ return;
94
+ }
95
+ if (name === 'pnpm' && msg.level === 'error') {
96
+ const err = (msg.err ?? {});
97
+ const message = typeof err.message === 'string' ? err.message : '';
98
+ if (message !== '')
99
+ snap.error = message.slice(0, 400);
100
+ }
101
+ }
102
+ function reset() {
103
+ seenPackages.clear();
104
+ Object.assign(snap, emptyProgress());
105
+ }
106
+ return {
107
+ get snapshot() {
108
+ return { ...snap };
109
+ },
110
+ feed,
111
+ reset,
112
+ };
113
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * registry(DESIGN.md §2):repo 内手工 curated 的 registry.json。
3
+ * 分发顺序:源覆盖 → jsDelivr @main → raw @main → TTL 缓存 → 包内快照兜底。
4
+ */
5
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { createRequire } from 'node:module';
7
+ import { join } from 'node:path';
8
+ import { cacheDir } from './env.js';
9
+ import { fetchJsonLimited } from './httpx.js';
10
+ export const CATEGORIES = ['market', 'tools', 'ui', 'search', 'media', 'other'];
11
+ const REPO = 'iasiv5/dsh-m';
12
+ // raw 优先:jsDelivr 对 @main 有 CDN 缓存(可 stale 数小时),raw 始终反映 main 最新内容;
13
+ // jsDelivr 降为备用线路(可通过 purge.jsdelivr.net 手动清缓存)。
14
+ const DEFAULT_URLS = [
15
+ { source: 'raw', url: `https://raw.githubusercontent.com/${REPO}/main/registry.json` },
16
+ { source: 'jsdelivr', url: `https://cdn.jsdelivr.net/gh/${REPO}@main/registry.json` },
17
+ ];
18
+ // ---------- 校验 ----------
19
+ const GITHUB_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
20
+ const HTTPS_URL_RE = /^https:\/\/\S+$/i;
21
+ export function validateRegistry(raw) {
22
+ const errors = [];
23
+ if (!raw || typeof raw !== 'object')
24
+ return { ok: false, errors: ['registry 根必须是对象'], registry: null };
25
+ const obj = raw;
26
+ if (obj.version !== 1)
27
+ errors.push('version 必须为 1');
28
+ if (!Array.isArray(obj.plugins))
29
+ return { ok: false, errors: [...errors, 'plugins 必须是数组'], registry: null };
30
+ const plugins = [];
31
+ const seen = new Set();
32
+ obj.plugins.forEach((item, index) => {
33
+ const where = `plugins[${index}]`;
34
+ if (!item || typeof item !== 'object') {
35
+ errors.push(`${where}: 必须是对象`);
36
+ return;
37
+ }
38
+ const e = item;
39
+ const id = String(e.id || '').trim();
40
+ if (!id || id.length > 64)
41
+ return errors.push(`${where}.id 无效`);
42
+ if (seen.has(id))
43
+ return errors.push(`${where}.id 重复: ${id}`);
44
+ seen.add(id);
45
+ const category = String(e.category || '');
46
+ if (!CATEGORIES.includes(category))
47
+ return errors.push(`${where}.category 无效: ${category}`);
48
+ const source = String(e.source || '');
49
+ if (source !== 'npm' && source !== 'github')
50
+ return errors.push(`${where}.source 无效: ${source}`);
51
+ const entry = {
52
+ id,
53
+ name: String(e.name || id).trim().slice(0, 100),
54
+ description: String(e.description || '').trim().slice(0, 500),
55
+ category: category,
56
+ tags: Array.isArray(e.tags) ? e.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 10) : [],
57
+ source,
58
+ };
59
+ if (source === 'npm') {
60
+ const npm = String(e.npm || '').trim();
61
+ if (!npm)
62
+ return errors.push(`${where}.npm 必填(source=npm)`);
63
+ entry.npm = npm;
64
+ }
65
+ if (source === 'github') {
66
+ const gh = String(e.github || '').trim();
67
+ if (!GITHUB_RE.test(gh))
68
+ return errors.push(`${where}.github 必须是 owner/repo(source=github)`);
69
+ entry.github = gh;
70
+ }
71
+ if (e.npm && source !== 'npm')
72
+ entry.npm = String(e.npm).trim();
73
+ if (e.github && source !== 'github' && GITHUB_RE.test(String(e.github).trim()))
74
+ entry.github = String(e.github).trim();
75
+ for (const key of ['homepage', 'icon']) {
76
+ const v = e[key];
77
+ if (v === undefined || v === null || v === '')
78
+ continue;
79
+ const s = String(v).trim();
80
+ if (!HTTPS_URL_RE.test(s))
81
+ return errors.push(`${where}.${key} 必须是 https URL`);
82
+ entry[key] = s;
83
+ }
84
+ plugins.push(entry);
85
+ });
86
+ return { ok: errors.length === 0, errors, registry: errors.length === 0 ? { version: 1, plugins } : null };
87
+ }
88
+ // ---------- 包内快照兜底 ----------
89
+ function bundledSnapshot() {
90
+ const require = createRequire(import.meta.url);
91
+ for (const rel of ['../registry.json', '../../registry.json']) {
92
+ try {
93
+ const raw = require(rel);
94
+ const parsed = validateRegistry(raw);
95
+ if (parsed.registry)
96
+ return parsed.registry;
97
+ }
98
+ catch {
99
+ /* try next */
100
+ }
101
+ }
102
+ return { version: 1, plugins: [] };
103
+ }
104
+ function cachePath() {
105
+ return join(cacheDir(), 'registry.json');
106
+ }
107
+ function readCache() {
108
+ try {
109
+ const raw = JSON.parse(readFileSync(cachePath(), 'utf8'));
110
+ if (!raw || typeof raw !== 'object' || !raw.registry || !Array.isArray(raw.registry.plugins))
111
+ return null;
112
+ return raw;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function writeCache(file) {
119
+ try {
120
+ mkdirSync(cacheDir(), { recursive: true });
121
+ writeFileSync(cachePath(), JSON.stringify(file, null, 2));
122
+ }
123
+ catch {
124
+ /* 缓存写失败不致命 */
125
+ }
126
+ }
127
+ function cacheFresh(file, ttlMin) {
128
+ const t = Date.parse(file.fetchedAt || '');
129
+ if (!Number.isFinite(t))
130
+ return false;
131
+ return Date.now() - t < ttlMin * 60_000;
132
+ }
133
+ // ---------- 加载 ----------
134
+ export async function loadRegistry(cfg = {}, opts = {}) {
135
+ const errors = [];
136
+ const timeoutMs = cfg.timeoutMs ?? 20_000;
137
+ const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
138
+ const cached = readCache();
139
+ if (!opts.force && cached && cacheFresh(cached, ttlMin)) {
140
+ return { registry: cached.registry, source: 'cache', fetchedAt: cached.fetchedAt, errors };
141
+ }
142
+ const candidates = [];
143
+ if (cfg.registryUrl && cfg.registryUrl.trim()) {
144
+ candidates.push({ source: 'override', url: cfg.registryUrl.trim() });
145
+ }
146
+ candidates.push(...DEFAULT_URLS);
147
+ for (const candidate of candidates) {
148
+ try {
149
+ const raw = await fetchJsonLimited(candidate.url, { timeoutMs });
150
+ const parsed = validateRegistry(raw);
151
+ if (!parsed.ok || !parsed.registry) {
152
+ errors.push(`${candidate.source}: registry 校验失败 — ${parsed.errors.slice(0, 3).join('; ')}`);
153
+ continue;
154
+ }
155
+ const fetchedAt = new Date().toISOString();
156
+ writeCache({ fetchedAt, source: candidate.source, registry: parsed.registry });
157
+ return { registry: parsed.registry, source: candidate.source, fetchedAt, errors };
158
+ }
159
+ catch (err) {
160
+ const he = err;
161
+ errors.push(`${candidate.source}: ${he?.status ? `HTTP ${he.status}` : err instanceof Error ? err.message : String(err)}`);
162
+ }
163
+ }
164
+ if (cached)
165
+ return { registry: cached.registry, source: 'cache', fetchedAt: cached.fetchedAt, errors };
166
+ return { registry: bundledSnapshot(), source: 'bundled', fetchedAt: null, errors };
167
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * 自重启:移植自 skillhub restart.ts(tag→OIDC 同源的 dsh-web shim 环境已验证)。
3
+ * 优先 systemd 单元重启(本机 = deepseek-harness.service,dsh-web.service 为转发 shim);
4
+ * 无 systemd 时退回 detached helper 等端口释放后换身重拉。
5
+ * 安全:restart 端点必须通过 trustedRestartRequest(Origin 与 Host 同源)。
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { dshArgv, nodeExecutable } from './dsh-cli.js';
12
+ function headerString(raw) {
13
+ if (raw === undefined)
14
+ return undefined;
15
+ const value = Array.isArray(raw) ? raw[0] : raw;
16
+ const trimmed = String(value || '').trim();
17
+ return trimmed === '' ? undefined : trimmed.split(',')[0].trim();
18
+ }
19
+ function parseHost(raw) {
20
+ try {
21
+ const parsed = new URL(raw.includes('://') ? raw : `http://${raw}`);
22
+ return { hostname: parsed.hostname.toLowerCase(), port: parsed.port };
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function isLoopbackHost(host) {
29
+ const parsed = parseHost(host);
30
+ if (parsed === null)
31
+ return false;
32
+ return parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1';
33
+ }
34
+ function hostsMatch(originHost, candidate) {
35
+ const a = parseHost(originHost);
36
+ const b = parseHost(candidate);
37
+ if (a === null || b === null)
38
+ return false;
39
+ if (a.hostname !== b.hostname)
40
+ return false;
41
+ if (a.port !== '' && b.port !== '' && a.port !== b.port)
42
+ return false;
43
+ return true;
44
+ }
45
+ export function servingPort(request) {
46
+ const host = headerString(request.headers.host);
47
+ if (host === undefined || !isLoopbackHost(host))
48
+ return null;
49
+ const match = /:(\d{1,5})$/u.exec(host);
50
+ if (match === null)
51
+ return null;
52
+ const port = Number(match[1]);
53
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
54
+ }
55
+ export function trustedRestartRequest(request) {
56
+ const origin = headerString(request.headers.origin);
57
+ if (origin === undefined)
58
+ return false;
59
+ let from;
60
+ try {
61
+ const parsed = new URL(origin);
62
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
63
+ return false;
64
+ from = parsed.host;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ const host = headerString(request.headers.host);
70
+ const forwardedHost = headerString(request.headers['x-forwarded-host']);
71
+ const candidates = [host, forwardedHost].filter((value) => value !== undefined);
72
+ return candidates.some((candidate) => hostsMatch(from, candidate));
73
+ }
74
+ export function readProcCgroup(readFile = (path, encoding) => readFileSync(path, encoding)) {
75
+ try {
76
+ return readFile('/proc/self/cgroup', 'utf8');
77
+ }
78
+ catch {
79
+ return '';
80
+ }
81
+ }
82
+ export function systemdUnitName(cgroupText) {
83
+ for (const line of cgroupText.split(/\r?\n/u)) {
84
+ if (line.trim() === '')
85
+ continue;
86
+ const path = line.includes(':') ? line.slice(line.lastIndexOf(':') + 1) : line;
87
+ const parts = path.split('/').filter(Boolean);
88
+ for (let i = parts.length - 1; i >= 0; i--) {
89
+ const part = parts[i];
90
+ if (!part.endsWith('.service'))
91
+ continue;
92
+ if (/^user@\d+\.service$/u.test(part))
93
+ continue;
94
+ return part;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ export function systemdRestartArgv(opts) {
100
+ const unit = systemdUnitName(opts.cgroup);
101
+ if (unit === null)
102
+ return null;
103
+ if (opts.cgroup.includes('/user.slice/')) {
104
+ return { file: 'systemctl', args: ['--user', 'restart', '--no-block', unit] };
105
+ }
106
+ if (opts.uid === 0) {
107
+ return { file: 'systemctl', args: ['restart', '--no-block', unit] };
108
+ }
109
+ return { file: 'sudo', args: ['-n', 'systemctl', 'restart', '--no-block', unit] };
110
+ }
111
+ export function restartLaunch() {
112
+ const launch = dshArgv();
113
+ return {
114
+ ...launch,
115
+ args: [...launch.args, ...process.argv.slice(2)],
116
+ cwd: launch.cwd ?? process.cwd(),
117
+ };
118
+ }
119
+ function respawnInvocation(launch, platform = process.platform) {
120
+ if (platform !== 'win32') {
121
+ return { file: launch.file, args: launch.args, viaShell: launch.viaShell, detached: true };
122
+ }
123
+ const quote = (part) => `'${part.replace(/'/g, "''")}'`;
124
+ return {
125
+ file: 'powershell.exe',
126
+ args: ['-NoProfile', '-WindowStyle', 'Hidden', '-Command',
127
+ [`& ${quote(launch.file)}`, ...launch.args.map(quote)].join(' ')],
128
+ viaShell: false,
129
+ detached: false,
130
+ };
131
+ }
132
+ function restartHelperSource(spawned, launch, logs, port) {
133
+ return [
134
+ "const { spawn } = require('node:child_process')",
135
+ "const fs = require('node:fs')",
136
+ "const net = require('node:net')",
137
+ `const file = ${JSON.stringify(spawned.file)}`,
138
+ `const args = ${JSON.stringify(spawned.args)}`,
139
+ `const cwd = ${JSON.stringify(launch.cwd)}`,
140
+ `const viaShell = ${JSON.stringify(spawned.viaShell)}`,
141
+ `const detached = ${JSON.stringify(spawned.detached)}`,
142
+ `const logOut = ${JSON.stringify(logs.out)}`,
143
+ `const logErr = ${JSON.stringify(logs.err)}`,
144
+ `const port = ${JSON.stringify(port)}`,
145
+ 'const sleep = (ms) => new Promise(r => setTimeout(r, ms))',
146
+ "const note = (line) => { try { fs.appendFileSync(logErr, `[dsh-m] ${line}\\n`) } catch {} }",
147
+ 'const listening = () => new Promise((resolve) => {',
148
+ ' const probe = net.connect({ host: "127.0.0.1", port })',
149
+ ' const done = (value) => { probe.destroy(); resolve(value) }',
150
+ ' probe.on("connect", () => done(true))',
151
+ ' probe.on("error", () => done(false))',
152
+ ' setTimeout(() => done(false), 500)',
153
+ '})',
154
+ 'const main = async () => {',
155
+ ' if (port) {',
156
+ ' const until = Date.now() + 30000',
157
+ ' while (Date.now() < until && await listening()) await sleep(250)',
158
+ ' if (await listening()) note(`port ${port} was still in use after 30s; starting anyway`)',
159
+ ' await sleep(300)',
160
+ ' } else {',
161
+ ' await sleep(1500)',
162
+ ' }',
163
+ " let child",
164
+ ' try {',
165
+ ' const out = fs.openSync(logOut, "a")',
166
+ ' const err = fs.openSync(logErr, "a")',
167
+ ' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
168
+ ' child.on("error", (error) => note(`could not start the replacement: ${error && error.message ? error.message : error}`))',
169
+ ' child.unref()',
170
+ ' } catch (error) {',
171
+ ' note(`could not start the replacement: ${error && error.message ? error.message : error}`)',
172
+ ' return',
173
+ ' }',
174
+ ' if (!port) { await sleep(3000); return }',
175
+ ' const upBy = Date.now() + 20000',
176
+ ' while (Date.now() < upBy && !(await listening())) await sleep(500)',
177
+ ' if (!(await listening())) note(`the replacement did not bind port ${port} within 20s — see the output log beside this one`)',
178
+ '}',
179
+ 'main()',
180
+ ].join('\n');
181
+ }
182
+ export function scheduleRestart(port = null, deps = {}) {
183
+ const pid = deps.pid ?? process.pid;
184
+ const systemd = systemdRestartArgv({
185
+ cgroup: deps.cgroup ?? readProcCgroup(),
186
+ uid: deps.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 1),
187
+ });
188
+ if (systemd !== null) {
189
+ ;
190
+ (deps.setTimeout ?? setTimeout)(() => {
191
+ const helper = (deps.spawn ?? spawn)(systemd.file, systemd.args, {
192
+ detached: true,
193
+ stdio: 'ignore',
194
+ env: process.env,
195
+ });
196
+ helper.unref();
197
+ }, 500);
198
+ return { pid, helperPid: undefined, via: 'systemd' };
199
+ }
200
+ const launch = (deps.restartLaunch ?? restartLaunch)();
201
+ const spawned = respawnInvocation(launch);
202
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
203
+ const logOut = join(tmpdir(), `dshm-restart-${stamp}.out.log`);
204
+ const logErr = join(tmpdir(), `dshm-restart-${stamp}.err.log`);
205
+ const helper = (deps.spawn ?? spawn)((deps.nodeExecutable ?? nodeExecutable)(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
206
+ detached: true,
207
+ stdio: 'ignore',
208
+ env: process.env,
209
+ });
210
+ helper.unref();
211
+ (deps.setTimeout ?? setTimeout)(() => (deps.kill ?? process.kill)(pid, 'SIGTERM'), 500);
212
+ return { pid, helperPid: helper.pid, via: 'helper' };
213
+ }