@addai/node 0.18.0 → 0.20.0
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/dist/autostart-linux.d.ts +69 -0
- package/dist/autostart-linux.js +390 -0
- package/dist/autostart-mac.d.ts +0 -34
- package/dist/autostart-mac.js +1 -26
- package/dist/autostart.d.ts +22 -1
- package/dist/autostart.js +41 -7
- package/dist/claude-binary.d.ts +19 -0
- package/dist/claude-binary.js +30 -0
- package/dist/command-runner.js +80 -2
- package/dist/desktop/install-engine.d.ts +16 -1
- package/dist/desktop/install-engine.js +44 -2
- package/dist/desktop/start-engine.d.ts +44 -0
- package/dist/desktop/start-engine.js +167 -0
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AutostartBackend, AutostartEntry } from './autostart';
|
|
2
|
+
export declare const USER_UNIT_DIR: string;
|
|
3
|
+
export declare const UNIT_PATH: string;
|
|
4
|
+
/** systemd records an `enable` as a symlink here; its presence IS "enabled". */
|
|
5
|
+
export declare const WANTS_PATH: string;
|
|
6
|
+
/** Where systemd records that a user may linger. Readable without a subprocess,
|
|
7
|
+
* which matters because status() rides every heartbeat. */
|
|
8
|
+
export declare function lingerPath(user?: string): string;
|
|
9
|
+
export declare function currentUser(env?: NodeJS.ProcessEnv): string;
|
|
10
|
+
export interface UnitSpec {
|
|
11
|
+
entry: AutostartEntry;
|
|
12
|
+
env: Record<string, string>;
|
|
13
|
+
logPath: string;
|
|
14
|
+
workingDirectory: string;
|
|
15
|
+
/**
|
|
16
|
+
* `append:` writes straight to our own log file, which is where every other
|
|
17
|
+
* platform puts it and where the console's Logs screen looks. It needs
|
|
18
|
+
* systemd 240+; older releases fail to load the unit entirely rather than
|
|
19
|
+
* ignoring the directive, so anything older is sent to the journal instead.
|
|
20
|
+
*/
|
|
21
|
+
logMode: 'append' | 'journal';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The unit file, as a string. Pure — this is what the tests assert on.
|
|
25
|
+
*
|
|
26
|
+
* Restart=always mirrors launchd's KeepAlive: the node is meant to be
|
|
27
|
+
* always-on, and a remote roll depends on it. A supervised `update_runtime`
|
|
28
|
+
* installs the new version and simply EXITS, and the supervisor starting the
|
|
29
|
+
* replacement is what makes that safe (see index.ts and isSupervised).
|
|
30
|
+
*
|
|
31
|
+
* RestartSec matches macOS's ThrottleInterval so a crash loop is one launch
|
|
32
|
+
* per 10s on both rather than a spin.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildUnit(spec: UnitSpec): string;
|
|
35
|
+
/** Pull ExecStart back out of a unit we wrote, for `status`. */
|
|
36
|
+
export declare function parseUnitEntry(unit: string): AutostartEntry | undefined;
|
|
37
|
+
/** systemd's major version, or null when it can't be read. */
|
|
38
|
+
export declare function systemdVersion(probe?: () => string | null): number | null;
|
|
39
|
+
/**
|
|
40
|
+
* Is this machine actually running systemd?
|
|
41
|
+
*
|
|
42
|
+
* `/run/systemd/system` is the documented test for "booted with systemd" —
|
|
43
|
+
* plenty of containers and a few distros have the binaries present but no
|
|
44
|
+
* running manager, and there the unit would be written and never run.
|
|
45
|
+
*/
|
|
46
|
+
export declare function systemdAvailable(exists?: (p: string) => boolean): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Normalise `systemctl is-active` output.
|
|
49
|
+
*
|
|
50
|
+
* It prints one word and exits non-zero for anything but `active`, so the exit
|
|
51
|
+
* code carries no information the word doesn't.
|
|
52
|
+
*/
|
|
53
|
+
export declare function normaliseActiveState(out: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Did the unit actually come up?
|
|
56
|
+
*
|
|
57
|
+
* The truth contract this codebase applies to harness logins, applied here:
|
|
58
|
+
* the exit code is a hint, the probe is the verdict. `systemctl enable --now`
|
|
59
|
+
* returns 0 for "request accepted", which is not the same as a running node —
|
|
60
|
+
* a unit can be accepted and then fail its very first ExecStart because a path
|
|
61
|
+
* is wrong or the runtime is missing, and reporting that as success is the
|
|
62
|
+
* failure mode that hurts most, because nothing looks wrong until an entity
|
|
63
|
+
* sits at "Getting ready…" forever.
|
|
64
|
+
*
|
|
65
|
+
* `activating` is given a few seconds rather than being called a failure; a
|
|
66
|
+
* unit that has genuinely failed reports `failed` immediately.
|
|
67
|
+
*/
|
|
68
|
+
export declare function waitUntilActive(probe?: () => string, attempts?: number, sleepMs?: number, sleep?: (ms: number) => void): string;
|
|
69
|
+
export declare const backend: AutostartBackend;
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Linux backend: a `systemd --user` unit.
|
|
3
|
+
//
|
|
4
|
+
// A USER unit, not a system one, for the same reason macOS gets a LaunchAgent
|
|
5
|
+
// and Windows a logon task rather than a service: the daemon needs the user's
|
|
6
|
+
// PATH, their ~/.claude and ~/.codex, and their keyring. A root unit would
|
|
7
|
+
// faithfully boot a node that then fails every single run.
|
|
8
|
+
//
|
|
9
|
+
// The piece with no equivalent on the other two platforms is LINGER. A user
|
|
10
|
+
// manager normally starts at the user's first login and stops when their last
|
|
11
|
+
// session ends, so on a server — where nobody logs in, and where an ssh session
|
|
12
|
+
// ending is the normal case — a user unit without linger is a node that dies
|
|
13
|
+
// the moment you disconnect. `loginctl enable-linger` is what makes the user
|
|
14
|
+
// manager start at BOOT and stay up. On a headless box it is not a nicety;
|
|
15
|
+
// without it this feature does not work at all.
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.backend = exports.WANTS_PATH = exports.UNIT_PATH = exports.USER_UNIT_DIR = void 0;
|
|
51
|
+
exports.lingerPath = lingerPath;
|
|
52
|
+
exports.currentUser = currentUser;
|
|
53
|
+
exports.buildUnit = buildUnit;
|
|
54
|
+
exports.parseUnitEntry = parseUnitEntry;
|
|
55
|
+
exports.systemdVersion = systemdVersion;
|
|
56
|
+
exports.systemdAvailable = systemdAvailable;
|
|
57
|
+
exports.normaliseActiveState = normaliseActiveState;
|
|
58
|
+
exports.waitUntilActive = waitUntilActive;
|
|
59
|
+
const fs = __importStar(require("fs"));
|
|
60
|
+
const os = __importStar(require("os"));
|
|
61
|
+
const path = __importStar(require("path"));
|
|
62
|
+
const child_process_1 = require("child_process");
|
|
63
|
+
const autostart_1 = require("./autostart");
|
|
64
|
+
exports.USER_UNIT_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
65
|
+
exports.UNIT_PATH = path.join(exports.USER_UNIT_DIR, autostart_1.SERVICE_NAME);
|
|
66
|
+
/** systemd records an `enable` as a symlink here; its presence IS "enabled". */
|
|
67
|
+
exports.WANTS_PATH = path.join(exports.USER_UNIT_DIR, 'default.target.wants', autostart_1.SERVICE_NAME);
|
|
68
|
+
/** Where systemd records that a user may linger. Readable without a subprocess,
|
|
69
|
+
* which matters because status() rides every heartbeat. */
|
|
70
|
+
function lingerPath(user = currentUser()) {
|
|
71
|
+
return path.join('/var/lib/systemd/linger', user);
|
|
72
|
+
}
|
|
73
|
+
function currentUser(env = process.env) {
|
|
74
|
+
if (env.USER)
|
|
75
|
+
return env.USER;
|
|
76
|
+
if (env.LOGNAME)
|
|
77
|
+
return env.LOGNAME;
|
|
78
|
+
try {
|
|
79
|
+
return os.userInfo().username;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* systemd writes `Environment=` as an ini-ish line, so a value carrying a
|
|
87
|
+
* space, a `#` or a quote has to be quoted and escaped or the unit silently
|
|
88
|
+
* parses wrong. A real PATH routinely contains spaces.
|
|
89
|
+
*/
|
|
90
|
+
function envLine(key, value) {
|
|
91
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
92
|
+
return `Environment="${key}=${escaped}"`;
|
|
93
|
+
}
|
|
94
|
+
/** ExecStart takes a command line, so each argument is quoted the same way. */
|
|
95
|
+
function execArg(value) {
|
|
96
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The unit file, as a string. Pure — this is what the tests assert on.
|
|
100
|
+
*
|
|
101
|
+
* Restart=always mirrors launchd's KeepAlive: the node is meant to be
|
|
102
|
+
* always-on, and a remote roll depends on it. A supervised `update_runtime`
|
|
103
|
+
* installs the new version and simply EXITS, and the supervisor starting the
|
|
104
|
+
* replacement is what makes that safe (see index.ts and isSupervised).
|
|
105
|
+
*
|
|
106
|
+
* RestartSec matches macOS's ThrottleInterval so a crash loop is one launch
|
|
107
|
+
* per 10s on both rather than a spin.
|
|
108
|
+
*/
|
|
109
|
+
function buildUnit(spec) {
|
|
110
|
+
const exec = [spec.entry.file, ...spec.entry.args].map(execArg).join(' ');
|
|
111
|
+
const env = Object.entries(spec.env).map(([k, v]) => envLine(k, v)).join('\n');
|
|
112
|
+
const out = spec.logMode === 'append' ? `append:${spec.logPath}` : 'journal';
|
|
113
|
+
return `[Unit]
|
|
114
|
+
Description=+Ai Node
|
|
115
|
+
Documentation=https://node.add.ai
|
|
116
|
+
# The node's first act is to reach Supabase, so starting before the network is
|
|
117
|
+
# up just means a first heartbeat that fails and retries.
|
|
118
|
+
After=network-online.target
|
|
119
|
+
Wants=network-online.target
|
|
120
|
+
|
|
121
|
+
[Service]
|
|
122
|
+
Type=simple
|
|
123
|
+
ExecStart=${exec}
|
|
124
|
+
WorkingDirectory=${spec.workingDirectory}
|
|
125
|
+
Restart=always
|
|
126
|
+
RestartSec=10
|
|
127
|
+
${env}
|
|
128
|
+
StandardOutput=${out}
|
|
129
|
+
StandardError=${out}
|
|
130
|
+
|
|
131
|
+
[Install]
|
|
132
|
+
WantedBy=default.target
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
/** Pull ExecStart back out of a unit we wrote, for `status`. */
|
|
136
|
+
function parseUnitEntry(unit) {
|
|
137
|
+
const line = /^ExecStart=(.*)$/m.exec(unit);
|
|
138
|
+
if (!line)
|
|
139
|
+
return undefined;
|
|
140
|
+
const parts = [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)]
|
|
141
|
+
.map(m => (m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2]));
|
|
142
|
+
if (!parts.length)
|
|
143
|
+
return undefined;
|
|
144
|
+
return { file: parts[0], args: parts.slice(1) };
|
|
145
|
+
}
|
|
146
|
+
function run(file, args) {
|
|
147
|
+
try {
|
|
148
|
+
const out = (0, child_process_1.execFileSync)(file, args, {
|
|
149
|
+
encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
150
|
+
// systemctl --user needs to find its user manager. Over ssh with no
|
|
151
|
+
// seat, or from a daemon started by something that stripped the
|
|
152
|
+
// environment, XDG_RUNTIME_DIR is missing and every call fails with
|
|
153
|
+
// "Failed to connect to bus" — which reads like systemd is broken.
|
|
154
|
+
env: { ...process.env, ...runtimeDirEnv() },
|
|
155
|
+
});
|
|
156
|
+
return { ok: true, output: out.toString().trim() };
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const e = err;
|
|
160
|
+
const stderr = e.stderr ? e.stderr.toString().trim() : '';
|
|
161
|
+
return { ok: false, output: stderr || e.message || `${file} failed` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function runtimeDirEnv() {
|
|
165
|
+
if (process.env.XDG_RUNTIME_DIR)
|
|
166
|
+
return {};
|
|
167
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
168
|
+
if (uid === null)
|
|
169
|
+
return {};
|
|
170
|
+
const guess = `/run/user/${uid}`;
|
|
171
|
+
return fs.existsSync(guess) ? { XDG_RUNTIME_DIR: guess } : {};
|
|
172
|
+
}
|
|
173
|
+
function systemctl(args) {
|
|
174
|
+
return run('systemctl', ['--user', ...args]);
|
|
175
|
+
}
|
|
176
|
+
/** systemd's major version, or null when it can't be read. */
|
|
177
|
+
function systemdVersion(probe = () => {
|
|
178
|
+
const r = run('systemctl', ['--version']);
|
|
179
|
+
return r.ok ? r.output : null;
|
|
180
|
+
}) {
|
|
181
|
+
const raw = probe();
|
|
182
|
+
if (!raw)
|
|
183
|
+
return null;
|
|
184
|
+
const m = /systemd\s+(\d+)/.exec(raw);
|
|
185
|
+
return m ? Number(m[1]) : null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Is this machine actually running systemd?
|
|
189
|
+
*
|
|
190
|
+
* `/run/systemd/system` is the documented test for "booted with systemd" —
|
|
191
|
+
* plenty of containers and a few distros have the binaries present but no
|
|
192
|
+
* running manager, and there the unit would be written and never run.
|
|
193
|
+
*/
|
|
194
|
+
function systemdAvailable(exists = p => fs.existsSync(p)) {
|
|
195
|
+
return exists('/run/systemd/system');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Normalise `systemctl is-active` output.
|
|
199
|
+
*
|
|
200
|
+
* It prints one word and exits non-zero for anything but `active`, so the exit
|
|
201
|
+
* code carries no information the word doesn't.
|
|
202
|
+
*/
|
|
203
|
+
function normaliseActiveState(out) {
|
|
204
|
+
const word = (out || '').trim().split(/\s+/)[0] ?? '';
|
|
205
|
+
return word || 'unknown';
|
|
206
|
+
}
|
|
207
|
+
/** Sleep without going async. `startup enable` is a one-shot CLI command. */
|
|
208
|
+
function nap(ms) {
|
|
209
|
+
const buf = new Int32Array(new SharedArrayBuffer(4));
|
|
210
|
+
Atomics.wait(buf, 0, 0, ms);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Did the unit actually come up?
|
|
214
|
+
*
|
|
215
|
+
* The truth contract this codebase applies to harness logins, applied here:
|
|
216
|
+
* the exit code is a hint, the probe is the verdict. `systemctl enable --now`
|
|
217
|
+
* returns 0 for "request accepted", which is not the same as a running node —
|
|
218
|
+
* a unit can be accepted and then fail its very first ExecStart because a path
|
|
219
|
+
* is wrong or the runtime is missing, and reporting that as success is the
|
|
220
|
+
* failure mode that hurts most, because nothing looks wrong until an entity
|
|
221
|
+
* sits at "Getting ready…" forever.
|
|
222
|
+
*
|
|
223
|
+
* `activating` is given a few seconds rather than being called a failure; a
|
|
224
|
+
* unit that has genuinely failed reports `failed` immediately.
|
|
225
|
+
*/
|
|
226
|
+
function waitUntilActive(probe = () => normaliseActiveState(systemctl(['is-active', autostart_1.SERVICE_NAME]).output), attempts = 8, sleepMs = 500, sleep = nap) {
|
|
227
|
+
let state = 'unknown';
|
|
228
|
+
for (let i = 0; i < attempts; i++) {
|
|
229
|
+
state = probe();
|
|
230
|
+
if (state === 'active' || state === 'failed')
|
|
231
|
+
return state;
|
|
232
|
+
sleep(sleepMs);
|
|
233
|
+
}
|
|
234
|
+
return state;
|
|
235
|
+
}
|
|
236
|
+
/** The last few log lines, for an error message that can be acted on. */
|
|
237
|
+
function recentLog() {
|
|
238
|
+
const r = run('journalctl', ['--user', '-u', autostart_1.SERVICE_NAME, '-n', '15', '--no-pager']);
|
|
239
|
+
return r.ok ? r.output : '';
|
|
240
|
+
}
|
|
241
|
+
function unsupported(reason) {
|
|
242
|
+
return { supported: false, enabled: false, supervisor: null, issues: [reason] };
|
|
243
|
+
}
|
|
244
|
+
function status() {
|
|
245
|
+
if (!systemdAvailable()) {
|
|
246
|
+
return unsupported('this machine is not running systemd, so there is nothing to start the node at boot');
|
|
247
|
+
}
|
|
248
|
+
if (!fs.existsSync(exports.UNIT_PATH)) {
|
|
249
|
+
return { supported: true, enabled: false, supervisor: 'systemd' };
|
|
250
|
+
}
|
|
251
|
+
// Deliberately no `systemctl` call here: status() rides every heartbeat, and
|
|
252
|
+
// the two files on disk are the whole answer. The unit says what to run; the
|
|
253
|
+
// default.target.wants symlink is how systemd records `enable`.
|
|
254
|
+
const unit = fs.readFileSync(exports.UNIT_PATH, 'utf8');
|
|
255
|
+
const entry = parseUnitEntry(unit);
|
|
256
|
+
const enabled = fs.existsSync(exports.WANTS_PATH);
|
|
257
|
+
const issues = [];
|
|
258
|
+
const program = entry?.args[0] ?? entry?.file;
|
|
259
|
+
if (program && !fs.existsSync(program)) {
|
|
260
|
+
issues.push(`the pinned program is gone (${program}) — re-run \`ainode startup enable\``);
|
|
261
|
+
}
|
|
262
|
+
// The one that actually bites on a server, and it is invisible until the
|
|
263
|
+
// first logout takes the node down with it.
|
|
264
|
+
if (enabled && !fs.existsSync(lingerPath())) {
|
|
265
|
+
issues.push('lingering is off, so this node stops when you log out — '
|
|
266
|
+
+ `run \`sudo loginctl enable-linger ${currentUser()}\``);
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
supported: true,
|
|
270
|
+
enabled,
|
|
271
|
+
supervisor: 'systemd',
|
|
272
|
+
entry,
|
|
273
|
+
path: exports.UNIT_PATH,
|
|
274
|
+
...(issues.length ? { issues } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function enable(entry, env, logPath) {
|
|
278
|
+
const warnings = [];
|
|
279
|
+
if (!systemdAvailable()) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
warnings,
|
|
283
|
+
error: 'this machine is not running systemd (no /run/systemd/system), so there is nothing to register with',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const version = systemdVersion();
|
|
287
|
+
// Unknown version is treated as old. Guessing "new" and being wrong means a
|
|
288
|
+
// unit systemd refuses to load at all, which is a worse failure than logs
|
|
289
|
+
// landing in the journal.
|
|
290
|
+
const logMode = version !== null && version >= 240 ? 'append' : 'journal';
|
|
291
|
+
if (logMode === 'journal') {
|
|
292
|
+
warnings.push(`systemd ${version ?? 'of unknown version'} is older than 240, so output goes to the journal `
|
|
293
|
+
+ `instead of ${logPath} — read it with \`journalctl --user -u ${autostart_1.SERVICE_NAME} -f\``);
|
|
294
|
+
}
|
|
295
|
+
fs.mkdirSync(exports.USER_UNIT_DIR, { recursive: true });
|
|
296
|
+
fs.writeFileSync(exports.UNIT_PATH, buildUnit({
|
|
297
|
+
entry,
|
|
298
|
+
env,
|
|
299
|
+
logPath,
|
|
300
|
+
workingDirectory: os.homedir(),
|
|
301
|
+
logMode,
|
|
302
|
+
}), { mode: 0o644 });
|
|
303
|
+
const reload = systemctl(['daemon-reload']);
|
|
304
|
+
if (!reload.ok) {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
path: exports.UNIT_PATH,
|
|
308
|
+
warnings,
|
|
309
|
+
error: `systemctl --user could not be reached: ${reload.output}\n`
|
|
310
|
+
+ 'If this is an ssh session on a server, the user manager may not be running — '
|
|
311
|
+
+ `try \`sudo loginctl enable-linger ${currentUser()}\` and run this again.`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// Linger BEFORE starting, so a node armed over ssh survives the disconnect
|
|
315
|
+
// that usually follows within the minute. Not fatal if it fails: the unit is
|
|
316
|
+
// still correct and still starts at the next login, so say what is missing
|
|
317
|
+
// rather than refusing the whole operation.
|
|
318
|
+
if (!fs.existsSync(lingerPath())) {
|
|
319
|
+
const linger = run('loginctl', ['enable-linger', currentUser()]);
|
|
320
|
+
if (!linger.ok) {
|
|
321
|
+
warnings.push('could not enable lingering, so this node will stop when you log out — '
|
|
322
|
+
+ `run \`sudo loginctl enable-linger ${currentUser()}\` to fix that`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const handover = (0, autostart_1.stopRunningDaemon)();
|
|
326
|
+
if (handover.kind === 'stuck') {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
path: exports.UNIT_PATH,
|
|
330
|
+
warnings,
|
|
331
|
+
error: `a node is already running here (pid ${handover.pid}) and did not stop within 40s — quit it, then run \`ainode startup enable\` again`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (handover.kind === 'self') {
|
|
335
|
+
// Deliberately `enable` without `--now`: starting the unit would spawn a
|
|
336
|
+
// second daemon, which cannot take the lockfile and would be restarted
|
|
337
|
+
// every 10s for as long as this one lives.
|
|
338
|
+
const armed = systemctl(['enable', autostart_1.SERVICE_NAME]);
|
|
339
|
+
if (!armed.ok) {
|
|
340
|
+
return { ok: false, path: exports.UNIT_PATH, warnings, error: `systemctl --user enable failed: ${armed.output}` };
|
|
341
|
+
}
|
|
342
|
+
warnings.push('this node is running right now, so systemd takes over when it next restarts');
|
|
343
|
+
return { ok: true, path: exports.UNIT_PATH, warnings };
|
|
344
|
+
}
|
|
345
|
+
if (handover.kind === 'stopped') {
|
|
346
|
+
warnings.push('stopped the node that was already running; systemd owns it now');
|
|
347
|
+
}
|
|
348
|
+
const armed = systemctl(['enable', '--now', autostart_1.SERVICE_NAME]);
|
|
349
|
+
if (!armed.ok) {
|
|
350
|
+
return { ok: false, path: exports.UNIT_PATH, warnings, error: `systemctl --user enable --now failed: ${armed.output}` };
|
|
351
|
+
}
|
|
352
|
+
// The verdict, not the hint. Everything above this line has only established
|
|
353
|
+
// that systemd accepted the unit.
|
|
354
|
+
const state = waitUntilActive();
|
|
355
|
+
if (state !== 'active') {
|
|
356
|
+
const log = recentLog();
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
path: exports.UNIT_PATH,
|
|
360
|
+
warnings,
|
|
361
|
+
error: [
|
|
362
|
+
`the unit was registered but did not start (systemd reports it "${state}").`,
|
|
363
|
+
`Its definition is at ${exports.UNIT_PATH} and has been left in place so you can look.`,
|
|
364
|
+
'',
|
|
365
|
+
` systemctl --user status ${autostart_1.SERVICE_NAME}`,
|
|
366
|
+
` journalctl --user -u ${autostart_1.SERVICE_NAME} -n 50`,
|
|
367
|
+
...(log ? ['', log] : []),
|
|
368
|
+
].join('\n'),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { ok: true, path: exports.UNIT_PATH, warnings };
|
|
372
|
+
}
|
|
373
|
+
function disable() {
|
|
374
|
+
if (!systemdAvailable())
|
|
375
|
+
return { ok: true }; // nothing was ever registered
|
|
376
|
+
// `disable --now` both stops the running unit and removes the symlink. This
|
|
377
|
+
// takes the node offline, which is what "stop starting at login" has to mean
|
|
378
|
+
// when the thing is running right now — same as the launchd backend.
|
|
379
|
+
systemctl(['disable', '--now', autostart_1.SERVICE_NAME]);
|
|
380
|
+
try {
|
|
381
|
+
fs.unlinkSync(exports.UNIT_PATH);
|
|
382
|
+
}
|
|
383
|
+
catch { /* already gone */ }
|
|
384
|
+
systemctl(['daemon-reload']);
|
|
385
|
+
// Linger is deliberately LEFT ON. It is a property of the user account, not
|
|
386
|
+
// of this node, and a user may well have other lingering units; turning it
|
|
387
|
+
// off here would reach outside what this feature owns.
|
|
388
|
+
return { ok: true };
|
|
389
|
+
}
|
|
390
|
+
exports.backend = { supervisor: 'systemd', status, enable, disable };
|
package/dist/autostart-mac.d.ts
CHANGED
|
@@ -25,38 +25,4 @@ export declare function parsePlistEntry(xml: string): AutostartEntry | undefined
|
|
|
25
25
|
/** Directories macOS gates behind user consent no background agent can give. */
|
|
26
26
|
export declare function protectedPathReason(scriptPath: string, home?: string): string | null;
|
|
27
27
|
export declare function protectedPathError(label: string, scriptPath: string): string;
|
|
28
|
-
/**
|
|
29
|
-
* Hand the node over to launchd.
|
|
30
|
-
*
|
|
31
|
-
* A daemon started by hand holds the lockfile, and `acquireLockfile()` throws
|
|
32
|
-
* for anyone else. Bootstrapping the agent underneath it would therefore mean
|
|
33
|
-
* launchd relaunching a job that exits 1 every ten seconds until the human
|
|
34
|
-
* closes their terminal. So we ask the incumbent to stand down first — SIGTERM
|
|
35
|
-
* runs its normal drain, so live entity turns finish rather than being cut.
|
|
36
|
-
*
|
|
37
|
-
* Except when the incumbent is US. `ainode startup enable` can be pressed from
|
|
38
|
-
* inside the console of the very daemon it is arming, and signalling ourselves
|
|
39
|
-
* there would kill the node the user is looking at. That case writes the plist
|
|
40
|
-
* and stops: ~/Library/LaunchAgents is read at every login, so the change
|
|
41
|
-
* lands at the next one without anybody being killed for it.
|
|
42
|
-
*/
|
|
43
|
-
export type Handover =
|
|
44
|
-
/** Nothing was running — safe to start the agent immediately. */
|
|
45
|
-
{
|
|
46
|
-
kind: 'none';
|
|
47
|
-
}
|
|
48
|
-
/** The running daemon is this very process. Write only, don't bootstrap. */
|
|
49
|
-
| {
|
|
50
|
-
kind: 'self';
|
|
51
|
-
}
|
|
52
|
-
/** Someone else's daemon stood down; the agent can take over now. */
|
|
53
|
-
| {
|
|
54
|
-
kind: 'stopped';
|
|
55
|
-
}
|
|
56
|
-
/** It was asked to stop and didn't. */
|
|
57
|
-
| {
|
|
58
|
-
kind: 'stuck';
|
|
59
|
-
pid: number;
|
|
60
|
-
};
|
|
61
|
-
export declare function stopRunningDaemon(timeoutMs?: number, selfPid?: number): Handover;
|
|
62
28
|
export declare const backend: AutostartBackend;
|
package/dist/autostart-mac.js
CHANGED
|
@@ -43,13 +43,11 @@ exports.buildPlist = buildPlist;
|
|
|
43
43
|
exports.parsePlistEntry = parsePlistEntry;
|
|
44
44
|
exports.protectedPathReason = protectedPathReason;
|
|
45
45
|
exports.protectedPathError = protectedPathError;
|
|
46
|
-
exports.stopRunningDaemon = stopRunningDaemon;
|
|
47
46
|
const fs = __importStar(require("fs"));
|
|
48
47
|
const os = __importStar(require("os"));
|
|
49
48
|
const path = __importStar(require("path"));
|
|
50
49
|
const child_process_1 = require("child_process");
|
|
51
50
|
const autostart_1 = require("./autostart");
|
|
52
|
-
const lockfile_1 = require("./lockfile");
|
|
53
51
|
exports.PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${autostart_1.LABEL}.plist`);
|
|
54
52
|
/** `<string>` bodies are the only place user data lands in the plist, and a
|
|
55
53
|
* PATH entry with an `&` in it would otherwise produce invalid XML that
|
|
@@ -186,29 +184,6 @@ function launchctl(args) {
|
|
|
186
184
|
return { ok: false, output: stderr || e.message || 'launchctl failed' };
|
|
187
185
|
}
|
|
188
186
|
}
|
|
189
|
-
function stopRunningDaemon(timeoutMs = 40_000, selfPid = process.pid) {
|
|
190
|
-
const lock = (0, lockfile_1.readLockfile)();
|
|
191
|
-
if (!lock || !(0, lockfile_1.lockfileAlive)(lock))
|
|
192
|
-
return { kind: 'none' };
|
|
193
|
-
if (lock.pid === selfPid)
|
|
194
|
-
return { kind: 'self' };
|
|
195
|
-
try {
|
|
196
|
-
process.kill(lock.pid, 'SIGTERM');
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
return { kind: 'stopped' };
|
|
200
|
-
}
|
|
201
|
-
// Synchronous poll: `startup enable` is a one-shot CLI command, and making
|
|
202
|
-
// the whole backend async to sleep 250ms would buy nothing.
|
|
203
|
-
const deadline = Date.now() + timeoutMs;
|
|
204
|
-
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
205
|
-
while (Date.now() < deadline) {
|
|
206
|
-
Atomics.wait(nap, 0, 0, 250);
|
|
207
|
-
if (!(0, lockfile_1.lockfileAlive)((0, lockfile_1.readLockfile)()))
|
|
208
|
-
return { kind: 'stopped' };
|
|
209
|
-
}
|
|
210
|
-
return { kind: 'stuck', pid: lock.pid };
|
|
211
|
-
}
|
|
212
187
|
function enable(entry, env, logPath, opts = {}) {
|
|
213
188
|
const warnings = [];
|
|
214
189
|
const script = entry.args[0] ?? entry.file;
|
|
@@ -232,7 +207,7 @@ function enable(entry, env, logPath, opts = {}) {
|
|
|
232
207
|
// handover would sit there watching a lockfile that keeps coming back and
|
|
233
208
|
// conclude the node is stuck. Booting out first makes the stop stick.
|
|
234
209
|
launchctl(['bootout', `${domain()}/${autostart_1.LABEL}`]);
|
|
235
|
-
const handover = stopRunningDaemon();
|
|
210
|
+
const handover = (0, autostart_1.stopRunningDaemon)();
|
|
236
211
|
if (handover.kind === 'stuck') {
|
|
237
212
|
return {
|
|
238
213
|
ok: false,
|
package/dist/autostart.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Supervisor = 'launchd' | 'schtasks';
|
|
1
|
+
export type Supervisor = 'launchd' | 'schtasks' | 'systemd';
|
|
2
2
|
/** The exact command a supervisor is told to run. */
|
|
3
3
|
export interface AutostartEntry {
|
|
4
4
|
file: string;
|
|
@@ -43,6 +43,27 @@ export declare const LABEL = "ai.add.node";
|
|
|
43
43
|
/** Task Scheduler's name for the same thing. Space-free: `schtasks` quoting
|
|
44
44
|
* through cmd.exe is a known source of Windows breakage in this codebase. */
|
|
45
45
|
export declare const TASK_NAME = "AiNode";
|
|
46
|
+
/** systemd's name for the same thing — a per-user unit, never a system one. */
|
|
47
|
+
export declare const SERVICE_NAME = "ainode.service";
|
|
48
|
+
export type Handover =
|
|
49
|
+
/** Nothing was running — safe to start the supervised job immediately. */
|
|
50
|
+
{
|
|
51
|
+
kind: 'none';
|
|
52
|
+
}
|
|
53
|
+
/** The running daemon is this very process. Write only, don't start. */
|
|
54
|
+
| {
|
|
55
|
+
kind: 'self';
|
|
56
|
+
}
|
|
57
|
+
/** Someone else's daemon stood down; the supervisor can take over now. */
|
|
58
|
+
| {
|
|
59
|
+
kind: 'stopped';
|
|
60
|
+
}
|
|
61
|
+
/** It was asked to stop and didn't. */
|
|
62
|
+
| {
|
|
63
|
+
kind: 'stuck';
|
|
64
|
+
pid: number;
|
|
65
|
+
};
|
|
66
|
+
export declare function stopRunningDaemon(timeoutMs?: number, selfPid?: number): Handover;
|
|
46
67
|
export interface ResolvedEntry {
|
|
47
68
|
entry?: AutostartEntry;
|
|
48
69
|
error?: string;
|
package/dist/autostart.js
CHANGED
|
@@ -50,7 +50,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
50
50
|
};
|
|
51
51
|
})();
|
|
52
52
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
-
exports.TASK_NAME = exports.LABEL = void 0;
|
|
53
|
+
exports.SERVICE_NAME = exports.TASK_NAME = exports.LABEL = void 0;
|
|
54
|
+
exports.stopRunningDaemon = stopRunningDaemon;
|
|
54
55
|
exports.resolveEntry = resolveEntry;
|
|
55
56
|
exports.buildEnv = buildEnv;
|
|
56
57
|
exports.status = status;
|
|
@@ -67,6 +68,31 @@ exports.LABEL = 'ai.add.node';
|
|
|
67
68
|
/** Task Scheduler's name for the same thing. Space-free: `schtasks` quoting
|
|
68
69
|
* through cmd.exe is a known source of Windows breakage in this codebase. */
|
|
69
70
|
exports.TASK_NAME = 'AiNode';
|
|
71
|
+
/** systemd's name for the same thing — a per-user unit, never a system one. */
|
|
72
|
+
exports.SERVICE_NAME = 'ainode.service';
|
|
73
|
+
function stopRunningDaemon(timeoutMs = 40_000, selfPid = process.pid) {
|
|
74
|
+
const lock = (0, lockfile_1.readLockfile)();
|
|
75
|
+
if (!lock || !(0, lockfile_1.lockfileAlive)(lock))
|
|
76
|
+
return { kind: 'none' };
|
|
77
|
+
if (lock.pid === selfPid)
|
|
78
|
+
return { kind: 'self' };
|
|
79
|
+
try {
|
|
80
|
+
process.kill(lock.pid, 'SIGTERM');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { kind: 'stopped' };
|
|
84
|
+
}
|
|
85
|
+
// Synchronous poll: `startup enable` is a one-shot CLI command, and making
|
|
86
|
+
// the whole backend async to sleep 250ms would buy nothing.
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
Atomics.wait(nap, 0, 0, 250);
|
|
91
|
+
if (!(0, lockfile_1.lockfileAlive)((0, lockfile_1.readLockfile)()))
|
|
92
|
+
return { kind: 'stopped' };
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'stuck', pid: lock.pid };
|
|
95
|
+
}
|
|
70
96
|
/**
|
|
71
97
|
* The command to write into the supervisor's definition.
|
|
72
98
|
*
|
|
@@ -154,6 +180,10 @@ function backend(platform = process.platform) {
|
|
|
154
180
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
155
181
|
return require('./autostart-win').backend;
|
|
156
182
|
}
|
|
183
|
+
if (platform === 'linux') {
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
185
|
+
return require('./autostart-linux').backend;
|
|
186
|
+
}
|
|
157
187
|
return null;
|
|
158
188
|
}
|
|
159
189
|
const UNSUPPORTED = { supported: false, enabled: false, supervisor: null };
|
|
@@ -199,13 +229,17 @@ function disable() {
|
|
|
199
229
|
/** Is a supervisor going to restart us if we exit? Decides whether a remote
|
|
200
230
|
* roll self-respawns or simply stands down and lets the supervisor act. */
|
|
201
231
|
function isSupervised(state = status(), env = process.env) {
|
|
202
|
-
// launchd
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
232
|
+
// launchd (KeepAlive) and systemd (Restart=always) are live supervisors:
|
|
233
|
+
// they bring the job straight back, which is what makes a remote roll safe
|
|
234
|
+
// to perform by simply exiting.
|
|
235
|
+
//
|
|
236
|
+
// Task Scheduler is not one. Its logon trigger has already fired, and the
|
|
237
|
+
// 5-minute repeat is a backstop measured in minutes, not a restart path — so
|
|
238
|
+
// a Windows roll self-respawns instead (see index.ts).
|
|
239
|
+
const live = ['launchd', 'systemd'];
|
|
240
|
+
if (env.AINODE_SUPERVISOR && live.includes(env.AINODE_SUPERVISOR))
|
|
207
241
|
return true;
|
|
208
|
-
return state.supervisor
|
|
242
|
+
return live.includes(state.supervisor) && state.enabled;
|
|
209
243
|
}
|
|
210
244
|
/* ── human-readable state, shared by the CLI and the TUI ──────────────── */
|
|
211
245
|
function describeStatus(s) {
|
package/dist/claude-binary.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's own npm prefix — `~/.ainode/tools`.
|
|
3
|
+
*
|
|
4
|
+
* Two ways a harness ends up in there rather than on the machine's global
|
|
5
|
+
* PATH. npmInstallGlobal falls back to it when the real global prefix is
|
|
6
|
+
* root-owned (the classic `/usr/local` Mac), and a node installed by
|
|
7
|
+
* node.add.ai points npm's builtin prefix at it deliberately, so the node
|
|
8
|
+
* carries its own runtime and its own CLIs and touches nothing else.
|
|
9
|
+
*
|
|
10
|
+
* win.ts's shared findCliBinary has searched here for a while, which is why
|
|
11
|
+
* codex/gemini/grok/kimi find their installs. This file predates that helper
|
|
12
|
+
* and keeps its own resolution on purpose — its POSIX probe runs an
|
|
13
|
+
* *interactive login* shell so a `claude` shell alias resolves — so it needs
|
|
14
|
+
* the same candidates added by hand rather than a refactor onto the helper.
|
|
15
|
+
*
|
|
16
|
+
* The legacy home is searched too: a box whose state dir was never migrated
|
|
17
|
+
* can still hold harnesses installed before the rename.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toolsPrefixCandidates(name: string, isWindows?: boolean, roots?: string[]): string[];
|
|
1
20
|
/**
|
|
2
21
|
* Find the Claude Code CLI binary. Mirrors the resolution strategy
|
|
3
22
|
* the desktop app uses, so the runtime spawns the same Claude the
|
package/dist/claude-binary.js
CHANGED
|
@@ -33,12 +33,40 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.toolsPrefixCandidates = toolsPrefixCandidates;
|
|
36
37
|
exports.findClaudeBinary = findClaudeBinary;
|
|
37
38
|
exports.encodeProjectPath = encodeProjectPath;
|
|
38
39
|
const fs = __importStar(require("fs"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
40
41
|
const child_process_1 = require("child_process");
|
|
42
|
+
const paths_1 = require("./paths");
|
|
41
43
|
const IS_WINDOWS = process.platform === 'win32';
|
|
44
|
+
/**
|
|
45
|
+
* The daemon's own npm prefix — `~/.ainode/tools`.
|
|
46
|
+
*
|
|
47
|
+
* Two ways a harness ends up in there rather than on the machine's global
|
|
48
|
+
* PATH. npmInstallGlobal falls back to it when the real global prefix is
|
|
49
|
+
* root-owned (the classic `/usr/local` Mac), and a node installed by
|
|
50
|
+
* node.add.ai points npm's builtin prefix at it deliberately, so the node
|
|
51
|
+
* carries its own runtime and its own CLIs and touches nothing else.
|
|
52
|
+
*
|
|
53
|
+
* win.ts's shared findCliBinary has searched here for a while, which is why
|
|
54
|
+
* codex/gemini/grok/kimi find their installs. This file predates that helper
|
|
55
|
+
* and keeps its own resolution on purpose — its POSIX probe runs an
|
|
56
|
+
* *interactive login* shell so a `claude` shell alias resolves — so it needs
|
|
57
|
+
* the same candidates added by hand rather than a refactor onto the helper.
|
|
58
|
+
*
|
|
59
|
+
* The legacy home is searched too: a box whose state dir was never migrated
|
|
60
|
+
* can still hold harnesses installed before the rename.
|
|
61
|
+
*/
|
|
62
|
+
function toolsPrefixCandidates(name, isWindows = IS_WINDOWS, roots = [paths_1.RUNTIME_HOME, paths_1.LEGACY_RUNTIME_HOME]) {
|
|
63
|
+
return roots.flatMap(root => isWindows
|
|
64
|
+
? [
|
|
65
|
+
path.join(root, 'tools', `${name}.exe`),
|
|
66
|
+
path.join(root, 'tools', `${name}.cmd`),
|
|
67
|
+
]
|
|
68
|
+
: [path.join(root, 'tools', 'bin', name)]);
|
|
69
|
+
}
|
|
42
70
|
/**
|
|
43
71
|
* Find the Claude Code CLI binary. Mirrors the resolution strategy
|
|
44
72
|
* the desktop app uses, so the runtime spawns the same Claude the
|
|
@@ -78,6 +106,7 @@ function findClaudeBinary() {
|
|
|
78
106
|
path.join(localAppData, 'npm', 'claude.cmd'),
|
|
79
107
|
path.join(userProfile, '.npm-global', 'claude.cmd'),
|
|
80
108
|
path.join(userProfile, 'AppData', 'Roaming', 'npm', 'claude.cmd'),
|
|
109
|
+
...toolsPrefixCandidates('claude'),
|
|
81
110
|
];
|
|
82
111
|
for (const p of candidates) {
|
|
83
112
|
try {
|
|
@@ -123,6 +152,7 @@ function findClaudeBinary() {
|
|
|
123
152
|
`${home}/.bun/bin/claude`,
|
|
124
153
|
`${home}/.volta/bin/claude`,
|
|
125
154
|
`${home}/.nvm/current/bin/claude`,
|
|
155
|
+
...toolsPrefixCandidates('claude'),
|
|
126
156
|
];
|
|
127
157
|
for (const p of candidates) {
|
|
128
158
|
try {
|
package/dist/command-runner.js
CHANGED
|
@@ -55,6 +55,7 @@ const store_1 = require("./store");
|
|
|
55
55
|
const capabilities_1 = require("./capabilities");
|
|
56
56
|
const heartbeat_1 = require("./heartbeat");
|
|
57
57
|
const win_1 = require("./win");
|
|
58
|
+
const start_engine_1 = require("./desktop/start-engine");
|
|
58
59
|
const harness_registry_1 = require("./harness-registry");
|
|
59
60
|
const self_update_1 = require("./self-update");
|
|
60
61
|
const autostart_1 = require("./autostart");
|
|
@@ -64,6 +65,7 @@ const docker_1 = require("./desktop/docker");
|
|
|
64
65
|
const manager_1 = require("./desktop/manager");
|
|
65
66
|
const creds_1 = require("./desktop/creds");
|
|
66
67
|
const install_engine_1 = require("./desktop/install-engine");
|
|
68
|
+
const engine_1 = require("./desktop/engine");
|
|
67
69
|
const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
68
70
|
/** Own version, read the same way index.ts does. Resolved here rather than
|
|
69
71
|
* imported from index.ts, which already imports this module. */
|
|
@@ -684,6 +686,63 @@ async function runDesktopCommand(cmd) {
|
|
|
684
686
|
* the verdict, not the installer's exit code - Docker Desktop on macOS
|
|
685
687
|
* installs the app but the daemon is not up until someone launches it, so a
|
|
686
688
|
* clean exit does not mean a usable engine. */
|
|
689
|
+
/**
|
|
690
|
+
* Wake an engine that is installed but asleep.
|
|
691
|
+
*
|
|
692
|
+
* Distinct from runInstallEngine on purpose: there is nothing to install, so
|
|
693
|
+
* there is nothing to reboot for and — on macOS and Windows — no password to
|
|
694
|
+
* ask for. Only the Linux path needs the sudo round trip, and only when this
|
|
695
|
+
* daemon is not already root, so most machines see a button that just works.
|
|
696
|
+
*/
|
|
697
|
+
async function runStartEngine(cmd) {
|
|
698
|
+
let log = '';
|
|
699
|
+
const onLog = (chunk) => {
|
|
700
|
+
log = (log + chunk).slice(-LOG_TAIL_CHARS);
|
|
701
|
+
void update(cmd.id, null, { log });
|
|
702
|
+
};
|
|
703
|
+
await update(cmd.id, 'running', { log });
|
|
704
|
+
try {
|
|
705
|
+
const engine = await (0, start_engine_1.startEngine)(onLog, async () => {
|
|
706
|
+
// Same parking pattern as the install: Studio collects the password the
|
|
707
|
+
// way it collects a harness login code.
|
|
708
|
+
await update(cmd.id, 'awaiting_input', { needs: 'sudo_password', log });
|
|
709
|
+
const deadline = Date.now() + INPUT_WAIT_MS;
|
|
710
|
+
while (Date.now() < deadline) {
|
|
711
|
+
const row = await pollRow(cmd.id);
|
|
712
|
+
if (!row)
|
|
713
|
+
break;
|
|
714
|
+
if (row.status === 'canceled')
|
|
715
|
+
return null;
|
|
716
|
+
const pw = row.input?.sudo_password;
|
|
717
|
+
if (pw) {
|
|
718
|
+
// Wipe it from the row the moment we hold it.
|
|
719
|
+
const t = token();
|
|
720
|
+
if (t) {
|
|
721
|
+
try {
|
|
722
|
+
await (0, supabase_client_1.rpc)('runtime_command_clear_input', { p_token: t, p_id: cmd.id });
|
|
723
|
+
}
|
|
724
|
+
catch { /* best effort — it still must not reach the log */ }
|
|
725
|
+
}
|
|
726
|
+
await update(cmd.id, 'running', { needs: null, log });
|
|
727
|
+
return pw;
|
|
728
|
+
}
|
|
729
|
+
await new Promise(r => setTimeout(r, INPUT_POLL_MS));
|
|
730
|
+
}
|
|
731
|
+
return null;
|
|
732
|
+
});
|
|
733
|
+
// The provider caches "no engine"; without this the next desktop command
|
|
734
|
+
// would still believe the machine cannot host anything.
|
|
735
|
+
(0, docker_1.resetProviderCache)();
|
|
736
|
+
if (!engine) {
|
|
737
|
+
await update(cmd.id, 'failed', { log }, 'The engine did not start. It may still be booting — this machine reports in every 30 seconds.');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
await update(cmd.id, 'completed', { log });
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
await update(cmd.id, 'failed', { log }, e instanceof Error ? e.message : String(e));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
687
746
|
async function runInstallEngine(cmd) {
|
|
688
747
|
let log = '';
|
|
689
748
|
const onLog = (chunk) => {
|
|
@@ -692,7 +751,11 @@ async function runInstallEngine(cmd) {
|
|
|
692
751
|
};
|
|
693
752
|
await update(cmd.id, 'running', { log });
|
|
694
753
|
try {
|
|
695
|
-
|
|
754
|
+
// Installed but unreachable is a permissions problem, not a missing one -
|
|
755
|
+
// the same button must repair rather than reinstall.
|
|
756
|
+
const before = await (0, engine_1.detectEngine)();
|
|
757
|
+
const blocked = !!before && !before.running;
|
|
758
|
+
const outcome = await (0, install_engine_1.installEngine)(onLog, async () => {
|
|
696
759
|
// Park the command and let Studio collect it, exactly as a harness
|
|
697
760
|
// login collects a paste-back code.
|
|
698
761
|
await update(cmd.id, 'awaiting_input', { needs: 'sudo_password', log });
|
|
@@ -720,8 +783,19 @@ async function runInstallEngine(cmd) {
|
|
|
720
783
|
await new Promise(r => setTimeout(r, INPUT_POLL_MS));
|
|
721
784
|
}
|
|
722
785
|
return null;
|
|
723
|
-
});
|
|
786
|
+
}, blocked);
|
|
724
787
|
(0, docker_1.resetProviderCache)();
|
|
788
|
+
if (outcome.needsRestart) {
|
|
789
|
+
// Group membership only applies to new processes, so this daemon still
|
|
790
|
+
// cannot reach the socket. Say so plainly and bounce it.
|
|
791
|
+
onLog('\nPermissions fixed. Restarting this machine\'s node so it picks up the new group…\n');
|
|
792
|
+
await update(cmd.id, 'completed', { log, restarting: true });
|
|
793
|
+
await (0, heartbeat_1.beat)();
|
|
794
|
+
if (restartHook) {
|
|
795
|
+
await restartHook({ commandId: cmd.id, version: VERSION, restartOnly: true });
|
|
796
|
+
}
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
725
799
|
const engine = await (0, docker_1.getProvider)(true);
|
|
726
800
|
if (!engine) {
|
|
727
801
|
await update(cmd.id, 'failed', { log }, 'Installed, but no engine is answering yet. On macOS, Docker Desktop has to be opened once before its daemon runs.');
|
|
@@ -753,6 +827,10 @@ async function execute(cmd) {
|
|
|
753
827
|
await runInstallEngine(cmd);
|
|
754
828
|
return;
|
|
755
829
|
}
|
|
830
|
+
if (cmd.kind === 'start_engine') {
|
|
831
|
+
await runStartEngine(cmd);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
756
834
|
if (DESKTOP_KINDS.includes(cmd.kind)) {
|
|
757
835
|
await runDesktopCommand(cmd);
|
|
758
836
|
return;
|
|
@@ -8,6 +8,16 @@ export interface InstallPlan {
|
|
|
8
8
|
}
|
|
9
9
|
/** Pure, so the choice is testable on any machine. */
|
|
10
10
|
export declare function installPlan(platform: NodeJS.Platform, isRoot: boolean): InstallPlan;
|
|
11
|
+
/** Docker is there but this user cannot reach its socket. The fix is group
|
|
12
|
+
* membership, not another install — and it needs root, so it takes the same
|
|
13
|
+
* password round trip. */
|
|
14
|
+
export declare function repairPlan(user: string): {
|
|
15
|
+
inner: string;
|
|
16
|
+
manual: string;
|
|
17
|
+
};
|
|
18
|
+
/** Group membership is fixed for FUTURE processes only, so the daemon that
|
|
19
|
+
* asked for the fix still cannot reach the socket until it restarts. */
|
|
20
|
+
export declare const REPAIR_NEEDS_RESTART = true;
|
|
11
21
|
export declare function runningAsRoot(): boolean;
|
|
12
22
|
/** Build the argv for a privileged install. The password NEVER goes in argv —
|
|
13
23
|
* argv is world-readable through /proc on Linux, so anyone with a shell on
|
|
@@ -17,4 +27,9 @@ export declare function sudoArgv(inner: string): {
|
|
|
17
27
|
file: string;
|
|
18
28
|
args: string[];
|
|
19
29
|
};
|
|
20
|
-
|
|
30
|
+
/** Install if it is missing; fix permissions if it is merely unreachable.
|
|
31
|
+
* One button, because from the outside they are the same complaint: this
|
|
32
|
+
* machine cannot host desktops. Returns whether a restart is now needed. */
|
|
33
|
+
export declare function installEngine(onLog: (s: string) => void, askPassword?: () => Promise<string | null>, alreadyInstalledButBlocked?: boolean): Promise<{
|
|
34
|
+
needsRestart: boolean;
|
|
35
|
+
}>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REPAIR_NEEDS_RESTART = void 0;
|
|
3
4
|
exports.installPlan = installPlan;
|
|
5
|
+
exports.repairPlan = repairPlan;
|
|
4
6
|
exports.runningAsRoot = runningAsRoot;
|
|
5
7
|
exports.sudoArgv = sudoArgv;
|
|
6
8
|
exports.installEngine = installEngine;
|
|
@@ -37,6 +39,19 @@ function installPlan(platform, isRoot) {
|
|
|
37
39
|
manual: 'curl -fsSL https://get.docker.com | sudo sh', needsRoot: true,
|
|
38
40
|
};
|
|
39
41
|
}
|
|
42
|
+
/** Docker is there but this user cannot reach its socket. The fix is group
|
|
43
|
+
* membership, not another install — and it needs root, so it takes the same
|
|
44
|
+
* password round trip. */
|
|
45
|
+
function repairPlan(user) {
|
|
46
|
+
const u = user.replace(/[^A-Za-z0-9._-]/g, '');
|
|
47
|
+
return {
|
|
48
|
+
inner: `usermod -aG docker ${u}`,
|
|
49
|
+
manual: `sudo usermod -aG docker ${u}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Group membership is fixed for FUTURE processes only, so the daemon that
|
|
53
|
+
* asked for the fix still cannot reach the socket until it restarts. */
|
|
54
|
+
exports.REPAIR_NEEDS_RESTART = true;
|
|
40
55
|
function runningAsRoot() {
|
|
41
56
|
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
42
57
|
}
|
|
@@ -47,11 +62,37 @@ function runningAsRoot() {
|
|
|
47
62
|
function sudoArgv(inner) {
|
|
48
63
|
return { file: 'sudo', args: ['-S', '-p', '', 'sh', '-c', inner] };
|
|
49
64
|
}
|
|
50
|
-
|
|
65
|
+
/** Install if it is missing; fix permissions if it is merely unreachable.
|
|
66
|
+
* One button, because from the outside they are the same complaint: this
|
|
67
|
+
* machine cannot host desktops. Returns whether a restart is now needed. */
|
|
68
|
+
async function installEngine(onLog, askPassword, alreadyInstalledButBlocked) {
|
|
51
69
|
const plan = installPlan(process.platform, runningAsRoot());
|
|
52
70
|
let file = plan.file;
|
|
53
71
|
let args = plan.args;
|
|
54
72
|
let password = null;
|
|
73
|
+
// Docker present but the socket is refused: repair, do not reinstall.
|
|
74
|
+
const repair = alreadyInstalledButBlocked
|
|
75
|
+
? repairPlan(process.env.USER || process.env.LOGNAME || 'root')
|
|
76
|
+
: null;
|
|
77
|
+
if (repair) {
|
|
78
|
+
if (runningAsRoot()) {
|
|
79
|
+
onLog(`fixing permissions\n ${repair.inner}\n\n`);
|
|
80
|
+
file = 'sh';
|
|
81
|
+
args = ['-c', repair.inner];
|
|
82
|
+
}
|
|
83
|
+
else if (askPassword) {
|
|
84
|
+
onLog(`This machine needs a sudo password to fix Docker permissions.\n ${repair.manual}\n\n`);
|
|
85
|
+
password = await askPassword();
|
|
86
|
+
if (!password)
|
|
87
|
+
throw new Error(`No password given. Run it on the machine instead:\n ${repair.manual}`);
|
|
88
|
+
const sudo = sudoArgv(repair.inner);
|
|
89
|
+
file = sudo.file;
|
|
90
|
+
args = sudo.args;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
throw new Error(`Needs root. Run this on the machine:\n ${repair.manual}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
55
96
|
if (!file && plan.needsRoot && askPassword) {
|
|
56
97
|
onLog('This machine needs a sudo password to install Docker.\n');
|
|
57
98
|
password = await askPassword();
|
|
@@ -86,6 +127,7 @@ async function installEngine(onLog, askPassword) {
|
|
|
86
127
|
child.on('error', err => reject(new Error(`${file} is not available here. Install it by hand:\n ${plan.manual}\n(${err.message})`)));
|
|
87
128
|
child.on('exit', code => code === 0
|
|
88
129
|
? resolve()
|
|
89
|
-
: reject(new Error(
|
|
130
|
+
: reject(new Error(`${repair ? 'permission fix' : 'install'} failed (exit ${code}). Try by hand:\n ${repair ? repair.manual : plan.manual}\n${tail.slice(-400)}`)));
|
|
90
131
|
});
|
|
132
|
+
return { needsRestart: !!repair && exports.REPAIR_NEEDS_RESTART };
|
|
91
133
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { EngineInfo } from './provider';
|
|
2
|
+
export interface StartPlan {
|
|
3
|
+
/** Null when this platform has no way we can drive without a password. */
|
|
4
|
+
file: string | null;
|
|
5
|
+
args: string[];
|
|
6
|
+
/** What a human would run, shown when we cannot do it ourselves. */
|
|
7
|
+
manual: string;
|
|
8
|
+
needsRoot: boolean;
|
|
9
|
+
/** Launch and walk away — Docker Desktop is a GUI app that outlives us. */
|
|
10
|
+
detached: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pure, so the choice is testable on any machine.
|
|
14
|
+
*
|
|
15
|
+
* @param programFiles %ProgramFiles% on Windows. Passed in rather than read
|
|
16
|
+
* from the environment so the Windows path is testable
|
|
17
|
+
* from a Mac, which is where it is usually written.
|
|
18
|
+
*/
|
|
19
|
+
export declare function startPlan(platform: NodeJS.Platform, isRoot: boolean, programFiles?: string): StartPlan;
|
|
20
|
+
/** Build the argv for a privileged start. Password on stdin, never argv —
|
|
21
|
+
* argv is world-readable through /proc. */
|
|
22
|
+
export declare function sudoStartArgv(): {
|
|
23
|
+
file: string;
|
|
24
|
+
args: string[];
|
|
25
|
+
};
|
|
26
|
+
export declare function runningAsRoot(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* How long to wait for the daemon after the launch command returns.
|
|
29
|
+
*
|
|
30
|
+
* Docker Desktop is not "started" when its process exists — it boots a VM.
|
|
31
|
+
* On a cold Windows machine that is routinely a minute or more, so a short
|
|
32
|
+
* timeout would report failure on a machine that was about to work.
|
|
33
|
+
*/
|
|
34
|
+
export declare const START_TIMEOUT_MS = 150000;
|
|
35
|
+
/** Poll until the engine actually answers, or we run out of patience. */
|
|
36
|
+
export declare function waitForEngine(onLog: (s: string) => void, timeoutMs?: number, probe?: () => Promise<EngineInfo | null>): Promise<EngineInfo | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Ask this machine's engine to start.
|
|
39
|
+
*
|
|
40
|
+
* Returns the engine once it answers, or null with the reason logged. The
|
|
41
|
+
* password callback is only ever invoked on Linux, and only when this process
|
|
42
|
+
* is not already root.
|
|
43
|
+
*/
|
|
44
|
+
export declare function startEngine(onLog: (chunk: string) => void, askPassword: () => Promise<string | null>): Promise<EngineInfo | null>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.START_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.startPlan = startPlan;
|
|
5
|
+
exports.sudoStartArgv = sudoStartArgv;
|
|
6
|
+
exports.runningAsRoot = runningAsRoot;
|
|
7
|
+
exports.waitForEngine = waitForEngine;
|
|
8
|
+
exports.startEngine = startEngine;
|
|
9
|
+
// Start a container engine that is installed but asleep.
|
|
10
|
+
//
|
|
11
|
+
// A machine with Docker installed and closed is the single most common reason
|
|
12
|
+
// the Desktops tab is dark, and it is the one case where the fix is genuinely
|
|
13
|
+
// one command. Reinstalling would be wrong (it is already there), and telling
|
|
14
|
+
// someone to walk to the machine is only honest while nothing here can do it.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately separate from install-engine: installing is a slow, privileged,
|
|
17
|
+
// possibly-rebooting operation, and starting is a fast unprivileged one on the
|
|
18
|
+
// two desktop platforms. Sharing a code path would have meant the start button
|
|
19
|
+
// inheriting the install button's sudo round trip on machines that never need
|
|
20
|
+
// it.
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const win_1 = require("../win");
|
|
23
|
+
const engine_1 = require("./engine");
|
|
24
|
+
/**
|
|
25
|
+
* Pure, so the choice is testable on any machine.
|
|
26
|
+
*
|
|
27
|
+
* @param programFiles %ProgramFiles% on Windows. Passed in rather than read
|
|
28
|
+
* from the environment so the Windows path is testable
|
|
29
|
+
* from a Mac, which is where it is usually written.
|
|
30
|
+
*/
|
|
31
|
+
function startPlan(platform, isRoot, programFiles) {
|
|
32
|
+
if (platform === 'darwin') {
|
|
33
|
+
// `open -a` returns as soon as the app is launching; the daemon comes up
|
|
34
|
+
// a while later, which is why the caller polls rather than trusting exit 0.
|
|
35
|
+
return {
|
|
36
|
+
file: 'open', args: ['-a', 'Docker'],
|
|
37
|
+
manual: 'open -a Docker', needsRoot: false, detached: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (platform === 'win32') {
|
|
41
|
+
// Spawned directly rather than through `cmd /c start`: quoting a path with
|
|
42
|
+
// spaces through cmd is the kind of thing that works until someone's
|
|
43
|
+
// Windows is installed on a different drive.
|
|
44
|
+
const base = programFiles || 'C:\\Program Files';
|
|
45
|
+
return {
|
|
46
|
+
file: `${base}\\Docker\\Docker\\Docker Desktop.exe`, args: [],
|
|
47
|
+
manual: 'Start Docker Desktop from the Start menu',
|
|
48
|
+
needsRoot: false, detached: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Linux: the daemon is a system service, so this needs root the same way
|
|
52
|
+
// installing does — and takes the same password round trip.
|
|
53
|
+
return {
|
|
54
|
+
file: isRoot ? 'sh' : null,
|
|
55
|
+
args: ['-c', 'systemctl start docker'],
|
|
56
|
+
manual: 'sudo systemctl start docker',
|
|
57
|
+
needsRoot: true, detached: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Build the argv for a privileged start. Password on stdin, never argv —
|
|
61
|
+
* argv is world-readable through /proc. */
|
|
62
|
+
function sudoStartArgv() {
|
|
63
|
+
return { file: 'sudo', args: ['-S', '-p', '', 'sh', '-c', 'systemctl start docker'] };
|
|
64
|
+
}
|
|
65
|
+
function runningAsRoot() {
|
|
66
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How long to wait for the daemon after the launch command returns.
|
|
70
|
+
*
|
|
71
|
+
* Docker Desktop is not "started" when its process exists — it boots a VM.
|
|
72
|
+
* On a cold Windows machine that is routinely a minute or more, so a short
|
|
73
|
+
* timeout would report failure on a machine that was about to work.
|
|
74
|
+
*/
|
|
75
|
+
exports.START_TIMEOUT_MS = 150_000;
|
|
76
|
+
const POLL_MS = 3_000;
|
|
77
|
+
/** Poll until the engine actually answers, or we run out of patience. */
|
|
78
|
+
async function waitForEngine(onLog, timeoutMs = exports.START_TIMEOUT_MS, probe = engine_1.detectEngine) {
|
|
79
|
+
const deadline = Date.now() + timeoutMs;
|
|
80
|
+
let dots = 0;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
const e = await probe();
|
|
83
|
+
if (e && e.running)
|
|
84
|
+
return e;
|
|
85
|
+
// A line every few seconds, so the log looks alive during a long VM boot
|
|
86
|
+
// rather than looking like it hung.
|
|
87
|
+
if (dots++ % 4 === 0) {
|
|
88
|
+
onLog(` still waiting for the engine… ${Math.round((Date.now() - (deadline - timeoutMs)) / 1000)}s\n`);
|
|
89
|
+
}
|
|
90
|
+
await new Promise(r => setTimeout(r, POLL_MS));
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Ask this machine's engine to start.
|
|
96
|
+
*
|
|
97
|
+
* Returns the engine once it answers, or null with the reason logged. The
|
|
98
|
+
* password callback is only ever invoked on Linux, and only when this process
|
|
99
|
+
* is not already root.
|
|
100
|
+
*/
|
|
101
|
+
async function startEngine(onLog, askPassword) {
|
|
102
|
+
const plan = startPlan(process.platform, runningAsRoot(), process.env.ProgramFiles);
|
|
103
|
+
if (!plan.file) {
|
|
104
|
+
// Linux, not root. Ask for the password and go through sudo.
|
|
105
|
+
const pw = await askPassword();
|
|
106
|
+
if (!pw) {
|
|
107
|
+
onLog(`\nNo password given. Start it on the machine instead:\n ${plan.manual}\n`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const { file, args } = sudoStartArgv();
|
|
111
|
+
const code = await run(file, args, onLog, false, pw);
|
|
112
|
+
if (code !== 0) {
|
|
113
|
+
onLog(`\nThat did not work. On the machine:\n ${plan.manual}\n`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
onLog(`Starting ${plan.file}…\n`);
|
|
119
|
+
const code = await run(plan.file, plan.args, onLog, plan.detached);
|
|
120
|
+
// A detached GUI launch has no meaningful exit code, and on Windows the
|
|
121
|
+
// exe returns immediately. Only a hard spawn failure is worth reporting,
|
|
122
|
+
// and that surfaces as -1 below.
|
|
123
|
+
if (code === -1) {
|
|
124
|
+
onLog(`\nCould not launch it. On the machine:\n ${plan.manual}\n`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
onLog('Waiting for the engine to answer…\n');
|
|
129
|
+
const engine = await waitForEngine(onLog);
|
|
130
|
+
if (!engine) {
|
|
131
|
+
onLog(`\nIt did not come up in time. It may still be starting — this page turns on by itself when it does.\n`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
onLog(`\n${engine.id} is ready.\n`);
|
|
135
|
+
return engine;
|
|
136
|
+
}
|
|
137
|
+
function run(binary, args, onLog, detached, stdin) {
|
|
138
|
+
return new Promise(resolve => {
|
|
139
|
+
try {
|
|
140
|
+
const inv = (0, win_1.resolveCliInvocation)(binary, args);
|
|
141
|
+
const child = (0, child_process_1.spawn)(inv.file, inv.args, {
|
|
142
|
+
env: process.env,
|
|
143
|
+
windowsHide: true,
|
|
144
|
+
detached,
|
|
145
|
+
stdio: stdin ? ['pipe', 'pipe', 'pipe'] : undefined,
|
|
146
|
+
});
|
|
147
|
+
if (stdin && child.stdin) {
|
|
148
|
+
child.stdin.write(`${stdin}\n`);
|
|
149
|
+
child.stdin.end();
|
|
150
|
+
}
|
|
151
|
+
child.stdout?.on('data', b => onLog(b.toString('utf8')));
|
|
152
|
+
child.stderr?.on('data', b => onLog(b.toString('utf8')));
|
|
153
|
+
child.on('error', () => resolve(-1));
|
|
154
|
+
if (detached) {
|
|
155
|
+
// Let it outlive this process; Docker Desktop must not die with the
|
|
156
|
+
// daemon that launched it.
|
|
157
|
+
child.unref();
|
|
158
|
+
resolve(0);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
child.on('exit', code => resolve(code ?? -1));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
resolve(-1);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addai/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|