@ctrl-spc/cli 1.3.3 → 1.3.5
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/commands/init.js +4 -0
- package/dist/commands/login.js +4 -0
- package/dist/commands/logout.js +33 -4
- package/dist/commands/run.js +20 -7
- package/dist/companion-ui.js +4 -3
- package/dist/index.js +23 -1
- package/dist/runtime-control.js +17 -6
- package/dist/wake.js +237 -0
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ask, select } from '../prompt.js';
|
|
|
9
9
|
import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
|
|
10
10
|
import { upsertProjectLink } from './link.js';
|
|
11
11
|
import { registerMachineWorkspace } from './run.js';
|
|
12
|
+
import { installWakeAgent } from '../wake.js';
|
|
12
13
|
function setGrouping(flags, grouping) {
|
|
13
14
|
if (flags.grouping) {
|
|
14
15
|
throw new Error(`Choose exactly one grouping option: --combine, --split, or --manifest <file>.`);
|
|
@@ -448,6 +449,9 @@ export async function init(argv = [], deps = {}) {
|
|
|
448
449
|
return;
|
|
449
450
|
}
|
|
450
451
|
const client = await getClient();
|
|
452
|
+
if (installWakeAgent()) {
|
|
453
|
+
console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
|
|
454
|
+
}
|
|
451
455
|
const { data: userData, error: userError } = await client.auth.getUser();
|
|
452
456
|
if (userError || !userData.user) {
|
|
453
457
|
console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
|
package/dist/commands/login.js
CHANGED
|
@@ -4,6 +4,7 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
4
4
|
import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
|
|
5
5
|
import { writeSession } from '../config.js';
|
|
6
6
|
import { getClient } from '../supabase.js';
|
|
7
|
+
import { installWakeAgent } from '../wake.js';
|
|
7
8
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
9
|
/** POST /callback bodies larger than this are rejected before JSON parsing. */
|
|
9
10
|
const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
|
|
@@ -16,6 +17,9 @@ export async function login() {
|
|
|
16
17
|
const outcome = await runLoginServer();
|
|
17
18
|
if (outcome.ok) {
|
|
18
19
|
console.log(`Signed in as ${outcome.email}`);
|
|
20
|
+
if (installWakeAgent()) {
|
|
21
|
+
console.log('Remote start enabled — this computer checks in every 60s so you can start it from the web. Disable: ctrl-spc wake off');
|
|
22
|
+
}
|
|
19
23
|
process.exitCode = 0;
|
|
20
24
|
}
|
|
21
25
|
else {
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import { clearSession } from '../config.js';
|
|
1
|
+
import { clearSession, getMachineIdentity } from '../config.js';
|
|
2
|
+
import { getClient } from '../supabase.js';
|
|
3
|
+
import { removeWakeAgent } from '../wake.js';
|
|
4
|
+
async function defaultNullHeartbeats() {
|
|
5
|
+
const client = await getClient();
|
|
6
|
+
const { error } = await client
|
|
7
|
+
.from('machines')
|
|
8
|
+
.update({ waker_seen_at: null, broker_seen_at: null })
|
|
9
|
+
.eq('id', getMachineIdentity().id);
|
|
10
|
+
if (error)
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* `ctrl-spc logout`: deletes the stored session. Local-only — the refresh
|
|
4
15
|
* token is discarded, not revoked server-side (Supabase revokes it on next
|
|
5
16
|
* rotation attempt; fine for v1's single-session CLI).
|
|
6
17
|
*/
|
|
7
|
-
export function logout() {
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
export async function logout(deps = {}) {
|
|
19
|
+
try {
|
|
20
|
+
await (deps.nullHeartbeats ?? defaultNullHeartbeats)();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Offline or already signed out. The freshness window removes stale UI.
|
|
24
|
+
}
|
|
25
|
+
let wakeRemovalFailed = false;
|
|
26
|
+
try {
|
|
27
|
+
;
|
|
28
|
+
(deps.removeWake ?? removeWakeAgent)();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Clearing the authenticated session is the security boundary. A stale
|
|
32
|
+
// plist without a session can only wake, fail authentication, and exit.
|
|
33
|
+
wakeRemovalFailed = true;
|
|
34
|
+
}
|
|
35
|
+
if ((deps.clear ?? clearSession)()) {
|
|
36
|
+
console.log(wakeRemovalFailed
|
|
37
|
+
? 'Logged out — stored session removed. The background start check could not be removed, but it no longer has access to your account.'
|
|
38
|
+
: 'Logged out — stored session removed and remote start disabled.');
|
|
10
39
|
}
|
|
11
40
|
else {
|
|
12
41
|
console.log('No stored session — already logged out.');
|
package/dist/commands/run.js
CHANGED
|
@@ -13,6 +13,7 @@ import { startMcpServer } from '../mcp.js';
|
|
|
13
13
|
import { getMachineIdentity } from '../config.js';
|
|
14
14
|
import { discoverRepositoryTopology } from '../topology.js';
|
|
15
15
|
import { createAgentDispatchController } from '../dispatch.js';
|
|
16
|
+
import { installWakeAgent, stampMachineHeartbeat } from '../wake.js';
|
|
16
17
|
function mcpUrl() {
|
|
17
18
|
return `http://localhost:${MCP_PORT}/mcp`;
|
|
18
19
|
}
|
|
@@ -697,8 +698,9 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
|
|
|
697
698
|
const intervalMs = deps.intervalMs ?? 30_000;
|
|
698
699
|
let timer;
|
|
699
700
|
let chain = Promise.resolve();
|
|
700
|
-
const runReconcile = async () => {
|
|
701
|
-
|
|
701
|
+
const runReconcile = async (refreshWorkspaces) => {
|
|
702
|
+
if (refreshWorkspaces)
|
|
703
|
+
await deps.refreshWorkspaces?.();
|
|
702
704
|
const projects = await loadProjects(client, userId, machine.id);
|
|
703
705
|
const currentIds = new Set(projects.map((project) => project.id));
|
|
704
706
|
for (const project of projects) {
|
|
@@ -722,10 +724,10 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
|
|
|
722
724
|
}
|
|
723
725
|
};
|
|
724
726
|
return {
|
|
725
|
-
reconcile() {
|
|
727
|
+
reconcile(options = {}) {
|
|
726
728
|
// Recover the queue after a transient failed poll; the caller still
|
|
727
729
|
// receives this run's rejection, but later intervals keep reconciling.
|
|
728
|
-
chain = chain.catch(() => { }).then(runReconcile);
|
|
730
|
+
chain = chain.catch(() => { }).then(() => runReconcile(options.refreshWorkspaces !== false));
|
|
729
731
|
return chain;
|
|
730
732
|
},
|
|
731
733
|
start() {
|
|
@@ -757,12 +759,13 @@ export function createProjectPresenceReconciler(client, userId, profileName, mac
|
|
|
757
759
|
* tool call can flip presence back to "working" mid-shutdown) and only then
|
|
758
760
|
* untracks presence, before exiting.
|
|
759
761
|
*/
|
|
760
|
-
function installShutdownHandler(presence, mcpServer, dispatch) {
|
|
762
|
+
function installShutdownHandler(presence, mcpServer, dispatch, onShutdown) {
|
|
761
763
|
let shuttingDown = false;
|
|
762
764
|
const shutdown = () => {
|
|
763
765
|
if (shuttingDown)
|
|
764
766
|
return;
|
|
765
767
|
shuttingDown = true;
|
|
768
|
+
onShutdown?.();
|
|
766
769
|
dispatch?.stop();
|
|
767
770
|
console.log('\nShutting down — closing MCP server, untracking presence...');
|
|
768
771
|
mcpServer
|
|
@@ -784,6 +787,8 @@ export async function run() {
|
|
|
784
787
|
if (retireLegacyDaemon()) {
|
|
785
788
|
console.log('Removed obsolete CTRL+SPC background launcher.');
|
|
786
789
|
}
|
|
790
|
+
if (process.argv[2] !== '__broker')
|
|
791
|
+
installWakeAgent();
|
|
787
792
|
const cwd = process.cwd();
|
|
788
793
|
const client = await getClient();
|
|
789
794
|
const { data: userData, error: userError } = await client.auth.getUser();
|
|
@@ -841,7 +846,7 @@ export async function run() {
|
|
|
841
846
|
loadProjects: loadRunnableMachineProjects,
|
|
842
847
|
refreshWorkspaces: () => reconcileMachineWorkspaces(client, userId, machine),
|
|
843
848
|
});
|
|
844
|
-
await presence.reconcile();
|
|
849
|
+
await presence.reconcile({ refreshWorkspaces: false });
|
|
845
850
|
presence.start();
|
|
846
851
|
let mcpServer;
|
|
847
852
|
try {
|
|
@@ -864,6 +869,14 @@ export async function run() {
|
|
|
864
869
|
await presence.stop().catch(() => { });
|
|
865
870
|
throw formatMcpStartupError(err, MCP_PORT);
|
|
866
871
|
}
|
|
872
|
+
// A broker heartbeat means the local server is genuinely accepting work.
|
|
873
|
+
// Stamp only after startMcpServer succeeds; otherwise a port/startup failure
|
|
874
|
+
// would make the web app show this computer as online for three minutes.
|
|
875
|
+
await stampMachineHeartbeat(client, machine.id, true).catch(() => { });
|
|
876
|
+
const heartbeatTimer = setInterval(() => {
|
|
877
|
+
void stampMachineHeartbeat(client, machine.id, true).catch(() => { });
|
|
878
|
+
}, 60_000);
|
|
879
|
+
heartbeatTimer.unref();
|
|
867
880
|
console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
|
|
868
881
|
'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
|
|
869
882
|
'set_task_role_slugs, ' +
|
|
@@ -872,5 +885,5 @@ export async function run() {
|
|
|
872
885
|
console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
|
|
873
886
|
console.log('Presence: available');
|
|
874
887
|
const dispatch = createAgentDispatchController(client, machine.id, registeredAgents);
|
|
875
|
-
installShutdownHandler(presence, mcpServer, dispatch);
|
|
888
|
+
installShutdownHandler(presence, mcpServer, dispatch, () => clearInterval(heartbeatTimer));
|
|
876
889
|
}
|
package/dist/companion-ui.js
CHANGED
|
@@ -1143,17 +1143,18 @@ export function renderCompanionUi(token, kind, runtimePort = kind === 'local' ?
|
|
|
1143
1143
|
const profile = snapshot.profiles.find((candidate) => candidate.kind === kind);
|
|
1144
1144
|
const action = profile.state === 'ready' ? 'stop' : 'start';
|
|
1145
1145
|
lastError = '';
|
|
1146
|
+
let succeeded = false;
|
|
1146
1147
|
setBusy(true, action === 'start' ? 'Switching connection…' : 'Turning connection off…');
|
|
1147
1148
|
try {
|
|
1148
|
-
await api('/api/runtime/' + kind + '/' + action, { method: 'POST' });
|
|
1149
|
-
|
|
1149
|
+
render(await api('/api/runtime/' + kind + '/' + action, { method: 'POST' }));
|
|
1150
|
+
succeeded = true;
|
|
1150
1151
|
} catch (error) {
|
|
1151
1152
|
lastError = error.message;
|
|
1152
1153
|
notice.className = 'notice error';
|
|
1153
1154
|
notice.textContent = lastError;
|
|
1154
1155
|
} finally {
|
|
1155
1156
|
setBusy(false);
|
|
1156
|
-
await refresh(false);
|
|
1157
|
+
if (!succeeded) await refresh(false);
|
|
1157
1158
|
}
|
|
1158
1159
|
}
|
|
1159
1160
|
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { link } from './commands/link.js';
|
|
|
6
6
|
import { fetchRepository } from './commands/fetch.js';
|
|
7
7
|
import { run } from './commands/run.js';
|
|
8
8
|
import { openCompanion, serveCompanion } from './companion.js';
|
|
9
|
+
import { installWakeAgent, removeWakeAgent, wakeCheck } from './wake.js';
|
|
9
10
|
export const HELP_TEXT = `ctrl-spc — CTRL+SPC CLI
|
|
10
11
|
|
|
11
12
|
Usage:
|
|
@@ -18,6 +19,8 @@ Usage:
|
|
|
18
19
|
ctrl-spc fetch Clone and link one unavailable project repository
|
|
19
20
|
[repository-id] [--project <project-id>] [--destination <path>] [--yes]
|
|
20
21
|
ctrl-spc open Open the local connection manager
|
|
22
|
+
ctrl-spc wake Enable/disable starting this computer's agents from the web
|
|
23
|
+
on|off
|
|
21
24
|
ctrl-spc Run the local agent process (presence + MCP server)
|
|
22
25
|
ctrl-spc --help Show this help text
|
|
23
26
|
`;
|
|
@@ -28,7 +31,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
28
31
|
await login();
|
|
29
32
|
return;
|
|
30
33
|
case 'logout':
|
|
31
|
-
logout();
|
|
34
|
+
await logout();
|
|
32
35
|
return;
|
|
33
36
|
case 'init':
|
|
34
37
|
await init(argv.slice(1));
|
|
@@ -42,6 +45,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
42
45
|
case 'open':
|
|
43
46
|
await openCompanion();
|
|
44
47
|
return;
|
|
48
|
+
case 'wake':
|
|
49
|
+
if (argv[1] === 'off') {
|
|
50
|
+
console.log(removeWakeAgent()
|
|
51
|
+
? 'Remote start disabled on this computer.'
|
|
52
|
+
: 'Remote start was not enabled.');
|
|
53
|
+
}
|
|
54
|
+
else if (argv[1] === 'on') {
|
|
55
|
+
console.log(installWakeAgent()
|
|
56
|
+
? 'Remote start enabled — this computer checks in every 60s. Disable: ctrl-spc wake off'
|
|
57
|
+
: 'Remote start requires macOS.');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.error('Usage: ctrl-spc wake on|off');
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
case '__wake-check':
|
|
65
|
+
await wakeCheck();
|
|
66
|
+
return;
|
|
45
67
|
case '__broker':
|
|
46
68
|
await run();
|
|
47
69
|
return;
|
package/dist/runtime-control.js
CHANGED
|
@@ -2,7 +2,10 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { configDir } from './config.js';
|
|
5
|
+
import { configDir, getMachineIdentity } from './config.js';
|
|
6
|
+
export function brokerMatchesRuntime(health, kind, machineId) {
|
|
7
|
+
return health?.runtime?.kind === kind && health.machine_id === machineId;
|
|
8
|
+
}
|
|
6
9
|
const CURRENT_PACKAGE_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
7
10
|
const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
8
11
|
export const LOCAL_RUNTIME_PORT = Number(process.env.CTRL_SPC_LOCAL_PORT) || 4572;
|
|
@@ -209,13 +212,16 @@ export async function startRuntime(kind) {
|
|
|
209
212
|
'Stop it from its terminal before switching connections.');
|
|
210
213
|
}
|
|
211
214
|
const currentHealth = await probeBroker(profile.port);
|
|
212
|
-
if (currentHealth?.runtime?.kind === kind)
|
|
213
|
-
|
|
215
|
+
if (currentHealth?.runtime?.kind === kind) {
|
|
216
|
+
if (brokerMatchesRuntime(currentHealth, kind, getMachineIdentity().id))
|
|
217
|
+
return;
|
|
218
|
+
throw new Error('Another CTRL+SPC connection is already running for a different computer identity. Stop it and try again.');
|
|
219
|
+
}
|
|
214
220
|
if (currentHealth) {
|
|
215
221
|
throw new Error(`Port ${profile.port} is already used by an unlabeled process. Turn it off before continuing.`);
|
|
216
222
|
}
|
|
217
223
|
mkdirSync(runtimeDir(), { recursive: true });
|
|
218
|
-
const logFd = openSync(logPath(kind), '
|
|
224
|
+
const logFd = openSync(logPath(kind), 'w', 0o600);
|
|
219
225
|
let child;
|
|
220
226
|
try {
|
|
221
227
|
child = spawn(process.execPath, [profile.entryPath, '__broker'], {
|
|
@@ -236,11 +242,16 @@ export async function startRuntime(kind) {
|
|
|
236
242
|
if (!child.pid)
|
|
237
243
|
throw new Error(`${profile.label} did not start.`);
|
|
238
244
|
writePrivateFile(pidPath(kind), String(child.pid));
|
|
245
|
+
let childExited = false;
|
|
246
|
+
child.once('exit', () => { childExited = true; });
|
|
239
247
|
const ready = await waitFor(async () => {
|
|
248
|
+
if (childExited)
|
|
249
|
+
return true;
|
|
240
250
|
const health = await probeBroker(profile.port);
|
|
241
|
-
return health
|
|
251
|
+
return brokerMatchesRuntime(health, kind, getMachineIdentity().id);
|
|
242
252
|
}, 30_000, 250);
|
|
243
|
-
|
|
253
|
+
const health = await probeBroker(profile.port);
|
|
254
|
+
if (!ready || !brokerMatchesRuntime(health, kind, getMachineIdentity().id)) {
|
|
244
255
|
rmSync(pidPath(kind), { force: true });
|
|
245
256
|
const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
|
|
246
257
|
throw new Error(`${profile.label} did not become ready.${log ? `\n${log}` : ''}`);
|
package/dist/wake.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { getClient } from './supabase.js';
|
|
7
|
+
import { getMachineIdentity } from './config.js';
|
|
8
|
+
import { currentCompanionKind, runtimeStatuses, startRuntime, } from './runtime-control.js';
|
|
9
|
+
const WAKE_LABEL = 'com.ctrl-spc.wake';
|
|
10
|
+
const WAKE_INTERVAL_SECONDS = 60;
|
|
11
|
+
const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
12
|
+
export const WAKE_REQUEST_FRESH_SECONDS = 150;
|
|
13
|
+
const FAILED_CLEANUP_MINUTES = 10;
|
|
14
|
+
export function wakeAgentPlistPath() {
|
|
15
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${WAKE_LABEL}.plist`);
|
|
16
|
+
}
|
|
17
|
+
function xmlEscape(value) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, ''');
|
|
24
|
+
}
|
|
25
|
+
export function renderWakePlist(nodePath, entryPath, pathEnv) {
|
|
26
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
27
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
28
|
+
<plist version="1.0">
|
|
29
|
+
<dict>
|
|
30
|
+
<key>Label</key><string>${WAKE_LABEL}</string>
|
|
31
|
+
<key>ProgramArguments</key>
|
|
32
|
+
<array>
|
|
33
|
+
<string>${xmlEscape(nodePath)}</string>
|
|
34
|
+
<string>${xmlEscape(entryPath)}</string>
|
|
35
|
+
<string>__wake-check</string>
|
|
36
|
+
</array>
|
|
37
|
+
<key>StartInterval</key><integer>${WAKE_INTERVAL_SECONDS}</integer>
|
|
38
|
+
<key>RunAtLoad</key><true/>
|
|
39
|
+
<key>ProcessType</key><string>Background</string>
|
|
40
|
+
<key>EnvironmentVariables</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>PATH</key><string>${xmlEscape(pathEnv)}</string>
|
|
43
|
+
</dict>
|
|
44
|
+
</dict>
|
|
45
|
+
</plist>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
function defaultLaunchctl(args) {
|
|
49
|
+
execFileSync('launchctl', args, { stdio: 'ignore' });
|
|
50
|
+
}
|
|
51
|
+
function defaultWrite(path, contents) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, contents, { mode: 0o644 });
|
|
54
|
+
chmodSync(path, 0o644);
|
|
55
|
+
}
|
|
56
|
+
function defaultRead(path) {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(path, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function installWakeAgent(deps = {}) {
|
|
65
|
+
if (process.env.CTRL_SPC_NO_WAKE_AGENT === '1')
|
|
66
|
+
return false;
|
|
67
|
+
if (process.platform !== 'darwin' && !deps.plistPath)
|
|
68
|
+
return false;
|
|
69
|
+
const plistPath = deps.plistPath ?? wakeAgentPlistPath();
|
|
70
|
+
const contents = renderWakePlist(deps.nodePath ?? process.execPath, deps.entryPath ?? CURRENT_ENTRY_PATH, deps.pathEnv ?? process.env.PATH ?? '/usr/bin:/bin');
|
|
71
|
+
if ((deps.read ?? defaultRead)(plistPath) === contents)
|
|
72
|
+
return true;
|
|
73
|
+
(deps.write ?? defaultWrite)(plistPath, contents);
|
|
74
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
75
|
+
if (uid === undefined)
|
|
76
|
+
return true;
|
|
77
|
+
const launchctl = deps.launchctl ?? defaultLaunchctl;
|
|
78
|
+
try {
|
|
79
|
+
launchctl(['bootout', `gui/${uid}`, plistPath]);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// The job was not loaded yet.
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
launchctl(['bootstrap', `gui/${uid}`, plistPath]);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Do not leave a byte-identical plist behind after a failed bootstrap:
|
|
89
|
+
// installWakeAgent intentionally skips launchctl for identical files, so
|
|
90
|
+
// keeping it would make every later retry report success without loading
|
|
91
|
+
// the job.
|
|
92
|
+
try {
|
|
93
|
+
;
|
|
94
|
+
(deps.remove ?? rmSync)(plistPath);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Preserve the launchctl error, which is the actionable failure.
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
export function removeWakeAgent(deps = {}) {
|
|
104
|
+
if (process.platform !== 'darwin' && !deps.plistPath)
|
|
105
|
+
return false;
|
|
106
|
+
const plistPath = deps.plistPath ?? wakeAgentPlistPath();
|
|
107
|
+
if (!(deps.exists ?? existsSync)(plistPath))
|
|
108
|
+
return false;
|
|
109
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
110
|
+
if (uid !== undefined) {
|
|
111
|
+
try {
|
|
112
|
+
;
|
|
113
|
+
(deps.launchctl ?? defaultLaunchctl)(['bootout', `gui/${uid}`, plistPath]);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// An unloaded job is already stopped; the file still needs removal.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
;
|
|
120
|
+
(deps.remove ?? rmSync)(plistPath);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
export async function stampMachineHeartbeat(client, machineId, brokerRunning) {
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
const patch = { waker_seen_at: now };
|
|
126
|
+
if (brokerRunning)
|
|
127
|
+
patch.broker_seen_at = now;
|
|
128
|
+
const { error } = await client.from('machines').update(patch).eq('id', machineId);
|
|
129
|
+
if (error)
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
function assertNoError(error) {
|
|
133
|
+
if (error)
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
function supabaseWakeDb(client, machineId) {
|
|
137
|
+
const table = () => client.from('machine_wake_requests');
|
|
138
|
+
const freshCutoff = () => new Date(Date.now() - WAKE_REQUEST_FRESH_SECONDS * 1000).toISOString();
|
|
139
|
+
return {
|
|
140
|
+
stampHeartbeat: (brokerRunning) => stampMachineHeartbeat(client, machineId, brokerRunning),
|
|
141
|
+
async claimFreshPending() {
|
|
142
|
+
const { data, error } = await table()
|
|
143
|
+
.select('id')
|
|
144
|
+
.eq('machine_id', machineId)
|
|
145
|
+
.eq('status', 'pending')
|
|
146
|
+
.gte('requested_at', freshCutoff())
|
|
147
|
+
.order('requested_at', { ascending: false })
|
|
148
|
+
.limit(1)
|
|
149
|
+
.maybeSingle();
|
|
150
|
+
assertNoError(error);
|
|
151
|
+
return data ?? null;
|
|
152
|
+
},
|
|
153
|
+
async failStalePending() {
|
|
154
|
+
const { error } = await table().update({
|
|
155
|
+
status: 'failed',
|
|
156
|
+
error: 'Request expired before this machine checked in.',
|
|
157
|
+
updated_at: new Date().toISOString(),
|
|
158
|
+
})
|
|
159
|
+
.eq('machine_id', machineId)
|
|
160
|
+
.eq('status', 'pending')
|
|
161
|
+
.lt('requested_at', freshCutoff());
|
|
162
|
+
assertNoError(error);
|
|
163
|
+
},
|
|
164
|
+
async resolvePending(id) {
|
|
165
|
+
const { error } = await table()
|
|
166
|
+
.delete()
|
|
167
|
+
.eq('id', id)
|
|
168
|
+
.eq('machine_id', machineId)
|
|
169
|
+
.eq('status', 'pending');
|
|
170
|
+
assertNoError(error);
|
|
171
|
+
},
|
|
172
|
+
async failRequest(id, message) {
|
|
173
|
+
const { error } = await table()
|
|
174
|
+
.update({ status: 'failed', error: message, updated_at: new Date().toISOString() })
|
|
175
|
+
.eq('id', id);
|
|
176
|
+
assertNoError(error);
|
|
177
|
+
},
|
|
178
|
+
async cleanupFailed() {
|
|
179
|
+
const cutoff = new Date(Date.now() - FAILED_CLEANUP_MINUTES * 60 * 1000).toISOString();
|
|
180
|
+
const { error } = await table()
|
|
181
|
+
.delete()
|
|
182
|
+
.eq('machine_id', machineId)
|
|
183
|
+
.eq('status', 'failed')
|
|
184
|
+
.lt('updated_at', cutoff);
|
|
185
|
+
assertNoError(error);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function defaultGetDb() {
|
|
190
|
+
const client = await getClient();
|
|
191
|
+
return supabaseWakeDb(client, getMachineIdentity().id);
|
|
192
|
+
}
|
|
193
|
+
async function defaultBrokerRunning() {
|
|
194
|
+
const statuses = await runtimeStatuses();
|
|
195
|
+
const machineId = getMachineIdentity().id;
|
|
196
|
+
return statuses.some((status) => status.running && status.health?.machine_id === machineId);
|
|
197
|
+
}
|
|
198
|
+
export async function wakeCheck(deps = {}) {
|
|
199
|
+
try {
|
|
200
|
+
if (await (deps.brokerRunning ?? defaultBrokerRunning)())
|
|
201
|
+
return 'broker_running';
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// A failed probe is treated as not running so the poller can recover it.
|
|
205
|
+
}
|
|
206
|
+
let db;
|
|
207
|
+
try {
|
|
208
|
+
db = await (deps.getDb ?? defaultGetDb)();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return 'no_session';
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await db.stampHeartbeat(false);
|
|
215
|
+
await db.failStalePending();
|
|
216
|
+
const pending = await db.claimFreshPending();
|
|
217
|
+
if (!pending) {
|
|
218
|
+
await db.cleanupFailed();
|
|
219
|
+
return 'idle';
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await (deps.start ?? startRuntime)((deps.runtimeKind ?? currentCompanionKind)());
|
|
223
|
+
await db.resolvePending(pending.id);
|
|
224
|
+
await db.stampHeartbeat(true);
|
|
225
|
+
await db.cleanupFailed();
|
|
226
|
+
return 'started';
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
await db.failRequest(pending.id, error instanceof Error ? error.message : String(error));
|
|
230
|
+
await db.cleanupFailed();
|
|
231
|
+
return 'start_failed';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return 'idle';
|
|
236
|
+
}
|
|
237
|
+
}
|