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.
@@ -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
+ }