@passioncode-ai/passioncode 0.1.53 → 0.2.1

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 (28) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +72 -23
  3. package/SECURITY.md +23 -3
  4. package/bin/passioncode.js +71 -6
  5. package/family.json +2 -2
  6. package/lib/catalog.js +91 -0
  7. package/lib/doctor.js +288 -0
  8. package/lib/launcher.js +123 -58
  9. package/lib/mcp-probe.js +91 -0
  10. package/lib/openpgp.js +209 -0
  11. package/lib/release-key.asc +10 -0
  12. package/lib/releases.js +211 -0
  13. package/lib/setup.js +238 -0
  14. package/package.json +6 -3
  15. package/payload/.claude-plugin/marketplace.json +3 -3
  16. package/payload/manifest.json +10 -9
  17. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -5
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +26 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +182 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
  26. package/payload/plugins/passioncode/.claude-plugin/plugin.json +2 -2
  27. package/payload/plugins/passioncode/hooks/session-start.js +13 -1
  28. package/payload/plugins/passioncode/skills/setting-up-passioncode/SKILL.md +109 -0
package/lib/setup.js ADDED
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+ /**
3
+ * `passioncode setup` (alias `onboard`): one idempotent command for a new machine and an existing
4
+ * setup (ADR-0004). It checks everything first (the doctor's checks), plans, asks, then:
5
+ * installs the absent apps the person chose — verified releases only;
6
+ * repairs what the checks found — the skill set and MCP registrations (`update`), an outdated or
7
+ * damaged app nobody is running (a verified reinstall; the old bundle goes to the Trash), a
8
+ * launchd service that does not answer (`launchctl kickstart`, never a kill);
9
+ * opens the apps it installed;
10
+ * then checks again and prints the before/after table and the steps that stay a person's.
11
+ * On a healthy machine the plan is empty, nothing changes, and it exits 0.
12
+ * `doctor --fix` is this command with `repairOnly`: no new apps, nothing opened.
13
+ */
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const L = require('./launcher');
18
+ const catalog = require('./catalog');
19
+ const releases = require('./releases');
20
+ const D = require('./doctor');
21
+
22
+ function prereqMissing(product) {
23
+ const missing = [];
24
+ for (const need of product.needs || []) {
25
+ if (need === 'docker' && releases.run('docker', ['--version'], { timeout: 10000 }).code === 127) missing.push('Docker (OrbStack or Docker Desktop)');
26
+ if (need === 'supabase' && releases.run('supabase', ['--version'], { timeout: 10000 }).code === 127) missing.push('the Supabase CLI');
27
+ }
28
+ return missing;
29
+ }
30
+
31
+ /** What setup would do, from the checks: installs of absent apps and repairs. */
32
+ function plan(checks, { platform, withIds = null, withoutIds = [], noApps = false, repairOnly = false }) {
33
+ const items = [];
34
+ if (platform === 'darwin' && !noApps && !repairOnly) {
35
+ for (const pr of catalog.PRODUCTS) {
36
+ if (fs.existsSync(catalog.appPath(pr))) continue;
37
+ if (withoutIds.includes(pr.id) || (withIds && !withIds.includes(pr.id))) continue;
38
+ const missing = prereqMissing(pr);
39
+ const explicit = !!(withIds && withIds.includes(pr.id));
40
+ items.push({
41
+ kind: 'install', product: pr.id, selected: explicit || !missing.length,
42
+ detail: `install ${pr.name}${pr.preview ? ' (early preview)' : ''} from its latest signed release`,
43
+ note: missing.length ? `needs ${missing.join(' and ')}${explicit ? '; installed because you asked for it' : '; skipped unless you ask for it (--with ' + pr.id + ')'}` : null,
44
+ });
45
+ }
46
+ }
47
+ const seen = new Set();
48
+ for (const c of checks) {
49
+ const r = c.repair;
50
+ if (!r) continue;
51
+ if (r.kind === 'replace' && (noApps || platform !== 'darwin')) continue;
52
+ if (r.kind === 'replace' && (withoutIds.includes(r.product) || (withIds && !withIds.includes(r.product)))) continue;
53
+ const key = r.kind === 'update' ? 'update' : `${r.kind}:${r.product || r.label}`;
54
+ if (seen.has(key)) { items.find((i) => i.key === key).because.push(c.id); continue; }
55
+ seen.add(key);
56
+ const pr = r.product ? catalog.byId(r.product) : null;
57
+ items.push({
58
+ key, kind: r.kind, product: r.product || null, label: r.label || null, selected: true, because: [c.id],
59
+ detail: r.kind === 'update' ? 'install or refresh the skill set and register the apps\' MCP servers for every agent'
60
+ : r.kind === 'replace' ? `reinstall ${pr.name} from its latest signed release (the current one goes to the Trash)`
61
+ : `start the ${r.label} service through launchd`,
62
+ });
63
+ }
64
+ return items;
65
+ }
66
+
67
+ const yesNo = async (ask, question, def) => {
68
+ const a = String(await ask(`${question} [${def ? 'Y/n' : 'y/N'}] `) || '').trim().toLowerCase();
69
+ return a ? a.startsWith('y') || a.startsWith('д') : def;
70
+ };
71
+
72
+ async function setup({
73
+ home = os.homedir(), yes = false, withIds = null, withoutIds = [], noApps = false, noOpen = false, dryRun = false,
74
+ repairOnly = false, online = true, ask = null, log = () => {}, platform = process.env.PASSIONCODE_PLATFORM || process.platform,
75
+ } = {}) {
76
+ const claude = L.claudeRunner();
77
+ const before = D.sortChecks(await D.collect({ home, platform, claude, online }));
78
+ const fresh = !before.some((c) => c.id === 'skills.installed' && c.state !== 'fail')
79
+ && !catalog.PRODUCTS.some((pr) => platform === 'darwin' && fs.existsSync(catalog.appPath(pr)));
80
+ const items = plan(before, { platform, withIds, withoutIds, noApps, repairOnly });
81
+ const result = {
82
+ version: D.PKG, platform, mode: fresh ? 'new' : 'existing', at: new Date().toISOString(), plan: items,
83
+ actions: [], before: { checks: before, summary: D.summarise(before) }, after: null, humanSteps: [], planOnly: false,
84
+ keyOverride: process.env.PASSIONCODE_RELEASE_KEY || null,
85
+ };
86
+
87
+ // Ask: one question per absent app, one for the repairs. Without a terminal and without --yes,
88
+ // nothing changes: the plan is the answer.
89
+ if (dryRun) { result.planOnly = true; return finish(result, home, before); }
90
+ if (!yes) {
91
+ if (!ask) { result.planOnly = items.some((i) => i.selected); if (result.planOnly) return finish(result, home, before); }
92
+ else {
93
+ for (const i of items.filter((x) => x.kind === 'install')) {
94
+ const pr = catalog.byId(i.product);
95
+ i.selected = await yesNo(ask, `Install ${pr.name}${pr.preview ? ' (early preview)' : ''}${i.note ? ` — ${i.note}` : ''}?`, i.selected);
96
+ }
97
+ const repairs = items.filter((x) => x.kind !== 'install');
98
+ if (repairs.length) {
99
+ const go = await yesNo(ask, `Apply ${repairs.length} repair(s): ${repairs.map((r) => r.detail).join('; ')}?`, true);
100
+ for (const r of repairs) r.selected = go;
101
+ }
102
+ }
103
+ }
104
+
105
+ const act = (kind, detail, product = null) => {
106
+ const a = { kind, detail, product, outcome: 'done' };
107
+ result.actions.push(a);
108
+ log(`${kind.padEnd(9)} ${detail}`);
109
+ return a;
110
+ };
111
+ const fail = (a, error) => { a.outcome = 'failed'; a.error = error.message || String(error); log(` failed: ${a.error}`); };
112
+ const installed = [];
113
+ let needUpdate = items.some((i) => i.kind === 'update' && i.selected);
114
+
115
+ // 1. Apps: installs of absent ones, verified reinstalls of outdated or damaged ones.
116
+ for (const i of items.filter((x) => (x.kind === 'install' || x.kind === 'replace') && x.selected)) {
117
+ const pr = catalog.byId(i.product);
118
+ const a = act(i.kind, i.detail, pr.id);
119
+ try {
120
+ const release = await releases.latestRelease(pr, { cacheFile: path.join(L.paths(home).state, 'releases-cache.json') });
121
+ const done = await releases.installApp(pr, release, {
122
+ work: path.join(L.paths(home).state, 'downloads', `${pr.id}-${release.version}`), replace: i.kind === 'replace', home,
123
+ report: (st) => log(` ${st.ok === false ? 'FAILED' : 'ok'} ${st.detail}`),
124
+ });
125
+ a.detail = `${i.kind === 'install' ? 'installed' : 'reinstalled'} ${pr.name} ${done.version} at ${done.path}${done.replaced ? `; the previous one is in ${done.replaced}` : ''}`;
126
+ if (i.kind === 'install') installed.push(pr); // a reinstalled app was idle and stays closed
127
+ needUpdate = needUpdate || (i.kind === 'install' && !!(pr.mcp && !pr.mcp.external));
128
+ } catch (error) { fail(a, error); }
129
+ }
130
+
131
+ // 2. The skill set and the bundled MCP registrations.
132
+ if (needUpdate) {
133
+ const a = act('update', 'the skill set and the apps\' MCP registrations');
134
+ try {
135
+ const r = L.update({ home, claude, includePreview: true }); // setup is where a person chose Fabric
136
+ const failed = r.steps.filter((st) => st.outcome === 'failed');
137
+ const changed = r.steps.filter((st) => st.outcome === 'done');
138
+ a.steps = changed.concat(failed);
139
+ a.detail = `PassionCode.ai ${r.version}: ${changed.length} change(s)${failed.length ? `, ${failed.length} failed` : ''}`;
140
+ if (failed.length) fail(a, new Error(failed.map((st) => `${st.detail}: ${st.error}`).join('; ')));
141
+ } catch (error) { fail(a, error); }
142
+ }
143
+
144
+ // 3. Services launchd owns and that did not answer: start, never kill.
145
+ for (const i of items.filter((x) => x.kind === 'kickstart' && x.selected)) {
146
+ const a = act('start', `launchctl kickstart gui/${process.getuid ? process.getuid() : 0}/${i.label}`);
147
+ const r = releases.run('launchctl', ['kickstart', `gui/${process.getuid ? process.getuid() : 0}/${i.label}`], { timeout: 30000 });
148
+ if (r.code !== 0) fail(a, new Error((r.err || r.out || `exit ${r.code}`).trim()));
149
+ }
150
+
151
+ // 4. Open what was installed for its first run (never in --fix, never with --no-open).
152
+ if (!repairOnly && !noOpen) {
153
+ for (const pr of installed) {
154
+ const a = act('open', `${pr.name} for its first run`, pr.id);
155
+ const r = releases.run('open', [catalog.appPath(pr)], { timeout: 30000 });
156
+ if (r.code !== 0) fail(a, new Error((r.err || r.out || `exit ${r.code}`).trim()));
157
+ }
158
+ }
159
+
160
+ const after = result.actions.length ? D.sortChecks(await D.collect({ home, platform, claude, online })) : before;
161
+ result.after = { checks: after, summary: D.summarise(after) };
162
+
163
+ // The person's steps, once: every remaining problem's fix, the guides of the apps just installed,
164
+ // what an install leaves to a person, and a restart when skills or MCP servers changed.
165
+ const steps = [];
166
+ const push = (t) => { if (t && !steps.includes(t)) steps.push(t); };
167
+ const anyFailed = result.actions.some((x) => x.outcome === 'failed');
168
+ for (const c of after) {
169
+ if (!(c.state === 'fail' || c.state === 'warn') || !c.fix) continue;
170
+ if (c.fix === `${D.NPX} setup` && !anyFailed && !result.planOnly) continue; // this run was that command
171
+ push(c.fix);
172
+ }
173
+ for (const i of items.filter((x) => x.kind === 'install' && !x.selected)) {
174
+ const pr = catalog.byId(i.product);
175
+ if (i.note) push(`${pr.name}: ${i.note}`);
176
+ }
177
+ for (const pr of installed) push(pr.guide);
178
+ if (result.actions.some((a) => a.kind === 'update' && a.outcome === 'done' && /[1-9]\d* change/.test(a.detail)) || installed.length) {
179
+ push('Restart your coding agents: skills and MCP servers load when a session starts.');
180
+ }
181
+ result.humanSteps = steps;
182
+ return finish(result, home, after);
183
+ }
184
+
185
+ function finish(result, home, checks) {
186
+ result.ok = !result.actions.some((a) => a.outcome === 'failed') && D.summarise(checks).fail === 0;
187
+ if (!result.after) result.after = { checks, summary: D.summarise(checks) };
188
+ // Stamped after the after-check: the update inside this run set installedAt earlier, and the
189
+ // session hook ignores a result older than the installed release.
190
+ if (!result.planOnly) D.save(home, { at: new Date().toISOString(), checks, summary: D.summarise(checks) });
191
+ return result;
192
+ }
193
+
194
+ function render(result, { repairOnly = false } = {}) {
195
+ const lines = [];
196
+ if (result.keyOverride) lines.push(`NOTE: releases are checked against the key in PASSIONCODE_RELEASE_KEY (${result.keyOverride}), not the bundled PassionCode.ai release key.`, '');
197
+ const b = result.before.summary, a = result.after.summary;
198
+ lines.push(`${repairOnly ? 'doctor --fix' : 'setup'} · ${result.mode === 'new' ? 'a new setup' : 'an existing setup'} · ${result.platform}`);
199
+ const todo = result.plan.filter((i) => i.selected);
200
+ if (result.planOnly) {
201
+ lines.push('', todo.length ? 'Plan (nothing was changed):' : 'Nothing to do.');
202
+ for (const i of result.plan) lines.push(` ${i.selected ? '•' : '–'} ${i.detail}${i.note ? ` (${i.note})` : ''}`);
203
+ if (todo.length) lines.push('', 'Run it with --yes to apply, or in a terminal to answer one question per app.');
204
+ } else if (!result.actions.length) {
205
+ lines.push('', 'Everything this command can set up is in place; nothing was changed.');
206
+ } else {
207
+ lines.push('', 'Done:');
208
+ for (const x of result.actions) lines.push(` ${x.outcome === 'failed' ? 'FAILED' : 'done '} ${x.detail}${x.error ? `\n ${x.error}` : ''}`);
209
+ }
210
+ // Before → after, by area, and every check whose state moved.
211
+ const areas = ['prerequisites', 'skills', 'apps', 'mcp', 'services'];
212
+ const count = (checks, area, st) => checks.filter((c) => c.area === area && c.state === st).length;
213
+ const single = result.planOnly || !result.actions.length;
214
+ lines.push('', single ? 'Checks now (ok/warn/fail)' : 'Checks before (ok/warn/fail) after (ok/warn/fail)');
215
+ for (const area of areas) {
216
+ const bc = result.before.checks, ac = result.after.checks;
217
+ if (!bc.some((c) => c.area === area) && !ac.some((c) => c.area === area)) continue;
218
+ const f = (cs) => `${count(cs, area, 'ok')}/${count(cs, area, 'warn')}/${count(cs, area, 'fail')}`;
219
+ lines.push(single ? ` ${area.padEnd(14)}${f(ac)}` : ` ${area.padEnd(14)}${f(bc).padEnd(24)}${f(ac)}`);
220
+ }
221
+ const was = new Map(result.before.checks.map((c) => [c.id, c.state]));
222
+ const moved = result.after.checks.filter((c) => was.has(c.id) && was.get(c.id) !== c.state && c.state !== 'info');
223
+ for (const c of moved) lines.push(` ${was.get(c.id)} → ${c.state}: ${c.detail}`);
224
+ const left = result.after.checks.filter((c) => c.state === 'fail' || c.state === 'warn');
225
+ if (left.length) {
226
+ lines.push('', result.planOnly ? 'Open now:' : 'Still open:');
227
+ for (const c of left) lines.push(` ${c.state === 'fail' ? 'FAIL' : 'warn'} ${c.detail}`);
228
+ }
229
+ if (result.humanSteps.length) {
230
+ lines.push('', 'Your steps (an agent cannot do these for you — sign-ins, consents, secrets in hidden prompts):');
231
+ result.humanSteps.forEach((t, n) => lines.push(` ${n + 1}. ${t}`));
232
+ }
233
+ lines.push('', single ? `${a.fail ? `${a.fail} problem(s)` : 'No problems'}. Check again any time: npx @passioncode-ai/passioncode@latest doctor`
234
+ : `${a.fail ? `${a.fail} problem(s) remain` : 'No problems remain'} (before: ${b.fail}). Check again any time: npx @passioncode-ai/passioncode@latest doctor`);
235
+ return lines.join('\n');
236
+ }
237
+
238
+ module.exports = { setup, plan, render };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.53",
4
- "description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
3
+ "version": "0.2.1",
4
+ "description": "Set up PassionCode.ai on a computer with one command — the apps from their verified releases, their MCP servers for every coding agent and the PassionCode.ai skills — and check and repair it with the same command or `doctor`.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
7
7
  },
