@passioncode-ai/passioncode 0.2.49 → 0.2.51

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 (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +12 -0
  3. package/SECURITY.md +3 -0
  4. package/bin/passioncode.js +12 -4
  5. package/family.json +1 -1
  6. package/lib/doctor.js +118 -16
  7. package/lib/releases.js +59 -7
  8. package/lib/setup.js +77 -10
  9. package/lib/trains.js +38 -0
  10. package/package.json +2 -1
  11. package/payload/.claude-plugin/marketplace.json +2 -2
  12. package/payload/manifest.json +8 -8
  13. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +18 -7
  16. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  17. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +25 -5
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -1
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +2 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +5 -3
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +2 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +95 -5
  27. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  28. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +11 -5
  29. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +9 -9
  30. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  31. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
  32. package/payload/plugins/passioncode/hooks/session-start.js +3 -1
  33. package/payload/plugins/passioncode/skills/setting-up-passioncode/SKILL.md +4 -0
  34. package/trains/organization-r1.json +13 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.51 - 2026-10-30
4
+
5
+ ### Fixed
6
+
7
+ - `setup` finds an app one folder down in its zip, as Fabric Switchboard's archive holds it
8
+ (`Fabric-Switchboard-<v>-macos-universal/Fabric Switchboard.app`, 0.6.83 to 0.7.1); before, it
9
+ refused the release with "holds no Fabric Switchboard.app". Two copies in one archive are refused.
10
+ - A release download or a GitHub API read whose connection drops before any answer is tried once
11
+ more after a second, instead of failing the install with "fetch failed".
12
+
13
+ ### Added
14
+
15
+ - Release trains (PC-89, organization R1 N-12): `setup --train organization-r1` installs and
16
+ reinstalls each app at the version the train pins (`trains/organization-r1.json`) instead of its
17
+ latest release, and remembers the train; `doctor` compares every app with its pin, offline too,
18
+ in each `app.<id>` line and one `train` line, on macOS and Windows. An app ahead of its pin is
19
+ reported and never taken back, nor is one whose version cannot be read. `setup --train none` returns to the latest releases. The first
20
+ pins are the releases of 2026-10-28; the train's record in fabric-workspace decides later ones.
21
+ In `setup --json`, a change of train is an action of kind `train`, and a run that named a new
22
+ train but was only a plan carries `trainNotSaved`.
23
+
24
+ ## 0.2.50 - 2026-10-28
25
+
26
+ ### Changed
27
+
28
+ - **Fabric Agent Adapter is pinned at `v0.57.0`** (it was `v0.56.0`): the kits read the Fabric Agent Contract at
29
+ `3830a33` (`v0.1.0+2026-10-27.6`). A person's comparison of codes is a human step (DEC-0142), the protocol register
30
+ knows the peer-share and organization-adaptation revisions, and on Windows the provider writer uses the folder Fabric
31
+ reads (PC-90).
32
+
3
33
  ## 0.2.49 - 2026-10-28
4
34
 
5
35
  ### Fixed
package/README.md CHANGED
@@ -112,6 +112,18 @@ the family; each line names its state and its fix. Exit codes: **0** no check fa
112
112
  check (or a setup step) failed, **2** bad arguments. `doctor --fix` runs setup's repairs without
113
113
  installing new apps. Details: the [CLI contract](docs/reference/cli.md#setup-and-doctor).
114
114
 
115
+ **Release trains.** An organization's machines install one set of pinned app versions instead of
116
+ each app's latest release: `setup --train organization-r1` installs and reinstalls the apps at
117
+ the versions in [`trains/organization-r1.json`](trains/organization-r1.json), remembers the train,
118
+ and `doctor` then compares every app with its pin (offline too) in an `app.<id>` line per app and
119
+ one `train` line. An app newer than its pin is reported and left alone (a damaged one, or one whose version cannot be read, is reinstalled from the latest
120
+ release): the train never takes an app back, because its data may not open in an older release. `setup --train none` returns to the
121
+ latest releases. Either change is saved with `--yes` or in a terminal; without both it is only
122
+ shown as a plan. The skills are pinned by the launcher release itself (`family.json`). Which
123
+ version each app pins is the train's record in fabric-workspace
124
+ ([SD-4](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/roadmap.md#sd-4)); a
125
+ change to the file is a launcher release.
126
+
115
127
  ## Staying current
116
128
 
117
129
  At Claude Code session start the `passioncode` plugin uses a cached npm check for up
package/SECURITY.md CHANGED
@@ -33,10 +33,13 @@ returns a nonzero exit code. See the [CLI recovery contract](docs/reference/cli.
33
33
  `~/.kimi-code/mcp.json` (entry names, commands and arguments; environment values are passed to
34
34
  the server it starts and never printed), the Fabric service descriptors (never their token
35
35
  files), the apps' `Info.plist` (on Windows, the apps' uninstall entries through `reg query` and their install folders),
36
+ the launcher's own `trains/<name>.json` and the `train` it saved in `~/.passioncode/state.json`,
36
37
  and the GitHub releases API. It starts each registered stdio MCP
37
38
  server for one proving call and stops it (never Fabric's, which enrols a session as it starts), and writes only `~/.passioncode/doctor.json` and a
38
39
  release cache.
39
40
 
41
+ `setup --train <name>` (or `none`) also writes that name into `~/.passioncode/state.json`; a train
42
+ changes which release is downloaded (the pinned tag instead of the latest), never how it is verified.
40
43
  `setup` also downloads releases from GitHub into `~/.passioncode/downloads/` (removed after
41
44
  each app), copies a verified app into `/Applications`, moves an app it replaces into `~/.Trash/`,
42
45
  runs `update` (above), runs `launchctl kickstart gui/<uid>/<label>` for a family service whose
@@ -19,7 +19,7 @@ function cachedNpmStatus({ latest, checkedAt, installed }) {
19
19
 
20
20
  const HELP = `passioncode — set up and check PassionCode.ai on this machine: the apps, their MCP servers, the skills
21
21
 
22
- npx @passioncode-ai/passioncode@latest setup [--yes] [--with ids] [--without ids] [--no-apps] [--no-open] [--dry-run] [--json]
22
+ npx @passioncode-ai/passioncode@latest setup [--yes] [--with ids] [--without ids] [--train name|none] [--no-apps] [--no-open] [--dry-run] [--json]
23
23
  a new machine: install and configure everything; an existing one: check, repair, check again
24
24
  npx @passioncode-ai/passioncode@latest doctor [--fix] [--json] check everything (read-only); --fix repairs as setup does
25
25
  npx @passioncode-ai/passioncode@latest update [--dry-run] [--json] install or update the skill set and the apps' MCP registrations
@@ -38,11 +38,16 @@ const IDS = ['switchboard', 'dashboards', 'observatory', 'inbox', 'fabric'];
38
38
  /** Flags of setup and doctor: booleans, plus --with/--without taking a comma list. Null when invalid. */
39
39
  function parseSetupArgs(cmd, rest) {
40
40
  const allowed = cmd === 'doctor' ? ['--fix', '--json', '--offline'] : ['--yes', '-y', '--json', '--offline', '--no-apps', '--no-open', '--dry-run'];
41
- const o = { flags: new Set(), withIds: null, withoutIds: [] };
41
+ const o = { flags: new Set(), withIds: null, withoutIds: [], train: undefined };
42
42
  for (let i = 0; i < rest.length; i++) {
43
43
  const arg = rest[i];
44
44
  const m = /^--(with|without)(?:=(.*))?$/.exec(arg);
45
- if (m && cmd !== 'doctor') {
45
+ const tr = /^--train(?:=(.*))?$/.exec(arg);
46
+ if (tr && cmd !== 'doctor') {
47
+ const value = tr[1] !== undefined ? tr[1] : rest[++i];
48
+ if (!value || o.train !== undefined) return null;
49
+ o.train = value === 'none' ? null : value; // PC-89: a release train's pinned versions instead of the latest
50
+ } else if (m && cmd !== 'doctor') {
46
51
  const value = m[2] !== undefined ? m[2] : rest[++i];
47
52
  const ids = String(value || '').split(',').map((x) => x.trim()).filter(Boolean);
48
53
  if (!ids.length || ids.some((x) => !IDS.includes(x))) return null;
@@ -68,6 +73,9 @@ async function setupOrDoctor(cmd, rest) {
68
73
  process.stderr.write(`passioncode: unsupported arguments for ${cmd}; run passioncode help. App ids: ${IDS.join(', ')}.\n`);
69
74
  return 2;
70
75
  }
76
+ if (o.train) {
77
+ try { require('../lib/trains').load(o.train); } catch (error) { process.stderr.write(`passioncode: ${error.message}.\n`); return 2; }
78
+ }
71
79
  const json = o.flags.has('--json');
72
80
  const online = !o.flags.has('--offline');
73
81
  if (cmd === 'doctor' && !o.flags.has('--fix')) {
@@ -81,7 +89,7 @@ async function setupOrDoctor(cmd, rest) {
81
89
  const log = json ? () => {} : (line) => process.stderr.write(`${line}\n`);
82
90
  const r = await S.setup({
83
91
  yes: repairOnly || o.flags.has('--yes'), withIds: o.withIds, withoutIds: o.withoutIds, noApps: o.flags.has('--no-apps'),
84
- noOpen: o.flags.has('--no-open'), dryRun: o.flags.has('--dry-run'), repairOnly, online, ask: json ? null : asker(), log,
92
+ noOpen: o.flags.has('--no-open'), dryRun: o.flags.has('--dry-run'), repairOnly, online, ask: json ? null : asker(), log, train: o.train,
85
93
  });
86
94
  process.stdout.write(json ? JSON.stringify(r, null, 2) + '\n' : S.render(r, { repairOnly }) + '\n');
87
95
  return r.planOnly ? 0 : r.ok ? 0 : 1;
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.56.0",
11
+ "ref": "v0.57.0",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/lib/doctor.js CHANGED
@@ -14,10 +14,13 @@ const L = require('./launcher');
14
14
  const catalog = require('./catalog');
15
15
  const releases = require('./releases');
16
16
  const W = require('./windows-apps');
17
+ const trains = require('./trains');
17
18
  const { probe } = require('./mcp-probe');
18
19
 
19
20
  const PKG = (() => { try { return require('../package.json').version; } catch (_) { return '0.0.0'; } })();
20
21
  const NPX = 'npx @passioncode-ai/passioncode@latest';
22
+ /** The page of one release: a train's pin is installed from there, not from the product page (which offers the latest). */
23
+ const releasePage = (pr, version) => `https://github.com/${pr.repo}/releases/tag/v${version}`;
21
24
 
22
25
  function servicesDir(home, platform) {
23
26
  if (process.env.PASSIONCODE_SERVICES_DIR) return process.env.PASSIONCODE_SERVICES_DIR;
@@ -84,9 +87,59 @@ async function wellKnown(origin) {
84
87
  } catch (error) { return { ok: false, reason: error.name === 'TimeoutError' ? 'no answer within 5 s' : error.message }; }
85
88
  }
86
89
 
87
- const newer = (a, b) => { const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number); for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] > pb[i]; return false; };
90
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
91
+ /** A version as x.y.z[-pre], or null when it is not one; Windows' four-part `1.2.3.0` reads as `1.2.3`. */
92
+ const normVersion = (v) => { const s = String(v || '').replace(/^(\d+\.\d+\.\d+)\.0$/, '$1'); return VERSION.test(s) ? s : null; };
88
93
 
89
- async function collect({ home, platform, claude, online }) {
94
+ /**
95
+ * a > b by semver precedence: the numeric core first, then a release above its own prerelease
96
+ * (`0.3.15` > `0.3.15-rc.2`). A version that is not x.y.z[-pre] is neither newer nor older.
97
+ */
98
+ const newer = (a, b) => {
99
+ if (!VERSION.test(String(a)) || !VERSION.test(String(b))) return false;
100
+ const split = (v) => { const [core, pre = null] = String(v).split(/-(.*)/s); return { core: core.split('.').map(Number), pre }; };
101
+ const pa = split(a), pb = split(b);
102
+ for (let i = 0; i < 3; i++) if ((pa.core[i] || 0) !== (pb.core[i] || 0)) return (pa.core[i] || 0) > (pb.core[i] || 0);
103
+ if (pa.pre === pb.pre) return false;
104
+ if (pa.pre === null || pb.pre === null) return pa.pre === null;
105
+ const xa = pa.pre.split('.'), xb = pb.pre.split('.');
106
+ for (let i = 0; i < Math.max(xa.length, xb.length); i++) {
107
+ if (xa[i] === undefined) return false;
108
+ if (xb[i] === undefined) return true;
109
+ if (xa[i] === xb[i]) continue;
110
+ const na = /^\d+$/.test(xa[i]), nb = /^\d+$/.test(xb[i]);
111
+ if (na && nb) return Number(xa[i]) > Number(xb[i]);
112
+ if (na !== nb) return nb; // numeric identifiers rank below alphanumeric ones
113
+ return xa[i] > xb[i];
114
+ }
115
+ return false;
116
+ };
117
+
118
+ /**
119
+ * The release train this machine follows (PC-89): `train` given (a name, or null for none) wins over
120
+ * the one `setup --train` saved in the launcher's state. { id, train } where train is null when none
121
+ * is followed, or an Error when the named one cannot be used.
122
+ */
123
+ function trainOf(home, train) {
124
+ let id = train;
125
+ if (id === undefined) { try { id = JSON.parse(fs.readFileSync(L.paths(home).stateFile, 'utf8')).train || null; } catch (_) { id = null; } }
126
+ if (!id) return { id: null, train: null };
127
+ try { return { id, train: trains.load(id) }; } catch (error) { return { id, train: error }; }
128
+ }
129
+
130
+ /** An app's row against the version its train pins: equal, behind (repairable) or ahead (left alone). */
131
+ function pinnedRow(pr, label, raw, at, t, { fixBehind, repairBehind, fixUnknown }) {
132
+ const pin = t.apps[pr.id];
133
+ const where = at ? ` in ${at}` : '';
134
+ const version = normVersion(raw);
135
+ if (!version) return { state: 'warn', detail: `${label} ${raw ? `${raw} (not a version this launcher compares)` : '(version unknown)'}${where}; release train ${t.id} pins ${pin}`, fix: fixUnknown, repair: null };
136
+ if (version === pin) return { state: 'ok', detail: `${label} ${version}${where} (pinned by release train ${t.id})`, fix: null, repair: null };
137
+ if (newer(pin, version)) return { state: 'warn', detail: `${label} ${version}${where}; release train ${t.id} pins ${pin}`, fix: fixBehind, repair: repairBehind };
138
+ // An app's data may not open in an older release (Fabric's schema), so the train never takes one back.
139
+ return { state: 'warn', detail: `${label} ${version}${where}, ahead of release train ${t.id} (${pin}); the train never takes an app back`, fix: null, repair: null };
140
+ }
141
+
142
+ async function collect({ home, platform, claude, online, train: trainName }) {
90
143
  const checks = [];
91
144
  // `repair` is what setup (and doctor --fix) may do about a check: { kind: 'update' } (the skill
92
145
  // set and the bundled MCP registrations), { kind: 'replace', product } (a verified reinstall of an
@@ -126,6 +179,9 @@ async function collect({ home, platform, claude, online }) {
126
179
  const probes = new Map();
127
180
  let docker = null;
128
181
  let windowsEntries = null; // the uninstall entries, read once (three `reg query` calls)
182
+ const followed = trainOf(home, trainName);
183
+ const train = followed.train instanceof Error ? null : followed.train;
184
+ const onTrain = []; // { pr, version } of each installed app the train pins
129
185
  for (const pr of catalog.PRODUCTS) {
130
186
  const at = catalog.appPath(pr);
131
187
  const installed = platform === 'darwin' && fs.existsSync(at);
@@ -138,14 +194,22 @@ async function collect({ home, platform, claude, online }) {
138
194
  }
139
195
  const found = W.findWindowsApp(pr, { entries: windowsEntries.entries, home });
140
196
  if (!found) {
141
- add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: not installed`, fix: `download it from ${pr.page}` });
197
+ add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: not installed`, fix: train && train.apps[pr.id] ? `download ${train.apps[pr.id]} from ${releasePage(pr, train.apps[pr.id])}` : `download it from ${pr.page}` });
198
+ } else if (train && train.apps[pr.id]) {
199
+ onTrain.push({ pr, version: found.version });
200
+ const r = pinnedRow(pr, label, found.version, found.at, train, {
201
+ fixBehind: `install ${train.apps[pr.id]} from ${releasePage(pr, train.apps[pr.id])}`, repairBehind: null,
202
+ fixUnknown: `install ${train.apps[pr.id]} from ${releasePage(pr, train.apps[pr.id])} if this one is older`,
203
+ });
204
+ add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, ...r });
142
205
  } else {
143
206
  let latest = null, latestErr = null;
144
207
  if (online) { try { latest = await releases.latestRelease(pr, { cacheFile }); } catch (error) { latestErr = error.message; } }
145
- const outdated = latest && found.version && newer(latest.version, found.version);
208
+ const outdated = latest && normVersion(found.version) && newer(latest.version, normVersion(found.version));
209
+ const odd = latest && !normVersion(found.version); // unknown or not x.y.z: never read as the latest
146
210
  add({
147
- id: `app.${pr.id}`, area: 'apps', product: pr.id, state: outdated || (!latest && online) ? 'warn' : 'ok',
148
- detail: `${label} ${found.version || '(version unknown)'}${found.at ? ` in ${found.at}` : ''}${latest ? (outdated ? `; ${latest.version} is out` : found.version ? ' (latest)' : `; the latest is ${latest.version}`) : online ? `; latest unknown (${latestErr})` : '; latest not checked (--offline)'}`,
211
+ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: outdated || odd || (!latest && online) ? 'warn' : 'ok',
212
+ detail: `${label} ${found.version || '(version unknown)'}${found.at ? ` in ${found.at}` : ''}${latest ? (outdated ? `; ${latest.version} is out` : odd ? (found.version ? ` (not a version this launcher compares); the latest is ${latest.version}` : `; the latest is ${latest.version}`) : ' (latest)') : online ? `; latest unknown (${latestErr})` : '; latest not checked (--offline)'}`,
149
213
  fix: outdated ? `install ${latest.version} from ${pr.page} (or accept the update ${pr.name} offers)` : null,
150
214
  });
151
215
  }
@@ -155,16 +219,28 @@ async function collect({ home, platform, claude, online }) {
155
219
  add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: not installed`, fix: `${NPX} setup --with ${pr.id}` });
156
220
  } else {
157
221
  const version = releases.appVersion(at);
158
- let latest = null, latestErr = null;
159
- if (online) { try { latest = await releases.latestRelease(pr, { cacheFile }); } catch (error) { latestErr = error.message; } }
160
- const outdated = latest && version && newer(latest.version, version);
161
222
  const running = releases.isRunning(at); // the app, or an agent's MCP server running from its bundle
162
- add({
163
- id: `app.${pr.id}`, area: 'apps', product: pr.id, state: outdated || (!latest && online) ? 'warn' : 'ok',
164
- detail: `${label} ${version || '?'}${latest ? (outdated ? `; ${latest.version} is out` : ' (latest)') : online ? `; latest unknown (${latestErr})` : '; latest not checked (--offline)'}`,
165
- fix: outdated ? (running ? `quit ${pr.name}, then run \`${NPX} setup\` (or accept the update ${pr.name} offers)` : `${NPX} setup`) : null,
166
- repair: outdated && !running ? { kind: 'replace', product: pr.id } : null,
167
- });
223
+ if (train && train.apps[pr.id]) {
224
+ onTrain.push({ pr, version });
225
+ const r = pinnedRow(pr, label, version, null, train, {
226
+ fixBehind: running ? `quit ${pr.name}, then run \`${NPX} setup\`` : `${NPX} setup`,
227
+ repairBehind: running ? null : { kind: 'replace', product: pr.id },
228
+ // Never a hand download on macOS: that skips the signed-checksum, team and notarization checks.
229
+ fixUnknown: `${pr.name}'s version could not be read; if its signature check fails too, \`${NPX} setup\` reinstalls it from the latest release`,
230
+ });
231
+ add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, ...r });
232
+ } else {
233
+ let latest = null, latestErr = null;
234
+ if (online) { try { latest = await releases.latestRelease(pr, { cacheFile }); } catch (error) { latestErr = error.message; } }
235
+ const outdated = latest && normVersion(version) && newer(latest.version, normVersion(version));
236
+ const odd = latest && !normVersion(version); // unknown or not x.y.z: never read as the latest
237
+ add({
238
+ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: outdated || odd || (!latest && online) ? 'warn' : 'ok',
239
+ detail: `${label} ${version || '?'}${latest ? (outdated ? `; ${latest.version} is out` : odd ? ` (${version ? 'not a version this launcher compares' : 'version unknown'}); the latest is ${latest.version}` : ' (latest)') : online ? `; latest unknown (${latestErr})` : '; latest not checked (--offline)'}`,
240
+ fix: outdated ? (running ? `quit ${pr.name}, then run \`${NPX} setup\` (or accept the update ${pr.name} offers)` : `${NPX} setup`) : null,
241
+ repair: outdated && !running ? { kind: 'replace', product: pr.id } : null,
242
+ });
243
+ }
168
244
  const v = releases.verifyApp(at, { gatekeeper: false });
