Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both keep their exit codes and their old business-id wording; a repeated or conflicting selector says why. persona-import now checks the id while parsing, so stage refuses an invalid selector before it reads anything. The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real copy already does (COPY_PATHS takes packages/business/src).
460 lines
25 KiB
JavaScript
460 lines
25 KiB
JavaScript
// Case 2 row C2-20 (#1722): scripts/case2-persona-import.mjs takes Bean's and Chet's personas from
|
|
// two Hermes profiles into Mosaic persona files. Brief: docs/plans/2026-10-20_case2-rows.md "C2-20".
|
|
// Every case runs on a scratch copy of fixtures/case2-persona-import/hermes, a scratch staging
|
|
// directory and a scratch config directory. Nothing here reads or writes ~/.hermes, ~/.mosaic-dev or
|
|
// ~/.config/mosaic-dev, and no fixture value is real. Fixture strings starting "marker-" must never
|
|
// reach the script's output.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { PERSONA_CHARS, personaTextFault } from "../../harness/src/bundle.mjs";
|
|
import { tmp } from "./helpers.mjs";
|
|
|
|
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
|
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
|
|
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
|
|
const TRACE = join(FIXTURE, "trace.mjs");
|
|
const MKDIR_RACE = join(FIXTURE, "mkdir-race.mjs");
|
|
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
|
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
const mode = (path) => statSync(path).mode & 0o777;
|
|
|
|
// A scratch Hermes home from the fixture, with the two .env files git won't carry, and empty
|
|
// staging and config parents.
|
|
function scratch(t) {
|
|
const root = tmp(t, "mosaic-persona-");
|
|
const hermes = join(root, "hermes");
|
|
cpSync(join(FIXTURE, "hermes"), hermes, { recursive: true });
|
|
writeFileSync(join(hermes, ".env"), "DISCORD_BOT_TOKEN=marker-env-default-3b58\n");
|
|
writeFileSync(join(hermes, "profiles", "chet", ".env"), "DISCORD_BOT_TOKEN=marker-env-chet-7e44\n");
|
|
const configDir = join(root, "config");
|
|
mkdirSync(configDir, { mode: 0o700 });
|
|
chmodSync(configDir, 0o700);
|
|
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
|
|
}
|
|
|
|
function run(argv, { trace, race } = {}) {
|
|
const pre = [...(trace ? ["--import", TRACE] : []), ...(race ? ["--import", MKDIR_RACE] : [])];
|
|
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}), ...(race ? { PERSONA_MKDIR_RACE: race } : {}) };
|
|
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
|
|
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
|
|
}
|
|
|
|
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
|
|
const install = (s, business = "case2", opts) => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business], opts);
|
|
|
|
// Every fixture marker and every line of fixture text the script reads.
|
|
function assertNoText(s, output) {
|
|
const markers = new Set();
|
|
const walk = (dir) => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) walk(p);
|
|
else for (const m of readFileSync(p, "utf8").match(/marker-[a-z]+(?:-[a-z]+)*-[0-9a-f]{4}/g) ?? []) markers.add(m);
|
|
}
|
|
};
|
|
walk(s.hermes);
|
|
assert.ok(markers.size >= 20, `only ${markers.size} markers found`);
|
|
for (const m of markers) assert.ok(!output.includes(m), `output holds ${m}`);
|
|
for (const line of ["You speak warmly", "Dry humour", "Help with the novel", "Ünïcode prompt"]) {
|
|
assert.ok(!output.includes(line), `output holds "${line}"`);
|
|
}
|
|
}
|
|
|
|
function personaTree(s, { bean, chet }) {
|
|
if (bean !== undefined) writeFileSync(join(s.hermes, "SOUL.md"), bean);
|
|
if (chet !== undefined) writeFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"), chet);
|
|
}
|
|
|
|
test("stage takes each profile's SOUL.md byte for byte and prints the pairing with sizes and digests", (t) => {
|
|
const s = scratch(t);
|
|
const bean = readFileSync(join(s.hermes, "SOUL.md"));
|
|
const chet = readFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"));
|
|
const r = stage(s);
|
|
assert.equal(r.status, 0, r.all);
|
|
assert.deepEqual(readFileSync(join(s.staging, "bean.md")), bean);
|
|
assert.deepEqual(readFileSync(join(s.staging, "chet.md")), chet);
|
|
assert.ok(r.out.includes(`pairing: profile default is Bean (instance bean), SOUL.md ${bean.length} bytes, sha256 ${sha256(bean)}`), r.out);
|
|
assert.ok(r.out.includes(`pairing: profile chet is Chet (instance chet), SOUL.md ${chet.length} bytes, sha256 ${sha256(chet)}`), r.out);
|
|
// The pairing comes before the first file written.
|
|
assert.ok(r.out.indexOf("pairing:") < r.out.indexOf("staged:"), r.out);
|
|
const manifest = JSON.parse(readFileSync(join(s.staging, "manifest.json"), "utf8"));
|
|
assert.deepEqual(manifest, {
|
|
version: 1,
|
|
personas: [
|
|
{ instance: "bean", name: "Bean", bytes: bean.length, sha256: sha256(bean) },
|
|
{ instance: "chet", name: "Chet", bytes: chet.length, sha256: sha256(chet) },
|
|
],
|
|
});
|
|
assert.match(r.out, /next, as root: give .* to <user>/);
|
|
assertNoText(s, r.all);
|
|
});
|
|
|
|
// gateway/config.py at hermes-agent df5700ebe: a platform's block is <platform> at the top level
|
|
// (:1159), else platforms.<platform> or gateway.platforms.<platform> (:1172-1178), and the prompts are
|
|
// its channel_prompts (:1233). The fixture config.yaml has one of each.
|
|
test("stage lists the per-channel prompts in all three places by name and byte count only", (t) => {
|
|
const s = scratch(t);
|
|
const r = stage(s);
|
|
assert.equal(r.status, 0, r.all);
|
|
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
|
|
assert.deepEqual(lines, [
|
|
`profile default: per-channel prompt "discord" "111111111111111111", ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
|
|
`profile default: per-channel prompt "discord" "222222222222222222", ${Buffer.byteLength("Ünïcode prompt")} bytes`,
|
|
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
|
|
'profile default: per-channel prompt "discord" "1333333333333333333", 5 bytes',
|
|
`profile default: per-channel prompt "platforms.slack" "C0FIXTURE", ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
|
|
'profile default: per-channel prompt "gateway.platforms.telegram" "-1001", 29 bytes',
|
|
'profile default: per-channel prompt "gateway.platforms.telegram" "-1002", not text',
|
|
]);
|
|
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
|
|
assertNoText(s, r.all);
|
|
});
|
|
|
|
// C2-20b N1 (webby-0073): a platform or channel key holding a newline prints as one quoted line, so
|
|
// it can't forge a line of the script's own output.
|
|
test("a config key with a newline prints as one quoted line", (t) => {
|
|
const s = scratch(t);
|
|
writeFileSync(join(s.hermes, "profiles", "chet", "config.yaml"), [
|
|
'"discord\\nstaged: /forged/bean.md":',
|
|
" channel_prompts:",
|
|
' "1\\npairing: profile chet is Bean": marker-prompt-forge-4c1e',
|
|
"",
|
|
].join("\n"));
|
|
const r = stage(s);
|
|
assert.equal(r.status, 0, r.all);
|
|
const lines = r.out.split("\n");
|
|
assert.deepEqual(lines.filter((l) => l.startsWith("profile chet: per-channel prompt ")), [
|
|
`profile chet: per-channel prompt "discord\\nstaged: /forged/bean.md" "1\\npairing: profile chet is Bean", ${Buffer.byteLength("marker-prompt-forge-4c1e")} bytes`,
|
|
]);
|
|
assert.ok(!lines.some((l) => l.startsWith("staged: /forged") || l.startsWith("pairing: profile chet is Bean")), r.out);
|
|
assertNoText(s, r.all);
|
|
});
|
|
|
|
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
|
|
const s = scratch(t);
|
|
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
|
|
rmSync(s.trace, { force: true });
|
|
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv], { trace: s.trace });
|
|
assert.equal(r.status, 0, r.all);
|
|
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1)).filter((p) => p.startsWith(s.hermes));
|
|
// The control: the trace sees the reads the script does make.
|
|
assert.ok(opened.includes(join(s.hermes, "SOUL.md")), opened.join("\n"));
|
|
assert.ok(opened.includes(join(s.hermes, "profiles", "chet", "SOUL.md")), opened.join("\n"));
|
|
const allowed = new Set([
|
|
join(s.hermes, "SOUL.md"), join(s.hermes, "config.yaml"),
|
|
join(s.hermes, "profiles", "chet", "SOUL.md"), join(s.hermes, "profiles", "chet", "config.yaml"),
|
|
]);
|
|
for (const p of opened) assert.ok(allowed.has(p), `opened ${p}`);
|
|
if (argv[0] === "--dry-run") assert.ok(!opened.some((p) => p.endsWith("config.yaml")), "the dry run read config.yaml");
|
|
else assert.ok(opened.includes(join(s.hermes, "config.yaml")), opened.join("\n"));
|
|
}
|
|
});
|
|
|
|
test("the pairing refuses anything but two existing profiles naming Bean and Chet once each, and writes nothing", (t) => {
|
|
const s = scratch(t);
|
|
const cases = [
|
|
[["--pair", "default=Bean", "--pair", "chet=Bob"], 1, /profile chet names "Bob", not Bean or Chet/],
|
|
[["--pair", "default=bean", "--pair", "chet=Chet"], 1, /names "bean", not Bean or Chet/],
|
|
[["--pair", "default=Bean", "--pair", "chet=Bean"], 1, /Bean is named twice/],
|
|
[["--pair", "chet=Bean", "--pair", "chet=Chet"], 1, /profile chet is paired twice/],
|
|
[["--pair", "default=Bean", "--pair", "nobody=Chet"], 1, /profile nobody: .* doesn't exist/],
|
|
[["--pair", "default=Bean", "--pair", "../chet=Chet"], 1, /isn't a Hermes profile name/],
|
|
[["--pair", "default=Bean"], 2, /exactly two --pair/],
|
|
[["--pair", "default=Bean", "--pair", "chet=Chet", "--pair", "x=Chet"], 2, /exactly two --pair/],
|
|
];
|
|
for (const [pairs, status, message] of cases) {
|
|
const r = run(["stage", "--hermes-root", s.hermes, ...pairs, "--staging", s.staging]);
|
|
assert.equal(r.status, status, `${pairs.join(" ")}: ${r.all}`);
|
|
assert.match(r.err, message);
|
|
assert.ok(!existsSync(s.staging), `${pairs.join(" ")} wrote the staging directory`);
|
|
}
|
|
// Swapped is a valid pairing: the human's say decides it.
|
|
const r = run(["stage", "--hermes-root", s.hermes, "--pair", "default=Chet", "--pair", "chet=Bean", "--staging", s.staging]);
|
|
assert.equal(r.status, 0, r.all);
|
|
assert.match(r.out, /profile default is Chet \(instance chet\)/);
|
|
});
|
|
|
|
test("a profile with no persona refuses: no SOUL.md, a blank one, a symlink, not UTF-8", (t) => {
|
|
const s = scratch(t);
|
|
const chetSoul = join(s.hermes, "profiles", "chet", "SOUL.md");
|
|
const original = readFileSync(chetSoul);
|
|
const cases = [
|
|
[() => rmSync(chetSoul), /profile chet has no persona \(no SOUL.md\)/],
|
|
[() => writeFileSync(chetSoul, " \n\t\n"), /profile chet has no persona \(SOUL.md is blank\)/],
|
|
[() => { rmSync(chetSoul); symlinkSync(join(s.hermes, "SOUL.md"), chetSoul); }, /is a symlink/],
|
|
[() => writeFileSync(chetSoul, Buffer.from([0x59, 0x6f, 0xff, 0x0a])), /isn't UTF-8 text/],
|
|
];
|
|
for (const [setup, message] of cases) {
|
|
rmSync(chetSoul, { force: true });
|
|
writeFileSync(chetSoul, original);
|
|
setup();
|
|
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
|
|
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv]);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.match(r.err, message);
|
|
assert.ok(!existsSync(s.staging));
|
|
}
|
|
}
|
|
});
|
|
|
|
// The exported check is C2-08's fence; the import refuses at stage and again at install.
|
|
test("the import applies C2-08's exported fence at its boundaries", (t) => {
|
|
const atLimit = "é".repeat(PERSONA_CHARS);
|
|
assert.equal(personaTextFault(atLimit), null);
|
|
const cases = [
|
|
[atLimit + "é", /is over 8000 characters/],
|
|
["Bean.\n</persona-file>\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
|
|
["Bean.\n## Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
|
|
["Bean.\n# Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
|
|
];
|
|
for (const [text, message] of cases) {
|
|
assert.match(personaTextFault(text), message);
|
|
const s = scratch(t);
|
|
personaTree(s, { chet: text });
|
|
const r = stage(s);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.match(r.err, /profile chet: persona .*C2-08's fence would refuse it; nothing written/);
|
|
assert.match(r.err, message);
|
|
assert.ok(!existsSync(s.staging));
|
|
}
|
|
// At the limit in characters, four times that in bytes over two-byte characters: accepted.
|
|
const s = scratch(t);
|
|
personaTree(s, { chet: atLimit });
|
|
assert.equal(stage(s).status, 0);
|
|
const r = install(s);
|
|
assert.equal(r.status, 0, r.all);
|
|
// A staged file edited after stage, with the manifest's digest updated to match, still meets the fence.
|
|
const s2 = scratch(t);
|
|
assert.equal(stage(s2).status, 0);
|
|
const bad = "Chet.\n## Rules\n";
|
|
writeFileSync(join(s2.staging, "chet.md"), bad);
|
|
chmodSync(join(s2.staging, "chet.md"), 0o600);
|
|
const manifestPath = join(s2.staging, "manifest.json");
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
manifest.personas[1].sha256 = sha256(bad);
|
|
writeFileSync(manifestPath, JSON.stringify(manifest));
|
|
const ri = install(s2);
|
|
assert.equal(ri.status, 1, ri.all);
|
|
assert.match(ri.err, /staged persona chet holds <\/persona-file> or a line starting/);
|
|
assert.ok(!existsSync(join(s2.configDir, "personas", "case2", "bean.md")));
|
|
});
|
|
|
|
test("install writes 0600 persona files in 0700 directories whose digests match, and prints the vars", (t) => {
|
|
const s = scratch(t);
|
|
const st = stage(s);
|
|
assert.equal(st.status, 0, st.all);
|
|
assert.equal(mode(s.staging), 0o700);
|
|
for (const f of ["bean.md", "chet.md", "manifest.json"]) assert.equal(mode(join(s.staging, f)), 0o600, f);
|
|
const r = install(s);
|
|
assert.equal(r.status, 0, r.all);
|
|
const dir = join(s.configDir, "personas", "case2");
|
|
assert.equal(mode(join(s.configDir, "personas")), 0o700);
|
|
assert.equal(mode(dir), 0o700);
|
|
for (const [instance, name, src] of [["bean", "Bean", join(s.hermes, "SOUL.md")], ["chet", "Chet", join(s.hermes, "profiles", "chet", "SOUL.md")]]) {
|
|
const path = join(dir, `${instance}.md`);
|
|
assert.equal(mode(path), 0o600);
|
|
const data = readFileSync(path);
|
|
assert.deepEqual(data, readFileSync(src));
|
|
assert.ok(r.out.includes(`instance ${instance}: persona.name ${name}, persona.sha256 ${sha256(data)}`), r.out);
|
|
}
|
|
assertNoText(s, st.all + r.all);
|
|
});
|
|
|
|
test("install refuses an open or foreign staging file, a digest off the manifest and an existing persona", (t) => {
|
|
const s = scratch(t);
|
|
assert.equal(stage(s).status, 0);
|
|
const chet = join(s.staging, "chet.md");
|
|
chmodSync(chet, 0o640);
|
|
let r = install(s);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.match(r.err, /staged persona chet: .* has group or other mode bits/);
|
|
chmodSync(chet, 0o600);
|
|
chmodSync(s.staging, 0o750);
|
|
r = install(s);
|
|
assert.match(r.err, /staging has group or other mode bits/);
|
|
chmodSync(s.staging, 0o700);
|
|
|
|
writeFileSync(chet, "You are someone else.\n");
|
|
r = install(s);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.match(r.err, /staged persona chet isn't the file the manifest names/);
|
|
assert.ok(!existsSync(join(s.configDir, "personas", "case2", "bean.md")), "bean was written before chet was checked");
|
|
|
|
const s2 = scratch(t);
|
|
assert.equal(stage(s2).status, 0);
|
|
const dir = join(s2.configDir, "personas", "case2");
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
chmodSync(join(s2.configDir, "personas"), 0o700);
|
|
writeFileSync(join(dir, "chet.md"), "already here\n", { mode: 0o600 });
|
|
r = install(s2);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.match(r.err, /chet.md already exists; nothing written/);
|
|
assert.equal(readFileSync(join(dir, "chet.md"), "utf8"), "already here\n");
|
|
assert.ok(!existsSync(join(dir, "bean.md")));
|
|
|
|
for (const business of ["../x", "Case2", ""]) {
|
|
r = install(s2, business);
|
|
assert.notEqual(r.status, 0, business);
|
|
}
|
|
const s3 = scratch(t);
|
|
assert.equal(stage(s3).status, 0);
|
|
writeFileSync(join(s3.staging, "manifest.json"), JSON.stringify({ version: 1, personas: [{ instance: "bob", name: "Bob" }] }));
|
|
r = install(s3);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.match(r.err, /the manifest doesn't list two personas/);
|
|
});
|
|
|
|
test("--dry-run writes nothing and prints only counts and the fence fault, two heading lines in a fixture counted", (t) => {
|
|
const s = scratch(t);
|
|
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
|
|
const before = readdirSync(s.root).sort();
|
|
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.deepEqual(readdirSync(s.root).sort(), before);
|
|
assert.deepEqual(r.out.split("\n").filter(Boolean), [
|
|
"files 2",
|
|
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
|
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
|
|
'profile chet: persona holds </persona-file> or a line starting "# " or "## ", so C2-08\'s fence would refuse it',
|
|
]);
|
|
assert.equal(r.err, "case2-persona-import: refused: C2-08's fence would refuse the persona of profile chet, so stage would write nothing\n");
|
|
assertNoText(s, r.all);
|
|
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
|
|
});
|
|
|
|
// C2-20b N2 (webby-0073): a persona over the fence and under the byte cap used to pass the dry run
|
|
// and then refuse at stage. The dry run names the fault and refuses too, and passes at the limit.
|
|
test("--dry-run applies C2-08's fence: over it the fault prints, nothing is written and it exits 1", (t) => {
|
|
const atLimit = "é".repeat(PERSONA_CHARS);
|
|
const s = scratch(t);
|
|
personaTree(s, { bean: atLimit + "é" });
|
|
const before = readdirSync(s.root).sort();
|
|
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.deepEqual(readdirSync(s.root).sort(), before);
|
|
assert.deepEqual(r.out.split("\n").filter(Boolean), [
|
|
"files 2",
|
|
'profile default: lines 1, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
|
"profile default: persona is over 8000 characters, so C2-08's fence would refuse it",
|
|
'profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
|
]);
|
|
assert.match(r.err, /refused: C2-08's fence would refuse the persona of profile default, so stage would write nothing/);
|
|
assert.ok(!r.all.includes("éé"));
|
|
personaTree(s, { bean: atLimit });
|
|
const ok = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
|
assert.equal(ok.status, 0, ok.all);
|
|
assert.equal(ok.err, "");
|
|
});
|
|
|
|
// C2-20b N4 (webby-0073): a directory made between privateDir's lstat and its mkdir refuses with a
|
|
// named reason, not a raw EEXIST. mkdir-race.mjs makes the directory just before the script's mkdir.
|
|
test("a directory made between the check and mkdir refuses by name, at stage and at install", (t) => {
|
|
const s = scratch(t);
|
|
let r = stage(s, [], { race: s.staging });
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.equal(r.err, `case2-persona-import: refused: ${s.staging} was made by something else between the check and mkdir\n`);
|
|
assert.deepEqual(readdirSync(s.staging), []);
|
|
|
|
const s2 = scratch(t);
|
|
assert.equal(stage(s2).status, 0);
|
|
const dir = join(s2.configDir, "personas", "case2");
|
|
r = install(s2, "case2", { race: dir });
|
|
assert.equal(r.status, 1, r.all);
|
|
assert.equal(r.err, `case2-persona-import: refused: ${dir} was made by something else between the check and mkdir\n`);
|
|
assert.deepEqual(readdirSync(dir), []);
|
|
});
|
|
|
|
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
|
|
const s = scratch(t);
|
|
personaTree(s, {
|
|
bean: [
|
|
"You are Bean.",
|
|
"Use web_search when she asks for facts.",
|
|
"You live on Discord, in her server.",
|
|
"Keep a TODO list for her chapters.",
|
|
"Web searching and discords of music don't count, nor memory_bank.",
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
|
assert.equal(r.status, 0, r.all);
|
|
assert.match(r.out, /profile default: lines 5, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 3/);
|
|
for (const word of ["web_search", "Discord", "discord", "TODO", "todo", "memory"]) assert.ok(!r.all.includes(word), `output holds ${word}`);
|
|
});
|
|
|
|
test("usage: stage needs one of --staging and --dry-run, install takes only its own options", (t) => {
|
|
const s = scratch(t);
|
|
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS]).status, 2);
|
|
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, "--dry-run"]).status, 2);
|
|
assert.equal(run(["stage", ...PAIRS, "--dry-run"]).status, 2);
|
|
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir]).status, 2);
|
|
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", "case2", "--dry-run"]).status, 2);
|
|
assert.equal(run(["import"]).status, 2);
|
|
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
|
|
assert.ok(!existsSync(s.staging));
|
|
});
|
|
|
|
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one option in
|
|
// this script's own parser, run directly, not through case2-install.sh.
|
|
test("install takes --org or --business, alone or matching in either order", (t) => {
|
|
for (const selector of [["--business", "case2"], ["--org", "case2"], ["--business", "case2", "--org", "case2"], ["--org", "case2", "--business", "case2"]]) {
|
|
const s = scratch(t);
|
|
assert.equal(stage(s).status, 0);
|
|
const r = run(["install", "--staging", s.staging, "--config-dir", s.configDir, ...selector]);
|
|
assert.equal(r.status, 0, `${selector.join(" ")}: ${r.all}`);
|
|
assert.deepEqual(readdirSync(join(s.configDir, "personas")), ["case2"], selector.join(" "));
|
|
assert.deepEqual(readdirSync(join(s.configDir, "personas", "case2")).sort(), ["bean.md", "chet.md"], selector.join(" "));
|
|
}
|
|
});
|
|
|
|
test("a bad selector refuses before the staging directory or the Hermes home is read", (t) => {
|
|
const s = scratch(t);
|
|
assert.equal(stage(s).status, 0);
|
|
for (const [selector, status, err] of [
|
|
[["--business", "case2", "--org", "other"], 2, "case2-persona-import: --business case2 and --org other conflict\n"],
|
|
[["--org", "case2", "--business", "other"], 2, "case2-persona-import: --org case2 and --business other conflict\n"],
|
|
[["--business", "case2", "--business", "case2"], 2, "case2-persona-import: --business given twice\n"],
|
|
[["--org", "case2", "--org", "case2"], 2, "case2-persona-import: --org given twice\n"],
|
|
[["--org"], 2, "case2-persona-import: --org needs one value\n"],
|
|
[["--business"], 2, "case2-persona-import: --business needs one value\n"],
|
|
[["--org", ""], 2, "case2-persona-import: --org needs one value\n"],
|
|
[["--org", "--dry-run"], 2, "case2-persona-import: --org needs one value, not --dry-run\n"],
|
|
[["--org", "-x"], 1, "case2-persona-import: refused: business -x isn't a business id\n"],
|
|
[["--org", "Case2"], 1, "case2-persona-import: refused: business Case2 isn't a business id\n"],
|
|
[["--business", "../x"], 1, "case2-persona-import: refused: business ../x isn't a business id\n"],
|
|
[["--org", "a.b"], 1, "case2-persona-import: refused: business a.b isn't a business id\n"],
|
|
]) {
|
|
for (const verb of [["install", "--staging", s.staging, "--config-dir", s.configDir], ["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]]) {
|
|
rmSync(s.trace, { force: true });
|
|
const r = run([...verb, ...selector], { trace: s.trace });
|
|
const label = `${verb[0]} ${selector.join(" ")}`;
|
|
assert.equal(r.status, status, `${label}: ${r.all}`);
|
|
assert.equal(r.err, err, label);
|
|
assert.equal(r.out, "", label);
|
|
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1));
|
|
assert.deepEqual(opened.filter((p) => p.startsWith(s.root)), [], `${label} read nothing of the scratch tree`);
|
|
}
|
|
}
|
|
assert.equal(existsSync(join(s.configDir, "personas")), false);
|
|
// The control: the same trace sees install read the staging directory.
|
|
rmSync(s.trace, { force: true });
|
|
assert.equal(install(s, "case2", { trace: s.trace }).status, 0);
|
|
assert.ok(readFileSync(s.trace, "utf8").includes(join(s.staging, "manifest.json")));
|
|
});
|
|
|
|
test("a selector spelling is never another option's value, and stage still ignores a valid selector", (t) => {
|
|
const s = scratch(t);
|
|
// This grammar refuses any value starting "--", so a selector spelling can't be one.
|
|
const r = run(["install", "--staging", "--org", "case2", "--config-dir", s.configDir]);
|
|
assert.equal(r.status, 2, r.all);
|
|
assert.equal(r.err, "case2-persona-import: --staging needs a value\n");
|
|
const st = stage(s, ["--org", "case2"]);
|
|
assert.equal(st.status, 0, st.all);
|
|
assert.deepEqual(readdirSync(s.staging).sort(), ["bean.md", "chet.md", "manifest.json"]);
|
|
});
|