create-principles-disciple 1.132.2 → 1.132.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/codex-adapter/dist/codec/index.d.ts +6 -0
  2. package/codex-adapter/dist/codec/index.js +5 -0
  3. package/codex-adapter/dist/codec/input-decoder.d.ts +13 -0
  4. package/codex-adapter/dist/codec/input-decoder.js +103 -0
  5. package/codex-adapter/dist/codec/output-encoder.d.ts +32 -0
  6. package/codex-adapter/dist/codec/output-encoder.js +51 -0
  7. package/codex-adapter/dist/host-adapter.d.ts +30 -0
  8. package/codex-adapter/dist/host-adapter.js +28 -0
  9. package/codex-adapter/dist/index.d.ts +25 -0
  10. package/codex-adapter/dist/index.js +19 -0
  11. package/codex-adapter/dist/ingestion/admission.d.ts +54 -0
  12. package/codex-adapter/dist/ingestion/admission.js +137 -0
  13. package/codex-adapter/dist/ingestion/catch-up.d.ts +41 -0
  14. package/codex-adapter/dist/ingestion/catch-up.js +94 -0
  15. package/codex-adapter/dist/ingestion/codex-home.d.ts +19 -0
  16. package/codex-adapter/dist/ingestion/codex-home.js +43 -0
  17. package/codex-adapter/dist/ingestion/codex-version.d.ts +30 -0
  18. package/codex-adapter/dist/ingestion/codex-version.js +53 -0
  19. package/codex-adapter/dist/ingestion/ingestion-fields.d.ts +17 -0
  20. package/codex-adapter/dist/ingestion/ingestion-fields.js +28 -0
  21. package/codex-adapter/dist/ingestion/ingestion.d.ts +71 -0
  22. package/codex-adapter/dist/ingestion/ingestion.js +248 -0
  23. package/codex-adapter/dist/ingestion/transcript-decoder.d.ts +72 -0
  24. package/codex-adapter/dist/ingestion/transcript-decoder.js +485 -0
  25. package/codex-adapter/dist/ingestion/transcript-locate.d.ts +13 -0
  26. package/codex-adapter/dist/ingestion/transcript-locate.js +75 -0
  27. package/codex-adapter/dist/ingestion/transcript-path.d.ts +25 -0
  28. package/codex-adapter/dist/ingestion/transcript-path.js +87 -0
  29. package/codex-adapter/dist/pd-hook.d.ts +9 -0
  30. package/codex-adapter/dist/pd-hook.js +159 -0
  31. package/codex-adapter/dist/tool-semantics.d.ts +20 -0
  32. package/codex-adapter/dist/tool-semantics.js +27 -0
  33. package/codex-adapter/dist/worker/workspace-worker.d.ts +59 -0
  34. package/codex-adapter/dist/worker/workspace-worker.js +270 -0
  35. package/codex-adapter/package.json +52 -0
  36. package/console/dist/server/routes/update.js +187 -19
  37. package/console/dist/server/update/mutation-controller.d.ts +18 -0
  38. package/console/dist/server/update/mutation-controller.js +29 -0
  39. package/console/dist/server/utils/installed-layout.d.ts +1 -0
  40. package/console/dist/server/utils/installed-layout.js +2 -0
  41. package/console/package.json +1 -0
  42. package/core/dist/runtime-v2/__tests__/architecture-regression.test.js +15 -0
  43. package/core/dist/runtime-v2/__tests__/architecture-regression.test.js.map +1 -1
  44. package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.d.ts +2 -0
  45. package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.d.ts.map +1 -0
  46. package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.js +71 -0
  47. package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.js.map +1 -0
  48. package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.d.ts +2 -0
  49. package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.d.ts.map +1 -0
  50. package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.js +219 -0
  51. package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.js.map +1 -0
  52. package/core/dist/runtime-v2/adapter/artificer-l2-adapter.d.ts.map +1 -1
  53. package/core/dist/runtime-v2/adapter/artificer-l2-adapter.js +2 -3
  54. package/core/dist/runtime-v2/adapter/artificer-l2-adapter.js.map +1 -1
  55. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.d.ts +12 -1
  56. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.d.ts.map +1 -1
  57. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.js +15 -2
  58. package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.js.map +1 -1
  59. package/core/dist/runtime-v2/adapter/pi-ai-http-transport.d.ts +50 -0
  60. package/core/dist/runtime-v2/adapter/pi-ai-http-transport.d.ts.map +1 -0
  61. package/core/dist/runtime-v2/adapter/pi-ai-http-transport.js +93 -0
  62. package/core/dist/runtime-v2/adapter/pi-ai-http-transport.js.map +1 -0
  63. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.d.ts.map +1 -1
  64. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.js +5 -0
  65. package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.js.map +1 -1
  66. package/core/dist/runtime-v2/internalization/__tests__/owner-decision.test.js +152 -0
  67. package/core/dist/runtime-v2/internalization/__tests__/owner-decision.test.js.map +1 -1
  68. package/core/dist/runtime-v2/internalization/owner-review.d.ts +7 -1
  69. package/core/dist/runtime-v2/internalization/owner-review.d.ts.map +1 -1
  70. package/core/dist/runtime-v2/internalization/owner-review.js +22 -3
  71. package/core/dist/runtime-v2/internalization/owner-review.js.map +1 -1
  72. package/core/package.json +3 -2
  73. package/dist/installer.d.ts +32 -0
  74. package/dist/installer.d.ts.map +1 -1
  75. package/dist/installer.js +198 -2
  76. package/dist/installer.js.map +1 -1
  77. package/dist/installers/codex-host-installer.d.ts.map +1 -1
  78. package/dist/installers/codex-host-installer.js +5 -3
  79. package/dist/installers/codex-host-installer.js.map +1 -1
  80. package/dist/update/release-manager-authority.d.ts +69 -0
  81. package/dist/update/release-manager-authority.d.ts.map +1 -0
  82. package/dist/update/release-manager-authority.js +125 -0
  83. package/dist/update/release-manager-authority.js.map +1 -0
  84. package/dist/utils/workspace-divergence.d.ts +36 -0
  85. package/dist/utils/workspace-divergence.d.ts.map +1 -0
  86. package/dist/utils/workspace-divergence.js +87 -0
  87. package/dist/utils/workspace-divergence.js.map +1 -0
  88. package/install-layout/dist/index.d.ts +5 -0
  89. package/install-layout/dist/index.js +1 -0
  90. package/install-layout/package.json +1 -1
  91. package/package.json +4 -2
  92. package/plugin/dist/bundle.js +870 -734
  93. package/plugin/dist/governance-audit.js +381 -247
  94. package/plugin/dist/openclaw-sdk.d.ts +3 -0
  95. package/plugin/dist/rulehost-evidence.js +420 -286
  96. package/plugin/dist/service/subagent-workflow/types.d.ts +2 -2
  97. package/plugin/dist/utils/retry.d.ts +1 -1
  98. package/plugin/dist/utils/workspace-resolver.d.ts +35 -4
  99. package/plugin/openclaw.plugin.json +1 -1
  100. package/plugin/package.json +1 -1
  101. package/release-manager/dist/i18n.d.ts +6 -0
  102. package/release-manager/dist/i18n.d.ts.map +1 -0
  103. package/release-manager/dist/i18n.js +234 -0
  104. package/release-manager/dist/i18n.js.map +1 -0
  105. package/release-manager/dist/index.d.ts +7 -0
  106. package/release-manager/dist/index.d.ts.map +1 -0
  107. package/release-manager/dist/index.js +474 -0
  108. package/release-manager/dist/index.js.map +1 -0
  109. package/release-manager/dist/installer.d.ts +217 -0
  110. package/release-manager/dist/installer.d.ts.map +1 -0
  111. package/release-manager/dist/installer.js +2451 -0
  112. package/release-manager/dist/installer.js.map +1 -0
  113. package/release-manager/dist/installers/codex-host-installer.d.ts +29 -0
  114. package/release-manager/dist/installers/codex-host-installer.d.ts.map +1 -0
  115. package/release-manager/dist/installers/codex-host-installer.js +496 -0
  116. package/release-manager/dist/installers/codex-host-installer.js.map +1 -0
  117. package/release-manager/dist/installers/index.d.ts +40 -0
  118. package/release-manager/dist/installers/index.d.ts.map +1 -0
  119. package/release-manager/dist/installers/index.js +35 -0
  120. package/release-manager/dist/installers/index.js.map +1 -0
  121. package/release-manager/dist/installers/openclaw-host-installer.d.ts +18 -0
  122. package/release-manager/dist/installers/openclaw-host-installer.d.ts.map +1 -0
  123. package/release-manager/dist/installers/openclaw-host-installer.js +414 -0
  124. package/release-manager/dist/installers/openclaw-host-installer.js.map +1 -0
  125. package/release-manager/dist/mvp-config.d.ts +197 -0
  126. package/release-manager/dist/mvp-config.d.ts.map +1 -0
  127. package/release-manager/dist/mvp-config.js +509 -0
  128. package/release-manager/dist/mvp-config.js.map +1 -0
  129. package/release-manager/dist/prompts.d.ts +30 -0
  130. package/release-manager/dist/prompts.d.ts.map +1 -0
  131. package/release-manager/dist/prompts.js +231 -0
  132. package/release-manager/dist/prompts.js.map +1 -0
  133. package/release-manager/dist/skill-language.d.ts +32 -0
  134. package/release-manager/dist/skill-language.d.ts.map +1 -0
  135. package/release-manager/dist/skill-language.js +107 -0
  136. package/release-manager/dist/skill-language.js.map +1 -0
  137. package/release-manager/dist/uninstaller.d.ts +55 -0
  138. package/release-manager/dist/uninstaller.d.ts.map +1 -0
  139. package/release-manager/dist/uninstaller.js +409 -0
  140. package/release-manager/dist/uninstaller.js.map +1 -0
  141. package/release-manager/dist/update/atomic-file.d.ts +35 -0
  142. package/release-manager/dist/update/atomic-file.d.ts.map +1 -0
  143. package/release-manager/dist/update/atomic-file.js +120 -0
  144. package/release-manager/dist/update/atomic-file.js.map +1 -0
  145. package/release-manager/dist/update/bootstrap-protocol.d.ts +55 -0
  146. package/release-manager/dist/update/bootstrap-protocol.d.ts.map +1 -0
  147. package/release-manager/dist/update/bootstrap-protocol.js +132 -0
  148. package/release-manager/dist/update/bootstrap-protocol.js.map +1 -0
  149. package/release-manager/dist/update/channel-metadata.d.ts +28 -0
  150. package/release-manager/dist/update/channel-metadata.d.ts.map +1 -0
  151. package/release-manager/dist/update/channel-metadata.js +90 -0
  152. package/release-manager/dist/update/channel-metadata.js.map +1 -0
  153. package/release-manager/dist/update/channel-promotion.d.ts +39 -0
  154. package/release-manager/dist/update/channel-promotion.d.ts.map +1 -0
  155. package/release-manager/dist/update/channel-promotion.js +55 -0
  156. package/release-manager/dist/update/channel-promotion.js.map +1 -0
  157. package/release-manager/dist/update/data-compatibility.d.ts +35 -0
  158. package/release-manager/dist/update/data-compatibility.d.ts.map +1 -0
  159. package/release-manager/dist/update/data-compatibility.js +45 -0
  160. package/release-manager/dist/update/data-compatibility.js.map +1 -0
  161. package/release-manager/dist/update/install-layout.d.ts +59 -0
  162. package/release-manager/dist/update/install-layout.d.ts.map +1 -0
  163. package/release-manager/dist/update/install-layout.js +127 -0
  164. package/release-manager/dist/update/install-layout.js.map +1 -0
  165. package/release-manager/dist/update/legacy-migration.d.ts +56 -0
  166. package/release-manager/dist/update/legacy-migration.d.ts.map +1 -0
  167. package/release-manager/dist/update/legacy-migration.js +254 -0
  168. package/release-manager/dist/update/legacy-migration.js.map +1 -0
  169. package/release-manager/dist/update/product-identity.d.ts +50 -0
  170. package/release-manager/dist/update/product-identity.d.ts.map +1 -0
  171. package/release-manager/dist/update/product-identity.js +63 -0
  172. package/release-manager/dist/update/product-identity.js.map +1 -0
  173. package/release-manager/dist/update/release-asset-manifest.d.ts +31 -0
  174. package/release-manager/dist/update/release-asset-manifest.d.ts.map +1 -0
  175. package/release-manager/dist/update/release-asset-manifest.js +161 -0
  176. package/release-manager/dist/update/release-asset-manifest.js.map +1 -0
  177. package/release-manager/dist/update/release-identity.d.ts +33 -0
  178. package/release-manager/dist/update/release-identity.d.ts.map +1 -0
  179. package/release-manager/dist/update/release-identity.js +108 -0
  180. package/release-manager/dist/update/release-identity.js.map +1 -0
  181. package/release-manager/dist/update/release-manager-authority.d.ts +69 -0
  182. package/release-manager/dist/update/release-manager-authority.d.ts.map +1 -0
  183. package/release-manager/dist/update/release-manager-authority.js +125 -0
  184. package/release-manager/dist/update/release-manager-authority.js.map +1 -0
  185. package/release-manager/dist/update/release-manager.d.ts +96 -0
  186. package/release-manager/dist/update/release-manager.d.ts.map +1 -0
  187. package/release-manager/dist/update/release-manager.js +253 -0
  188. package/release-manager/dist/update/release-manager.js.map +1 -0
  189. package/release-manager/dist/update/release-metadata.d.ts +57 -0
  190. package/release-manager/dist/update/release-metadata.d.ts.map +1 -0
  191. package/release-manager/dist/update/release-metadata.js +200 -0
  192. package/release-manager/dist/update/release-metadata.js.map +1 -0
  193. package/release-manager/dist/update/release-policy.d.ts +51 -0
  194. package/release-manager/dist/update/release-policy.d.ts.map +1 -0
  195. package/release-manager/dist/update/release-policy.js +103 -0
  196. package/release-manager/dist/update/release-policy.js.map +1 -0
  197. package/release-manager/dist/update/rollback-policy.d.ts +56 -0
  198. package/release-manager/dist/update/rollback-policy.d.ts.map +1 -0
  199. package/release-manager/dist/update/rollback-policy.js +61 -0
  200. package/release-manager/dist/update/rollback-policy.js.map +1 -0
  201. package/release-manager/dist/update/transaction-journal.d.ts +131 -0
  202. package/release-manager/dist/update/transaction-journal.d.ts.map +1 -0
  203. package/release-manager/dist/update/transaction-journal.js +346 -0
  204. package/release-manager/dist/update/transaction-journal.js.map +1 -0
  205. package/release-manager/dist/update/trust-metadata.d.ts +55 -0
  206. package/release-manager/dist/update/trust-metadata.d.ts.map +1 -0
  207. package/release-manager/dist/update/trust-metadata.js +170 -0
  208. package/release-manager/dist/update/trust-metadata.js.map +1 -0
  209. package/release-manager/dist/update/update-history.d.ts +46 -0
  210. package/release-manager/dist/update/update-history.d.ts.map +1 -0
  211. package/release-manager/dist/update/update-history.js +123 -0
  212. package/release-manager/dist/update/update-history.js.map +1 -0
  213. package/release-manager/dist/utils/config-file-io.d.ts +26 -0
  214. package/release-manager/dist/utils/config-file-io.d.ts.map +1 -0
  215. package/release-manager/dist/utils/config-file-io.js +128 -0
  216. package/release-manager/dist/utils/config-file-io.js.map +1 -0
  217. package/release-manager/dist/utils/env.d.ts +60 -0
  218. package/release-manager/dist/utils/env.d.ts.map +1 -0
  219. package/release-manager/dist/utils/env.js +308 -0
  220. package/release-manager/dist/utils/env.js.map +1 -0
  221. package/release-manager/dist/utils/logger.d.ts +15 -0
  222. package/release-manager/dist/utils/logger.d.ts.map +1 -0
  223. package/release-manager/dist/utils/logger.js +30 -0
  224. package/release-manager/dist/utils/logger.js.map +1 -0
  225. package/release-manager/dist/utils/workspace-divergence.d.ts +36 -0
  226. package/release-manager/dist/utils/workspace-divergence.d.ts.map +1 -0
  227. package/release-manager/dist/utils/workspace-divergence.js +87 -0
  228. package/release-manager/dist/utils/workspace-divergence.js.map +1 -0
  229. package/release-manager/package.json +79 -0