169
245
  add({ id: `app.${pr.id}.signature`, area: 'apps', product: pr.id, state: v.ok ? 'ok' : 'fail', detail: `${pr.name}: ${v.checks.map((c) => `${c.check} ${c.ok ? 'ok' : `FAILED (${c.detail})`}`).join(', ')}`, fix: v.ok ? null : (running ? `quit ${pr.name}, then run \`${NPX} setup\`` : `${NPX} setup`), repair: !v.ok && !running ? { kind: 'replace', product: pr.id } : null });
170
246
  add({ id: `app.${pr.id}.running`, area: 'apps', product: pr.id, state: 'info', detail: `${pr.name}: ${running ? 'in use (the app, or an agent\'s MCP server from its bundle)' : 'not running (it starts when opened)'}`, fix: running ? null : `open "${at}" — or ask this machine's app supervisor to start it` });
@@ -220,6 +296,32 @@ async function collect({ home, platform, claude, online }) {
220
296
  }
221
297
  }
222
298
 
299
+ // The release train (PC-89): one row naming it and how many of its apps sit at their pin.
300
+ if (followed.train instanceof Error) {
301
+ const fix = `${NPX} setup --yes --no-apps --train none (or update the launcher)`;
302
+ add({ id: 'train', area: 'apps', state: 'fail', detail: `release train ${followed.id}: ${followed.train.message}`, fix });
303
+ // setup touches no app while the saved train is unusable, so no row may promise that it installs
304
+ // or reinstalls one. A hand download (Windows) is outside the launcher and stays.
305
+ for (const c of checks) {
306
+ if (!c.product || !/^(app\.[^.]+(\.signature)?|mcp\..+)$/.test(c.id)) continue;
307
+ // An MCP row's plain `setup` is the update repair, which still runs; only its app install does not.
308
+ const viaSetup = !!c.fix && c.fix.includes(c.id.startsWith('mcp.') ? `${NPX} setup --with` : `${NPX} setup`);
309
+ if (viaSetup || (c.repair && c.repair.kind === 'replace')) {
310
+ if (viaSetup) c.fix = `first make the release train usable: ${fix}`;
311
+ if (c.repair && c.repair.kind === 'replace') c.repair = null;
312
+ }
313
+ }
314
+ } else if (train) {
315
+ const pinned = Object.keys(train.apps).filter((id) => catalog.byId(id));
316
+ const at = onTrain.filter((x) => normVersion(x.version) === train.apps[x.pr.id]).length;
317
+ const off = onTrain.some((x) => normVersion(x.version) !== train.apps[x.pr.id]);
318
+ add({
319
+ id: 'train', area: 'apps', state: off ? 'warn' : 'ok',
320
+ detail: `release train ${train.id} (${train.title}${train.pinned ? `, pinned ${train.pinned}` : ''}): ${at} of ${pinned.length} apps at their pinned version${onTrain.length ? `; ${onTrain.map((x) => `${x.pr.name} ${x.version || '?'}`).join(', ')}` : ''}`,
321
+ fix: null,
322
+ });
323
+ }
324
+
223
325
  // One proving call per distinct registered command.
