@cohortapp/agent-sdk 2.11.15 → 2.13.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.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/upgrade/global-refresh.mjs — keep the GLOBAL npm install of the SDK at
|
|
3
|
+
* the version the agent dir just upgraded to (design 2026-09-08, WP-M6 step 4).
|
|
4
|
+
*
|
|
5
|
+
* WHY. Every Claude Code session on a seat — the front-door session, a peer,
|
|
6
|
+
* a home-dir `claude` — resolves `maestro`, `cohort` and `cohort-mcp` from
|
|
7
|
+
* PATH, i.e. from `npm root -g`. autoupdate.sh installs the SDK LOCALLY in the
|
|
8
|
+
* agent dir, so without this step the global bins drift behind the agent dir
|
|
9
|
+
* on every hop. `planGlobalRefresh` is pure (version in, argv out);
|
|
10
|
+
* `refreshGlobalInstall` is the edge, and honours `MAESTRO_SKIP_GLOBAL_INSTALL=1`
|
|
11
|
+
* (every test sets it; a seat may set it to pin the global install by hand).
|
|
12
|
+
*
|
|
13
|
+
* @module lib/upgrade/global-refresh
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
export const SDK_PACKAGE = "@cohortapp/agent-sdk";
|
|
21
|
+
export const SKIP_ENV = "MAESTRO_SKIP_GLOBAL_INSTALL";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The version of `pkg` in `npm ls -g <pkg> --json --depth=0` output; "" when
|
|
25
|
+
* absent or unparseable. Tolerates the tree-text form as a fallback.
|
|
26
|
+
* @param {string} out
|
|
27
|
+
* @param {string} [pkg]
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function parseGlobalVersion(out, pkg = SDK_PACKAGE) {
|
|
31
|
+
const s = String(out || "").trim();
|
|
32
|
+
if (!s) return "";
|
|
33
|
+
try {
|
|
34
|
+
const j = JSON.parse(s);
|
|
35
|
+
const v = j && j.dependencies && j.dependencies[pkg] && j.dependencies[pkg].version;
|
|
36
|
+
if (typeof v === "string" && v) return v;
|
|
37
|
+
} catch { /* not JSON: try the tree form below */ }
|
|
38
|
+
const m = s.match(new RegExp(`${pkg.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}@(\\d+\\.\\d+\\.\\d+[^\\s]*)`));
|
|
39
|
+
return m ? m[1] : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether to refresh the global install. Pure.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {string} o.version the version the agent dir now runs (target)
|
|
47
|
+
* @param {string} [o.globalVersion] what `npm ls -g` reports ("" = not installed)
|
|
48
|
+
* @param {boolean} [o.skip] MAESTRO_SKIP_GLOBAL_INSTALL=1
|
|
49
|
+
* @param {string} [o.pkg]
|
|
50
|
+
* @returns {{action:"skip"|"none"|"install", reason:string, argv:string[]|null}}
|
|
51
|
+
*/
|
|
52
|
+
export function planGlobalRefresh(o = {}) {
|
|
53
|
+
const pkg = o.pkg || SDK_PACKAGE;
|
|
54
|
+
const version = String(o.version || "").trim();
|
|
55
|
+
const argv = version ? ["npm", "install", "-g", `${pkg}@${version}`, "--no-audit", "--no-fund"] : null;
|
|
56
|
+
if (o.skip) return { action: "skip", reason: `${SKIP_ENV}=1`, argv };
|
|
57
|
+
if (!version) return { action: "skip", reason: "target version unknown", argv: null };
|
|
58
|
+
const cur = String(o.globalVersion || "").trim();
|
|
59
|
+
if (cur === version) return { action: "none", reason: `global install already at ${version}`, argv };
|
|
60
|
+
return { action: "install", reason: cur ? `global install is ${cur}, agent dir is ${version}` : "not installed globally", argv };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Default exec: {status, stdout, stderr}; never throws. */
|
|
64
|
+
function defaultExec(cmd, args, opts = {}) {
|
|
65
|
+
try {
|
|
66
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 180_000, ...opts });
|
|
67
|
+
if (r.error) return { status: 127, stdout: "", stderr: String(r.error.message || r.error) };
|
|
68
|
+
return { status: r.status == null ? 1 : r.status, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { status: 127, stdout: "", stderr: String((err && err.message) || err) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the global version through `exec`. Edge; "" on any failure.
|
|
76
|
+
* @param {{exec?:Function, pkg?:string}} [o]
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function readGlobalVersion(o = {}) {
|
|
80
|
+
const exec = o.exec || defaultExec;
|
|
81
|
+
const pkg = o.pkg || SDK_PACKAGE;
|
|
82
|
+
// `npm ls -g` exits 1 when the package is absent AND still prints the JSON
|
|
83
|
+
// that says so; the stdout is the answer either way.
|
|
84
|
+
const r = exec("npm", ["ls", "-g", pkg, "--json", "--depth=0"], { timeout: 20_000 });
|
|
85
|
+
return parseGlobalVersion(r && r.stdout, pkg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Plan + run the refresh. Edge. Never throws; the result is a step row.
|
|
90
|
+
* @param {{version:string, env?:object, exec?:Function, pkg?:string}} o
|
|
91
|
+
* @returns {{ok:boolean, action:string, detail:string, argv:string[]|null}}
|
|
92
|
+
*/
|
|
93
|
+
export function refreshGlobalInstall(o = {}) {
|
|
94
|
+
const env = o.env || process.env;
|
|
95
|
+
const exec = o.exec || defaultExec;
|
|
96
|
+
const skip = String(env[SKIP_ENV] || "") === "1";
|
|
97
|
+
const globalVersion = skip ? "" : readGlobalVersion({ exec, pkg: o.pkg });
|
|
98
|
+
const plan = planGlobalRefresh({ version: o.version, globalVersion, skip, pkg: o.pkg });
|
|
99
|
+
if (plan.action !== "install") {
|
|
100
|
+
return { ok: true, action: plan.action, detail: `${plan.action === "skip" ? "skipped" : "no change"} (${plan.reason})${plan.argv && plan.action === "skip" ? `: ${plan.argv.join(" ")}` : ""}`, argv: plan.argv };
|
|
101
|
+
}
|
|
102
|
+
const r = exec(plan.argv[0], plan.argv.slice(1), { timeout: 180_000 });
|
|
103
|
+
if (r.status === 0) return { ok: true, action: "install", detail: `${plan.argv.join(" ")} (${plan.reason})`, argv: plan.argv };
|
|
104
|
+
const err = String(r.stderr || "").trim().split("\n").filter(Boolean).pop() || `exit ${r.status}`;
|
|
105
|
+
return { ok: false, action: "install", detail: `${plan.argv.join(" ")} failed: ${err} — run it by hand`, argv: plan.argv };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default { planGlobalRefresh, parseGlobalVersion, readGlobalVersion, refreshGlobalInstall, SDK_PACKAGE, SKIP_ENV };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* global-refresh.test.mjs — planGlobalRefresh is pure; refreshGlobalInstall
|
|
3
|
+
* never reaches a real npm (exec is injected AND MAESTRO_SKIP_GLOBAL_INSTALL=1
|
|
4
|
+
* is honoured).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { planGlobalRefresh, parseGlobalVersion, refreshGlobalInstall, readGlobalVersion } from "./global-refresh.mjs";
|
|
10
|
+
|
|
11
|
+
const ARGV = ["npm", "install", "-g", "@cohortapp/agent-sdk@2.12.0", "--no-audit", "--no-fund"];
|
|
12
|
+
|
|
13
|
+
test("planGlobalRefresh: install when absent or behind, none when equal, skip under MAESTRO_SKIP_GLOBAL_INSTALL", () => {
|
|
14
|
+
assert.deepEqual(planGlobalRefresh({ version: "2.12.0", globalVersion: "" }), { action: "install", reason: "not installed globally", argv: ARGV });
|
|
15
|
+
assert.deepEqual(planGlobalRefresh({ version: "2.12.0", globalVersion: "2.11.17" }), { action: "install", reason: "global install is 2.11.17, agent dir is 2.12.0", argv: ARGV });
|
|
16
|
+
assert.equal(planGlobalRefresh({ version: "2.12.0", globalVersion: "2.12.0" }).action, "none");
|
|
17
|
+
const s = planGlobalRefresh({ version: "2.12.0", globalVersion: "1.0.0", skip: true });
|
|
18
|
+
assert.equal(s.action, "skip");
|
|
19
|
+
assert.deepEqual(s.argv, ARGV, "the skipped command is still reported so an operator can run it by hand");
|
|
20
|
+
assert.equal(planGlobalRefresh({ version: "", globalVersion: "1.0.0" }).action, "skip");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parseGlobalVersion reads npm ls --json, tolerates the tree form and the not-installed shape", () => {
|
|
24
|
+
assert.equal(parseGlobalVersion(JSON.stringify({ dependencies: { "@cohortapp/agent-sdk": { version: "2.11.17" } } })), "2.11.17");
|
|
25
|
+
assert.equal(parseGlobalVersion(JSON.stringify({ name: "lib", dependencies: {} })), "");
|
|
26
|
+
assert.equal(parseGlobalVersion("/opt/homebrew/lib\n└── @cohortapp/agent-sdk@2.12.0\n"), "2.12.0");
|
|
27
|
+
assert.equal(parseGlobalVersion(""), "");
|
|
28
|
+
assert.equal(parseGlobalVersion("not json"), "");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function recorder(responses) {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const exec = (cmd, args) => { calls.push([cmd, ...args]); return responses(cmd, args); };
|
|
34
|
+
exec.calls = calls;
|
|
35
|
+
return exec;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("refreshGlobalInstall: with MAESTRO_SKIP_GLOBAL_INSTALL=1 nothing is executed and the row says what was skipped", () => {
|
|
39
|
+
const exec = recorder(() => ({ status: 0, stdout: "", stderr: "" }));
|
|
40
|
+
const r = refreshGlobalInstall({ version: "2.12.0", env: { MAESTRO_SKIP_GLOBAL_INSTALL: "1" }, exec });
|
|
41
|
+
assert.equal(r.ok, true);
|
|
42
|
+
assert.equal(r.action, "skip");
|
|
43
|
+
assert.match(r.detail, /npm install -g @cohortapp\/agent-sdk@2\.12\.0 --no-audit --no-fund/);
|
|
44
|
+
assert.deepEqual(exec.calls, [], "no npm call at all under the skip flag");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("refreshGlobalInstall: runs the pinned install through exec when behind; reports a failure as ok:false with the tail of stderr", () => {
|
|
48
|
+
const behind = recorder((cmd, args) => (args[0] === "ls"
|
|
49
|
+
? { status: 1, stdout: JSON.stringify({ dependencies: { "@cohortapp/agent-sdk": { version: "2.11.17" } } }), stderr: "" }
|
|
50
|
+
: { status: 0, stdout: "", stderr: "" }));
|
|
51
|
+
const r = refreshGlobalInstall({ version: "2.12.0", env: {}, exec: behind });
|
|
52
|
+
assert.equal(r.ok, true);
|
|
53
|
+
assert.deepEqual(behind.calls[1], ARGV);
|
|
54
|
+
|
|
55
|
+
const same = recorder(() => ({ status: 0, stdout: JSON.stringify({ dependencies: { "@cohortapp/agent-sdk": { version: "2.12.0" } } }), stderr: "" }));
|
|
56
|
+
const n = refreshGlobalInstall({ version: "2.12.0", env: {}, exec: same });
|
|
57
|
+
assert.equal(n.action, "none");
|
|
58
|
+
assert.equal(same.calls.length, 1, "only the read");
|
|
59
|
+
|
|
60
|
+
const fail = recorder((cmd, args) => (args[0] === "ls" ? { status: 1, stdout: "{}", stderr: "" } : { status: 243, stdout: "", stderr: "npm ERR! code EACCES\nnpm ERR! permission denied\n" }));
|
|
61
|
+
const f = refreshGlobalInstall({ version: "2.12.0", env: {}, exec: fail });
|
|
62
|
+
assert.equal(f.ok, false);
|
|
63
|
+
assert.match(f.detail, /permission denied/);
|
|
64
|
+
assert.equal(readGlobalVersion({ exec: fail }), "");
|
|
65
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/upgrade/launchd-reconcile.mjs — bring ~/Library/LaunchAgents in line with
|
|
3
|
+
* the plists generate-plists.sh just rendered (design 2026-09-08, WP-M6 step 2).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. On every existing seat the OLD autoupdate.sh runs the NEW
|
|
6
|
+
* package's `maestro upgrade` on the first hop. Nothing else on that seat knows
|
|
7
|
+
* the front-door session job (`ai.maestro.<first>-session`) exists, so
|
|
8
|
+
* `upgrade()` itself has to install it — and, more generally, has to make the
|
|
9
|
+
* installed job set match the generated one without an operator.
|
|
10
|
+
*
|
|
11
|
+
* `planLaunchdReconcile` is PURE: it takes the generated labels, the installed
|
|
12
|
+
* labels (a plist file present under LaunchAgents), the loaded labels (from
|
|
13
|
+
* launchd), a `sameContent(label)` predicate and an optional `hold` reason,
|
|
14
|
+
* and returns the actions. `applyLaunchdReconcile` is the edge: it copies,
|
|
15
|
+
* chmods, and drives `launchctl` through an injected `exec`, and reports per
|
|
16
|
+
* action — never throws.
|
|
17
|
+
*
|
|
18
|
+
* Invariants (pinned by launchd-reconcile.test.mjs):
|
|
19
|
+
* · a generated label with no installed file → install (copy, chmod 600,
|
|
20
|
+
* `launchctl bootstrap gui/<uid> <plist>`; if launchd says it is already
|
|
21
|
+
* loaded that is success; otherwise fall back to `launchctl load`);
|
|
22
|
+
* · an installed file whose content differs → REWRITE THE FILE ONLY. Never
|
|
23
|
+
* `kickstart -k`: launchd keeps the definition it loaded and does not
|
|
24
|
+
* re-read a rewritten plist until bootout + bootstrap or the next login,
|
|
25
|
+
* so a kickstart would fire a calendar/interval job out of schedule for
|
|
26
|
+
* nothing — and for `-autoupdate` it would SIGKILL the very job this
|
|
27
|
+
* upgrade is running inside (autoupdate.sh → node upgrade), mid-steps.
|
|
28
|
+
* If the job is not loaded, bootstrap it;
|
|
29
|
+
* · same content but not loaded → bootstrap (a generated job is meant to run)
|
|
30
|
+
* — UNLESS the seat is held: `.emergency-stop` / `state/EMERGENCY_STOP`
|
|
31
|
+
* (scripts/emergency-stop.sh unloaded every job on purpose) or the
|
|
32
|
+
* autoupdate kill-switch (`~/.maestro-no-autoupdate`, `.no-autoupdate`).
|
|
33
|
+
* A held seat gets its files written and nothing bootstrapped; every
|
|
34
|
+
* held row says why;
|
|
35
|
+
* · installed but no longer generated → reported as an orphan, never booted
|
|
36
|
+
* out (`launchctl bootout` is not something an upgrade does).
|
|
37
|
+
*
|
|
38
|
+
* "Loaded" is measured in the GUI domain (`launchctl print gui/<uid>`), because
|
|
39
|
+
* a domain-less `launchctl list` from ssh — the documented way to administer a
|
|
40
|
+
* mac-mini seat — does not show gui-domain agents and would plan every job as
|
|
41
|
+
* "load". `launchctl list` is only the fallback when `print` is unavailable.
|
|
42
|
+
*
|
|
43
|
+
* @module lib/upgrade/launchd-reconcile
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
"use strict";
|
|
47
|
+
|
|
48
|
+
import { copyFileSync, chmodSync, mkdirSync, readFileSync, readdirSync, existsSync } from "node:fs";
|
|
49
|
+
import { join, basename } from "node:path";
|
|
50
|
+
import { spawnSync } from "node:child_process";
|
|
51
|
+
|
|
52
|
+
/** Only plists with these label prefixes are ours to touch or report. */
|
|
53
|
+
export const MAESTRO_LABEL_PREFIXES = Object.freeze(["ai.maestro.", "ai.adaptic."]);
|
|
54
|
+
|
|
55
|
+
/** Why a replaced-while-loaded job is not restarted. Shown per row. */
|
|
56
|
+
export const REWRITE_ONLY_NOTE = "file rewritten; launchd keeps the loaded definition until next login (or: launchctl bootout gui/<uid>/<label> && launchctl bootstrap gui/<uid> <plist>)";
|
|
57
|
+
|
|
58
|
+
/** `ai.maestro.ivy-session.plist` → `ai.maestro.ivy-session`. */
|
|
59
|
+
export function labelOf(name) {
|
|
60
|
+
return String(name || "").replace(/\.plist$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isMaestroLabel(label) {
|
|
64
|
+
const l = String(label || "");
|
|
65
|
+
return MAESTRO_LABEL_PREFIXES.some((p) => l.startsWith(p));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse `launchctl list` output into labels. The label is the last whitespace
|
|
70
|
+
* column of every row after the header; a job that is loaded but not running
|
|
71
|
+
* shows PID `-` and still counts as loaded.
|
|
72
|
+
* @param {string} out
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
export function parseLaunchctlList(out) {
|
|
76
|
+
return String(out || "")
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((l) => l.trim().split(/\s+/).pop())
|
|
79
|
+
.filter((l) => l && l !== "Label");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse `launchctl print gui/<uid>` output into the labels of its
|
|
84
|
+
* `services = { … }` block (one `<pid> <status> <label>` row per service; the
|
|
85
|
+
* label is the last column). Returns null when there is no services block —
|
|
86
|
+
* the caller then falls back to `launchctl list`.
|
|
87
|
+
* @param {string} out
|
|
88
|
+
* @returns {string[]|null}
|
|
89
|
+
*/
|
|
90
|
+
export function parseLaunchctlPrintServices(out) {
|
|
91
|
+
const lines = String(out || "").split("\n");
|
|
92
|
+
const start = lines.findIndex((l) => /^\s*services\s*=\s*\{\s*$/.test(l));
|
|
93
|
+
if (start < 0) return null;
|
|
94
|
+
const labels = [];
|
|
95
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
96
|
+
const line = lines[i];
|
|
97
|
+
if (/^\s*\}\s*$/.test(line)) break;
|
|
98
|
+
const label = line.trim().split(/\s+/).pop();
|
|
99
|
+
if (label) labels.push(label);
|
|
100
|
+
}
|
|
101
|
+
return labels;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decide what to do for every generated and installed label. Pure.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} o
|
|
108
|
+
* @param {string[]} o.generated labels rendered into scripts/local-triggers/plists
|
|
109
|
+
* @param {string[]} o.installed maestro labels with a file in ~/Library/LaunchAgents
|
|
110
|
+
* @param {string[]} [o.loaded] labels launchd reports as loaded
|
|
111
|
+
* @param {(label:string)=>boolean} [o.sameContent] generated file == installed file
|
|
112
|
+
* @param {string|null} [o.hold] when set, nothing is bootstrapped and every held row carries this reason
|
|
113
|
+
* @returns {{actions: Array<{label:string, action:"install"|"replace"|"load"|"unchanged"|"orphan", bootstrap:boolean, held:boolean, reason:string}>, summary: {install:number, replace:number, load:number, unchanged:number, orphan:number, held:number}, hold:string|null}}
|
|
114
|
+
*/
|
|
115
|
+
export function planLaunchdReconcile(o = {}) {
|
|
116
|
+
const generated = uniq((o.generated || []).map(labelOf));
|
|
117
|
+
const installed = new Set((o.installed || []).map(labelOf));
|
|
118
|
+
const loaded = new Set((o.loaded || []).map(labelOf));
|
|
119
|
+
const sameContent = typeof o.sameContent === "function" ? o.sameContent : () => false;
|
|
120
|
+
const hold = o.hold ? String(o.hold) : null;
|
|
121
|
+
const actions = [];
|
|
122
|
+
const want = (label, action, reason) => {
|
|
123
|
+
// `bootstrap` is what the plan WANTS; a held seat keeps the file write and drops the bootstrap.
|
|
124
|
+
if (hold) actions.push({ label, action, bootstrap: false, held: true, reason: `${reason}; held: ${hold}` });
|
|
125
|
+
else actions.push({ label, action, bootstrap: true, held: false, reason });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (const label of generated) {
|
|
129
|
+
const isLoaded = loaded.has(label);
|
|
130
|
+
if (!installed.has(label)) { want(label, "install", "not installed"); continue; }
|
|
131
|
+
let same = false;
|
|
132
|
+
try { same = !!sameContent(label); } catch { same = false; /* an unreadable file is treated as different: rewriting it is the safe direction */ }
|
|
133
|
+
if (!same) {
|
|
134
|
+
if (!isLoaded) want(label, "replace", "content differs; not loaded");
|
|
135
|
+
else actions.push({ label, action: "replace", bootstrap: false, held: false, reason: `content differs; ${REWRITE_ONLY_NOTE}` });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!isLoaded) want(label, "load", "up to date; not loaded");
|
|
139
|
+
else actions.push({ label, action: "unchanged", bootstrap: false, held: false, reason: "up to date; loaded" });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const gen = new Set(generated);
|
|
143
|
+
for (const label of uniq([...installed])) {
|
|
144
|
+
if (gen.has(label) || !isMaestroLabel(label)) continue;
|
|
145
|
+
actions.push({ label, action: "orphan", bootstrap: false, held: false, reason: "installed but no longer generated; left alone (never booted out)" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const summary = { install: 0, replace: 0, load: 0, unchanged: 0, orphan: 0, held: 0 };
|
|
149
|
+
for (const a of actions) {
|
|
150
|
+
summary[a.action] = (summary[a.action] || 0) + 1;
|
|
151
|
+
if (a.held) summary.held++;
|
|
152
|
+
}
|
|
153
|
+
return { actions, summary, hold };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function uniq(arr) {
|
|
157
|
+
return [...new Set(arr.filter(Boolean))];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Default `exec`: spawnSync shaped as {status, stdout, stderr}; never throws. */
|
|
161
|
+
export function defaultExec(cmd, args, opts = {}) {
|
|
162
|
+
try {
|
|
163
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20_000, ...opts });
|
|
164
|
+
if (r.error) return { status: 127, stdout: "", stderr: String(r.error.message || r.error) };
|
|
165
|
+
return { status: r.status == null ? 1 : r.status, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return { status: 127, stdout: "", stderr: String((err && err.message) || err) };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The uid whose gui domain the seat's agents live in. */
|
|
172
|
+
export function defaultUid() {
|
|
173
|
+
return typeof process.getuid === "function" ? process.getuid() : 501;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The seat's hold marker, if any: the emergency stop or the autoupdate
|
|
178
|
+
* kill-switch. Edge (fs). Returns a short reason naming the file, or null.
|
|
179
|
+
* @param {{cwd:string, home:string}} o
|
|
180
|
+
* @returns {string|null}
|
|
181
|
+
*/
|
|
182
|
+
export function readHoldReason(o = {}) {
|
|
183
|
+
const candidates = [
|
|
184
|
+
[join(o.cwd, ".emergency-stop"), "emergency stop (.emergency-stop)"],
|
|
185
|
+
[join(o.cwd, "state", "EMERGENCY_STOP"), "emergency stop (state/EMERGENCY_STOP)"],
|
|
186
|
+
[join(o.home, ".maestro-no-autoupdate"), "autoupdate kill-switch (~/.maestro-no-autoupdate)"],
|
|
187
|
+
[join(o.cwd, ".no-autoupdate"), "autoupdate kill-switch (.no-autoupdate)"],
|
|
188
|
+
];
|
|
189
|
+
for (const [p, reason] of candidates) {
|
|
190
|
+
try { if (existsSync(p)) return reason; } catch { /* unreadable → not held */ }
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The labels launchd has loaded for this user, measured in the gui domain.
|
|
197
|
+
* Edge. `launchctl print gui/<uid>` is the truth from any context (ssh
|
|
198
|
+
* included); a domain-less `launchctl list` is the fallback.
|
|
199
|
+
* @param {{exec?:Function, uid?:number}} o
|
|
200
|
+
* @returns {{loaded:string[], ok:boolean, via:"print"|"list"|"none"}}
|
|
201
|
+
*/
|
|
202
|
+
export function readLoadedLabels(o = {}) {
|
|
203
|
+
const exec = o.exec || defaultExec;
|
|
204
|
+
const uid = Number.isFinite(o.uid) ? o.uid : defaultUid();
|
|
205
|
+
const p = exec("launchctl", ["print", `gui/${uid}`]);
|
|
206
|
+
if (p && p.status === 0) {
|
|
207
|
+
const labels = parseLaunchctlPrintServices(p.stdout);
|
|
208
|
+
if (labels) return { loaded: labels, ok: true, via: "print" };
|
|
209
|
+
}
|
|
210
|
+
const l = exec("launchctl", ["list"]);
|
|
211
|
+
if (l && l.status === 0) return { loaded: parseLaunchctlList(l.stdout), ok: true, via: "list" };
|
|
212
|
+
return { loaded: [], ok: false, via: "none" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Is one label loaded in gui/<uid>? Edge. Used after a failed bootstrap to
|
|
217
|
+
* tell "already loaded" (success) from "could not load" (fall back / fail).
|
|
218
|
+
*/
|
|
219
|
+
export function isLabelLoaded(label, o = {}) {
|
|
220
|
+
const exec = o.exec || defaultExec;
|
|
221
|
+
const uid = Number.isFinite(o.uid) ? o.uid : defaultUid();
|
|
222
|
+
const r = exec("launchctl", ["print", `gui/${uid}/${label}`]);
|
|
223
|
+
return !!(r && r.status === 0 && !/Could not find service/i.test(`${r.stdout}\n${r.stderr}`));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read the two label sets and the loaded set from disk / launchctl. Edge.
|
|
228
|
+
* @param {{generatedDir:string, launchAgentsDir:string, exec?:Function, uid?:number}} o
|
|
229
|
+
* @returns {{generated:string[], installed:string[], loaded:string[], sameContent:(label:string)=>boolean, launchctlOk:boolean, loadedVia:string}}
|
|
230
|
+
*/
|
|
231
|
+
export function readLaunchdState(o = {}) {
|
|
232
|
+
const exec = o.exec || defaultExec;
|
|
233
|
+
const list = (dir) => {
|
|
234
|
+
try { return readdirSync(dir).filter((n) => n.endsWith(".plist")); } catch { return []; /* absent dir → nothing installed/generated */ }
|
|
235
|
+
};
|
|
236
|
+
const generated = list(o.generatedDir).map(labelOf);
|
|
237
|
+
const installed = list(o.launchAgentsDir).map(labelOf).filter(isMaestroLabel);
|
|
238
|
+
const r = readLoadedLabels({ exec, uid: o.uid });
|
|
239
|
+
const sameContent = (label) => {
|
|
240
|
+
try {
|
|
241
|
+
return readFileSync(join(o.generatedDir, `${label}.plist`)).equals(readFileSync(join(o.launchAgentsDir, `${label}.plist`)));
|
|
242
|
+
} catch { return false; /* unreadable on either side → treat as different */ }
|
|
243
|
+
};
|
|
244
|
+
return { generated, installed, loaded: r.loaded, sameContent, launchctlOk: r.ok, loadedVia: r.via };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Apply a plan. Edge: copies, chmods, and calls `launchctl` through `exec`.
|
|
249
|
+
* Each action reports independently; a failure never throws or stops the rest.
|
|
250
|
+
* Never kickstarts, never boots out.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} o
|
|
253
|
+
* @param {ReturnType<typeof planLaunchdReconcile>} o.plan
|
|
254
|
+
* @param {string} o.generatedDir
|
|
255
|
+
* @param {string} o.launchAgentsDir
|
|
256
|
+
* @param {number} o.uid
|
|
257
|
+
* @param {Function} [o.exec]
|
|
258
|
+
* @returns {{ok:boolean, results:Array<{label:string, action:string, ok:boolean, detail:string}>}}
|
|
259
|
+
*/
|
|
260
|
+
export function applyLaunchdReconcile(o = {}) {
|
|
261
|
+
const exec = o.exec || defaultExec;
|
|
262
|
+
const target = `gui/${o.uid}`;
|
|
263
|
+
const results = [];
|
|
264
|
+
let ok = true;
|
|
265
|
+
|
|
266
|
+
const copyPlist = (label) => {
|
|
267
|
+
const src = join(o.generatedDir, `${label}.plist`);
|
|
268
|
+
const dst = join(o.launchAgentsDir, `${label}.plist`);
|
|
269
|
+
mkdirSync(o.launchAgentsDir, { recursive: true });
|
|
270
|
+
copyFileSync(src, dst);
|
|
271
|
+
chmodSync(dst, 0o600);
|
|
272
|
+
return dst;
|
|
273
|
+
};
|
|
274
|
+
const bootstrap = (label, dst) => {
|
|
275
|
+
const b = exec("launchctl", ["bootstrap", target, dst]);
|
|
276
|
+
if (b.status === 0) return { ok: true, via: "bootstrap" };
|
|
277
|
+
// launchd answers 5 (EIO) both for "already loaded" and for a bad plist —
|
|
278
|
+
// ask it which before touching the domain-less `load`, which from ssh
|
|
279
|
+
// would register a second copy of the job in the wrong domain.
|
|
280
|
+
if (isLabelLoaded(label, { exec, uid: o.uid })) return { ok: true, via: `bootstrap ${b.status} but already loaded in ${target}` };
|
|
281
|
+
const l = exec("launchctl", ["load", dst]);
|
|
282
|
+
if (l.status === 0) return { ok: true, via: "load" };
|
|
283
|
+
return { ok: false, detail: `bootstrap ${b.status}${trim(b.stderr)}; load ${l.status}${trim(l.stderr)}` };
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
for (const a of o.plan.actions) {
|
|
287
|
+
const row = { label: a.label, action: a.action, ok: true, detail: a.reason };
|
|
288
|
+
try {
|
|
289
|
+
if (a.action === "install" || a.action === "replace" || a.action === "load") {
|
|
290
|
+
const dst = a.action === "load" ? join(o.launchAgentsDir, `${a.label}.plist`) : copyPlist(a.label);
|
|
291
|
+
if (a.bootstrap) {
|
|
292
|
+
const r = bootstrap(a.label, dst);
|
|
293
|
+
row.ok = r.ok;
|
|
294
|
+
row.detail = r.ok ? `${a.action}: loaded via launchctl ${r.via}` : `${a.action}: file written but not loaded — ${r.detail}`;
|
|
295
|
+
} else if (a.held) {
|
|
296
|
+
row.detail = `${a.action === "load" ? "left as is" : "file written"}; ${a.reason}`;
|
|
297
|
+
} else if (a.action === "replace") {
|
|
298
|
+
row.detail = `replaced (file only): ${a.reason}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
row.ok = false;
|
|
303
|
+
row.detail = `${a.action} failed: ${(err && err.message) || err}`;
|
|
304
|
+
}
|
|
305
|
+
if (!row.ok) ok = false;
|
|
306
|
+
results.push(row);
|
|
307
|
+
}
|
|
308
|
+
return { ok, results };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function trim(s) {
|
|
312
|
+
const t = String(s || "").trim().split("\n")[0];
|
|
313
|
+
return t ? ` (${t})` : "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** One-line rendering of a result row for the upgrade summary. */
|
|
317
|
+
export function formatReconcileRow(r) {
|
|
318
|
+
return `${r.ok ? "✓" : "✗"} ${r.label}: ${r.detail}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Labels present in a directory of plists (helper for verify). */
|
|
322
|
+
export function listPlistLabels(dir) {
|
|
323
|
+
if (!existsSync(dir)) return [];
|
|
324
|
+
try { return readdirSync(dir).filter((n) => n.endsWith(".plist")).map((n) => labelOf(basename(n))); } catch { return []; /* unreadable dir → nothing generated/installed */ }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default { planLaunchdReconcile, applyLaunchdReconcile, readLaunchdState, readLoadedLabels, readHoldReason, isLabelLoaded, parseLaunchctlList, parseLaunchctlPrintServices, labelOf, isMaestroLabel, listPlistLabels, formatReconcileRow, REWRITE_ONLY_NOTE };
|