@@ -45,6 +45,9 @@
45
45
  "passioncode",
46
46
  "fabric",
47
47
  "skills",
48
- "launcher"
48
+ "launcher",
49
+ "setup",
50
+ "doctor",
51
+ "mcp"
49
52
  ]
50
53
  }
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.29.0",
14
+ "version": "0.30.0",
15
15
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -35,8 +35,8 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.53",
39
- "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
38
+ "version": "0.2.1",
39
+ "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
42
42
  "url": "https://passioncode.ai/"
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.53",
3
+ "version": "0.2.1",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.29.0",
11
- "commit": "73af866a11e5f5df16e062d860806e3af331d014",
12
- "version": "0.29.0",
10
+ "ref": "v0.30.0",
11
+ "commit": "668b934ae92b1b52356f250e7dd7c3131d81842c",
12
+ "version": "0.30.0",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -23,7 +23,7 @@
23
23
  "legacyMarketplaces": [
24
24
  "fabric-agent-adapter"
25
25
  ],
26
- "contentHash": "sha256:ab96afe1f3face2a185938cc5a8f4f2893079a30e2ea279bd686177f4a16efc4",
26
+ "contentHash": "sha256:7d270382683711b72cfb4a6ce0ddfbe28612a5f6d71ffe978c10ce96adad019f",
27
27
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
28
28
  "author": {
29
29
  "name": "PassionCode.ai",
@@ -62,17 +62,18 @@
62
62
  "name": "passioncode",
63
63
  "displayName": "PassionCode.ai",
64
64
  "repo": "passioncode-ai/passioncode",
65
- "ref": "v0.1.53",
65
+ "ref": "v0.2.1",
66
66
  "commit": null,
67
- "version": "0.1.53",
67
+ "version": "0.2.1",
68
68
  "via": "this repository",
69
69
  "skills": [
70
+ "setting-up-passioncode",
70
71
  "working-in-passioncode"
71
72
  ],
72
73
  "legacyPluginIds": [],
73
74
  "legacyMarketplaces": [],
74
- "contentHash": "sha256:3a08376ccad2da28008eb612cc2eb5d2fe6b06e7a8be67adabbf27f1ae187758",
75
- "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
+ "contentHash": "sha256:d982253f26d5d1135cb02afc64465d1ade106485f3a79bacfdfd6db4ff4f4965",
76
+ "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
76
77
  "author": {
77
78
  "name": "PassionCode.ai",
78
79
  "url": "https://passioncode.ai/"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.29.0",
6
+ "version": "0.30.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.29.0"
8
+ version: "0.30.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
11
11
  ---
@@ -17,7 +17,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
17
17
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
18
18
  metadata:
19
19
  author: PassionCode.ai
20
- version: "0.29.0"
20
+ version: "0.30.0"
21
21
  contract-version: "0.1.0"
22
22
  extension: "fabric-service/0.1"
23
23
  extension-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
@@ -203,7 +203,8 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
203
203
 
204
204
  On Linux the supervisor is a systemd user unit (`systemd_unit` + `systemd_install`), on
205
205
  Windows a per-user Scheduled Task (`task_xml` + `task_install`); the Node kit has the same
206
- writers (`systemdUnit`, `taskXml`, …) and both kits write identical files.
206
+ writers and installers (`launchdPlist` + `launchdInstall`, `systemdUnit`, `taskXml`, …) and
207
+ both kits write identical files.
207
208
 
208
209
  Code runs from an immutable release directory; an upgrade writes a new release,
209
210
  rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
@@ -12,7 +12,7 @@ wins and this page is the defect. The rule each section serves:
12
12
  | LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
13
13
  | LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
14
14
  | LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
15
- | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install`, `launchd_uninstall` (Python); `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
15
+ | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install` / `launchdInstall`, `launchd_uninstall` / `launchdUninstall`; `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
16
16
  | LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
17
17
 
18
18
  ## Contents
@@ -26,7 +26,7 @@ wins and this page is the defect. The rule each section serves:
26
26
  - [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
27
27
  - [Linux and Windows](#linux-and-windows)
28
28
 
29
- ## launchd plist (generate it with `launchd_plist`)
29
+ ## launchd plist (generate it with `launchd_plist` or `launchdPlist`; both kits write the same bytes)
30
30
 
31
31
  | Key | Value | Why |
32
32
  |---|---|---|
@@ -37,7 +37,7 @@ wins and this page is the defect. The rule each section serves:
37
37
  | `ThrottleInterval` | `10` | bounds a crash loop |
38
38
  | `ExitTimeOut` | above the drain and its hard exit (15 s default; `Drain` ends by 10 s) | SIGKILL arrives after it, so it must never come first |
39
39
  | `ProcessType` | `Standard` — never `Background`, and no `Nice` > 0 or `LowPriorityIO` | a host probes the service and agents call it while the Mac is busy; a background job is starved for tens of seconds under load and every host reports an outage that never happened (the probe's `lifecycle.priority` rule fails it) |
40
- | `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets; `FABRIC_SERVICE_SUPERVISOR=launchd` | never a secret value — the plist is world-readable in backups; the supervisor marker is how the lock knows to back off |
40
+ | `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets; `FABRIC_SERVICE_SUPERVISOR=launchd` | never a secret value — the plist is world-readable in backups; the supervisor marker is how the lock knows to back off (the probe's `lifecycle.supervisor-marker` rule fails a plist without it: a copy that finds the lock held would exit 75 at once into a respawn every `ThrottleInterval`) |
41
41
  | `StandardOutPath`/`StandardErrorPath` | `~/Library/Logs/<id>/service.log` | one place the host tails |
42
42
 
43
43
  ## Install, upgrade, uninstall
@@ -45,14 +45,14 @@ wins and this page is the defect. The rule each section serves:
45
45
  1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
46
46
  (a venv or `node_modules` inside it). Never edit a release after it is written.
47
47
  2. `write_descriptor` (port and id claims are checked here).
48
- 3. `launchd_install`: write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
48
+ 3. `launchd_install` (Node: `await launchdInstall(...)`, the same steps): write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
49
49
  `enable` **only when no override is recorded** (a first install), `bootout` and wait
50
50
  until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
51
51
  then poll the well-known document until it answers with this `id.instance`.
52
52
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
53
53
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release
54
54
  and the one before it stay for rollback, older ones go (LC-11, LC-15).
55
- 5. Uninstall = `launchd_uninstall` + `remove_descriptor`. Data stays; `purge=True`
55
+ 5. Uninstall = `launchd_uninstall` (Node: `await launchdUninstall(...)`) + `remove_descriptor`. Data stays; `purge=True`
56
56
  (with `service_id`) removes data, logs and cache when the operator asks.
57
57
 
58
58
  **The operator's intent wins (LC-14).** A host stops a service with `bootout` **and**
@@ -75,6 +75,29 @@ def plist_problems(plist: dict, label: object, token: object = None) -> list:
75
75
  return problems
76
76
 
77
77
 
78
+ def marker_problem(plist: dict) -> Optional[str]:
79
+ """What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
80
+
81
+ Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
82
+ lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
83
+ env = plist.get("EnvironmentVariables")
84
+ env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
85
+ argv = plist.get("ProgramArguments")
86
+ argv = [str(a) for a in argv] if isinstance(argv, list) else []
87
+ marker = fs.supervisor_marker(env, argv)
88
+ if marker["kind"] == "supervised" and marker["manager"] == "launchd":
89
+ return None
90
+ fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
91
+ "regenerate the plist with launchd_plist / launchdPlist and reinstall")
92
+ if marker["kind"] == "conflict":
93
+ return "%s — %s" % (fs.marker_conflict_message(marker), fix)
94
+ if marker["kind"] == "supervised":
95
+ return "the marker names %s, not launchd — %s" % (marker["manager"], fix)
96
+ if marker.get("unreadable") is not None:
97
+ return "the marker %r names no supervisor — %s" % (marker["unreadable"], fix)
98
+ return "no %s=launchd — %s" % (fs.SUPERVISOR_ENV, fix)
99
+
100
+
78
101
  def priority_problems(plist: dict) -> list:
79
102
  """A service that answers hosts and agents must not be scheduled as background work."""
80
103
  out = []
@@ -701,6 +724,9 @@ class Probe:
701
724
  return
702
725
  problems = plist_problems(plist, life.get("label"), self.token)
703
726
  self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
727
+ marker = marker_problem(plist)
728
+ self.add("lifecycle.supervisor-marker", "FAIL" if marker else "PASS",
729
+ marker or "%s: %s=launchd, so a copy that finds the lock held backs off" % (plist_path.name, fs.SUPERVISOR_ENV))
704
730
  slow = priority_problems(plist)
705
731
  self.add("lifecycle.priority", "FAIL" if slow else "PASS",
706
732
  "; ".join(slow) + " — macOS may starve the service under load and hosts then see an outage; use ProcessType Standard"