@@ -0,0 +1,2451 @@
1
+ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, cpSync, renameSync, chmodSync, symlinkSync } from 'fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { pathToFileURL } from 'node:url';
4
+ import fse from 'fs-extra';
5
+ import * as path from 'path';
6
+ import * as http from 'http';
7
+ import { execFileSync, spawn } from 'child_process';
8
+ import ora from 'ora';
9
+ import { select } from '@inquirer/prompts';
10
+ import { logger } from './utils/logger.js';
11
+ import { checkOpenClawGateway, stopOpenClawGateway, restartOpenClawGateway } from './utils/env.js';
12
+ import { t } from './i18n.js';
13
+ import { generateConfigYamlContent, ExistingConfigVerifyInfraError, validateExistingConfigYamlForPreserve, getConfigYamlPath, readEnabledChannelsFromConfigYaml, getOpenClawDir, getPdDir, getPdRuntimeDir, getInstallManifestPath, getPdRuntimeBackupsDir, getPluginExtDir, getInstalledPluginDir, getInstalledLayoutPackageDir, getPdBackupsDir, getInstalledPdCliDir, getInstalledBinDir, getInstalledConsoleDir, isWindows, } from './mvp-config.js';
14
+ import { getHostInstallers } from './installers/index.js';
15
+ import { mergeInstallManifestWorkspaces, parseInstallManifest } from '@principles/install-layout';
16
+ import { detectOpenClawMainWorkspaceDivergence } from './utils/workspace-divergence.js';
17
+ import { applySkillLanguageSelection } from './skill-language.js';
18
+ import { parseReleaseAssetIdentity, parseReleaseAssetManifest, ReleaseAssetManifestError, verifyReleaseAssetManifestAsync, verifyReleaseAssetTarget, } from './update/release-asset-manifest.js';
19
+ import { appendJournalTransition } from './update/transaction-journal.js';
20
+ /** PRI-343: Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
21
+ export const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess';
22
+ function isRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ /**
26
+ * PRI-343: Pure function — deep-merges allowConversationAccess: true into
27
+ * the openclaw.json config without mutating the input.
28
+ *
29
+ * Ensures plugins.entries['principles-disciple'].hooks.allowConversationAccess
30
+ * is set to true, creating intermediate objects if missing.
31
+ * Preserves all other fields.
32
+ */
33
+ export function ensureConversationAccess(config) {
34
+ const result = { ...config };
35
+ // Guard: plugins must be a non-null object
36
+ if (!isRecord(result.plugins)) {
37
+ return result;
38
+ }
39
+ const plugins = { ...result.plugins };
40
+ // Guard: plugins.entries must be a non-null object
41
+ if (!isRecord(plugins.entries)) {
42
+ return result;
43
+ }
44
+ const entries = { ...plugins.entries };
45
+ // Guard: principles-disciple entry must be a non-null object (or missing)
46
+ const rawEntry = entries['principles-disciple'];
47
+ const entry = isRecord(rawEntry)
48
+ ? { ...rawEntry }
49
+ : { enabled: true };
50
+ // Guard: hooks must be a non-null object (or missing)
51
+ const rawHooks = entry.hooks;
52
+ const hooks = isRecord(rawHooks)
53
+ ? { ...rawHooks }
54
+ : {};
55
+ // Set allowConversationAccess: true (idempotent)
56
+ hooks[CONVERSATION_ACCESS_CONFIG_KEY] = true;
57
+ entry.hooks = hooks;
58
+ entries['principles-disciple'] = entry;
59
+ plugins.entries = entries;
60
+ result.plugins = plugins;
61
+ return result;
62
+ }
63
+ const INSTALL_TIMEOUT_MS = parseInt(process.env.PD_INSTALL_TIMEOUT_MS || '300000', 10);
64
+ // 超时常量
65
+ const PD_CLI_VERIFICATION_TIMEOUT_MS = 30_000;
66
+ const STORY_A_VERIFICATION_TIMEOUT_MS = 30_000;
67
+ const CONSOLE_HEALTH_CHECK_TIMEOUT_MS = 8_000;
68
+ const CONSOLE_WARMUP_TIME_MS = 6_000;
69
+ // 端口范围常量。PD_CONSOLE_PORT_BASE 允许在操作系统保留了大段端口
70
+ // (如 Windows excludedportrange) 的机器上整体平移探测窗口;未设置时
71
+ // 保持历史默认 3100–3199。resolveConsolePortBase() 是唯一 resolved
72
+ // base:安装验证窗口、console 健康检查、autolaunch 扫描、测试全部
73
+ // 消费它——任何新窗口不得再硬编码第二个 base。
74
+ export function resolveConsolePortBase() {
75
+ const raw = process.env.PD_CONSOLE_PORT_BASE;
76
+ if (raw === undefined)
77
+ return 3100;
78
+ // Number() (not parseInt) so '3300.5' is rejected instead of silently
79
+ // truncating to 3300.
80
+ const parsed = Number(raw);
81
+ if (!Number.isInteger(parsed) || parsed < 1024 || parsed > 65000) {
82
+ throw new Error(`PD_CONSOLE_PORT_BASE must be an integer in [1024, 65000], got: ${JSON.stringify(raw)}`);
83
+ }
84
+ return parsed;
85
+ }
86
+ const CONSOLE_PORT_RANGE_MIN = resolveConsolePortBase();
87
+ const CONSOLE_PORT_RANGE_MAX = CONSOLE_PORT_RANGE_MIN + 99;
88
+ // Task 8: auto-launch console via `pd console open` after successful install.
89
+ // Derived from the SAME resolved base as the verification window above.
90
+ const CONSOLE_AUTOLAUNCH_BASE_PORT = CONSOLE_PORT_RANGE_MIN;
91
+ const CONSOLE_AUTOLAUNCH_PORT_SCAN_LIMIT = 20; // base..base+19 (matches pd console open PORT_FALLBACK_LIMIT)
92
+ const CONSOLE_AUTOLAUNCH_READY_TIMEOUT_MS = 12_000;
93
+ const CONSOLE_AUTOLAUNCH_POLL_INTERVAL_MS = 500;
94
+ // 允许的原生模块白名单
95
+ const ALLOWED_NATIVE_MODULES = ['better-sqlite3'];
96
+ // Shared components use ~/.pd/runtime. The plugin package is host-neutral for
97
+ // Codex-only installs; OpenClaw/all still receive their adapter copy under
98
+ // ~/.openclaw/extensions for host discovery.
99
+ let activeHostTarget = 'openclaw';
100
+ function installsOpenClaw(host) {
101
+ return host === 'openclaw' || host === 'all';
102
+ }
103
+ function installedPluginDir() {
104
+ return getInstalledPluginDir();
105
+ }
106
+ function pluginInstallDirs() {
107
+ return installsOpenClaw(activeHostTarget)
108
+ ? [getInstalledPluginDir(), getPluginExtDir()]
109
+ : [getInstalledPluginDir()];
110
+ }
111
+ export function mergeInstallManifestHosts(current, host) {
112
+ const hosts = new Set();
113
+ const parsed = parseInstallManifest(current);
114
+ if (current !== undefined && !parsed.manifest) {
115
+ throw new Error(parsed.error ?? 'install_manifest_malformed');
116
+ }
117
+ for (const existingHost of parsed.manifest?.hosts ?? [])
118
+ hosts.add(existingHost);
119
+ if (host === 'all' || host === 'codex')
120
+ hosts.add('codex');
121
+ if (host === 'all' || host === 'openclaw')
122
+ hosts.add('openclaw');
123
+ return [...hosts];
124
+ }
125
+ function resolveInstallManifestHosts(host) {
126
+ let current;
127
+ try {
128
+ current = JSON.parse(readFileSync(getInstallManifestPath(), 'utf8'));
129
+ }
130
+ catch (error) {
131
+ if (existsSync(getInstallManifestPath()))
132
+ throw error;
133
+ }
134
+ return mergeInstallManifestHosts(current, host);
135
+ }
136
+ /**
137
+ * PRI-624 Slice C: the install manifest records every Workspace this install
138
+ * serves so the Companion can discover its per-Workspace workers (SPEC §13
139
+ * "canonical install manifest"). Idempotent by canonical path.
140
+ */
141
+ function resolveInstallManifestWorkspaces(workspaceDir) {
142
+ let current;
143
+ try {
144
+ current = JSON.parse(readFileSync(getInstallManifestPath(), 'utf8'));
145
+ }
146
+ catch (error) {
147
+ if (existsSync(getInstallManifestPath()))
148
+ throw error;
149
+ }
150
+ const parsed = parseInstallManifest(current);
151
+ if (current !== undefined && !parsed.manifest) {
152
+ throw new Error(parsed.error ?? 'install_manifest_malformed');
153
+ }
154
+ return mergeInstallManifestWorkspaces(parsed.manifest, path.resolve(workspaceDir));
155
+ }
156
+ function writeInstallManifest(hosts, workspaces) {
157
+ mkdirSync(getPdDir(), { recursive: true });
158
+ writeFileSync(getInstallManifestPath(), JSON.stringify({ layoutVersion: 1, mode: 'canonical', hosts, workspaces }, null, 2) + '\n', 'utf8');
159
+ }
160
+ function installBundledLayoutPackage(pluginDir) {
161
+ const source = path.join(pluginDir, 'install-layout');
162
+ const destination = getInstalledLayoutPackageDir();
163
+ if (!existsSync(path.join(source, 'package.json')) || !existsSync(path.join(source, 'dist'))) {
164
+ throw new Error('Bundled @principles/install-layout is incomplete. Re-run the installer with a current package.');
165
+ }
166
+ rmSync(destination, { recursive: true, force: true });
167
+ cpSync(source, destination, { recursive: true });
168
+ }
169
+ /**
170
+ * Run npm with array-form argv, no shell (PRI-569 hardening).
171
+ *
172
+ * On Windows `npm` is an npm.cmd shim: Node cannot spawn .cmd without a
173
+ * shell (EINVAL/ENOENT), so win32 routes through cmd.exe with CONSTANT argv
174
+ * elements — args are compile-time literals at every call site; the one
175
+ * external-derived value (registry version) is regex-guarded before use.
176
+ * Returns captured stdout (encoding utf-8).
177
+ */
178
+ function execNpm(args, cwd, timeoutOverride) {
179
+ // encoding:'utf-8' resolves the string overload of execFileSync (TS-typed);
180
+ // callers and tests must honor that contract (string output).
181
+ return (process.platform === 'win32'
182
+ ? execFileSync('cmd.exe', ['/c', 'npm', ...args], {
183
+ cwd,
184
+ encoding: 'utf-8',
185
+ stdio: 'pipe',
186
+ env: process.env,
187
+ windowsHide: true,
188
+ timeout: timeoutOverride ?? INSTALL_TIMEOUT_MS,
189
+ })
190
+ : execFileSync('npm', args, {
191
+ cwd,
192
+ encoding: 'utf-8',
193
+ stdio: 'pipe',
194
+ env: process.env,
195
+ timeout: timeoutOverride ?? INSTALL_TIMEOUT_MS,
196
+ })).trim();
197
+ }
198
+ /**
199
+ * 执行 npm install 并提供友好的错误提示
200
+ */
201
+ async function runNpmInstall(cwd, componentName = 'npm') {
202
+ try {
203
+ execNpm(['install', '--ignore-scripts', '--legacy-peer-deps'], cwd);
204
+ }
205
+ catch (e) {
206
+ const errorMsg = e instanceof Error ? e.message : String(e);
207
+ // `t` is the module-level i18n translator (imported at top of file).
208
+ // i18n.ts has no imports, so there is no circular dependency.
209
+ const hint = errorMsg.includes('ETIMEDOUT') || errorMsg.includes('network') || errorMsg.includes('timeout')
210
+ ? t('npm_hint_network_timeout').replace('{path}', cwd)
211
+ : errorMsg.includes('EACCES') || errorMsg.includes('permission') || errorMsg.includes('EPERM')
212
+ ? t('npm_hint_permission_denied')
213
+ : errorMsg.includes('ENOSPC')
214
+ ? t('npm_hint_disk_space')
215
+ : t('npm_hint_manual_fix').replace('{path}', cwd);
216
+ throw new Error(`${componentName} npm install failed: ${errorMsg}\n\n${hint}`, { cause: e });
217
+ }
218
+ }
219
+ /**
220
+ * 验证原生模块:better-sqlite3 随发布资产携带 prebuilt 二进制
221
+ * (prebuilds/*.node),node-gyp-build 在 require 时解析——无需(也没有)
222
+ * npm rebuild 步骤。本函数的 require 探针就是唯一验证(历史上存在一个
223
+ * 名为 rebuildNativeModules 的空壳函数,已删除以避免误导维护者)。
224
+ */
225
+ export function verifyNativeModules(cwd, componentName) {
226
+ for (const nativeMod of ALLOWED_NATIVE_MODULES) {
227
+ const nativeModPath = path.join(cwd, 'node_modules', nativeMod);
228
+ if (!existsSync(nativeModPath))
229
+ continue;
230
+ try {
231
+ execFileSync(process.execPath, ['-e', `require('${nativeMod}')`], { cwd, stdio: 'pipe' });
232
+ }
233
+ catch {
234
+ throw new Error(`${componentName} native module ${nativeMod} failed its require probe: the prebuilt binary for this Node.js ABI is missing or incompatible (no rebuild step exists by design). Install the platform release asset matching this OS, architecture, and ABI.`);
235
+ }
236
+ }
237
+ }
238
+ const SELF_CONTAINED_DEPENDENCY_NEXT_ACTION = 'Install a complete platform release asset for this Node.js ABI and re-run the installer.';
239
+ const SELF_CONTAINED_NATIVE_NEXT_ACTION = 'Install the platform release asset matching this operating system, architecture, and Node.js ABI.';
240
+ export class SelfContainedDependencyError extends Error {
241
+ reason;
242
+ nextAction;
243
+ component;
244
+ dependency;
245
+ constructor(options) {
246
+ super(options.message, { cause: options.cause });
247
+ this.name = 'SelfContainedDependencyError';
248
+ this.reason = options.reason;
249
+ this.nextAction = options.nextAction;
250
+ this.component = options.component;
251
+ this.dependency = options.dependency;
252
+ }
253
+ }
254
+ function legacyNpmInstallEnabled() {
255
+ const value = process.env.PD_ALLOW_LEGACY_NPM_INSTALL;
256
+ return value === '1' || value === 'true';
257
+ }
258
+ const NPM_DISTRIBUTED_COMPONENTS = [
259
+ ['Core', 'core'],
260
+ ['Host runtime', 'host-runtime'],
261
+ ['Codex adapter', 'codex-adapter'],
262
+ ['Plugin', 'plugin'],
263
+ ['PD CLI', 'pd-cli'],
264
+ ['Console', 'console'],
265
+ ['Install layout', 'install-layout'],
266
+ ];
267
+ // Components whose install step demands MORE than the generic
268
+ // package.json+dist shape check — the per-component extra files are
269
+ // preflighted here so the form-gate refusal is exact (a truncated package
270
+ // must fail BEFORE any mutation, matching the per-component install
271
+ // requirements instead of discovering them mid-deployment).
272
+ const NPM_DISTRIBUTED_REQUIRED_FILES = {
273
+ 'release-manager': ['package.json', path.join('dist', 'update', 'release-manager-authority.js')],
274
+ console: ['package.json', path.join('dist', 'server.js'), path.join('dist', 'web', 'index.html')],
275
+ };
276
+ function releaseAssetPresent(pluginDir) {
277
+ return existsSync(path.join(pluginDir, '_release', 'asset.json'));
278
+ }
279
+ /**
280
+ * Form-gate for the npm-distributed package shape: every bundled component
281
+ * directory must carry its required files. Returns the missing items
282
+ * (empty = complete) so the refusal can name them (rc-3: fail loud with
283
+ * specifics, not a generic "invalid asset"). release-manager and console
284
+ * are checked against their ACTUAL install-time requirements — they are
285
+ * consumed unconditionally during deployment, so a package missing them
286
+ * must be refused before the backup step, not mid-copy (review blocker,
287
+ * PR #1525).
288
+ */
289
+ function missingNpmDistributedComponents(pluginDir) {
290
+ const missing = [];
291
+ // Components with exact install-time file demands replace the generic
292
+ // package.json+dist check (console appears in both lists — the exact list
293
+ // subsumes the generic one, so dedupe to avoid reporting each miss twice).
294
+ const exactShapeComponents = new Set(Object.keys(NPM_DISTRIBUTED_REQUIRED_FILES));
295
+ const componentDirs = [
296
+ ...NPM_DISTRIBUTED_COMPONENTS.map(([, directory]) => directory).filter((directory) => !exactShapeComponents.has(directory)),
297
+ ...exactShapeComponents,
298
+ ];
299
+ for (const componentDirectory of componentDirs) {
300
+ const componentDir = path.join(pluginDir, componentDirectory);
301
+ const requiredFiles = NPM_DISTRIBUTED_REQUIRED_FILES[componentDirectory]
302
+ ?? ['package.json', componentDirectory === 'plugin' ? '' : 'dist'];
303
+ for (const requiredFile of requiredFiles) {
304
+ if (!existsSync(path.join(componentDir, requiredFile))) {
305
+ missing.push(`${componentDirectory}/${requiredFile}`);
306
+ }
307
+ }
308
+ }
309
+ return missing;
310
+ }
311
+ /**
312
+ * Decide which payload shape this package instance carries and whether
313
+ * registry dependency resolution (npm install) is required. Env override
314
+ * first (explicit operator intent), then the physical shape: a present
315
+ * `_release/asset.json` always takes the self-contained path (a present
316
+ * but CORRUPT asset still fails hard inside the preflight — never
317
+ * silently falling back to registry resolution); a package without the
318
+ * release asset but with complete component trees is the npm-distributed
319
+ * shape and resolves dependencies from the registry.
320
+ */
321
+ export function decideInstallPayloadMode(pluginDir) {
322
+ if (legacyNpmInstallEnabled())
323
+ return 'npm-distributed';
324
+ if (releaseAssetPresent(pluginDir))
325
+ return 'self-contained';
326
+ return 'npm-distributed';
327
+ }
328
+ // Per-run payload mode, set by install() alongside activeHostTarget (same
329
+ // module-level idiom) — prepareComponentDependencies' call sites are the
330
+ // per-component wrappers, which have no natural parameter path from install().
331
+ let activePayloadMode = 'self-contained';
332
+ /**
333
+ * Whether registry dependency resolution (npm install / global root
334
+ * discovery) may run: true for the npm-distributed payload shape and the
335
+ * PD_ALLOW_LEGACY_NPM_INSTALL recovery path. Host installers consume this
336
+ * gate instead of re-reading the env (their concern is payload shape, not
337
+ * the specific env var).
338
+ */
339
+ export function isNpmDependencyResolutionEnabled() {
340
+ return activePayloadMode === 'npm-distributed';
341
+ }
342
+ /**
343
+ * Supported release assets are self-contained: installation validates the
344
+ * copied component and never resolves dependencies or runs lifecycle scripts.
345
+ * The old npm path remains opt-in for development/legacy recovery only.
346
+ */
347
+ export async function prepareBundledComponentDependencies(cwd, componentName) {
348
+ const packageJsonPath = path.join(cwd, 'package.json');
349
+ let packageJsonRaw;
350
+ try {
351
+ packageJsonRaw = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
352
+ }
353
+ catch (error) {
354
+ throw new SelfContainedDependencyError({
355
+ reason: 'self_contained_package_manifest_invalid',
356
+ nextAction: SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
357
+ message: `${componentName} package.json is missing or malformed after copy.`,
358
+ component: componentName,
359
+ cause: error,
360
+ });
361
+ }
362
+ if (!isRecord(packageJsonRaw)) {
363
+ throw new SelfContainedDependencyError({
364
+ reason: 'self_contained_package_manifest_invalid',
365
+ nextAction: SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
366
+ message: `${componentName} package.json must contain an object.`,
367
+ component: componentName,
368
+ });
369
+ }
370
+ const { dependencies } = packageJsonRaw;
371
+ if (dependencies !== undefined && !isRecord(dependencies)) {
372
+ throw new SelfContainedDependencyError({
373
+ reason: 'self_contained_package_manifest_invalid',
374
+ nextAction: SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
375
+ message: `${componentName} package.json dependencies must contain an object.`,
376
+ component: componentName,
377
+ });
378
+ }
379
+ for (const dependency of Object.keys(dependencies ?? {})) {
380
+ if (!existsSync(path.join(cwd, 'node_modules', dependency))) {
381
+ throw new SelfContainedDependencyError({
382
+ reason: 'self_contained_runtime_dependency_missing',
383
+ nextAction: SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
384
+ message: `${componentName} self-contained release asset is missing declared runtime dependency ${dependency}.`,
385
+ component: componentName,
386
+ dependency,
387
+ });
388
+ }
389
+ }
390
+ try {
391
+ verifyNativeModules(cwd, componentName);
392
+ }
393
+ catch (error) {
394
+ throw new SelfContainedDependencyError({
395
+ reason: 'self_contained_native_module_unloadable',
396
+ nextAction: SELF_CONTAINED_NATIVE_NEXT_ACTION,
397
+ message: `${componentName} bundled better-sqlite3 cannot be loaded by this Node.js runtime.`,
398
+ component: componentName,
399
+ dependency: 'better-sqlite3',
400
+ cause: error,
401
+ });
402
+ }
403
+ }
404
+ const SELF_CONTAINED_COMPONENTS = [
405
+ ['Core', 'core'],
406
+ ['Host runtime', 'host-runtime'],
407
+ ['Plugin', 'plugin'],
408
+ ['PD CLI', 'pd-cli'],
409
+ ['Console', 'console'],
410
+ ['Install layout', 'install-layout'],
411
+ ];
412
+ export async function preflightSelfContainedReleaseAsset(assetDir) {
413
+ const identityPath = path.join(assetDir, '_release', 'asset.json');
414
+ let identityValue;
415
+ try {
416
+ identityValue = JSON.parse(readFileSync(identityPath, 'utf8'));
417
+ const identity = parseReleaseAssetIdentity(identityValue);
418
+ verifyReleaseAssetTarget(identity, {
419
+ platform: process.platform,
420
+ arch: process.arch,
421
+ nodeAbi: process.versions.modules,
422
+ });
423
+ }
424
+ catch (error) {
425
+ const targetMismatch = error instanceof ReleaseAssetManifestError && error.code === 'asset_target_mismatch';
426
+ throw new SelfContainedDependencyError({
427
+ reason: targetMismatch ? 'self_contained_asset_target_mismatch' : 'self_contained_asset_identity_invalid',
428
+ nextAction: targetMismatch ? SELF_CONTAINED_NATIVE_NEXT_ACTION : SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
429
+ message: error instanceof Error ? error.message : 'Release asset identity is missing or malformed.',
430
+ component: 'Release asset',
431
+ cause: error,
432
+ });
433
+ }
434
+ try {
435
+ const manifestValue = JSON.parse(readFileSync(path.join(assetDir, '_release', 'manifest.json'), 'utf8'));
436
+ await verifyReleaseAssetManifestAsync(assetDir, parseReleaseAssetManifest(manifestValue));
437
+ }
438
+ catch (error) {
439
+ throw new SelfContainedDependencyError({
440
+ reason: 'self_contained_asset_integrity_invalid',
441
+ nextAction: SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
442
+ message: error instanceof Error ? error.message : 'Release asset manifest is missing, malformed, or does not match its files.',
443
+ component: 'Release asset',
444
+ cause: error,
445
+ });
446
+ }
447
+ for (const [componentName, componentDirectory] of SELF_CONTAINED_COMPONENTS) {
448
+ await prepareBundledComponentDependencies(path.join(assetDir, componentDirectory), componentName);
449
+ }
450
+ }
451
+ async function prepareComponentDependencies(cwd, componentName) {
452
+ if (activePayloadMode === 'self-contained') {
453
+ await prepareBundledComponentDependencies(cwd, componentName);
454
+ return;
455
+ }
456
+ await runNpmInstall(cwd, componentName);
457
+ verifyNativeModules(cwd, componentName);
458
+ }
459
+ /**
460
+ * 验证路径是否在工作区目录内,防止路径遍历攻击
461
+ */
462
+ export function validateWorkspacePath(targetPath, workspaceDir) {
463
+ const resolved = path.resolve(targetPath);
464
+ const workspace = path.resolve(workspaceDir);
465
+ if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
466
+ throw new Error(`Security error: Path "${targetPath}" is outside workspace directory "${workspaceDir}"`);
467
+ }
468
+ }
469
+ const INSTALL_STEPS = [
470
+ { name: 'Checking built plugin', weight: 3 },
471
+ { name: 'Checking workspace rule compatibility', weight: 1 },
472
+ { name: 'Backing up existing install', weight: 3 },
473
+ { name: 'Installing bundled @principles/core', weight: 8 },
474
+ { name: 'Validating bundled core dependencies', weight: 5 },
475
+ { name: 'Installing bundled @principles/host-runtime', weight: 3 },
476
+ { name: 'Validating bundled host runtime dependencies', weight: 5 },
477
+ { name: 'Installing bundled @principles/codex-adapter', weight: 3 },
478
+ { name: 'Installing plugin', weight: 10 },
479
+ { name: 'Preparing core library for plugin', weight: 3 },
480
+ { name: 'Validating bundled plugin dependencies', weight: 10 },
481
+ { name: 'Installing pd CLI', weight: 8 },
482
+ { name: 'Preparing core library for pd-cli', weight: 3 },
483
+ { name: 'Verifying pd CLI', weight: 3 },
484
+ { name: 'Installing pd-console', weight: 8 },
485
+ { name: 'Preparing core library for console', weight: 3 },
486
+ { name: 'Validating bundled console dependencies', weight: 5 },
487
+ { name: 'Verifying pd-console', weight: 3 },
488
+ { name: 'Copying templates', weight: 3 },
489
+ { name: 'Generating config.yaml', weight: 2 },
490
+ { name: 'Creating config', weight: 2 },
491
+ { name: 'Verifying demo', weight: 5 },
492
+ { name: 'Updating host config', weight: 3 },
493
+ ];
494
+ const TOTAL_WEIGHT = INSTALL_STEPS.reduce((sum, step) => sum + step.weight, 0);
495
+ function updateProgress(spinner, currentStep, message) {
496
+ if (!spinner)
497
+ return;
498
+ if (currentStep < 0 || currentStep >= INSTALL_STEPS.length) {
499
+ spinner.text = message;
500
+ return;
501
+ }
502
+ const completedWeight = INSTALL_STEPS.slice(0, currentStep + 1).reduce((sum, s) => sum + s.weight, 0);
503
+ const percent = Math.round((completedWeight / TOTAL_WEIGHT) * 100);
504
+ spinner.text = `${message} (${percent}%)`;
505
+ }
506
+ /**
507
+ * Move legacy PD backup directories out of the extensions dir into the PD
508
+ * backups root. Older versions created backups as siblings of the plugin
509
+ * inside ~/.openclaw/extensions — OpenClaw plugin discovery scans every
510
+ * extensions/ child directory, so those backups get discovered as duplicate
511
+ * principles-disciple plugins ("duplicate plugin id detected" on every
512
+ * gateway startup). Best-effort: failures are logged, never fatal (rc-9).
513
+ *
514
+ * Exported for real-filesystem tests (tests/backup-location.test.ts).
515
+ */
516
+ export function migrateLegacyPdBackups() {
517
+ const extensionsDir = path.join(getOpenClawDir(), 'extensions');
518
+ let entries;
519
+ try {
520
+ entries = readdirSync(extensionsDir, { withFileTypes: true });
521
+ }
522
+ catch {
523
+ return; // no extensions dir — nothing to migrate
524
+ }
525
+ const isLegacyName = (name) => name.startsWith('.pd-backup-') || /^principles-disciple\.backup\.\d+$/.test(name);
526
+ const backupsDir = getPdBackupsDir();
527
+ let moved = 0;
528
+ for (const entry of entries) {
529
+ if (!entry.isDirectory() || !isLegacyName(entry.name))
530
+ continue;
531
+ let dest = path.join(backupsDir, entry.name);
532
+ let suffix = 1;
533
+ while (existsSync(dest)) {
534
+ dest = path.join(backupsDir, `${entry.name}-${suffix}`);
535
+ suffix += 1;
536
+ }
537
+ try {
538
+ mkdirSync(backupsDir, { recursive: true });
539
+ renameSync(path.join(extensionsDir, entry.name), dest);
540
+ moved += 1;
541
+ }
542
+ catch (e) {
543
+ logger.warn(`Could not migrate legacy PD backup ${entry.name} out of extensions/: ${e instanceof Error ? e.message : String(e)}. Move it out manually to silence the OpenClaw "duplicate plugin id" warning.`);
544
+ }
545
+ }
546
+ if (moved > 0) {
547
+ logger.info(`Migrated ${moved} legacy PD backup dir(s) out of the extensions dir to ${backupsDir}`);
548
+ }
549
+ }
550
+ /**
551
+ * Exported for real-filesystem tests (tests/backup-location.test.ts).
552
+ */
553
+ export function backupExistingInstall(host = 'openclaw') {
554
+ const extDir = getPluginExtDir();
555
+ const runtimeDir = getPdRuntimeDir();
556
+ const hasExt = installsOpenClaw(host) && existsSync(extDir);
557
+ const hasRuntime = existsSync(runtimeDir);
558
+ if (!hasExt && !hasRuntime)
559
+ return { type: 'no_existing', backupDir: null };
560
+ // The backup must live OUTSIDE the extensions dir (see getPdBackupsDir):
561
+ // OpenClaw plugin discovery scans every extensions/ child directory and
562
+ // would discover the backup as a second principles-disciple plugin.
563
+ const backupsDir = hasExt ? getPdBackupsDir() : getPdRuntimeBackupsDir();
564
+ const timestamp = Date.now();
565
+ const backupDir = hasExt ? path.join(backupsDir, `principles-disciple.backup.${timestamp}`) : null;
566
+ const runtimeBackupDir = hasRuntime ? path.join(getPdRuntimeBackupsDir(), `runtime.backup.${timestamp}`) : null;
567
+ try {
568
+ mkdirSync(backupsDir, { recursive: true });
569
+ if (backupDir)
570
+ renameSync(extDir, backupDir);
571
+ if (runtimeBackupDir) {
572
+ mkdirSync(getPdRuntimeBackupsDir(), { recursive: true });
573
+ renameSync(runtimeDir, runtimeBackupDir);
574
+ }
575
+ logger.info(`Backed up existing install to ${backupDir ?? runtimeBackupDir}`);
576
+ return { type: 'backed_up', backupDir, runtimeBackupDir };
577
+ }
578
+ catch (e) {
579
+ throw new Error(`Could not backup existing install at ${extDir}: ${e instanceof Error ? e.message : String(e)}. Aborting to prevent data loss — resolve the lock or rename manually and re-run.`, { cause: e });
580
+ }
581
+ }
582
+ function restoreBackup(backupDir, runtimeBackupDir) {
583
+ if ((!backupDir || !existsSync(backupDir)) && (!runtimeBackupDir || !existsSync(runtimeBackupDir)))
584
+ return { restored: true };
585
+ const extDir = getPluginExtDir();
586
+ const runtimeDir = getPdRuntimeDir();
587
+ try {
588
+ if (backupDir && existsSync(extDir)) {
589
+ rmSync(extDir, { recursive: true, force: true });
590
+ }
591
+ if (backupDir && existsSync(backupDir))
592
+ renameSync(backupDir, extDir);
593
+ if (runtimeBackupDir && existsSync(runtimeBackupDir)) {
594
+ if (existsSync(runtimeDir))
595
+ rmSync(runtimeDir, { recursive: true, force: true });
596
+ renameSync(runtimeBackupDir, runtimeDir);
597
+ }
598
+ logger.info('Restored previous install from backup');
599
+ return { restored: true };
600
+ }
601
+ catch (e) {
602
+ const msg = `Failed to restore backup: ${e instanceof Error ? e.message : String(e)}`;
603
+ logger.error(msg);
604
+ return { restored: false, error: msg };
605
+ }
606
+ }
607
+ function cleanupBackup(backupDir, runtimeBackupDir) {
608
+ for (const backup of [backupDir, runtimeBackupDir]) {
609
+ if (!backup || !existsSync(backup))
610
+ continue;
611
+ try {
612
+ rmSync(backup, { recursive: true, force: true });
613
+ }
614
+ catch {
615
+ // non-fatal
616
+ }
617
+ }
618
+ }
619
+ /**
620
+ * Decide how to handle a running gateway.
621
+ * - --stop-gateway flag → 'stop' (auto-stop, no prompt, even in interactive).
622
+ * - non-interactive (quiet/--json/--yes) without the flag → 'abort' (refuse
623
+ * cleanly; rc-9: do NOT silently proceed into a known failure).
624
+ * - interactive without the flag → warn + 3-way prompt (stop / proceed / abort).
625
+ */
626
+ async function resolveGatewayAction(status, opts) {
627
+ if (opts.stopGateway)
628
+ return 'stop';
629
+ if (!opts.interactive)
630
+ return 'abort';
631
+ const portInfo = status.port ? ` (port ${status.port})` : '';
632
+ const pidInfo = status.pid ? `, PID ${status.pid}` : '';
633
+ logger.warn(`${t('gateway_running')}${portInfo}${pidInfo}.`);
634
+ logger.warn(t('gateway_lock_warning'));
635
+ const choice = await select({
636
+ message: t('gateway_prompt_title'),
637
+ choices: [
638
+ { value: 'stop', name: t('gateway_choice_stop') },
639
+ { value: 'proceed', name: t('gateway_choice_proceed') },
640
+ { value: 'abort', name: t('gateway_choice_abort') },
641
+ ],
642
+ });
643
+ return choice;
644
+ }
645
+ /**
646
+ * Structured failure result for a gateway pre-flight refusal — either the run
647
+ * aborted (gateway running, not stopped) or --stop-gateway was requested but
648
+ * `openclaw gateway stop` failed. All components stay 'skipped': both refusal
649
+ * paths return before any mutation (cli-5).
650
+ */
651
+ function buildGatewayRefusalResult(options, detail) {
652
+ return {
653
+ success: false,
654
+ workspaceDir: options.workspaceDir,
655
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
656
+ templatesCount: 0,
657
+ components: { plugin: 'skipped', cli: 'skipped', console: 'skipped' },
658
+ verification: { features: 'skipped', storyA: 'skipped' },
659
+ enabledChannels: options.channels,
660
+ nextAction: detail.nextAction,
661
+ reason: detail.reason,
662
+ ...(detail.error !== undefined ? { error: detail.error } : {}),
663
+ };
664
+ }
665
+ /** Map a scanner refusal status into the preflight refusal contract (P1-3). */
666
+ function refuseForStatus(status, parsedReason, remediation) {
667
+ switch (status) {
668
+ case 'legacy_dependency':
669
+ return {
670
+ ok: false,
671
+ status: 'legacy_dependency',
672
+ reason: parsedReason ?? `legacy_rule_contract_dependency: ${status}`,
673
+ ...(remediation !== undefined ? { remediation } : {}),
674
+ };
675
+ case 'scan_failed':
676
+ return {
677
+ ok: false,
678
+ status: 'scan_failed',
679
+ reason: parsedReason ?? 'compatibility_scan_failed: workspace state could not be scanned',
680
+ ...(remediation !== undefined
681
+ ? { remediation }
682
+ : { remediation: 'Repair the workspace database or its permissions, then retry the upgrade. The current installation has not been replaced.' }),
683
+ };
684
+ case 'scan_unavailable':
685
+ return {
686
+ ok: false,
687
+ status: 'scan_unavailable',
688
+ reason: parsedReason ?? 'compatibility_scan_unavailable',
689
+ ...(remediation !== undefined
690
+ ? { remediation }
691
+ : { remediation: 'Re-download/rebuild the installer before upgrading. The current installation has not been replaced.' }),
692
+ };
693
+ }
694
+ }
695
+ /** Fail closed with the strict protocol-violation reason (P2, 2026-08-20). */
696
+ function protocolInvalid(detail) {
697
+ return {
698
+ ok: false,
699
+ status: 'scan_failed',
700
+ reason: `compatibility_scan_protocol_invalid: ${detail}`,
701
+ remediation: 'The compatibility scanner returned an unexpected result. Re-download/rebuild the installer before upgrading. The current installation has not been replaced.',
702
+ };
703
+ }
704
+ /**
705
+ * Strict (ok, status) protocol parse (P2, 2026-08-20).
706
+ *
707
+ * Boolean `ok` alone is NOT authoritative — the (ok, status) pair is the
708
+ * contract. Only ok=true + (clean|no_state_db) is a successful scan. Every
709
+ * other combination fails closed:
710
+ * - ok=true + refusal/success-impossible status → protocol invalid
711
+ * - ok=false + clean|no_state_db/unknown → protocol invalid
712
+ * - ok=false + legacy_dependency|scan_failed|scan_unavailable → refusal
713
+ * - non-boolean / missing ok → protocol invalid
714
+ * The payload is never trusted to self-describe a pass.
715
+ */
716
+ export function parseCompatibilityScanStdout(stdout) {
717
+ if (typeof stdout !== 'string' || stdout.trim().length === 0) {
718
+ return { ok: false, status: 'scan_failed', reason: 'compatibility_scan_unreadable: empty stdout' };
719
+ }
720
+ let parsed;
721
+ try {
722
+ parsed = JSON.parse(stdout);
723
+ }
724
+ catch {
725
+ return { ok: false, status: 'scan_failed', reason: 'compatibility_scan_unreadable: stdout is not JSON' };
726
+ }
727
+ if (!isRecord(parsed)) {
728
+ return { ok: false, status: 'scan_failed', reason: 'compatibility_scan_unreadable: stdout is not an object' };
729
+ }
730
+ const status = typeof parsed.status === 'string' ? parsed.status : 'unknown';
731
+ const reason = typeof parsed.reason === 'string' ? parsed.reason : undefined;
732
+ const remediation = typeof parsed.remediation === 'string' ? parsed.remediation : undefined;
733
+ if (parsed.ok === true) {
734
+ if (status === 'clean' || status === 'no_state_db') {
735
+ return { ok: true, status };
736
+ }
737
+ return protocolInvalid(`ok=true with status=${status}`);
738
+ }
739
+ if (parsed.ok === false) {
740
+ if (status === 'legacy_dependency' || status === 'scan_failed' || status === 'scan_unavailable') {
741
+ return refuseForStatus(status, reason, remediation);
742
+ }
743
+ return protocolInvalid(`ok=false with status=${status}`);
744
+ }
745
+ return protocolInvalid(`ok is not a boolean`);
746
+ }
747
+ async function defaultLegacyRulePreflightRunner(pdCliEntry, workspaceDir) {
748
+ // The entry path is boundary-checked before it is used as a subprocess
749
+ // target, and the subprocess is invoked with an argv array (no shell).
750
+ const { execFile } = await import('node:child_process');
751
+ const { promisify } = await import('node:util');
752
+ const execFileAsync = promisify(execFile);
753
+ try {
754
+ const result = await execFileAsync(process.execPath, [pdCliEntry, 'runtime', 'compatibility-scan', '--json', '--workspace', workspaceDir], { timeout: 60_000 });
755
+ return parseCompatibilityScanStdout(result.stdout);
756
+ }
757
+ catch (err) {
758
+ // The scan command exits 1 WITH valid JSON for any refusal (legacy
759
+ // dependency, DB failure, ...). Preserve that structured result instead
760
+ // of smearing scan_failed into legacy_dependency (P1-3).
761
+ const { stdout } = err;
762
+ if (typeof stdout === 'string' && stdout.trim().length > 0) {
763
+ const fromStdout = parseCompatibilityScanStdout(stdout);
764
+ if (!fromStdout.ok)
765
+ return fromStdout;
766
+ }
767
+ return {
768
+ ok: false,
769
+ status: 'scan_failed',
770
+ reason: `compatibility_scan_failed: ${err instanceof Error ? err.message : String(err)}`,
771
+ remediation: 'Repair the workspace database or its permissions, then retry the upgrade. The current installation has not been replaced.',
772
+ };
773
+ }
774
+ }
775
+ export async function runLegacyRuleContractPreflight(pluginDir, workspaceDir, runScan = defaultLegacyRulePreflightRunner) {
776
+ const resolvedWorkspace = path.resolve(workspaceDir);
777
+ // Self-contained status decision (P0/P1-2): a workspace with no persisted
778
+ // PD state has nothing a contract change could break, so the scan is
779
+ // skipped cleanly. The function must NOT rely on the caller having
780
+ // pre-checked for state.db — Console/installer callers differ.
781
+ const stateDbPath = path.join(resolvedWorkspace, '.pd', 'state.db');
782
+ if (!existsSync(stateDbPath)) {
783
+ return {
784
+ ok: true,
785
+ status: 'no_state_db',
786
+ reason: 'state.db not found — no persisted activations to scan',
787
+ };
788
+ }
789
+ const pdCliRoot = path.resolve(pluginDir, 'pd-cli');
790
+ const pdCliEntry = path.resolve(pdCliRoot, 'dist', 'index.js');
791
+ if (pdCliEntry !== pdCliRoot && !pdCliEntry.startsWith(pdCliRoot + path.sep)) {
792
+ return {
793
+ ok: false,
794
+ status: 'scan_unavailable',
795
+ reason: `compatibility_scan_unavailable: pd-cli entry escapes package dir (${pdCliEntry})`,
796
+ remediation: 'Re-download/rebuild the installer before upgrading. The current installation has not been replaced.',
797
+ };
798
+ }
799
+ if (!existsSync(pdCliEntry) || !statSync(pdCliEntry).isFile()) {
800
+ // A stateful workspace is about to be upgraded and we cannot prove its
801
+ // active RuleCode is compatible with the new runtime. This is NOT a
802
+ // "fresh layout, skip it" case — fresh workspaces were handled above by
803
+ // the no_state_db early return. Fail closed (P0/P1-2).
804
+ return {
805
+ ok: false,
806
+ status: 'scan_unavailable',
807
+ reason: 'compatibility_scan_unavailable',
808
+ remediation: 'The bundled compatibility scanner is missing. The package may be incomplete or corrupted. ' +
809
+ 'Re-download/rebuild the installer before upgrading. The current installation has not been replaced.',
810
+ };
811
+ }
812
+ try {
813
+ return await runScan(pdCliEntry, resolvedWorkspace);
814
+ }
815
+ catch (err) {
816
+ return {
817
+ ok: false,
818
+ status: 'scan_failed',
819
+ reason: `compatibility_scan_failed: ${err instanceof Error ? err.message : String(err)}`,
820
+ remediation: 'Repair the workspace database or its permissions, then retry the upgrade. The current installation has not been replaced.',
821
+ };
822
+ }
823
+ }
824
+ /**
825
+ * Structured refusal for the legacy-contract preflight: nothing has been
826
+ * mutated yet (the old installation is untouched) and the message names the
827
+ * affected rules via the scan's remediation text.
828
+ */
829
+ function buildCompatibilityRefusalResult(options, outcome) {
830
+ const nextAction = outcome.remediation ?? 'Migrate or deactivate the listed rules, then re-run the installer.';
831
+ return {
832
+ success: false,
833
+ workspaceDir: options.workspaceDir,
834
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
835
+ templatesCount: 0,
836
+ components: { plugin: 'skipped', cli: 'skipped', console: 'skipped' },
837
+ verification: { features: 'skipped', storyA: 'skipped' },
838
+ enabledChannels: options.channels,
839
+ nextAction,
840
+ reason: outcome.reason ?? 'legacy_rule_contract_dependency',
841
+ ...(outcome.remediation !== undefined ? { error: outcome.remediation } : {}),
842
+ };
843
+ }
844
+ export async function checkBuiltPlugin(pluginDir) {
845
+ const distDir = path.join(pluginDir, 'plugin', 'dist');
846
+ const pluginJson = path.join(pluginDir, 'plugin', 'openclaw.plugin.json');
847
+ if (!existsSync(distDir) || !existsSync(pluginJson)) {
848
+ throw new Error(`Built plugin files missing at ${distDir}. Package may be corrupted.`);
849
+ }
850
+ const manifestRaw = JSON.parse(readFileSync(pluginJson, 'utf-8'));
851
+ if (typeof manifestRaw !== 'object' || manifestRaw === null || Array.isArray(manifestRaw)) {
852
+ throw new Error('openclaw.plugin.json is not a valid object');
853
+ }
854
+ const manifest = manifestRaw;
855
+ const { activation } = manifest;
856
+ if (typeof activation !== 'object' || activation === null || Array.isArray(activation)) {
857
+ throw new Error('openclaw.plugin.json is missing activation object — PD hooks will not execute via gateway');
858
+ }
859
+ const activationObj = activation;
860
+ const { onCapabilities } = activationObj;
861
+ if (!Array.isArray(onCapabilities) || !onCapabilities.includes('hook')) {
862
+ throw new Error('openclaw.plugin.json.activation.onCapabilities does not include "hook" — PD hooks will not execute via gateway. Re-bundle after PR #725 is merged.');
863
+ }
864
+ const pkgJsonPath = path.join(pluginDir, 'plugin', 'package.json');
865
+ if (existsSync(pkgJsonPath)) {
866
+ const pkgRaw = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
867
+ if (typeof pkgRaw === 'object' && pkgRaw !== null && !Array.isArray(pkgRaw)) {
868
+ const pkg = pkgRaw;
869
+ const { openclaw } = pkg;
870
+ if (typeof openclaw === 'object' && openclaw !== null && !Array.isArray(openclaw)) {
871
+ const openclawObj = openclaw;
872
+ if (openclawObj.setupEntry !== './dist/bundle.js') {
873
+ throw new Error(`plugin package.json openclaw.setupEntry is "${String(openclawObj.setupEntry)}" (expected "./dist/bundle.js") — gateway will not load PD hooks. Re-bundle after PR #725 is merged.`);
874
+ }
875
+ }
876
+ }
877
+ }
878
+ }
879
+ export async function installPluginToStaging(pluginDir, language) {
880
+ const builtPluginDir = path.join(pluginDir, 'plugin');
881
+ for (const targetDir of pluginInstallDirs()) {
882
+ await fse.ensureDir(targetDir);
883
+ await fse.copy(builtPluginDir, targetDir, { overwrite: true });
884
+ // The published plugin declares its bundled core as file:./core. Keep that
885
+ // package-local contract while storing the actual core once in the shared
886
+ // host-neutral runtime directory.
887
+ const pluginCoreLink = path.join(targetDir, 'core');
888
+ if (!existsSync(pluginCoreLink)) {
889
+ const coreDir = path.join(getPdRuntimeDir(), 'core');
890
+ if (isWindows())
891
+ symlinkSync(coreDir, pluginCoreLink, 'junction');
892
+ else
893
+ symlinkSync(path.relative(targetDir, coreDir), pluginCoreLink, 'dir');
894
+ }
895
+ // OpenClaw publishes skills by name with no locale mechanism. Applying the
896
+ // same selected language to both copies also keeps the canonical package
897
+ // ready if another host is attached later.
898
+ const selection = applySkillLanguageSelection(targetDir, language);
899
+ if (!selection.applied) {
900
+ logger.warn(`Skill language "${language}" not applied at ${targetDir} (${selection.note ?? 'unknown'}) — published skills stay at the manifest default`);
901
+ }
902
+ }
903
+ }
904
+ // ADR-0020 §2.3: The OpenClaw config write logic previously lived here as
905
+ // `updateOpenClawConfig()`. It has been extracted to OpenClawHostInstaller
906
+ // (./installers/openclaw-host-installer.ts) and is invoked via
907
+ // `runHostInstallers()` below. This file no longer writes openclaw.json
908
+ // directly — the HostInstaller interface keeps install/uninstall host-agnostic.
909
+ async function installPluginDependencies() {
910
+ for (const extDir of pluginInstallDirs()) {
911
+ await prepareComponentDependencies(extDir, 'Plugin');
912
+ }
913
+ }
914
+ function getNpmGlobalBinDir() {
915
+ try {
916
+ const prefix = execNpm(['prefix', '-g']).trim();
917
+ if (!prefix)
918
+ return null;
919
+ return process.platform === 'win32' ? prefix : path.join(prefix, 'bin');
920
+ }
921
+ catch {
922
+ return null;
923
+ }
924
+ }
925
+ function installGlobalPdShim() {
926
+ if (!legacyNpmInstallEnabled()) {
927
+ logger.info('Skipping npm global shim discovery for the self-contained release asset.');
928
+ return false;
929
+ }
930
+ // Allow skipping global shim installation in smoke tests to avoid
931
+ // polluting the host's npm global bin dir. The bundled pd-cli is
932
+ // still installed locally (getInstalledBinDir); only the global
933
+ // symlink/shim is skipped. Mirrors PD_SKIP_NPM_UPGRADE pattern.
934
+ if (process.env.PD_SKIP_GLOBAL_SHIM === '1' || process.env.PD_SKIP_GLOBAL_SHIM === 'true') {
935
+ logger.info('Skipping global pd shim installation (PD_SKIP_GLOBAL_SHIM set).');
936
+ return false;
937
+ }
938
+ const globalBin = getNpmGlobalBinDir();
939
+ if (!globalBin)
940
+ return false;
941
+ try {
942
+ mkdirSync(globalBin, { recursive: true });
943
+ const installedBinDir = getInstalledBinDir();
944
+ if (isWindows()) {
945
+ const pluginCmd = path.join(installedBinDir, 'pd.cmd');
946
+ writeFileSync(path.join(globalBin, 'pd.cmd'), `@echo off\r\ncall "${pluginCmd.replace(/"/g, '""')}" %*\r\n`, 'utf-8');
947
+ const pluginPs = path.join(installedBinDir, 'pd.ps1');
948
+ writeFileSync(path.join(globalBin, 'pd.ps1'), `$shim = "${pluginPs.replace(/`/g, '``').replace(/"/g, '`"')}"\r\n& $shim @args\r\nexit $LASTEXITCODE\r\n`, 'utf-8');
949
+ }
950
+ else {
951
+ const pluginSh = path.join(installedBinDir, 'pd');
952
+ const globalSh = path.join(globalBin, 'pd');
953
+ writeFileSync(globalSh, `#!/usr/bin/env sh\nexec "${pluginSh.replace(/"/g, '\\"')}" "$@"\n`, 'utf-8');
954
+ chmodSync(globalSh, 0o755);
955
+ }
956
+ return true;
957
+ }
958
+ catch (e) {
959
+ logger.warn(`Global pd shim installation failed: ${e instanceof Error ? e.message : String(e)}`);
960
+ return false;
961
+ }
962
+ }
963
+ function tryUpgradePdCliFromNpm(installedPdCliDir) {
964
+ if (!legacyNpmInstallEnabled()) {
965
+ logger.info('Skipping npm pd-cli upgrade for the self-contained release asset.');
966
+ return;
967
+ }
968
+ // Allow skipping the npm upgrade in smoke tests / offline environments.
969
+ // The bundled pd-cli is built from the current repo state and is the
970
+ // authoritative version for testing. Upgrading to an npm-published version
971
+ // can introduce incompatibilities (e.g., when local core has removed
972
+ // exports that the npm pd-cli still imports).
973
+ if (process.env.PD_SKIP_NPM_UPGRADE === '1' || process.env.PD_SKIP_NPM_UPGRADE === 'true') {
974
+ logger.info('Skipping pd-cli npm upgrade (PD_SKIP_NPM_UPGRADE set).');
975
+ return;
976
+ }
977
+ try {
978
+ const npmVersion = execNpm(['view', '@principles/pd-cli', 'version'], undefined, 15_000).trim();
979
+ // Fully anchored semver: this value is interpolated into a cmd.exe
980
+ // command line below, so a registry response carrying shell
981
+ // metacharacters after a valid-looking prefix must be rejected outright.
982
+ if (!npmVersion || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(npmVersion))
983
+ return;
984
+ const localPkgPath = path.join(installedPdCliDir, 'package.json');
985
+ const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf-8'));
986
+ const localVersion = localPkg.version;
987
+ if (npmVersion === localVersion)
988
+ return;
989
+ logger.info(`Upgrading pd-cli from bundled v${localVersion} to npm v${npmVersion}...`);
990
+ const tmpDir = path.join(installedPdCliDir, '__npm_upgrade_tmp');
991
+ try {
992
+ mkdirSync(tmpDir, { recursive: true });
993
+ // Platform dispatch (CodeRabbit review): cmd.exe does not exist on
994
+ // POSIX, where npm is a real executable and can be spawned directly.
995
+ // The only registry-derived value on this command line is npmVersion,
996
+ // restricted to a metacharacter-free semver charset by the anchored
997
+ // check above; tmpDir is the local install root (profile-derived, not
998
+ // attacker-controlled). CodeQL's js/indirect-command-line-injection
999
+ // cannot model the regex sanitizer — see the evidence-based dismissal
1000
+ // on alert 431.
1001
+ const packArgs = ['pack', `@principles/pd-cli@${npmVersion}`, '--pack-destination', tmpDir];
1002
+ if (process.platform === 'win32') {
1003
+ execFileSync('cmd.exe', ['/d', '/s', '/c', 'npm', ...packArgs], {
1004
+ encoding: 'utf-8',
1005
+ timeout: 30_000,
1006
+ stdio: ['pipe', 'pipe', 'pipe'],
1007
+ });
1008
+ }
1009
+ else {
1010
+ execFileSync('npm', packArgs, {
1011
+ encoding: 'utf-8',
1012
+ timeout: 30_000,
1013
+ stdio: ['pipe', 'pipe', 'pipe'],
1014
+ });
1015
+ }
1016
+ const tgzFiles = readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
1017
+ const [tgzFile] = tgzFiles;
1018
+ if (!tgzFile) {
1019
+ logger.info('No npm tarball found, keeping bundled version.');
1020
+ return;
1021
+ }
1022
+ const extractDir = path.join(tmpDir, 'extracted');
1023
+ mkdirSync(extractDir, { recursive: true });
1024
+ execFileSync('tar', ['-xzf', path.join(tmpDir, tgzFile), '-C', extractDir], {
1025
+ encoding: 'utf-8',
1026
+ timeout: 15_000,
1027
+ stdio: ['pipe', 'pipe', 'pipe'],
1028
+ });
1029
+ const packageDir = path.join(extractDir, 'package');
1030
+ if (!existsSync(path.join(packageDir, 'dist', 'index.js'))) {
1031
+ logger.info('Npm package structure unexpected, keeping bundled version.');
1032
+ return;
1033
+ }
1034
+ rmSync(path.join(installedPdCliDir, 'dist'), { recursive: true, force: true });
1035
+ cpSync(path.join(packageDir, 'dist'), path.join(installedPdCliDir, 'dist'), { recursive: true });
1036
+ if (existsSync(path.join(packageDir, 'package.json'))) {
1037
+ copyFileSync(path.join(packageDir, 'package.json'), localPkgPath);
1038
+ }
1039
+ logger.success(`pd-cli upgraded to v${npmVersion}`);
1040
+ }
1041
+ finally {
1042
+ rmSync(tmpDir, { recursive: true, force: true });
1043
+ }
1044
+ }
1045
+ catch (e) {
1046
+ const msg = e instanceof Error ? e.message : String(e);
1047
+ logger.info(`pd-cli npm upgrade skipped (${msg}). Bundled version is functional.`);
1048
+ }
1049
+ }
1050
+ function syncPdCli(pluginDir) {
1051
+ const pdCliSourceDir = path.join(pluginDir, 'pd-cli');
1052
+ const distDir = path.join(pdCliSourceDir, 'dist');
1053
+ if (!existsSync(path.join(distDir, 'index.js'))) {
1054
+ throw new Error('PD CLI dist/index.js not found in package. Cannot install pd command.');
1055
+ }
1056
+ const installedPdCliDir = getInstalledPdCliDir();
1057
+ rmSync(installedPdCliDir, { recursive: true, force: true });
1058
+ mkdirSync(installedPdCliDir, { recursive: true });
1059
+ cpSync(distDir, path.join(installedPdCliDir, 'dist'), { recursive: true });
1060
+ copyFileSync(path.join(pdCliSourceDir, 'package.json'), path.join(installedPdCliDir, 'package.json'));
1061
+ const bundledNodeModules = path.join(pdCliSourceDir, 'node_modules');
1062
+ if (existsSync(bundledNodeModules)) {
1063
+ cpSync(bundledNodeModules, path.join(installedPdCliDir, 'node_modules'), { recursive: true });
1064
+ }
1065
+ // Create node_modules/@principles/core symlink so pd-cli can resolve
1066
+ // its @principles/core dependency (rewritten to "file:../core" by bundle-plugin.mjs).
1067
+ // Without this, `node dist/index.js --version` fails because static imports
1068
+ // from @principles/core/runtime-v2 cannot be resolved.
1069
+ const coreLinkDir = path.join(installedPdCliDir, 'node_modules', '@principles');
1070
+ const coreLinkTarget = path.join(getPdRuntimeDir(), 'core');
1071
+ mkdirSync(coreLinkDir, { recursive: true });
1072
+ const coreLinkPath = path.join(coreLinkDir, 'core');
1073
+ if (!existsSync(coreLinkPath)) {
1074
+ if (isWindows()) {
1075
+ // On Windows, use a junction (directory symlink) which doesn't require elevated privileges
1076
+ symlinkSync(coreLinkTarget, coreLinkPath, 'junction');
1077
+ }
1078
+ else {
1079
+ // On Unix, use a relative symlink for portability
1080
+ symlinkSync('../../../core', coreLinkPath, 'dir');
1081
+ }
1082
+ }
1083
+ // Create node_modules/@principles/host-runtime symlink so pd-cli can resolve
1084
+ // its @principles/host-runtime dependency (rewritten to "file:../host-runtime"
1085
+ // by bundle-plugin.mjs). Without this, `pd --version` crashes with
1086
+ // ERR_MODULE_NOT_FOUND because pd-cli statically imports createProductionHostRuntime.
1087
+ // host-runtime's own better-sqlite3 / js-yaml / @principles/core dependencies
1088
+ // resolve through the plugin's <ext>/node_modules/ (shared via Node's upward
1089
+ // module resolution), so no separate npm install is needed.
1090
+ const hostRuntimeLinkTarget = path.join(getPdRuntimeDir(), 'host-runtime');
1091
+ const hostRuntimeLinkPath = path.join(coreLinkDir, 'host-runtime');
1092
+ if (!existsSync(hostRuntimeLinkPath)) {
1093
+ if (isWindows()) {
1094
+ symlinkSync(hostRuntimeLinkTarget, hostRuntimeLinkPath, 'junction');
1095
+ }
1096
+ else {
1097
+ symlinkSync('../../../host-runtime', hostRuntimeLinkPath, 'dir');
1098
+ }
1099
+ }
1100
+ // Create node_modules/@principles/codex-adapter symlink so pd-cli can resolve
1101
+ // its @principles/codex-adapter dependency (rewritten to
1102
+ // "file:../codex-adapter" by bundle-plugin.mjs). Without this, `pd codex
1103
+ // worker` crashes with ERR_MODULE_NOT_FOUND (PRI-624).
1104
+ const codexAdapterLinkTarget = path.join(getPdRuntimeDir(), 'codex-adapter');
1105
+ const codexAdapterLinkPath = path.join(coreLinkDir, 'codex-adapter');
1106
+ if (!existsSync(codexAdapterLinkPath)) {
1107
+ if (isWindows()) {
1108
+ symlinkSync(codexAdapterLinkTarget, codexAdapterLinkPath, 'junction');
1109
+ }
1110
+ else {
1111
+ symlinkSync('../../../codex-adapter', codexAdapterLinkPath, 'dir');
1112
+ }
1113
+ }
1114
+ // Create node_modules/principles-disciple symlink so pd-cli can resolve
1115
+ // its principles-disciple dependency (the plugin package, rewritten to
1116
+ // "file:../plugin" by bundle-plugin.mjs). The plugin is installed at the
1117
+ // extension dir root (getPluginExtDir()), so the symlink target is the
1118
+ // ext dir itself — Node resolves `import 'principles-disciple'` via the
1119
+ // plugin's package.json exports field.
1120
+ // Without this, `pd runtime init` crashes with ERR_MODULE_NOT_FOUND
1121
+ // because runtime-init.ts statically imports initTrajectorySchema/initWorkflowSchema.
1122
+ const pdLinkDir = path.join(installedPdCliDir, 'node_modules');
1123
+ const pdLinkTarget = installedPluginDir();
1124
+ mkdirSync(pdLinkDir, { recursive: true });
1125
+ const pdLinkPath = path.join(pdLinkDir, 'principles-disciple');
1126
+ if (!existsSync(pdLinkPath)) {
1127
+ if (isWindows()) {
1128
+ symlinkSync(pdLinkTarget, pdLinkPath, 'junction');
1129
+ }
1130
+ else {
1131
+ // Relative from <ext>/pd-cli/node_modules/ to <ext>/: go up twice
1132
+ // (node_modules → pd-cli → ext)
1133
+ symlinkSync('../../', pdLinkPath, 'dir');
1134
+ }
1135
+ }
1136
+ tryUpgradePdCliFromNpm(installedPdCliDir);
1137
+ const installedBinDir = getInstalledBinDir();
1138
+ mkdirSync(installedBinDir, { recursive: true });
1139
+ const installedEntry = path.join(installedPdCliDir, 'dist', 'index.js');
1140
+ if (isWindows()) {
1141
+ const cmdShim = [
1142
+ '@echo off',
1143
+ `node "${installedEntry.replace(/"/g, '""')}" %*`,
1144
+ '',
1145
+ ].join('\r\n');
1146
+ writeFileSync(path.join(installedBinDir, 'pd.cmd'), cmdShim, 'utf-8');
1147
+ const psShim = [
1148
+ '$ErrorActionPreference = "Stop"',
1149
+ `$entry = "${installedEntry.replace(/`/g, '``').replace(/"/g, '`"')}"`,
1150
+ '& node $entry @args',
1151
+ 'exit $LASTEXITCODE',
1152
+ '',
1153
+ ].join('\r\n');
1154
+ writeFileSync(path.join(installedBinDir, 'pd.ps1'), psShim, 'utf-8');
1155
+ }
1156
+ else {
1157
+ const shShim = [
1158
+ '#!/usr/bin/env sh',
1159
+ `exec node "${installedEntry.replace(/"/g, '\\"')}" "$@"`,
1160
+ '',
1161
+ ].join('\n');
1162
+ const target = path.join(installedBinDir, 'pd');
1163
+ writeFileSync(target, shShim, 'utf-8');
1164
+ chmodSync(target, 0o755);
1165
+ }
1166
+ return installGlobalPdShim();
1167
+ }
1168
+ function verifyPdCliShim() {
1169
+ const localShim = path.join(getInstalledBinDir(), isWindows() ? 'pd.cmd' : 'pd');
1170
+ let localOk = false;
1171
+ let localError;
1172
+ try {
1173
+ const installedEntry = path.join(getInstalledPdCliDir(), 'dist', 'index.js');
1174
+ execFileSync(process.execPath, [installedEntry, '--version'], { stdio: 'pipe', timeout: PD_CLI_VERIFICATION_TIMEOUT_MS });
1175
+ localOk = true;
1176
+ }
1177
+ catch (e) {
1178
+ let detail = 'unknown error';
1179
+ if (typeof e === 'object' && e !== null) {
1180
+ const stderr = Object.hasOwn(e, 'stderr') ? Reflect.get(e, 'stderr') : undefined;
1181
+ const message = Object.hasOwn(e, 'message') ? Reflect.get(e, 'message') : undefined;
1182
+ const stderrText = Buffer.isBuffer(stderr) ? stderr.toString().trim().slice(0, 500) : '';
1183
+ if (stderrText.length > 0)
1184
+ detail = stderrText;
1185
+ else if (typeof message === 'string' && message.trim().length > 0)
1186
+ detail = message.trim().slice(0, 500);
1187
+ }
1188
+ else if (typeof e === 'string' && e.trim().length > 0) {
1189
+ detail = e.trim().slice(0, 500);
1190
+ }
1191
+ localError = detail;
1192
+ logger.warn(`PD CLI local verification failed: ${detail}`);
1193
+ }
1194
+ const globalOk = (() => {
1195
+ try {
1196
+ if (isWindows()) {
1197
+ // 'pd' is an npm .cmd shim on Windows — resolve via cmd.exe with a
1198
+ // constant argv array (no shell string, no interpolation).
1199
+ execFileSync('cmd.exe', ['/c', 'pd', '--version'], { stdio: 'pipe', timeout: PD_CLI_VERIFICATION_TIMEOUT_MS, windowsHide: true });
1200
+ }
1201
+ else {
1202
+ execFileSync('pd', ['--version'], { stdio: 'pipe', timeout: PD_CLI_VERIFICATION_TIMEOUT_MS });
1203
+ }
1204
+ return true;
1205
+ }
1206
+ catch {
1207
+ return false;
1208
+ }
1209
+ })();
1210
+ return { localOk, globalOk, localPath: localShim, localError };
1211
+ }
1212
+ /**
1213
+ * PRI-672: the ReleaseManager authority module ships as the release-manager/
1214
+ * payload component (package name create-principles-disciple). Installed to
1215
+ * ~/.pd/runtime so the console's rewritten `create-principles-disciple`
1216
+ * dependency resolves; installConsole creates the resolution link.
1217
+ */
1218
+ function getInstalledReleaseManagerDir() {
1219
+ return path.join(getPdRuntimeDir(), 'release-manager');
1220
+ }
1221
+ function installBundledReleaseManagerPackage(pluginDir) {
1222
+ const source = path.join(pluginDir, 'release-manager');
1223
+ const destination = getInstalledReleaseManagerDir();
1224
+ if (!existsSync(path.join(source, 'package.json')) || !existsSync(path.join(source, 'dist', 'update', 'release-manager-authority.js'))) {
1225
+ throw new Error('Bundled release-manager component is incomplete (missing package.json or dist/update/release-manager-authority.js). Re-run the installer with a current package.');
1226
+ }
1227
+ rmSync(destination, { recursive: true, force: true });
1228
+ cpSync(source, destination, { recursive: true });
1229
+ }
1230
+ /**
1231
+ * PR #1525 review: the npm-distributed payload ships release-manager/ as
1232
+ * package.json + dist only — no node_modules. Without its own dependencies
1233
+ * (tuf-js / @tufjs/models) the authority module's static import chain
1234
+ * (release-manager → trust-metadata → tuf-js) cannot resolve, and the gap
1235
+ * would only surface later as `installer_missing` in the console. The
1236
+ * self-contained payload ships the bundle-time npm ci node_modules, so
1237
+ * registry resolution runs for the npm-distributed shape only.
1238
+ */
1239
+ async function installReleaseManagerDependencies() {
1240
+ if (!isNpmDependencyResolutionEnabled())
1241
+ return;
1242
+ await runNpmInstall(getInstalledReleaseManagerDir(), 'ReleaseManager');
1243
+ }
1244
+ /**
1245
+ * PR #1525 review smoke: import the REAL authority module from the installed
1246
+ * tree so a dependency gap fails the install loudly here with a structured
1247
+ * message instead of surfacing as `installer_missing` at console runtime.
1248
+ * Mode-independent — the self-contained payload's shipped node_modules is
1249
+ * proven the same way.
1250
+ */
1251
+ async function verifyReleaseManagerAuthorityImports() {
1252
+ const authorityPath = path.join(getInstalledReleaseManagerDir(), 'dist', 'update', 'release-manager-authority.js');
1253
+ if (!existsSync(authorityPath)) {
1254
+ throw new Error('Installed release-manager authority module not found — installation is incomplete.');
1255
+ }
1256
+ try {
1257
+ await import(pathToFileURL(authorityPath).href);
1258
+ }
1259
+ catch (error) {
1260
+ const detail = error instanceof Error ? error.message : String(error);
1261
+ throw new Error(`ReleaseManager authority module failed to load — its runtime dependencies are incomplete. Re-run the installer to repair the installation. (${detail})`, { cause: error });
1262
+ }
1263
+ }
1264
+ function installConsole(consoleDir) {
1265
+ const consoleSrc = path.join(consoleDir, 'console');
1266
+ const consoleDest = getInstalledConsoleDir();
1267
+ if (!existsSync(consoleSrc)) {
1268
+ throw new Error('Console bundle not found in package. Cannot install pd-console.');
1269
+ }
1270
+ const serverJs = path.join(consoleSrc, 'dist', 'server.js');
1271
+ const webIndex = path.join(consoleSrc, 'dist', 'web', 'index.html');
1272
+ if (!existsSync(serverJs)) {
1273
+ throw new Error('Console dist/server.js not found in bundle. Package may be corrupted.');
1274
+ }
1275
+ if (!existsSync(webIndex)) {
1276
+ throw new Error('Console dist/web/index.html not found in bundle. Package may be corrupted.');
1277
+ }
1278
+ rmSync(consoleDest, { recursive: true, force: true });
1279
+ cpSync(consoleSrc, consoleDest, { recursive: true });
1280
+ const pluginLinkPath = path.join(consoleDest, 'node_modules', 'principles-disciple');
1281
+ mkdirSync(path.dirname(pluginLinkPath), { recursive: true });
1282
+ if (!existsSync(pluginLinkPath)) {
1283
+ if (isWindows()) {
1284
+ symlinkSync(installedPluginDir(), pluginLinkPath, 'junction');
1285
+ }
1286
+ else {
1287
+ symlinkSync(path.relative(path.dirname(pluginLinkPath), installedPluginDir()), pluginLinkPath, 'dir');
1288
+ }
1289
+ }
1290
+ // PRI-672: console resolves its create-principles-disciple dependency (the
1291
+ // ReleaseManager authority module, rewritten to file:../release-manager by
1292
+ // bundle-plugin.mjs) through this link into ~/.pd/runtime.
1293
+ const releaseManagerLinkPath = path.join(consoleDest, 'node_modules', 'create-principles-disciple');
1294
+ if (!existsSync(releaseManagerLinkPath)) {
1295
+ mkdirSync(path.dirname(releaseManagerLinkPath), { recursive: true });
1296
+ if (isWindows()) {
1297
+ symlinkSync(getInstalledReleaseManagerDir(), releaseManagerLinkPath, 'junction');
1298
+ }
1299
+ else {
1300
+ symlinkSync(path.relative(path.dirname(releaseManagerLinkPath), getInstalledReleaseManagerDir()), releaseManagerLinkPath, 'dir');
1301
+ }
1302
+ }
1303
+ }
1304
+ function getInstalledCoreDir() {
1305
+ return path.join(getPdRuntimeDir(), 'core');
1306
+ }
1307
+ function getInstalledHostRuntimeDir() {
1308
+ return path.join(getPdRuntimeDir(), 'host-runtime');
1309
+ }
1310
+ function getInstalledCodexAdapterDir() {
1311
+ return path.join(getPdRuntimeDir(), 'codex-adapter');
1312
+ }
1313
+ function installBundledCore(pluginDir) {
1314
+ const coreSrc = path.join(pluginDir, 'core');
1315
+ const coreDest = getInstalledCoreDir();
1316
+ if (!existsSync(coreSrc)) {
1317
+ throw new Error('Bundled @principles/core not found in package. Cannot resolve runtime dependencies.');
1318
+ }
1319
+ const corePkgJson = path.join(coreSrc, 'package.json');
1320
+ const coreDist = path.join(coreSrc, 'dist');
1321
+ if (!existsSync(corePkgJson) || !existsSync(coreDist)) {
1322
+ throw new Error('Bundled @principles/core is incomplete (missing package.json or dist). Package may be corrupted.');
1323
+ }
1324
+ rmSync(coreDest, { recursive: true, force: true });
1325
+ cpSync(coreSrc, coreDest, { recursive: true });
1326
+ }
1327
+ function installBundledHostRuntime(pluginDir) {
1328
+ const hostRuntimeSrc = path.join(pluginDir, 'host-runtime');
1329
+ const hostRuntimeDest = getInstalledHostRuntimeDir();
1330
+ if (!existsSync(hostRuntimeSrc)) {
1331
+ throw new Error('Bundled @principles/host-runtime not found in package. Cannot resolve runtime dependencies.');
1332
+ }
1333
+ const hostRuntimePkgJson = path.join(hostRuntimeSrc, 'package.json');
1334
+ const hostRuntimeDist = path.join(hostRuntimeSrc, 'dist');
1335
+ if (!existsSync(hostRuntimePkgJson) || !existsSync(hostRuntimeDist)) {
1336
+ throw new Error('Bundled @principles/host-runtime is incomplete (missing package.json or dist). Package may be corrupted.');
1337
+ }
1338
+ rmSync(hostRuntimeDest, { recursive: true, force: true });
1339
+ cpSync(hostRuntimeSrc, hostRuntimeDest, { recursive: true });
1340
+ }
1341
+ /**
1342
+ * PRI-624: pd-cli's `codex worker` / `codex ingest catch-up` commands import
1343
+ * the workspace worker cycle from @principles/codex-adapter, so the bundled
1344
+ * package is installed as a runtime sibling exactly like host-runtime.
1345
+ */
1346
+ function installBundledCodexAdapter(pluginDir) {
1347
+ const codexAdapterSrc = path.join(pluginDir, 'codex-adapter');
1348
+ const codexAdapterDest = getInstalledCodexAdapterDir();
1349
+ if (!existsSync(codexAdapterSrc)) {
1350
+ throw new Error('Bundled @principles/codex-adapter not found in package. Cannot resolve runtime dependencies.');
1351
+ }
1352
+ const codexAdapterPkgJson = path.join(codexAdapterSrc, 'package.json');
1353
+ const codexAdapterDist = path.join(codexAdapterSrc, 'dist');
1354
+ if (!existsSync(codexAdapterPkgJson) || !existsSync(codexAdapterDist)) {
1355
+ throw new Error('Bundled @principles/codex-adapter is incomplete (missing package.json or dist). Package may be corrupted.');
1356
+ }
1357
+ rmSync(codexAdapterDest, { recursive: true, force: true });
1358
+ cpSync(codexAdapterSrc, codexAdapterDest, { recursive: true });
1359
+ }
1360
+ function ensureCoreDependency(_targetDir) {
1361
+ const coreDir = getInstalledCoreDir();
1362
+ if (!existsSync(coreDir)) {
1363
+ throw new Error('Installed @principles/core not found. Run installBundledCore first.');
1364
+ }
1365
+ }
1366
+ async function installCoreDependencies() {
1367
+ const coreDir = getInstalledCoreDir();
1368
+ await prepareComponentDependencies(coreDir, 'Core');
1369
+ }
1370
+ async function installHostRuntimeDependencies() {
1371
+ const hostRuntimeDir = getInstalledHostRuntimeDir();
1372
+ await prepareComponentDependencies(hostRuntimeDir, 'Host runtime');
1373
+ }
1374
+ async function installPdCliDependencies() {
1375
+ const pdCliDir = getInstalledPdCliDir();
1376
+ await prepareComponentDependencies(pdCliDir, 'PD CLI');
1377
+ }
1378
+ async function installConsoleDependencies() {
1379
+ const consoleDest = getInstalledConsoleDir();
1380
+ await prepareComponentDependencies(consoleDest, 'Console');
1381
+ }
1382
+ const CONSOLE_PORT_MAX_RETRIES = 3;
1383
+ async function verifyConsole(workspaceDir) {
1384
+ const consoleDest = getInstalledConsoleDir();
1385
+ const serverEntry = path.join(consoleDest, 'dist', 'server.js');
1386
+ if (!existsSync(serverEntry)) {
1387
+ return { ok: false, url: '', process: null, reason: 'Console server entry not found' };
1388
+ }
1389
+ // EP-06 regression guard (PR #1169): verify the web UI bundle exists BEFORE
1390
+ // spawning the server. Without dist/web/index.html the server returns 404
1391
+ // "Run npm run build:ui first" on every route — a fatal first-impression bug
1392
+ // for new users. npm install --ignore-scripts can remove dist/ in edge cases.
1393
+ const webIndex = path.join(consoleDest, 'dist', 'web', 'index.html');
1394
+ if (!existsSync(webIndex)) {
1395
+ return { ok: false, url: '', process: null, reason: 'Console web UI (dist/web/index.html) not found — bundle may be corrupted or npm install removed dist/. Re-run: npx create-principles-disciple' };
1396
+ }
1397
+ // Build a shuffled port list to avoid retrying the same port
1398
+ const portRange = [];
1399
+ for (let p = CONSOLE_PORT_RANGE_MIN; p <= CONSOLE_PORT_RANGE_MAX; p++)
1400
+ portRange.push(p);
1401
+ for (let i = portRange.length - 1; i > 0; i--) {
1402
+ const j = Math.floor(Math.random() * (i + 1));
1403
+ const a = portRange[i];
1404
+ const b = portRange[j];
1405
+ if (a === undefined || b === undefined)
1406
+ continue;
1407
+ portRange[i] = b;
1408
+ portRange[j] = a;
1409
+ }
1410
+ const portsToTry = portRange.slice(0, CONSOLE_PORT_MAX_RETRIES);
1411
+ let lastReason = '';
1412
+ for (const port of portsToTry) {
1413
+ const child = spawn(process.execPath, [serverEntry, '--workspace', workspaceDir, '--port', String(port), '--no-auth', '--host', '127.0.0.1'], {
1414
+ stdio: 'pipe',
1415
+ env: { ...process.env },
1416
+ detached: false,
1417
+ });
1418
+ let childExited = false;
1419
+ let childExitCode = null;
1420
+ let childStderr = '';
1421
+ let childStdout = '';
1422
+ child.on('exit', (code) => {
1423
+ childExited = true;
1424
+ childExitCode = code;
1425
+ });
1426
+ child.stderr?.on('data', (chunk) => {
1427
+ childStderr += chunk.toString();
1428
+ });
1429
+ child.stdout?.on('data', (chunk) => {
1430
+ childStdout += chunk.toString();
1431
+ });
1432
+ await new Promise((resolve) => { setTimeout(resolve, CONSOLE_WARMUP_TIME_MS); });
1433
+ if (childExited) {
1434
+ lastReason = `Console process exited prematurely with code ${childExitCode} on port ${port}. Stderr: ${childStderr.slice(0, 500)}`;
1435
+ continue; // Try next port
1436
+ }
1437
+ const url = `http://127.0.0.1:${port}`;
1438
+ let ok = false;
1439
+ let reason = '';
1440
+ try {
1441
+ const result = await new Promise((resolve, reject) => {
1442
+ const req = http.get(`${url}/api/health`, (res) => {
1443
+ const chunks = [];
1444
+ res.on('data', (chunk) => chunks.push(chunk));
1445
+ res.on('end', () => {
1446
+ resolve({ statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString() });
1447
+ });
1448
+ });
1449
+ req.on('error', reject);
1450
+ req.setTimeout(CONSOLE_HEALTH_CHECK_TIMEOUT_MS, () => { req.destroy(); reject(new Error('health check timeout')); });
1451
+ });
1452
+ if (result.statusCode !== 200) {
1453
+ reason = `Console /api/health returned HTTP ${result.statusCode} (expected 200)`;
1454
+ }
1455
+ else if (!result.body || result.body.trim().length === 0) {
1456
+ reason = 'Console /api/health returned empty body';
1457
+ }
1458
+ else {
1459
+ try {
1460
+ const parsed = JSON.parse(result.body);
1461
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1462
+ reason = 'Console /api/health returned non-object JSON';
1463
+ }
1464
+ else {
1465
+ ok = true;
1466
+ }
1467
+ }
1468
+ catch {
1469
+ reason = 'Console /api/health returned malformed JSON';
1470
+ }
1471
+ }
1472
+ }
1473
+ catch (e) {
1474
+ reason = `Console health check failed on port ${port}: ${e instanceof Error ? e.message : String(e)}. Stderr: ${childStderr.slice(0, 300)}. Stdout: ${childStdout.slice(0, 300)}`;
1475
+ }
1476
+ if (!ok) {
1477
+ try {
1478
+ child.kill('SIGTERM');
1479
+ }
1480
+ catch { /* ignore */ }
1481
+ try {
1482
+ if (!childExited) {
1483
+ child.kill('SIGKILL');
1484
+ }
1485
+ }
1486
+ catch { /* ignore */ }
1487
+ lastReason = reason;
1488
+ continue; // Try next port
1489
+ }
1490
+ return { ok: true, url, process: child };
1491
+ }
1492
+ return { ok: false, url: '', process: null, reason: `All ${CONSOLE_PORT_MAX_RETRIES} port attempts failed. Last: ${lastReason}` };
1493
+ }
1494
+ // ─── Task 8: auto-launch console at end of successful install ────────────────
1495
+ /**
1496
+ * Task 8: Open the system browser to a URL. Minimal platform dispatch — NOT a
1497
+ * launcher subsystem. The console launcher (port detection, reuse, health) is
1498
+ * `pd console open` (handleConsoleOpen), reused via spawn in autoLaunchConsole.
1499
+ *
1500
+ * Best-effort: failures are reported but do not crash the installer.
1501
+ */
1502
+ function openBrowserForOnboarding(url) {
1503
+ let cmd;
1504
+ let args;
1505
+ if (process.platform === 'win32') {
1506
+ cmd = process.env.ComSpec || 'cmd.exe';
1507
+ args = ['/c', 'start', '""', url];
1508
+ }
1509
+ else if (process.platform === 'darwin') {
1510
+ cmd = 'open';
1511
+ args = [url];
1512
+ }
1513
+ else {
1514
+ cmd = 'xdg-open';
1515
+ args = [url];
1516
+ }
1517
+ try {
1518
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
1519
+ // P2-E: handle async spawn errors (ENOENT, EACCES) — without this,
1520
+ // the process emits an unhandled 'error' event that crashes Node.
1521
+ child.on('error', (err) => {
1522
+ logger.warn(`Browser launch failed asynchronously: ${err.message}`);
1523
+ });
1524
+ child.unref();
1525
+ return { opened: true };
1526
+ }
1527
+ catch (err) {
1528
+ return { opened: false, reason: err instanceof Error ? err.message : String(err) };
1529
+ }
1530
+ }
1531
+ /**
1532
+ * Probe /api/health on a single port. Reuses the http module already imported.
1533
+ */
1534
+ function probeAutolaunchHealth(port, timeoutMs = 1500) {
1535
+ return new Promise((resolve) => {
1536
+ const req = http.get(`http://127.0.0.1:${port}/api/health`, (res) => {
1537
+ if (res.statusCode !== 200) {
1538
+ resolve(false);
1539
+ return;
1540
+ }
1541
+ const chunks = [];
1542
+ res.on('data', (c) => chunks.push(c));
1543
+ res.on('end', () => {
1544
+ try {
1545
+ const body = JSON.parse(Buffer.concat(chunks).toString());
1546
+ // P1-B: verify a PD-specific field inside body.data, not just the generic
1547
+ // { success: true } envelope that ANY service could return. PD Console's
1548
+ // /api/health responds via sendSuccess -> { success: true, data: {...} } where
1549
+ // data.overall is one of 'healthy' | 'degraded' | 'error' (HealthCheckModel).
1550
+ // The previous top-level `body.healthy` branch was dead code (PD Console never
1551
+ // returns it) and `body.success === true` alone matched any generic service.
1552
+ // rc-5-object-hasown-not-in: use Object.hasOwn, not `in`, for untrusted keys.
1553
+ const isPdConsole = (() => {
1554
+ if (typeof body !== 'object' || body === null)
1555
+ return false;
1556
+ if (!Object.hasOwn(body, 'success') || Reflect.get(body, 'success') !== true)
1557
+ return false;
1558
+ if (!Object.hasOwn(body, 'data'))
1559
+ return false;
1560
+ const data = Reflect.get(body, 'data');
1561
+ if (typeof data !== 'object' || data === null)
1562
+ return false;
1563
+ // data.overall is the PD-specific discriminative field.
1564
+ if (!Object.hasOwn(data, 'overall'))
1565
+ return false;
1566
+ const overall = Reflect.get(data, 'overall');
1567
+ return overall === 'healthy' || overall === 'degraded' || overall === 'error';
1568
+ })();
1569
+ resolve(isPdConsole);
1570
+ }
1571
+ catch {
1572
+ resolve(false);
1573
+ }
1574
+ });
1575
+ });
1576
+ req.on('error', () => resolve(false));
1577
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); });
1578
+ });
1579
+ }
1580
+ /**
1581
+ * Task 8: Auto-launch the PD Console at the end of a successful install by
1582
+ * reusing `pd console open` (handleConsoleOpen). Spawns it detached so the
1583
+ * console survives installer exit, then opens the browser to /welcome.
1584
+ *
1585
+ * EP-03: on failure, returns consoleUrl=undefined + fallbackAction so the user
1586
+ * is never left without a way to reach the console.
1587
+ * EP-04: detached + unref keeps the console running after the installer exits.
1588
+ * EP-06: reuses pd-cli's handleConsoleOpen via the CLI entry — no new launcher.
1589
+ */
1590
+ async function autoLaunchConsole(workspaceDir) {
1591
+ const pdCliEntry = path.join(getInstalledPdCliDir(), 'dist', 'index.js');
1592
+ if (!existsSync(pdCliEntry)) {
1593
+ return { fallbackAction: `pd console open --workspace "${workspaceDir}" --no-auth (auto-launch skipped: pd CLI entry not found)` };
1594
+ }
1595
+ // Spawn `pd console open` detached — reuses handleConsoleOpen (port detection,
1596
+ // reuse, health). --no-browser because we open /welcome ourselves.
1597
+ const child = spawn(process.execPath, [pdCliEntry, 'console', 'open', '--workspace', workspaceDir, '--no-auth', '--no-browser'], { detached: true, stdio: 'ignore', shell: false });
1598
+ child.unref();
1599
+ // Wait for the console to become ready (bounded poll on 3100..3105).
1600
+ const deadline = Date.now() + CONSOLE_AUTOLAUNCH_READY_TIMEOUT_MS;
1601
+ while (Date.now() < deadline) {
1602
+ for (let i = 0; i < CONSOLE_AUTOLAUNCH_PORT_SCAN_LIMIT; i++) {
1603
+ const port = CONSOLE_AUTOLAUNCH_BASE_PORT + i;
1604
+ if (await probeAutolaunchHealth(port)) {
1605
+ // HashRouter: client-side routes live under /#/ — server only serves /,
1606
+ // anything else (incl. /welcome) is treated as an API path and returns
1607
+ // 404 not_found. Must open /#/welcome so the browser loads index.html
1608
+ // first, then React Router handles the hash segment client-side.
1609
+ const consoleUrl = `http://127.0.0.1:${port}/#/welcome`;
1610
+ const browserResult = openBrowserForOnboarding(consoleUrl);
1611
+ if (!browserResult.opened) {
1612
+ // EP-03: browser failed but console is up — surface the URL + reason.
1613
+ return { consoleUrl, fallbackAction: `Console ready at ${consoleUrl} (browser auto-open failed: ${browserResult.reason ?? 'unknown'} — open the URL manually)` };
1614
+ }
1615
+ return { consoleUrl };
1616
+ }
1617
+ }
1618
+ await new Promise((r) => setTimeout(r, CONSOLE_AUTOLAUNCH_POLL_INTERVAL_MS));
1619
+ }
1620
+ // EP-03: console did not become ready — provide manual launch instruction.
1621
+ return { fallbackAction: `pd console open --workspace "${workspaceDir}" --no-auth (auto-launch did not become ready in time; run manually)` };
1622
+ }
1623
+ async function copyCoreTemplates(opts) {
1624
+ let count = 0;
1625
+ const coreSrc = path.join(opts.pluginDir, 'templates', 'langs', opts.language, 'core');
1626
+ const fallbackSrc = path.join(opts.pluginDir, 'templates', 'langs', 'en', 'core');
1627
+ const actualSrc = existsSync(coreSrc) ? coreSrc : (existsSync(fallbackSrc) ? fallbackSrc : null);
1628
+ if (!actualSrc)
1629
+ return 0;
1630
+ const files = readdirSync(actualSrc).filter(f => f.endsWith('.md'));
1631
+ for (const file of files) {
1632
+ const srcPath = path.join(actualSrc, file);
1633
+ const destPath = path.join(opts.workspaceDir, file);
1634
+ // 验证目标路径安全
1635
+ validateWorkspacePath(destPath, opts.workspaceDir);
1636
+ if (existsSync(destPath) && opts.mode === 'smart') {
1637
+ await fse.copy(srcPath, `${destPath}.update`, { overwrite: true });
1638
+ }
1639
+ else {
1640
+ await fse.ensureDir(opts.workspaceDir);
1641
+ await fse.copy(srcPath, destPath, { overwrite: true });
1642
+ }
1643
+ count++;
1644
+ }
1645
+ return count;
1646
+ }
1647
+ async function copyPrinciplesLayer(opts) {
1648
+ let count = 0;
1649
+ // 根据语言选择源目录
1650
+ const langPrinciplesSrc = path.join(opts.pluginDir, 'templates', 'langs', opts.language, 'principles');
1651
+ const defaultPrinciplesSrc = path.join(opts.pluginDir, 'templates', 'workspace', '.principles');
1652
+ const actualPrinciplesSrc = existsSync(langPrinciplesSrc) ? langPrinciplesSrc : defaultPrinciplesSrc;
1653
+ const principlesDest = path.join(opts.workspaceDir, '.principles');
1654
+ if (!existsSync(actualPrinciplesSrc))
1655
+ return 0;
1656
+ const files = readdirSync(actualPrinciplesSrc);
1657
+ for (const file of files) {
1658
+ const srcPath = path.join(actualPrinciplesSrc, file);
1659
+ const destPath = path.join(principlesDest, file);
1660
+ if (statSync(srcPath).isDirectory())
1661
+ continue;
1662
+ // 验证目标路径安全
1663
+ validateWorkspacePath(destPath, opts.workspaceDir);
1664
+ if (existsSync(destPath) && opts.mode === 'smart') {
1665
+ await fse.copy(srcPath, `${destPath}.update`, { overwrite: true });
1666
+ }
1667
+ else {
1668
+ await fse.ensureDir(principlesDest);
1669
+ await fse.copy(srcPath, destPath, { overwrite: true });
1670
+ }
1671
+ count++;
1672
+ }
1673
+ const modelsSrc = path.join(actualPrinciplesSrc, 'models');
1674
+ const modelsDest = path.join(principlesDest, 'models');
1675
+ if (existsSync(modelsSrc)) {
1676
+ await fse.ensureDir(modelsDest);
1677
+ await fse.copy(modelsSrc, modelsDest, { overwrite: true });
1678
+ count += readdirSync(modelsDest).length;
1679
+ }
1680
+ return count;
1681
+ }
1682
+ async function generateConfigYamlConfig(workspaceDir, runtimeProfile) {
1683
+ const configPath = getConfigYamlPath(workspaceDir);
1684
+ const configDir = path.dirname(configPath);
1685
+ await fse.ensureDir(configDir);
1686
+ // Fix-4 (P0-BUG-4): pass through the runtime profile so the generated
1687
+ // config.yaml's pd.default profile is pre-filled (avoids silent LLM
1688
+ // failures on first run).
1689
+ // CodeQL TOCTOU fix: use 'wx' (exclusive create) instead of existsSync
1690
+ // + default write. The 'wx' flag atomically creates the file and fails
1691
+ // with EEXIST if it already exists — eliminating the race between a
1692
+ // separate check and the write. If the file exists (pre-existing or
1693
+ // created concurrently), EEXIST is caught below and we preserve the
1694
+ // existing config (PRI-308).
1695
+ try {
1696
+ writeFileSync(configPath, generateConfigYamlContent(runtimeProfile), { encoding: 'utf8', flag: 'wx' });
1697
+ return configPath;
1698
+ }
1699
+ catch (err) {
1700
+ // rc-2: instanceof Error is the runtime guard; the cast extends the type
1701
+ // with the optional `code` property present on Node.js fs errors.
1702
+ if (!(err instanceof Error) || err.code !== 'EEXIST')
1703
+ throw err;
1704
+ }
1705
+ // PRI-308: preserve existing valid config.yaml (file exists — either
1706
+ // pre-existing or created concurrently between ensureDir and writeFileSync).
1707
+ // PRI-645: the config is preserved verbatim — the PRI-523 host-flag
1708
+ // migration that wrote registry-default entries here is retired (defaults
1709
+ // belong to the registry; effective values are unchanged).
1710
+ try {
1711
+ validateExistingConfigYamlForPreserve(workspaceDir);
1712
+ // Existing config is structurally valid — preserve it
1713
+ logger.info(`Existing .pd/config.yaml is valid, preserving it`);
1714
+ // rc-9-no-silent-fallback: when the user supplied a runtimeProfile via
1715
+ // --provider/--api-key-env but an existing valid config.yaml was found,
1716
+ // the profile is NOT written. Emit a clear warning so the user knows
1717
+ // their --provider/--api-key-env flags were ignored and how to update
1718
+ // the profile manually.
1719
+ if (runtimeProfile) {
1720
+ logger.warn(`--provider/--api-key-env were provided but an existing valid .pd/config.yaml was found. ` +
1721
+ `The runtime profile was not written. To update it, edit .pd/config.yaml or run: pd console open --workspace "${workspaceDir}"`);
1722
+ }
1723
+ return configPath;
1724
+ }
1725
+ catch (e) {
1726
+ if (e instanceof ExistingConfigVerifyInfraError) {
1727
+ // Read failure (EPERM/EBUSY/...) while verifying the existing config:
1728
+ // the config file is NOT necessarily malformed. Do NOT advise deleting
1729
+ // it — that would destroy a valid Owner config.
1730
+ const reason = e instanceof Error ? e.message : String(e);
1731
+ throw new Error(`Failed to verify existing .pd/config.yaml: ${reason}. ` +
1732
+ 'The existing config was left unchanged. Close other tools holding the file, check disk permissions and free space, then re-run the installer.', { cause: e });
1733
+ }
1734
+ // Existing config is malformed — fail loud, do not overwrite
1735
+ const reason = e instanceof Error ? e.message : String(e);
1736
+ throw new Error(`Existing .pd/config.yaml is malformed: ${reason}. Delete the file and re-run the installer, or fix it manually.`, { cause: e });
1737
+ }
1738
+ }
1739
+ async function createConfigFile(workspaceDir, channels) {
1740
+ const configDir = getOpenClawDir();
1741
+ const configPath = path.join(configDir, 'principles-disciple.json');
1742
+ let existingChannels = null;
1743
+ let existingFeatures = null;
1744
+ if (existsSync(configPath)) {
1745
+ const existingRaw = readFileSync(configPath, 'utf-8');
1746
+ const existing = JSON.parse(existingRaw);
1747
+ if (existing !== null && typeof existing === 'object' && !Array.isArray(existing)) {
1748
+ const existingObj = existing;
1749
+ if (Object.hasOwn(existingObj, 'channels') && Array.isArray(existingObj.channels)) {
1750
+ existingChannels = existingObj.channels.filter((c) => typeof c === 'string');
1751
+ }
1752
+ if (Object.hasOwn(existingObj, 'features') && Array.isArray(existingObj.features)) {
1753
+ existingFeatures = existingObj.features.filter((f) => typeof f === 'string');
1754
+ }
1755
+ }
1756
+ }
1757
+ const config = {
1758
+ workspace: workspaceDir,
1759
+ state: path.join(workspaceDir, '.state'),
1760
+ channels: existingChannels ?? channels,
1761
+ installedAt: new Date().toISOString(),
1762
+ mvpFirst: true,
1763
+ };
1764
+ if (existingFeatures) {
1765
+ config.features = existingFeatures;
1766
+ }
1767
+ await fse.ensureDir(configDir);
1768
+ await fse.writeJson(configPath, config, { spaces: 2 });
1769
+ }
1770
+ /**
1771
+ * ADR-0020 §2.3: Run host installers for the selected HostTarget.
1772
+ *
1773
+ * Iterates the concrete HostInstaller instances returned by getHostInstallers()
1774
+ * and calls install() on each. Returns an array of results — one per host.
1775
+ *
1776
+ * rc-9: failures in one host do NOT abort the other (e.g. if Codex adapter
1777
+ * is missing, OpenClaw install still succeeds). Each result includes a
1778
+ * structured reason + nextAction.
1779
+ */
1780
+ async function runHostInstallers(host, ctx) {
1781
+ const installers = getHostInstallers(host);
1782
+ const results = [];
1783
+ for (const installer of installers) {
1784
+ try {
1785
+ const result = await installer.install(ctx);
1786
+ results.push(result);
1787
+ }
1788
+ catch (err) {
1789
+ // rc-9: never silently swallow — record as failure with reason.
1790
+ results.push({
1791
+ success: false,
1792
+ hostId: installer.hostId,
1793
+ configAction: 'skipped',
1794
+ reason: `Installer threw: ${err instanceof Error ? err.message : String(err)}`,
1795
+ nextAction: `Check ${installer.hostId} host configuration and re-run: npx create-principles-disciple install --host ${installer.hostId}`,
1796
+ });
1797
+ }
1798
+ }
1799
+ return results;
1800
+ }
1801
+ function sha256File(filePath) {
1802
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
1803
+ }
1804
+ /**
1805
+ * Identity of the payload being installed. Prefers the self-contained asset
1806
+ * manifest (covers the whole payload); falls back to the bundled pd-cli
1807
+ * package manifest. Both are REAL digests — the journal never stores a
1808
+ * placeholder where a verifiable value is available (same discipline as
1809
+ * legacy-migration.ts). The last-resort fallback hashes the literal reason
1810
+ * string only to satisfy the journal's 64-hex format requirement; it is not
1811
+ * part of any release-metadata identity chain.
1812
+ */
1813
+ function resolveInstallerPayloadIdentity(pluginDir) {
1814
+ let productVersion = 'unknown';
1815
+ const pdCliPkgPath = path.join(pluginDir, 'pd-cli', 'package.json');
1816
+ if (existsSync(pdCliPkgPath)) {
1817
+ try {
1818
+ const parsed = JSON.parse(readFileSync(pdCliPkgPath, 'utf8'));
1819
+ if (typeof parsed.version === 'string' && parsed.version.length > 0)
1820
+ productVersion = parsed.version;
1821
+ }
1822
+ catch {
1823
+ // Identity falls back to 'unknown'; journaling must not brick install.
1824
+ }
1825
+ }
1826
+ const assetManifestPath = path.join(pluginDir, '_release', 'manifest.json');
1827
+ let releaseMetadataDigest;
1828
+ let releaseMetadataDigestSource;
1829
+ if (existsSync(assetManifestPath)) {
1830
+ releaseMetadataDigest = sha256File(assetManifestPath);
1831
+ releaseMetadataDigestSource = 'manifest';
1832
+ }
1833
+ else if (existsSync(pdCliPkgPath)) {
1834
+ releaseMetadataDigest = sha256File(pdCliPkgPath);
1835
+ releaseMetadataDigestSource = 'package_manifest';
1836
+ }
1837
+ else {
1838
+ releaseMetadataDigest = createHash('sha256').update('installer-payload-missing-identity').digest('hex');
1839
+ releaseMetadataDigestSource = 'fallback';
1840
+ }
1841
+ return { productVersion, releaseMetadataDigest, releaseMetadataDigestSource };
1842
+ }
1843
+ /** Opens one installer transaction: `~/.pd/transactions/<transactionId>.jsonl`. */
1844
+ export function beginInstallerJournal(pluginDir) {
1845
+ const { productVersion, releaseMetadataDigest, releaseMetadataDigestSource } = resolveInstallerPayloadIdentity(pluginDir);
1846
+ const transactionId = `install-${Date.now()}-${randomUUID().slice(0, 8)}`;
1847
+ const journalPath = path.join(getPdDir(), 'transactions', `${transactionId}.jsonl`);
1848
+ const releaseId = `bundled-${productVersion}-${releaseMetadataDigest.slice(0, 12)}`;
1849
+ return { transactionId, journalPath, releaseId, productVersion, releaseMetadataDigest, releaseMetadataDigestSource, degraded: false, lastState: null };
1850
+ }
1851
+ /**
1852
+ * Append one transition durably (append + fsync) BEFORE the side effect it
1853
+ * describes. Throws on failure — the caller decides Tier-1 (refuse before
1854
+ * mutation) vs Tier-2 (degrade and continue).
1855
+ */
1856
+ // eslint-disable-next-line @typescript-eslint/max-params -- (from, to, detail) mirrors the JournalTransition shape it appends
1857
+ export function journalInstallerTransition(journal, from, to, detail) {
1858
+ appendJournalTransition(journal.journalPath, {
1859
+ at: new Date().toISOString(),
1860
+ from,
1861
+ to,
1862
+ transactionId: journal.transactionId,
1863
+ releaseId: journal.releaseId,
1864
+ productVersion: journal.productVersion,
1865
+ releaseMetadataDigest: journal.releaseMetadataDigest,
1866
+ releaseMetadataDigestSource: journal.releaseMetadataDigestSource,
1867
+ // The installer model has no active.json generation chain yet (it never
1868
+ // writes active.json); generation continuity becomes ReleaseManager's
1869
+ // responsibility when it takes over mutations (PRI-661).
1870
+ generation: 1,
1871
+ detail,
1872
+ });
1873
+ journal.lastState = to;
1874
+ }
1875
+ /** Tier-2 wrapper: on append failure, degrade (mark + warn) instead of throwing. */
1876
+ // eslint-disable-next-line @typescript-eslint/max-params -- (from, to, detail) mirrors the JournalTransition shape it appends
1877
+ export function journalInstallerTransitionDegrading(journal, from, to, detail) {
1878
+ if (journal.degraded)
1879
+ return;
1880
+ try {
1881
+ journalInstallerTransition(journal, from, to, detail);
1882
+ }
1883
+ catch (error) {
1884
+ journal.degraded = true;
1885
+ logger.error(`Transaction journal append failed at '${to}' — continuing under the backup/restore safety net; `
1886
+ + `this transaction is partially journaled (ADR-0024 D-2 Tier-2 degradation): `
1887
+ + `${error instanceof Error ? error.message : String(error)}`);
1888
+ }
1889
+ }
1890
+ function installerJournalRecord(journal) {
1891
+ return { transactionId: journal.transactionId, journalPath: journal.journalPath, degraded: journal.degraded };
1892
+ }
1893
+ export async function install(options, pluginDir, mode = {}) {
1894
+ // `quiet` (= jsonMode) suppresses human output / spinner. `nonInteractive`
1895
+ // (--yes/--non-interactive/--json) gates PROMPTING. They differ for `--yes`
1896
+ // (human output on, but must NOT prompt). nonInteractive defaults to quiet
1897
+ // because jsonMode always implies non-interactive.
1898
+ const quiet = mode.quiet === true;
1899
+ const nonInteractive = mode.nonInteractive ?? quiet;
1900
+ activeHostTarget = options.host;
1901
+ // Decide the payload shape once: self-contained (release asset) or
1902
+ // npm-distributed (registry-resolved dependencies). A present `_release`
1903
+ // asset keeps the hard preflight; the npm-distributed shape must pass
1904
+ // the component form-gate or fail loud naming what is missing.
1905
+ activePayloadMode = decideInstallPayloadMode(pluginDir);
1906
+ if (activePayloadMode === 'self-contained') {
1907
+ try {
1908
+ await preflightSelfContainedReleaseAsset(pluginDir);
1909
+ }
1910
+ catch (error) {
1911
+ const preflightError = error instanceof SelfContainedDependencyError
1912
+ ? error
1913
+ : new SelfContainedDependencyError({
1914
+ reason: 'self_contained_asset_preflight_failed',
1915
+ nextAction: SELF_CONTAINED_DEPENDENCY_NEXT_ACTION,
1916
+ message: error instanceof Error ? error.message : String(error),
1917
+ component: 'Release asset',
1918
+ cause: error,
1919
+ });
1920
+ return {
1921
+ success: false,
1922
+ workspaceDir: options.workspaceDir,
1923
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
1924
+ templatesCount: 0,
1925
+ components: { plugin: 'skipped', cli: 'skipped', console: 'skipped' },
1926
+ verification: { features: 'skipped', storyA: 'skipped' },
1927
+ enabledChannels: options.channels,
1928
+ nextAction: preflightError.nextAction,
1929
+ reason: preflightError.reason,
1930
+ component: preflightError.component,
1931
+ dependency: preflightError.dependency,
1932
+ error: `${preflightError.message} — ${t('rollback_no_changes')}`,
1933
+ };
1934
+ }
1935
+ }
1936
+ else {
1937
+ if (!quiet)
1938
+ logger.warn('npm-distributed package detected — resolving component dependencies from the npm registry.');
1939
+ const missing = missingNpmDistributedComponents(pluginDir);
1940
+ if (missing.length > 0) {
1941
+ return {
1942
+ success: false,
1943
+ workspaceDir: options.workspaceDir,
1944
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
1945
+ templatesCount: 0,
1946
+ components: { plugin: 'skipped', cli: 'skipped', console: 'skipped' },
1947
+ verification: { features: 'skipped', storyA: 'skipped' },
1948
+ enabledChannels: options.channels,
1949
+ nextAction: 'The package is incomplete — reinstall it: npm cache clear --force && npx create-principles-disciple@latest. No changes were made.',
1950
+ reason: `npm_bundle_incomplete: missing ${missing.join(', ')}`,
1951
+ component: 'Release asset',
1952
+ error: `Bundled components incomplete (missing ${missing.join(', ')}) — the npm package is corrupted or truncated. No changes were made.`,
1953
+ };
1954
+ }
1955
+ }
1956
+ // Gateway lock pre-flight: a running gateway holds native-module file handles
1957
+ // that make the backup rename fail with EPERM. Decide stop/abort/proceed
1958
+ // BEFORE mutating anything (cli-5: abort/stop-failed paths must not mutate).
1959
+ let restartedGateway = false;
1960
+ const gatewayStatus = installsOpenClaw(options.host)
1961
+ ? await checkOpenClawGateway()
1962
+ : { isRunning: false };
1963
+ if (gatewayStatus.isRunning) {
1964
+ const action = await resolveGatewayAction(gatewayStatus, {
1965
+ stopGateway: options.stopGateway,
1966
+ interactive: !nonInteractive,
1967
+ });
1968
+ if (action === 'abort') {
1969
+ logger.warn(t('gateway_aborted_reason'));
1970
+ return buildGatewayRefusalResult(options, {
1971
+ reason: `gateway_running_aborted: ${t('gateway_aborted_reason')}`,
1972
+ nextAction: t('gateway_aborted_next'),
1973
+ });
1974
+ }
1975
+ if (action === 'stop') {
1976
+ if (!quiet)
1977
+ logger.info(t('gateway_stopping'));
1978
+ const stopRes = await stopOpenClawGateway();
1979
+ if (!stopRes.ok) {
1980
+ logger.error(`${t('gateway_stop_failed')} ${stopRes.error ?? ''}`);
1981
+ return buildGatewayRefusalResult(options, {
1982
+ reason: `gateway_stop_failed: ${t('gateway_stop_failed_reason')}${stopRes.error ? ` — ${stopRes.error}` : ''}`,
1983
+ nextAction: t('gateway_stop_failed_next'),
1984
+ error: stopRes.error,
1985
+ });
1986
+ }
1987
+ if (!quiet)
1988
+ logger.success(t('gateway_stopped'));
1989
+ restartedGateway = true; // restart after install, even on failure
1990
+ }
1991
+ else {
1992
+ // action === 'proceed': user accepted the risk of EPERM.
1993
+ logger.warn(t('gateway_proceed_warn'));
1994
+ }
1995
+ }
1996
+ const spinner = quiet ? null : ora('Installing...').start();
1997
+ let backupDir = null;
1998
+ let runtimeBackupDir = null;
1999
+ // ADR-0024 D-2: non-null once the transaction is planned (first mutation is imminent).
2000
+ let journal = null;
2001
+ let installManifestHosts;
2002
+ const components = { plugin: 'skipped', cli: 'skipped', console: 'skipped' };
2003
+ const verification = { features: 'skipped', storyA: 'skipped' };
2004
+ let consoleProcess = null;
2005
+ let stepIndex = 0;
2006
+ const killConsoleChild = () => {
2007
+ if (consoleProcess) {
2008
+ try {
2009
+ consoleProcess.kill('SIGTERM');
2010
+ }
2011
+ catch { /* ignore */ }
2012
+ try {
2013
+ consoleProcess.kill('SIGKILL');
2014
+ }
2015
+ catch { /* ignore */ }
2016
+ consoleProcess = null;
2017
+ }
2018
+ };
2019
+ try {
2020
+ if (spinner)
2021
+ updateProgress(spinner, stepIndex, 'Checking built plugin...');
2022
+ await checkBuiltPlugin(pluginDir);
2023
+ verification.manifestActivation = 'verified';
2024
+ stepIndex++;
2025
+ // Legacy rule contract preflight — run BEFORE the backup/rename step so
2026
+ // a refusal leaves the existing installation completely untouched. Only
2027
+ // meaningful when the target workspace already has PD state; fresh
2028
+ // workspaces scan clean (no state.db → nothing persisted to conflict).
2029
+ if (existsSync(path.join(path.resolve(options.workspaceDir), '.pd', 'state.db'))) {
2030
+ if (spinner)
2031
+ updateProgress(spinner, stepIndex, 'Checking workspace rule compatibility...');
2032
+ const compat = await runLegacyRuleContractPreflight(pluginDir, options.workspaceDir);
2033
+ if (!compat.ok) {
2034
+ if (!quiet)
2035
+ logger.error(compat.reason ?? 'legacy_rule_contract_dependency');
2036
+ return buildCompatibilityRefusalResult(options, compat);
2037
+ }
2038
+ }
2039
+ stepIndex++;
2040
+ if (spinner)
2041
+ updateProgress(spinner, stepIndex, 'Backing up existing install...');
2042
+ // ADR-0024 D-2 (PRI-664): journal-first — record 'planned' BEFORE the
2043
+ // first runtime mutation (the backup rename). Tier-1 policy: if the
2044
+ // journal cannot be written at all, refuse before mutating (fail loud,
2045
+ // zero side effects) rather than performing an unjournaled mutation
2046
+ // (refusal over silent degradation, ADR-0024 §2.4 rule 4).
2047
+ journal = beginInstallerJournal(pluginDir);
2048
+ try {
2049
+ journalInstallerTransition(journal, null, 'planned', `host=${options.host} mode=${options.mode}`);
2050
+ }
2051
+ catch (journalError) {
2052
+ if (spinner)
2053
+ spinner.fail('Install failed');
2054
+ const journalDetail = journalError instanceof Error ? journalError.message : String(journalError);
2055
+ logger.error(`Transaction journal unavailable — refusing to mutate the runtime unjournaled (ADR-0024 D-2): ${journalDetail}`);
2056
+ return {
2057
+ success: false,
2058
+ workspaceDir: options.workspaceDir,
2059
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
2060
+ templatesCount: 0,
2061
+ components,
2062
+ verification,
2063
+ enabledChannels: options.channels,
2064
+ nextAction: 'Resolve write access to ~/.pd/transactions (disk space / permissions), then re-run the installer. No changes were made.',
2065
+ reason: `transaction_journal_unavailable: ${journalDetail}`,
2066
+ error: `Could not write the transaction journal — refusing to mutate the runtime unjournaled (ADR-0024 D-2). No changes were made.`,
2067
+ journal: { transactionId: journal.transactionId, journalPath: journal.journalPath, degraded: true },
2068
+ };
2069
+ }
2070
+ // Validate the existing host-ownership record before mutating runtime or
2071
+ // host config. A malformed manifest must not be discovered only after the
2072
+ // old installation has already been replaced (rc-3/rc-9).
2073
+ installManifestHosts = resolveInstallManifestHosts(options.host);
2074
+ if (installsOpenClaw(options.host))
2075
+ migrateLegacyPdBackups();
2076
+ const { backupDir: backupDirFromResult, runtimeBackupDir: runtimeBackupDirFromResult } = backupExistingInstall(options.host);
2077
+ backupDir = backupDirFromResult;
2078
+ runtimeBackupDir = runtimeBackupDirFromResult ?? null;
2079
+ stepIndex++;
2080
+ if (spinner)
2081
+ updateProgress(spinner, stepIndex, 'Installing bundled @principles/core...');
2082
+ installBundledCore(pluginDir);
2083
+ stepIndex++;
2084
+ if (spinner)
2085
+ updateProgress(spinner, stepIndex, 'Validating bundled core dependencies...');
2086
+ await installCoreDependencies();
2087
+ stepIndex++;
2088
+ if (spinner)
2089
+ updateProgress(spinner, stepIndex, 'Installing bundled @principles/host-runtime...');
2090
+ installBundledHostRuntime(pluginDir);
2091
+ stepIndex++;
2092
+ if (spinner)
2093
+ updateProgress(spinner, stepIndex, 'Validating bundled host runtime dependencies...');
2094
+ await installHostRuntimeDependencies();
2095
+ stepIndex++;
2096
+ if (spinner)
2097
+ updateProgress(spinner, stepIndex, 'Installing bundled @principles/codex-adapter...');
2098
+ installBundledCodexAdapter(pluginDir);
2099
+ stepIndex++;
2100
+ installBundledLayoutPackage(pluginDir);
2101
+ if (spinner)
2102
+ updateProgress(spinner, stepIndex, 'Installing release-manager authority module...');
2103
+ installBundledReleaseManagerPackage(pluginDir);
2104
+ await installReleaseManagerDependencies();
2105
+ await verifyReleaseManagerAuthorityImports();
2106
+ stepIndex++;
2107
+ if (spinner)
2108
+ updateProgress(spinner, stepIndex, 'Installing plugin...');
2109
+ await installPluginToStaging(pluginDir, options.language);
2110
+ stepIndex++;
2111
+ if (spinner)
2112
+ updateProgress(spinner, stepIndex, 'Preparing core library for plugin...');
2113
+ ensureCoreDependency(getPluginExtDir());
2114
+ stepIndex++;
2115
+ if (spinner)
2116
+ updateProgress(spinner, stepIndex, 'Validating bundled plugin dependencies...');
2117
+ await installPluginDependencies();
2118
+ components.plugin = 'verified';
2119
+ stepIndex++;
2120
+ if (spinner)
2121
+ updateProgress(spinner, stepIndex, 'Installing pd CLI...');
2122
+ syncPdCli(pluginDir);
2123
+ await installPdCliDependencies();
2124
+ stepIndex++;
2125
+ if (spinner)
2126
+ updateProgress(spinner, stepIndex, 'Preparing core library for pd-cli...');
2127
+ ensureCoreDependency(getInstalledPdCliDir());
2128
+ stepIndex++;
2129
+ if (spinner)
2130
+ updateProgress(spinner, stepIndex, 'Verifying pd CLI...');
2131
+ const cliVerify = verifyPdCliShim();
2132
+ if (!cliVerify.localOk) {
2133
+ throw new Error(`PD CLI verification failed — local shim is not executable after install: ${cliVerify.localError ?? 'unknown error'}. Check Node.js and PATH configuration.`);
2134
+ }
2135
+ if (cliVerify.globalOk) {
2136
+ components.cli = 'verified';
2137
+ }
2138
+ else {
2139
+ components.cli = 'verified_local_only';
2140
+ components.cliLocalPath = cliVerify.localPath;
2141
+ logger.warn(`Global pd command not on PATH. Use local entry: "${cliVerify.localPath}" or add ${getInstalledBinDir()} to PATH.`);
2142
+ }
2143
+ stepIndex++;
2144
+ if (spinner)
2145
+ updateProgress(spinner, stepIndex, 'Installing pd-console...');
2146
+ installConsole(pluginDir);
2147
+ stepIndex++;
2148
+ if (spinner)
2149
+ updateProgress(spinner, stepIndex, 'Preparing core library for console...');
2150
+ ensureCoreDependency(getInstalledConsoleDir());
2151
+ stepIndex++;
2152
+ if (spinner)
2153
+ updateProgress(spinner, stepIndex, 'Validating bundled console dependencies...');
2154
+ await installConsoleDependencies();
2155
+ stepIndex++;
2156
+ // ADR-0024 D-2: all runtime content is laid down — the new installation
2157
+ // is staged (nothing has been discarded yet; backups still hold the old one).
2158
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'staged', 'runtime components installed');
2159
+ if (spinner)
2160
+ updateProgress(spinner, stepIndex, 'Verifying pd-console...');
2161
+ const consoleVerify = await verifyConsole(options.workspaceDir);
2162
+ if (consoleVerify.ok) {
2163
+ components.console = 'configured';
2164
+ components.consoleEntrypoint = consoleVerify.url;
2165
+ consoleProcess = consoleVerify.process;
2166
+ }
2167
+ else {
2168
+ throw new Error(`Console verification failed: ${consoleVerify.reason ?? 'unknown'}. Installation rolled back — plugin and CLI are not activated.`);
2169
+ }
2170
+ stepIndex++;
2171
+ // ADR-0024 D-2: the console probe passed — the staged installation is live.
2172
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'probed', `console verified at ${consoleVerify.url}`);
2173
+ if (spinner)
2174
+ updateProgress(spinner, stepIndex, 'Copying templates...');
2175
+ const templatesCount = await copyCoreTemplates({
2176
+ pluginDir,
2177
+ language: options.language,
2178
+ workspaceDir: options.workspaceDir,
2179
+ mode: options.mode,
2180
+ });
2181
+ const principlesCount = await copyPrinciplesLayer({
2182
+ pluginDir,
2183
+ language: options.language,
2184
+ workspaceDir: options.workspaceDir,
2185
+ mode: options.mode,
2186
+ });
2187
+ stepIndex++;
2188
+ if (spinner)
2189
+ updateProgress(spinner, stepIndex, 'Generating config.yaml...');
2190
+ const configYamlPath = await generateConfigYamlConfig(options.workspaceDir, options.runtimeProfile);
2191
+ verification.features = 'passed';
2192
+ stepIndex++;
2193
+ if (spinner)
2194
+ updateProgress(spinner, stepIndex, 'Creating config...');
2195
+ await createConfigFile(options.workspaceDir, options.channels);
2196
+ stepIndex++;
2197
+ // PRI-686 Fix C: after pinning PD canonical, check OpenClaw's resolved
2198
+ // main-agent workspace against it. Read-only; a divergence means PD
2199
+ // commands and hooks would write to two different state trees.
2200
+ try {
2201
+ const divergence = detectOpenClawMainWorkspaceDivergence(options.workspaceDir);
2202
+ if (divergence.divergent) {
2203
+ logger.warn(`Workspace divergence detected: ${divergence.detail}. ` +
2204
+ `${divergence.nextAction} ` +
2205
+ `Until fixed, OpenClaw sessions in "${divergence.openclawMainWorkspace}" may be unable to record pain signals (needs_evidence).`);
2206
+ }
2207
+ }
2208
+ catch (e) {
2209
+ logger.warn(`OpenClaw workspace divergence check failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
2210
+ }
2211
+ if (spinner)
2212
+ updateProgress(spinner, stepIndex, 'Initializing PD databases...');
2213
+ try {
2214
+ const pdCliEntry = path.join(getInstalledPdCliDir(), 'dist', 'index.js');
2215
+ execFileSync(process.execPath, [pdCliEntry, 'runtime', 'init', '--confirm', '--workspace', options.workspaceDir], {
2216
+ stdio: ['ignore', 'ignore', 'pipe'],
2217
+ timeout: 30000,
2218
+ });
2219
+ }
2220
+ catch (err) {
2221
+ // rc-9: surface failure — do not silently swallow.
2222
+ // Non-fatal: demo story-a and runtime hooks will surface DB issues.
2223
+ const initWarn = err instanceof Error ? err.message : String(err);
2224
+ logger.warn(`pd runtime init failed (non-fatal): ${initWarn}`);
2225
+ }
2226
+ stepIndex++;
2227
+ if (spinner)
2228
+ updateProgress(spinner, stepIndex, 'Verifying demo...');
2229
+ try {
2230
+ // Demo isolation (2026-08-19): the install target already holds
2231
+ // initialized PD state at this point (.pd/state.db, .principles/
2232
+ // PROFILE.json), so the CLI's demo guard refuses to write demo data
2233
+ // into it. Run WITHOUT --workspace — pd-cli provisions and cleans up
2234
+ // its own throwaway temp workspace, exercising the same installed
2235
+ // pd-cli + core chain without polluting the user's workspace.
2236
+ // The entry path is boundary-checked before it is used as a
2237
+ // subprocess target (canonical-path containment + existence).
2238
+ const pdCliRoot = path.resolve(getInstalledPdCliDir());
2239
+ const pdCliEntry = path.resolve(pdCliRoot, 'dist', 'index.js');
2240
+ if (pdCliEntry !== pdCliRoot && !pdCliEntry.startsWith(pdCliRoot + path.sep)) {
2241
+ throw new Error(`pd-cli entry escapes install dir: ${pdCliEntry}`);
2242
+ }
2243
+ if (!existsSync(pdCliEntry) || !statSync(pdCliEntry).isFile()) {
2244
+ throw new Error(`pd-cli entry missing: ${pdCliEntry}`);
2245
+ }
2246
+ const { execFile } = await import('node:child_process');
2247
+ const { promisify } = await import('node:util');
2248
+ const execFileAsync = promisify(execFile);
2249
+ await execFileAsync(process.execPath, [pdCliEntry, 'demo', 'story-a', '--json'], {
2250
+ timeout: STORY_A_VERIFICATION_TIMEOUT_MS,
2251
+ });
2252
+ verification.storyA = 'passed';
2253
+ }
2254
+ catch (e) {
2255
+ throw new Error(`Demo verification failed: ${e instanceof Error ? e.message : String(e)}. Installation rolled back — plugin and CLI are not activated.`, { cause: e });
2256
+ }
2257
+ stepIndex++;
2258
+ if (spinner)
2259
+ updateProgress(spinner, stepIndex, 'Updating host config...');
2260
+ const hostResults = await runHostInstallers(options.host, {
2261
+ workspaceDir: options.workspaceDir,
2262
+ pluginDir,
2263
+ language: options.language,
2264
+ mode: options.mode,
2265
+ runtimeProfile: options.runtimeProfile
2266
+ ? {
2267
+ provider: options.runtimeProfile.provider,
2268
+ model: options.runtimeProfile.model,
2269
+ apiKeyEnv: options.runtimeProfile.apiKeyEnv,
2270
+ }
2271
+ : undefined,
2272
+ });
2273
+ // Report host install results (rc-9: surface failures, don't silently swallow).
2274
+ // rc-9 propagation: if ANY host installer failed, the aggregate install
2275
+ // result is NOT successful — callers (CLI, tests) must see the failure.
2276
+ // Previously failures were only logged, leaving `success: true` even when
2277
+ // a host adapter failed to install (silent failure).
2278
+ const hostFailures = [];
2279
+ for (const hr of hostResults) {
2280
+ if (!hr.success) {
2281
+ const msg = `Host "${hr.hostId}": ${hr.reason ?? 'unknown failure'}. Next: ${hr.nextAction}`;
2282
+ hostFailures.push(msg);
2283
+ logger.warn(msg);
2284
+ }
2285
+ else if (hr.configAction === 'skipped') {
2286
+ logger.info(`Host "${hr.hostId}" install: ${hr.reason ?? 'skipped'}. Next: ${hr.nextAction}`);
2287
+ }
2288
+ }
2289
+ if (hostFailures.length > 0) {
2290
+ throw new Error(`Host installation failed: ${hostFailures.join(' | ')}`);
2291
+ }
2292
+ writeInstallManifest(installManifestHosts, resolveInstallManifestWorkspaces(options.workspaceDir));
2293
+ // ADR-0024 D-2: host installers completed and the install manifest is
2294
+ // written — the new installation is fully activated (backups not yet
2295
+ // discarded, so a crash here still recovers via the backup).
2296
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'activated', 'host installers completed; install manifest written');
2297
+ cleanupBackup(backupDir, runtimeBackupDir);
2298
+ // ADR-0024 D-2: backup cleanup is the commit point of the transaction.
2299
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'confirmed', 'backup cleaned up; install complete');
2300
+ if (spinner) {
2301
+ spinner.succeed('Install complete!');
2302
+ }
2303
+ killConsoleChild();
2304
+ // Task 8: auto-launch console via `pd console open` (reuses PRI-300
2305
+ // handleConsoleOpen — no new launcher subsystem). Opens the browser to
2306
+ // /welcome for onboarding. Detached so the console survives installer exit.
2307
+ const launchResult = await autoLaunchConsole(options.workspaceDir);
2308
+ // PRI-645: report the ACTUAL effective channels (registry default +
2309
+ // sparse override). Never mask an explicit Owner disable with the
2310
+ // requested channels — that would re-create ERR-042 (reporting the
2311
+ // requested config instead of the actual state).
2312
+ const actualEnabledChannels = readEnabledChannelsFromConfigYaml(options.workspaceDir);
2313
+ const cliWorking = components.cli === 'verified' || components.cli === 'verified_local_only';
2314
+ const isComplete = components.plugin === 'verified' && cliWorking && components.console === 'configured';
2315
+ // A host installer failure means the install is not fully successful,
2316
+ // even if all component verifications passed. The operator should see
2317
+ // success=false and be guided to fix the failed host.
2318
+ const nextActions = [];
2319
+ if (components.cli === 'verified') {
2320
+ nextActions.push(`pd runtime canary --workspace "${options.workspaceDir}" --json`);
2321
+ }
2322
+ else if (components.cli === 'verified_local_only' && components.cliLocalPath) {
2323
+ nextActions.push(`"${components.cliLocalPath}" runtime canary --workspace "${options.workspaceDir}" --json`);
2324
+ }
2325
+ if (components.console === 'configured') {
2326
+ if (launchResult.consoleUrl) {
2327
+ // Console auto-launched — point user at the live URL.
2328
+ nextActions.push(`Console ready at ${launchResult.consoleUrl} (browser opened automatically)`);
2329
+ }
2330
+ else {
2331
+ // EP-03: auto-launch did not succeed — keep manual start instruction.
2332
+ nextActions.push(`pd console --workspace "${options.workspaceDir}" --no-auth (listens on 127.0.0.1 only)`);
2333
+ }
2334
+ }
2335
+ if (launchResult.fallbackAction) {
2336
+ nextActions.push(launchResult.fallbackAction);
2337
+ }
2338
+ return {
2339
+ success: isComplete,
2340
+ workspaceDir: options.workspaceDir,
2341
+ configYamlPath,
2342
+ templatesCount: templatesCount + principlesCount,
2343
+ components,
2344
+ verification,
2345
+ enabledChannels: actualEnabledChannels,
2346
+ nextAction: nextActions.join(' | '),
2347
+ consoleUrl: launchResult.consoleUrl,
2348
+ hostResults,
2349
+ journal: installerJournalRecord(journal),
2350
+ };
2351
+ }
2352
+ catch (error) {
2353
+ if (spinner)
2354
+ spinner.fail('Install failed');
2355
+ killConsoleChild();
2356
+ // ADR-0024 D-2: record the outcome (journal may be null when the throw
2357
+ // happened before the transaction was planned — zero side effects then).
2358
+ // `failed` and `rolled_back` are BOTH terminal states and one journal
2359
+ // file must end at exactly one terminal state (the strict reader rejects
2360
+ // any transition after a terminal one), so the outcome is either-or:
2361
+ // - backup restored (a real rollback happened) → `rolled_back`
2362
+ // - no backup existed (mutation never started) or restore failed → `failed`
2363
+ const errorMsgRaw = error instanceof Error ? error.message : String(error);
2364
+ const restoreResult = restoreBackup(backupDir, runtimeBackupDir);
2365
+ if (journal) {
2366
+ const restoreFailed = Boolean(backupDir || runtimeBackupDir) && !restoreResult.restored;
2367
+ const detail = restoreFailed
2368
+ ? `${errorMsgRaw}; backup restore FAILED: ${restoreResult.error ?? 'unknown'}`
2369
+ : errorMsgRaw;
2370
+ if ((backupDir || runtimeBackupDir) && restoreResult.restored) {
2371
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'rolled_back', detail);
2372
+ }
2373
+ else {
2374
+ journalInstallerTransitionDegrading(journal, journal.lastState, 'failed', detail);
2375
+ }
2376
+ }
2377
+ const errorMsg = errorMsgRaw;
2378
+ // ERR-046 / rc-9: never claim a restore that didn't happen. When backupDir
2379
+ // is null, the backup step never completed (it threw — e.g. EPERM — or
2380
+ // there was no existing install), so the existing install was never moved
2381
+ // and nothing was restored. The old code printed "Previous install has been
2382
+ // restored." here, which was misleading (success-shaped, no restore).
2383
+ const isLockError = /EPERM|EBUSY|EACCES|operation not permitted/i.test(errorMsg);
2384
+ const extDir = getPluginExtDir();
2385
+ // EP-11: all operator-visible failure/rollback text goes through t().
2386
+ const hasBackup = Boolean(backupDir || runtimeBackupDir);
2387
+ const rollbackSuffix = !hasBackup
2388
+ ? t('rollback_no_changes')
2389
+ : restoreResult.restored
2390
+ ? t('rollback_restored')
2391
+ : t('rollback_failed')
2392
+ .replace('{restoreError}', restoreResult.error ?? '')
2393
+ .replace('{extDir}', extDir)
2394
+ .replace('{backupDir}', backupDir ?? runtimeBackupDir ?? '');
2395
+ const rollbackNextAction = !hasBackup
2396
+ ? (isLockError
2397
+ ? t('next_no_changes_lock')
2398
+ : t('next_no_changes_other'))
2399
+ : restoreResult.restored
2400
+ ? t('next_restored')
2401
+ : t('next_restore_failed')
2402
+ .replace('{extDir}', extDir)
2403
+ .replace('{backupDir}', backupDir ?? runtimeBackupDir ?? '')
2404
+ .replace('{errorMsg}', errorMsg);
2405
+ const rollbackReason = !hasBackup
2406
+ ? (isLockError ? `install_aborted_lock: ${errorMsg}` : `install_failed_before_mutation: ${errorMsg}`)
2407
+ : restoreResult.restored
2408
+ ? errorMsg
2409
+ : `install_failed_rollback_failed: ${errorMsg}`;
2410
+ const nextAction = error instanceof SelfContainedDependencyError
2411
+ ? restoreResult.restored || !hasBackup
2412
+ ? error.nextAction
2413
+ : `${error.nextAction} ${rollbackNextAction}`
2414
+ : rollbackNextAction;
2415
+ const reason = error instanceof SelfContainedDependencyError ? error.reason : rollbackReason;
2416
+ return {
2417
+ success: false,
2418
+ workspaceDir: options.workspaceDir,
2419
+ configYamlPath: getConfigYamlPath(options.workspaceDir),
2420
+ templatesCount: 0,
2421
+ components,
2422
+ verification,
2423
+ enabledChannels: options.channels,
2424
+ nextAction,
2425
+ reason,
2426
+ component: error instanceof SelfContainedDependencyError ? error.component : undefined,
2427
+ dependency: error instanceof SelfContainedDependencyError ? error.dependency : undefined,
2428
+ error: `${errorMsg} — ${rollbackSuffix}`,
2429
+ journal: journal ? installerJournalRecord(journal) : undefined,
2430
+ };
2431
+ }
2432
+ finally {
2433
+ // If we stopped the gateway at the pre-flight, restart it regardless of
2434
+ // install outcome (success or failure) — never leave the gateway down.
2435
+ // rc-9: a restart failure is reported but does not override the install
2436
+ // result already computed above.
2437
+ if (restartedGateway) {
2438
+ if (!quiet)
2439
+ logger.info(t('gateway_restarting'));
2440
+ const restartRes = await restartOpenClawGateway();
2441
+ if (restartRes.ok) {
2442
+ if (!quiet)
2443
+ logger.success(t('gateway_restarted'));
2444
+ }
2445
+ else {
2446
+ logger.error(`${t('gateway_restart_failed')} ${restartRes.error ?? ''}`);
2447
+ }
2448
+ }
2449
+ }
2450
+ }
2451
+ //# sourceMappingURL=installer.js.map