allaboard 0.0.0-stage → 0.4.0

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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,134 @@
1
+ /**
2
+ * What allaboard asks of the owner's npm to update itself: the newest published version, whether a
3
+ * version is published, whether the running copy is the one `npm install -g` replaces, and the
4
+ * install. It goes through `npm`, never over HTTP of its own, so the owner's registry, proxy and
5
+ * credentials apply (issue #361). npm runs through `RunAsync`: an install takes 30 to 60 seconds.
6
+ *
7
+ * The shapes of npm's failures were measured on npm 11.17.0 (2026-10-17): an unreachable registry
8
+ * prints `npm error code ECONNREFUSED` and ends with status 1; a `lib/node_modules` that is not
9
+ * writable prints `npm error code EACCES`, `npm error path <folder>` and ends with 243; a killed
10
+ * npm ends with 137 and says nothing. An unknown version under `--json` prints `npm error code
11
+ * E404` and ends with 1.
12
+ */
13
+ import { lstatSync, realpathSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { getLogger } from "../log.js";
16
+ import { isVersion } from "../semver.js";
17
+ import { packageVersion } from "../version.js";
18
+ import { copyOf } from "./program.js";
19
+ export const logger = getLogger("allaboard.host.npm");
20
+ export { isNewer, isVersion } from "../semver.js";
21
+ const NETWORK_CODES = new Set([
22
+ "ECONNREFUSED",
23
+ "ENOTFOUND",
24
+ "ETIMEDOUT",
25
+ "EAI_AGAIN",
26
+ "ECONNRESET",
27
+ "ENETUNREACH",
28
+ ]);
29
+ /** The failure that `result`, of a command that did not end well, tells. */
30
+ export function classifyFailure(result) {
31
+ const code = /^npm error code (\S+)\s*$/m.exec(result.stderr)?.[1];
32
+ if (code !== undefined && NETWORK_CODES.has(code))
33
+ return { kind: "network" };
34
+ if (code === "EACCES" || code === "EPERM") {
35
+ const path = /^npm error path (\/.+?)\s*$/m.exec(result.stderr)?.[1];
36
+ return { kind: "permission", path: path ?? null };
37
+ }
38
+ return { kind: "other", status: result.status };
39
+ }
40
+ /** The version `npm view` printed for `args`, or why there is none. */
41
+ async function viewed(run, args, signal) {
42
+ const result = await run("npm", ["view", ...args, "version", "--json"], signal);
43
+ if (result.status !== 0)
44
+ return { failure: classifyFailure(result), result };
45
+ try {
46
+ const parsed = JSON.parse(result.stdout);
47
+ if (typeof parsed === "string" && isVersion(parsed))
48
+ return { version: parsed };
49
+ }
50
+ catch { }
51
+ return { failure: { kind: "other", status: 0 }, result };
52
+ }
53
+ /**
54
+ * The newest version published on npm (`dist-tags.latest`), or the failure that hid it. Every
55
+ * function below that runs npm takes the `signal` that ends it (SIGTERM, see `RunAsync`).
56
+ */
57
+ export async function latestVersion(run, signal) {
58
+ const found = await viewed(run, ["allaboard"], signal);
59
+ return "failure" in found ? { failure: found.failure } : { version: found.version };
60
+ }
61
+ /**
62
+ * Whether npm publishes `version` of allaboard. An unknown version is `false`; npm that cannot
63
+ * answer is the failure. A string that is no version is not asked about.
64
+ */
65
+ export async function publishedVersion(run, version, signal) {
66
+ if (!isVersion(version))
67
+ return { published: false };
68
+ const found = await viewed(run, [`allaboard@${version}`], signal);
69
+ if ("version" in found)
70
+ return { published: found.version === version };
71
+ // The code of npm's refusal, not an exit status: an unknown version and a package that does
72
+ // not exist both print E404.
73
+ return /^npm error code E404\s*$/m.test(found.result.stderr)
74
+ ? { published: false }
75
+ : { failure: found.failure };
76
+ }
77
+ /** The real path of `path`, or `path` itself when it cannot be resolved. */
78
+ function real(path) {
79
+ try {
80
+ return realpathSync(path);
81
+ }
82
+ catch {
83
+ return path;
84
+ }
85
+ }
86
+ /**
87
+ * Whether the copy that runs from `main` (the real path of its `dist/main.js`) at `version` is
88
+ * the one `npm install -g allaboard` replaces: `<npm root -g>/allaboard` by real path, not a link,
89
+ * its version published. Returns its package folder, else the refusal. `version` is the one the
90
+ * process read at its start: after an install the copy's `package.json` holds the next one.
91
+ */
92
+ export async function updatableCopy(run, main, version, signal) {
93
+ const rooted = await run("npm", ["root", "-g"], signal);
94
+ if (rooted.status !== 0)
95
+ return { refusal: { kind: "npm", failure: classifyFailure(rooted) } };
96
+ const root = real(rooted.stdout.trim());
97
+ const copy = join(root, "allaboard");
98
+ const own = real(main);
99
+ const running = copyOf(own);
100
+ try {
101
+ // `npm link` makes the global folder a link to the checkout; the process then runs from the
102
+ // checkout, which is where the link points.
103
+ if (lstatSync(copy).isSymbolicLink() && real(copy) === running) {
104
+ return { refusal: { kind: "link" } };
105
+ }
106
+ }
107
+ catch { }
108
+ if (own !== join(copy, "dist", "main.js")) {
109
+ return { refusal: { kind: "not-global", folder: running } };
110
+ }
111
+ const published = await publishedVersion(run, version, signal);
112
+ if ("failure" in published)
113
+ return { refusal: { kind: "npm", failure: published.failure } };
114
+ return published.published ? { copy } : { refusal: { kind: "unpublished", version } };
115
+ }
116
+ /**
117
+ * Runs `npm install -g allaboard@<version>` over the copy in `copy` and reads that copy's
118
+ * `package.json` again. Throws a RangeError, with nothing run, for a `version` that is not an
119
+ * exact version. npm's own words go to the log; the result carries only what they classify. An
120
+ * install that `signal` ended is npm's own rollback (measured 2026-10-17: it finishes its current
121
+ * step, puts the old folder back and ends with 1): a failure, the copy as it was.
122
+ */
123
+ export async function installVersion(run, copy, version, signal) {
124
+ if (!isVersion(version))
125
+ throw new RangeError(`not a version to install: ${JSON.stringify(version)}`);
126
+ const result = await run("npm", ["install", "-g", `allaboard@${version}`], signal);
127
+ if (result.status !== 0) {
128
+ const failure = classifyFailure(result);
129
+ logger.error(`npm install allaboard@${version} failed with status ${result.status}: ${result.stderr.trim().slice(0, 4000)}`);
130
+ return { kind: "failed", failure };
131
+ }
132
+ const found = packageVersion(join(copy, "package.json"));
133
+ return found === version ? { kind: "installed", version } : { kind: "mismatch", found };
134
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The program a service manager starts: which Node, which file, and the folders a session's
3
+ * commands need on their `PATH`. Read from the command being run, so `allaboard service install`
4
+ * names the copy it was started from.
5
+ */
6
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
7
+ import { delimiter, dirname, join } from "node:path";
8
+ /** The first executable file called `name` in the folders of `pathVariable`, or null. */
9
+ export function findOnPath(name, pathVariable) {
10
+ for (const folder of pathVariable.split(delimiter)) {
11
+ if (folder === "")
12
+ continue;
13
+ const candidate = join(folder, name);
14
+ try {
15
+ // A folder passes the access check too.
16
+ if (!statSync(candidate).isFile())
17
+ continue;
18
+ accessSync(candidate, constants.X_OK);
19
+ return candidate;
20
+ }
21
+ catch { }
22
+ }
23
+ return null;
24
+ }
25
+ // npm's folders page (read 2026-10-16): "Global installs on Unix systems go to
26
+ // `{prefix}/lib/node_modules`" and executables "are linked into `{prefix}/bin` on Unix".
27
+ const GLOBAL_ENTRY = /^(.*)\/lib\/node_modules\/allaboard\/dist\/main\.js$/;
28
+ /**
29
+ * The program of the running command: `entry` is `process.argv[1]` (npm's link in its `bin`
30
+ * folder, resolved here), `pathVariable` the caller's `PATH`, `execPath` the fallback Node.
31
+ */
32
+ export function programOf(entry, pathVariable, execPath) {
33
+ const main = realpathSync(entry);
34
+ const prefix = GLOBAL_ENTRY.exec(main)?.[1];
35
+ return {
36
+ node: findOnPath("node", pathVariable) ?? execPath,
37
+ main,
38
+ npmBin: prefix === undefined ? null : join(prefix, "bin"),
39
+ };
40
+ }
41
+ /** The folders of the service's `PATH`: Node's own first, then npm's, then the system's. */
42
+ export function servicePath(program, system) {
43
+ const folders = [dirname(program.node), program.npmBin, ...system];
44
+ return [...new Set(folders.filter((folder) => folder !== null))].join(":");
45
+ }
46
+ /** The package folder of the copy whose entry file is `main`: the folder above `dist/`. */
47
+ export function copyOf(main) {
48
+ return dirname(dirname(main));
49
+ }
@@ -0,0 +1,52 @@
1
+ /** Runs a system command for the host layer, with no shell between, and waits for it. */
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ /** `Run` on the real machine: a command that cannot start reports status null and why. */
4
+ export const runCommand = (command, args) => {
5
+ const result = spawnSync(command, args, { encoding: "utf8" });
6
+ if (result.error !== undefined) {
7
+ return { status: null, stdout: "", stderr: result.error.message };
8
+ }
9
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr };
10
+ };
11
+ function abortReason(signal) {
12
+ const reason = signal.reason;
13
+ return reason instanceof Error ? reason.message : "aborted before the command started";
14
+ }
15
+ /** `RunAsync` on the real machine: same reporting as `runCommand`, the event loop free meanwhile. */
16
+ export const runAsync = (command, args, signal) => new Promise((resolve) => {
17
+ // A signal that has aborted already starts nothing: there is no command to end.
18
+ if (signal?.aborted) {
19
+ resolve({ status: null, stdout: "", stderr: abortReason(signal) });
20
+ return;
21
+ }
22
+ let stdout = "";
23
+ let stderr = "";
24
+ let settled = false;
25
+ const done = (result) => {
26
+ if (settled)
27
+ return;
28
+ settled = true;
29
+ signal?.removeEventListener("abort", terminate);
30
+ resolve(result);
31
+ };
32
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
33
+ // Not `spawn`'s own `signal` option: it reports the abort as an 'error' at once, before the
34
+ // command has ended, and the caller waits for the end.
35
+ // A child that did not start has no pid, and a kill with none aimed at the caller's whole
36
+ // process group on Node 26.5.1.
37
+ function terminate() {
38
+ if (child.pid !== undefined)
39
+ child.kill("SIGTERM");
40
+ }
41
+ signal?.addEventListener("abort", terminate, { once: true });
42
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
43
+ stdout += chunk;
44
+ });
45
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
46
+ stderr += chunk;
47
+ });
48
+ // 'error' without 'close': the command could not start (a missing file, no permission).
49
+ child.on("error", (error) => done({ status: null, stdout: "", stderr: error.message }));
50
+ // 'close', not 'exit': the streams are drained by then. A signal leaves the status null.
51
+ child.on("close", (status) => done({ status, stdout, stderr }));
52
+ });
@@ -0,0 +1,385 @@
1
+ /**
2
+ * The service on macOS: a user LaunchAgent, `local.allaboard`, loaded in the owner's login session,
3
+ * where Claude Code's credentials are (the Keychain). The plist is the one `docs/setup.md` wrote by
4
+ * hand before this command existed: same label, keys and log. A named instance has a service of its
5
+ * own, `local.allaboard.<name>`, started with `--instance <name>`; the default instance's plist is
6
+ * the one 0.1.x writes, byte for byte, so a copy with instances and a copy without agree on it.
7
+ * The instance reaches this layer as its name (null for the default): the host layer imports
8
+ * nothing of the core.
9
+ *
10
+ * Whether the job is loaded and its pid come from `launchctl list`, whose three columns (pid or
11
+ * `-`, last exit status, label) `man launchctl` documents; `launchctl print` would say more, but
12
+ * its man page calls its output "NOT API in any sense at all".
13
+ */
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { packageVersion } from "../../version.js";
17
+ import { copyOf, servicePath } from "../program.js";
18
+ import { listReport } from "./listing.js";
19
+ const DEFAULT_LABEL = "local.allaboard";
20
+ // The rule of an instance's name (`core/instance.ts`, `parseInstanceName`), kept here because the
21
+ // host layer imports nothing of the core; `test/host/service/launchd.test.ts` holds the two
22
+ // together. `service list` reads plists it did not write, so it checks the name itself.
23
+ const INSTANCE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
24
+ const RESERVED_NAMES = ["default", "allaboard"];
25
+ /** Whether `name` is a name an instance may have. */
26
+ export function isInstanceName(name) {
27
+ return INSTANCE_NAME.test(name) && !RESERVED_NAMES.includes(name);
28
+ }
29
+ /** The service's label: `local.allaboard` for the default instance, `local.allaboard.<name>` else. */
30
+ export function labelOf(instance) {
31
+ return instance === null ? DEFAULT_LABEL : `${DEFAULT_LABEL}.${instance}`;
32
+ }
33
+ // After `ExitTimeOut` seconds launchd kills a job that `bootout` stopped; macOS 27.0 caps it at
34
+ // 60 (measured 2026-09-26, `docs/setup.md`, Restarts and shutdown).
35
+ export const EXIT_TIMEOUT_SECONDS = 60;
36
+ // Folders a session's commands find as a terminal's do, after Node's and npm's (`servicePath`).
37
+ const SYSTEM_PATH = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
38
+ function agentsDir(home) {
39
+ return join(home, "Library", "LaunchAgents");
40
+ }
41
+ /** The plist's path for the instance of the user whose home is `home`. */
42
+ export function plistPath(home, instance) {
43
+ return join(agentsDir(home), `${labelOf(instance)}.plist`);
44
+ }
45
+ /** The file launchd writes the instance's stdout and stderr to. */
46
+ export function logPath(home, instance) {
47
+ return join(home, "Library", "Logs", "allaboard", `${instance ?? "allaboard"}.log`);
48
+ }
49
+ function xml(text) {
50
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
51
+ }
52
+ /**
53
+ * The plist that starts `program` at login, keeps it alive and writes its output to `log`. The
54
+ * default instance (`instance` null) passes no argument, as 0.1.x does.
55
+ */
56
+ export function plistFor(program, log, instance = null) {
57
+ const argument = instance === null
58
+ ? ""
59
+ : `\n <string>--instance</string>\n <string>${xml(instance)}</string>`;
60
+ return `<?xml version="1.0" encoding="UTF-8"?>
61
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
62
+ <plist version="1.0">
63
+ <dict>
64
+ <key>Label</key>
65
+ <string>${labelOf(instance)}</string>
66
+ <key>ProgramArguments</key>
67
+ <array>
68
+ <string>${xml(program.node)}</string>
69
+ <string>${xml(program.main)}</string>${argument}
70
+ </array>
71
+ <key>EnvironmentVariables</key>
72
+ <dict>
73
+ <key>PATH</key>
74
+ <string>${xml(servicePath(program, SYSTEM_PATH))}</string>
75
+ </dict>
76
+ <key>RunAtLoad</key>
77
+ <true/>
78
+ <key>KeepAlive</key>
79
+ <true/>
80
+ <key>ExitTimeOut</key>
81
+ <integer>${EXIT_TIMEOUT_SECONDS}</integer>
82
+ <key>StandardOutPath</key>
83
+ <string>${xml(log)}</string>
84
+ <key>StandardErrorPath</key>
85
+ <string>${xml(log)}</string>
86
+ </dict>
87
+ </plist>
88
+ `;
89
+ }
90
+ /** The jobs `launchctl list` prints, one per row of three columns; the header and noise are skipped. */
91
+ function listedRows(output) {
92
+ const number = (text) => (/^-?\d+$/.test(text) ? Number(text) : null);
93
+ return output.split("\n").flatMap((line) => {
94
+ const columns = line.trim().split(/\s+/);
95
+ if (columns.length !== 3)
96
+ return [];
97
+ return [
98
+ {
99
+ label: columns[2],
100
+ pid: number(columns[0]),
101
+ lastExit: number(columns[1]),
102
+ },
103
+ ];
104
+ });
105
+ }
106
+ /** The job `label` in the output of `launchctl list`, or null when it is not loaded. */
107
+ export function listedJob(output, label) {
108
+ const row = listedRows(output).find((job) => job.label === label);
109
+ return row === undefined ? null : { pid: row.pid, lastExit: row.lastExit };
110
+ }
111
+ /**
112
+ * The instances of allaboard that `launchctl list` shows loaded: the default instance as null, the
113
+ * others by name. A label is one when it is `local.allaboard` or `local.allaboard.<name>` with a
114
+ * name the rule allows, exactly as `listedJob` matches one.
115
+ */
116
+ function listedInstances(output) {
117
+ const found = [];
118
+ for (const { label } of listedRows(output)) {
119
+ if (label === DEFAULT_LABEL)
120
+ found.push(null);
121
+ else if (label.startsWith(`${DEFAULT_LABEL}.`)) {
122
+ const name = label.slice(DEFAULT_LABEL.length + 1);
123
+ if (isInstanceName(name))
124
+ found.push(name);
125
+ }
126
+ }
127
+ return found;
128
+ }
129
+ class LaunchctlError extends Error {
130
+ }
131
+ const ok = (out) => ({ code: 0, out, err: [] });
132
+ const failed = (code, err) => ({ code, out: [], err });
133
+ // The files that may be an allaboard service: `local.allaboard.plist` and `local.allaboard.<...>.plist`.
134
+ // Which instance one is comes from what it says inside (`inspected`), not from this name.
135
+ const OWN_PLIST = /^local\.allaboard(?:\..+)?\.plist$/;
136
+ const NOT_MANAGEABLE = "not an allaboard instance it can manage";
137
+ /** The Label and program arguments of the plist at `file`, or null when `plutil` cannot read them. */
138
+ function plistFacts(run, file) {
139
+ const result = run("plutil", ["-convert", "json", "-o", "-", file]);
140
+ if (result.status !== 0)
141
+ return null;
142
+ try {
143
+ const parsed = JSON.parse(result.stdout);
144
+ if (typeof parsed !== "object" || parsed === null)
145
+ return null;
146
+ const args = "ProgramArguments" in parsed ? parsed.ProgramArguments : null;
147
+ if (!Array.isArray(args) || !args.every((item) => typeof item === "string"))
148
+ return null;
149
+ const label = "Label" in parsed && typeof parsed.Label === "string" ? parsed.Label : null;
150
+ return { label, args };
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** The instance a plist starts and its entry file, or the reason it is not one `file` can manage. */
157
+ function inspected(file, facts) {
158
+ if (facts === null)
159
+ return { reason: "its plist cannot be read" };
160
+ const [, main, ...rest] = facts.args;
161
+ let instance = null;
162
+ if (rest.length === 1 && rest[0] === "--instance") {
163
+ return { reason: "its program arguments end with --instance and no name" };
164
+ }
165
+ if (rest.length === 2 && rest[0] === "--instance")
166
+ instance = rest[1];
167
+ else if (rest.length !== 0) {
168
+ return {
169
+ reason: "its program arguments are the entry file alone, or the entry file and --instance <name>",
170
+ };
171
+ }
172
+ if (instance !== null && !isInstanceName(instance)) {
173
+ return {
174
+ reason: `${JSON.stringify(instance)} is not an instance name: 1 to 32 characters of a-z, 0-9 and "-", starting with a letter or a digit, and not "default" or "allaboard"`,
175
+ };
176
+ }
177
+ const expected = labelOf(instance);
178
+ const named = instance === null ? "the default instance" : `instance ${JSON.stringify(instance)}`;
179
+ if (facts.label === null)
180
+ return { reason: "it has no Label" };
181
+ if (facts.label !== expected) {
182
+ return {
183
+ reason: `its Label is ${facts.label}, but its program arguments start ${named} (Label ${expected})`,
184
+ };
185
+ }
186
+ if (file !== `${expected}.plist`) {
187
+ return {
188
+ reason: `its file name says another instance than its program arguments, which name ${named} (${expected}.plist)`,
189
+ };
190
+ }
191
+ return { instance, main };
192
+ }
193
+ /** The launchd implementation of `ServiceManager`. */
194
+ export class Launchd {
195
+ #options;
196
+ constructor(options) {
197
+ this.#options = options;
198
+ }
199
+ get #label() {
200
+ return labelOf(this.#options.instance);
201
+ }
202
+ get #plist() {
203
+ return plistPath(this.#options.home, this.#options.instance);
204
+ }
205
+ get #log() {
206
+ return logPath(this.#options.home, this.#options.instance);
207
+ }
208
+ get #target() {
209
+ return `gui/${this.#options.uid}/${this.#label}`;
210
+ }
211
+ /** A command of this instance as the owner types it: `--instance` is part of it when named. */
212
+ #command(action) {
213
+ const { instance } = this.#options;
214
+ return `\`allaboard service ${action}${instance === null ? "" : ` --instance ${instance}`}\``;
215
+ }
216
+ #launchctl(args) {
217
+ const result = this.#options.run("launchctl", args);
218
+ if (result.status !== 0) {
219
+ const said = (result.stderr || result.stdout).trim();
220
+ throw new LaunchctlError(`launchctl ${args[0]} failed${said === "" ? "" : `: ${said}`}`);
221
+ }
222
+ return result.stdout;
223
+ }
224
+ #job() {
225
+ return listedJob(this.#launchctl(["list"]), this.#label);
226
+ }
227
+ /** The result of `action`, or the sentence of the failure of launchctl or of a file it touched. */
228
+ #attempt(action) {
229
+ try {
230
+ return { done: action() };
231
+ }
232
+ catch (error) {
233
+ if (error instanceof LaunchctlError)
234
+ return { failure: error.message };
235
+ // A file it could not read or write, such as a folder it may not create: Node's message
236
+ // names the path and the reason.
237
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
238
+ return { failure: error.message };
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+ #guarded(action) {
244
+ const attempt = this.#attempt(action);
245
+ return "failure" in attempt ? failed(1, [attempt.failure]) : attempt.done;
246
+ }
247
+ install(program) {
248
+ return this.#guarded(() => {
249
+ const text = plistFor(program, this.#log, this.#options.instance);
250
+ const written = existsSync(this.#plist) ? readFileSync(this.#plist, "utf8") : null;
251
+ if (this.#job() !== null) {
252
+ if (written === text)
253
+ return ok([`${this.#label} is already installed and loaded.`]);
254
+ return failed(1, [
255
+ written === null
256
+ ? `${this.#label} is loaded, and its plist ${this.#plist} is gone.`
257
+ : `${this.#label} is loaded with another definition (${this.#plist}).`,
258
+ `${this.#command("uninstall")} then ${this.#command("install")} replaces it; the uninstall ends turns still running after ${EXIT_TIMEOUT_SECONDS} seconds.`,
259
+ ]);
260
+ }
261
+ mkdirSync(dirname(this.#plist), { recursive: true });
262
+ mkdirSync(dirname(this.#log), { recursive: true });
263
+ writeFileSync(this.#plist, text);
264
+ const lint = this.#options.run("plutil", ["-lint", this.#plist]);
265
+ if (lint.status !== 0) {
266
+ return failed(1, [`plutil refused ${this.#plist}: ${(lint.stdout || lint.stderr).trim()}`]);
267
+ }
268
+ this.#launchctl(["bootstrap", `gui/${this.#options.uid}`, this.#plist]);
269
+ const copy = copyOf(program.main);
270
+ const version = packageVersion(join(copy, "package.json"));
271
+ return ok([
272
+ `Wrote ${this.#plist}.`,
273
+ `Loaded ${this.#label}: allaboard starts now and at every login.`,
274
+ `Log: ${this.#log}`,
275
+ version === null
276
+ ? `Runs the copy in ${copy}, whose package.json cannot be read.`
277
+ : `Runs the copy in ${copy}, version ${version}.`,
278
+ ]);
279
+ });
280
+ }
281
+ uninstall() {
282
+ return this.#guarded(() => {
283
+ const loaded = this.#job() !== null;
284
+ const present = existsSync(this.#plist);
285
+ if (!loaded && !present)
286
+ return ok([`${this.#label} is not installed.`]);
287
+ const out = [];
288
+ if (loaded) {
289
+ // Returns once the daemon has exited: its drain is cut at `ExitTimeOut`.
290
+ this.#launchctl(["bootout", this.#target]);
291
+ out.push(`Stopped ${this.#label}.`);
292
+ }
293
+ if (present) {
294
+ rmSync(this.#plist);
295
+ out.push(`Removed ${this.#plist}.`);
296
+ }
297
+ out.push(`Left in place: ${this.#options.configDir} (.env, state.json) and ${dirname(this.#log)}.`);
298
+ return ok(out);
299
+ });
300
+ }
301
+ status() {
302
+ return this.#guarded(() => {
303
+ const job = this.#job();
304
+ if (job === null) {
305
+ const line = existsSync(this.#plist)
306
+ ? `${this.#label} is installed and not loaded: ${this.#command("install")} loads it.`
307
+ : `${this.#label} is not installed.`;
308
+ return { code: 3, out: [line], err: [] };
309
+ }
310
+ if (job.pid !== null) {
311
+ return ok([`${this.#label} is running, pid ${job.pid}.`, `Log: ${this.#log}`]);
312
+ }
313
+ const exit = job.lastExit === null ? "" : `; its last exit status was ${job.lastExit}`;
314
+ return {
315
+ code: 3,
316
+ out: [`${this.#label} is loaded and not running${exit}.`, `Log: ${this.#log}`],
317
+ err: [],
318
+ };
319
+ });
320
+ }
321
+ restart() {
322
+ return this.#guarded(() => {
323
+ const job = this.#job();
324
+ if (job === null) {
325
+ return failed(1, [`${this.#label} is not loaded: ${this.#command("install")} starts it.`]);
326
+ }
327
+ if (job.pid === null) {
328
+ return failed(1, [
329
+ `${this.#label} is not running; launchd starts it again on its own. Its log says why it stopped: ${this.#log}`,
330
+ ]);
331
+ }
332
+ // Only sends the signal: the daemon lets running turns finish, exits, and `KeepAlive` starts
333
+ // it again. `bootout` or `kickstart -k` would kill it after `ExitTimeOut`.
334
+ this.#launchctl(["kill", "TERM", this.#target]);
335
+ return ok([
336
+ `Sent SIGTERM to ${this.#label} (pid ${job.pid}): running turns finish first, then launchd starts it again.`,
337
+ ]);
338
+ });
339
+ }
340
+ list() {
341
+ return listReport(this.instances());
342
+ }
343
+ runningPid() {
344
+ const attempt = this.#attempt(() => this.#job()?.pid ?? null);
345
+ return "failure" in attempt ? null : attempt.done;
346
+ }
347
+ instances() {
348
+ const attempt = this.#attempt(() => {
349
+ const directory = agentsDir(this.#options.home);
350
+ const files = existsSync(directory)
351
+ ? readdirSync(directory)
352
+ .filter((name) => OWN_PLIST.test(name))
353
+ .sort()
354
+ : [];
355
+ const listing = this.#launchctl(["list"]);
356
+ // A job can outlive its plist (the file removed by hand): launchd keeps it loaded, and only
357
+ // `launchctl list` knows. A loaded instance whose plist file is not there is listed as such.
358
+ const gone = listedInstances(listing).filter((instance) => !files.includes(`${labelOf(instance)}.plist`));
359
+ const found = files.map((file) => ({
360
+ file,
361
+ what: inspected(file, plistFacts(this.#options.run, join(directory, file))),
362
+ }));
363
+ const instances = [
364
+ ...found.flatMap(({ what }) => ("reason" in what ? [] : [{ ...what, gone: false }])),
365
+ ...gone.map((instance) => ({ instance, main: undefined, gone: true })),
366
+ ]
367
+ // The default instance first, then the others by name.
368
+ .sort((a, b) => (a.instance ?? "").localeCompare(b.instance ?? ""));
369
+ const rows = instances.map(({ instance, main, gone: plistGone }) => {
370
+ const job = listedJob(listing, labelOf(instance));
371
+ const base = { instance, loaded: job !== null, pid: job?.pid ?? null };
372
+ if (plistGone)
373
+ return { ...base, entry: "plist-gone" };
374
+ if (main === undefined)
375
+ return { ...base, entry: "unreadable" };
376
+ const copy = copyOf(main);
377
+ return { ...base, entry: { copy, version: packageVersion(join(copy, "package.json")) } };
378
+ });
379
+ // What no command of an instance could find or remove is named, not listed as one.
380
+ const refused = found.flatMap(({ file, what }) => "reason" in what ? [`${file}: ${NOT_MANAGEABLE}: ${what.reason}`] : []);
381
+ return { rows, refused };
382
+ });
383
+ return "failure" in attempt ? attempt : attempt.done;
384
+ }
385
+ }
@@ -0,0 +1,42 @@
1
+ /** The columns of `rows` padded to their widest cell, two spaces apart; the last is not padded. */
2
+ function table(rows) {
3
+ const widths = [0, 1, 2].map((column) => Math.max(...rows.map((row) => row[column].length)));
4
+ return rows.map((row) => row
5
+ .map((cell, column) => (column < 3 ? cell.padEnd(widths[column] + 2) : cell))
6
+ .join(""));
7
+ }
8
+ function cellsOf(row) {
9
+ const state = !row.loaded
10
+ ? "not loaded"
11
+ : row.pid === null
12
+ ? "loaded, not running"
13
+ : `running, pid ${row.pid}`;
14
+ // Not a name to type: `--instance default` is refused.
15
+ const name = row.instance ?? "(default)";
16
+ if (row.entry === "plist-gone")
17
+ return [name, `${state}; its plist is gone`, "-", "-"];
18
+ if (row.entry === "unreadable")
19
+ return [name, state, "unreadable plist", "-"];
20
+ return [name, state, row.entry.copy, row.entry.version ?? "unknown"];
21
+ }
22
+ /** The report of `service list`: the table, then the definitions that are no instance's. */
23
+ export function listReport(listing) {
24
+ if ("failure" in listing)
25
+ return { code: 1, out: [], err: [listing.failure] };
26
+ if (listing.rows.length === 0 && listing.refused.length === 0) {
27
+ return { code: 0, out: ["No allaboard service is installed."], err: [] };
28
+ }
29
+ const header = ["INSTANCE", "STATE", "COPY", "VERSION"];
30
+ return {
31
+ code: 0,
32
+ out: [
33
+ ...(listing.rows.length === 0 ? [] : table([header, ...listing.rows.map(cellsOf)])),
34
+ ...listing.refused,
35
+ ],
36
+ err: [],
37
+ };
38
+ }
39
+ /** The loaded instances whose copy is the package folder `copy`: the ones an update restarts. */
40
+ export function loadedOn(rows, copy) {
41
+ return rows.filter((row) => row.loaded && typeof row.entry === "object" && row.entry.copy === copy);
42
+ }
@@ -0,0 +1 @@
1
+ export const SERVICE_ACTIONS = ["install", "uninstall", "status", "restart", "list"];