@passioncode-ai/passioncode 0.2.46 → 0.2.47

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.47 - 2026-10-28
4
+
5
+ ### Fixed
6
+
7
+ - **`update` on Windows moves `current` to the new release** (PC-85, the Windows acceptance of 2026-10-28): Windows
8
+ refuses a rename over a directory link (`EPERM ... rename 'current.<pid>.lnk' -> 'current'`), so an update stopped
9
+ halfway, kept the old release and left a `current.<pid>.lnk` per attempt. There the old link is now removed first
10
+ (the link itself, never what it points at) and put back if the rename fails again; the temporary link never outlives
11
+ the call, and the ones a failed update left are removed by the next one.
12
+ - **`doctor` checks the apps on Windows** (PC-86): it reads each app's uninstall entry (`reg query`, the name and
13
+ version its installer wrote) or finds it in `%LOCALAPPDATA%\Programs\<name>`, `%LOCALAPPDATA%\<name>` or Program
14
+ Files, and says which version is installed and whether a newer one is out, instead of "app checks run on macOS".
15
+ Setup still installs apps on macOS only.
16
+
3
17
  ## 0.2.46 - 2026-10-28
4
18
 
5
19
  ### Changed
package/README.md CHANGED
@@ -47,7 +47,7 @@ secret into a product's own hidden prompt, starting Docker for Fabric, and resta
47
47
  one question per app; `--yes` accepts the plan, `--with`/`--without <ids>` choose apps
48
48
  (`switchboard`, `dashboards`, `observatory`, `inbox`, `fabric`), `--no-apps` sets up the skills
49
49
  and MCP only, `--dry-run` shows the plan. Apps are installed on macOS; on other systems setup
50
- names each download page. Skills only: `npx @passioncode-ai/passioncode@latest update`.
50
+ names each download page, and on Windows `doctor` reads which version of each app is installed. Skills only: `npx @passioncode-ai/passioncode@latest update`.
51
51
  2. **Configure:** nothing to set, no account and no key. Background updates are on by default;
52
52
  `npx @passioncode-ai/passioncode@latest config set update.auto off` turns them off. Each app's
53
53
  own onboarding (sign-ins, Fabric Inbox's server, Observatory's projects) is listed by `setup`.
package/SECURITY.md CHANGED
@@ -32,7 +32,8 @@ returns a nonzero exit code. See the [CLI recovery contract](docs/reference/cli.
32
32
  `doctor` reads: `~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`,
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
- files), the apps' `Info.plist`, and the GitHub releases API. It starts each registered stdio MCP
35
+ files), the apps' `Info.plist` (on Windows, the apps' uninstall entries through `reg query` and their install folders),
36
+ and the GitHub releases API. It starts each registered stdio MCP
36
37
  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
37
38
  release cache.
38
39
 
package/lib/doctor.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * `passioncode doctor`: reads the whole PassionCode.ai setup of this machine and names, for every
4
4
  * check, its state, the exact fix and what setup may repair (ADR-0004). It changes nothing: the only
5
5
  * processes it starts are the registered stdio MCP servers for one proving call each (never Fabric's,
6
- * which enrols a session when it starts), stopped when the call returns. `doctor --fix` is setup's
6
+ * which enrols a session when it starts), stopped when the call returns, and on Windows three
7
+ * read-only `reg query` calls for the apps' uninstall entries (PC-86). `doctor --fix` is setup's
7
8
  * repair half (lib/setup.js), not this module.
8
9
  */
9
10
  const fs = require('fs');
@@ -12,6 +13,7 @@ const path = require('path');
12
13
  const L = require('./launcher');
13
14
  const catalog = require('./catalog');
14
15
  const releases = require('./releases');
16
+ const W = require('./windows-apps');
15
17
  const { probe } = require('./mcp-probe');
16
18
 
17
19
  const PKG = (() => { try { return require('../package.json').version; } catch (_) { return '0.0.0'; } })();
