@commonlyai/cli 0.1.24 → 0.1.25
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/package.json +1 -1
- package/src/commands/daemon.js +170 -0
- package/src/index.js +6 -0
- package/src/lib/daemon-store.js +75 -0
package/package.json
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commonly daemon <subcommand>
|
|
3
|
+
*
|
|
4
|
+
* Phase 2, slice 1: register one machine, persist its scoped daemon bearer,
|
|
5
|
+
* and report/send heartbeats. Adoption and supervision deliberately arrive in
|
|
6
|
+
* later slices; this command never reads an agent runtime credential.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { hostname } from 'os';
|
|
10
|
+
import { createClient } from '../lib/api.js';
|
|
11
|
+
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
12
|
+
import { loadDaemonRecord, saveDaemonRecord } from '../lib/daemon-store.js';
|
|
13
|
+
|
|
14
|
+
const requireDaemonRecord = () => {
|
|
15
|
+
const record = loadDaemonRecord();
|
|
16
|
+
if (!record) {
|
|
17
|
+
throw new Error('No local daemon is registered. Run: commonly daemon register');
|
|
18
|
+
}
|
|
19
|
+
return record;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const requireUserToken = (instance) => {
|
|
23
|
+
const token = getToken(instance);
|
|
24
|
+
if (!token) throw new Error(`Not logged in to ${instance}. Run: commonly login --instance ${instance}`);
|
|
25
|
+
return token;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const requireRegistrationResponse = (response) => {
|
|
29
|
+
const machine = response?.machine;
|
|
30
|
+
if (!machine?.id || !machine?.machineId || !machine?.name || !response?.daemonToken) {
|
|
31
|
+
throw new Error('Server returned an incomplete machine registration. No daemon credential was stored.');
|
|
32
|
+
}
|
|
33
|
+
return { machine, daemonToken: response.daemonToken };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Exported for a service-level test: persistence failure must revoke the
|
|
37
|
+
// freshly-created machine because the raw bearer is not recoverable later.
|
|
38
|
+
export const registerDaemonMachine = async ({
|
|
39
|
+
client,
|
|
40
|
+
instanceUrl,
|
|
41
|
+
name,
|
|
42
|
+
persist = saveDaemonRecord,
|
|
43
|
+
}) => {
|
|
44
|
+
const { machine, daemonToken } = requireRegistrationResponse(
|
|
45
|
+
await client.post('/api/machines', { name }),
|
|
46
|
+
);
|
|
47
|
+
const record = {
|
|
48
|
+
machineDbId: machine.id,
|
|
49
|
+
machineId: machine.machineId,
|
|
50
|
+
machineName: machine.name,
|
|
51
|
+
instanceUrl,
|
|
52
|
+
daemonToken,
|
|
53
|
+
registeredAt: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
persist(record);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The token is a one-time response. If it cannot be secured locally, tear
|
|
60
|
+
// down the server row so it cannot remain a live, unrecoverable bearer.
|
|
61
|
+
try {
|
|
62
|
+
await client.del(`/api/machines/${machine.id}`);
|
|
63
|
+
} catch (revokeError) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Could not store the daemon credential and could not revoke machine ${machine.name}: ${revokeError.message}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Could not store the daemon credential securely: ${error.message}. Machine registration was revoked.`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { machine, record };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const heartbeatDaemonMachine = async ({ client, record }) => (
|
|
75
|
+
client.post(`/api/machines/${record.machineDbId}/heartbeat`)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const getDaemonMachineStatus = async ({ client }) => {
|
|
79
|
+
const response = await client.get('/api/machines/me');
|
|
80
|
+
return response?.machine || null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const registerDaemon = (program) => {
|
|
84
|
+
const daemon = program.command('daemon').description('Manage the local Commonly daemon');
|
|
85
|
+
|
|
86
|
+
daemon.addHelpText('after', `
|
|
87
|
+
The daemon token is scoped to this machine and stored in a 0600 file under
|
|
88
|
+
~/.commonly/daemon/. It is never shown again after registration.
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
$ commonly daemon register --name "Sam's MacBook"
|
|
92
|
+
$ commonly daemon heartbeat
|
|
93
|
+
$ commonly daemon status
|
|
94
|
+
`);
|
|
95
|
+
|
|
96
|
+
daemon
|
|
97
|
+
.command('register')
|
|
98
|
+
.description('Register this machine and securely store its daemon credential')
|
|
99
|
+
.option('--name <name>', 'Machine name (default: this computer\'s hostname)')
|
|
100
|
+
.option('--instance <url-or-key>', 'Target Commonly instance')
|
|
101
|
+
.action(async (opts) => {
|
|
102
|
+
try {
|
|
103
|
+
const existing = loadDaemonRecord();
|
|
104
|
+
if (existing) {
|
|
105
|
+
throw new Error(`A local daemon is already registered for ${existing.machineName}. Run: commonly daemon status`);
|
|
106
|
+
}
|
|
107
|
+
const instanceUrl = resolveInstanceUrl(opts.instance);
|
|
108
|
+
const client = createClient({ instance: instanceUrl, token: requireUserToken(opts.instance || instanceUrl) });
|
|
109
|
+
const { machine, record } = await registerDaemonMachine({
|
|
110
|
+
client,
|
|
111
|
+
instanceUrl,
|
|
112
|
+
name: String(opts.name || hostname()).trim(),
|
|
113
|
+
});
|
|
114
|
+
console.log(`Registered ${machine.name}. Daemon credential stored securely.`);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await heartbeatDaemonMachine({
|
|
118
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
119
|
+
record,
|
|
120
|
+
});
|
|
121
|
+
console.log('Initial heartbeat accepted.');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(`Machine is registered, but its initial heartbeat failed: ${error.message}`);
|
|
124
|
+
console.error('Retry with: commonly daemon heartbeat');
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(`Daemon registration failed: ${error.message}`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
daemon
|
|
134
|
+
.command('heartbeat')
|
|
135
|
+
.description('Send a machine liveness heartbeat using the stored daemon credential')
|
|
136
|
+
.action(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const record = requireDaemonRecord();
|
|
139
|
+
const response = await heartbeatDaemonMachine({
|
|
140
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
141
|
+
record,
|
|
142
|
+
});
|
|
143
|
+
console.log(`Heartbeat accepted for ${response?.machine?.name || record.machineName}.`);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error(`Daemon heartbeat failed: ${error.message}`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
daemon
|
|
151
|
+
.command('status')
|
|
152
|
+
.description('Show the server-derived liveness of this machine')
|
|
153
|
+
.action(async () => {
|
|
154
|
+
try {
|
|
155
|
+
const record = requireDaemonRecord();
|
|
156
|
+
const machine = await getDaemonMachineStatus({
|
|
157
|
+
client: createClient({ instance: record.instanceUrl, token: record.daemonToken }),
|
|
158
|
+
});
|
|
159
|
+
if (!machine) {
|
|
160
|
+
console.log(`${record.machineName}: no longer registered on the server.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const lastSeen = machine.lastSeenAt ? new Date(machine.lastSeenAt).toLocaleString() : 'never';
|
|
164
|
+
console.log(`${machine.name}: ${machine.status} (last heartbeat: ${lastSeen})`);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(`Daemon status failed: ${error.message}`);
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
};
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'url';
|
|
|
16
16
|
|
|
17
17
|
import { registerLogin, registerWhoami } from './commands/login.js';
|
|
18
18
|
import { registerAgent } from './commands/agent.js';
|
|
19
|
+
import { registerDaemon } from './commands/daemon.js';
|
|
19
20
|
import { registerPod } from './commands/pod.js';
|
|
20
21
|
import { registerDev } from './commands/dev.js';
|
|
21
22
|
|
|
@@ -37,6 +38,9 @@ registerWhoami(program);
|
|
|
37
38
|
// Agent management
|
|
38
39
|
registerAgent(program);
|
|
39
40
|
|
|
41
|
+
// Local daemon lifecycle
|
|
42
|
+
registerDaemon(program);
|
|
43
|
+
|
|
40
44
|
// Pod management
|
|
41
45
|
registerPod(program);
|
|
42
46
|
|
|
@@ -53,6 +57,8 @@ Quick start:
|
|
|
53
57
|
$ commonly agent attach claude --pod <podId> --name my-claude
|
|
54
58
|
$ commonly agent run my-claude # Ctrl+C to stop
|
|
55
59
|
$ commonly agent detach my-claude # clean uninstall
|
|
60
|
+
$ commonly daemon register --name "My MacBook"
|
|
61
|
+
$ commonly daemon status
|
|
56
62
|
|
|
57
63
|
Custom Python agent:
|
|
58
64
|
$ commonly agent init --language python --name research-bot --pod <podId>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local daemon credential storage.
|
|
3
|
+
*
|
|
4
|
+
* A daemon is one machine-level supervisor, not an agent token file. Keep its
|
|
5
|
+
* credential in its own 0700 directory and enforce 0600 on every write; the
|
|
6
|
+
* bearer must never be printed or placed in the ordinary CLI config file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
statSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'fs';
|
|
19
|
+
import { homedir } from 'os';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
|
|
22
|
+
const daemonDir = () => join(homedir(), '.commonly', 'daemon');
|
|
23
|
+
export const daemonRecordPath = () => join(daemonDir(), 'machine.json');
|
|
24
|
+
|
|
25
|
+
const validateRecord = (record) => {
|
|
26
|
+
if (!record
|
|
27
|
+
|| typeof record.machineDbId !== 'string'
|
|
28
|
+
|| typeof record.machineId !== 'string'
|
|
29
|
+
|| typeof record.machineName !== 'string'
|
|
30
|
+
|| typeof record.instanceUrl !== 'string'
|
|
31
|
+
|| typeof record.daemonToken !== 'string'
|
|
32
|
+
|| !record.daemonToken.startsWith('cm_daemon_')) {
|
|
33
|
+
throw new Error('Daemon credential file is invalid. Revoke the machine before registering again.');
|
|
34
|
+
}
|
|
35
|
+
return record;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const loadDaemonRecord = () => {
|
|
39
|
+
const path = daemonRecordPath();
|
|
40
|
+
const dir = daemonDir();
|
|
41
|
+
if (!existsSync(path)) return null;
|
|
42
|
+
if ((statSync(dir).mode & 0o077) !== 0) {
|
|
43
|
+
throw new Error(`Daemon credential directory permissions are insecure at ${dir}. Set them to 0700 before running the daemon.`);
|
|
44
|
+
}
|
|
45
|
+
if ((statSync(path).mode & 0o077) !== 0) {
|
|
46
|
+
throw new Error(`Daemon credential file permissions are insecure at ${path}. Set them to 0600 before running the daemon.`);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return validateRecord(JSON.parse(readFileSync(path, 'utf8')));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof Error && error.message.startsWith('Daemon credential file')) {
|
|
52
|
+
throw new Error(`${error.message} (${path})`);
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Daemon credential file is unreadable at ${path}. Revoke the machine before registering again.`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const saveDaemonRecord = (record) => {
|
|
59
|
+
const safeRecord = validateRecord(record);
|
|
60
|
+
const dir = daemonDir();
|
|
61
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
62
|
+
chmodSync(dir, 0o700);
|
|
63
|
+
|
|
64
|
+
const path = daemonRecordPath();
|
|
65
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(temporaryPath, `${JSON.stringify(safeRecord, null, 2)}\n`, { mode: 0o600 });
|
|
68
|
+
chmodSync(temporaryPath, 0o600);
|
|
69
|
+
renameSync(temporaryPath, path);
|
|
70
|
+
chmodSync(path, 0o600);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
};
|