224
326
  const results = await Promise.all([...probes.values()].map(async (t) => {
225
327
  const tool = t.product.mcp.prove ? t.product.mcp.prove.tool : false;
@@ -307,4 +409,4 @@ function render(result) {
307
409
  return lines.join('\n').replace(/^\n/, '');
308
410
  }
309
411
 
310
- module.exports = { doctor, render, servicesDir, presentAgents, mcpEntryOf, collect, summarise, sortChecks, save, NPX, PKG };
412
+ module.exports = { doctor, render, trainOf, newer, normVersion, servicesDir, presentAgents, mcpEntryOf, collect, summarise, sortChecks, save, NPX, PKG };
package/lib/releases.js CHANGED
@@ -24,8 +24,20 @@ function run(cmd, args, { timeout = 60000, input, maxBuffer = 1024 * 1024 } = {}
24
24
  return { code: r.status === null ? 1 : r.status, out: r.stdout || '', err: r.stderr || '' };
25
25
  }
26
26
 
27
+ /**
28
+ * fetch, once more after a second when the connection itself failed (no answer at all: a reset or a
29
+ * dropped socket). Both callers only read, so a repeat is safe; an HTTP error or a timeout is final.
30
+ */
31
+ async function fetchTwice(url, options) {
32
+ try { return await fetch(url, options()); } catch (error) {
33
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') throw error;
34
+ await new Promise((r) => setTimeout(r, 1000));
35
+ return fetch(url, options());
36
+ }
37
+ }
38
+
27
39
  async function getJson(url) {
28
- const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'passioncode-launcher' }, signal: AbortSignal.timeout(15000) });
40
+ const res = await fetchTwice(url, () => ({ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'passioncode-launcher' }, signal: AbortSignal.timeout(15000) }));
29
41
  if (!res.ok) throw new Error(`${url} answered HTTP ${res.status}`);
