@dmsdc-ai/aigentry-telepty 0.4.2 → 0.4.3
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/CHANGELOG.md +74 -0
- package/cli.js +13 -9
- package/daemon-control.js +128 -7
- package/package.json +4 -4
- package/src/version-handshake.js +82 -0
- package/src/win-kill-process.js +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.4.3] - 2026-05-23
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **telepty#15** — Daemon version mismatch auto-restart + port-owner
|
|
10
|
+
fallback + banner-to-stderr (root-cause fix for task #400).
|
|
11
|
+
- All five daemon-related banners in `cli.js` (lines 429, 585, 592,
|
|
12
|
+
594, 600) now emit to `process.stderr` instead of `process.stdout`.
|
|
13
|
+
Closes task #400 (banner contaminated `telepty list --json | jq`
|
|
14
|
+
stdin → `Invalid numeric literal`). A new lint-style regression
|
|
15
|
+
test (`test/banner-stderr-jq-safety.test.js`) statically scans
|
|
16
|
+
`cli.js` and fails CI if any "⚙️/⚠️ Daemon…" banner regresses
|
|
17
|
+
back to `process.stdout.write`.
|
|
18
|
+
- New pure-functional `src/version-handshake.js` exposes
|
|
19
|
+
`decideVersionAction({ daemonVersion, cliVersion })` returning a
|
|
20
|
+
stable action enum (`START` / `RESTART` / `NOOP`) plus reason.
|
|
21
|
+
Six-cell decision matrix: daemon unreachable, CLI-version missing,
|
|
22
|
+
versions equal, daemon older (newer-wins → restart), daemon newer
|
|
23
|
+
(preserve newer daemon → noop), non-semver (string compare).
|
|
24
|
+
Wired into `cli.js` `ensureDaemonRunning` so the previously-inline
|
|
25
|
+
`meta.version !== pkg.version` check now delegates to the module.
|
|
26
|
+
- New port-owner fallback in `daemon-control.js`:
|
|
27
|
+
`findPortOwnerPid(port)` uses `lsof -nP -iTCP:<port> -sTCP:LISTEN -t`
|
|
28
|
+
on POSIX and `Get-NetTCPConnection -State Listen -LocalPort <port>`
|
|
29
|
+
on Windows. `cleanupDaemonProcesses` now treats the listener as a
|
|
30
|
+
third kill candidate (`source: 'port-owner'`) — but only after
|
|
31
|
+
confirming the PID is actually a telepty daemon via
|
|
32
|
+
`pidMatchesTeleptyCmdline`. Unconfirmed port-owners are never
|
|
33
|
+
killed (zero-arbitrary-kill safety). `probeTeleptyOnPort` (HTTP
|
|
34
|
+
`/api/health`) is exported for future async-aware callers.
|
|
35
|
+
- SIGTERM → SIGKILL grace period bumped from 1500 ms to 5000 ms
|
|
36
|
+
(POSIX). Configurable via `TELEPTY_DAEMON_KILL_GRACE_MS` env.
|
|
37
|
+
- New `src/win-kill-process.js` (parallel to existing
|
|
38
|
+
`src/win-resolve-executable.js`) provides `buildTaskkillArgs(pid)`
|
|
39
|
+
and `killWindowsProcess(pid, opts)`. Unit-testable taskkill args
|
|
40
|
+
generator with injectable `execFileSync`. `daemon-control.js`
|
|
41
|
+
Windows branch now delegates to this module.
|
|
42
|
+
- `cleanupDaemonProcesses(opts)` accepts injectors
|
|
43
|
+
(`readDaemonState`, `listDaemonProcesses`, `findPortOwnerPid`,
|
|
44
|
+
`pidMatchesTeleptyCmdline`, `stopDaemonProcess`, `includePortOwner`,
|
|
45
|
+
`port`) for unit-testable source attribution.
|
|
46
|
+
- **Tests**: 343 / 343 pass (301 baseline preserved + 42 new across
|
|
47
|
+
four files: `test/version-handshake.test.js` (16),
|
|
48
|
+
`test/win-kill-process.test.js` (10),
|
|
49
|
+
`test/daemon-control-port-owner.test.js` (10),
|
|
50
|
+
`test/banner-stderr-jq-safety.test.js` (6)).
|
|
51
|
+
|
|
52
|
+
### Notes
|
|
53
|
+
|
|
54
|
+
- No new npm dependencies (Constitution §17 무의존).
|
|
55
|
+
- No drive-by refactors (Rule 29 surgical); changes limited to
|
|
56
|
+
`cli.js`, `daemon-control.js`, `src/version-handshake.js` (NEW),
|
|
57
|
+
`src/win-kill-process.js` (NEW), and four new test files.
|
|
58
|
+
- **Snyk SAST scan on changed files** — `daemon-control.js` +
|
|
59
|
+
`src/version-handshake.js` + `src/win-kill-process.js` +
|
|
60
|
+
`test/version-handshake.test.js` +
|
|
61
|
+
`test/win-kill-process.test.js` +
|
|
62
|
+
`test/daemon-control-port-owner.test.js` +
|
|
63
|
+
`test/banner-stderr-jq-safety.test.js` = **0 findings**
|
|
64
|
+
(At-Inception clean). `cli.js` shows the same **5 pre-existing
|
|
65
|
+
findings** carried from v0.4.2 (2 Medium Command Injection at
|
|
66
|
+
`execSync` (was L469 → now L471) and `pty.spawn` (was L1096 → now
|
|
67
|
+
L1100); 3 Low Path Traversal at `fs.readFileSync`/`fs.readdirSync`
|
|
68
|
+
(was L2308/L2310/L2619 → now L2312/L2314/L2623)) with **identical
|
|
69
|
+
fingerprints** vs HEAD~1 (5/5 verified by direct rescan of HEAD~1
|
|
70
|
+
`cli.js`: fingerprint leading hashes `6eb481d6`, `24799351`,
|
|
71
|
+
`11a45176`, `11a45176`, `e0fda459` all match). Line numbers
|
|
72
|
+
downstream of `cli.js:21` shifted +2/+4 due to the new
|
|
73
|
+
`version-handshake` require + the expanded
|
|
74
|
+
`restartDaemonGraceful` banner/comment paths; logical
|
|
75
|
+
source→sink unchanged, no new sink call sites added. Out of
|
|
76
|
+
telepty#15 surgical scope. Tracked in
|
|
77
|
+
**dmsdc-ai/aigentry-telepty#26** (task #408) for follow-up PR.
|
|
78
|
+
|
|
5
79
|
## [0.4.2] - 2026-05-17
|
|
6
80
|
|
|
7
81
|
### Fixed
|
package/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./s
|
|
|
18
18
|
const { buildSharedContextPrompt, createSharedContextDescriptor, ensureSharedContextFile } = require('./shared-context');
|
|
19
19
|
const { runInteractiveSkillInstaller } = require('./skill-installer');
|
|
20
20
|
const { resolveWindowsExecutable } = require('./src/win-resolve-executable');
|
|
21
|
+
const { decideVersionAction } = require('./src/version-handshake');
|
|
21
22
|
const crossMachine = require('./cross-machine');
|
|
22
23
|
const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
|
|
23
24
|
const { FileMailbox } = require('./src/mailbox/index');
|
|
@@ -426,7 +427,8 @@ async function restartDaemonGraceful(options = {}) {
|
|
|
426
427
|
// Retry with backoff
|
|
427
428
|
if (attempt < maxAttempts) {
|
|
428
429
|
const backoff = 1000 * attempt;
|
|
429
|
-
|
|
430
|
+
// stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
|
|
431
|
+
process.stderr.write(`\x1b[33m⚠️ Daemon restart attempt ${attempt}/${maxAttempts} failed. Retrying in ${backoff / 1000}s...\x1b[0m\n`);
|
|
430
432
|
await new Promise(r => setTimeout(r, backoff));
|
|
431
433
|
}
|
|
432
434
|
}
|
|
@@ -580,24 +582,26 @@ async function ensureDaemonRunning(options = {}) {
|
|
|
580
582
|
});
|
|
581
583
|
|
|
582
584
|
if (sessionsRes.ok && hasCapabilities) {
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
585
|
+
// Delegate decision to pure-functional handshake so the policy is unit-testable
|
|
586
|
+
// and consistent across CLI invocations.
|
|
587
|
+
const decision = decideVersionAction({ daemonVersion: meta && meta.version, cliVersion: pkg.version });
|
|
588
|
+
if (decision.action === 'restart') {
|
|
589
|
+
// stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
|
|
590
|
+
process.stderr.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
|
|
586
591
|
await restartDaemonGraceful({ requiredCapabilities });
|
|
587
592
|
return;
|
|
588
|
-
} else {
|
|
589
|
-
return;
|
|
590
593
|
}
|
|
594
|
+
return;
|
|
591
595
|
} else if (sessionsRes.ok && !meta) {
|
|
592
|
-
process.
|
|
596
|
+
process.stderr.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
|
|
593
597
|
} else if (sessionsRes.ok && meta) {
|
|
594
|
-
process.
|
|
598
|
+
process.stderr.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
|
|
595
599
|
}
|
|
596
600
|
} catch (e) {
|
|
597
601
|
// Continue to auto-start below.
|
|
598
602
|
}
|
|
599
603
|
|
|
600
|
-
process.
|
|
604
|
+
process.stderr.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
|
|
601
605
|
await restartDaemonGraceful({ requiredCapabilities });
|
|
602
606
|
}
|
|
603
607
|
|
package/daemon-control.js
CHANGED
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const http = require('http');
|
|
6
7
|
const { execFileSync, execSync } = require('child_process');
|
|
8
|
+
const { killWindowsProcess } = require('./src/win-kill-process');
|
|
7
9
|
|
|
8
10
|
const TELEPTY_DIR = path.join(os.homedir(), '.telepty');
|
|
9
11
|
const DAEMON_STATE_FILE = path.join(TELEPTY_DIR, 'daemon-state.json');
|
|
12
|
+
const DEFAULT_KILL_GRACE_MS = 5000;
|
|
13
|
+
|
|
14
|
+
function killGraceMs() {
|
|
15
|
+
const raw = Number(process.env.TELEPTY_DAEMON_KILL_GRACE_MS);
|
|
16
|
+
if (Number.isFinite(raw) && raw >= 0) return raw;
|
|
17
|
+
return DEFAULT_KILL_GRACE_MS;
|
|
18
|
+
}
|
|
10
19
|
|
|
11
20
|
function ensureTeleptyDir() {
|
|
12
21
|
fs.mkdirSync(TELEPTY_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -159,12 +168,12 @@ function stopDaemonProcess(pid) {
|
|
|
159
168
|
|
|
160
169
|
try {
|
|
161
170
|
if (process.platform === 'win32') {
|
|
162
|
-
|
|
163
|
-
return
|
|
171
|
+
// Windows: delegate to dedicated module so taskkill args are unit-testable.
|
|
172
|
+
return killWindowsProcess(pid);
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
process.kill(pid, 'SIGTERM');
|
|
167
|
-
const deadline = Date.now() +
|
|
176
|
+
const deadline = Date.now() + killGraceMs();
|
|
168
177
|
while (Date.now() < deadline) {
|
|
169
178
|
if (!isProcessRunning(pid)) {
|
|
170
179
|
return true;
|
|
@@ -179,25 +188,134 @@ function stopDaemonProcess(pid) {
|
|
|
179
188
|
}
|
|
180
189
|
}
|
|
181
190
|
|
|
182
|
-
|
|
191
|
+
// Port-owner discovery for telepty#15 port-owner fallback.
|
|
192
|
+
// Returns the LISTEN-state pid bound to `port` on the local machine, or null
|
|
193
|
+
// if no listener can be identified (or detection failed).
|
|
194
|
+
function findPortOwnerPid(port, opts) {
|
|
195
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
196
|
+
const o = opts || {};
|
|
197
|
+
const platform = o.platform || process.platform;
|
|
198
|
+
const exec = o.execSync || execSync;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
if (platform === 'win32') {
|
|
202
|
+
// PowerShell: Get-NetTCPConnection guarantees LISTEN-state filter.
|
|
203
|
+
const script =
|
|
204
|
+
`Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue ` +
|
|
205
|
+
`| Select-Object -First 1 -ExpandProperty OwningProcess`;
|
|
206
|
+
const output = String(exec(`powershell.exe -NoProfile -Command "${script}"`, {
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
209
|
+
})).trim();
|
|
210
|
+
const pid = Number(output);
|
|
211
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// POSIX: lsof -t prints only PIDs; -sTCP:LISTEN narrows to listeners.
|
|
215
|
+
const output = String(exec(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, {
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
218
|
+
})).trim();
|
|
219
|
+
if (!output) return null;
|
|
220
|
+
const pid = Number(output.split(/\s+/)[0]);
|
|
221
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Confirm via HTTP probe that the listener on `port` is actually a telepty
|
|
228
|
+
// daemon (token-less /api/health endpoint is enough — daemon.js:2891 path).
|
|
229
|
+
function probeTeleptyOnPort(port, opts) {
|
|
230
|
+
if (!Number.isInteger(port) || port <= 0) return Promise.resolve(false);
|
|
231
|
+
const o = opts || {};
|
|
232
|
+
const timeoutMs = Number.isFinite(o.timeoutMs) ? o.timeoutMs : 1500;
|
|
233
|
+
const httpGet = o.httpGet || http.get;
|
|
234
|
+
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
let settled = false;
|
|
237
|
+
const finish = (value) => {
|
|
238
|
+
if (settled) return;
|
|
239
|
+
settled = true;
|
|
240
|
+
resolve(value);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
let req;
|
|
244
|
+
try {
|
|
245
|
+
req = httpGet({ hostname: '127.0.0.1', port, path: '/api/health', timeout: timeoutMs }, (res) => {
|
|
246
|
+
let body = '';
|
|
247
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
248
|
+
res.on('end', () => {
|
|
249
|
+
try {
|
|
250
|
+
const data = JSON.parse(body);
|
|
251
|
+
finish(Boolean(data && data.status === 'ok'));
|
|
252
|
+
} catch {
|
|
253
|
+
finish(false);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
finish(false);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
req.on('error', () => finish(false));
|
|
263
|
+
req.on('timeout', () => {
|
|
264
|
+
try { req.destroy(); } catch {}
|
|
265
|
+
finish(false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Confirm via local process scan that `pid`'s command line looks like a
|
|
271
|
+
// telepty daemon (fallback when HTTP probe fails — daemon may be stuck).
|
|
272
|
+
function pidMatchesTeleptyCmdline(pid) {
|
|
273
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
274
|
+
try {
|
|
275
|
+
const processes = process.platform === 'win32' ? listWindowsProcesses() : listUnixProcesses();
|
|
276
|
+
const match = processes.find((item) => item.pid === pid);
|
|
277
|
+
return Boolean(match && isLikelyTeleptyDaemon(match.commandLine));
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function cleanupDaemonProcesses(opts) {
|
|
284
|
+
const o = opts || {};
|
|
183
285
|
const targets = new Map();
|
|
184
|
-
const state = readDaemonState();
|
|
286
|
+
const state = (o.readDaemonState || readDaemonState)();
|
|
185
287
|
|
|
186
288
|
if (state && Number.isInteger(state.pid) && state.pid > 0 && state.pid !== process.pid) {
|
|
187
289
|
targets.set(state.pid, { pid: state.pid, source: 'state-file' });
|
|
188
290
|
}
|
|
189
291
|
|
|
190
|
-
for (const item of listDaemonProcesses()) {
|
|
292
|
+
for (const item of (o.listDaemonProcesses || listDaemonProcesses)()) {
|
|
191
293
|
if (!targets.has(item.pid)) {
|
|
192
294
|
targets.set(item.pid, { pid: item.pid, source: 'process-scan', commandLine: item.commandLine });
|
|
193
295
|
}
|
|
194
296
|
}
|
|
195
297
|
|
|
298
|
+
// 3rd source: port-owner fallback (telepty#15).
|
|
299
|
+
// Only consider it a kill candidate after a confirmation step so we never
|
|
300
|
+
// SIGTERM an arbitrary process that happens to own the port.
|
|
301
|
+
// Confirmation order: HTTP /api/health probe → ps cmdline match.
|
|
302
|
+
// Sync-friendly: HTTP probe is opt-in (port-owner kept disabled in tests).
|
|
303
|
+
if (o.includePortOwner !== false) {
|
|
304
|
+
const portOwnerPid = (o.findPortOwnerPid || findPortOwnerPid)(o.port || 3848);
|
|
305
|
+
if (portOwnerPid && portOwnerPid !== process.pid && !targets.has(portOwnerPid)) {
|
|
306
|
+
const confirmCmdline = (o.pidMatchesTeleptyCmdline || pidMatchesTeleptyCmdline);
|
|
307
|
+
if (confirmCmdline(portOwnerPid)) {
|
|
308
|
+
targets.set(portOwnerPid, { pid: portOwnerPid, source: 'port-owner' });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
196
313
|
const stopped = [];
|
|
197
314
|
const failed = [];
|
|
198
315
|
|
|
199
316
|
for (const item of targets.values()) {
|
|
200
|
-
|
|
317
|
+
const killer = o.stopDaemonProcess || stopDaemonProcess;
|
|
318
|
+
if (killer(item.pid)) {
|
|
201
319
|
stopped.push(item);
|
|
202
320
|
} else {
|
|
203
321
|
failed.push(item);
|
|
@@ -217,7 +335,10 @@ module.exports = {
|
|
|
217
335
|
claimDaemonState,
|
|
218
336
|
cleanupDaemonProcesses,
|
|
219
337
|
clearDaemonState,
|
|
338
|
+
findPortOwnerPid,
|
|
220
339
|
isProcessRunning,
|
|
221
340
|
listDaemonProcesses,
|
|
341
|
+
pidMatchesTeleptyCmdline,
|
|
342
|
+
probeTeleptyOnPort,
|
|
222
343
|
readDaemonState
|
|
223
344
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"CHANGELOG.md"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
-
"test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
37
|
-
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js",
|
|
38
|
-
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
36
|
+
"test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
37
|
+
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js",
|
|
38
|
+
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
39
39
|
"regen-fixtures": "node scripts/regen-snippet-fixtures.js"
|
|
40
40
|
},
|
|
41
41
|
"keywords": [
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/version-handshake.js — pure-functional daemon/CLI version handshake policy.
|
|
2
|
+
//
|
|
3
|
+
// Fixes telepty#15 (auto-restart on daemon version mismatch) and task #400
|
|
4
|
+
// (mismatch-banner-on-stdout contaminates `telepty list --json | jq`).
|
|
5
|
+
//
|
|
6
|
+
// Decision policy (newer-wins; daemon-side EADDRINUSE health-probe already
|
|
7
|
+
// handles the symmetric case where a newer daemon refuses to clobber). Input
|
|
8
|
+
// is the daemon `/api/meta` response (or null when unreachable) plus the local
|
|
9
|
+
// CLI package version. Output is a stable action enum the caller maps to side
|
|
10
|
+
// effects (restart vs noop vs start). Pure → unit-testable without sockets.
|
|
11
|
+
//
|
|
12
|
+
// Constraints honored:
|
|
13
|
+
// - Constitution §1 lightweight: ≤80 lines core logic, no deps.
|
|
14
|
+
// - Constitution §2 cross-platform: pure JS, no OS calls.
|
|
15
|
+
// - Constitution §17 무의존: no new npm dependencies.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const ACTIONS = Object.freeze({
|
|
20
|
+
NOOP: 'noop',
|
|
21
|
+
RESTART: 'restart',
|
|
22
|
+
START: 'start'
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function parseSemver(value) {
|
|
26
|
+
if (typeof value !== 'string') return null;
|
|
27
|
+
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareSemver(a, b) {
|
|
33
|
+
const left = parseSemver(a);
|
|
34
|
+
const right = parseSemver(b);
|
|
35
|
+
if (!left || !right) return null;
|
|
36
|
+
for (let i = 0; i < 3; i += 1) {
|
|
37
|
+
if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decideVersionAction(input) {
|
|
43
|
+
const daemonVersion = input && input.daemonVersion;
|
|
44
|
+
const cliVersion = input && input.cliVersion;
|
|
45
|
+
|
|
46
|
+
// No daemon reachable → caller should start one (separate from restart).
|
|
47
|
+
if (!daemonVersion) {
|
|
48
|
+
return { action: ACTIONS.START, reason: 'daemon-unreachable' };
|
|
49
|
+
}
|
|
50
|
+
if (!cliVersion) {
|
|
51
|
+
return { action: ACTIONS.NOOP, reason: 'cli-version-missing' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cmp = compareSemver(daemonVersion, cliVersion);
|
|
55
|
+
|
|
56
|
+
// Non-semver values: fall back to string compare so dev/test tags still flow.
|
|
57
|
+
if (cmp === null) {
|
|
58
|
+
if (daemonVersion === cliVersion) {
|
|
59
|
+
return { action: ACTIONS.NOOP, reason: 'versions-equal-nonsemver' };
|
|
60
|
+
}
|
|
61
|
+
return { action: ACTIONS.RESTART, reason: 'version-mismatch-nonsemver' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (cmp === 0) {
|
|
65
|
+
return { action: ACTIONS.NOOP, reason: 'versions-equal' };
|
|
66
|
+
}
|
|
67
|
+
if (cmp < 0) {
|
|
68
|
+
// Daemon older than CLI → newer-wins, restart.
|
|
69
|
+
return { action: ACTIONS.RESTART, reason: 'daemon-older' };
|
|
70
|
+
}
|
|
71
|
+
// Daemon newer than CLI → respect newer daemon; CLI talks via HTTP wire
|
|
72
|
+
// protocol that is forward-compatible. No restart (avoid clobbering newer).
|
|
73
|
+
return { action: ACTIONS.NOOP, reason: 'daemon-newer' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
ACTIONS,
|
|
78
|
+
decideVersionAction,
|
|
79
|
+
// Exported for tests + reuse by daemon-control.js port-owner probe.
|
|
80
|
+
parseSemver,
|
|
81
|
+
compareSemver
|
|
82
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/win-kill-process.js — Windows process-kill helper.
|
|
2
|
+
//
|
|
3
|
+
// Fixes telepty#15 (port-owner fallback when stale daemon holds port 3848).
|
|
4
|
+
// On Windows `process.kill(pid, 'SIGTERM')` is unreliable for daemons spawned
|
|
5
|
+
// in a different console group; `taskkill /T /F` is the supported path.
|
|
6
|
+
//
|
|
7
|
+
// On POSIX this module is a no-op (callers fall back to native SIGTERM →
|
|
8
|
+
// SIGKILL grace inline in `daemon-control.js`).
|
|
9
|
+
//
|
|
10
|
+
// Exports:
|
|
11
|
+
// buildTaskkillArgs(pid) → ['/PID', '<pid>', '/T', '/F']
|
|
12
|
+
// killWindowsProcess(pid, opts) → boolean — true on success
|
|
13
|
+
//
|
|
14
|
+
// Constraints honored:
|
|
15
|
+
// - Constitution §1 lightweight: ≤80 lines, child_process only.
|
|
16
|
+
// - Constitution §2 cross-platform: POSIX → returns false fast.
|
|
17
|
+
// - Constitution §17 무의존: no new dependencies.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { execFileSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
function buildTaskkillArgs(pid) {
|
|
24
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
25
|
+
throw new Error('telepty: buildTaskkillArgs requires a positive integer pid');
|
|
26
|
+
}
|
|
27
|
+
return ['/PID', String(pid), '/T', '/F'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function killWindowsProcess(pid, opts) {
|
|
31
|
+
const o = opts || {};
|
|
32
|
+
const platform = o.platform || process.platform;
|
|
33
|
+
if (platform !== 'win32') return false;
|
|
34
|
+
|
|
35
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const exec = o.execFileSync || execFileSync;
|
|
40
|
+
try {
|
|
41
|
+
exec('taskkill', buildTaskkillArgs(pid), { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
buildTaskkillArgs,
|
|
50
|
+
killWindowsProcess
|
|
51
|
+
};
|