@adhdev/daemon-core 0.7.43 → 0.7.44

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.7.43",
3
+ "version": "0.7.44",
4
4
  "description": "ADHDev local session host core — session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.7.43",
3
+ "version": "0.7.44",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,6 +26,7 @@ import { logCommand } from '../logging/command-log.js';
26
26
  import { getRecentLogs, LOG_PATH } from '../logging/logger.js';
27
27
  import { buildSessionEntries } from '../status/builders.js';
28
28
  import { getSessionCompletionMarker } from '../status/snapshot.js';
29
+ import { spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
29
30
  import * as fs from 'fs';
30
31
 
31
32
  // ─── Types ───
@@ -315,36 +316,41 @@ export class DaemonCommandRouter {
315
316
  // Check latest version
316
317
  const latest = execSync(`npm view ${pkgName} version`, { encoding: 'utf-8', timeout: 10000 }).trim();
317
318
  LOG.info('Upgrade', `Latest ${pkgName}: v${latest}`);
319
+ let currentInstalled: string | null = null;
320
+ try {
321
+ const currentJson = execSync(`npm ls -g ${pkgName} --depth=0 --json`, {
322
+ encoding: 'utf-8',
323
+ timeout: 10000,
324
+ stdio: ['pipe', 'pipe', 'pipe'],
325
+ }).trim();
326
+ const parsed = JSON.parse(currentJson);
327
+ currentInstalled = parsed?.dependencies?.[pkgName]?.version || null;
328
+ } catch {
329
+ // ignore ls failures; upgrade can still proceed
330
+ }
318
331
 
319
- // Install latest (--force ensures native addons are rebuilt cleanly)
320
- execSync(`npm install -g ${pkgName}@latest --force`, {
321
- encoding: 'utf-8',
322
- timeout: 120000,
323
- stdio: ['pipe', 'pipe', 'pipe'],
332
+ if (currentInstalled === latest) {
333
+ LOG.info('Upgrade', `Already on latest version v${latest}; skipping install`);
334
+ return { success: true, upgraded: false, alreadyLatest: true, version: latest };
335
+ }
336
+
337
+ spawnDetachedDaemonUpgradeHelper({
338
+ packageName: pkgName,
339
+ targetVersion: latest,
340
+ parentPid: process.pid,
341
+ restartArgv: process.argv.slice(1),
342
+ cwd: process.cwd(),
343
+ sessionHostAppName: process.env.ADHDEV_SESSION_HOST_NAME || 'adhdev',
324
344
  });
325
- LOG.info('Upgrade', `✅ Upgraded to v${latest}`);
345
+ LOG.info('Upgrade', `Scheduled detached upgrade to v${latest}`);
326
346
 
327
- // Schedule restart after response is sent
347
+ // Exit after the command response has been sent so the helper can replace the package cleanly.
328
348
  setTimeout(() => {
329
- LOG.info('Upgrade', 'Restarting daemon with new version...');
330
- // Remove PID file so the new process doesn't see 'already running'
331
- try {
332
- const path = require('path');
333
- const fs = require('fs');
334
- const pidFile = path.join(process.env.HOME || process.env.USERPROFILE || '', '.adhdev', 'daemon.pid');
335
- if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
336
- } catch { /* ignore */ }
337
- const { spawn } = require('child_process');
338
- const child = spawn(process.execPath, process.argv.slice(1), {
339
- detached: true,
340
- stdio: 'ignore',
341
- env: { ...process.env },
342
- });
343
- child.unref();
349
+ LOG.info('Upgrade', 'Exiting daemon so detached upgrader can continue...');
344
350
  process.exit(0);
345
351
  }, 3000);
346
352
 
347
- return { success: true, upgraded: true, version: latest };
353
+ return { success: true, upgraded: true, version: latest, restarting: true };
348
354
  } catch (e: any) {
349
355
  LOG.error('Upgrade', `Failed: ${e.message}`);
350
356
  return { success: false, error: e.message };
@@ -0,0 +1,214 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { spawn } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
8
+
9
+ export interface DaemonUpgradeHelperPayload {
10
+ packageName: string;
11
+ targetVersion: string;
12
+ parentPid: number;
13
+ restartArgv: string[];
14
+ cwd?: string;
15
+ sessionHostAppName?: string;
16
+ }
17
+
18
+ function getUpgradeLogPath(): string {
19
+ const home = os.homedir();
20
+ const dir = path.join(home, '.adhdev');
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ return path.join(dir, 'daemon-upgrade.log');
23
+ }
24
+
25
+ function appendUpgradeLog(message: string): void {
26
+ const line = `[${new Date().toISOString()}] ${message}\n`;
27
+ try {
28
+ fs.appendFileSync(getUpgradeLogPath(), line, 'utf8');
29
+ } catch {
30
+ // noop
31
+ }
32
+ }
33
+
34
+ function getNpmExecutable(): string {
35
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
36
+ }
37
+
38
+ function killPid(pid: number): boolean {
39
+ try {
40
+ if (process.platform === 'win32') {
41
+ execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
42
+ } else {
43
+ process.kill(pid, 'SIGTERM');
44
+ }
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
52
+ const start = Date.now();
53
+ while (Date.now() - start < timeoutMs) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ await new Promise((resolve) => setTimeout(resolve, 250));
57
+ } catch {
58
+ return;
59
+ }
60
+ }
61
+ }
62
+
63
+ function stopSessionHostProcesses(appName: string): void {
64
+ const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
65
+ try {
66
+ if (fs.existsSync(pidFile)) {
67
+ const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
68
+ if (Number.isFinite(pid)) {
69
+ killPid(pid);
70
+ }
71
+ }
72
+ } catch {
73
+ // noop
74
+ } finally {
75
+ try {
76
+ fs.unlinkSync(pidFile);
77
+ } catch {
78
+ // noop
79
+ }
80
+ }
81
+
82
+ if (process.platform !== 'win32') {
83
+ try {
84
+ const raw = execFileSync('pgrep', ['-f', 'session-host-daemon'], { encoding: 'utf8' }).trim();
85
+ for (const line of raw.split('\n')) {
86
+ const pid = Number.parseInt(line.trim(), 10);
87
+ if (Number.isFinite(pid)) {
88
+ killPid(pid);
89
+ }
90
+ }
91
+ } catch {
92
+ // noop
93
+ }
94
+ }
95
+ }
96
+
97
+ function removeDaemonPidFile(): void {
98
+ const pidFile = path.join(os.homedir(), '.adhdev', 'daemon.pid');
99
+ try {
100
+ fs.unlinkSync(pidFile);
101
+ } catch {
102
+ // noop
103
+ }
104
+ }
105
+
106
+ function cleanupStaleGlobalInstallDirs(pkgName: string): void {
107
+ const npmRoot = execFileSync(getNpmExecutable(), ['root', '-g'], { encoding: 'utf8' }).trim();
108
+ if (!npmRoot) return;
109
+ const npmPrefix = execFileSync(getNpmExecutable(), ['prefix', '-g'], { encoding: 'utf8' }).trim();
110
+ const binDir = process.platform === 'win32' ? npmPrefix : path.join(npmPrefix, 'bin');
111
+ const packageBaseName = pkgName.startsWith('@') ? pkgName.split('/')[1] : pkgName;
112
+ const binNames = new Set<string>([packageBaseName]);
113
+ if (pkgName === '@adhdev/daemon-standalone') {
114
+ binNames.add('adhdev-standalone');
115
+ }
116
+
117
+ if (pkgName.startsWith('@')) {
118
+ const [scope, name] = pkgName.split('/');
119
+ const scopeDir = path.join(npmRoot, scope);
120
+ if (!fs.existsSync(scopeDir)) return;
121
+ for (const entry of fs.readdirSync(scopeDir)) {
122
+ if (!entry.startsWith(`.${name}-`)) continue;
123
+ fs.rmSync(path.join(scopeDir, entry), { recursive: true, force: true });
124
+ appendUpgradeLog(`Removed stale scoped staging dir: ${path.join(scopeDir, entry)}`);
125
+ }
126
+ } else {
127
+ for (const entry of fs.readdirSync(npmRoot)) {
128
+ if (!entry.startsWith(`.${pkgName}-`)) continue;
129
+ fs.rmSync(path.join(npmRoot, entry), { recursive: true, force: true });
130
+ appendUpgradeLog(`Removed stale staging dir: ${path.join(npmRoot, entry)}`);
131
+ }
132
+ }
133
+
134
+ if (fs.existsSync(binDir)) {
135
+ for (const entry of fs.readdirSync(binDir)) {
136
+ if (![...binNames].some((name) => entry.startsWith(`.${name}-`))) continue;
137
+ fs.rmSync(path.join(binDir, entry), { recursive: true, force: true });
138
+ appendUpgradeLog(`Removed stale bin staging entry: ${path.join(binDir, entry)}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ export function spawnDetachedDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): void {
144
+ const env = { ...process.env, [UPGRADE_HELPER_ENV]: JSON.stringify(payload) };
145
+ const child = spawn(process.execPath, process.argv.slice(1), {
146
+ detached: true,
147
+ stdio: 'ignore',
148
+ windowsHide: true,
149
+ cwd: payload.cwd || process.cwd(),
150
+ env,
151
+ });
152
+ child.unref();
153
+ }
154
+
155
+ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Promise<void> {
156
+ const restartArgv = Array.isArray(payload.restartArgv) ? payload.restartArgv : [];
157
+ const sessionHostAppName = payload.sessionHostAppName || process.env.ADHDEV_SESSION_HOST_NAME || 'adhdev';
158
+ appendUpgradeLog(`Upgrade helper started for ${payload.packageName}@${payload.targetVersion}`);
159
+
160
+ if (Number.isFinite(payload.parentPid) && payload.parentPid > 0) {
161
+ appendUpgradeLog(`Waiting for parent pid ${payload.parentPid} to exit`);
162
+ await waitForPidExit(payload.parentPid, 15000);
163
+ }
164
+
165
+ stopSessionHostProcesses(sessionHostAppName);
166
+ removeDaemonPidFile();
167
+ cleanupStaleGlobalInstallDirs(payload.packageName);
168
+
169
+ const spec = `${payload.packageName}@${payload.targetVersion || 'latest'}`;
170
+ appendUpgradeLog(`Installing ${spec}`);
171
+ const installOutput = execFileSync(
172
+ getNpmExecutable(),
173
+ ['install', '-g', spec, '--force'],
174
+ {
175
+ encoding: 'utf8',
176
+ stdio: 'pipe',
177
+ maxBuffer: 20 * 1024 * 1024,
178
+ },
179
+ );
180
+ if (installOutput.trim()) {
181
+ appendUpgradeLog(installOutput.trim());
182
+ }
183
+
184
+ if (restartArgv.length > 0) {
185
+ const env = { ...process.env };
186
+ delete env[UPGRADE_HELPER_ENV];
187
+ appendUpgradeLog(`Restarting daemon with args: ${restartArgv.join(' ')}`);
188
+ const child = spawn(process.execPath, restartArgv, {
189
+ detached: true,
190
+ stdio: 'ignore',
191
+ windowsHide: true,
192
+ cwd: payload.cwd || process.cwd(),
193
+ env,
194
+ });
195
+ child.unref();
196
+ } else {
197
+ appendUpgradeLog('No restart argv provided; upgrade completed without restart');
198
+ }
199
+ }
200
+
201
+ export async function maybeRunDaemonUpgradeHelperFromEnv(): Promise<boolean> {
202
+ const raw = process.env[UPGRADE_HELPER_ENV];
203
+ if (!raw) return false;
204
+ delete process.env[UPGRADE_HELPER_ENV];
205
+
206
+ try {
207
+ const payload = JSON.parse(raw) as DaemonUpgradeHelperPayload;
208
+ await runDaemonUpgradeHelper(payload);
209
+ process.exit(0);
210
+ } catch (error: any) {
211
+ appendUpgradeLog(`Upgrade helper failed: ${error?.stack || error?.message || String(error)}`);
212
+ process.exit(1);
213
+ }
214
+ }
package/src/index.ts CHANGED
@@ -89,6 +89,7 @@ export { DaemonCommandHandler } from './commands/handler.js';
89
89
  export type { CommandResult, CommandContext } from './commands/handler.js';
90
90
  export { DaemonCommandRouter } from './commands/router.js';
91
91
  export type { CommandRouterDeps, CommandRouterResult } from './commands/router.js';
92
+ export { maybeRunDaemonUpgradeHelperFromEnv } from './commands/upgrade-helper.js';
92
93
 
93
94
  // ── Status ──
94
95
  export { DaemonStatusReporter } from './status/reporter.js';