@commonlyai/cli 0.1.227 → 0.1.228

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.227",
3
+ "version": "0.1.228",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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 survives reboots')
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('The daemon now starts at login and is kept alive. Uninstall with: commonly daemon uninstall');
451
+ console.log(installSummary(installed));
451
452
  } catch (error) {
452
453
  console.error(`Daemon install failed: ${error.message}`);
453
454
  process.exitCode = 1;
@@ -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 ({