Skip to content

Commit bcd5df7

Browse files
committed
feat(workflows): resolve workflow tool packages from declared npm dependencies
1 parent 081c161 commit bcd5df7

3 files changed

Lines changed: 321 additions & 1 deletion

File tree

‎docs/VENDORING.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ fully covered by vendored workflow-definition-loader.ts + shared
140140
isAnnotatedDirectorFactory predicate from already-vendored @intx/agent.
141141
No first-party consumer imports tool-packaging; stays on transitive
142142
published 0.3.0. Revisit if a future ticket establishes tool packages
143-
from closures at runtime.
143+
from closures at runtime. CL-4473 first slice: `src/workflows/tool-packages.ts`
144+
resolves a workflow package's declared tool-package npm deps from its laid-out
145+
`node_modules/` tree; full closure materialization stays open.
144146

145147
The license column records what each package declares in its own
146148
`package.json`; the corresponding `LICENSE` file travels with every vendored
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2+
import {
3+
mkdir,
4+
mkdtemp,
5+
realpath,
6+
rm,
7+
symlink,
8+
writeFile,
9+
} from "node:fs/promises";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
12+
13+
import { resolveWorkflowToolPackages } from "./tool-packages.js";
14+
import { defined } from "../../testkit/defined.js";
15+
16+
const TOOL_NAME = "@corbits/tool-gh";
17+
18+
async function writeJSON(path: string, value: unknown): Promise<void> {
19+
await writeFile(path, JSON.stringify(value));
20+
}
21+
22+
// A workflow package that declares `toolDeps` as npm dependencies.
23+
async function makeWorkflow(
24+
root: string,
25+
toolDeps: Record<string, string>,
26+
): Promise<string> {
27+
const dir = join(root, "workflow");
28+
await mkdir(dir, { recursive: true });
29+
await writeJSON(join(dir, "package.json"), {
30+
name: "@corbits/workflow-pr-review",
31+
version: "0.1.0",
32+
dependencies: toolDeps,
33+
});
34+
return dir;
35+
}
36+
37+
// A laid-out tool package inside the workflow's `node_modules/`.
38+
async function layOutTool(
39+
workflowDir: string,
40+
manifest: Record<string, unknown>,
41+
): Promise<string> {
42+
const dir = join(workflowDir, "node_modules", TOOL_NAME);
43+
await mkdir(dir, { recursive: true });
44+
await writeJSON(join(dir, "package.json"), manifest);
45+
const entry = join(dir, "dist", "tools.js");
46+
await mkdir(join(dir, "dist"), { recursive: true });
47+
await writeFile(entry, "export const tools = [];\n");
48+
return dir;
49+
}
50+
51+
describe("resolveWorkflowToolPackages", () => {
52+
let root: string;
53+
54+
beforeEach(async () => {
55+
root = await mkdtemp(join(tmpdir(), "wf-tool-pkgs-"));
56+
});
57+
58+
afterEach(async () => {
59+
await rm(root, { recursive: true, force: true });
60+
});
61+
62+
test("resolves a declared tool package to its interchange.tools entry", async () => {
63+
const workflowDir = await makeWorkflow(root, {
64+
[TOOL_NAME]: "workspace:*",
65+
});
66+
const toolDir = await layOutTool(workflowDir, {
67+
name: TOOL_NAME,
68+
version: "1.2.3",
69+
interchange: { tools: "./dist/tools.js" },
70+
});
71+
const resolved = defined(
72+
(await resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]))[0],
73+
"resolved tool package",
74+
);
75+
expect(resolved.name).toBe(TOOL_NAME);
76+
expect(resolved.version).toBe("1.2.3");
77+
expect(resolved.dir).toBe(await realpath(toolDir));
78+
expect(resolved.toolsEntry).toBe(
79+
join(await realpath(toolDir), "dist", "tools.js"),
80+
);
81+
});
82+
83+
test("resolves through a symlinked node_modules entry like a workspace layout", async () => {
84+
const workflowDir = await makeWorkflow(root, {
85+
[TOOL_NAME]: "workspace:*",
86+
});
87+
const target = join(root, "real-tool-pkg");
88+
await mkdir(join(target, "dist"), { recursive: true });
89+
await writeJSON(join(target, "package.json"), {
90+
name: TOOL_NAME,
91+
version: "2.0.0",
92+
interchange: { tools: "./dist/tools.js" },
93+
});
94+
await writeFile(
95+
join(target, "dist", "tools.js"),
96+
"export const tools = [];\n",
97+
);
98+
await mkdir(join(workflowDir, "node_modules", "@corbits"), {
99+
recursive: true,
100+
});
101+
await symlink(target, join(workflowDir, "node_modules", TOOL_NAME));
102+
const resolved = defined(
103+
(await resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]))[0],
104+
"resolved tool package",
105+
);
106+
expect(resolved.dir).toBe(await realpath(target));
107+
expect(resolved.toolsEntry).toBe(
108+
join(await realpath(target), "dist", "tools.js"),
109+
);
110+
});
111+
112+
test("dedupes a tool package requested twice", async () => {
113+
const workflowDir = await makeWorkflow(root, {
114+
[TOOL_NAME]: "workspace:*",
115+
});
116+
await layOutTool(workflowDir, {
117+
name: TOOL_NAME,
118+
version: "1.2.3",
119+
interchange: { tools: "./dist/tools.js" },
120+
});
121+
const resolved = await resolveWorkflowToolPackages(workflowDir, [
122+
TOOL_NAME,
123+
TOOL_NAME,
124+
]);
125+
expect(resolved).toHaveLength(1);
126+
});
127+
128+
test("rejects a package present on disk but missing from dependencies", async () => {
129+
const workflowDir = await makeWorkflow(root, {});
130+
await layOutTool(workflowDir, {
131+
name: TOOL_NAME,
132+
version: "1.2.3",
133+
interchange: { tools: "./dist/tools.js" },
134+
});
135+
await expect(
136+
resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]),
137+
).rejects.toThrow(/not a dependency.*declare it as an npm dep/);
138+
});
139+
140+
test("rejects a declared dependency that is not installed", async () => {
141+
const workflowDir = await makeWorkflow(root, {
142+
[TOOL_NAME]: "workspace:*",
143+
});
144+
await expect(
145+
resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]),
146+
).rejects.toThrow(/could not be resolved.*direct dependency/);
147+
});
148+
149+
test("rejects an installed dependency without an interchange.tools entry", async () => {
150+
const workflowDir = await makeWorkflow(root, {
151+
[TOOL_NAME]: "workspace:*",
152+
});
153+
await layOutTool(workflowDir, { name: TOOL_NAME, version: "1.2.3" });
154+
await expect(
155+
resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]),
156+
).rejects.toThrow(/declares no "interchange\.tools" entry/);
157+
});
158+
159+
test("rejects an interchange.tools entry that escapes its directory", async () => {
160+
const workflowDir = await makeWorkflow(root, {
161+
[TOOL_NAME]: "workspace:*",
162+
});
163+
await layOutTool(workflowDir, {
164+
name: TOOL_NAME,
165+
version: "1.2.3",
166+
interchange: { tools: "../evil.js" },
167+
});
168+
await expect(
169+
resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]),
170+
).rejects.toThrow(/escapes its directory/);
171+
});
172+
173+
test("rejects a resolved directory whose package.json names another package", async () => {
174+
const workflowDir = await makeWorkflow(root, {
175+
[TOOL_NAME]: "workspace:*",
176+
});
177+
await layOutTool(workflowDir, {
178+
name: "@corbits/tool-other",
179+
version: "1.2.3",
180+
interchange: { tools: "./dist/tools.js" },
181+
});
182+
await expect(
183+
resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]),
184+
).rejects.toThrow(/names itself/);
185+
});
186+
187+
test("rejects a workflow package.json missing name and version", async () => {
188+
const workflowDir = join(root, "workflow");
189+
await mkdir(workflowDir, { recursive: true });
190+
await writeJSON(join(workflowDir, "package.json"), { private: true });
191+
await expect(
192+
resolveWorkflowToolPackages(workflowDir, [TOOL_NAME]),
193+
).rejects.toThrow(/invalid package\.json/);
194+
});
195+
});

