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.
- package/codex-adapter/dist/codec/index.d.ts +6 -0
- package/codex-adapter/dist/codec/index.js +5 -0
- package/codex-adapter/dist/codec/input-decoder.d.ts +13 -0
- package/codex-adapter/dist/codec/input-decoder.js +103 -0
- package/codex-adapter/dist/codec/output-encoder.d.ts +32 -0
- package/codex-adapter/dist/codec/output-encoder.js +51 -0
- package/codex-adapter/dist/host-adapter.d.ts +30 -0
- package/codex-adapter/dist/host-adapter.js +28 -0
- package/codex-adapter/dist/index.d.ts +25 -0
- package/codex-adapter/dist/index.js +19 -0
- package/codex-adapter/dist/ingestion/admission.d.ts +54 -0
- package/codex-adapter/dist/ingestion/admission.js +137 -0
- package/codex-adapter/dist/ingestion/catch-up.d.ts +41 -0
- package/codex-adapter/dist/ingestion/catch-up.js +94 -0
- package/codex-adapter/dist/ingestion/codex-home.d.ts +19 -0
- package/codex-adapter/dist/ingestion/codex-home.js +43 -0
- package/codex-adapter/dist/ingestion/codex-version.d.ts +30 -0
- package/codex-adapter/dist/ingestion/codex-version.js +53 -0
- package/codex-adapter/dist/ingestion/ingestion-fields.d.ts +17 -0
- package/codex-adapter/dist/ingestion/ingestion-fields.js +28 -0
- package/codex-adapter/dist/ingestion/ingestion.d.ts +71 -0
- package/codex-adapter/dist/ingestion/ingestion.js +248 -0
- package/codex-adapter/dist/ingestion/transcript-decoder.d.ts +72 -0
- package/codex-adapter/dist/ingestion/transcript-decoder.js +485 -0
- package/codex-adapter/dist/ingestion/transcript-locate.d.ts +13 -0
- package/codex-adapter/dist/ingestion/transcript-locate.js +75 -0
- package/codex-adapter/dist/ingestion/transcript-path.d.ts +25 -0
- package/codex-adapter/dist/ingestion/transcript-path.js +87 -0
- package/codex-adapter/dist/pd-hook.d.ts +9 -0
- package/codex-adapter/dist/pd-hook.js +159 -0
- package/codex-adapter/dist/tool-semantics.d.ts +20 -0
- package/codex-adapter/dist/tool-semantics.js +27 -0
- package/codex-adapter/dist/worker/workspace-worker.d.ts +59 -0
- package/codex-adapter/dist/worker/workspace-worker.js +270 -0
- package/codex-adapter/package.json +52 -0
- package/console/dist/server/routes/update.js +187 -19
- package/console/dist/server/update/mutation-controller.d.ts +18 -0
- package/console/dist/server/update/mutation-controller.js +29 -0
- package/console/dist/server/utils/installed-layout.d.ts +1 -0
- package/console/dist/server/utils/installed-layout.js +2 -0
- package/console/package.json +1 -0
- package/core/dist/runtime-v2/__tests__/architecture-regression.test.js +15 -0
- package/core/dist/runtime-v2/__tests__/architecture-regression.test.js.map +1 -1
- package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.d.ts +2 -0
- package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.d.ts.map +1 -0
- package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.js +71 -0
- package/core/dist/runtime-v2/adapter/__tests__/pd-stream-simple.test.js.map +1 -0
- package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.d.ts +2 -0
- package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.d.ts.map +1 -0
- package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.js +219 -0
- package/core/dist/runtime-v2/adapter/__tests__/pi-ai-http-transport.test.js.map +1 -0
- package/core/dist/runtime-v2/adapter/artificer-l2-adapter.d.ts.map +1 -1
- package/core/dist/runtime-v2/adapter/artificer-l2-adapter.js +2 -3
- package/core/dist/runtime-v2/adapter/artificer-l2-adapter.js.map +1 -1
- package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.d.ts +12 -1
- package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.d.ts.map +1 -1
- package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.js +15 -2
- package/core/dist/runtime-v2/adapter/l2-agent-loop-adapter.js.map +1 -1
- package/core/dist/runtime-v2/adapter/pi-ai-http-transport.d.ts +50 -0
- package/core/dist/runtime-v2/adapter/pi-ai-http-transport.d.ts.map +1 -0
- package/core/dist/runtime-v2/adapter/pi-ai-http-transport.js +93 -0
- package/core/dist/runtime-v2/adapter/pi-ai-http-transport.js.map +1 -0
- package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.d.ts.map +1 -1
- package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.js +5 -0
- package/core/dist/runtime-v2/adapter/pi-ai-runtime-adapter.js.map +1 -1
- package/core/dist/runtime-v2/internalization/__tests__/owner-decision.test.js +152 -0
- package/core/dist/runtime-v2/internalization/__tests__/owner-decision.test.js.map +1 -1
- package/core/dist/runtime-v2/internalization/owner-review.d.ts +7 -1
- package/core/dist/runtime-v2/internalization/owner-review.d.ts.map +1 -1
- package/core/dist/runtime-v2/internalization/owner-review.js +22 -3
- package/core/dist/runtime-v2/internalization/owner-review.js.map +1 -1
- package/core/package.json +3 -2
- package/dist/installer.d.ts +32 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +198 -2
- package/dist/installer.js.map +1 -1
- package/dist/installers/codex-host-installer.d.ts.map +1 -1
- package/dist/installers/codex-host-installer.js +5 -3
- package/dist/installers/codex-host-installer.js.map +1 -1
- package/dist/update/release-manager-authority.d.ts +69 -0
- package/dist/update/release-manager-authority.d.ts.map +1 -0
- package/dist/update/release-manager-authority.js +125 -0
- package/dist/update/release-manager-authority.js.map +1 -0
- package/dist/utils/workspace-divergence.d.ts +36 -0
- package/dist/utils/workspace-divergence.d.ts.map +1 -0
- package/dist/utils/workspace-divergence.js +87 -0
- package/dist/utils/workspace-divergence.js.map +1 -0
- package/install-layout/dist/index.d.ts +5 -0
- package/install-layout/dist/index.js +1 -0
- package/install-layout/package.json +1 -1
- package/package.json +4 -2
- package/plugin/dist/bundle.js +870 -734
- package/plugin/dist/governance-audit.js +381 -247
- package/plugin/dist/openclaw-sdk.d.ts +3 -0
- package/plugin/dist/rulehost-evidence.js +420 -286
- package/plugin/dist/service/subagent-workflow/types.d.ts +2 -2
- package/plugin/dist/utils/retry.d.ts +1 -1
- package/plugin/dist/utils/workspace-resolver.d.ts +35 -4
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/dist/i18n.d.ts +6 -0
- package/release-manager/dist/i18n.d.ts.map +1 -0
- package/release-manager/dist/i18n.js +234 -0
- package/release-manager/dist/i18n.js.map +1 -0
- package/release-manager/dist/index.d.ts +7 -0
- package/release-manager/dist/index.d.ts.map +1 -0
- package/release-manager/dist/index.js +474 -0
- package/release-manager/dist/index.js.map +1 -0
- package/release-manager/dist/installer.d.ts +217 -0
- package/release-manager/dist/installer.d.ts.map +1 -0
- package/release-manager/dist/installer.js +2451 -0
- package/release-manager/dist/installer.js.map +1 -0
- package/release-manager/dist/installers/codex-host-installer.d.ts +29 -0
- package/release-manager/dist/installers/codex-host-installer.d.ts.map +1 -0
- package/release-manager/dist/installers/codex-host-installer.js +496 -0
- package/release-manager/dist/installers/codex-host-installer.js.map +1 -0
- package/release-manager/dist/installers/index.d.ts +40 -0
- package/release-manager/dist/installers/index.d.ts.map +1 -0
- package/release-manager/dist/installers/index.js +35 -0
- package/release-manager/dist/installers/index.js.map +1 -0
- package/release-manager/dist/installers/openclaw-host-installer.d.ts +18 -0
- package/release-manager/dist/installers/openclaw-host-installer.d.ts.map +1 -0
- package/release-manager/dist/installers/openclaw-host-installer.js +414 -0
- package/release-manager/dist/installers/openclaw-host-installer.js.map +1 -0
- package/release-manager/dist/mvp-config.d.ts +197 -0
- package/release-manager/dist/mvp-config.d.ts.map +1 -0
- package/release-manager/dist/mvp-config.js +509 -0
- package/release-manager/dist/mvp-config.js.map +1 -0
- package/release-manager/dist/prompts.d.ts +30 -0
- package/release-manager/dist/prompts.d.ts.map +1 -0
- package/release-manager/dist/prompts.js +231 -0
- package/release-manager/dist/prompts.js.map +1 -0
- package/release-manager/dist/skill-language.d.ts +32 -0
- package/release-manager/dist/skill-language.d.ts.map +1 -0
- package/release-manager/dist/skill-language.js +107 -0
- package/release-manager/dist/skill-language.js.map +1 -0
- package/release-manager/dist/uninstaller.d.ts +55 -0
- package/release-manager/dist/uninstaller.d.ts.map +1 -0
- package/release-manager/dist/uninstaller.js +409 -0
- package/release-manager/dist/uninstaller.js.map +1 -0
- package/release-manager/dist/update/atomic-file.d.ts +35 -0
- package/release-manager/dist/update/atomic-file.d.ts.map +1 -0
- package/release-manager/dist/update/atomic-file.js +120 -0
- package/release-manager/dist/update/atomic-file.js.map +1 -0
- package/release-manager/dist/update/bootstrap-protocol.d.ts +55 -0
- package/release-manager/dist/update/bootstrap-protocol.d.ts.map +1 -0
- package/release-manager/dist/update/bootstrap-protocol.js +132 -0
- package/release-manager/dist/update/bootstrap-protocol.js.map +1 -0
- package/release-manager/dist/update/channel-metadata.d.ts +28 -0
- package/release-manager/dist/update/channel-metadata.d.ts.map +1 -0
- package/release-manager/dist/update/channel-metadata.js +90 -0
- package/release-manager/dist/update/channel-metadata.js.map +1 -0
- package/release-manager/dist/update/channel-promotion.d.ts +39 -0
- package/release-manager/dist/update/channel-promotion.d.ts.map +1 -0
- package/release-manager/dist/update/channel-promotion.js +55 -0
- package/release-manager/dist/update/channel-promotion.js.map +1 -0
- package/release-manager/dist/update/data-compatibility.d.ts +35 -0
- package/release-manager/dist/update/data-compatibility.d.ts.map +1 -0
- package/release-manager/dist/update/data-compatibility.js +45 -0
- package/release-manager/dist/update/data-compatibility.js.map +1 -0
- package/release-manager/dist/update/install-layout.d.ts +59 -0
- package/release-manager/dist/update/install-layout.d.ts.map +1 -0
- package/release-manager/dist/update/install-layout.js +127 -0
- package/release-manager/dist/update/install-layout.js.map +1 -0
- package/release-manager/dist/update/legacy-migration.d.ts +56 -0
- package/release-manager/dist/update/legacy-migration.d.ts.map +1 -0
- package/release-manager/dist/update/legacy-migration.js +254 -0
- package/release-manager/dist/update/legacy-migration.js.map +1 -0
- package/release-manager/dist/update/product-identity.d.ts +50 -0
- package/release-manager/dist/update/product-identity.d.ts.map +1 -0
- package/release-manager/dist/update/product-identity.js +63 -0
- package/release-manager/dist/update/product-identity.js.map +1 -0
- package/release-manager/dist/update/release-asset-manifest.d.ts +31 -0
- package/release-manager/dist/update/release-asset-manifest.d.ts.map +1 -0
- package/release-manager/dist/update/release-asset-manifest.js +161 -0
- package/release-manager/dist/update/release-asset-manifest.js.map +1 -0
- package/release-manager/dist/update/release-identity.d.ts +33 -0
- package/release-manager/dist/update/release-identity.d.ts.map +1 -0
- package/release-manager/dist/update/release-identity.js +108 -0
- package/release-manager/dist/update/release-identity.js.map +1 -0
- package/release-manager/dist/update/release-manager-authority.d.ts +69 -0
- package/release-manager/dist/update/release-manager-authority.d.ts.map +1 -0
- package/release-manager/dist/update/release-manager-authority.js +125 -0
- package/release-manager/dist/update/release-manager-authority.js.map +1 -0
- package/release-manager/dist/update/release-manager.d.ts +96 -0
- package/release-manager/dist/update/release-manager.d.ts.map +1 -0
- package/release-manager/dist/update/release-manager.js +253 -0
- package/release-manager/dist/update/release-manager.js.map +1 -0
- package/release-manager/dist/update/release-metadata.d.ts +57 -0
- package/release-manager/dist/update/release-metadata.d.ts.map +1 -0
- package/release-manager/dist/update/release-metadata.js +200 -0
- package/release-manager/dist/update/release-metadata.js.map +1 -0
- package/release-manager/dist/update/release-policy.d.ts +51 -0
- package/release-manager/dist/update/release-policy.d.ts.map +1 -0
- package/release-manager/dist/update/release-policy.js +103 -0
- package/release-manager/dist/update/release-policy.js.map +1 -0
- package/release-manager/dist/update/rollback-policy.d.ts +56 -0
- package/release-manager/dist/update/rollback-policy.d.ts.map +1 -0
- package/release-manager/dist/update/rollback-policy.js +61 -0
- package/release-manager/dist/update/rollback-policy.js.map +1 -0
- package/release-manager/dist/update/transaction-journal.d.ts +131 -0
- package/release-manager/dist/update/transaction-journal.d.ts.map +1 -0
- package/release-manager/dist/update/transaction-journal.js +346 -0
- package/release-manager/dist/update/transaction-journal.js.map +1 -0
- package/release-manager/dist/update/trust-metadata.d.ts +55 -0
- package/release-manager/dist/update/trust-metadata.d.ts.map +1 -0
- package/release-manager/dist/update/trust-metadata.js +170 -0
- package/release-manager/dist/update/trust-metadata.js.map +1 -0
- package/release-manager/dist/update/update-history.d.ts +46 -0
- package/release-manager/dist/update/update-history.d.ts.map +1 -0
- package/release-manager/dist/update/update-history.js +123 -0
- package/release-manager/dist/update/update-history.js.map +1 -0
- package/release-manager/dist/utils/config-file-io.d.ts +26 -0
- package/release-manager/dist/utils/config-file-io.d.ts.map +1 -0
- package/release-manager/dist/utils/config-file-io.js +128 -0
- package/release-manager/dist/utils/config-file-io.js.map +1 -0
- package/release-manager/dist/utils/env.d.ts +60 -0
- package/release-manager/dist/utils/env.d.ts.map +1 -0
- package/release-manager/dist/utils/env.js +308 -0
- package/release-manager/dist/utils/env.js.map +1 -0
- package/release-manager/dist/utils/logger.d.ts +15 -0
- package/release-manager/dist/utils/logger.d.ts.map +1 -0
- package/release-manager/dist/utils/logger.js +30 -0
- package/release-manager/dist/utils/logger.js.map +1 -0
- package/release-manager/dist/utils/workspace-divergence.d.ts +36 -0
- package/release-manager/dist/utils/workspace-divergence.d.ts.map +1 -0
- package/release-manager/dist/utils/workspace-divergence.js +87 -0
- package/release-manager/dist/utils/workspace-divergence.js.map +1 -0
- 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
|