@passioncode-ai/passioncode 0.2.50 → 0.2.52

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.52 - 2026-11-02
4
+
5
+ ### Fixed
6
+
7
+ - `setup` in a terminal, answered "no" to every offer (at least one of them defaulting to yes) and so
8
+ changing nothing, says "You declined every offer; nothing was changed." instead of "Everything this
9
+ command can set up is in place" (or "Nothing to do." when it named a train), and a release train named in that run is reported as not saved without the
10
+ advice to answer in a terminal (PC-91). Forgetting a train with `--train none` is still a change.
11
+ Such a run now lists `setup` among the person's steps when a failing or warning check's own fix
12
+ names it (`setup --train <name>` when the run declined that train).
13
+
14
+ ## 0.2.51 - 2026-10-30
15
+
16
+ ### Fixed
17
+
18
+ - `setup` finds an app one folder down in its zip, as Fabric Switchboard's archive holds it
19
+ (`Fabric-Switchboard-<v>-macos-universal/Fabric Switchboard.app`, 0.6.83 to 0.7.1); before, it
20
+ refused the release with "holds no Fabric Switchboard.app". Two copies in one archive are refused.
21
+ - A release download or a GitHub API read whose connection drops before any answer is tried once
22
+ more after a second, instead of failing the install with "fetch failed".
23
+
24
+ ### Added
25
+
26
+ - Release trains (PC-89, organization R1 N-12): `setup --train organization-r1` installs and
27
+ reinstalls each app at the version the train pins (`trains/organization-r1.json`) instead of its
28
+ latest release, and remembers the train; `doctor` compares every app with its pin, offline too,
29
+ in each `app.<id>` line and one `train` line, on macOS and Windows. An app ahead of its pin is
30
+ reported and never taken back, nor is one whose version cannot be read. `setup --train none` returns to the latest releases. The first
31
+ pins are the releases of 2026-10-28; the train's record in fabric-workspace decides later ones.
32
+ In `setup --json`, a change of train is an action of kind `train`, and a run that named a new
33
+ train but was only a plan carries `trainNotSaved`.
34
+
3
35
  ## 0.2.50 - 2026-10-28
4
36
 
5
37
  ### Changed
