// 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 /); assertNoText(s, r.all); }); // gateway/config.py at hermes-agent df5700ebe: a platform's block is at the top level // (:1159), else platforms. or gateway.platforms. (: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\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 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"]); });