create-checkstack-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -0
- package/src/cli-args.test.ts +72 -0
- package/src/cli-args.ts +130 -0
- package/src/cli.ts +177 -0
- package/src/external-plugin-lifecycle.it.test.ts +478 -0
- package/src/git.ts +39 -0
- package/src/local-registry.test.ts +143 -0
- package/src/local-registry.ts +313 -0
- package/src/npm-view-resolver.test.ts +140 -0
- package/src/npm-view-resolver.ts +140 -0
- package/src/scaffold-standalone.test.ts +211 -0
- package/src/scaffold-standalone.ts +104 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/** The package.json fields this harness reads. Unknown keys are ignored. */
|
|
7
|
+
const packageJsonSchema = z.object({
|
|
8
|
+
name: z.string().optional(),
|
|
9
|
+
version: z.string().optional(),
|
|
10
|
+
private: z.boolean().optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** Read + zod-parse a package.json, returning the fields we care about. */
|
|
14
|
+
function readPackageJson(pkgPath: string): z.infer<typeof packageJsonSchema> {
|
|
15
|
+
return packageJsonSchema.parse(
|
|
16
|
+
JSON.parse(fs.readFileSync(pkgPath, "utf8")) as unknown,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Local-registry publish harness for the external-plugin lifecycle
|
|
22
|
+
* integration test (#251 Phase 3).
|
|
23
|
+
*
|
|
24
|
+
* The published-tarball path is what the integration test guards, so it
|
|
25
|
+
* needs a real registry serving the *current* working tree's
|
|
26
|
+
* `@checkstack/*` packages — not whatever is live on npm. We spin up a
|
|
27
|
+
* throwaway [Verdaccio](https://verdaccio.org/) on `localhost`, publish
|
|
28
|
+
* every non-private workspace package into it, and point the scaffolder +
|
|
29
|
+
* `bun install` at it.
|
|
30
|
+
*
|
|
31
|
+
* Publish-protocol caveat (plan §7.2 step 1.3): `scripts/publish-packages.ts`
|
|
32
|
+
* publishes to the REAL npm registry via `bun publish` and skips
|
|
33
|
+
* already-published versions, so it is not directly reusable here. We use
|
|
34
|
+
* the plan's preferred default (a): `bun publish --registry <localUrl>` per
|
|
35
|
+
* package. `bun publish` (unlike `npm publish`) resolves `workspace:*` to
|
|
36
|
+
* concrete versions at pack time — the same reason `publish-packages.ts`
|
|
37
|
+
* uses it — so the published tarballs install cleanly outside the monorepo.
|
|
38
|
+
*
|
|
39
|
+
* Everything network/process-touching is injectable so the pure builders
|
|
40
|
+
* (config, `.npmrc`, arg vectors) stay unit-testable without spawning
|
|
41
|
+
* anything.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Default host:port the throwaway Verdaccio listens on. */
|
|
45
|
+
export const DEFAULT_REGISTRY_PORT = 4873;
|
|
46
|
+
export const DEFAULT_REGISTRY_URL = `http://localhost:${DEFAULT_REGISTRY_PORT}`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build a Verdaccio `config.yaml` that allows anonymous publish for the
|
|
50
|
+
* scope(s) the test publishes, with auth disabled (a throwaway token still
|
|
51
|
+
* satisfies the publish protocol — see {@link buildNpmrc}). Uglify/web UI
|
|
52
|
+
* are off to keep the boot fast.
|
|
53
|
+
*/
|
|
54
|
+
export function buildVerdaccioConfig({
|
|
55
|
+
storageDir,
|
|
56
|
+
port = DEFAULT_REGISTRY_PORT,
|
|
57
|
+
}: {
|
|
58
|
+
storageDir: string;
|
|
59
|
+
port?: number;
|
|
60
|
+
}): string {
|
|
61
|
+
// Anonymous publish is intentional: this registry is ephemeral, bound to
|
|
62
|
+
// localhost, and never persists past the test. `$all` / `$anonymous`
|
|
63
|
+
// groups let the publish step skip a real login flow.
|
|
64
|
+
return [
|
|
65
|
+
`storage: ${JSON.stringify(storageDir)}`,
|
|
66
|
+
"uplinks:",
|
|
67
|
+
" npmjs:",
|
|
68
|
+
" url: https://registry.npmjs.org/",
|
|
69
|
+
"packages:",
|
|
70
|
+
" '@checkstack/*':",
|
|
71
|
+
" access: $all",
|
|
72
|
+
" publish: $anonymous",
|
|
73
|
+
" unpublish: $anonymous",
|
|
74
|
+
" 'create-checkstack-plugin':",
|
|
75
|
+
" access: $all",
|
|
76
|
+
" publish: $anonymous",
|
|
77
|
+
" unpublish: $anonymous",
|
|
78
|
+
// Everything else proxies to the public registry so transitive
|
|
79
|
+
// third-party deps (drizzle-orm, @orpc/*, vite, ...) still resolve.
|
|
80
|
+
" '**':",
|
|
81
|
+
" access: $all",
|
|
82
|
+
" publish: $anonymous",
|
|
83
|
+
" proxy: npmjs",
|
|
84
|
+
"log: { type: stdout, format: pretty, level: warn }",
|
|
85
|
+
`listen: 0.0.0.0:${port}`,
|
|
86
|
+
"",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a throwaway `.npmrc` that (a) pins the `@checkstack` scope +
|
|
92
|
+
* `create-checkstack-plugin` fetches to the local registry and (b) carries
|
|
93
|
+
* a fake `_authToken` so the publish protocol is satisfied even though the
|
|
94
|
+
* registry accepts anonymous writes.
|
|
95
|
+
*/
|
|
96
|
+
export function buildNpmrc({
|
|
97
|
+
registryUrl = DEFAULT_REGISTRY_URL,
|
|
98
|
+
}: {
|
|
99
|
+
registryUrl?: string;
|
|
100
|
+
} = {}): string {
|
|
101
|
+
const hostPath = registryUrl.replace(/^https?:/, "");
|
|
102
|
+
return [
|
|
103
|
+
`registry=${registryUrl}`,
|
|
104
|
+
`@checkstack:registry=${registryUrl}`,
|
|
105
|
+
`${hostPath}/:_authToken=fake-checkstack-it-token`,
|
|
106
|
+
"always-auth=false",
|
|
107
|
+
"",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Build the `bun publish` argv for one package dir against the local registry. */
|
|
112
|
+
export function buildBunPublishArgs({
|
|
113
|
+
registryUrl = DEFAULT_REGISTRY_URL,
|
|
114
|
+
}: {
|
|
115
|
+
registryUrl?: string;
|
|
116
|
+
} = {}): string[] {
|
|
117
|
+
// `--access public` mirrors publish-packages.ts; `--no-git-checks` is not
|
|
118
|
+
// a bun flag, so we simply publish from the package dir. bun resolves
|
|
119
|
+
// workspace:* to concrete versions automatically.
|
|
120
|
+
return ["publish", "--registry", registryUrl, "--access", "public"];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build the `npm view <pkg> version` argv against the local registry. */
|
|
124
|
+
export function buildNpmViewVersionArgs({
|
|
125
|
+
packageName,
|
|
126
|
+
registryUrl = DEFAULT_REGISTRY_URL,
|
|
127
|
+
}: {
|
|
128
|
+
packageName: string;
|
|
129
|
+
registryUrl?: string;
|
|
130
|
+
}): string[] {
|
|
131
|
+
return ["view", `${packageName}`, "version", "--registry", registryUrl];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A workspace package the harness publishes. */
|
|
135
|
+
export interface WorkspacePackage {
|
|
136
|
+
name: string;
|
|
137
|
+
version: string;
|
|
138
|
+
dir: string;
|
|
139
|
+
private: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Discover every publishable workspace package (`core/*`, `plugins/*`),
|
|
144
|
+
* skipping `private: true` and `_`-prefixed scaffold/test dirs. We publish
|
|
145
|
+
* the FULL set rather than a hand-maintained ~11 because the scaffolded
|
|
146
|
+
* plugin depends on `@checkstack/backend`, whose transitive closure spans
|
|
147
|
+
* most of the platform — a partial list would break `bun install`.
|
|
148
|
+
*/
|
|
149
|
+
export function discoverWorkspacePackages({
|
|
150
|
+
monorepoRoot,
|
|
151
|
+
}: {
|
|
152
|
+
monorepoRoot: string;
|
|
153
|
+
}): WorkspacePackage[] {
|
|
154
|
+
const packages: WorkspacePackage[] = [];
|
|
155
|
+
for (const wsDir of ["core", "plugins"]) {
|
|
156
|
+
const wsPath = path.join(monorepoRoot, wsDir);
|
|
157
|
+
if (!fs.existsSync(wsPath)) continue;
|
|
158
|
+
for (const entry of fs.readdirSync(wsPath, { withFileTypes: true })) {
|
|
159
|
+
if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
|
|
160
|
+
const pkgPath = path.join(wsPath, entry.name, "package.json");
|
|
161
|
+
if (!fs.existsSync(pkgPath)) continue;
|
|
162
|
+
const pkg = readPackageJson(pkgPath);
|
|
163
|
+
if (!pkg.name || !pkg.version) continue;
|
|
164
|
+
packages.push({
|
|
165
|
+
name: pkg.name,
|
|
166
|
+
version: pkg.version,
|
|
167
|
+
dir: path.join(wsPath, entry.name),
|
|
168
|
+
private: pkg.private === true,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return packages;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Result of one package publish attempt. */
|
|
176
|
+
export interface PublishOutcome {
|
|
177
|
+
name: string;
|
|
178
|
+
version: string;
|
|
179
|
+
status: number;
|
|
180
|
+
stderr: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Injectable subprocess runner so the publish loop is testable. */
|
|
184
|
+
export type CommandRunner = (args: {
|
|
185
|
+
command: string;
|
|
186
|
+
args: string[];
|
|
187
|
+
cwd: string;
|
|
188
|
+
env: NodeJS.ProcessEnv;
|
|
189
|
+
}) => { status: number; stderr: string };
|
|
190
|
+
|
|
191
|
+
const defaultRunner: CommandRunner = ({ command, args, cwd, env }) => {
|
|
192
|
+
const result = spawnSync(command, args, { cwd, env, encoding: "utf8" });
|
|
193
|
+
return {
|
|
194
|
+
status: result.status ?? 1,
|
|
195
|
+
stderr: `${result.stderr ?? ""}${result.error ? result.error.message : ""}`,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Publish every non-private discovered package to the local registry via
|
|
201
|
+
* `bun publish --registry <url>`. Returns one outcome per attempted
|
|
202
|
+
* package; the caller asserts each `status === 0`.
|
|
203
|
+
*/
|
|
204
|
+
export function publishWorkspacePackages({
|
|
205
|
+
packages,
|
|
206
|
+
registryUrl = DEFAULT_REGISTRY_URL,
|
|
207
|
+
npmrcPath,
|
|
208
|
+
run = defaultRunner,
|
|
209
|
+
env = process.env,
|
|
210
|
+
}: {
|
|
211
|
+
packages: WorkspacePackage[];
|
|
212
|
+
registryUrl?: string;
|
|
213
|
+
npmrcPath: string;
|
|
214
|
+
run?: CommandRunner;
|
|
215
|
+
env?: NodeJS.ProcessEnv;
|
|
216
|
+
}): PublishOutcome[] {
|
|
217
|
+
const publishArgs = buildBunPublishArgs({ registryUrl });
|
|
218
|
+
// Route bun/npm auth + registry through the throwaway .npmrc.
|
|
219
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
220
|
+
...env,
|
|
221
|
+
NPM_CONFIG_USERCONFIG: npmrcPath,
|
|
222
|
+
BUN_CONFIG_REGISTRY: registryUrl,
|
|
223
|
+
};
|
|
224
|
+
const outcomes: PublishOutcome[] = [];
|
|
225
|
+
for (const pkg of packages) {
|
|
226
|
+
if (pkg.private) continue;
|
|
227
|
+
const { status, stderr } = run({
|
|
228
|
+
command: "bun",
|
|
229
|
+
args: publishArgs,
|
|
230
|
+
cwd: pkg.dir,
|
|
231
|
+
env: childEnv,
|
|
232
|
+
});
|
|
233
|
+
outcomes.push({ name: pkg.name, version: pkg.version, status, stderr });
|
|
234
|
+
}
|
|
235
|
+
return outcomes;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A live Verdaccio process handle plus its base URL. */
|
|
239
|
+
export interface RegistryHandle {
|
|
240
|
+
url: string;
|
|
241
|
+
child: ChildProcess;
|
|
242
|
+
stop: () => Promise<void>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Spawn Verdaccio via `npx verdaccio --config <file>` and wait until the
|
|
247
|
+
* registry answers HTTP. Used by the integration test only.
|
|
248
|
+
*/
|
|
249
|
+
export async function startVerdaccio({
|
|
250
|
+
configPath,
|
|
251
|
+
url = DEFAULT_REGISTRY_URL,
|
|
252
|
+
readyTimeoutMs = 60_000,
|
|
253
|
+
}: {
|
|
254
|
+
configPath: string;
|
|
255
|
+
url?: string;
|
|
256
|
+
readyTimeoutMs?: number;
|
|
257
|
+
}): Promise<RegistryHandle> {
|
|
258
|
+
const child = spawn("npx", ["--yes", "verdaccio", "--config", configPath], {
|
|
259
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const stop = async (): Promise<void> => {
|
|
263
|
+
if (child.exitCode === null && !child.killed) {
|
|
264
|
+
child.kill("SIGTERM");
|
|
265
|
+
await new Promise<void>((resolve) => {
|
|
266
|
+
const t = setTimeout(() => {
|
|
267
|
+
child.kill("SIGKILL");
|
|
268
|
+
resolve();
|
|
269
|
+
}, 3000);
|
|
270
|
+
child.on("exit", () => {
|
|
271
|
+
clearTimeout(t);
|
|
272
|
+
resolve();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
279
|
+
while (Date.now() < deadline) {
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch(`${url}/-/ping`);
|
|
282
|
+
if (res.ok || res.status === 404) {
|
|
283
|
+
// 404 still means the HTTP server is up and routing.
|
|
284
|
+
return { url, child, stop };
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// not up yet
|
|
288
|
+
}
|
|
289
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
290
|
+
}
|
|
291
|
+
await stop();
|
|
292
|
+
throw new Error(`Verdaccio did not become ready at ${url} in time`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Locate the monorepo root by walking up from a starting dir until a
|
|
297
|
+
* `package.json` whose name is `checkstack-monorepo` (the root workspace)
|
|
298
|
+
* is found.
|
|
299
|
+
*/
|
|
300
|
+
export function findMonorepoRoot({ from }: { from: string }): string {
|
|
301
|
+
let dir = from;
|
|
302
|
+
for (let i = 0; i < 20; i++) {
|
|
303
|
+
const pkgPath = path.join(dir, "package.json");
|
|
304
|
+
if (fs.existsSync(pkgPath)) {
|
|
305
|
+
const pkg = readPackageJson(pkgPath);
|
|
306
|
+
if (pkg.name === "checkstack-monorepo") return dir;
|
|
307
|
+
}
|
|
308
|
+
const parent = path.dirname(dir);
|
|
309
|
+
if (parent === dir) break;
|
|
310
|
+
dir = parent;
|
|
311
|
+
}
|
|
312
|
+
throw new Error(`Could not find checkstack-monorepo root above ${from}`);
|
|
313
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createNpmViewResolver,
|
|
4
|
+
buildNpmViewArgs,
|
|
5
|
+
type NpmViewRunner,
|
|
6
|
+
} from "./npm-view-resolver";
|
|
7
|
+
|
|
8
|
+
/** Records every package it was asked for and answers from a fixed table. */
|
|
9
|
+
function fakeRunner(
|
|
10
|
+
versions: Record<string, string>,
|
|
11
|
+
): { runner: NpmViewRunner; calls: string[] } {
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
const runner: NpmViewRunner = ({ packageName }) => {
|
|
14
|
+
calls.push(packageName);
|
|
15
|
+
const v = versions[packageName];
|
|
16
|
+
return Promise.resolve(v ? { version: v } : { version: undefined });
|
|
17
|
+
};
|
|
18
|
+
return { runner, calls };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("buildNpmViewArgs", () => {
|
|
22
|
+
it("queries <pkg>@<tag> version with the default latest tag", () => {
|
|
23
|
+
expect(
|
|
24
|
+
buildNpmViewArgs({ packageName: "@checkstack/common" }),
|
|
25
|
+
).toEqual(["view", "@checkstack/common@latest", "version"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("threads a custom version tag", () => {
|
|
29
|
+
expect(
|
|
30
|
+
buildNpmViewArgs({ packageName: "@checkstack/common", versionTag: "next" }),
|
|
31
|
+
).toEqual(["view", "@checkstack/common@next", "version"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("threads a custom registry", () => {
|
|
35
|
+
expect(
|
|
36
|
+
buildNpmViewArgs({
|
|
37
|
+
packageName: "@checkstack/common",
|
|
38
|
+
registry: "http://localhost:4873",
|
|
39
|
+
}),
|
|
40
|
+
).toEqual([
|
|
41
|
+
"view",
|
|
42
|
+
"@checkstack/common@latest",
|
|
43
|
+
"version",
|
|
44
|
+
"--registry",
|
|
45
|
+
"http://localhost:4873",
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("createNpmViewResolver", () => {
|
|
51
|
+
it("resolves a published @checkstack/* dep to a caret on its latest version", async () => {
|
|
52
|
+
const { runner } = fakeRunner({ "@checkstack/common": "0.12.0" });
|
|
53
|
+
const resolve = createNpmViewResolver({ runNpmView: runner });
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
await resolve({
|
|
57
|
+
packageName: "@checkstack/common",
|
|
58
|
+
workspaceRange: "workspace:*",
|
|
59
|
+
}),
|
|
60
|
+
).toBe("^0.12.0");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("resolves each @checkstack/* dep independently (not lockstepped)", async () => {
|
|
64
|
+
const { runner } = fakeRunner({
|
|
65
|
+
"@checkstack/common": "0.12.0",
|
|
66
|
+
"@checkstack/backend": "0.15.0",
|
|
67
|
+
});
|
|
68
|
+
const resolve = createNpmViewResolver({ runNpmView: runner });
|
|
69
|
+
|
|
70
|
+
expect(
|
|
71
|
+
await resolve({
|
|
72
|
+
packageName: "@checkstack/common",
|
|
73
|
+
workspaceRange: "workspace:*",
|
|
74
|
+
}),
|
|
75
|
+
).toBe("^0.12.0");
|
|
76
|
+
expect(
|
|
77
|
+
await resolve({
|
|
78
|
+
packageName: "@checkstack/backend",
|
|
79
|
+
workspaceRange: "workspace:*",
|
|
80
|
+
}),
|
|
81
|
+
).toBe("^0.15.0");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("leaves local workspace siblings as workspace:* (not published)", async () => {
|
|
85
|
+
const { runner, calls } = fakeRunner({});
|
|
86
|
+
const resolve = createNpmViewResolver({
|
|
87
|
+
runNpmView: runner,
|
|
88
|
+
localSiblings: new Set(["widget-common", "widget-frontend"]),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(
|
|
92
|
+
await resolve({
|
|
93
|
+
packageName: "widget-common",
|
|
94
|
+
workspaceRange: "workspace:*",
|
|
95
|
+
}),
|
|
96
|
+
).toBe("workspace:*");
|
|
97
|
+
// The registry is never queried for a local sibling.
|
|
98
|
+
expect(calls).not.toContain("widget-common");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("caches a resolution so the registry is queried once per package", async () => {
|
|
102
|
+
const { runner, calls } = fakeRunner({ "@checkstack/common": "0.12.0" });
|
|
103
|
+
const resolve = createNpmViewResolver({ runNpmView: runner });
|
|
104
|
+
|
|
105
|
+
await resolve({
|
|
106
|
+
packageName: "@checkstack/common",
|
|
107
|
+
workspaceRange: "workspace:*",
|
|
108
|
+
});
|
|
109
|
+
await resolve({
|
|
110
|
+
packageName: "@checkstack/common",
|
|
111
|
+
workspaceRange: "workspace:*",
|
|
112
|
+
});
|
|
113
|
+
expect(calls).toEqual(["@checkstack/common"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns undefined for an unresolvable published dep so the engine can aggregate the failure", async () => {
|
|
117
|
+
const { runner } = fakeRunner({});
|
|
118
|
+
const resolve = createNpmViewResolver({ runNpmView: runner });
|
|
119
|
+
|
|
120
|
+
expect(
|
|
121
|
+
await resolve({
|
|
122
|
+
packageName: "@checkstack/common",
|
|
123
|
+
workspaceRange: "workspace:*",
|
|
124
|
+
}),
|
|
125
|
+
).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("propagates a subprocess failure (registry unreachable)", async () => {
|
|
129
|
+
const runner: NpmViewRunner = () =>
|
|
130
|
+
Promise.reject(new Error("npm view exited with status 1"));
|
|
131
|
+
const resolve = createNpmViewResolver({ runNpmView: runner });
|
|
132
|
+
|
|
133
|
+
await expect(
|
|
134
|
+
resolve({
|
|
135
|
+
packageName: "@checkstack/common",
|
|
136
|
+
workspaceRange: "workspace:*",
|
|
137
|
+
}),
|
|
138
|
+
).rejects.toThrow(/npm view exited/);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { VersionResolver } from "@checkstack/scripts/scaffold";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* npm-view-backed {@link VersionResolver} for the standalone scaffolder.
|
|
6
|
+
*
|
|
7
|
+
* The scaffolding engine (in `@checkstack/scripts`) rewrites every
|
|
8
|
+
* `workspace:*` range in the rendered trio to a concrete version via an
|
|
9
|
+
* injected resolver. This is the production resolver: it queries the
|
|
10
|
+
* registry's `latest` dist-tag (or a `--version-tag`) per dependency via
|
|
11
|
+
* `npm view <pkg>@<tag> version` and pins a caret range (`^<version>`).
|
|
12
|
+
*
|
|
13
|
+
* Key behaviours:
|
|
14
|
+
* - `@checkstack/*` versions are 0.x and NOT lockstepped, so each dep is
|
|
15
|
+
* resolved independently (one `npm view` per package), with a single
|
|
16
|
+
* in-process cache so repeated lookups hit the registry once.
|
|
17
|
+
* - The three local trio siblings (`widget-common`, `widget-backend`,
|
|
18
|
+
* `widget-frontend`) are NOT published; they install from the local
|
|
19
|
+
* Bun workspace, so the resolver returns their `workspace:*` range
|
|
20
|
+
* unchanged (and `plugin-pack --bundle` rewrites them at pack time).
|
|
21
|
+
* - A registry miss returns `undefined` so the engine can aggregate and
|
|
22
|
+
* fail loud with the full list of unresolved deps. A subprocess error
|
|
23
|
+
* (registry unreachable) propagates as a thrown error.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Result of querying the registry for one package's version. */
|
|
27
|
+
export interface NpmViewResult {
|
|
28
|
+
/** The concrete version, or `undefined` if the package was not found. */
|
|
29
|
+
version: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Injectable subprocess seam: runs `npm view` for one package. */
|
|
33
|
+
export type NpmViewRunner = (args: {
|
|
34
|
+
packageName: string;
|
|
35
|
+
registry?: string;
|
|
36
|
+
versionTag: string;
|
|
37
|
+
}) => Promise<NpmViewResult>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the `npm view` argument vector for one package. Pure, so the exact
|
|
41
|
+
* registry/tag threading is unit-testable without spawning a process.
|
|
42
|
+
*/
|
|
43
|
+
export function buildNpmViewArgs({
|
|
44
|
+
packageName,
|
|
45
|
+
versionTag = "latest",
|
|
46
|
+
registry,
|
|
47
|
+
}: {
|
|
48
|
+
packageName: string;
|
|
49
|
+
versionTag?: string;
|
|
50
|
+
registry?: string;
|
|
51
|
+
}): string[] {
|
|
52
|
+
const args = ["view", `${packageName}@${versionTag}`, "version"];
|
|
53
|
+
if (registry) args.push("--registry", registry);
|
|
54
|
+
return args;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default {@link NpmViewRunner}: spawns `npm view`, captures stdout, and
|
|
59
|
+
* parses the printed version. A non-zero exit (other than "package not
|
|
60
|
+
* found") rejects so the caller surfaces a clear network error.
|
|
61
|
+
*/
|
|
62
|
+
export const spawnNpmViewRunner: NpmViewRunner = ({
|
|
63
|
+
packageName,
|
|
64
|
+
registry,
|
|
65
|
+
versionTag,
|
|
66
|
+
}) => {
|
|
67
|
+
const args = buildNpmViewArgs({ packageName, versionTag, registry });
|
|
68
|
+
return new Promise<NpmViewResult>((resolve, reject) => {
|
|
69
|
+
const child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
70
|
+
let stdout = "";
|
|
71
|
+
let stderr = "";
|
|
72
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
73
|
+
stdout += chunk.toString();
|
|
74
|
+
});
|
|
75
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
76
|
+
stderr += chunk.toString();
|
|
77
|
+
});
|
|
78
|
+
child.on("error", reject);
|
|
79
|
+
child.on("close", (code) => {
|
|
80
|
+
const version = stdout.trim().replaceAll(/['"]/g, "") || undefined;
|
|
81
|
+
if (code === 0) {
|
|
82
|
+
resolve({ version });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// `npm view` exits non-zero when the package is absent (E404). Treat
|
|
86
|
+
// that as "unresolved" (undefined) so the engine aggregates; any other
|
|
87
|
+
// failure (network, auth) is a hard error.
|
|
88
|
+
if (/E404|404 Not Found|is not in this registry/i.test(stderr)) {
|
|
89
|
+
resolve({ version: undefined });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
reject(
|
|
93
|
+
new Error(
|
|
94
|
+
`npm view ${packageName} exited with status ${code}: ${stderr.trim()}`,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create the npm-view-backed resolver.
|
|
103
|
+
*
|
|
104
|
+
* @param runNpmView injectable subprocess seam (defaults to a real
|
|
105
|
+
* `npm view` spawn). Tests pass a fake.
|
|
106
|
+
* @param registry optional registry URL to thread into `npm view`.
|
|
107
|
+
* @param versionTag dist-tag to resolve (default `latest`).
|
|
108
|
+
* @param localSiblings the generated trio package names, left as
|
|
109
|
+
* `workspace:*` because they resolve from the local workspace.
|
|
110
|
+
*/
|
|
111
|
+
export function createNpmViewResolver({
|
|
112
|
+
runNpmView = spawnNpmViewRunner,
|
|
113
|
+
registry,
|
|
114
|
+
versionTag = "latest",
|
|
115
|
+
localSiblings = new Set<string>(),
|
|
116
|
+
}: {
|
|
117
|
+
runNpmView?: NpmViewRunner;
|
|
118
|
+
registry?: string;
|
|
119
|
+
versionTag?: string;
|
|
120
|
+
localSiblings?: Set<string>;
|
|
121
|
+
} = {}): VersionResolver {
|
|
122
|
+
const cache = new Map<string, Promise<string | undefined>>();
|
|
123
|
+
|
|
124
|
+
return ({ packageName, workspaceRange }) => {
|
|
125
|
+
// Local trio siblings are not published: keep the workspace range so
|
|
126
|
+
// `bun install` resolves them from the local workspace.
|
|
127
|
+
if (localSiblings.has(packageName)) {
|
|
128
|
+
return Promise.resolve(workspaceRange);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cached = cache.get(packageName);
|
|
132
|
+
if (cached) return cached;
|
|
133
|
+
|
|
134
|
+
const resolution = runNpmView({ packageName, registry, versionTag }).then(
|
|
135
|
+
({ version }) => (version ? `^${version}` : undefined),
|
|
136
|
+
);
|
|
137
|
+
cache.set(packageName, resolution);
|
|
138
|
+
return resolution;
|
|
139
|
+
};
|
|
140
|
+
}
|