Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fbe2b60
fix(fs): fail when a component directory is a file
steve-calvert-glean Oct 21, 2026
886db5c
fix(config): keep source.partials inside the project
steve-calvert-glean Oct 21, 2026
598f211
fix(managed): compare physical paths and check deletions before writing
steve-calvert-glean Oct 21, 2026
e2154f3
fix(config): reject URL-shaped traversal in config path fields
steve-calvert-glean Oct 21, 2026
bf0913e
fix(config): load the config file without jiti's module cache
steve-calvert-glean Oct 21, 2026
d616084
fix(fs): probe case sensitivity on the destination volume, once per b…
steve-calvert-glean Oct 21, 2026
1fd883d
fix(build): resolve final symlinks when checking write collisions
steve-calvert-glean Oct 21, 2026
b46def6
fix(managed): check deletion containment before any write or delete
steve-calvert-glean Oct 21, 2026
5f864e3
fix(build): keep handed-over paths owned and warn before overwriting …
steve-calvert-glean Oct 21, 2026
c355903
fix(managed): protect discovered legacy sources unless pluginpack wro…
steve-calvert-glean Oct 21, 2026
1d70dbc
fix(build): refuse to leave a dropped target's output behind
steve-calvert-glean Oct 21, 2026
f2a2cd9
fix(managed): keep a path renamed only by case when pruning
steve-calvert-glean Oct 21, 2026
b88e62a
fix(managed): let a 0.11 codex package at outDir "." prune itself aft…
steve-calvert-glean Oct 21, 2026
a3387c2
test(lifecycle): walk the current authoring shape through a full life…
steve-calvert-glean Oct 21, 2026
ff65a70
test(golden): pin every target's emitted output byte for byte
steve-calvert-glean Oct 21, 2026
3d3ac5a
test(pack): walk the lifecycle with the installed tarball
steve-calvert-glean Oct 21, 2026
cd39b78
test(readme): run the README shell and config examples
steve-calvert-glean Oct 21, 2026
637f86f
test(lifecycle): assert the outDir handoff refusal and the config reload
steve-calvert-glean Oct 21, 2026
29d520c
test(lifecycle): decode artifact bytes from any Uint8Array
steve-calvert-glean Oct 21, 2026
2bf8f47
test(pack): check every target init scaffolds
steve-calvert-glean Oct 21, 2026
a825807
test(pack): drop the unused initial value
steve-calvert-glean Oct 21, 2026
beadea6
test(lifecycle): give the six-target journeys a CI-sized timeout
steve-calvert-glean Oct 21, 2026
bf2348c
test(lifecycle): set one CI-sized timeout for the journey file
steve-calvert-glean Oct 21, 2026
d3cc199
Merge remote-tracking branch 'origin/main' into test/lifecycle-journeys
steve-calvert-glean Oct 22, 2026
2b8cb10
test(readme): classify the corrected install command blocks
steve-calvert-glean Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ package-lock.json
tests/fixtures/cursor/*.schema.json
tests/fixtures/agent-plugins/*.schema.json
CHANGELOG.md
# Golden output is pinned byte for byte as pluginpack emits it.
tests/golden/
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"test": "vitest run",
"test:all": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build && npm run docs",
"test:coverage": "vitest run --coverage",
"test:golden:update": "env UPDATE_GOLDEN=1 vitest run tests/golden.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
153 changes: 139 additions & 14 deletions scripts/verify-packed-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,65 @@
// CI check: every existing test runs against the in-repo `dist/cli.js` or
// source, never a truly installed package. This packs the real tarball,
// installs it into a scratch project the way a real consumer would, and
// exercises the installed binary — catching a broken `bin` shebang or a
// `files`/tsup misconfig that `npm pack --dry-run` alone would miss.
// walks the documented lifecycle with the installed binary: init, add a
// skill, build, validate, delete the skill, build, validate, clean. That
// catches a broken `bin` shebang or a `files`/tsup misconfig that
// `npm pack --dry-run` alone would miss, and a lifecycle step that breaks
// for a real install (such as a rebuild that cannot remove a deleted skill).
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

const repoRoot = process.cwd();
const scratch = mkdtempSync(path.join(tmpdir(), "pluginpack-pack-smoke-"));

/**
* The targets `pluginpack init` configures, each writing to `dist/<target>`.
* Read from the first build's output rather than listed here, so the script
* keeps checking every scaffolded target when init gains one.
*/
let initTargets;

/** Every file under `dir`, as posix paths relative to it, sorted. */
function listFiles(dir) {
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) =>
path
.relative(dir, path.join(entry.parentPath, entry.name))
.split(path.sep)
.join("/"),
)
.sort();
}