30
42
  return res.json();
31
43
  }
@@ -48,20 +60,45 @@ async function latestRelease(product, { cacheFile = null, now = Date.now() } = {
48
60
  } else {
49
61
  raw = await getJson(`${API()}/repos/${product.repo}/releases/latest`);
50
62
  }
63
+ const release = shape(product, raw);
64
+ if (cacheFile) {
65
+ cache[product.repo] = { at: new Date(now).toISOString(), release };
66
+ try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2)); } catch (_) { /* a cache only */ }
67
+ }
68
+ return release;
69
+ }
70
+
71
+ function shape(product, raw) {
51
72
  const release = {
52
73
  tag: raw.tag_name, version: String(raw.tag_name || '').replace(/^v/, ''), prerelease: !!raw.prerelease, url: raw.html_url,
53
74
  assets: (raw.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size, digest: a.digest || null })),
54
75
  };
55
76
  if (!/^\d+\.\d+\.\d+/.test(release.version)) throw new Error(`${product.repo}: unexpected tag ${raw.tag_name}`);
77
+ return release;
78
+ }
79
+
80
+ /**
81
+ * The release a train pins (PC-89): tag `v<version>`, draft or not published refused. A published
82
+ * release does not change, so it is cached without expiry beside the latest ones.
83
+ */
84
+ async function releaseAt(product, version, { cacheFile = null } = {}) {
85
+ const key = `${product.repo}@v${version}`;
86
+ let cache = {};
87
+ if (cacheFile) { try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch (_) { cache = {}; } }
88
+ if (cache[key] && cache[key].release) return cache[key].release;
89
+ const raw = await getJson(`${API()}/repos/${product.repo}/releases/tags/v${encodeURIComponent(version)}`);
90
+ if (raw.draft) throw new Error(`${product.repo} v${version} is a draft, not a published release`);
91
+ const release = shape(product, raw);
92
+ if (release.version !== version) throw new Error(`${product.repo}: asked for v${version}, got ${raw.tag_name}`);
56
93
  if (cacheFile) {
57
- cache[product.repo] = { at: new Date(now).toISOString(), release };
94
+ cache[key] = { at: new Date().toISOString(), release };
58
95
  try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2)); } catch (_) { /* a cache only */ }
