Flip the agent-skill default from mono (single dws/ dir) to multi (per-product dingtalk-* + dws-shared) across all distribution faces, and fix the upgrade path so it no longer re-installs mono alongside multi (mono+multi co-existence bug). - upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh removes mono leftovers and stale skills, refreshes the multi cache - install.sh/ps1/install-skills.sh/npm install.js: multi real-install (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE - skill setup: non-interactive default multi; full installs now clean stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered (-s/-x) installs stay additive - mutual exclusion is symmetric and includes dws-shared (previously leaked through the dingtalk- prefix) on all faces - install.js: guard empty/corrupt multi trees (fall back to mono), validate SKILL.md on the mono branch, guard cache refreshes - docs: roadmap (8/30 back-schedule), migration plan, distribution mechanism, rollout capability, capability completion, architecture optimization, wukong comparison (archived; line retired) Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
9.5 KiB
JavaScript
222 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* install_js_smoke.mjs — smoke test for build/npm/install.js (npm postinstall).
|
|
*
|
|
* Runs the REAL build/npm/install.js against a staged fake package:
|
|
*
|
|
* <tmp>/pkg/
|
|
* install.js (copied from build/npm/install.js)
|
|
* assets/
|
|
* dws-<os>-<arch>.tar.gz (dummy archive holding a fake `dws` binary)
|
|
* dws-skills.zip (tiny release-layout fixture built on the fly,
|
|
* NOT the real skills/ tree)
|
|
*
|
|
* Scenarios (each with an isolated fake HOME):
|
|
* 1. multi install — dingtalk-* and dws-shared land as sibling
|
|
* skills, mono leftover dws/ and stale
|
|
* dingtalk-* are removed, and the
|
|
* ~/.dws/skills/{multi,mono} caches fill.
|
|
* 2. empty multi/ tree — warns and falls back to mono instead of
|
|
* crashing postinstall; a previously good multi
|
|
* cache is NOT wiped.
|
|
* 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
|
|
* "invalid DWS_SKILL_MODE" error.
|
|
* 4. multi-only zip, mono — mono install is skipped with a warning; the
|
|
* staging root is NOT copied into a dws/ dir.
|
|
*
|
|
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
|
|
* install.js itself shells out to). Skips cleanly on win32.
|
|
*
|
|
* Usage (standalone; there is intentionally no Go test harness for the npm
|
|
* installer — test/scripts/install_script_test.go only execs POSIX sh):
|
|
*
|
|
* node test/scripts/install_js_smoke.mjs # self-contained, <10s
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import childProcess from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
|
|
const PLATFORM_MAP = {
|
|
"darwin-x64": "dws-darwin-amd64.tar.gz",
|
|
"darwin-arm64": "dws-darwin-arm64.tar.gz",
|
|
"linux-x64": "dws-linux-amd64.tar.gz",
|
|
"linux-arm64": "dws-linux-arm64.tar.gz",
|
|
};
|
|
|
|
const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..");
|
|
const installJsSource = path.join(repoRoot, "build", "npm", "install.js");
|
|
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
|
|
|
if (process.platform === "win32" || !assetName) {
|
|
console.log(`SKIP: install.js smoke test needs a unix host with tar/zip/unzip (got ${process.platform}-${process.arch})`);
|
|
process.exit(0);
|
|
}
|
|
for (const tool of ["tar", "zip", "unzip"]) {
|
|
try {
|
|
childProcess.execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" });
|
|
} catch {
|
|
console.log(`SKIP: required tool not on PATH: ${tool}`);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
function sh(command, args, options = {}) {
|
|
childProcess.execFileSync(command, args, { stdio: "ignore", ...options });
|
|
}
|
|
|
|
function writeFile(filePath, content, mode = 0o644) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, { mode });
|
|
}
|
|
|
|
// stagePkg builds a fake npm package whose assets/dws-skills.zip contains
|
|
// exactly the given zip entries ({ "relative/path": "content" }) plus any
|
|
// listed empty directories. Returns { tmp, pkg, home }.
|
|
function stagePkg(zipEntries, emptyDirs = []) {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-smoke-"));
|
|
const pkg = path.join(tmp, "pkg");
|
|
const assets = path.join(pkg, "assets");
|
|
fs.mkdirSync(assets, { recursive: true });
|
|
fs.copyFileSync(installJsSource, path.join(pkg, "install.js"));
|
|
|
|
const binStage = path.join(tmp, "bin-stage");
|
|
writeFile(path.join(binStage, "dws"), "#!/bin/sh\necho fake-dws\n", 0o755);
|
|
sh("tar", ["-czf", path.join(assets, assetName), "-C", binStage, "."]);
|
|
|
|
const zipStage = path.join(tmp, "zip-stage");
|
|
for (const [rel, content] of Object.entries(zipEntries)) {
|
|
writeFile(path.join(zipStage, rel), content);
|
|
}
|
|
for (const dir of emptyDirs) {
|
|
fs.mkdirSync(path.join(zipStage, dir), { recursive: true });
|
|
}
|
|
sh("zip", ["-qr", path.join(assets, "dws-skills.zip"), "."], { cwd: zipStage });
|
|
|
|
return { tmp, pkg, home: path.join(tmp, "home") };
|
|
}
|
|
|
|
function runInstall(pkg, home, skillMode) {
|
|
const env = { ...process.env, HOME: home };
|
|
if (skillMode !== undefined) {
|
|
env.DWS_SKILL_MODE = skillMode;
|
|
} else {
|
|
delete env.DWS_SKILL_MODE;
|
|
}
|
|
return childProcess.spawnSync(process.execPath, [path.join(pkg, "install.js")], {
|
|
env,
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
const scenarios = [];
|
|
function scenario(name, fn) {
|
|
scenarios.push([name, fn]);
|
|
}
|
|
|
|
scenario("multi install lays out sibling skills and caches", () => {
|
|
const { tmp, pkg, home } = stagePkg({
|
|
"SKILL.md": "# mono root copy\n",
|
|
"mono/SKILL.md": "# mono fixture\n",
|
|
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
|
"multi/dingtalk-test/references/guide.md": "guide\n",
|
|
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
|
});
|
|
try {
|
|
// Pre-existing state the multi install must reconcile.
|
|
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
|
|
writeFile(path.join(home, ".agents", "skills", "dingtalk-stale", "SKILL.md"), "stale\n");
|
|
writeFile(path.join(home, ".agents", "skills", "other-skill", "SKILL.md"), "not dws\n");
|
|
|
|
const res = runInstall(pkg, home, undefined); // default mode = multi
|
|
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
|
assert.match(res.stdout, /Skill mode: multi/);
|
|
|
|
const base = path.join(home, ".agents", "skills");
|
|
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "SKILL.md")), "dingtalk-test installed");
|
|
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "references", "guide.md")), "references copied");
|
|
assert.ok(fs.existsSync(path.join(base, "dws-shared", "SKILL.md")), "dws-shared installed");
|
|
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono leftover removed");
|
|
assert.ok(!fs.existsSync(path.join(base, "dingtalk-stale")), "stale skill removed");
|
|
assert.ok(fs.existsSync(path.join(base, "other-skill", "SKILL.md")), "non-DWS skill preserved");
|
|
|
|
assert.ok(fs.existsSync(path.join(home, ".dws", "skills", "multi", "dingtalk-test", "SKILL.md")), "multi cache filled");
|
|
assert.equal(fs.readFileSync(path.join(home, ".dws", "skills", "mono", "SKILL.md"), "utf8"), "# mono fixture\n", "mono cache from mono/ tree");
|
|
assert.ok(fs.existsSync(path.join(pkg, "vendor", "dws")), "binary installed into vendor/");
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => {
|
|
const { tmp, pkg, home } = stagePkg({
|
|
"SKILL.md": "# mono root copy\n",
|
|
"mono/SKILL.md": "# mono fixture\n",
|
|
// Corrupt multi tree: a product subdir without SKILL.md.
|
|
"multi/dingtalk-broken/references/guide.md": "orphan\n",
|
|
});
|
|
try {
|
|
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
|
|
// A previously good multi cache must survive an empty/corrupt bundle.
|
|
writeFile(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "good cache\n");
|
|
|
|
const res = runInstall(pkg, home, "multi");
|
|
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
|
assert.match(res.stdout, /falling back to mono/);
|
|
|
|
const base = path.join(home, ".agents", "skills");
|
|
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "# mono fixture\n", "mono installed from mono/ tree");
|
|
assert.ok(!fs.existsSync(path.join(base, "dingtalk-broken")), "broken multi skill not installed");
|
|
assert.equal(
|
|
fs.readFileSync(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "utf8"),
|
|
"good cache\n",
|
|
"previously good multi cache must not be wiped",
|
|
);
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario("bogus DWS_SKILL_MODE fails fast with a clear error", () => {
|
|
const { tmp, pkg, home } = stagePkg({
|
|
"mono/SKILL.md": "# mono fixture\n",
|
|
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
|
});
|
|
try {
|
|
const res = runInstall(pkg, home, "bogus");
|
|
assert.notEqual(res.status, 0, "bogus mode must exit non-zero");
|
|
assert.match(res.stderr, /invalid DWS_SKILL_MODE/);
|
|
assert.ok(!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-test")), "nothing installed on mode error");
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
scenario("multi-only zip in mono mode skips skill install instead of copying staging root", () => {
|
|
const { tmp, pkg, home } = stagePkg({
|
|
// No root SKILL.md and no mono/ tree — a multi-only release layout.
|
|
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
|
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
|
});
|
|
try {
|
|
const res = runInstall(pkg, home, "mono");
|
|
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
|
assert.match(res.stdout, /mono skill tree not found.*skipping skill install/s);
|
|
const base = path.join(home, ".agents", "skills");
|
|
assert.ok(!fs.existsSync(path.join(base, "dws")), "staging root must not be copied into dws/");
|
|
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills", "mono")), "mono cache not refreshed without a mono tree");
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
for (const [name, fn] of scenarios) {
|
|
process.stdout.write(`• ${name} ... `);
|
|
fn();
|
|
process.stdout.write("ok\n");
|
|
}
|
|
console.log(`OK — ${scenarios.length} install.js smoke scenarios passed`);
|