‎src/workflows/tool-packages.ts‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { readFile, realpath } from "node:fs/promises";
2+
import { join } from "node:path";
3+
import { ArkErrors, type } from "arktype";
4+
5+
import { isContainedEntryPath, PackageJSON } from "@intx/types/package-json";
6+
7+
// A workflow package declares the tool packages its steps need as npm
8+
// dependencies (CL-4473). Resolution reads the laid-out `node_modules/` tree,
9+
// the same contract the vendored workflow-definition-loader enforces for
10+
// plugin packages: the dep must be declared, resolvable, and carry an
11+
// `interchange.tools` entry contained in its own directory.
12+
const WorkflowPackageJSON = type({
13+
name: "string",
14+
version: "string",
15+
"dependencies?": "Record<string, string>",
16+
"peerDependencies?": "Record<string, string>",
17+
}).onUndeclaredKey("ignore");
18+
19+
export interface WorkflowToolPackage {
20+
name: string;
21+
version: string;
22+
// Realpath of the resolved tool package directory.
23+
dir: string;
24+
// Absolute path of the package's `interchange.tools` entry module.
25+
toolsEntry: string;
26+
}
27+
28+
async function readJSON(dir: string, label: string): Promise<unknown> {
29+
const path = join(dir, "package.json");
30+
let raw: string;
31+
try {
32+
raw = await readFile(path, "utf8");
33+
} catch (cause) {
34+
throw new Error(`cannot read ${label} package.json at ${path}`, {
35+
cause,
36+
});
37+
}
38+
try {
39+
return JSON.parse(raw) as unknown;
40+
} catch (cause) {
41+
throw new Error(`cannot parse ${label} package.json at ${path}`, {
42+
cause,
43+
});
44+
}
45+
}
46+
47+
// Resolve one declared tool package from the workflow package's laid-out
48+
// `node_modules/`. Realpath first so workspace symlinks and the closure
49+
// materializer's laid-out tree compare realpath-vs-realpath downstream.
50+
async function resolveOne(
51+
workflowDir: string,
52+
declared: ReadonlySet<string>,
53+
name: string,
54+
): Promise<WorkflowToolPackage> {
55+
if (!declared.has(name)) {
56+
throw new Error(
57+
`tool package ${JSON.stringify(name)} is not a dependency of the workflow package at ${workflowDir}; declare it as an npm dep`,
58+
);
59+
}
60+
const linkedDir = join(workflowDir, "node_modules", name);
61+
let dir: string;
62+
try {
63+
dir = await realpath(linkedDir);
64+
} catch (cause) {
65+
throw new Error(
66+
`tool package ${JSON.stringify(name)} could not be resolved from the workflow package at ${workflowDir}; it must be installed as a direct dependency`,
67+
{ cause },
68+
);
69+
}
70+
const parsed = PackageJSON(await readJSON(dir, `tool package ${name}`));
71+
if (parsed instanceof ArkErrors) {
72+
throw new Error(
73+
`tool package ${JSON.stringify(name)} at ${dir} has an invalid package.json: ${String(parsed)}`,
74+
);
75+
}
76+
if (parsed.name !== name) {
77+
throw new Error(
78+
`tool package at ${dir} names itself ${JSON.stringify(parsed.name)} but was resolved as ${JSON.stringify(name)}`,
79+
);
80+
}
81+
const entryRel = parsed.interchange?.tools;
82+
if (entryRel === undefined) {
83+
throw new Error(
84+
`tool package ${JSON.stringify(name)} at ${dir} declares no "interchange.tools" entry; it is not a tool package`,
85+
);
86+
}
87+
if (!isContainedEntryPath(entryRel)) {
88+
throw new Error(
89+
`tool package ${JSON.stringify(name)} at ${dir} declares an "interchange.tools" entry ${JSON.stringify(entryRel)} that escapes its directory`,
90+
);
91+
}
92+
return {
93+
name,
94+
version: parsed.version,
95+
dir,
96+
toolsEntry: join(dir, entryRel),
97+
};
98+
}
99+
100+
// Resolve a workflow package's declared tool-package dependencies to their
101+
// `interchange.tools` entries. Names must appear in the workflow package's
102+
// `dependencies` (or `peerDependencies`); anything else is rejected even when
103+
// present on disk, so the declaration — not the layout — is authoritative.
104+
export async function resolveWorkflowToolPackages(
105+
workflowDir: string,
106+
toolPackageNames: readonly string[],
107+
): Promise<WorkflowToolPackage[]> {
108+
const raw = await readJSON(workflowDir, "workflow package");
109+
const parsed = WorkflowPackageJSON(raw);
110+
if (parsed instanceof ArkErrors) {
111+
throw new Error(
112+
`workflow package at ${workflowDir} has an invalid package.json: ${String(parsed)}`,
113+
);
114+
}
115+
const declared = new Set([
116+
...Object.keys(parsed.dependencies ?? {}),
117+
...Object.keys(parsed.peerDependencies ?? {}),
118+
]);
119+
const unique = [...new Set(toolPackageNames)];
120+
return Promise.all(
121+
unique.map((name) => resolveOne(workflowDir, declared, name)),
122+
);
123+
}

0 commit comments

Comments
 (0)