@@ -123,12 +125,32 @@ async function collect({ home, platform, claude, online }) {
123
125
  const cacheFile = path.join(p.state, 'releases-cache.json');
124
126
  const probes = new Map();
125
127
  let docker = null;
128
+ let windowsEntries = null; // the uninstall entries, read once (three `reg query` calls)
126
129
  for (const pr of catalog.PRODUCTS) {
127
130
  const at = catalog.appPath(pr);
128
131
  const installed = platform === 'darwin' && fs.existsSync(at);
129
132
  const label = `${pr.name}${pr.preview ? ' (early preview)' : ''}`;
130
- if (platform !== 'darwin') {
131
- add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: app checks run on macOS; on this system download it from ${pr.page}` });
133
+ if (platform === 'win32') {
134
+ // PC-86: presence and version from the installer's own records; setup installs nothing here.
135
+ if (!windowsEntries) {
136
+ windowsEntries = W.readUninstallEntries(releases.run);
137
+ for (const u of windowsEntries.unread) add({ id: `apps.registry.${u.hive.split('\\')[0].toLowerCase()}${u.hive.includes('WOW6432Node') ? '-wow64' : ''}`, area: 'apps', state: 'warn', detail: `could not read ${u.hive} (${u.why}); an app installed for every user may show as not installed` });
138
+ }
139
+ const found = W.findWindowsApp(pr, { entries: windowsEntries.entries, home });
140
+ 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}` });
142
+ } else {
143
+ let latest = null, latestErr = null;
144
+ 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);
146
+ 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)'}`,
149
+ fix: outdated ? `install ${latest.version} from ${pr.page} (or accept the update ${pr.name} offers)` : null,
150
+ });
151
+ }
152
+ } else if (platform !== 'darwin') {
153
+ add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: app checks run on macOS and Windows; on this system download it from ${pr.page}` });
132
154
  } else if (!installed) {
133
155
  add({ id: `app.${pr.id}`, area: 'apps', product: pr.id, state: 'info', detail: `${label}: not installed`, fix: `${NPX} setup --with ${pr.id}` });
134
156
  } else {
package/lib/launcher.js CHANGED
@@ -65,13 +65,42 @@ function readState(file, { tolerant = false } = {}) {
65
65
  }
66
66
  }
67
67
 
68
- /** Replace a symlink atomically (create beside it, rename over it). */
69
- function symlinkAtomic(target, at) {
68
+ /**
69
+ * Replace a symlink atomically (create beside it, rename over it). Windows refuses any rename over
70
+ * a directory link (EPERM, PC-85), so there the old link is removed first: unlink takes the link
71
+ * itself, never what it points at, and the old link is put back if the rename fails again. The
72
+ * temporary link never outlives the call, and the ones an earlier failed update left are removed.
73
+ * On Windows the swap is therefore not atomic: between the unlink and the rename `at` is absent,
74
+ * and a run killed there leaves no `current` until the next update (status reports it missing).
75
+ */
76
+ function symlinkAtomic(target, at, { platform = process.platform, rename = fs.renameSync, unlink = fs.unlinkSync } = {}) {
70
77
  fs.mkdirSync(path.dirname(at), { recursive: true });
71
78
  const tmp = `${at}.${process.pid}.lnk`;
72
- try { fs.unlinkSync(tmp); } catch (_) { /* none */ }
79
+ const dropTmp = () => { try { fs.unlinkSync(tmp); } catch (_) { /* none */ } };
80
+ dropTmp();
73
81
  fs.symlinkSync(target, tmp);
74
- fs.renameSync(tmp, at);
82
+ try {
83
+ rename(tmp, at);
84
+ } catch (error) {
85
+ const old = lstat(at);
86
+ if (platform !== 'win32' || !['EPERM', 'EACCES', 'EEXIST'].includes(error.code) || !old || !old.isSymbolicLink()) { dropTmp(); throw error; }
87
+ const previous = linkTarget(at);
88
+ try { unlink(at); } catch (unlinkError) { dropTmp(); throw unlinkError; }
89
+ try { rename(tmp, at); } catch (again) {
90
+ dropTmp();
91
+ try { fs.symlinkSync(previous, at); } catch (back) { throw new Error(`${again.message}; and putting back ${at} → ${previous} failed (${back.message})`); }
92
+ throw again;
93
+ }
94
+ }
95
+ if (platform !== 'win32') return; // only a refused rename on Windows ever left one (PC-85)
96
+ const base = path.basename(at);
97
+ let siblings = [];
98
+ try { siblings = fs.readdirSync(path.dirname(at)); } catch (_) { /* nothing to sweep */ }
99
+ for (const name of siblings) {
100
+ if (!name.startsWith(`${base}.`) || !/^\.\d+\.lnk$/.test(name.slice(base.length))) continue;
101
+ const left = path.join(path.dirname(at), name);
102
+ if (lstat(left)?.isSymbolicLink()) { try { fs.unlinkSync(left); } catch (_) { /* the next update sweeps it */ } }
103
+ }
75
104
  }
76
105
 
77
106
  // --- one update at a time (lifecycle contract LC-03) ---------------------------------------
@@ -854,5 +883,5 @@ module.exports = {
854
883
  restore: (opts = {}) => withUpdateLock(opts.home, () => restore(opts)),
855
884
  uninstall: (opts = {}) => withUpdateLock(opts.home, () => uninstall(opts)),
856
885
  status, setConfig, paths, CHANNELS, MARKETPLACE, payloadManifest, marketplaceAddArg,
857
- acquireUpdateLock, UpdateRunning, staleReleases, claudeRunner, bundledMcpServers, mcpFileTargets, readSettings, tomlTableRange, tomlTableCommand, tomlTableArgs, tomlMentions,
886
+ acquireUpdateLock, UpdateRunning, staleReleases, symlinkAtomic, claudeRunner, bundledMcpServers, mcpFileTargets, readSettings, tomlTableRange, tomlTableCommand, tomlTableArgs, tomlMentions,
858
887
  };
package/lib/releases.js CHANGED
@@ -16,9 +16,10 @@ const catalog = require('./catalog');
16
16
  const API = () => (process.env.PASSIONCODE_GITHUB_API || 'https://api.github.com').replace(/\/$/, '');
17
17
  const CACHE_TTL_MS = 30 * 60 * 1000;
18
18
 
19
- function run(cmd, args, { timeout = 60000, input } = {}) {
20
- const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, input });
19
+ function run(cmd, args, { timeout = 60000, input, maxBuffer = 1024 * 1024 } = {}) {
20
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, input, maxBuffer });
21
21
  if (r.error && r.error.code === 'ENOENT') return { code: 127, out: '', err: `${cmd} not found` };
22
+ if (r.error && r.error.code === 'ENOBUFS') return { code: 1, out: '', err: `${cmd} wrote more than ${maxBuffer} bytes` };
22
23
  if (r.error && r.error.code === 'ETIMEDOUT') return { code: 124, out: r.stdout || '', err: `${cmd} timed out after ${Math.round(timeout / 1000)} s` };
23
24
  return { code: r.status === null ? 1 : r.status, out: r.stdout || '', err: r.stderr || '' };
24
25
  }
package/lib/setup.js CHANGED
@@ -76,7 +76,8 @@ async function setup({
76
76
  const claude = L.claudeRunner();
77
77
  const before = D.sortChecks(await D.collect({ home, platform, claude, online }));
78
78
  const fresh = !before.some((c) => c.id === 'skills.installed' && c.state !== 'fail')
79
- && !catalog.PRODUCTS.some((pr) => platform === 'darwin' && fs.existsSync(catalog.appPath(pr)));
79
+ // An app doctor found (ok or warn: on Windows from the installer's records, PC-86) makes it existing.
80
+ && !before.some((c) => /^app\.[^.]+$/.test(c.id) && (c.state === 'ok' || c.state === 'warn'));
80
81
  const items = plan(before, { platform, withIds, withoutIds, noApps, repairOnly });
81
82
  const result = {
82
83
  version: D.PKG, platform, mode: fresh ? 'new' : 'existing', at: new Date().toISOString(), plan: items,
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ /**
3
+ * Where a PassionCode.ai app is installed on Windows and which version it is, read without
4
+ * starting it (PC-86): the uninstall entry its installer wrote (electron-builder and Tauri both
5
+ * write DisplayName and DisplayVersion; Tauri also InstallLocation), else the folder a per-user
6
+ * installer uses (%LOCALAPPDATA%\Programs\<name> for electron-builder, %LOCALAPPDATA%\<name> for
7
+ * Tauri) or a per-machine one under Program Files, with the program in it.
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const HIVES = [
13
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
14
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
15
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
16
+ ];
17
+
18
+ /** `reg query <hive> /s` output → [{ key, DisplayName, DisplayVersion, InstallLocation, UninstallString }]. */
19
+ function parseUninstall(text) {
20
+ const entries = [];
21
+ let entry = null;
22
+ for (const line of String(text || '').split(/\r?\n/)) {
23
+ if (/^HKEY_/.test(line)) { entry = { key: line.trim() }; entries.push(entry); continue; }
24
+ const m = /^\s+(\S+)\s+REG_(?:SZ|EXPAND_SZ)\s+(.*)$/.exec(line);
25
+ if (m && entry) entry[m[1]] = m[2].trim();
26
+ }
27
+ return entries.filter((e) => e.DisplayName);
28
+ }
29
+
30
+ /**
31
+ * Every uninstall entry on this machine, and the hives `reg` could not read (a missing hive is
32
+ * normal: HKLM's WOW6432Node exists only on 64-bit Windows), so a reader can say an app it did not
33
+ * find may still be there.
34
+ */
35
+ function readUninstallEntries(run) {
36
+ const entries = [], unread = [];
37
+ for (const hive of HIVES) {
38
+ const r = run('reg', ['query', hive, '/s'], { timeout: 15000, maxBuffer: 32 * 1024 * 1024 });
39
+ if (r.code === 0) entries.push(...parseUninstall(r.out));
40
+ else if (!/unable to find/i.test(`${r.err || ''}${r.out || ''}`)) unread.push({ hive, why: (r.err || r.out || `exit ${r.code}`).trim().split(/\r?\n/)[0] });
41
+ }
42
+ return { entries, unread };
43
+ }
44
+
45
+ /** electron-builder names its entry "<name> <version>", Tauri "<name>"; "Fabric" never matches "Fabric Inbox". */
46
+ function names(entry, name) {
47
+ return entry.DisplayName === name || (!!entry.DisplayVersion && entry.DisplayName === `${name} ${entry.DisplayVersion}`);
48
+ }
49
+
50
+ /** The install folder an entry names, or null: an MSI entry's `MsiExec.exe /X{...}` names none. */
51
+ function folderOf(entry) {
52
+ if (entry.InstallLocation) return entry.InstallLocation.replace(/^"|"$/g, '').replace(/\\$/, '');
53
+ const m = /^"([^"]+)"|^(\S+\.exe)/i.exec(entry.UninstallString || '');
54
+ const exe = m && (m[1] || m[2]);
55
+ if (!exe || !exe.includes('\\') || /(^|\\)msiexec\.exe$/i.test(exe)) return null;
56
+ return exe.slice(0, exe.lastIndexOf('\\'));
57
+ }
58
+
59
+ function folders(name, { env, home }) {
60
+ const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
61
+ return [path.join(local, 'Programs', name), path.join(local, name), env.ProgramFiles && path.join(env.ProgramFiles, name)].filter(Boolean);
62
+ }
63
+
64
+ function hasProgram(dir) { try { return fs.readdirSync(dir).some((n) => /\.exe$/i.test(n)); } catch (_) { return false; } }
65
+
66
+ /** { at, version, source: 'registry' | 'folder' } or null when the app is not installed. */
67
+ function findWindowsApp(product, { entries, env = process.env, home }) {
68
+ const dir = folders(product.name, { env, home }).find(hasProgram);
69
+ const hit = entries.find((e) => names(e, product.name));
70
+ if (hit) return { at: folderOf(hit) || dir || null, version: hit.DisplayVersion || null, source: 'registry' };
71
+ return dir ? { at: dir, version: null, source: 'folder' } : null;
72
+ }
73
+
74
+ module.exports = { parseUninstall, readUninstallEntries, findWindowsApp, HIVES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
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"
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.2.46",
38
+ "version": "0.2.47",
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.46",
3
+ "version": "0.2.47",
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.46",
65
+ "ref": "v0.2.47",
66
66
  "commit": null,
67
- "version": "0.2.46",
67
+ "version": "0.2.47",
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:26a83f55d769e493d16987ae596bb26288fa03f2b2f81231d22d5298ccd603bc",
75
+ "contentHash": "sha256:4fbc28ab1c059e892eb3998c42946149e777a76d54eca40842bfe66927342ccb",
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.46",
5
+ "version": "0.2.47",
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",