package/README.md CHANGED
@@ -112,6 +112,19 @@ 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 shown as a plan. A named
122
+ train is not saved when every offer in the terminal (at least one of them defaulting to yes) is
123
+ answered no, while `--train none` is saved on any answer. The skills are pinned by the launcher release itself (`family.json`). Which
124
+ version each app pins is the train's record in fabric-workspace
125
+ ([SD-4](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/roadmap.md#sd-4)); a
126
+ change to the file is a launcher release.
127
+
115
128
  ## Staying current
116
129
 
117
130
  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/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,34 @@ 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 (at least one of
152
+ // them defaulting 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) {
158
+ result.planOnly = true; result.declined = true; result.trainNotSaved = trainName;
159
+ // Its checks were made for the train it declined: what fixes them is setup with that train.
160
+ result.humanSteps = humanSteps(result, before, items, [], noApps, `${D.NPX} setup --train ${trainName}`);
161
+ return finish(result, home, before);
162
+ }
163
+ if (changesTrain) { // the train already saved is left as it is: nothing to write
164
+ const was = D.trainOf(home).id;
165
+ saveTrain(home, trainName);
166
+ // A change of train is a change of this machine, reported like any other action.
167
+ if (was !== trainName) {
168
+ const detail = trainName ? `saved release train ${trainName}${was ? ` (it was ${was})` : ''}` : `forgot release train ${was}; apps follow their latest releases`;
169
+ result.actions.push({ kind: 'train', detail, product: null, outcome: 'done' });
170
+ log(`${'train'.padEnd(9)} ${detail}`);
171
+ }
172
+ }
173
+
174
+ // Said so in the report: an empty run after "no" to every offer is not a machine with nothing to set
175
+ // up. Forgetting a train (`--train none`) is still a change, so that run is not "declined".
176
+ if (declinedAll && !result.actions.length) result.declined = true;
177
+
106
178
  const act = (kind, detail, product = null) => {
107
179
  const a = { kind, detail, product, outcome: 'done' };
108
180
  result.actions.push(a);
@@ -118,7 +190,9 @@ async function setup({
118
190
  const pr = catalog.byId(i.product);
119
191
  const a = act(i.kind, i.detail, pr.id);
120
192
  try {
121
- const release = await releases.latestRelease(pr, { cacheFile: path.join(L.paths(home).state, 'releases-cache.json') });
193
+ const cacheFile = path.join(L.paths(home).state, 'releases-cache.json');
194
+ const pin = pinFor(pr, train);
195
+ const release = pin ? await releases.releaseAt(pr, pin, { cacheFile }) : await releases.latestRelease(pr, { cacheFile });
122
196
  const done = await releases.installApp(pr, release, {
123
197
  work: path.join(L.paths(home).state, 'downloads', `${pr.id}-${release.version}`), replace: i.kind === 'replace', home,
124
198
  report: (st) => log(` ${st.ok === false ? 'FAILED' : 'ok'} ${st.detail}`),
@@ -161,15 +235,24 @@ async function setup({
161
235
  const after = result.actions.length ? D.sortChecks(await D.collect({ home, platform, claude, online })) : before;
162
236
  result.after = { checks: after, summary: D.summarise(after) };
163
237
 
164
- // The person's steps, once: every remaining problem's fix, the guides of the apps just installed,
165
- // what an install leaves to a person, and a restart when skills or MCP servers changed.
238
+ result.humanSteps = humanSteps(result, after, items, installed, noApps);
239
+ return finish(result, home, after);
240
+ }
241
+
242
+ // The person's steps, once: every remaining problem's fix, the guides of the apps just installed,
243
+ // what an install leaves to a person, and a restart when skills or MCP servers changed.
244
+ function humanSteps(result, after, items, installed, noApps, setupStep = `${D.NPX} setup`) {
166
245
  const steps = [];
167
246
  const push = (t) => { if (t && !steps.includes(t)) steps.push(t); };
168
247
  const anyFailed = result.actions.some((x) => x.outcome === 'failed');
169
248
  for (const c of after) {
170
249
  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);
250
+ if (c.fix === `${D.NPX} setup` && !anyFailed && !result.planOnly && !noApps && !result.declined) continue; // this run was that command (unless it was declined)
251
+ const train = 'first make the release train usable: '; // the train row names the same step
252
+ const fix = c.fix.startsWith(train) ? c.fix.slice(train.length) : c.fix;
253
+ // Every setup a fix names (alone, after "quit X, then run", or with --with) becomes the step's own
254
+ // command; a fix that already names a train is left as written.
255
+ push(fix.includes('--train') ? fix : fix.split(`${D.NPX} setup`).join(setupStep));
173
256
  }
174
257
  for (const i of items.filter((x) => x.kind === 'install' && !x.selected)) {
175
258
  const pr = catalog.byId(i.product);
@@ -179,8 +262,7 @@ async function setup({
179
262
  if (result.actions.some((a) => a.kind === 'update' && a.outcome === 'done' && /[1-9]\d* change/.test(a.detail)) || installed.length) {
180
263
  push('Restart your coding agents: skills and MCP servers load when a session starts.');
181
264
  }
182
- result.humanSteps = steps;
183
- return finish(result, home, after);
265
+ return steps;
184
266
  }
185
267
 
186
268
  function finish(result, home, checks) {
@@ -192,6 +274,8 @@ function finish(result, home, checks) {
192
274
  return result;
193
275
  }
194
276
 
277
+ const DECLINED = 'You declined every offer; nothing was changed. The checks below still stand.';
278
+
195
279
  function render(result, { repairOnly = false } = {}) {
196
280
  const lines = [];
197
281
  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.`, '');
@@ -199,11 +283,12 @@ function render(result, { repairOnly = false } = {}) {
199
283
  lines.push(`${repairOnly ? 'doctor --fix' : 'setup'} · ${result.mode === 'new' ? 'a new setup' : 'an existing setup'} · ${result.platform}`);
200
284
  const todo = result.plan.filter((i) => i.selected);
201
285
  if (result.planOnly) {
202
- lines.push('', todo.length ? 'Plan (nothing was changed):' : 'Nothing to do.');
286
+ lines.push('', todo.length ? 'Plan (nothing was changed):' : result.declined ? DECLINED : 'Nothing to do.');
203
287
  for (const i of result.plan) lines.push(` ${i.selected ? '•' : '–'} ${i.detail}${i.note ? ` (${i.note})` : ''}`);
288
+ if (result.trainNotSaved) lines.push('', `The release train (${result.trainNotSaved}) was not saved${result.declined ? '.' : ': run it with --yes, or answer in a terminal.'}`);
204
289
  if (todo.length) lines.push('', 'Run it with --yes to apply, or in a terminal to answer one question per app.');
205
290
  } else if (!result.actions.length) {
206
- lines.push('', 'Everything this command can set up is in place; nothing was changed.');
291
+ lines.push('', result.declined ? DECLINED : 'Everything this command can set up is in place; nothing was changed.');
207
292
  } else {
208
293
  lines.push('', 'Done:');
209
294
  for (const x of result.actions) lines.push(` ${x.outcome === 'failed' ? 'FAILED' : 'done '} ${x.detail}${x.error ? `\n ${x.error}` : ''}`);
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
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"
@@ -10,6 +10,7 @@
10
10
  "lib",
11
11
  "payload",
12
12
  "family.json",
13
+ "trains",
13
14
  "README.md",
14
15
  "LICENSE",
15
16
  "COMMERCIAL-LICENSE.md",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.2.50",
38
+ "version": "0.2.52",
39
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
@@ -62,9 +62,9 @@
62
62
  "name": "passioncode",
63
63
  "displayName": "PassionCode.ai",
64
64
  "repo": "passioncode-ai/passioncode",
65
- "ref": "v0.2.50",
65
+ "ref": "v0.2.52",
66
66
  "commit": null,
67
- "version": "0.2.50",
67
+ "version": "0.2.52",
68
68
  "via": "this repository",
69
69
  "skills": [
70
70
  "setting-up-passioncode",
@@ -72,7 +72,7 @@
72
72
  ],
73
73
  "legacyPluginIds": [],
74
74
  "legacyMarketplaces": [],
75
- "contentHash": "sha256:62f9cc79be968422f6efddfae912b00e2d8087f03de8ce14528179a46c1e3722",
75
+ "contentHash": "sha256:8b9f7952e681760859099cf84a4f29a0cd264dde45b7287765aac937218b068e",
76
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.",
77
77
  "author": {
78
78
  "name": "PassionCode.ai",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "passioncode",
4
4
  "displayName": "PassionCode.ai",
5
- "version": "0.2.50",
5
+ "version": "0.2.52",
6
6
  "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 at session start that keeps the PassionCode.ai skill set current.",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
@@ -79,7 +79,9 @@ async function main() {
79
79
  if (fails > 0 && !stale) {
80
80
  const ids = Array.isArray(last.failed) ? last.failed.filter((x) => typeof x === 'string').slice(0, 3) : [];
81
81
  const when = typeof last.at === 'string' ? last.at.slice(0, 10) : 'an earlier session';
82
- lines.push(`[passioncode] the setup check of ${when} found ${fails} problem(s)${ids.length ? ` (${ids.join(', ')}${last.failed.length > 3 ? ', …' : ''})` : ''}; \`npx @passioncode-ai/passioncode@latest setup\` repairs them, \`doctor\` shows them.`);
82
+ // A release train this launcher cannot use is not repaired by plain setup (PC-89).
83
+ const how = Array.isArray(last.failed) && last.failed.includes('train') ? 'setup --yes --no-apps --train none` (or a train this launcher ships) clears the unusable release train, `doctor' : 'setup` repairs them, `doctor';
84
+ lines.push(`[passioncode] the setup check of ${when} found ${fails} problem(s)${ids.length ? ` (${ids.join(', ')}${last.failed.length > 3 ? ', …' : ''})` : ''}; \`npx @passioncode-ai/passioncode@latest ${how}\` shows them.`);
83
85
  }
84
86
  if (lines.length) process.stdout.write(lines.join('\n') + '\n');
85
87
  }
@@ -96,6 +96,10 @@ Reply in the person's language, briefly:
96
96
  wait for the network.
97
97
  - **The command fails to start** (npm error, registry down): report the error with its exit code
98
98
  and stop. Do not fall back to manual installs.
99
+ - **An organization's machine**: when the person's organization runs a release train, add
100
+ `--train <name>` (today `organization-r1`): the apps are installed at the train's pinned versions
101
+ and `doctor` then shows a `train` line. An app newer than its pin is left alone. `--train none`
102
+ returns to the latest releases.
99
103
  - **Not macOS**: the result names each app's download page; the person installs apps from there,
100
104
  then runs `setup` again for the MCP servers. On Windows `doctor` reads which apps are installed
101
105
  and their versions; a fix that names a download page is the person's install, not a command.
@@ -0,0 +1,13 @@
1
+ {
2
+ "train": "organization-r1",
3
+ "title": "Organization R1",
4
+ "pinned": "2026-10-28",
5
+ "record": "https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/roadmap.md#sd-4",
6
+ "apps": {
7
+ "fabric": "0.3.15",
8
+ "switchboard": "0.7.0",
9
+ "dashboards": "0.8.15",
10
+ "observatory": "0.30.0",
11
+ "inbox": "0.77.3"
12
+ }
13
+ }