@commonlyai/cli 0.1.227 → 0.1.229
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
CHANGED
package/src/commands/daemon.js
CHANGED
|
@@ -28,6 +28,7 @@ import { getLastTurn } from '../lib/session-store.js';
|
|
|
28
28
|
import { getAdapter, providerKeyEnvNames } from '../lib/adapters/index.js';
|
|
29
29
|
import {
|
|
30
30
|
installDaemonService,
|
|
31
|
+
installSummary,
|
|
31
32
|
uninstallDaemonService,
|
|
32
33
|
startDaemonService,
|
|
33
34
|
stopDaemonService,
|
|
@@ -432,7 +433,7 @@ Examples:
|
|
|
432
433
|
|
|
433
434
|
daemon
|
|
434
435
|
.command('install')
|
|
435
|
-
.description('Register the daemon as a login service (launchd/systemd) so it
|
|
436
|
+
.description('Register the daemon as a login service (launchd/systemd); on Linux, also turn on lingering so it starts at boot')
|
|
436
437
|
.action(async () => {
|
|
437
438
|
try {
|
|
438
439
|
// A service without a credential just crash-loops at boot.
|
|
@@ -442,12 +443,12 @@ Examples:
|
|
|
442
443
|
// adapter. The keys are captured here because this is the only moment
|
|
443
444
|
// the operator's shell is in reach; the ones that are missing are named
|
|
444
445
|
// by installDaemonService rather than left to be discovered per seat.
|
|
445
|
-
await installDaemonService({
|
|
446
|
+
const installed = await installDaemonService({
|
|
446
447
|
...serviceDeps(),
|
|
447
448
|
providerKeyEnvNames: providerKeyEnvNames(),
|
|
448
449
|
optionalEnvNames: [UPDATE_CHECK_OPT_OUT_ENV],
|
|
449
450
|
});
|
|
450
|
-
console.log(
|
|
451
|
+
console.log(installSummary(installed));
|
|
451
452
|
} catch (error) {
|
|
452
453
|
console.error(`Daemon install failed: ${error.message}`);
|
|
453
454
|
process.exitCode = 1;
|
|
@@ -40,6 +40,8 @@ const templateLiteral = (value) => {
|
|
|
40
40
|
};
|
|
41
41
|
// Explicit API-only list: new MCP tools do not silently gain public authority.
|
|
42
42
|
// attach_file reads this process's filesystem, including its credential file.
|
|
43
|
+
// catch_up is left out too: it moves the read cursor past history unread, so a
|
|
44
|
+
// public seat past 50 unseen falls back to continueAnyway, which it keeps.
|
|
43
45
|
export const PUBLIC_GOOSE_TOOLS = Object.freeze([
|
|
44
46
|
'commonly_get_started', 'commonly_post_message', 'commonly_claim_message',
|
|
45
47
|
'commonly_release_claim', 'commonly_get_messages', 'commonly_get_context',
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { randomBytes } from 'crypto';
|
|
14
|
-
import { renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
15
|
-
import { homedir } from 'os';
|
|
14
|
+
import { existsSync, renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
15
|
+
import { homedir, userInfo } from 'os';
|
|
16
16
|
import { join, dirname, resolve } from 'path';
|
|
17
17
|
|
|
18
18
|
export const LAUNCHD_LABEL = 'me.commonly.daemon';
|
|
@@ -196,6 +196,59 @@ WantedBy=default.target
|
|
|
196
196
|
`;
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
// D171: a systemd user unit is started by the user's own systemd manager, and
|
|
200
|
+
// without lingering that manager starts at the user's login, not at boot. On a
|
|
201
|
+
// headless box nobody logs in to, the daemon would then stay down after every
|
|
202
|
+
// reboot. logind records lingering as one file per user, so that file is what
|
|
203
|
+
// says the daemon starts at boot, not the exit code of the call that asked.
|
|
204
|
+
// logind names the file cescape(user name) (src/basic/escape.c): the named
|
|
205
|
+
// escapes, and octal for any other byte below a space or from 127 up.
|
|
206
|
+
const NAMED_ESCAPES = {
|
|
207
|
+
7: 'a', 8: 'b', 12: 'f', 10: 'n', 13: 'r', 9: 't', 11: 'v', 92: '\\', 34: '"', 39: "'",
|
|
208
|
+
};
|
|
209
|
+
const cescape = (value) => [...Buffer.from(value, 'utf8')].map((byte) => {
|
|
210
|
+
if (NAMED_ESCAPES[byte]) return `\\${NAMED_ESCAPES[byte]}`;
|
|
211
|
+
if (byte < 32 || byte >= 127) return `\\${byte.toString(8).padStart(3, '0')}`;
|
|
212
|
+
return String.fromCharCode(byte);
|
|
213
|
+
}).join('');
|
|
214
|
+
export const lingerFile = (username) => join('/var/lib/systemd/linger', cescape(username));
|
|
215
|
+
|
|
216
|
+
// The command is printed for a person to paste into a shell, so a user name the
|
|
217
|
+
// shell would split or expand is single-quoted. The call above it takes argv.
|
|
218
|
+
const shellWord = (value) => (/^[A-Za-z0-9._@%+=:,/-]+$/.test(value)
|
|
219
|
+
? value : `'${value.replace(/'/g, "'\\''")}'`);
|
|
220
|
+
|
|
221
|
+
// logind before v253 passes the escaped file name to manager_add_user_by_name at
|
|
222
|
+
// boot, so it never starts a user whose name needs escaping. The version isn't read,
|
|
223
|
+
// so for such a name the file alone doesn't prove a boot start.
|
|
224
|
+
const lingeringStartsAtBoot = (username, warn) => {
|
|
225
|
+
if (cescape(username) === username) return true;
|
|
226
|
+
warn(`Lingering is on for ${username}, but systemd before v253 cannot start a user whose name needs escaping at boot, so the daemon is only sure to start when ${username} logs in.`);
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const ensureLinger = async ({ username, existsFile, execCmd, warn }) => {
|
|
231
|
+
if (existsFile(lingerFile(username))) return lingeringStartsAtBoot(username, warn);
|
|
232
|
+
let failure = '';
|
|
233
|
+
try {
|
|
234
|
+
// --no-ask-password: the install must not stop at a polkit prompt; a refusal
|
|
235
|
+
// is answered below with the command to run instead.
|
|
236
|
+
await execCmd(['loginctl', '--no-ask-password', 'enable-linger', username]);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
failure = ` (${error.message})`;
|
|
239
|
+
}
|
|
240
|
+
if (existsFile(lingerFile(username))) return lingeringStartsAtBoot(username, warn);
|
|
241
|
+
warn(`Lingering is off for ${username}${failure}, so the daemon starts when ${username} logs in, not at boot. On a machine nobody logs in to, it stays down after a reboot. To start it at boot, run: sudo loginctl enable-linger ${shellWord(username)}`);
|
|
242
|
+
return false;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// The line the install command ends with. "Survives reboots" is said only when
|
|
246
|
+
// the service starts at boot: a LaunchAgent, like a systemd user unit without
|
|
247
|
+
// lingering, starts at login.
|
|
248
|
+
export const installSummary = ({ startsAtBoot }) => (startsAtBoot
|
|
249
|
+
? 'The daemon now starts at boot and is kept alive, so it survives reboots. Uninstall with: commonly daemon uninstall'
|
|
250
|
+
: 'The daemon now starts at login and is kept alive. Uninstall with: commonly daemon uninstall');
|
|
251
|
+
|
|
199
252
|
export const installDaemonService = async ({
|
|
200
253
|
platform = process.platform,
|
|
201
254
|
home = homedir(),
|
|
@@ -213,6 +266,11 @@ export const installDaemonService = async ({
|
|
|
213
266
|
execCmd, // async (argv: string[]) => void — throws on failure
|
|
214
267
|
chmod = () => {},
|
|
215
268
|
ensureFile,
|
|
269
|
+
existsFile = existsSync,
|
|
270
|
+
// Resolved only on Linux, and only after the unit is up: userInfo() throws
|
|
271
|
+
// for a uid with no passwd entry.
|
|
272
|
+
username,
|
|
273
|
+
lookupUsername = () => userInfo().username,
|
|
216
274
|
log = () => {},
|
|
217
275
|
warn = log,
|
|
218
276
|
}) => {
|
|
@@ -228,6 +286,7 @@ export const installDaemonService = async ({
|
|
|
228
286
|
const providerEnv = providerEnvPairs({ names: [...new Set([...providerKeyEnvNames, ...optionalEnvNames])], env });
|
|
229
287
|
const missing = missingProviderKeys({ names: providerKeyEnvNames, env });
|
|
230
288
|
|
|
289
|
+
let startsAtBoot = false;
|
|
231
290
|
if (target.kind === 'launchd') {
|
|
232
291
|
// The mode and the atomicity are the WRITER's, not an ordering rule here:
|
|
233
292
|
// `writeServiceFile` creates a 0600 sibling and renames it over the target,
|
|
@@ -245,12 +304,22 @@ export const installDaemonService = async ({
|
|
|
245
304
|
// the environment just written, the way launchd's unload/load does.
|
|
246
305
|
await execCmd(['systemctl', '--user', 'enable', SYSTEMD_UNIT]);
|
|
247
306
|
await execCmd(['systemctl', '--user', 'restart', SYSTEMD_UNIT]);
|
|
307
|
+
// After the unit is up, so a refused linger leaves a working login service.
|
|
308
|
+
let lingerUser = username;
|
|
309
|
+
if (!lingerUser) {
|
|
310
|
+
try {
|
|
311
|
+
lingerUser = lookupUsername();
|
|
312
|
+
} catch (error) {
|
|
313
|
+
warn(`Could not read this account's user name (${error.message}), so lingering could not be checked: the daemon starts at login, and at boot only if lingering is already on for this account.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (lingerUser) startsAtBoot = await ensureLinger({ username: lingerUser, existsFile, execCmd, warn });
|
|
248
317
|
}
|
|
249
318
|
for (const name of missing) {
|
|
250
319
|
warn(`${name} is not set in this shell, so it is NOT in the service file. A seat whose adapter needs it will fail to start; export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
251
320
|
}
|
|
252
321
|
log(`Installed ${target.kind} service (${target.file}). Logs: ${daemonLogPath(home)}`);
|
|
253
|
-
return target;
|
|
322
|
+
return { ...target, startsAtBoot };
|
|
254
323
|
};
|
|
255
324
|
|
|
256
325
|
export const uninstallDaemonService = async ({
|
|
@@ -2,4 +2,6 @@
|
|
|
2
2
|
// Claude workspace seats follow the same list. Each
|
|
3
3
|
// adapter removes them in its own vocabulary (TASK-287; Goose's public list
|
|
4
4
|
// already leaves them out).
|
|
5
|
-
|
|
5
|
+
// commonly_catch_up moves the read cursor past history unread; a wrapper seat
|
|
6
|
+
// already catches up before each turn, so no seat on this list needs the tool.
|
|
7
|
+
export const PUBLIC_WITHHELD_TOOLS = Object.freeze(['commonly_attach_file', 'commonly_catch_up']);
|