59
96
  }
60
97
  return release;
61
98
  }
62
99
 
63
100
  async function download(url, dest) {
64
- const res = await fetch(url, { headers: { 'User-Agent': 'passioncode-launcher' }, redirect: 'follow', signal: AbortSignal.timeout(30 * 60 * 1000) });
101
+ const res = await fetchTwice(url, () => ({ headers: { 'User-Agent': 'passioncode-launcher' }, redirect: 'follow', signal: AbortSignal.timeout(30 * 60 * 1000) }));
65
102
  if (!res.ok || !res.body) throw new Error(`download ${url}: HTTP ${res.status}`);
66
103
  const hash = crypto.createHash('sha256');
67
104
  const fd = fs.openSync(dest, 'w');
@@ -118,6 +155,21 @@ function appVersion(appDir) {
118
155
  * Download, verify and install one absent app into the applications folder. `report(step)` gets
119
156
  * each stage. Throws with the first failed check; the working directory is always removed.
120
157
  */
158
+ /**
159
+ * The app bundle in an unpacked archive or a mounted image: at its root, or inside one folder at the
160
+ * root (Fabric Switchboard's zip holds `Fabric-Switchboard-<v>-macos-universal/Fabric Switchboard.app`).
161
+ * Two candidates are refused rather than guessed between. Returns the path or null.
162
+ */
163
+ function findBundle(root, appName) {
164
+ const top = path.join(root, appName);
165
+ if (fs.existsSync(top)) return top;
166
+ let dirs = [];
167
+ try { dirs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== '__MACOSX'); } catch (_) { return null; }
168
+ const found = dirs.map((d) => path.join(root, d.name, appName)).filter((p) => fs.existsSync(p));
169
+ if (found.length > 1) throw new Error(`the archive holds ${found.length} copies of ${appName}; nothing was installed`);
170
+ return found[0] || null;
171
+ }
172
+
121
173
  async function installApp(product, release, { work, report = () => {}, replace = false, home = os.homedir() }) {
122
174
  const target = catalog.appPath(product);
123
175
  if (fs.existsSync(target) && !replace) throw new Error(`${target} already exists`);
@@ -157,13 +209,13 @@ async function installApp(product, release, { work, report = () => {}, replace =
157
209
  fs.mkdirSync(mount);
158
210
  const a = run('hdiutil', ['attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mount, file], { timeout: 300000 });
159
211
  if (a.code !== 0) { mount = null; throw new Error(`hdiutil attach: ${(a.err || a.out).trim()}`); }
160
- source = path.join(mount, product.app);
212
+ source = findBundle(mount, product.app);
161
213
  } else {
162
214
  const x = run('ditto', ['-x', '-k', file, unpacked], { timeout: 600000 });
163
215
  if (x.code !== 0) throw new Error(`ditto -x: ${(x.err || x.out).trim()}`);
164
- source = path.join(unpacked, product.app);
216
+ source = findBundle(unpacked, product.app);
165
217
  }
166
- if (!fs.existsSync(source)) throw new Error(`${assetName} holds no ${product.app}`);
218
+ if (!source) throw new Error(`${assetName} holds no ${product.app}`);
167
219
  const app = verifyApp(source);
168
220
  for (const c of app.checks) report({ kind: 'verify', detail: `${product.app}: ${c.check} — ${c.detail}`, ok: c.ok });
169
221
  if (!app.ok) throw new Error(`${product.app} failed ${app.checks.filter((c) => !c.ok).map((c) => c.check).join(', ')}; nothing was installed`);
@@ -209,4 +261,4 @@ async function installApp(product, release, { work, report = () => {}, replace =
209
261
  }
210
262
  }
211
263
 
212
- module.exports = { latestRelease, installApp, verifyApp, appVersion, isRunning, freeBytes, run, download, API };
264
+ module.exports = { findBundle, latestRelease, releaseAt, installApp, verifyApp, appVersion, isRunning, freeBytes, run, download, API };
package/lib/setup.js CHANGED
@@ -18,6 +18,19 @@ const L = require('./launcher');
18
18
  const catalog = require('./catalog');
19
19
  const releases = require('./releases');
20
20
  const D = require('./doctor');
21
+ const trains = require('./trains');
22
+
23
+ /** `setup --train <name>` remembers the train for later runs and for doctor; `--train none` forgets it. */
24
+ function saveTrain(home, id) {
25
+ const file = L.paths(home).stateFile;
26
+ let state = {};
27
+ try { state = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
28
+ if (id) state.train = id; else delete state.train;
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ const tmp = `${file}.${process.pid}.tmp`;
31
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
32
+ fs.renameSync(tmp, file);
33
+ }
21
34
 
22
35
  function prereqMissing(product) {
23
36
  const missing = [];
@@ -29,7 +42,22 @@ function prereqMissing(product) {
29
42
  }
30
43
 
31
44
  /** 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 }) {
45
+ /**
46
+ * The pin a train sets for `pr`, or null for the latest release. An installed app newer than its
47
+ * pin is never taken back (its data may not open in an older release), so its reinstall (a damaged
48
+ * signature, PC-89) takes the latest release instead.
49
+ */
50
+ function pinFor(pr, train) {
51
+ const pin = train && train.apps[pr.id];
52
+ if (!pin) return null;
53
+ if (!fs.existsSync(catalog.appPath(pr))) return pin;
54
+ // A bundle whose version cannot be read may be ahead of the pin: it is reinstalled from the latest.
55
+ const installed = D.normVersion(releases.appVersion(catalog.appPath(pr)));
56
+ return !installed || D.newer(installed, pin) ? null : pin;
57
+ }
58
+
59
+ function plan(checks, { platform, withIds = null, withoutIds = [], noApps = false, repairOnly = false, train = null }) {
60
+ const from = (pr) => (pinFor(pr, train) ? `its release ${train.apps[pr.id]}, pinned by release train ${train.id}` : 'its latest signed release');
33
61
  const items = [];
34
62
  if (platform === 'darwin' && !noApps && !repairOnly) {
35
63
  for (const pr of catalog.PRODUCTS) {
@@ -39,7 +67,7 @@ function plan(checks, { platform, withIds = null, withoutIds = [], noApps = fals
39
67
  const explicit = !!(withIds && withIds.includes(pr.id));
40
68
  items.push({
41
69
  kind: 'install', product: pr.id, selected: explicit || !missing.length,
42
- detail: `install ${pr.name}${pr.preview ? ' (early preview)' : ''} from its latest signed release`,
70
+ detail: `install ${pr.name}${pr.preview ? ' (early preview)' : ''} from ${from(pr)}`,
43
71
  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
72
  });
45
73
  }
@@ -57,7 +85,7 @@ function plan(checks, { platform, withIds = null, withoutIds = [], noApps = fals
57
85
  items.push({
58
86
  key, kind: r.kind, product: r.product || null, label: r.label || null, selected: true, because: [c.id],
59
87
  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)`
88
+ : r.kind === 'replace' ? `reinstall ${pr.name} from ${from(pr)} (the current one goes to the Trash)`
61
89
  : `start the ${r.label} service through launchd`,
62
90
  });
63
91
  }
@@ -72,13 +100,19 @@ const yesNo = async (ask, question, def) => {
72
100
  async function setup({
73
101
  home = os.homedir(), yes = false, withIds = null, withoutIds = [], noApps = false, noOpen = false, dryRun = false,
74
102
  repairOnly = false, online = true, ask = null, log = () => {}, platform = process.env.PASSIONCODE_PLATFORM || process.platform,
103
+ train: trainName, // undefined: the saved one; null: none; a name: that train (PC-89)
75
104
  } = {}) {
76
105
  const claude = L.claudeRunner();
77
- const before = D.sortChecks(await D.collect({ home, platform, claude, online }));
106
+ if (trainName) trains.load(trainName); // refused before anything is checked or changed
107
+ const before = D.sortChecks(await D.collect({ home, platform, claude, online, train: trainName }));
108
+ const followed = D.trainOf(home, trainName);
109
+ const train = followed.train instanceof Error ? null : followed.train; // an unusable one is doctor's fail row
78
110
  const fresh = !before.some((c) => c.id === 'skills.installed' && c.state !== 'fail')
79
111
  // An app doctor found (ok or warn: on Windows from the installer's records, PC-86) makes it existing.
80
112
  && !before.some((c) => /^app\.[^.]+$/.test(c.id) && (c.state === 'ok' || c.state === 'warn'));
81
- const items = plan(before, { platform, withIds, withoutIds, noApps, repairOnly });
113
+ // A saved train this launcher cannot use touches no app: neither its pins nor the latest releases,
114
+ // until the person names a train or `none` (the `train` row's fix).
115
+ const items = plan(before, { platform, withIds, withoutIds, noApps: noApps || followed.train instanceof Error, repairOnly, train });
82
116
  const result = {
83
117
  version: D.PKG, platform, mode: fresh ? 'new' : 'existing', at: new Date().toISOString(), plan: items,
84
118
  actions: [], before: { checks: before, summary: D.summarise(before) }, after: null, humanSteps: [], planOnly: false,
@@ -87,10 +121,20 @@ async function setup({
87
121
 
88
122
  // Ask: one question per absent app, one for the repairs. Without a terminal and without --yes,
89
123
  // nothing changes: the plan is the answer.
90
- if (dryRun) { result.planOnly = true; return finish(result, home, before); }
124
+ // A train named again is no change: only a different one waits for agreement.
125
+ const changesTrain = trainName !== undefined && trainName !== D.trainOf(home).id;
126
+ if (dryRun) { result.planOnly = true; if (changesTrain) result.trainNotSaved = trainName || 'none'; return finish(result, home, before); }
127
+ let asked = false;
128
+ const offeredYes = items.filter((i) => i.selected); // the offers a person declines by answering no
91
129
  if (!yes) {
92
- if (!ask) { result.planOnly = items.some((i) => i.selected); if (result.planOnly) return finish(result, home, before); }
130
+ // A train named without --yes and without a terminal is a change nobody agreed to: plan only.
131
+ if (!ask) {
132
+ result.planOnly = items.some((i) => i.selected) || changesTrain;
133
+ if (changesTrain) result.trainNotSaved = trainName || 'none';
134
+ if (result.planOnly) return finish(result, home, before);
135
+ }
93
136
  else {
137
+ asked = true;
94
138
  for (const i of items.filter((x) => x.kind === 'install')) {
95
139
  const pr = catalog.byId(i.product);
96
140
  i.selected = await yesNo(ask, `Install ${pr.name}${pr.preview ? ' (early preview)' : ''}${i.note ? ` — ${i.note}` : ''}?`, i.selected);
@@ -103,6 +147,25 @@ async function setup({
103
147
  }
104
148
  }
105
149
 
150
+ // The train is saved by a run the person agreed to: --yes, or answers in a terminal. Without
151
+ // either nothing changes, whatever the plan. A person who answered no to every offer that defaulted
152
+ // to yes declined a named train too; forgetting one (`none`) touches no app, so any answer saves
153
+ // it: it is the way out of a saved train this launcher cannot use.
154
+ const declinedAll = asked && offeredYes.length > 0 && !items.some((i) => i.selected);
155
+ // A named train declined with everything else changes nothing; its checks (made for that train)
156
+ // are not saved as this machine's state either.
157
+ if (trainName && declinedAll && changesTrain) { result.planOnly = true; result.trainNotSaved = trainName; return finish(result, home, before); }
158
+ if (changesTrain) { // the train already saved is left as it is: nothing to write
159
+ const was = D.trainOf(home).id;
160
+ saveTrain(home, trainName);
161
+ // A change of train is a change of this machine, reported like any other action.
162
+ if (was !== trainName) {
163
+ const detail = trainName ? `saved release train ${trainName}${was ? ` (it was ${was})` : ''}` : `forgot release train ${was}; apps follow their latest releases`;
164
+ result.actions.push({ kind: 'train', detail, product: null, outcome: 'done' });
165
+ log(`${'train'.padEnd(9)} ${detail}`);
166
+ }
167
+ }
168
+
106
169
  const act = (kind, detail, product = null) => {
107
170
  const a = { kind, detail, product, outcome: 'done' };
108
171
  result.actions.push(a);
@@ -118,7 +181,9 @@ async function setup({
118
181
  const pr = catalog.byId(i.product);
119
182
  const a = act(i.kind, i.detail, pr.id);
120
183
  try {
121
- const release = await releases.latestRelease(pr, { cacheFile: path.join(L.paths(home).state, 'releases-cache.json') });
184
+ const cacheFile = path.join(L.paths(home).state, 'releases-cache.json');
185
+ const pin = pinFor(pr, train);
186
+ const release = pin ? await releases.releaseAt(pr, pin, { cacheFile }) : await releases.latestRelease(pr, { cacheFile });
122
187
  const done = await releases.installApp(pr, release, {
123
188
  work: path.join(L.paths(home).state, 'downloads', `${pr.id}-${release.version}`), replace: i.kind === 'replace', home,
124
189
  report: (st) => log(` ${st.ok === false ? 'FAILED' : 'ok'} ${st.detail}`),
@@ -168,8 +233,9 @@ async function setup({
168
233
  const anyFailed = result.actions.some((x) => x.outcome === 'failed');
169
234
  for (const c of after) {
170
235
  if (!(c.state === 'fail' || c.state === 'warn') || !c.fix) continue;
171
- if (c.fix === `${D.NPX} setup` && !anyFailed && !result.planOnly) continue; // this run was that command
172
- push(c.fix);
236
+ if (c.fix === `${D.NPX} setup` && !anyFailed && !result.planOnly && !noApps) continue; // this run was that command
237
+ const train = 'first make the release train usable: '; // the train row names the same step
238
+ push(c.fix.startsWith(train) ? c.fix.slice(train.length) : c.fix);
173
239
  }
174
240
  for (const i of items.filter((x) => x.kind === 'install' && !x.selected)) {
175
241
  const pr = catalog.byId(i.product);
@@ -201,6 +267,7 @@ function render(result, { repairOnly = false } = {}) {
201
267
  if (result.planOnly) {
202
268
  lines.push('', todo.length ? 'Plan (nothing was changed):' : 'Nothing to do.');
203
269
  for (const i of result.plan) lines.push(` ${i.selected ? '•' : '–'} ${i.detail}${i.note ? ` (${i.note})` : ''}`);
270
+ if (result.trainNotSaved) lines.push('', `The release train (${result.trainNotSaved}) was not saved: run it with --yes, or answer in a terminal.`);
204
271
  if (todo.length) lines.push('', 'Run it with --yes to apply, or in a terminal to answer one question per app.');
205
272
  } else if (!result.actions.length) {
206
273
  lines.push('', 'Everything this command can set up is in place; nothing was changed.');
package/lib/trains.js ADDED
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ /**
3
+ * Release trains (PC-89, organization R1 SD-4 O-2): one set of pinned app versions that every
4
+ * machine of an organization installs, instead of each app's latest release. A train is a file in
5
+ * `trains/<id>.json` shipped with the launcher; the skills are pinned by the launcher release itself
6
+ * (`family.json`). The train's record (who pins what, and when) lives in fabric-workspace.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const catalog = require('./catalog');
11
+
12
+ const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
13
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
14
+ const dir = () => process.env.PASSIONCODE_TRAINS || path.join(__dirname, '..', 'trains');
15
+
16
+ /** The train named `id`, validated, or an Error saying why it cannot be used. */
17
+ function load(id) {
18
+ if (typeof id !== 'string' || !ID.test(id)) throw new Error(`"${id}" is not a release train name`);
19
+ let doc;
20
+ try { doc = JSON.parse(fs.readFileSync(path.join(dir(), `${id}.json`), 'utf8')); } catch (error) {
21
+ if (error.code === 'ENOENT') throw new Error(`no release train "${id}" in this launcher (known: ${list().join(', ') || 'none'})`);
22
+ throw new Error(`release train "${id}" cannot be read: ${error.message}`);
23
+ }
24
+ if (doc.train !== id) throw new Error(`release train file ${id}.json names "${doc.train}"`);
25
+ const apps = doc.apps && typeof doc.apps === 'object' ? doc.apps : null;
26
+ if (!apps || !Object.keys(apps).length) throw new Error(`release train "${id}" pins no app`);
27
+ for (const [app, version] of Object.entries(apps)) {
28
+ if (!catalog.byId(app)) throw new Error(`release train "${id}" pins an unknown app "${app}"`);
29
+ if (typeof version !== 'string' || !VERSION.test(version)) throw new Error(`release train "${id}" pins ${app} at "${version}", not a version`);
30
+ }
31
+ return { id, title: doc.title || id, pinned: doc.pinned || null, record: doc.record || null, apps: { ...apps } };
32
+ }
33
+
34
+ function list() {
35
+ try { return fs.readdirSync(dir()).filter((n) => n.endsWith('.json')).map((n) => n.slice(0, -5)).sort(); } catch (_) { return []; }
36
+ }
37
+
38
+ module.exports = { load, list };