build(plugin): isolate dist/, add install.mjs, untrack .js artifacts

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.<id>.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).
This commit is contained in:
h z
2026-05-23 22:44:01 +01:00
parent 63fc342238
commit 955f13d72a
7 changed files with 293 additions and 430 deletions

268
scripts/install.mjs Normal file
View File

@@ -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 <dir> override ~/.openclaw target
* --backend-url <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 <url>
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> AGENT_WORKSPACE=<ws> AGENT_VERIFY=… \\', 'cyan');
log(' secret-mgr set --key dialectic-agent-apikey --secret <raw>', '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();