@prohost/cli 0.16.5 → 0.16.6

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/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.6
10
+
11
+ - **A hand-off note that can't be written undoes the dedicated install.** The note is part of the
12
+ takeover: without it the computer's key stayed in both homes, and a plain `host unpair` in the
13
+ original one would remove the computer under the running service. Now the service is
14
+ uninstalled and the key the install copied is removed (on Windows its administrators' copy too),
15
+ and the install fails with what to fix. A service home that already held the key before keeps
16
+ it, and the install still fails.
17
+
9
18
  ## 0.16.5
10
19
 
11
20
  - **A dedicated install takes the computer's key over.** `sudo prohost host install --dedicated`
package/README.md CHANGED
@@ -247,9 +247,9 @@ holder:** once the service home has the computer, the home it was taken from (`-
247
247
  or `$SUDO_USER`'s) keeps only a note — `{handed_to: {label, service_user, service_home, at},
248
248
  computer_id, name, account, code_host_home?}`, no key — written as that home's owner.
249
249
  `host status` there reports `handed_to`; `unpair`, `pair` and a personal `install` refuse and
250
- name the service. If the note can't be written the install says so loudly (the key is then
251
- still in both homes: delete the old one by hand, never `unpair` from it). A failed install
252
- leaves the key where it was. `sudo prohost host uninstall --label <id>` gives the key back to
250
+ name the service. The note is part of the takeover: if it can't be written, the install is
251
+ undone (the service uninstalled, the key it copied removed) and fails, so the key is never left in
252
+ both homes; a failed install leaves the key where it was. `sudo prohost host uninstall --label <id>` gives the key back to
253
253
  that home (so `prohost host install` runs it there again); `sudo prohost host unpair --label <id>`
254
254
  removes the computer. Both read the service's home from its installed definition (root's file),
255
255
  never from the note, which the home's owner can edit. **Updates drain:**
@@ -11,7 +11,7 @@
11
11
  * prohost host pause | resume
12
12
  * prohost host migrate [--from <home>]… [--home <dir,…>] [--keep-services] [--dry-run] [--json]
13
13
  */
14
- import type { InstallIo } from './install.js';
14
+ import type { InstallIo, ServiceSpec } from './install.js';
15
15
  import type { LoginInfo } from './logins.js';
16
16
  import { runMigrate } from './migrate.js';
17
17
  type Flags = Record<string, string | boolean>;
@@ -63,5 +63,7 @@ export declare function statusJson(env: NodeJS.ProcessEnv, now?: number, options
63
63
  export declare function signInSpawn(command: string, args: string[], platform?: NodeJS.Platform): [string, string[], {
64
64
  windowsVerbatimArguments?: boolean;
65
65
  }];
66
+ /** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
67
+ export declare function serviceStillLoaded(spec: ServiceSpec, serviceIo: InstallIo, pidAlive?: (pid: number) => boolean): boolean;
66
68
  export declare function runHost(subcommand: string | undefined, flags: Flags, printHelp: () => void, io?: HostIo): Promise<number>;
67
69
  export {};
@@ -29,7 +29,7 @@ import { addAccount, isValidLabel, loadAccounts, parseAccountRuntime, accountEnv
29
29
  import { revokeComputer } from './api.js';
30
30
  import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, handoffNotice, handoffOf, hostLockPath, SERVICE_LABEL, readHandoff, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
31
31
  import { journalPath, readEvents } from './events.js';
32
- import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, installedDedicatedService, publishedComputer, publishedComputerId, readServiceCredential, rewriteHomeCredential, windowsServiceCredentialPath, windowsHomesDir, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
32
+ import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, installedDedicatedService, systemdUnitOf, publishedComputer, publishedComputerId, readServiceCredential, rewriteHomeCredential, windowsServiceCredentialPath, windowsHomesDir, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
33
33
  import { LegacyHarnesses } from './legacy.js';
34
34
  import { detectLogins } from './logins.js';
35
35
  import { adoptComputer, loadAdopted, runMigrate } from './migrate.js';
@@ -490,6 +490,45 @@ function serviceCredential(platform, home, user, serviceIo, label) {
490
490
  return null;
491
491
  }
492
492
  }
493
+ /** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
494
+ export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
495
+ const run = serviceIo.run ?? defaultServiceRunner;
496
+ try {
497
+ if (spec.platform === 'darwin')
498
+ return run('launchctl', ['print', `system/${spec.label}`]).status === 0;
499
+ if (spec.platform === 'win32') {
500
+ if (run('schtasks', ['/Query', '/TN', windowsTaskOf(spec)]).status === 0)
501
+ return true;
502
+ // Deleting a task does not stop what it started: the host's own lock names a live process.
503
+ // Only "no such file" means no lock; any other read error is an unknown, so still there (fail closed).
504
+ const lock = (serviceIo.readFile ?? ((f) => {
505
+ try {
506
+ return readFileSync(f, 'utf8');
507
+ }
508
+ catch (e) {
509
+ if (e.code === 'ENOENT')
510
+ return undefined;
511
+ throw e;
512
+ }
513
+ }))(path.win32.join(spec.home, 'host', 'service.lock'));
514
+ const pid = lock ? JSON.parse(lock).pid : undefined;
515
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pidAlive(pid);
516
+ }
517
+ return run('systemctl', ['is-active', '--quiet', systemdUnitOf(spec)]).status === 0;
518
+ }
519
+ catch {
520
+ return true;
521
+ }
522
+ }
523
+ function processAlive(pid) {
524
+ try {
525
+ process.kill(pid, 0);
526
+ return true;
527
+ }
528
+ catch (e) {
529
+ return e.code === 'EPERM';
530
+ }
531
+ }
493
532
  /** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
494
533
  function handedFromHome(flags, env, platform) {
495
534
  return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
@@ -756,6 +795,8 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
756
795
  (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
757
796
  // The key this install takes over, read before it runs: only a copy it really made is retired.
758
797
  const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
798
+ // Whether the service home already held a key before this install (then it copies none).
799
+ const serviceHadKey = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) !== null : false;
759
800
  const outcome = installService(spec, io.serviceIo, {
760
801
  ...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
761
802
  ...(dedicated ? { adoptFrom } : {}),
@@ -790,8 +831,55 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
790
831
  });
791
832
  const problem = rewriteHomeCredential(platform, adoptFrom, `${JSON.stringify(note, null, 2)}\n`, io.serviceIo);
792
833
  if (problem) {
793
- err(` ✗ The service has the computer, but its key is still in ${adoptFrom} too (${problem}). ` +
794
- `Delete ${path.join(adoptFrom, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from that home: it would remove the computer.`);
834
+ // The note is part of the takeover: without it the key would live in both homes, and an
835
+ // `unpair` here would remove the computer under the service. What this install did is undone.
836
+ err(` ✗ Could not leave the hand-off note in ${adoptFrom} (${problem}).`);
837
+ // What the original home holds now decides the undo (on Windows a rename can land before a
838
+ // later check fails): its key still there → undo; the note there after all → handed over;
839
+ // neither → the service keeps the only key.
840
+ const sourceKey = tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom });
841
+ const sourceNote = readHandoff({ ...env, PROHOST_HOME: adoptFrom });
842
+ const handedAnyway = !sourceKey && sourceNote?.handed_to.label === spec.label && sourceNote?.computer_id === handedFrom.computer_id;
843
+ if (handedAnyway) {
844
+ err(` ! The note is in place after all: the computer's key is the service's alone. Check ${path.join(adoptFrom, 'host', 'computer.json')}.`);
845
+ }
846
+ else if (sourceKey?.computer_id !== handedFrom.computer_id) {
847
+ err(` ✗ ${adoptFrom} no longer holds this computer's key, so ${spec.label} keeps it: check ${path.join(adoptFrom, 'host', 'computer.json')}, never delete ${home}.`);
848
+ return 1;
849
+ }
850
+ else if (!serviceHadKey) {
851
+ const undone = (() => {
852
+ try {
853
+ return uninstallService(spec, io.serviceIo);
854
+ }
855
+ catch (e) {
856
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
857
+ }
858
+ })();
859
+ // Removed and no longer loaded, not just "nothing failed": a Windows task that would not
860
+ // stop removes nothing, and a launchd / systemd service whose stop failed may still run.
861
+ const serviceGone = undone.ok && undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
862
+ const keyGone = serviceGone && !rewriteHomeCredential(platform, home, undefined, io.serviceIo);
863
+ if (keyGone && platform === 'win32') {
864
+ try {
865
+ (io.serviceIo?.removeFile ?? ((f) => rmSync(f, { force: true })))(windowsServiceCredentialPath(spec));
866
+ }
867
+ catch {
868
+ /* reported below with the rest */
869
+ }
870
+ }
871
+ err(serviceGone && keyGone
872
+ ? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
873
+ (platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
874
+ : ` ✗ Could not fully undo it: remove ${spec.label} (sudo prohost host uninstall --label ${spec.label}) and ${path.join(home, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
875
+ return 1;
876
+ }
877
+ else {
878
+ err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
879
+ `Fix the problem above, then install again.`);
880
+ }
881
+ if (!handedAnyway)
882
+ return 1;
795
883
  }
796
884
  else {
797
885
  out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.16.5";
10
+ export declare const CLI_VERSION = "0.16.6";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.5";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.6";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.16.5';
10
+ export const CLI_VERSION = '0.16.6';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.16.5",
3
+ "version": "0.16.6",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",