@withtruing/companion 0.0.0-stage → 0.13.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/LICENSE +21 -0
- package/README.md +328 -2
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +48 -4
package/dist/service.js
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
3
|
+
import { homedir, uptime } from 'node:os';
|
|
4
|
+
import { dirname, join, posix, resolve, win32 } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { loadConfig } from './config.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
// Kept on purpose from the old name so existing pairings still work.
|
|
9
|
+
const SERVICE_DIR_NAME = '.product-console-companion';
|
|
10
|
+
const SERVICE_FILE_NAME = 'service.json';
|
|
11
|
+
const PID_FILE_NAME = 'service.pid';
|
|
12
|
+
const STOP_FILE_NAME = 'STOP';
|
|
13
|
+
const LOG_FILE_NAME = 'run.log';
|
|
14
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
15
|
+
const RESTART_DELAY_MS = 30_000;
|
|
16
|
+
const EXIT_WINDOW_MS = 15 * 60_000;
|
|
17
|
+
const CRASH_PAUSE_MS = 10 * 60_000;
|
|
18
|
+
const STOP_GRACE_MS = 10_000;
|
|
19
|
+
const BOOT_TIME_TOLERANCE_MS = 2 * 60_000;
|
|
20
|
+
const ALLOWED_ENV = ['CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'HOME', 'USERPROFILE'];
|
|
21
|
+
const defaultIo = {
|
|
22
|
+
exists: existsSync,
|
|
23
|
+
mkdir: (path) => mkdirSync(path, { recursive: true }),
|
|
24
|
+
read: (path) => readFileSync(path, 'utf8'),
|
|
25
|
+
write: (path, contents, mode) => writeFileSync(path, contents, mode === undefined ? undefined : { mode }),
|
|
26
|
+
remove: (path) => {
|
|
27
|
+
try {
|
|
28
|
+
unlinkSync(path);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code !== 'ENOENT')
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const defaultCommands = {
|
|
37
|
+
async exec(command, args) {
|
|
38
|
+
const result = await execFileAsync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
39
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
40
|
+
},
|
|
41
|
+
spawnDetached(command, args, options) {
|
|
42
|
+
spawn(command, args, {
|
|
43
|
+
cwd: options.cwd,
|
|
44
|
+
env: options.env,
|
|
45
|
+
detached: true,
|
|
46
|
+
stdio: 'ignore',
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
}).unref();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
function xml(value) {
|
|
52
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
53
|
+
.replaceAll('"', '"').replaceAll("'", ''');
|
|
54
|
+
}
|
|
55
|
+
function vbs(value) {
|
|
56
|
+
return value.replaceAll('"', '""');
|
|
57
|
+
}
|
|
58
|
+
function systemdArgument(value) {
|
|
59
|
+
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
60
|
+
}
|
|
61
|
+
function desktopArgument(value) {
|
|
62
|
+
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('`', '\\`').replaceAll('$', '\\$')}"`;
|
|
63
|
+
}
|
|
64
|
+
export function servicePaths(overrides = {}) {
|
|
65
|
+
const homeDir = resolve(overrides.homeDir ?? homedir());
|
|
66
|
+
const serviceDir = join(homeDir, SERVICE_DIR_NAME);
|
|
67
|
+
// Windows keeps the Startup folder under %APPDATA%, which can be redirected away from the home
|
|
68
|
+
// folder; tests that inject a home folder keep everything under it.
|
|
69
|
+
const roamingAppData = overrides.homeDir === undefined && process.env.APPDATA
|
|
70
|
+
? process.env.APPDATA
|
|
71
|
+
: join(homeDir, 'AppData', 'Roaming');
|
|
72
|
+
return {
|
|
73
|
+
homeDir,
|
|
74
|
+
serviceDir,
|
|
75
|
+
serviceFile: join(serviceDir, SERVICE_FILE_NAME),
|
|
76
|
+
pidFile: join(serviceDir, PID_FILE_NAME),
|
|
77
|
+
stopFile: join(serviceDir, STOP_FILE_NAME),
|
|
78
|
+
logFile: join(serviceDir, LOG_FILE_NAME),
|
|
79
|
+
oldLogFile: join(serviceDir, `${LOG_FILE_NAME}.1`),
|
|
80
|
+
windowsLauncher: join(serviceDir, 'start-hidden.vbs'),
|
|
81
|
+
windowsShortcutScript: join(serviceDir, 'create-startup-shortcut.vbs'),
|
|
82
|
+
windowsStartupShortcut: join(resolve(overrides.windowsStartupDir ?? join(roamingAppData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')), 'Truing companion.lnk'),
|
|
83
|
+
legacyWindowsStartupShortcut: join(resolve(overrides.windowsStartupDir ?? join(roamingAppData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')), 'Product Console companion.lnk'),
|
|
84
|
+
macLaunchAgent: join(resolve(overrides.macLaunchAgentsDir ?? join(homeDir, 'Library', 'LaunchAgents')), 'com.withtruing.companion.plist'),
|
|
85
|
+
legacyMacLaunchAgent: join(resolve(overrides.macLaunchAgentsDir ?? join(homeDir, 'Library', 'LaunchAgents')), 'com.fynch.product-console-companion.plist'),
|
|
86
|
+
linuxSystemdUnit: join(resolve(overrides.linuxSystemdUserDir ?? join(homeDir, '.config', 'systemd', 'user')), 'truing-companion.service'),
|
|
87
|
+
legacyLinuxSystemdUnit: join(resolve(overrides.linuxSystemdUserDir ?? join(homeDir, '.config', 'systemd', 'user')), 'product-console-companion.service'),
|
|
88
|
+
linuxDesktopFile: join(resolve(overrides.linuxAutostartDir ?? join(homeDir, '.config', 'autostart')), 'truing-companion.desktop'),
|
|
89
|
+
legacyLinuxDesktopFile: join(resolve(overrides.linuxAutostartDir ?? join(homeDir, '.config', 'autostart')), 'product-console-companion.desktop'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function serviceConfig(cliPath, cwd, env = process.env, nodePath = process.execPath, platform = process.platform) {
|
|
93
|
+
const carriedEnvironment = {};
|
|
94
|
+
for (const name of ALLOWED_ENV) {
|
|
95
|
+
const value = env[name];
|
|
96
|
+
if (value)
|
|
97
|
+
carriedEnvironment[name] = value;
|
|
98
|
+
}
|
|
99
|
+
// Paths follow the target OS's rules, so the per-OS tests produce the same files on any CI OS.
|
|
100
|
+
const resolvePath = platform === 'win32' ? win32.resolve : posix.resolve;
|
|
101
|
+
return {
|
|
102
|
+
nodePath: resolvePath(nodePath),
|
|
103
|
+
cliPath: resolvePath(cliPath),
|
|
104
|
+
cwd: resolvePath(cwd),
|
|
105
|
+
env: carriedEnvironment,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function isNpxCachePath(cliPath) {
|
|
109
|
+
return /(?:^|[\\/])_npx(?:[\\/]|$)/i.test(resolve(cliPath));
|
|
110
|
+
}
|
|
111
|
+
export function windowsLauncherContents(config) {
|
|
112
|
+
const command = `"${config.nodePath}" "${config.cliPath}" daemon`;
|
|
113
|
+
return [
|
|
114
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
115
|
+
`shell.Run "${vbs(command)}", 0, False`,
|
|
116
|
+
'Set shell = Nothing',
|
|
117
|
+
'',
|
|
118
|
+
].join('\r\n');
|
|
119
|
+
}
|
|
120
|
+
export function windowsShortcutContents(paths, windowsDirectory) {
|
|
121
|
+
const wscript = join(windowsDirectory, 'System32', 'wscript.exe');
|
|
122
|
+
return [
|
|
123
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
124
|
+
`Set shortcut = shell.CreateShortcut("${vbs(paths.windowsStartupShortcut)}")`,
|
|
125
|
+
`shortcut.TargetPath = "${vbs(wscript)}"`,
|
|
126
|
+
`shortcut.Arguments = "${vbs(`"${paths.windowsLauncher}"`)}"`,
|
|
127
|
+
`shortcut.WorkingDirectory = "${vbs(paths.serviceDir)}"`,
|
|
128
|
+
'shortcut.Description = "Starts Truing companion silently"',
|
|
129
|
+
'shortcut.Save',
|
|
130
|
+
'',
|
|
131
|
+
].join('\r\n');
|
|
132
|
+
}
|
|
133
|
+
export function macLaunchAgentContents(config, paths) {
|
|
134
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
135
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
136
|
+
<plist version="1.0">
|
|
137
|
+
<dict>
|
|
138
|
+
<key>Label</key>
|
|
139
|
+
<string>com.withtruing.companion</string>
|
|
140
|
+
<key>ProgramArguments</key>
|
|
141
|
+
<array>
|
|
142
|
+
<string>${xml(config.nodePath)}</string>
|
|
143
|
+
<string>${xml(config.cliPath)}</string>
|
|
144
|
+
<string>daemon</string>
|
|
145
|
+
</array>
|
|
146
|
+
<key>WorkingDirectory</key>
|
|
147
|
+
<string>${xml(config.cwd)}</string>
|
|
148
|
+
<key>RunAtLoad</key>
|
|
149
|
+
<true/>
|
|
150
|
+
<key>StandardOutPath</key>
|
|
151
|
+
<string>${xml(paths.logFile)}</string>
|
|
152
|
+
<key>StandardErrorPath</key>
|
|
153
|
+
<string>${xml(paths.logFile)}</string>
|
|
154
|
+
</dict>
|
|
155
|
+
</plist>
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
export function linuxSystemdUnitContents(config, paths) {
|
|
159
|
+
return `[Unit]
|
|
160
|
+
Description=Truing companion
|
|
161
|
+
After=network-online.target
|
|
162
|
+
|
|
163
|
+
[Service]
|
|
164
|
+
Type=simple
|
|
165
|
+
ExecStart=${systemdArgument(config.nodePath)} ${systemdArgument(config.cliPath)} daemon
|
|
166
|
+
WorkingDirectory=${config.cwd}
|
|
167
|
+
Restart=on-failure
|
|
168
|
+
StandardOutput=append:${paths.logFile}
|
|
169
|
+
StandardError=append:${paths.logFile}
|
|
170
|
+
|
|
171
|
+
[Install]
|
|
172
|
+
WantedBy=default.target
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
export function linuxDesktopContents(config) {
|
|
176
|
+
return `[Desktop Entry]
|
|
177
|
+
Type=Application
|
|
178
|
+
Name=Truing companion
|
|
179
|
+
Comment=Starts Truing companion silently
|
|
180
|
+
Exec=${desktopArgument(config.nodePath)} ${desktopArgument(config.cliPath)} daemon
|
|
181
|
+
Path=${config.cwd}
|
|
182
|
+
Terminal=false
|
|
183
|
+
X-GNOME-Autostart-enabled=true
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
function writeFile(io, path, contents, mode) {
|
|
187
|
+
io.mkdir(dirname(path));
|
|
188
|
+
io.write(path, contents, mode);
|
|
189
|
+
}
|
|
190
|
+
async function removeLegacyStartup(platform, paths, io, commands, uid = process.getuid?.() ?? 0) {
|
|
191
|
+
const exists = (path) => {
|
|
192
|
+
try {
|
|
193
|
+
return io.exists(path);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const remove = (path) => {
|
|
200
|
+
try {
|
|
201
|
+
io.remove(path);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
if (platform === 'win32') {
|
|
209
|
+
const found = exists(paths.legacyWindowsStartupShortcut);
|
|
210
|
+
if (!found)
|
|
211
|
+
return null;
|
|
212
|
+
return remove(paths.legacyWindowsStartupShortcut) ? 'removed' : 'failed';
|
|
213
|
+
}
|
|
214
|
+
if (platform === 'darwin') {
|
|
215
|
+
const found = exists(paths.legacyMacLaunchAgent);
|
|
216
|
+
if (!found)
|
|
217
|
+
return null;
|
|
218
|
+
try {
|
|
219
|
+
await commands.exec('launchctl', ['bootout', `gui/${uid}`, paths.legacyMacLaunchAgent]);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
try {
|
|
223
|
+
await commands.exec('launchctl', ['unload', '-w', paths.legacyMacLaunchAgent]);
|
|
224
|
+
}
|
|
225
|
+
catch { /* Not loaded. */ }
|
|
226
|
+
}
|
|
227
|
+
return remove(paths.legacyMacLaunchAgent) ? 'removed' : 'failed';
|
|
228
|
+
}
|
|
229
|
+
const found = exists(paths.legacyLinuxSystemdUnit) || exists(paths.legacyLinuxDesktopFile);
|
|
230
|
+
if (!found)
|
|
231
|
+
return null;
|
|
232
|
+
try {
|
|
233
|
+
await commands.exec('systemctl', ['--user', 'disable', '--now', 'product-console-companion.service']);
|
|
234
|
+
}
|
|
235
|
+
catch { /* systemd may not exist. */ }
|
|
236
|
+
const removedUnit = remove(paths.legacyLinuxSystemdUnit);
|
|
237
|
+
const removedDesktop = remove(paths.legacyLinuxDesktopFile);
|
|
238
|
+
try {
|
|
239
|
+
await commands.exec('systemctl', ['--user', 'daemon-reload']);
|
|
240
|
+
}
|
|
241
|
+
catch { /* systemd may not exist. */ }
|
|
242
|
+
return removedUnit && removedDesktop ? 'removed' : 'failed';
|
|
243
|
+
}
|
|
244
|
+
export async function installService(options) {
|
|
245
|
+
const platform = options.platform ?? process.platform;
|
|
246
|
+
const env = options.env ?? process.env;
|
|
247
|
+
const io = options.io ?? defaultIo;
|
|
248
|
+
const commands = options.commands ?? defaultCommands;
|
|
249
|
+
const paths = servicePaths(options.paths);
|
|
250
|
+
const config = serviceConfig(options.cliPath, options.cwd ?? process.cwd(), env, options.nodePath, platform);
|
|
251
|
+
if (isNpxCachePath(config.cliPath)) {
|
|
252
|
+
throw new Error('This copy is running from the temporary npx cache. Run `npm install -g @withtruing/companion` first, then run `truing install`.');
|
|
253
|
+
}
|
|
254
|
+
const legacyCleanup = await removeLegacyStartup(platform, paths, io, commands, options.uid ?? process.getuid?.() ?? 0);
|
|
255
|
+
// The old package shares these service files, so stop it before its saved CLI path is replaced.
|
|
256
|
+
await stopService(options);
|
|
257
|
+
const legacyOutput = legacyCleanup === 'removed'
|
|
258
|
+
? ["Removed the old Product Console companion's automatic startup."]
|
|
259
|
+
: legacyCleanup === 'failed'
|
|
260
|
+
? ["Could not completely remove the old Product Console companion's automatic startup; continuing with Truing companion installation."]
|
|
261
|
+
: [];
|
|
262
|
+
io.mkdir(paths.serviceDir);
|
|
263
|
+
writeFile(io, paths.serviceFile, `${JSON.stringify(config, null, 2)}\n`, 0o600);
|
|
264
|
+
io.remove(paths.stopFile);
|
|
265
|
+
if (platform === 'win32') {
|
|
266
|
+
const windowsDirectory = options.windowsDirectory ?? env.SystemRoot ?? 'C:\\Windows';
|
|
267
|
+
writeFile(io, paths.windowsLauncher, windowsLauncherContents(config));
|
|
268
|
+
writeFile(io, paths.windowsShortcutScript, windowsShortcutContents(paths, windowsDirectory));
|
|
269
|
+
io.remove(paths.windowsStartupShortcut);
|
|
270
|
+
await commands.exec('cscript.exe', ['//nologo', paths.windowsShortcutScript]);
|
|
271
|
+
await commands.exec('cscript.exe', ['//nologo', paths.windowsLauncher]);
|
|
272
|
+
return [...legacyOutput,
|
|
273
|
+
'Truing companion will now start silently when you sign in to Windows, and it has been started now.',
|
|
274
|
+
'Run `truing stop` to stop it, or `truing uninstall` to remove automatic startup.',
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
if (platform === 'darwin') {
|
|
278
|
+
writeFile(io, paths.macLaunchAgent, macLaunchAgentContents(config, paths));
|
|
279
|
+
const domain = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
|
|
280
|
+
// A reinstall replaces an agent that is already loaded; bootstrap refuses a loaded label.
|
|
281
|
+
try {
|
|
282
|
+
await commands.exec('launchctl', ['bootout', domain, paths.macLaunchAgent]);
|
|
283
|
+
}
|
|
284
|
+
catch { /* Not loaded yet. */ }
|
|
285
|
+
try {
|
|
286
|
+
await commands.exec('launchctl', ['bootstrap', domain, paths.macLaunchAgent]);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
await commands.exec('launchctl', ['load', '-w', paths.macLaunchAgent]);
|
|
290
|
+
}
|
|
291
|
+
return [...legacyOutput,
|
|
292
|
+
'Truing companion will now start silently when you sign in to your Mac, and it has been started now.',
|
|
293
|
+
'Run `truing stop` to stop it, or `truing uninstall` to remove automatic startup.',
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
writeFile(io, paths.linuxSystemdUnit, linuxSystemdUnitContents(config, paths));
|
|
297
|
+
try {
|
|
298
|
+
await commands.exec('systemctl', ['--user', 'daemon-reload']);
|
|
299
|
+
await commands.exec('systemctl', ['--user', 'enable', '--now', 'truing-companion.service']);
|
|
300
|
+
return [...legacyOutput,
|
|
301
|
+
'Truing companion was installed as a systemd user service and has been started now.',
|
|
302
|
+
'Run `truing stop` to stop it, or `truing uninstall` to remove automatic startup.',
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
writeFile(io, paths.linuxDesktopFile, linuxDesktopContents(config));
|
|
307
|
+
commands.spawnDetached(config.nodePath, [config.cliPath, 'daemon'], {
|
|
308
|
+
cwd: config.cwd,
|
|
309
|
+
env: { ...process.env, ...config.env },
|
|
310
|
+
windowsHide: true,
|
|
311
|
+
});
|
|
312
|
+
return [...legacyOutput,
|
|
313
|
+
'Truing companion will now start silently through desktop autostart, and it has been started now.',
|
|
314
|
+
'Run `truing stop` to stop it, or `truing uninstall` to remove automatic startup.',
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
export function readServiceConfig(paths = servicePaths(), io = defaultIo) {
|
|
319
|
+
if (!io.exists(paths.serviceFile)) {
|
|
320
|
+
throw new Error('The background companion is not installed. Run `truing install` first.');
|
|
321
|
+
}
|
|
322
|
+
return JSON.parse(io.read(paths.serviceFile));
|
|
323
|
+
}
|
|
324
|
+
function readPidRecord(paths, io = defaultIo) {
|
|
325
|
+
if (!io.exists(paths.pidFile))
|
|
326
|
+
return null;
|
|
327
|
+
try {
|
|
328
|
+
const record = JSON.parse(io.read(paths.pidFile));
|
|
329
|
+
return Number.isSafeInteger(record.pid) && record.pid > 0 ? record : null;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function processAlive(pid) {
|
|
336
|
+
try {
|
|
337
|
+
process.kill(pid, 0);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return error.code === 'EPERM';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function currentBootTime() {
|
|
345
|
+
return Date.now() - uptime() * 1000;
|
|
346
|
+
}
|
|
347
|
+
function recordAlive(record) {
|
|
348
|
+
if (record.bootTime !== undefined
|
|
349
|
+
&& Math.abs(record.bootTime - currentBootTime()) > BOOT_TIME_TOLERANCE_MS)
|
|
350
|
+
return false;
|
|
351
|
+
return processAlive(record.pid);
|
|
352
|
+
}
|
|
353
|
+
function pidRecord(pid, role, childPid) {
|
|
354
|
+
return { pid, role, ...(childPid ? { childPid } : {}), bootTime: Math.round(currentBootTime()) };
|
|
355
|
+
}
|
|
356
|
+
export function acquireInstanceLock(role, paths = servicePaths(), pid = process.pid) {
|
|
357
|
+
mkdirSync(paths.serviceDir, { recursive: true });
|
|
358
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
359
|
+
try {
|
|
360
|
+
const fd = openSync(paths.pidFile, 'wx', 0o600);
|
|
361
|
+
writeSync(fd, `${JSON.stringify(pidRecord(pid, role))}\n`);
|
|
362
|
+
closeSync(fd);
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
if (error.code !== 'EEXIST')
|
|
367
|
+
throw error;
|
|
368
|
+
const current = readPidRecord(paths);
|
|
369
|
+
if (current && recordAlive(current))
|
|
370
|
+
return false;
|
|
371
|
+
try {
|
|
372
|
+
unlinkSync(paths.pidFile);
|
|
373
|
+
}
|
|
374
|
+
catch (removeError) {
|
|
375
|
+
if (removeError.code !== 'ENOENT')
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
export function updateInstanceLock(role, childPid, paths = servicePaths()) {
|
|
383
|
+
writeFileSync(paths.pidFile, `${JSON.stringify(pidRecord(process.pid, role, childPid))}\n`, { mode: 0o600 });
|
|
384
|
+
}
|
|
385
|
+
export function releaseInstanceLock(paths = servicePaths(), pid = process.pid) {
|
|
386
|
+
const record = readPidRecord(paths);
|
|
387
|
+
if (record?.pid === pid)
|
|
388
|
+
defaultIo.remove(paths.pidFile);
|
|
389
|
+
}
|
|
390
|
+
function rotateLog(paths) {
|
|
391
|
+
try {
|
|
392
|
+
if (statSync(paths.logFile).size < MAX_LOG_BYTES)
|
|
393
|
+
return;
|
|
394
|
+
defaultIo.remove(paths.oldLogFile);
|
|
395
|
+
renameSync(paths.logFile, paths.oldLogFile);
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
if (error.code !== 'ENOENT')
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
export function appendServiceLog(paths, message) {
|
|
403
|
+
mkdirSync(paths.serviceDir, { recursive: true });
|
|
404
|
+
rotateLog(paths);
|
|
405
|
+
const timestamp = new Date().toISOString();
|
|
406
|
+
const lines = message.replace(/\r\n/g, '\n').split('\n').filter((line) => line.length > 0);
|
|
407
|
+
if (lines.length === 0)
|
|
408
|
+
return;
|
|
409
|
+
const fd = openSync(paths.logFile, 'a', 0o600);
|
|
410
|
+
try {
|
|
411
|
+
writeSync(fd, lines.map((line) => `[${timestamp}] ${line}\n`).join(''));
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
closeSync(fd);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function defaultSpawnChild(config) {
|
|
418
|
+
return spawn(config.nodePath, [config.cliPath, 'run'], {
|
|
419
|
+
cwd: config.cwd,
|
|
420
|
+
env: { ...process.env, ...config.env, PRODUCT_CONSOLE_COMPANION_DAEMON_CHILD: '1' },
|
|
421
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
422
|
+
windowsHide: true,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/** On Windows `kill()` ends only the run process and would leave its Claude / Codex / git
|
|
426
|
+
* children behind, so the whole tree goes. */
|
|
427
|
+
function defaultTerminateChild(child) {
|
|
428
|
+
if (process.platform === 'win32' && child.pid) {
|
|
429
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
430
|
+
killer.on('error', () => child.kill());
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
child.kill();
|
|
434
|
+
}
|
|
435
|
+
function childExit(child) {
|
|
436
|
+
return new Promise((resolvePromise) => {
|
|
437
|
+
child.once('close', (code, signal) => resolvePromise({ code, signal }));
|
|
438
|
+
child.once('error', () => resolvePromise({ code: 1, signal: null }));
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
export class DaemonRestartPolicy {
|
|
442
|
+
exits = [];
|
|
443
|
+
nextDelay(now) {
|
|
444
|
+
this.exits = this.exits.filter((time) => now - time <= EXIT_WINDOW_MS);
|
|
445
|
+
this.exits.push(now);
|
|
446
|
+
if (this.exits.length < 5)
|
|
447
|
+
return RESTART_DELAY_MS;
|
|
448
|
+
this.exits = [];
|
|
449
|
+
return CRASH_PAUSE_MS;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function waitForDelayOrStop(delayMs, clock, stopRequested) {
|
|
453
|
+
let remaining = delayMs;
|
|
454
|
+
while (remaining > 0) {
|
|
455
|
+
if (stopRequested())
|
|
456
|
+
return true;
|
|
457
|
+
const step = Math.min(1000, remaining);
|
|
458
|
+
await clock.sleep(step);
|
|
459
|
+
remaining -= step;
|
|
460
|
+
}
|
|
461
|
+
return stopRequested();
|
|
462
|
+
}
|
|
463
|
+
export async function runDaemon(options = {}) {
|
|
464
|
+
const paths = options.paths ?? servicePaths();
|
|
465
|
+
const config = options.config ?? readServiceConfig(paths);
|
|
466
|
+
const clock = options.clock ?? { now: Date.now, sleep: (ms) => new Promise((done) => setTimeout(done, ms)) };
|
|
467
|
+
const spawnChild = options.spawnChild ?? defaultSpawnChild;
|
|
468
|
+
const stopRequested = options.stopRequested ?? (() => existsSync(paths.stopFile));
|
|
469
|
+
const acquireLock = options.acquireLock ?? (() => acquireInstanceLock('daemon', paths));
|
|
470
|
+
const updateLock = options.updateLock ?? ((childPid) => updateInstanceLock('daemon', childPid, paths));
|
|
471
|
+
const releaseLock = options.releaseLock ?? (() => releaseInstanceLock(paths));
|
|
472
|
+
const writeLog = options.appendLog ?? ((line) => appendServiceLog(paths, line));
|
|
473
|
+
const terminateChild = options.terminateChild ?? defaultTerminateChild;
|
|
474
|
+
const logger = options.logger ?? console;
|
|
475
|
+
// A log that can't be written (disk full, file briefly locked) must not take the supervisor down.
|
|
476
|
+
const appendLog = (line) => {
|
|
477
|
+
try {
|
|
478
|
+
writeLog(line);
|
|
479
|
+
}
|
|
480
|
+
catch { /* Best effort. */ }
|
|
481
|
+
};
|
|
482
|
+
if (!acquireLock()) {
|
|
483
|
+
logger.log('A companion daemon or foreground run is already active; this daemon will exit.');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
defaultIo.remove(paths.stopFile);
|
|
487
|
+
let stopping = false;
|
|
488
|
+
let child = null;
|
|
489
|
+
const requestStop = () => {
|
|
490
|
+
stopping = true;
|
|
491
|
+
if (child)
|
|
492
|
+
terminateChild(child);
|
|
493
|
+
};
|
|
494
|
+
process.on('SIGINT', requestStop);
|
|
495
|
+
process.on('SIGTERM', requestStop);
|
|
496
|
+
// The STOP file is watched on its own short timer, so a stop ends a running child promptly
|
|
497
|
+
// without the restart clock having to tick while the child runs.
|
|
498
|
+
const stopWatch = setInterval(() => {
|
|
499
|
+
if (!stopping && stopRequested())
|
|
500
|
+
requestStop();
|
|
501
|
+
}, options.stopPollMs ?? 1000);
|
|
502
|
+
const shouldStop = () => stopping || stopRequested();
|
|
503
|
+
const policy = new DaemonRestartPolicy();
|
|
504
|
+
try {
|
|
505
|
+
appendLog('Background supervisor started.');
|
|
506
|
+
while (!shouldStop()) {
|
|
507
|
+
child = spawnChild(config);
|
|
508
|
+
updateLock(child.pid);
|
|
509
|
+
child.stdout?.on('data', (chunk) => appendLog(String(chunk)));
|
|
510
|
+
child.stderr?.on('data', (chunk) => appendLog(String(chunk)));
|
|
511
|
+
const result = await childExit(child);
|
|
512
|
+
child = null;
|
|
513
|
+
updateLock(undefined);
|
|
514
|
+
if (shouldStop())
|
|
515
|
+
break;
|
|
516
|
+
const delay = policy.nextDelay(clock.now());
|
|
517
|
+
appendLog(`Run process exited (code ${result.code ?? 'none'}, signal ${result.signal ?? 'none'}); restarting in ${Math.round(delay / 1000)} seconds.`);
|
|
518
|
+
if (await waitForDelayOrStop(delay, clock, shouldStop))
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
appendLog('Background supervisor stopped.');
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
logger.error(`Background supervisor failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
clearInterval(stopWatch);
|
|
529
|
+
if (child)
|
|
530
|
+
terminateChild(child);
|
|
531
|
+
releaseLock();
|
|
532
|
+
defaultIo.remove(paths.stopFile);
|
|
533
|
+
process.off('SIGINT', requestStop);
|
|
534
|
+
process.off('SIGTERM', requestStop);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
export async function startService(options = {}) {
|
|
538
|
+
const paths = servicePaths(options.paths);
|
|
539
|
+
const config = readServiceConfig(paths, options.io ?? defaultIo);
|
|
540
|
+
const commands = options.commands ?? defaultCommands;
|
|
541
|
+
(options.io ?? defaultIo).remove(paths.stopFile);
|
|
542
|
+
if ((options.platform ?? process.platform) === 'win32' && (options.io ?? defaultIo).exists(paths.windowsLauncher)) {
|
|
543
|
+
await commands.exec('cscript.exe', ['//nologo', paths.windowsLauncher]);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
commands.spawnDetached(config.nodePath, [config.cliPath, 'daemon'], {
|
|
547
|
+
cwd: config.cwd,
|
|
548
|
+
env: { ...process.env, ...config.env },
|
|
549
|
+
windowsHide: true,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
async function killPid(pid, platform, commands) {
|
|
553
|
+
if (pid === process.pid || !processAlive(pid))
|
|
554
|
+
return;
|
|
555
|
+
if (platform === 'win32') {
|
|
556
|
+
try {
|
|
557
|
+
await commands.exec('taskkill.exe', ['/pid', String(pid), '/T', '/F']);
|
|
558
|
+
}
|
|
559
|
+
catch { /* Already gone. */ }
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
process.kill(pid, 'SIGTERM');
|
|
564
|
+
}
|
|
565
|
+
catch { /* Already gone. */ }
|
|
566
|
+
}
|
|
567
|
+
const WINDOWS_PROCESS_LIST = 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId)`t$($_.CommandLine)" }';
|
|
568
|
+
/** Finds this install's daemon / run processes when there is no usable pid file. Current Windows 11
|
|
569
|
+
* no longer ships wmic, so the process list comes from a hidden, non-interactive CIM query. */
|
|
570
|
+
export async function fallbackPids(config, platform, commands) {
|
|
571
|
+
const isCompanion = (commandLine) => commandLine.includes(config.cliPath)
|
|
572
|
+
&& /\s"?(?:daemon|run)"?(?:\s|$)/.test(commandLine);
|
|
573
|
+
const valid = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid !== process.pid;
|
|
574
|
+
try {
|
|
575
|
+
if (platform === 'win32') {
|
|
576
|
+
const { stdout } = await commands.exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROCESS_LIST]);
|
|
577
|
+
return stdout.split(/\r?\n/).map((line) => {
|
|
578
|
+
const tab = line.indexOf('\t');
|
|
579
|
+
return tab > 0 && isCompanion(line.slice(tab + 1)) ? Number(line.slice(0, tab).trim()) : 0;
|
|
580
|
+
}).filter(valid);
|
|
581
|
+
}
|
|
582
|
+
const { stdout } = await commands.exec('ps', ['-eo', 'pid=,args=']);
|
|
583
|
+
return stdout.split('\n').map((line) => {
|
|
584
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
585
|
+
return match && isCompanion(match[2]) ? Number(match[1]) : 0;
|
|
586
|
+
}).filter(valid);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/** Asks the daemon to stop through the STOP file (and SIGTERM where signals exist), gives it a few
|
|
593
|
+
* seconds to end its run process and clean up, then force-ends whatever is still alive. */
|
|
594
|
+
export async function stopService(options = {}) {
|
|
595
|
+
const paths = servicePaths(options.paths);
|
|
596
|
+
const io = options.io ?? defaultIo;
|
|
597
|
+
const commands = options.commands ?? defaultCommands;
|
|
598
|
+
const platform = options.platform ?? process.platform;
|
|
599
|
+
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
600
|
+
const isAlive = options.isAlive ?? processAlive;
|
|
601
|
+
io.mkdir(paths.serviceDir);
|
|
602
|
+
io.write(paths.stopFile, `${new Date().toISOString()}\n`, 0o600);
|
|
603
|
+
const stored = readPidRecord(paths, io);
|
|
604
|
+
const record = stored && recordAlive(stored) ? stored : null;
|
|
605
|
+
const config = io.exists(paths.serviceFile) ? readServiceConfig(paths, io) : null;
|
|
606
|
+
const pids = new Set();
|
|
607
|
+
if (record?.childPid)
|
|
608
|
+
pids.add(record.childPid);
|
|
609
|
+
if (record)
|
|
610
|
+
pids.add(record.pid);
|
|
611
|
+
if (pids.size === 0 && config) {
|
|
612
|
+
for (const pid of await fallbackPids(config, platform, commands))
|
|
613
|
+
pids.add(pid);
|
|
614
|
+
}
|
|
615
|
+
if (record?.role === 'daemon') {
|
|
616
|
+
if (platform !== 'win32') {
|
|
617
|
+
try {
|
|
618
|
+
process.kill(record.pid, 'SIGTERM');
|
|
619
|
+
}
|
|
620
|
+
catch { /* Already gone. */ }
|
|
621
|
+
}
|
|
622
|
+
for (let waited = 0; waited < STOP_GRACE_MS && isAlive(record.pid); waited += 250)
|
|
623
|
+
await sleep(250);
|
|
624
|
+
}
|
|
625
|
+
for (const pid of pids) {
|
|
626
|
+
if (isAlive(pid))
|
|
627
|
+
await killPid(pid, platform, commands);
|
|
628
|
+
}
|
|
629
|
+
// A force-ended daemon can't remove its own pid file; a stale one must not block the next start.
|
|
630
|
+
if (stored && !(record && isAlive(record.pid)))
|
|
631
|
+
io.remove(paths.pidFile);
|
|
632
|
+
return pids.size > 0;
|
|
633
|
+
}
|
|
634
|
+
export async function uninstallService(options = {}) {
|
|
635
|
+
const platform = options.platform ?? process.platform;
|
|
636
|
+
const io = options.io ?? defaultIo;
|
|
637
|
+
const commands = options.commands ?? defaultCommands;
|
|
638
|
+
const paths = servicePaths(options.paths);
|
|
639
|
+
await stopService(options);
|
|
640
|
+
const legacyCleanup = await removeLegacyStartup(platform, paths, io, commands, options.uid ?? process.getuid?.() ?? 0);
|
|
641
|
+
if (platform === 'win32') {
|
|
642
|
+
io.remove(paths.windowsStartupShortcut);
|
|
643
|
+
io.remove(paths.windowsLauncher);
|
|
644
|
+
io.remove(paths.windowsShortcutScript);
|
|
645
|
+
}
|
|
646
|
+
else if (platform === 'darwin') {
|
|
647
|
+
try {
|
|
648
|
+
await commands.exec('launchctl', ['bootout', `gui/${options.uid ?? process.getuid?.() ?? 0}`, paths.macLaunchAgent]);
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
try {
|
|
652
|
+
await commands.exec('launchctl', ['unload', '-w', paths.macLaunchAgent]);
|
|
653
|
+
}
|
|
654
|
+
catch { /* Not loaded. */ }
|
|
655
|
+
}
|
|
656
|
+
io.remove(paths.macLaunchAgent);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
try {
|
|
660
|
+
await commands.exec('systemctl', ['--user', 'disable', '--now', 'truing-companion.service']);
|
|
661
|
+
}
|
|
662
|
+
catch { /* systemd may not exist. */ }
|
|
663
|
+
io.remove(paths.linuxSystemdUnit);
|
|
664
|
+
io.remove(paths.linuxDesktopFile);
|
|
665
|
+
try {
|
|
666
|
+
await commands.exec('systemctl', ['--user', 'daemon-reload']);
|
|
667
|
+
}
|
|
668
|
+
catch { /* systemd may not exist. */ }
|
|
669
|
+
}
|
|
670
|
+
io.remove(paths.serviceFile);
|
|
671
|
+
io.remove(paths.stopFile);
|
|
672
|
+
return [
|
|
673
|
+
...(legacyCleanup === 'removed'
|
|
674
|
+
? ["Removed the old Product Console companion's automatic startup."]
|
|
675
|
+
: legacyCleanup === 'failed'
|
|
676
|
+
? ["Could not completely remove the old Product Console companion's automatic startup; continuing with Truing companion uninstall."]
|
|
677
|
+
: []),
|
|
678
|
+
'Truing companion automatic startup was removed and the background process was stopped.',
|
|
679
|
+
'Your pairings and logs were kept.',
|
|
680
|
+
];
|
|
681
|
+
}
|
|
682
|
+
export function serviceStatus(paths = servicePaths(), io = defaultIo) {
|
|
683
|
+
const record = readPidRecord(paths, io);
|
|
684
|
+
const config = loadConfig();
|
|
685
|
+
let logLines = [];
|
|
686
|
+
if (io.exists(paths.logFile)) {
|
|
687
|
+
logLines = io.read(paths.logFile).trimEnd().split(/\r?\n/).slice(-5);
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
installed: io.exists(paths.serviceFile) && (io.exists(paths.windowsStartupShortcut) || io.exists(paths.macLaunchAgent)
|
|
691
|
+
|| io.exists(paths.linuxSystemdUnit) || io.exists(paths.linuxDesktopFile)),
|
|
692
|
+
running: record !== null && recordAlive(record),
|
|
693
|
+
role: record?.role ?? null,
|
|
694
|
+
pid: record?.pid ?? null,
|
|
695
|
+
logLines,
|
|
696
|
+
projects: (config?.pairings ?? []).flatMap((pairing) => (pairing.connections.map(({ connectionId, repoPath }) => ({ connectionId, repoPath })))),
|
|
697
|
+
pairings: (config?.pairings ?? []).map((pairing) => ({
|
|
698
|
+
url: pairing.url,
|
|
699
|
+
workspaceId: pairing.workspaceId,
|
|
700
|
+
projects: pairing.connections.map(({ connectionId, repoPath, managed }) => ({
|
|
701
|
+
connectionId,
|
|
702
|
+
repoPath,
|
|
703
|
+
...(managed ? { managed } : {}),
|
|
704
|
+
})),
|
|
705
|
+
workspaceTasks: pairing.pollWorkspaceTasks
|
|
706
|
+
? { backend: pairing.workspaceBackend ?? 'api-key' }
|
|
707
|
+
: undefined,
|
|
708
|
+
})),
|
|
709
|
+
};
|
|
710
|
+
}
|