@platless/axom-cli 0.1.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/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { launchAgentPath, renderLaunchAgent } from './launchd.js';
|
|
6
|
+
import { renderSystemdUnit, systemdUnitPath } from './systemd.js';
|
|
7
|
+
import { assertSafeServiceContext, SERVICE_LABEL } from './types.js';
|
|
8
|
+
async function runProcess(command, args) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
11
|
+
let stdout = '';
|
|
12
|
+
let stderr = '';
|
|
13
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
14
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
15
|
+
child.once('error', reject);
|
|
16
|
+
child.once('close', (code) => {
|
|
17
|
+
if (code === 0)
|
|
18
|
+
resolve({ code, stdout, stderr });
|
|
19
|
+
else
|
|
20
|
+
reject(new Error(`${command} ${args.join(' ')} failed (${code ?? 'signal'}): ${stderr || stdout}`));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function writeTemp(contents) {
|
|
25
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'axom-workstation-service-'));
|
|
26
|
+
const file = path.join(directory, `${SERVICE_LABEL}.tmp`);
|
|
27
|
+
await fs.writeFile(file, contents, { mode: 0o600 });
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether launchd's own reported job state is "running" — `launchctl print` *succeeding* only means
|
|
32
|
+
* the job is loaded, not that it is actually running: a crash-looping or stopped-but-loaded job
|
|
33
|
+
* still prints successfully, with output like `state = not running` and no `pid` line. Real output
|
|
34
|
+
* for a running job looks like:
|
|
35
|
+
*
|
|
36
|
+
* ```
|
|
37
|
+
* state = running
|
|
38
|
+
* pid = 33497
|
|
39
|
+
* last exit code = (never exited)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Falls back to treating the print as running if no `state` line is found at all, so an
|
|
43
|
+
* unrecognised output shape (a future macOS version) still reports something instead of throwing.
|
|
44
|
+
*/
|
|
45
|
+
function launchdIsRunning(printStdout) {
|
|
46
|
+
const match = /^\s*state = (.+)$/m.exec(printStdout);
|
|
47
|
+
if (!match)
|
|
48
|
+
return true;
|
|
49
|
+
return match[1].trim() === 'running';
|
|
50
|
+
}
|
|
51
|
+
async function assertWritable(directory) {
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(directory, fs.constants.W_OK);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`The npm prefix ${directory} is not writable by the service user. Grant the authorizing user write access before enabling unattended updates.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const defaultDeps = {
|
|
60
|
+
run: runProcess,
|
|
61
|
+
writeTemp,
|
|
62
|
+
assertWritable,
|
|
63
|
+
ensureDirectory: async (directory) => { await fs.mkdir(directory, { recursive: true }); },
|
|
64
|
+
pathExists: async (file) => {
|
|
65
|
+
try {
|
|
66
|
+
await fs.access(file);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
wait: async (milliseconds) => { await new Promise((resolve) => { setTimeout(resolve, milliseconds); }); },
|
|
74
|
+
removeTemp: async (file) => {
|
|
75
|
+
await fs.rm(file, { force: true });
|
|
76
|
+
const directory = path.dirname(file);
|
|
77
|
+
// `writeTemp` owns only its uniquely prefixed directory. Never infer ownership from a caller
|
|
78
|
+
// supplied path: tests and integrations may stage a file directly under /tmp.
|
|
79
|
+
if (path.basename(directory).startsWith('axom-workstation-service-')) {
|
|
80
|
+
await fs.rmdir(directory).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Pauses between `kickstart` attempts while launchd is still transitioning a job out of the domain
|
|
86
|
+
* (error 37). The schedule MUST outlast launchd's 20s SIGTERM-to-SIGKILL grace (`ExitTimeOut`,
|
|
87
|
+
* which the LaunchAgent this writes does not override), because on the server-driven update path
|
|
88
|
+
* the job being torn down is the old daemon — and that daemon deliberately lingers after SIGTERM
|
|
89
|
+
* to observe this very command (`awaitProvisionOutcome`, runUpdate.ts). launchd keeps the job "in
|
|
90
|
+
* transition" for exactly as long as that process lives, so a budget shorter than the grace loses
|
|
91
|
+
* the race by construction: the previous 3.25s schedule returned 37 on every attempt on two real
|
|
92
|
+
* machines, gave up, and — the `bootout` having already succeeded — left both with no job
|
|
93
|
+
* registered in launchd at all. Exported so runUpdate.ts can assert its confirm window exceeds
|
|
94
|
+
* this whole schedule instead of re-deriving the invariant by hand.
|
|
95
|
+
*/
|
|
96
|
+
export const LAUNCHD_TRANSITION_BACKOFF_MS = [250, 500, 1_000, 1_500, 2_500, 4_000, 6_000, 8_000];
|
|
97
|
+
/**
|
|
98
|
+
* Creates a manager for the current user's service. Service files are staged as `.tmp` files and
|
|
99
|
+
* renamed only after a complete copy, so an interrupted install never leaves a partial unit.
|
|
100
|
+
*/
|
|
101
|
+
export function createServiceManager(context, overrides = {}) {
|
|
102
|
+
assertSafeServiceContext(context);
|
|
103
|
+
const deps = { ...defaultDeps, ...overrides };
|
|
104
|
+
const target = context.platform === 'darwin' ? launchAgentPath(context.homeDir) : systemdUnitPath(context.homeDir);
|
|
105
|
+
const contents = context.platform === 'darwin' ? renderLaunchAgent(context) : renderSystemdUnit(context);
|
|
106
|
+
const launchdDomain = `gui/${context.userId}/${SERVICE_LABEL}`;
|
|
107
|
+
// `gui/<uid>` exists only while someone is logged into the GUI. Switching this agent to
|
|
108
|
+
// `user/<uid>` was tried and reverted — record the measurement so nobody re-derives the wrong
|
|
109
|
+
// conclusion: `launchctl bootstrap user/<uid> <plist>` fails with "Bootstrap failed: 5" for any
|
|
110
|
+
// agent plist, including a minimal one that only runs /bin/sleep, on machines with and without a
|
|
111
|
+
// GUI session, with and without root. What actually varies is whether the GUI session exists: an
|
|
112
|
+
// SSH shell on a logged-in machine reaches `gui/<uid>` fine and needs no sudo; a Mac at the login
|
|
113
|
+
// window has no such domain and every call returns 125. Distinct from `launchdAbsent`: that means
|
|
114
|
+
// "domain reachable, job not present", this means "no domain at all".
|
|
115
|
+
const launchdDomainUnreachable = (error) => /Domain does not support specified action/i.test(error instanceof Error ? error.message : String(error));
|
|
116
|
+
const launchdAbsent = (error) => /Could not find service|No such process/i.test(error instanceof Error ? error.message : String(error));
|
|
117
|
+
/**
|
|
118
|
+
* The single place launchd's "125" becomes something a user can act on. Every darwin path —
|
|
119
|
+
* install, start, stop, restart, status — funnels through here, because translating it in only
|
|
120
|
+
* one of them is exactly the gap this replaces: `start` on a machine with the plist already in
|
|
121
|
+
* place never reaches `install`, so it surfaced the raw text.
|
|
122
|
+
*/
|
|
123
|
+
const NO_GUI_SESSION = 'No GUI login session on this machine, so launchd has no user session to run the workstation '
|
|
124
|
+
+ 'service in. Log in at the console (or enable automatic login), then run '
|
|
125
|
+
+ '`axom workstation install` again.';
|
|
126
|
+
const withGuiSessionHint = async (op) => {
|
|
127
|
+
try {
|
|
128
|
+
return await op();
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (context.platform === 'darwin' && launchdDomainUnreachable(error))
|
|
132
|
+
throw new Error(NO_GUI_SESSION);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const launchdBootstrapRace = (error) => /Bootstrap failed:\s*5:\s*Input\/output error/i.test(error instanceof Error ? error.message : String(error));
|
|
137
|
+
const launchdTransitioning = (error) => /launchctl kickstart.*failed \(37\)/i.test(error instanceof Error ? error.message : String(error));
|
|
138
|
+
const launchdPrint = () => deps.run('launchctl', ['print', launchdDomain]);
|
|
139
|
+
const launchdKickstart = () => deps.run('launchctl', ['kickstart', '-k', launchdDomain]);
|
|
140
|
+
const launchdBootstrap = () => deps.run('launchctl', ['bootstrap', `gui/${context.userId}`, target]);
|
|
141
|
+
const detail = (error) => (error instanceof Error ? error.message : String(error)).trim();
|
|
142
|
+
const launchdStuck = (error) => new Error(`launchd never finished releasing ${SERVICE_LABEL}: ${LAUNCHD_TRANSITION_BACKOFF_MS.length + 1} kickstart attempts over `
|
|
143
|
+
+ `${LAUNCHD_TRANSITION_BACKOFF_MS.reduce((total, each) => total + each, 0)}ms all reported the job as still transitioning. `
|
|
144
|
+
+ 'The service is NOT registered with launchd right now, so nothing will start it. '
|
|
145
|
+
+ 'Re-run `axom workstation install` on this machine once launchd has settled. '
|
|
146
|
+
+ `Last launchd error: ${detail(error)}`);
|
|
147
|
+
/**
|
|
148
|
+
* Bring the job up, tolerating the window where launchd has acknowledged a bootout but has not
|
|
149
|
+
* finished tearing the job down.
|
|
150
|
+
*
|
|
151
|
+
* Error 37 is launchd's answer to `kickstart` for a job in that transitional state. The retry
|
|
152
|
+
* used to be a single 100ms sleep, which real-machine acceptance disproved twice on two machines:
|
|
153
|
+
* `kickstart` still returned 37, the caller gave up, and — because the `bootout` in
|
|
154
|
+
* `performInstall` had already succeeded — the machine was left with *no* job in launchd at all.
|
|
155
|
+
* The schedule below is deliberately bounded (never an unbounded retry loop) and deliberately
|
|
156
|
+
* shorter than the window `provisionService` waits for this whole command (runUpdate.ts), so an
|
|
157
|
+
* exhausted retry is reported as a failure rather than swallowed by that timeout.
|
|
158
|
+
*
|
|
159
|
+
* Exhaustion throws {@link launchdStuck} instead of falling through to `bootstrap`: bootstrapping
|
|
160
|
+
* a job launchd is still tearing down fails with its own unrelated-looking error, and reporting
|
|
161
|
+
* *that* is how the real cause stayed invisible.
|
|
162
|
+
*/
|
|
163
|
+
const launchdStart = async () => {
|
|
164
|
+
let transitionError = null;
|
|
165
|
+
for (let attempt = 0;; attempt++) {
|
|
166
|
+
let loaded = false;
|
|
167
|
+
try {
|
|
168
|
+
// Querying the system domain first avoids the common duplicate Bootstrap failed: 5 error.
|
|
169
|
+
await launchdPrint();
|
|
170
|
+
loaded = true;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (!launchdAbsent(error))
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
if (loaded) {
|
|
177
|
+
try {
|
|
178
|
+
return await launchdKickstart();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
// The job may be booted out after `print` but before `kickstart`, or still be on its way
|
|
182
|
+
// out (37). Re-probe after a short pause: once launchd reports definitive absence the
|
|
183
|
+
// loop falls through to `bootstrap` below, which is the correct action for an absent job.
|
|
184
|
+
if (!launchdAbsent(error) && !launchdTransitioning(error))
|
|
185
|
+
throw error;
|
|
186
|
+
if (attempt >= LAUNCHD_TRANSITION_BACKOFF_MS.length)
|
|
187
|
+
throw launchdStuck(error);
|
|
188
|
+
transitionError = error;
|
|
189
|
+
await deps.wait(LAUNCHD_TRANSITION_BACKOFF_MS[attempt]);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
return await launchdBootstrap();
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!launchdBootstrapRace(error)) {
|
|
198
|
+
// A bootstrap failure that follows a run of 37s is that transition, still unfinished.
|
|
199
|
+
// Say so, rather than surfacing a bootstrap error nobody can connect to the real cause.
|
|
200
|
+
if (transitionError)
|
|
201
|
+
throw launchdStuck(error);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
// A concurrent bootstrap can win between the absence check and our bootstrap. Confirm that
|
|
205
|
+
// state before kickstarting; never turn an arbitrary bootstrap error into a false success.
|
|
206
|
+
await launchdPrint();
|
|
207
|
+
return launchdKickstart();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const lifecycle = async (action) => {
|
|
212
|
+
if (context.platform === 'linux') {
|
|
213
|
+
return deps.run('systemctl', ['--user', action === 'status' ? 'is-active' : action, SERVICE_LABEL + '.service']);
|
|
214
|
+
}
|
|
215
|
+
if (action === 'status')
|
|
216
|
+
return launchdPrint();
|
|
217
|
+
// `kill` is not a stop operation for a KeepAlive job: launchd immediately respawns it. A
|
|
218
|
+
// bootout removes the job from the user's GUI domain; start/bootstrap restores it from the
|
|
219
|
+
// durable LaunchAgent plist. This keeps explicit stop meaningful across both service managers.
|
|
220
|
+
if (action === 'stop')
|
|
221
|
+
return deps.run('launchctl', ['bootout', launchdDomain]);
|
|
222
|
+
if (action === 'restart') {
|
|
223
|
+
try {
|
|
224
|
+
await deps.run('launchctl', ['bootout', launchdDomain]);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// `restart` after an explicit `stop` is a valid request: absence means there is nothing
|
|
228
|
+
// left to unload, and launchdStart will bootstrap the durable plist.
|
|
229
|
+
if (!launchdAbsent(error))
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
return launchdStart();
|
|
233
|
+
}
|
|
234
|
+
return launchdStart();
|
|
235
|
+
};
|
|
236
|
+
// Extracted so `start()` can reuse it: reinstalling is the same work whether triggered
|
|
237
|
+
// explicitly (`axom workstation install`) or as `start()`'s self-heal for a missing definition
|
|
238
|
+
// file. Both platform branches leave the service running, so callers need no separate start.
|
|
239
|
+
const performInstall = async (_options = {}) => {
|
|
240
|
+
await deps.assertWritable(context.npmPrefix);
|
|
241
|
+
await deps.ensureDirectory(path.dirname(target));
|
|
242
|
+
const temp = await deps.writeTemp(contents);
|
|
243
|
+
const targetTemp = `${target}.tmp`;
|
|
244
|
+
try {
|
|
245
|
+
await deps.run('install', ['-m', '644', temp, targetTemp]);
|
|
246
|
+
await deps.run('mv', [targetTemp, target]);
|
|
247
|
+
if (context.platform === 'linux') {
|
|
248
|
+
await deps.run('systemctl', ['--user', 'daemon-reload']);
|
|
249
|
+
await deps.run('systemctl', ['--user', 'enable', '--now', `${SERVICE_LABEL}.service`]);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
// Reload an existing service before bootstrapping the replacement into the GUI domain.
|
|
253
|
+
await deps.run('launchctl', ['bootout', launchdDomain]).catch(() => undefined);
|
|
254
|
+
// A just-booted-out job can briefly race launchd's domain bookkeeping. Reuse the
|
|
255
|
+
// bounded probe/bootstrap path so Bootstrap failed: 5 is confirmed then kickstarted, and
|
|
256
|
+
// so a launchd that never releases the job is reported instead of leaving the machine
|
|
257
|
+
// with the bootout applied and nothing bootstrapped in its place.
|
|
258
|
+
await launchdStart();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await deps.removeTemp?.(temp);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
return {
|
|
266
|
+
async install(options = {}) {
|
|
267
|
+
await withGuiSessionHint(() => performInstall(options));
|
|
268
|
+
},
|
|
269
|
+
/**
|
|
270
|
+
* The inverse of `install`: unload/disable the running unit (tolerating one that was never
|
|
271
|
+
* loaded — uninstalling something absent is success, not an error) and remove its definition
|
|
272
|
+
* file. Best-effort by design: a machine that already hand-removed the file should still end
|
|
273
|
+
* up in the same clean state.
|
|
274
|
+
*/
|
|
275
|
+
async uninstall() {
|
|
276
|
+
if (context.platform === 'linux') {
|
|
277
|
+
await deps.run('systemctl', ['--user', 'disable', '--now', `${SERVICE_LABEL}.service`]).catch((error) => {
|
|
278
|
+
if (!/does not exist|not loaded|No such file/i.test(error instanceof Error ? error.message : String(error)))
|
|
279
|
+
throw error;
|
|
280
|
+
});
|
|
281
|
+
await deps.run('rm', ['-f', target]);
|
|
282
|
+
// Unlike `disable` above, `daemon-reload` has no "the unit was already absent" failure
|
|
283
|
+
// mode to discriminate — it just reloads systemd's unit cache wholesale. A failure here
|
|
284
|
+
// means something is genuinely wrong with the user's systemd session (permissions, DBus
|
|
285
|
+
// unreachable), not a benign no-op, so let it propagate exactly like install()'s identical
|
|
286
|
+
// call at line 153: swallowing it would report `uninstall` as successful while leaving a
|
|
287
|
+
// real error unsurfaced.
|
|
288
|
+
await deps.run('systemctl', ['--user', 'daemon-reload']);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
await deps.run('launchctl', ['bootout', launchdDomain]).catch((error) => {
|
|
292
|
+
if (!launchdAbsent(error))
|
|
293
|
+
throw error;
|
|
294
|
+
});
|
|
295
|
+
await deps.run('rm', ['-f', target]);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
/**
|
|
299
|
+
* `uninstall` removes only the service definition file, never the workstation credential that
|
|
300
|
+
* gates whether `axom workstation start`'s credential chain treats this machine as already
|
|
301
|
+
* registered. A `start` after `uninstall` would otherwise bootstrap a definition that is no
|
|
302
|
+
* longer on disk and surface launchd/systemd's raw error; reinstall first instead.
|
|
303
|
+
*/
|
|
304
|
+
async start() {
|
|
305
|
+
await withGuiSessionHint(async () => {
|
|
306
|
+
if (!(await deps.pathExists(target)))
|
|
307
|
+
await performInstall({});
|
|
308
|
+
await lifecycle('start');
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
async stop() { await withGuiSessionHint(() => lifecycle('stop')); },
|
|
312
|
+
async restart() { await withGuiSessionHint(() => lifecycle('restart')); },
|
|
313
|
+
async status() {
|
|
314
|
+
try {
|
|
315
|
+
const result = await lifecycle('status');
|
|
316
|
+
// `systemctl is-active` succeeding means the unit genuinely is active — its own definition
|
|
317
|
+
// of "status" already answers the question. `launchctl print` succeeding only means the
|
|
318
|
+
// job is loaded, so on macOS the answer has to come from the state it actually reports.
|
|
319
|
+
const running = context.platform === 'darwin' ? launchdIsRunning(result.stdout) : true;
|
|
320
|
+
return { running, detail: result.stdout.trim() || undefined };
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
// Absence must report `{ running: false }`, not throw — and darwin/linux say "absent" in
|
|
324
|
+
// different vocabularies, so each platform gets its own predicate rather than one regex
|
|
325
|
+
// built to (mis)match both. `launchdAbsent` is the same predicate the start/stop paths
|
|
326
|
+
// already trust to recognise real launchd absence text.
|
|
327
|
+
if (context.platform === 'darwin') {
|
|
328
|
+
if (launchdAbsent(error))
|
|
329
|
+
return { running: false };
|
|
330
|
+
// No GUI session means no domain, which means the service is definitionally not running.
|
|
331
|
+
// Reporting that is more truthful than raising — `status` answers a question, and the
|
|
332
|
+
// answer here is simply "no". The actionable explanation belongs on the paths that were
|
|
333
|
+
// asked to *change* something (start/stop/restart/install), not on the one that asks.
|
|
334
|
+
if (launchdDomainUnreachable(error))
|
|
335
|
+
return { running: false };
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
339
|
+
// `systemctl is-active` exits nonzero for an inactive *or failed* unit. Its process error
|
|
340
|
+
// is not a privilege error and must remain a useful status result.
|
|
341
|
+
if (/inactive|systemctl(?: --user)? is-active.*\bfailed\b/i.test(detail))
|
|
342
|
+
return { running: false };
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
export async function installService(context, options) {
|
|
349
|
+
await createServiceManager(context).install(options);
|
|
350
|
+
}
|
|
351
|
+
export async function startService(context) {
|
|
352
|
+
await createServiceManager(context).start();
|
|
353
|
+
}
|
|
354
|
+
export async function stopService(context) {
|
|
355
|
+
await createServiceManager(context).stop();
|
|
356
|
+
}
|
|
357
|
+
export async function restartService(context) {
|
|
358
|
+
await createServiceManager(context).restart();
|
|
359
|
+
}
|
|
360
|
+
export async function serviceStatus(context) {
|
|
361
|
+
return createServiceManager(context).status();
|
|
362
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { assertSafeServiceContext, SERVICE_LABEL } from './types.js';
|
|
2
|
+
function xml(value) {
|
|
3
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
4
|
+
}
|
|
5
|
+
function keyValue(key, value) {
|
|
6
|
+
return `\t\t<key>${xml(key)}</key>\n\t\t<string>${xml(value)}</string>`;
|
|
7
|
+
}
|
|
8
|
+
export function launchAgentPath(homeDir) {
|
|
9
|
+
return `${homeDir}/Library/LaunchAgents/${SERVICE_LABEL}.plist`;
|
|
10
|
+
}
|
|
11
|
+
export function renderLaunchAgent(context) {
|
|
12
|
+
assertSafeServiceContext(context);
|
|
13
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
14
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
15
|
+
<plist version="1.0">
|
|
16
|
+
<dict>
|
|
17
|
+
\t<key>Label</key>
|
|
18
|
+
\t<string>${SERVICE_LABEL}</string>
|
|
19
|
+
\t<key>WorkingDirectory</key>
|
|
20
|
+
\t<string>${xml(context.homeDir)}</string>
|
|
21
|
+
\t<key>ProgramArguments</key>
|
|
22
|
+
\t<array>
|
|
23
|
+
\t\t<string>${xml(context.nodePath)}</string>
|
|
24
|
+
\t\t<string>${xml(context.cliPath)}</string>
|
|
25
|
+
\t\t<string>__daemon</string>
|
|
26
|
+
\t</array>
|
|
27
|
+
\t<key>EnvironmentVariables</key>
|
|
28
|
+
\t<dict>
|
|
29
|
+
${keyValue('HOME', context.homeDir)}
|
|
30
|
+
${keyValue('AXOM_WORKSTATION_CONFIG_DIR', context.configDir)}
|
|
31
|
+
${keyValue('AXOM_WORKSTATION_NPM_PATH', context.npmPath)}
|
|
32
|
+
${keyValue('AXOM_WORKSTATION_NPM_PREFIX', context.npmPrefix)}
|
|
33
|
+
${keyValue('PATH', context.pathEnv)}
|
|
34
|
+
\t</dict>
|
|
35
|
+
\t<key>StandardOutPath</key>
|
|
36
|
+
\t<string>${xml(`${context.configDir}/daemon.log`)}</string>
|
|
37
|
+
\t<key>StandardErrorPath</key>
|
|
38
|
+
\t<string>${xml(`${context.configDir}/daemon.log`)}</string>
|
|
39
|
+
\t<key>RunAtLoad</key>
|
|
40
|
+
\t<true/>
|
|
41
|
+
\t<key>KeepAlive</key>
|
|
42
|
+
\t<true/>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
/** A login shell that will not answer within this is not worth blocking an install on. */
|
|
3
|
+
const LOGIN_SHELL_TIMEOUT_MS = 3_000;
|
|
4
|
+
/**
|
|
5
|
+
* The `PATH` the user's own login shell produces, or `undefined` if it cannot be obtained.
|
|
6
|
+
*
|
|
7
|
+
* Needed because a service definition can be (re)written from two very different environments. When
|
|
8
|
+
* a person runs `service install` in a terminal, `process.env.PATH` already describes where their
|
|
9
|
+
* tools are. When the **daemon** re-provisions itself after an update, `process.env.PATH` is the
|
|
10
|
+
* service's own PATH — the one the service definition gave it. Building the new definition from
|
|
11
|
+
* that would bake in whatever the old one had, so a machine whose daemon cannot see `~/.local/bin`
|
|
12
|
+
* could never repair itself by updating: it would faithfully reinstall its own blind spot.
|
|
13
|
+
*
|
|
14
|
+
* `-l` is what makes this worth doing: the profile files are where per-user prefixes get added.
|
|
15
|
+
* Best effort by construction — a shell that errors, times out, or prints nothing leaves the caller
|
|
16
|
+
* on `process.env.PATH`, which is exactly today's behaviour.
|
|
17
|
+
*/
|
|
18
|
+
export function loginShellPath(deps = {}) {
|
|
19
|
+
const shell = deps.shell ?? process.env.SHELL;
|
|
20
|
+
if (!shell)
|
|
21
|
+
return Promise.resolve(undefined);
|
|
22
|
+
const exec = deps.exec ?? execFile;
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
try {
|
|
25
|
+
exec(shell, ['-lc', 'printf %s "$PATH"'], { timeout: LOGIN_SHELL_TIMEOUT_MS }, (error, stdout) => {
|
|
26
|
+
if (error)
|
|
27
|
+
return resolve(undefined);
|
|
28
|
+
const value = String(stdout).trim();
|
|
29
|
+
resolve(value.length > 0 ? value : undefined);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
resolve(undefined);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { assertSafeServiceContext, SERVICE_LABEL } from './types.js';
|
|
2
|
+
const SIMPLE_TOKEN = /^[A-Za-z0-9_@+.,/:=-]+$/;
|
|
3
|
+
function escapeUnitValue(value) {
|
|
4
|
+
// `%%` is the literal-percent form for systemd specifier expansion. Environment assignments
|
|
5
|
+
// are not shell commands, so `$` remains literal there.
|
|
6
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
7
|
+
}
|
|
8
|
+
function escapeExecValue(value) {
|
|
9
|
+
// ExecStart additionally performs systemd's environment expansion; `$$` carries a literal `$`
|
|
10
|
+
// into Node instead of resolving an environment variable while systemd parses the unit.
|
|
11
|
+
return escapeUnitValue(value).replace(/\$/g, '$$$$');
|
|
12
|
+
}
|
|
13
|
+
/** One argument according to systemd's command-line parser. */
|
|
14
|
+
function systemdToken(value) {
|
|
15
|
+
const escaped = escapeExecValue(value);
|
|
16
|
+
return SIMPLE_TOKEN.test(value) ? escaped : `"${escaped}"`;
|
|
17
|
+
}
|
|
18
|
+
/** Environment quotes must enclose the complete KEY=value assignment, not just its value. */
|
|
19
|
+
function environment(key, value) {
|
|
20
|
+
const assignment = `${key}=${value}`;
|
|
21
|
+
return SIMPLE_TOKEN.test(value) ? `Environment=${assignment}` : `Environment="${escapeUnitValue(assignment)}"`;
|
|
22
|
+
}
|
|
23
|
+
export function systemdUnitPath(homeDir) {
|
|
24
|
+
return `${homeDir}/.config/systemd/user/${SERVICE_LABEL}.service`;
|
|
25
|
+
}
|
|
26
|
+
export function renderSystemdUnit(context) {
|
|
27
|
+
assertSafeServiceContext(context);
|
|
28
|
+
return `[Unit]
|
|
29
|
+
Description=Axomwork Workstation Client
|
|
30
|
+
After=network-online.target
|
|
31
|
+
Wants=network-online.target
|
|
32
|
+
|
|
33
|
+
[Service]
|
|
34
|
+
Type=simple
|
|
35
|
+
WorkingDirectory=${systemdToken(context.homeDir)}
|
|
36
|
+
${environment('HOME', context.homeDir)}
|
|
37
|
+
${environment('AXOM_WORKSTATION_CONFIG_DIR', context.configDir)}
|
|
38
|
+
${environment('AXOM_WORKSTATION_NPM_PATH', context.npmPath)}
|
|
39
|
+
${environment('AXOM_WORKSTATION_NPM_PREFIX', context.npmPrefix)}
|
|
40
|
+
${environment('PATH', context.pathEnv)}
|
|
41
|
+
ExecStart=${systemdToken(context.nodePath)} ${systemdToken(context.cliPath)} __daemon
|
|
42
|
+
Restart=always
|
|
43
|
+
RestartSec=5
|
|
44
|
+
|
|
45
|
+
[Install]
|
|
46
|
+
WantedBy=default.target
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export const SERVICE_LABEL = 'com.platless.axom-workstation';
|
|
3
|
+
const PATH_FIELDS = ['homeDir', 'nodePath', 'cliPath', 'npmPath', 'npmPrefix', 'configDir'];
|
|
4
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
|
5
|
+
const SAFE_USERNAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
6
|
+
/**
|
|
7
|
+
* The context is rendered into a user-owned service definition. Paths may legitimately contain
|
|
8
|
+
* spaces and quotes, but never controls; accepting a newline would let an environment value add a
|
|
9
|
+
* new unit directive. Reject traversal spellings too, so the displayed and executed paths match.
|
|
10
|
+
*/
|
|
11
|
+
/** `/usr/bin` and `/bin` are the floor: a pathological `PATH` must still leave a usable service. */
|
|
12
|
+
const PATH_FLOOR = ['/usr/bin', '/bin'];
|
|
13
|
+
/**
|
|
14
|
+
* The `PATH` to write into the service definition, built from the environment `service install`
|
|
15
|
+
* was run in.
|
|
16
|
+
*
|
|
17
|
+
* That environment is the best evidence available of where the user's tools actually live: the
|
|
18
|
+
* install command is typed into a shell that can see them. The previous behaviour — node's own
|
|
19
|
+
* directory plus `/usr/bin:/bin` — could not see `~/.local/bin`, `/opt/homebrew/bin` or any other
|
|
20
|
+
* per-user prefix, so a bridge that spawns its CLI (codex does; the Claude one does not, which is
|
|
21
|
+
* exactly why the gap stayed hidden) reported the tool as missing while it worked fine in a
|
|
22
|
+
* terminal.
|
|
23
|
+
*
|
|
24
|
+
* [nodeDirectory] is forced in because the daemon must be able to re-exec node regardless of what
|
|
25
|
+
* the caller's `PATH` says. Empty and relative entries are dropped rather than rejected: a real
|
|
26
|
+
* shell `PATH` may well contain `.` or a stray `::`, and a install that fails over that would be
|
|
27
|
+
* worse than one that quietly ignores an entry the daemon could not safely use anyway. Control
|
|
28
|
+
* characters are the one hard error — a newline in a systemd unit value would let an environment
|
|
29
|
+
* entry inject a directive of its own.
|
|
30
|
+
*/
|
|
31
|
+
export function buildServicePath(rawPath, nodeDirectory) {
|
|
32
|
+
const segments = [nodeDirectory, ...(rawPath ?? '').split(':'), ...PATH_FLOOR];
|
|
33
|
+
const kept = [];
|
|
34
|
+
for (const segment of segments) {
|
|
35
|
+
if (CONTROL_CHARACTER.test(segment))
|
|
36
|
+
throw new Error('PATH contains control characters');
|
|
37
|
+
if (segment.length === 0 || !path.isAbsolute(segment))
|
|
38
|
+
continue;
|
|
39
|
+
if (!kept.includes(segment))
|
|
40
|
+
kept.push(segment);
|
|
41
|
+
}
|
|
42
|
+
return kept.join(':');
|
|
43
|
+
}
|
|
44
|
+
export function assertSafeServiceContext(context) {
|
|
45
|
+
if (context.platform !== 'darwin' && context.platform !== 'linux') {
|
|
46
|
+
throw new Error('platform must be darwin or linux');
|
|
47
|
+
}
|
|
48
|
+
if (!SAFE_USERNAME.test(context.username)) {
|
|
49
|
+
throw new Error('username contains unsafe characters');
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isInteger(context.userId) || context.userId < 0) {
|
|
52
|
+
throw new Error('userId must be a non-negative integer');
|
|
53
|
+
}
|
|
54
|
+
for (const field of PATH_FIELDS) {
|
|
55
|
+
const value = context[field];
|
|
56
|
+
if (CONTROL_CHARACTER.test(value))
|
|
57
|
+
throw new Error(`${field} contains control characters`);
|
|
58
|
+
if (!path.isAbsolute(value))
|
|
59
|
+
throw new Error(`${field} must be an absolute path`);
|
|
60
|
+
if (path.normalize(value) !== value)
|
|
61
|
+
throw new Error(`${field} must not contain traversal or redundant separators`);
|
|
62
|
+
}
|
|
63
|
+
// pathEnv is a colon-joined list, so the single-path rules above do not apply to it as a whole.
|
|
64
|
+
// It is checked per segment, and by the same standard: nothing that could end a plist string or
|
|
65
|
+
// a unit line early. Built by buildServicePath, but re-checked here because the context can also
|
|
66
|
+
// be constructed by a caller that did not go through it.
|
|
67
|
+
if (CONTROL_CHARACTER.test(context.pathEnv))
|
|
68
|
+
throw new Error('pathEnv contains control characters');
|
|
69
|
+
const segments = context.pathEnv.split(':');
|
|
70
|
+
if (segments.length === 0 || segments.some((segment) => segment.length === 0 || !path.isAbsolute(segment))) {
|
|
71
|
+
throw new Error('pathEnv must be a colon-joined list of absolute paths');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
export const SUPERVISION_ENV = 'AXOM_WORKSTATION_SUPERVISION';
|
|
4
|
+
const VALUES = new Set(['launchd', 'systemd', 'container', 'foreground']);
|
|
5
|
+
const defaultProbes = {
|
|
6
|
+
platform: process.platform,
|
|
7
|
+
env: process.env,
|
|
8
|
+
exists: (p) => existsSync(p),
|
|
9
|
+
readText: (p) => { try {
|
|
10
|
+
return readFileSync(p, 'utf8');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
} },
|
|
15
|
+
systemdUserSessionAvailable: () => {
|
|
16
|
+
try {
|
|
17
|
+
const out = execFileSync('systemctl', ['--user', 'is-system-running'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim();
|
|
18
|
+
return out !== 'offline' && out !== '';
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// systemctl --user is-system-running exits non-zero for degraded while still printing it
|
|
22
|
+
if (error instanceof Error && 'stdout' in error) {
|
|
23
|
+
const stdout = error.stdout?.toString().trim();
|
|
24
|
+
return stdout && stdout !== 'offline' && stdout !== '';
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Who restarts the daemon when it exits. Explicit env wins; then macOS is launchd; a container is
|
|
32
|
+
* detected *before* systemd because a container occasionally has systemd but almost never a user
|
|
33
|
+
* session, and the Docker sandbox is the case this exists for; a real systemd-in-container user
|
|
34
|
+
* pins the value with the env var.
|
|
35
|
+
*/
|
|
36
|
+
export function detectSupervision(overrides = {}) {
|
|
37
|
+
const p = { ...defaultProbes, ...overrides };
|
|
38
|
+
const explicit = p.env[SUPERVISION_ENV]?.trim();
|
|
39
|
+
if (explicit) {
|
|
40
|
+
if (!VALUES.has(explicit))
|
|
41
|
+
throw new Error(`${SUPERVISION_ENV} must be one of launchd, systemd, container, foreground (got '${explicit}')`);
|
|
42
|
+
return explicit;
|
|
43
|
+
}
|
|
44
|
+
if (p.platform === 'darwin')
|
|
45
|
+
return 'launchd';
|
|
46
|
+
const cgroup = p.readText('/proc/1/cgroup') ?? '';
|
|
47
|
+
if (p.exists('/.dockerenv') || p.exists('/run/.containerenv') || /docker|containerd|kubepods/.test(cgroup) || Boolean(p.env.KUBERNETES_SERVICE_HOST))
|
|
48
|
+
return 'container';
|
|
49
|
+
if (p.systemdUserSessionAvailable())
|
|
50
|
+
return 'systemd';
|
|
51
|
+
return 'foreground';
|
|
52
|
+
}
|