iteration-cli 0.4.2 → 0.4.3

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.
@@ -1,4 +1,11 @@
1
+ export type LaunchAgentRestart = {
2
+ isLoaded(timeoutMs: number): Promise<boolean>;
3
+ bootstrap(timeoutMs: number): Promise<void>;
4
+ wait(delayMs: number): Promise<void>;
5
+ now(): number;
6
+ };
1
7
  export declare function launchAgentPlist(cliPath?: string, nodePath?: string, logDirectory?: string, dataDirectory?: string): string;
8
+ export declare function bootstrapAfterBootout(wasLoaded: boolean, control: LaunchAgentRestart): Promise<void>;
2
9
  export declare function installLaunchAgent(dataDirectory?: string): Promise<string>;
3
10
  export declare function uninstallLaunchAgent(): Promise<void>;
4
11
  export declare function launchAgentStatus(): Promise<boolean>;
@@ -3,9 +3,13 @@ import { execFile as execFileCallback } from "node:child_process";
3
3
  import { promises as fs } from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
6
7
  import { fileURLToPath } from "node:url";
7
8
  const execFile = promisify(execFileCallback);
8
9
  const LABEL = "com.iteration.codex-collector";
10
+ const LAUNCHCTL_TIMEOUT_MS = 5_000;
11
+ const RESTART_TIMEOUT_MS = 10_000;
12
+ const RESTART_POLL_MS = 200;
9
13
  function xml(value) {
10
14
  return value
11
15
  .replaceAll("&", "&amp;")
@@ -52,18 +56,73 @@ function launchctlTarget() {
52
56
  throw new Error("Could not determine the current macOS user id.");
53
57
  return `gui/${userId}`;
54
58
  }
55
- async function bootoutIfLoaded(target) {
59
+ function serviceMissing(error) {
60
+ const details = error;
61
+ return (String(details.code) === "113" ||
62
+ details.stderr?.includes("Could not find service") === true);
63
+ }
64
+ async function serviceLoaded(target, timeoutMs = LAUNCHCTL_TIMEOUT_MS) {
56
65
  try {
57
- await execFile("launchctl", ["print", `${target}/${LABEL}`]);
66
+ await execFile("launchctl", ["print", `${target}/${LABEL}`], {
67
+ timeout: timeoutMs,
68
+ });
69
+ return true;
58
70
  }
59
71
  catch (error) {
60
- const details = error;
61
- if (String(details.code) === "113" ||
62
- details.stderr?.includes("Could not find service"))
63
- return;
72
+ if (serviceMissing(error))
73
+ return false;
64
74
  throw error;
65
75
  }
66
- await execFile("launchctl", ["bootout", `${target}/${LABEL}`]);
76
+ }
77
+ async function bootoutIfLoaded(target) {
78
+ if (!(await serviceLoaded(target)))
79
+ return false;
80
+ await execFile("launchctl", ["bootout", `${target}/${LABEL}`], {
81
+ timeout: LAUNCHCTL_TIMEOUT_MS,
82
+ });
83
+ return true;
84
+ }
85
+ function transientBootstrapFailure(error) {
86
+ const details = error;
87
+ return (String(details.code) === "5" ||
88
+ /^Bootstrap failed:\s*5\b/m.test(details.stderr ?? ""));
89
+ }
90
+ function restartTimeoutError(lastBootstrapFailure) {
91
+ return new Error(lastBootstrapFailure === undefined
92
+ ? "The previous Codex collector did not stop within 10 seconds."
93
+ : "launchctl bootstrap still failed with error 5 after 10 seconds.", { cause: lastBootstrapFailure });
94
+ }
95
+ export async function bootstrapAfterBootout(wasLoaded, control) {
96
+ if (!wasLoaded) {
97
+ await control.bootstrap(LAUNCHCTL_TIMEOUT_MS);
98
+ return;
99
+ }
100
+ const deadline = control.now() + RESTART_TIMEOUT_MS;
101
+ let lastBootstrapFailure;
102
+ while (true) {
103
+ let remaining = deadline - control.now();
104
+ if (remaining <= 0) {
105
+ throw restartTimeoutError(lastBootstrapFailure);
106
+ }
107
+ if (!(await control.isLoaded(Math.min(remaining, LAUNCHCTL_TIMEOUT_MS)))) {
108
+ remaining = deadline - control.now();
109
+ if (remaining <= 0)
110
+ continue;
111
+ try {
112
+ await control.bootstrap(Math.min(remaining, LAUNCHCTL_TIMEOUT_MS));
113
+ return;
114
+ }
115
+ catch (error) {
116
+ if (!transientBootstrapFailure(error))
117
+ throw error;
118
+ lastBootstrapFailure = error;
119
+ }
120
+ }
121
+ remaining = deadline - control.now();
122
+ if (remaining > 0) {
123
+ await control.wait(Math.min(remaining, RESTART_POLL_MS));
124
+ }
125
+ }
67
126
  }
68
127
  export async function installLaunchAgent(dataDirectory) {
69
128
  if (process.platform !== "darwin") {
@@ -77,9 +136,20 @@ export async function installLaunchAgent(dataDirectory) {
77
136
  await fs.writeFile(file, launchAgentPlist(undefined, undefined, undefined, dataDirectory ??
78
137
  path.join(os.homedir(), ".config", "iteration", "codex-collector")), { encoding: "utf8", mode: 0o644 });
79
138
  const target = launchctlTarget();
80
- await bootoutIfLoaded(target);
81
- await execFile("launchctl", ["bootstrap", target, file]);
82
- await execFile("launchctl", ["kickstart", "-p", `${target}/${LABEL}`]);
139
+ const wasLoaded = await bootoutIfLoaded(target);
140
+ await bootstrapAfterBootout(wasLoaded, {
141
+ isLoaded: (timeoutMs) => serviceLoaded(target, timeoutMs),
142
+ bootstrap: async (timeoutMs) => {
143
+ await execFile("launchctl", ["bootstrap", target, file], {
144
+ timeout: timeoutMs,
145
+ });
146
+ },
147
+ wait: sleep,
148
+ now: Date.now,
149
+ });
150
+ await execFile("launchctl", ["kickstart", "-p", `${target}/${LABEL}`], {
151
+ timeout: LAUNCHCTL_TIMEOUT_MS,
152
+ });
83
153
  return file;
84
154
  }
85
155
  export async function uninstallLaunchAgent() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",