From 955f13d72ab79fe997ccbb851b4e2912313e77ab Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 22:44:01 +0100 Subject: [PATCH] build(plugin): isolate dist/, add install.mjs, untrack .js artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the long-standing 'jiti picks stale .js over updated .ts' trap that silently shipped wrong code at least 5 times during sim e2e. Root cause was three things compounding: 1. tsconfig.plugin.json had outDir=plugin (same as rootDir), so tsc emitted compiled .js next to .ts sources. jiti's extension resolver prefers .js, so the moment a .ts changed without a matching rebuild, the .js won and the new code never ran. 2. The .js artifacts were tracked in git, so even on clean clones the stale files came back. 3. There was no install script. Every deploy was an ad-hoc tar + rsync that copied both .ts and .js to the same target dir, recreating the race on the install side. This commit fixes all three together: - tsconfig.plugin.json: outDir=dist/dialectic, module/moduleResolution flipped from ESNext/bundler to NodeNext/NodeNext (so emitted .js works under plain Node ESM at runtime, the only thing jiti and the openclaw gateway actually use). - .gitignore: /dist/ + plugin/**/*.{js,mjs,cjs,js.map,d.ts}, then git rm --cached the 4 existing tracked .js files. - scripts/install.mjs (new, ESM): mirrors Fabric.OpenclawPlugin's install.mjs pattern — detect, checkDeps, build (clean dist/ first), install (copy dist/dialectic/ + openclaw.plugin.json + package.json to ~/.openclaw/plugins/dialectic/), configure (plugins.allow, plugins.load.paths, plugins.entries..enabled). Supports --install / --build-only / --uninstall / --skip-check / --verbose / --openclaw-profile-path / --backend-url. Verified on sim dind-t2: install.mjs --install produces a plugin directory with ONLY .js files (no .ts left behind), gateway loads it, 8 tools register cleanly, dialectic_list_topics + fabric-guild-list work end-to-end. Plugin is now fully ESM-clean: type=module + NodeNext + .js import extensions + no bundler-only knobs. Matches the project-wide invariant [[project-fabric-esm]] (every Fabric subproject is ESM). --- .gitignore | 11 ++ plugin/index.js | 27 ---- plugin/src/backend-client.js | 90 ------------ plugin/src/hf-precheck.js | 50 ------- plugin/src/tools.js | 258 --------------------------------- scripts/install.mjs | 268 +++++++++++++++++++++++++++++++++++ tsconfig.plugin.json | 19 ++- 7 files changed, 293 insertions(+), 430 deletions(-) delete mode 100644 plugin/index.js delete mode 100644 plugin/src/backend-client.js delete mode 100644 plugin/src/hf-precheck.js delete mode 100644 plugin/src/tools.js create mode 100644 scripts/install.mjs diff --git a/.gitignore b/.gitignore index 4cc4275..c6040e8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,14 @@ /.vscode/ *.swp .DS_Store + +# Build output. tsconfig.plugin.json emits compiled JS into dist/dialectic/. +# Never commit it — install.mjs builds fresh on every deploy. Co-locating +# .js next to .ts under plugin/src/ used to silently ship stale code +# (jiti prefers .js when both are present). +/dist/ +plugin/**/*.js +plugin/**/*.mjs +plugin/**/*.cjs +plugin/**/*.js.map +plugin/**/*.d.ts diff --git a/plugin/index.js b/plugin/index.js deleted file mode 100644 index da7e3b0..0000000 --- a/plugin/index.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Dialectic — OpenClaw plugin entry. - * - * Tools: dialectic_list_topics, dialectic_topic_detail, - * dialectic_propose_topic, dialectic_signup, - * dialectic_post_argument, dialectic_submit_verdict, - * dialectic_view_verdict - * - * Loader gotchas (per [[reference-meridian-plugin-contract]]): - * - openclaw.plugin.json MUST declare `activation.onStartup: true` - * or this plugin is silently treated as lazy/on-demand and - * register() is never called. - * - Every tool name MUST appear in contracts.tools or the tool - * never surfaces to agents. - * - Plain `export default { id, name, register }` works in jiti - * (matches prism-facet); don't pull SDK helpers without - * `openclaw` actually being resolvable at runtime. - */ -import { registerDialecticTools } from './src/tools.js'; -export default { - id: 'dialectic', - name: 'Dialectic', - register(api) { - registerDialecticTools(api); - api.logger.info('[dialectic] plugin registered'); - }, -}; diff --git a/plugin/src/backend-client.js b/plugin/src/backend-client.js deleted file mode 100644 index cd47018..0000000 --- a/plugin/src/backend-client.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * HTTP client for Dialectic backend (Go service). - * - * Auth: per-agent api key via `Authorization: Bearer `. Key is - * read from secret-mgr under the agent's `dialectic-agent-apikey` - * entry (provisioned at recruitment time — see Phase 3 deferred items). - * - * Errors: - * - Network/transport → throws. - * - Backend 4xx/5xx → throws with status + body text so the tool - * execute() can surface a useful error to the agent. - */ -import { execSync } from 'node:child_process'; -export class BackendClient { - baseUrl; - agentId; - timeoutMs; - cachedApiKey = null; - constructor(opts) { - this.baseUrl = opts.baseUrl.replace(/\/+$/, ''); - this.agentId = opts.agentId; - this.timeoutMs = opts.timeoutMs ?? 15000; - } - /** - * Read the agent's dialectic api key from secret-mgr. Cached in - * memory after first read so successive tool calls don't fork - * secret-mgr repeatedly. AGENT_VERIFY is required for secret-mgr - * to authorize the read. - */ - resolveApiKey() { - if (this.cachedApiKey) - return this.cachedApiKey; - try { - const out = execSync('secret-mgr get dialectic-agent-apikey', { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000, - }).trim(); - if (!out) { - throw new Error('empty'); - } - this.cachedApiKey = out; - return out; - } - catch { - throw new Error('dialectic api key not provisioned (secret-mgr key `dialectic-agent-apikey` missing). ' + - 'Phase 3 deferred: ask hangman / admin to mint one via the recruitment flow.'); - } - } - async get(path) { - return this.request('GET', path); - } - async post(path, body) { - return this.request('POST', path, body); - } - async put(path, body) { - return this.request('PUT', path, body); - } - async request(method, path, body) { - const url = `${this.baseUrl}${path.startsWith('/') ? path : '/' + path}`; - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), this.timeoutMs); - try { - const res = await fetch(url, { - method, - headers: { - 'content-type': 'application/json', - 'authorization': `Bearer ${this.resolveApiKey()}`, - }, - body: body === undefined ? undefined : JSON.stringify(body), - signal: ctrl.signal, - }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`dialectic ${method} ${path}: ${res.status} ${text.slice(0, 500)}`); - } - if (!text) - return null; - try { - return JSON.parse(text); - } - catch { - return text; - } - } - finally { - clearTimeout(timer); - } - } -} diff --git a/plugin/src/hf-precheck.js b/plugin/src/hf-precheck.js deleted file mode 100644 index 955c83d..0000000 --- a/plugin/src/hf-precheck.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * HF calendar pre-check for `dialectic_signup`. - * - * Per the design (section 8): before sending a signup to the backend, - * the plugin must verify the agent has an `on_call` slot that covers - * the entire debate window [debate_start_at, debate_end_at]. - * - * Data source: the HarborForge OpenclawPlugin already exposes - * `globalThis.__hfAgentStatus.get(agentId)` (Phase 1) which returns - * the agent's CURRENT status — but that's not enough; we need slot - * coverage over a FUTURE window. - * - * For Phase 3 v1: degrade gracefully — if HF can't be queried for - * future slots (no exposed accessor yet), require the caller to - * pass `pre_validated: false`. Backend stores the flag as audit; a - * follow-up Phase can tighten this. - * - * Phase 3 deferred: - * - HarborForge.OpenclawPlugin needs a new global accessor for - * "does agent X have an on_call slot covering [from, to]?". - * Until that lands, this function returns { ok: true, source: "skipped" } - * and trusts the agent's plugin invocation. - */ -export async function hfOnCallCoverageCheck(agentId, debateStartAt, debateEndAt) { - const _G = globalThis; - const hf = _G['__hfAgentStatus']; - // Phase 3 deferred: HF doesn't yet expose a window-coverage check. - // If it does (future), use it; otherwise skip and trust. - if (!hf || typeof hf.hasOnCallCovering !== 'function') { - return { ok: true, source: 'skipped' }; - } - try { - const covered = await hf.hasOnCallCovering(agentId, debateStartAt, debateEndAt); - if (covered === undefined) { - // HF queried but said "I don't know" — treat as skipped not failed - return { ok: true, source: 'skipped' }; - } - if (!covered) { - return { - ok: false, - reason: `agent ${agentId} has no on_call slot covering [${debateStartAt} → ${debateEndAt}]`, - source: 'hf', - }; - } - return { ok: true, source: 'hf' }; - } - catch { - return { ok: true, source: 'skipped' }; - } -} diff --git a/plugin/src/tools.js b/plugin/src/tools.js deleted file mode 100644 index cb07ffc..0000000 --- a/plugin/src/tools.js +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Tool registration for the Dialectic plugin. - * - * Six tools wired here, one per Dialectic backend endpoint the agent - * needs to drive end-to-end participation in a debate: - * - * dialectic_list_topics GET /api/topics - * dialectic_topic_detail GET /api/topics/{id} - * dialectic_propose_topic POST /api/topics - * dialectic_signup POST /api/topics/{id}/signups (with HF pre-check) - * dialectic_post_argument POST /api/topics/{id}/arguments - * dialectic_view_verdict GET /api/topics/{id}/verdict - * - * Every tool returns the standard MCP `{ content: [{ type: 'text', text }] }` - * shape; errors are caught and returned as a text payload so the agent - * sees something actionable rather than a runtime throw. - */ -import { BackendClient } from './backend-client.js'; -import { hfOnCallCoverageCheck } from './hf-precheck.js'; -const DEFAULT_BACKEND_URL = 'https://dialectic-api.hangman-lab.top'; -export function registerDialecticTools(api) { - // Config precedence: env var > openclaw plugin config > default. - // pluginConfig is the plugin-scoped subset (openclaw.json - // plugins.entries.dialectic.config) — confirmed by reading the - // openclaw SDK's api-builder which assigns `pluginConfig: - // params.pluginConfig`. `api.config` (whole openclaw.json) is left - // accessible for downstream that needs cross-plugin lookups. - const backendUrl = (process.env.DIALECTIC_BACKEND_URL ?? '').trim() || - api.pluginConfig?.backendUrl || - DEFAULT_BACKEND_URL; - // Per-tool we pull ctx.agentId out of the factory (the - // backend api key resolves from that agent's secret-mgr). - api.registerTool((ctx) => ({ - name: 'dialectic_list_topics', - description: 'List Dialectic debate topics, optionally filtered. Use status to scope to active phases ' + - '(created | signup_open | signup_closed | debating | completed | cancelled), visibility ' + - '(public | private), limit (default 50, max 200), offset (pagination).', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - status: { type: 'string', description: 'filter by status' }, - visibility: { type: 'string', description: 'filter by visibility' }, - limit: { type: 'integer', minimum: 1, maximum: 200 }, - offset: { type: 'integer', minimum: 0 }, - }, - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - const query = new URLSearchParams(); - for (const k of ['status', 'visibility', 'limit', 'offset']) { - if (params[k] != null) - query.set(k, String(params[k])); - } - const q = query.toString(); - return await client.get(`/api/topics${q ? '?' + q : ''}`); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_topic_detail', - description: 'Get full detail for one Dialectic topic — lifecycle timestamps, status, ' + - 'verdict_schema_id, allocated camps if past signup_close, and verdict if completed.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { topic_id: { type: 'string' } }, - required: ['topic_id'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_propose_topic', - description: 'Create a new Dialectic debate topic. Provide the title, summary, the 4 lifecycle ' + - 'timestamps (RFC3339, signup_open < signup_close <= debate_start < debate_end), and the ' + - "verdict_schema_id ('binary' | 'claim-resolution' | 'policy-recommendation' | 'free-form'). " + - 'visibility defaults to private — flip to public via UI after creation if appropriate. ' + - 'announce_guild_base_url + announce_channel_id (BOTH or NEITHER) pick a Fabric announce ' + - 'channel for backend-driven lifecycle broadcasts (signup_open / signup_closed / debating / ' + - 'completed / cancelled). Discover candidates via the Fabric channel-list tools — pick the ' + - "guild whose audience matches your topic's purpose and a channel of xType=announce. Omit " + - 'both → no broadcasts for this topic (agents must poll detail to track state).', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - title: { type: 'string' }, - summary: { type: 'string' }, - verdict_schema_id: { type: 'string' }, - visibility: { type: 'string' }, - signup_open_at: { type: 'string' }, - signup_close_at: { type: 'string' }, - debate_start_at: { type: 'string' }, - debate_end_at: { type: 'string' }, - announce_guild_base_url: { - type: 'string', - description: "Fabric guild HTTP base URL for the broadcast target (e.g. https://fabric-api.hangman-lab.top). " + - "Required if announce_channel_id is set, must be omitted otherwise.", - }, - announce_channel_id: { - type: 'string', - description: "Fabric channel UUID of an xType=announce channel in the chosen guild. " + - "Required if announce_guild_base_url is set, must be omitted otherwise.", - }, - }, - required: [ - 'title', 'summary', 'verdict_schema_id', - 'signup_open_at', 'signup_close_at', 'debate_start_at', 'debate_end_at', - ], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - const body = { - title: params.title, - summary: params.summary, - verdict_schema_id: params.verdict_schema_id, - signup_open_at: params.signup_open_at, - signup_close_at: params.signup_close_at, - debate_start_at: params.debate_start_at, - debate_end_at: params.debate_end_at, - }; - if (params.visibility) - body.visibility = params.visibility; - if (params.announce_guild_base_url) - body.announce_guild_base_url = params.announce_guild_base_url; - if (params.announce_channel_id) - body.announce_channel_id = params.announce_channel_id; - return await client.post(`/api/topics`, body); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_signup', - description: 'Volunteer (sign up) for one or more camps on a debate topic. ' + - "Camps are 'pro' | 'con' | 'judge'; you may volunteer for any subset, allocation " + - 'will pick at most one. Topic must be in `signup_open` status. ' + - 'Pre-flight: the plugin attempts to verify you have an on_call slot covering the debate window ' + - '(HF coverage check); if HF lookup is unavailable, the check is skipped and recorded as audit only.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - topic_id: { type: 'string' }, - willing_camps: { - type: 'array', - items: { type: 'string', enum: ['pro', 'con', 'judge'] }, - minItems: 1, - }, - }, - required: ['topic_id', 'willing_camps'], - }, - execute: async (_id, params) => asContent(async () => { - const agentId = ctx.agentId ?? ''; - const client = new BackendClient({ baseUrl: backendUrl, agentId }); - // Fetch topic detail so we know the debate window for HF pre-check. - const topic = (await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}`)); - if (!topic || typeof topic !== 'object') { - throw new Error('topic lookup failed (empty response)'); - } - const debateStart = topic.debate_start_at; - const debateEnd = topic.debate_end_at; - if (!debateStart || !debateEnd) { - throw new Error('topic detail missing debate window timestamps'); - } - const precheck = await hfOnCallCoverageCheck(agentId, debateStart, debateEnd); - if (!precheck.ok) { - throw new Error(`HF pre-check failed: ${precheck.reason}`); - } - return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/signups`, { - willing_camps: params.willing_camps, - pre_validated: precheck.source === 'hf', - }); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_post_argument', - description: 'Post an argument to a Dialectic topic you are currently allocated to. Must be in `debating` ' + - 'status. Content max 32KB. The argument attaches to the latest open round and is visible to ' + - 'other camp members and observers (and publicly if the topic is public).', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - topic_id: { type: 'string' }, - content: { type: 'string', maxLength: 32000 }, - }, - required: ['topic_id', 'content'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/arguments`, { content: params.content }); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_submit_verdict', - description: 'Submit the structured verdict for a debate you are the judge of. Topic must be in `debating` ' + - 'status AND past its debate_end_at. The `verdict` JSON shape must match the topic\'s ' + - 'verdict_schema_id (binary / claim-resolution / policy-recommendation / free-form). On success ' + - 'the topic transitions to `completed` and the verdict is visible via dialectic_view_verdict.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { - topic_id: { type: 'string' }, - verdict: { - type: 'object', - description: 'Structured verdict matching the topic verdict_schema_id shape', - additionalProperties: true, - }, - rationale: { type: 'string', description: 'Reasoning behind the verdict; non-empty' }, - tokens_input: { type: 'integer', minimum: 0, description: 'Optional cost telemetry' }, - tokens_output: { type: 'integer', minimum: 0, description: 'Optional cost telemetry' }, - }, - required: ['topic_id', 'verdict', 'rationale'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - const body = { - verdict: params.verdict, - rationale: params.rationale, - }; - if (typeof params.tokens_input === 'number') - body.tokens_input = params.tokens_input; - if (typeof params.tokens_output === 'number') - body.tokens_output = params.tokens_output; - return await client.post(`/api/topics/${encodeURIComponent(params.topic_id)}/verdict`, body); - }), - })); - api.registerTool((ctx) => ({ - name: 'dialectic_view_verdict', - description: 'Fetch the structured verdict for a completed Dialectic topic. 404 if the debate is still ' + - 'in progress or the judge has not yet submitted.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { topic_id: { type: 'string' } }, - required: ['topic_id'], - }, - execute: async (_id, params) => asContent(async () => { - const client = new BackendClient({ baseUrl: backendUrl, agentId: ctx.agentId ?? '' }); - return await client.get(`/api/topics/${encodeURIComponent(params.topic_id)}/verdict`); - }), - })); - api.logger.info(`[dialectic] registered 7 tools (backend=${backendUrl})`); -} -/** Wraps an async backend call and converts result/error into MCP content shape. */ -async function asContent(fn) { - try { - const out = await fn(); - const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2); - return { content: [{ type: 'text', text }] }; - } - catch (err) { - const msg = err?.message ?? String(err); - return { content: [{ type: 'text', text: `error: ${msg}` }] }; - } -} diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100644 index 0000000..85fa6d2 --- /dev/null +++ b/scripts/install.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +/** + * Dialectic.OpenclawPlugin installer (modeled on Fabric's install.mjs). + * + * node scripts/install.mjs --install build + install + configure + * node scripts/install.mjs --build-only build only + * node scripts/install.mjs --uninstall remove plugin + config + * + * Flags: + * --skip-check skip Node version check + * --verbose / -v verbose build output + * --openclaw-profile-path override ~/.openclaw target + * --backend-url seed channels.dialectic / plugin + * backendUrl config (default left unset + * so the manifest's default applies) + */ + +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { homedir } from 'os'; + +const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_ID = 'dialectic'; +// tsconfig.plugin.json emits here. Build artifacts NEVER live alongside +// .ts sources — this avoids jiti picking a stale .js when the .ts has +// been updated (the bug that silently shipped wrong code several times +// during the sim e2e). See tsconfig.plugin.json comment block. +const DIST_DIR = join(__dirname, 'dist', PLUGIN_ID); + +const args = process.argv.slice(2); +const opt = { + install: args.includes('--install'), + buildOnly: args.includes('--build-only'), + skipCheck: args.includes('--skip-check'), + verbose: args.includes('--verbose') || args.includes('-v'), + uninstall: args.includes('--uninstall'), +}; +const pIdx = args.indexOf('--openclaw-profile-path'); +const profileOverride = pIdx !== -1 && args[pIdx + 1] ? resolve(args[pIdx + 1]) : null; +const bIdx = args.indexOf('--backend-url'); +const backendUrl = bIdx !== -1 && args[bIdx + 1] ? args[bIdx + 1] : null; + +function openclawPath() { + if (profileOverride) return profileOverride; + if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH); + return join(homedir(), '.openclaw'); +} + +const c = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; +const log = (m, k = 'reset') => console.log(`${c[k]}${m}${c.reset}`); +const step = (n, t, m) => log(`[${n}/${t}] ${m}`, 'cyan'); +const ok = (m) => log(` ✓ ${m}`, 'green'); +const warn = (m) => log(` ⚠ ${m}`, 'yellow'); +const err = (m) => log(` ✗ ${m}`, 'red'); + +function exec(cmd, o = {}) { + return execSync(cmd, { + cwd: __dirname, + stdio: o.silent ? 'pipe' : 'inherit', + encoding: 'utf8', + ...o, + }); +} + +function cfgGet(key, def) { + try { + const out = exec( + `openclaw config get ${key} --json 2>/dev/null || echo undefined`, + { silent: true }, + ).trim(); + return out === 'undefined' || out === '' ? def : JSON.parse(out); + } catch { + return def; + } +} + +function cfgSet(key, val) { + exec(`openclaw config set ${key} '${JSON.stringify(val)}' --json`, { silent: true }); +} + +function cfgUnset(key) { + try { + exec(`openclaw config unset ${key}`, { silent: true }); + } catch { + /* ignore */ + } +} + +function copyDir(src, dest) { + mkdirSync(dest, { recursive: true }); + for (const e of readdirSync(src, { withFileTypes: true })) { + if (e.name === 'node_modules') continue; + const s = join(src, e.name); + const d = join(dest, e.name); + e.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); + } +} + +function detect() { + step(1, 5, 'Detecting environment...'); + let node = null; + try { + node = exec('node --version', { silent: true }).trim(); + ok(`Node ${node}`); + } catch { + err('Node not found'); + } + try { + ok(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`); + } catch { + warn('openclaw CLI not in PATH'); + } + return { node }; +} + +function checkDeps(env) { + if (opt.skipCheck) return; + step(2, 5, 'Checking dependencies...'); + if (!env.node || parseInt(env.node.slice(1), 10) < 18) { + err('Node 18+ required'); + process.exit(1); + } + ok('deps OK'); +} + +function build() { + step(3, 5, 'Building plugin...'); + // Wipe dist first so the build never silently mixes new + stale files. + rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); + exec('npm install', { silent: !opt.verbose }); + exec('npm run build', { silent: !opt.verbose }); + if (!existsSync(join(DIST_DIR, 'index.js'))) { + throw new Error(`build produced no ${join('dist', PLUGIN_ID, 'index.js')}`); + } + ok(`compiled -> dist/${PLUGIN_ID}`); +} + +function clearInstall(base) { + const dest = join(base, 'plugins', PLUGIN_ID); + if (existsSync(dest)) { + rmSync(dest, { recursive: true, force: true }); + ok(`removed ${dest}`); + } +} + +function cleanupConfig(base) { + const dest = join(base, 'plugins', PLUGIN_ID); + const allow = cfgGet('plugins.allow', []); + if (Array.isArray(allow) && allow.includes(PLUGIN_ID)) { + cfgSet('plugins.allow', allow.filter((x) => x !== PLUGIN_ID)); + ok('removed from plugins.allow'); + } + const paths = cfgGet('plugins.load.paths', []); + if (Array.isArray(paths) && paths.includes(dest)) { + cfgSet('plugins.load.paths', paths.filter((x) => x !== dest)); + ok('removed from plugins.load.paths'); + } + cfgUnset(`plugins.entries.${PLUGIN_ID}`); + ok('removed plugin entry'); +} + +function install() { + step(4, 5, 'Installing...'); + const base = openclawPath(); + const dest = join(base, 'plugins', PLUGIN_ID); + log(` OpenClaw path: ${base}`, 'blue'); + + if (existsSync(dest)) { + warn('existing install -> replacing'); + clearInstall(base); + } + mkdirSync(dirname(dest), { recursive: true }); + // Only dist/dialectic/ ships — pure compiled JS + no .ts left behind + // to confuse jiti's extension resolver. Manifest + package.json are + // copied explicitly because tsc doesn't touch non-.ts files. + copyDir(DIST_DIR, dest); + copyFileSync( + join(__dirname, 'plugin', 'openclaw.plugin.json'), + join(dest, 'openclaw.plugin.json'), + ); + copyFileSync( + join(__dirname, 'plugin', 'package.json'), + join(dest, 'package.json'), + ); + ok(`plugin files -> ${dest}`); + exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose }); + ok('runtime deps installed'); + return { base, dest }; +} + +function configure(base, dest) { + step(5, 5, 'Configuring OpenClaw...'); + const paths = cfgGet('plugins.load.paths', []); + if (Array.isArray(paths) && !paths.includes(dest)) { + cfgSet('plugins.load.paths', [...paths, dest]); + } + ok(`plugins.load.paths includes ${dest}`); + + const allow = cfgGet('plugins.allow', []); + if (Array.isArray(allow) && !allow.includes(PLUGIN_ID)) { + cfgSet('plugins.allow', [...allow, PLUGIN_ID]); + } + ok(`plugins.allow includes ${PLUGIN_ID}`); + + if (cfgGet(`plugins.entries.${PLUGIN_ID}.enabled`, undefined) === undefined) { + cfgSet(`plugins.entries.${PLUGIN_ID}.enabled`, true); + } + ok('plugin entry configured'); + + // Seed backendUrl only when explicitly requested via --backend-url. + // Otherwise the manifest's default (https://dialectic-api.hangman-lab.top) + // applies, and operators can override later via: + // openclaw config set plugins.entries.dialectic.config.backendUrl + if (backendUrl) { + cfgSet(`plugins.entries.${PLUGIN_ID}.config.backendUrl`, backendUrl); + ok(`backendUrl = ${backendUrl}`); + } +} + +function main() { + console.log(''); + log('Dialectic.OpenclawPlugin installer', 'cyan'); + console.log(''); + try { + const env = detect(); + if (opt.uninstall) { + const base = openclawPath(); + clearInstall(base); + cleanupConfig(base); + log('\nRun: openclaw gateway restart', 'yellow'); + return; + } + checkDeps(env); + build(); + if (opt.buildOnly) { + log('\nbuild-only — not installed.', 'yellow'); + return; + } + const { base, dest } = install(); + configure(base, dest); + console.log(''); + log('Install complete. Next:', 'blue'); + log(' 1. Provision agent api key (one per agent):', 'cyan'); + log(' AGENT_ID= AGENT_WORKSPACE= AGENT_VERIFY=… \\', 'cyan'); + log(' secret-mgr set --key dialectic-agent-apikey --secret ', 'cyan'); + log(' (raw key minted via the backend admin endpoint, see', 'cyan'); + log(' skills/dialectic-hangman-lab/dialectic-ctrl)', 'cyan'); + log(' 2. openclaw gateway restart', 'cyan'); + log(' 3. (sim/test only) set DIALECTIC_PLUGIN_BYPASS_HF=1 in the', 'cyan'); + log(' gateway env to skip the HF on_call coverage check.', 'cyan'); + console.log(''); + } catch (e) { + log(`\nInstall failed: ${e.message}`, 'red'); + process.exit(1); + } +} + +main(); diff --git a/tsconfig.plugin.json b/tsconfig.plugin.json index d9b04b7..9bd4b25 100644 --- a/tsconfig.plugin.json +++ b/tsconfig.plugin.json @@ -1,17 +1,26 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "outDir": "plugin", + // NodeNext (not ESNext/bundler) so the emitted .js works under + // plain Node.js ESM at runtime — jiti + the openclaw gateway + // both run on Node, not under a bundler. + "module": "NodeNext", + "moduleResolution": "NodeNext", + // Emit compiled JS into a separate dist/ tree so build artifacts + // never collide with .ts sources. With outDir === rootDir, + // tsc produces src/foo.js next to src/foo.ts, and jiti picks + // .js (its default extension-resolve order) — which silently + // shipped stale code several times during sim e2e. Isolating + // dist/ removes the entire race. + "outDir": "dist/dialectic", "rootDir": "plugin", "declaration": false, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "allowJs": true + "allowJs": false }, "include": ["plugin/**/*.ts"], - "exclude": ["plugin/**/*.js"] + "exclude": ["plugin/**/*.js", "dist/**/*"] }