/** Every file under `dir` with its contents, skipping `skip` (relative to dir). */
function snapshot(dir, skip) {
return Object.fromEntries(
listFiles(dir)
.filter((file) => !skip(file))
.map((file) => [file, readFileSync(path.join(dir, file), "utf8")]),
);
}

function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}

try {
const packRaw = execFileSync(
"npm",
Expand All @@ -25,7 +74,11 @@ try {
mkdirSync(installDir);
writeFileSync(
path.join(installDir, "package.json"),
JSON.stringify({ name: "pack-smoke-test", version: "0.0.0", private: true }),
JSON.stringify({
name: "pack-smoke-test",
version: "0.0.0",
private: true,
}),
);
execFileSync("npm", ["install", tarballPath], {
cwd: installDir,
Expand All @@ -34,20 +87,92 @@ try {

const bin = path.join(installDir, "node_modules", ".bin", "pluginpack");
const version = execFileSync(bin, ["--version"], { encoding: "utf8" }).trim();
if (!version) {
throw new Error("Installed CLI printed no version.");
}
check(version, "Installed CLI printed no version.");
console.log(`Installed CLI reports version: ${version}`);

const initDir = path.join(installDir, "project");
mkdirSync(initDir);
execFileSync(bin, ["init"], { cwd: initDir, stdio: "inherit" });
if (!existsSync(path.join(initDir, "pluginpack.config.ts"))) {
throw new Error(
"pluginpack init did not create pluginpack.config.ts from the installed package.",
const project = path.join(installDir, "project");
mkdirSync(project);
const pluginpack = (...args) => {
console.log(`$ pluginpack ${args.join(" ")}`);
execFileSync(bin, args, { cwd: project, stdio: "inherit" });
};
const isOutput = (file) => file.startsWith("dist/");
const outputs = () =>
listFiles(path.join(project, "dist")).map((file) => `dist/${file}`);
const skillOutputs = (skill) =>
outputs().filter((file) => file.includes(`/skills/${skill}/`));

// --- init --------------------------------------------------------------
pluginpack("init");
check(
existsSync(path.join(project, "pluginpack.config.ts")),
"pluginpack init did not create pluginpack.config.ts from the installed package.",
);

// --- add a skill, build, validate ---------------------------------------
const extraSkill = path.join(project, "shared/example/skills/extra");
mkdirSync(extraSkill, { recursive: true });
writeFileSync(
path.join(extraSkill, "SKILL.md"),
"---\nname: extra\ndescription: A second skill.\n---\n\nExtra.\n",
);
const sources = snapshot(project, isOutput);
pluginpack("build");
initTargets = readdirSync(path.join(project, "dist")).sort();
check(
initTargets.length >= 4,
`Expected init to scaffold at least four targets, got: ${initTargets.join(", ")}`,
);
for (const target of initTargets) {
pluginpack("validate", "--target", target);
check(
existsSync(
path.join(project, "dist", target, ".pluginpack", `${target}.json`),
),
`build wrote no managed manifest for ${target}.`,
);
}
console.log("Installed CLI's `init` command works.");
check(
skillOutputs("extra").length === initTargets.length,
`Expected the extra skill in every target, got: ${skillOutputs("extra").join(", ")}`,
);
// A hand-written file in the output, which no command may delete.
const notes = path.join(project, "dist/cursor/NOTES.md");
writeFileSync(notes, "Hand-written.\n");

// --- delete the skill, build, validate ----------------------------------
rmSync(extraSkill, { recursive: true });
delete sources["shared/example/skills/extra/SKILL.md"];
pluginpack("build");
for (const target of initTargets) {
pluginpack("validate", "--target", target);
}
check(
skillOutputs("extra").length === 0,
`Rebuild left the deleted skill behind: ${skillOutputs("extra").join(", ")}`,
);
check(
skillOutputs("example").length === initTargets.length,
`Rebuild lost the remaining skill: ${skillOutputs("example").join(", ")}`,
);

// --- clean --------------------------------------------------------------
pluginpack("clean");
check(
JSON.stringify(outputs()) === JSON.stringify(["dist/cursor/NOTES.md"]),
`clean should leave only the hand-written file, left: ${outputs().join(", ")}`,
);
check(
readFileSync(notes, "utf8") === "Hand-written.\n",
"clean changed a hand-written file.",
);
check(
JSON.stringify(snapshot(project, isOutput)) === JSON.stringify(sources),
"The lifecycle changed an authored source file.",
);
console.log(
"Installed CLI walks init -> build -> validate -> delete a skill -> build -> clean.",
);
} finally {
rmSync(scratch, { recursive: true, force: true });
}
Loading
Loading