@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 +32 -0
- package/README.md +13 -0
- package/SECURITY.md +3 -0
- package/bin/passioncode.js +12 -4
- package/lib/doctor.js +118 -16
- package/lib/releases.js +59 -7
- package/lib/setup.js +101 -16
- package/lib/trains.js +38 -0
- package/package.json +2 -1
- package/payload/.claude-plugin/marketplace.json +1 -1
- package/payload/manifest.json +4 -4
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/passioncode/hooks/session-start.js +3 -1
- package/payload/plugins/passioncode/skills/setting-up-passioncode/SKILL.md +4 -0
- package/trains/organization-r1.json +13 -0
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
|
package/bin/passioncode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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[
|
|
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
|
|
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 =
|
|
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 =
|
|
216
|
+
source = findBundle(unpacked, product.app);
|
|
165
217
|
}
|
|
166
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/payload/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"family": "passioncode",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
65
|
+
"ref": "v0.2.52",
|
|
66
66
|
"commit": null,
|
|
67
|
-
"version": "0.2.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|