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,478 @@
1
+ /**
2
+ * End-to-end integration test for the external-plugin authoring lifecycle
3
+ * against PUBLISHED tarballs (#251 Phase 3, plan §7).
4
+ *
5
+ * This is the single heavy lane that guards the whole external story: it
6
+ * publishes the CURRENT working tree's `@checkstack/*` packages to a
7
+ * throwaway local Verdaccio registry, scaffolds a standalone plugin
8
+ * workspace on the fly (Phase-2 engine, resolver pointed at the local
9
+ * registry), `bun install`s it from that registry, packs + bundles it, then
10
+ * boots the published dev server and asserts the plugin serves
11
+ * `POST /api/<pluginId>/*` == 200 and the frontend Vite server comes up
12
+ * and answers HTTP (a hard assertion — #251 bug 2 is fixed).
13
+ *
14
+ * Because it publishes the live tree, it implicitly catches "a package.json
15
+ * change broke the published shape" regressions — the rot this feature
16
+ * exists to prevent. Finer-grained logic (version rewriting, env
17
+ * construction, frontend-entry picking) lives in the fast unit suites; this
18
+ * lane is intentionally one test.
19
+ *
20
+ * Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it.
21
+ * The `integration` CI job sets that flag, provides Postgres, and (Phase 3)
22
+ * provides a Verdaccio registry. Connection comes from
23
+ * `CHECKSTACK_IT_PG_URL`; the registry from `CHECKSTACK_SCAFFOLD_REGISTRY`
24
+ * (defaulting to the harness-managed local registry).
25
+ *
26
+ * Heavy by design (publish + full `bun install` + backend boot is minutes,
27
+ * not seconds), so the suite uses a generous timeout.
28
+ */
29
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
30
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
31
+ import fs from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { scaffoldStandaloneWorkspace, localSiblingNames } from "./scaffold-standalone";
36
+ import { createNpmViewResolver } from "./npm-view-resolver";
37
+ import {
38
+ DEFAULT_REGISTRY_URL,
39
+ buildVerdaccioConfig,
40
+ buildNpmrc,
41
+ buildNpmViewVersionArgs,
42
+ discoverWorkspacePackages,
43
+ publishWorkspacePackages,
44
+ findMonorepoRoot,
45
+ startVerdaccio,
46
+ type RegistryHandle,
47
+ } from "./local-registry";
48
+
49
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
50
+ const PG_URL =
51
+ process.env.CHECKSTACK_IT_PG_URL ??
52
+ "postgres://checkstack:checkstack@localhost:5432/checkstack";
53
+
54
+ const BASE_NAME = "widget";
55
+ const PLUGIN_ID = "widget"; // pluginId is the unscoped base, used in /api/<pluginId>/*
56
+ // A dedicated npm scope for the generated trio so its package names
57
+ // (@checkstackit/widget-*) never collide with the real @checkstack/* packages
58
+ // we publish to the same local registry. The routing pluginId stays unscoped
59
+ // (PLUGIN_ID above) regardless of the package scope.
60
+ const PACKAGE_SCOPE = "checkstackit";
61
+ // The published @checkstack/backend default-export server binds a fixed
62
+ // port (it does not honour $PORT), so the backend always listens on 3000.
63
+ // We poll that port rather than overriding PORT, which would be ignored.
64
+ const BACKEND_PORT = 3000;
65
+ const FRONTEND_PORT = 5917;
66
+ // A custom (arbitrary) Tailwind utility class we inject into the scaffolded
67
+ // plugin's frontend. `251px` is an uncommon value unlikely to appear in the
68
+ // platform's own CSS, so finding it in the compiled dev CSS proves the dev
69
+ // server compiled the PLUGIN'S OWN classes.
70
+ const TAILWIND_PROBE_CLASS = "p-[251px]";
71
+ const TAILWIND_PROBE_CSS = "251px";
72
+
73
+ /** Run a command, returning status + captured output (no inherited stdio). */
74
+ function run({
75
+ command,
76
+ args,
77
+ cwd,
78
+ env,
79
+ timeoutMs = 600_000,
80
+ }: {
81
+ command: string;
82
+ args: string[];
83
+ cwd: string;
84
+ env?: NodeJS.ProcessEnv;
85
+ timeoutMs?: number;
86
+ }): { status: number; stdout: string; stderr: string } {
87
+ const result = spawnSync(command, args, {
88
+ cwd,
89
+ env: { ...process.env, ...env },
90
+ encoding: "utf8",
91
+ timeout: timeoutMs,
92
+ maxBuffer: 64 * 1024 * 1024,
93
+ });
94
+ return {
95
+ status: result.status ?? 1,
96
+ stdout: result.stdout ?? "",
97
+ stderr: `${result.stderr ?? ""}${result.error ? result.error.message : ""}`,
98
+ };
99
+ }
100
+
101
+ async function poll<T>({
102
+ fn,
103
+ timeoutMs,
104
+ intervalMs = 1000,
105
+ }: {
106
+ fn: () => Promise<T | undefined>;
107
+ timeoutMs: number;
108
+ intervalMs?: number;
109
+ }): Promise<T | undefined> {
110
+ const deadline = Date.now() + timeoutMs;
111
+ while (Date.now() < deadline) {
112
+ const result = await fn();
113
+ if (result !== undefined) return result;
114
+ await new Promise((r) => setTimeout(r, intervalMs));
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
120
+ "external plugin lifecycle (published tarballs)",
121
+ () => {
122
+ let tmpRoot: string;
123
+ let workspaceDir: string;
124
+ let backendDir: string;
125
+ let npmrcPath: string;
126
+ let registryUrl: string;
127
+ let cacheDir: string;
128
+ let ownedRegistry: RegistryHandle | undefined;
129
+ let devChild: ChildProcess | undefined;
130
+ let monorepoRoot: string;
131
+
132
+ beforeAll(async () => {
133
+ monorepoRoot = findMonorepoRoot({ from: HERE });
134
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccp-e2e-"));
135
+
136
+ // Per-run isolated bun install cache. CRITICAL: bun's global
137
+ // content-addressed cache is keyed by name@version, so re-publishing the
138
+ // SAME @checkstack/* version (the tree's current version, unchanged
139
+ // between local runs) would serve a STALE tarball from a previous run
140
+ // instead of the just-published one. An isolated cache dir forces every
141
+ // run to fetch the freshly-published tarballs from the local registry.
142
+ cacheDir = path.join(tmpRoot, "bun-cache");
143
+ fs.mkdirSync(cacheDir, { recursive: true });
144
+
145
+ // The throwaway .npmrc routing every fetch/publish at the local
146
+ // registry, plus the fake auth token that satisfies the publish proto.
147
+ registryUrl = process.env.CHECKSTACK_SCAFFOLD_REGISTRY ?? DEFAULT_REGISTRY_URL;
148
+ npmrcPath = path.join(tmpRoot, ".npmrc");
149
+ fs.writeFileSync(npmrcPath, buildNpmrc({ registryUrl }));
150
+
151
+ // Two modes:
152
+ // - CI provides Verdaccio AND publishes the workspace itself (a
153
+ // dedicated step, so a publish failure is attributed clearly).
154
+ // Detected via CHECKSTACK_SCAFFOLD_REGISTRY being preset — the test
155
+ // then only verifies the registry answers and does NOT re-publish
156
+ // (that would collide on already-published versions).
157
+ // - Local runs own the lifecycle: spin up Verdaccio here and publish
158
+ // the full non-private workspace into it.
159
+ const registryProvidedByCi = Boolean(process.env.CHECKSTACK_SCAFFOLD_REGISTRY);
160
+ if (!registryProvidedByCi) {
161
+ const storageDir = path.join(tmpRoot, "verdaccio-storage");
162
+ const configPath = path.join(tmpRoot, "verdaccio.yaml");
163
+ fs.mkdirSync(storageDir, { recursive: true });
164
+ fs.writeFileSync(configPath, buildVerdaccioConfig({ storageDir }));
165
+ ownedRegistry = await startVerdaccio({ configPath, url: registryUrl });
166
+
167
+ // Publish the full non-private workspace to the local registry. We
168
+ // use `bun publish --registry <url>` (resolves workspace:* to
169
+ // concrete versions) rather than the real-registry
170
+ // `publish-packages.ts`. We publish the FULL set because the
171
+ // scaffolded plugin depends on @checkstack/backend, whose transitive
172
+ // closure spans most of the platform.
173
+ const packages = discoverWorkspacePackages({ monorepoRoot });
174
+ const outcomes = publishWorkspacePackages({
175
+ packages,
176
+ registryUrl,
177
+ npmrcPath,
178
+ env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
179
+ });
180
+ const failed = outcomes.filter((o) => o.status !== 0);
181
+ expect(
182
+ failed,
183
+ `publish failures:\n${failed.map((f) => `${f.name}: ${f.stderr}`).join("\n")}`,
184
+ ).toEqual([]);
185
+ }
186
+
187
+ // Sanity: @checkstack/backend and create-checkstack-plugin answer.
188
+ for (const name of ["@checkstack/backend", "create-checkstack-plugin"]) {
189
+ const view = run({
190
+ command: "npm",
191
+ args: buildNpmViewVersionArgs({ packageName: name, registryUrl }),
192
+ cwd: tmpRoot,
193
+ env: { NPM_CONFIG_USERCONFIG: npmrcPath },
194
+ timeoutMs: 60_000,
195
+ });
196
+ expect(view.status, `npm view ${name}: ${view.stderr}`).toBe(0);
197
+ expect(view.stdout.trim().length).toBeGreaterThan(0);
198
+ }
199
+ }, 600_000);
200
+
201
+ afterAll(async () => {
202
+ if (devChild && devChild.exitCode === null) {
203
+ devChild.kill("SIGTERM");
204
+ await new Promise((r) => setTimeout(r, 500));
205
+ if (devChild.exitCode === null) devChild.kill("SIGKILL");
206
+ }
207
+ await ownedRegistry?.stop();
208
+ if (tmpRoot) fs.rmSync(tmpRoot, { recursive: true, force: true });
209
+ });
210
+
211
+ it("scaffolds with zero workspace ranges and a concrete version per @checkstack dep", async () => {
212
+ workspaceDir = path.join(tmpRoot, BASE_NAME);
213
+ // Resolver pointed at the local registry — exactly the injected seam
214
+ // the create-checkstack-plugin CLI exposes via --registry. Local trio
215
+ // siblings stay workspace:* (resolved from the local Bun workspace).
216
+ const resolveVersion = createNpmViewResolver({
217
+ registry: registryUrl,
218
+ localSiblings: localSiblingNames({ baseName: BASE_NAME, packageScope: PACKAGE_SCOPE }),
219
+ });
220
+ await scaffoldStandaloneWorkspace({
221
+ rootDir: workspaceDir,
222
+ baseName: BASE_NAME,
223
+ description: "Widget e2e plugin",
224
+ packageScope: PACKAGE_SCOPE,
225
+ resolveVersion,
226
+ });
227
+
228
+ backendDir = path.join(workspaceDir, "packages", `${BASE_NAME}-backend`);
229
+ expect(fs.existsSync(backendDir)).toBe(true);
230
+ expect(fs.existsSync(path.join(workspaceDir, "packages", `${BASE_NAME}-common`))).toBe(true);
231
+ expect(fs.existsSync(path.join(workspaceDir, "packages", `${BASE_NAME}-frontend`))).toBe(true);
232
+
233
+ // Inject a probe element using a CUSTOM (arbitrary) Tailwind utility
234
+ // class into the plugin's own frontend source. The dev server must
235
+ // compile it into the dev CSS from a published install (proving the
236
+ // @checkstack/frontend Tailwind-toolchain-as-runtime-deps + preset
237
+ // export + plugin-glob injection actually styles author code, not just
238
+ // the precompiled @checkstack/ui components). Asserted in the dev test.
239
+ const frontendIndex = path.join(
240
+ workspaceDir,
241
+ "packages",
242
+ `${BASE_NAME}-frontend`,
243
+ "src",
244
+ "index.tsx",
245
+ );
246
+ const original = fs.readFileSync(frontendIndex, "utf8");
247
+ fs.writeFileSync(
248
+ frontendIndex,
249
+ `// e2e Tailwind probe: a custom arbitrary utility class.\n` +
250
+ `export const __TailwindProbe = () => <div className="${TAILWIND_PROBE_CLASS}" />;\n` +
251
+ original,
252
+ );
253
+
254
+ // Every @checkstack/* dep must be a concrete (caret) version, never
255
+ // workspace:* — install rejects workspace ranges. Only the local trio
256
+ // siblings keep workspace:* (they resolve from the local workspace).
257
+ const siblings = localSiblingNames({ baseName: BASE_NAME, packageScope: PACKAGE_SCOPE });
258
+ for (const type of ["common", "backend", "frontend"]) {
259
+ const pkg = JSON.parse(
260
+ fs.readFileSync(
261
+ path.join(workspaceDir, "packages", `${BASE_NAME}-${type}`, "package.json"),
262
+ "utf8",
263
+ ),
264
+ ) as Record<string, Record<string, string> | unknown>;
265
+ for (const section of ["dependencies", "devDependencies"] as const) {
266
+ const deps = (pkg[section] as Record<string, string> | undefined) ?? {};
267
+ for (const [name, range] of Object.entries(deps)) {
268
+ if (name.startsWith("@checkstack/")) {
269
+ expect(range, `${name} in ${type}`).not.toContain("workspace:");
270
+ expect(range).toMatch(/^\^?\d/);
271
+ } else if (siblings.has(name)) {
272
+ expect(range).toBe("workspace:*");
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ // The backend primary must carry checkstack.bundle (required for
279
+ // plugin-pack --bundle). Siblings must NOT.
280
+ const backendPkg = JSON.parse(
281
+ fs.readFileSync(path.join(backendDir, "package.json"), "utf8"),
282
+ ) as { checkstack?: { bundle?: string[] } };
283
+ expect(backendPkg.checkstack?.bundle).toEqual([
284
+ `@${PACKAGE_SCOPE}/${BASE_NAME}-common`,
285
+ `@${PACKAGE_SCOPE}/${BASE_NAME}-frontend`,
286
+ ]);
287
+ });
288
+
289
+ it("installs from the local registry", () => {
290
+ const install = run({
291
+ command: "bun",
292
+ args: ["install"],
293
+ cwd: workspaceDir,
294
+ env: {
295
+ NPM_CONFIG_USERCONFIG: npmrcPath,
296
+ BUN_CONFIG_REGISTRY: registryUrl,
297
+ // Isolated cache so the just-published tarballs win over any
298
+ // same-version copy in bun's global cache (see beforeAll).
299
+ BUN_INSTALL_CACHE_DIR: cacheDir,
300
+ },
301
+ });
302
+ expect(install.status, install.stderr).toBe(0);
303
+ // @checkstack/backend + @checkstack/dev-server must resolve in the
304
+ // backend package's reachable node_modules (hoisted or local).
305
+ const resolves = (name: string) => {
306
+ const r = run({
307
+ command: "bun",
308
+ args: ["-e", `require.resolve(${JSON.stringify(`${name}/package.json`)})`],
309
+ cwd: backendDir,
310
+ });
311
+ return r.status === 0;
312
+ };
313
+ expect(resolves("@checkstack/backend")).toBe(true);
314
+ expect(resolves("@checkstack/dev-server")).toBe(true);
315
+ }, 600_000);
316
+
317
+ it("validates, packs, and bundles (workspace rewrite is a no-op for @checkstack deps)", () => {
318
+ const packEnv = {
319
+ NPM_CONFIG_USERCONFIG: npmrcPath,
320
+ BUN_CONFIG_REGISTRY: registryUrl,
321
+ BUN_INSTALL_CACHE_DIR: cacheDir,
322
+ };
323
+ const validate = run({
324
+ command: "bun",
325
+ args: ["run", "pack", "--", "--validate-only"],
326
+ cwd: backendDir,
327
+ env: packEnv,
328
+ });
329
+ expect(validate.status, validate.stderr || validate.stdout).toBe(0);
330
+
331
+ const bundle = run({
332
+ command: "bun",
333
+ args: ["run", "pack", "--", "--bundle"],
334
+ cwd: backendDir,
335
+ env: packEnv,
336
+ });
337
+ expect(bundle.status, bundle.stderr || bundle.stdout).toBe(0);
338
+
339
+ const distDir = path.join(backendDir, "dist");
340
+ const bundleTarball = fs
341
+ .readdirSync(distDir)
342
+ .find((f) => f.endsWith("-bundle.tgz"));
343
+ expect(bundleTarball, "expected a *-bundle.tgz in dist/").toBeDefined();
344
+ }, 600_000);
345
+
346
+ it("boots the dev server and serves POST /api/<pluginId>/* == 200 with a JSON array", async () => {
347
+ // Boot the published dev server via the scaffolded `dev` script. It
348
+ // spawns the real @checkstack/backend with the plugin loaded under
349
+ // synthetic dev auth, against the integration Postgres.
350
+ devChild = spawn("bun", ["run", "dev"], {
351
+ cwd: backendDir,
352
+ env: {
353
+ ...process.env,
354
+ NPM_CONFIG_USERCONFIG: npmrcPath,
355
+ BUN_CONFIG_REGISTRY: registryUrl,
356
+ BUN_INSTALL_CACHE_DIR: cacheDir,
357
+ DATABASE_URL: PG_URL,
358
+ // The published backend binds a fixed port; PORT is not honoured,
359
+ // so we poll BACKEND_PORT (3000) below. The Vite dev port IS
360
+ // configurable, so we set it to avoid a 5173 collision.
361
+ FRONTEND_PORT: String(FRONTEND_PORT),
362
+ NODE_ENV: "development",
363
+ },
364
+ stdio: ["ignore", "pipe", "pipe"],
365
+ });
366
+ let bootLog = "";
367
+ devChild.stdout?.on("data", (c: Buffer) => {
368
+ bootLog += c.toString();
369
+ });
370
+ devChild.stderr?.on("data", (c: Buffer) => {
371
+ bootLog += c.toString();
372
+ });
373
+
374
+ // Poll the plugin endpoint until the backend is ready (migrations +
375
+ // plugin load take time on a cold install).
376
+ const body = await poll({
377
+ timeoutMs: 180_000,
378
+ fn: async () => {
379
+ if (devChild?.exitCode !== null && devChild?.exitCode !== undefined) {
380
+ throw new Error(`dev server exited early (${devChild.exitCode}):\n${bootLog}`);
381
+ }
382
+ try {
383
+ const res = await fetch(`http://localhost:${BACKEND_PORT}/api/${PLUGIN_ID}/getItems`, {
384
+ method: "POST",
385
+ headers: { "content-type": "application/json" },
386
+ body: JSON.stringify({ json: {} }),
387
+ });
388
+ if (res.status !== 200) return undefined;
389
+ return await res.json();
390
+ } catch {
391
+ return undefined;
392
+ }
393
+ },
394
+ });
395
+ expect(body, `no 200 from /api/${PLUGIN_ID}/getItems.\nboot log:\n${bootLog}`).toBeDefined();
396
+ // oRPC wraps the array output in a { json: [...] } envelope.
397
+ const payload =
398
+ body && typeof body === "object" && "json" in body
399
+ ? (body as { json: unknown }).json
400
+ : body;
401
+ expect(Array.isArray(payload)).toBe(true);
402
+
403
+ // (backend+frontend case) The dev server must recognise the
404
+ // -frontend sibling in the bundle and bring up the Vite frontend dev
405
+ // server from the PUBLISHED install. The dev server resolves
406
+ // @checkstack/frontend (its own peer dep, NOT reachable from the
407
+ // plugin's package under a clean `bun install`) from its own install
408
+ // location, and picks up the workspace `-frontend` sibling by
409
+ // scanning sibling directories — #251 bug 2, fixed in
410
+ // @checkstack/dev-server's dev-frontend.ts. Both the "started" log
411
+ // line AND a live HTTP response are now HARD assertions, so this lane
412
+ // is fully green including the frontend.
413
+ const startedFrontend = await poll({
414
+ timeoutMs: 60_000,
415
+ fn: async () =>
416
+ /🎨 Frontend dev server/.test(bootLog) ? true : undefined,
417
+ });
418
+ expect(
419
+ startedFrontend,
420
+ `dev server never started the Vite frontend.\nboot log:\n${bootLog}`,
421
+ ).toBe(true);
422
+ expect(
423
+ /Frontend dev server failed to start/.test(bootLog),
424
+ `dev server logged a Vite startup failure (regression of #251 bug 2).\nboot log:\n${bootLog}`,
425
+ ).toBe(false);
426
+
427
+ const viteUp = await poll({
428
+ timeoutMs: 60_000,
429
+ fn: async () => {
430
+ if (devChild?.exitCode !== null && devChild?.exitCode !== undefined) {
431
+ throw new Error(
432
+ `dev server exited before Vite answered (${devChild.exitCode}):\n${bootLog}`,
433
+ );
434
+ }
435
+ try {
436
+ const res = await fetch(`http://localhost:${FRONTEND_PORT}/`);
437
+ return res.status < 500 ? true : undefined;
438
+ } catch {
439
+ return undefined;
440
+ }
441
+ },
442
+ });
443
+ expect(
444
+ viteUp,
445
+ `Vite did not answer on :${FRONTEND_PORT} (frontend HMR unavailable).\nboot log:\n${bootLog}`,
446
+ ).toBe(true);
447
+
448
+ // The dev shell must be STYLED from a published install: fetch the
449
+ // compiled dev CSS (Vite serves index.css's processed output via the
450
+ // `?direct` query) and assert the PLUGIN'S OWN custom Tailwind class
451
+ // compiled. This proves @checkstack/frontend shipping the Tailwind
452
+ // toolchain as runtime deps + exporting `tailwind-preset` + the dev
453
+ // server injecting the plugin's source globs all work end-to-end.
454
+ const compiledCss = await poll({
455
+ timeoutMs: 60_000,
456
+ fn: async () => {
457
+ try {
458
+ const res = await fetch(
459
+ `http://localhost:${FRONTEND_PORT}/src/index.css?direct`,
460
+ );
461
+ if (res.status >= 400) return undefined;
462
+ const text = await res.text();
463
+ // Skip Vite's HTML error-overlay response (a failed PostCSS load
464
+ // returns 200 with an HTML doc, not CSS).
465
+ if (text.includes("Internal Server Error")) return undefined;
466
+ return text.includes(TAILWIND_PROBE_CSS) ? text : undefined;
467
+ } catch {
468
+ return undefined;
469
+ }
470
+ },
471
+ });
472
+ expect(
473
+ compiledCss,
474
+ `dev CSS did not contain the plugin's custom Tailwind class (${TAILWIND_PROBE_CLASS} → "${TAILWIND_PROBE_CSS}"); the dev shell is unstyled.\nboot log:\n${bootLog}`,
475
+ ).toBeDefined();
476
+ }, 300_000);
477
+ },
478
+ );
package/src/git.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * Initialise a git repo in `dir` and make an initial commit.
5
+ *
6
+ * Runs in the thin CLI shell only (never the shared engine), so the
7
+ * in-monorepo `create` path is unaffected. Failures are non-fatal: a
8
+ * generated repo without git is still usable, so we warn and continue.
9
+ */
10
+ export function initGitRepo({
11
+ dir,
12
+ log = console.log,
13
+ warn = console.warn,
14
+ }: {
15
+ dir: string;
16
+ log?: (message: string) => void;
17
+ warn?: (message: string) => void;
18
+ }): boolean {
19
+ const steps: { args: string[]; label: string }[] = [
20
+ { args: ["init"], label: "git init" },
21
+ { args: ["add", "-A"], label: "git add" },
22
+ {
23
+ args: ["commit", "-m", "chore: scaffold plugin with create-checkstack-plugin"],
24
+ label: "git commit",
25
+ },
26
+ ];
27
+
28
+ for (const { args, label } of steps) {
29
+ const result = spawnSync("git", args, { cwd: dir, stdio: "ignore" });
30
+ if (result.status !== 0) {
31
+ warn(
32
+ `Skipped git setup (${label} failed). Initialise git manually if you want version control.`,
33
+ );
34
+ return false;
35
+ }
36
+ }
37
+ log("Initialised a git repository with an initial commit.");
38
+ return true;
39
+ }
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, afterEach } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ buildVerdaccioConfig,
7
+ buildNpmrc,
8
+ buildBunPublishArgs,
9
+ buildNpmViewVersionArgs,
10
+ discoverWorkspacePackages,
11
+ publishWorkspacePackages,
12
+ findMonorepoRoot,
13
+ DEFAULT_REGISTRY_URL,
14
+ type WorkspacePackage,
15
+ } from "./local-registry";
16
+
17
+ const tmpDirs: string[] = [];
18
+
19
+ function makeTmpDir(): string {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccp-localreg-"));
21
+ tmpDirs.push(dir);
22
+ return dir;
23
+ }
24
+
25
+ afterEach(() => {
26
+ for (const dir of tmpDirs.splice(0)) {
27
+ fs.rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ describe("buildVerdaccioConfig", () => {
32
+ it("grants anonymous publish to @checkstack/* and create-checkstack-plugin", () => {
33
+ const yaml = buildVerdaccioConfig({ storageDir: "/tmp/store", port: 4999 });
34
+ expect(yaml).toContain("'@checkstack/*'");
35
+ expect(yaml).toContain("'create-checkstack-plugin'");
36
+ expect(yaml).toContain("publish: $anonymous");
37
+ expect(yaml).toContain("listen: 0.0.0.0:4999");
38
+ expect(yaml).toContain('storage: "/tmp/store"');
39
+ // Falls back to the public registry for third-party transitive deps.
40
+ expect(yaml).toContain("proxy: npmjs");
41
+ });
42
+ });
43
+
44
+ describe("buildNpmrc", () => {
45
+ it("pins the scope to the local registry and carries a throwaway token", () => {
46
+ const npmrc = buildNpmrc({ registryUrl: "http://localhost:4873" });
47
+ expect(npmrc).toContain("@checkstack:registry=http://localhost:4873");
48
+ expect(npmrc).toContain("//localhost:4873/:_authToken=fake-checkstack-it-token");
49
+ });
50
+
51
+ it("defaults to the default registry url", () => {
52
+ expect(buildNpmrc()).toContain(`registry=${DEFAULT_REGISTRY_URL}`);
53
+ });
54
+ });
55
+
56
+ describe("buildBunPublishArgs / buildNpmViewVersionArgs", () => {
57
+ it("targets the local registry", () => {
58
+ expect(buildBunPublishArgs({ registryUrl: "http://x:1" })).toEqual([
59
+ "publish",
60
+ "--registry",
61
+ "http://x:1",
62
+ "--access",
63
+ "public",
64
+ ]);
65
+ expect(
66
+ buildNpmViewVersionArgs({ packageName: "@checkstack/common", registryUrl: "http://x:1" }),
67
+ ).toEqual(["view", "@checkstack/common", "version", "--registry", "http://x:1"]);
68
+ });
69
+ });
70
+
71
+ describe("discoverWorkspacePackages", () => {
72
+ it("collects core/* + plugins/* package.jsons, skipping _-dirs and missing names", () => {
73
+ const root = makeTmpDir();
74
+ const mk = (rel: string, json: Record<string, unknown>) => {
75
+ const dir = path.join(root, rel);
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(json));
78
+ };
79
+ mk("core/alpha", { name: "@checkstack/alpha", version: "1.0.0" });
80
+ mk("core/secret", { name: "@checkstack/release", version: "1.0.0", private: true });
81
+ mk("core/_test-scaffolds", { name: "@checkstack/scaffolds", version: "1.0.0" });
82
+ mk("plugins/beta", { name: "@checkstack/beta", version: "2.0.0" });
83
+ mk("plugins/nameless", { version: "3.0.0" });
84
+
85
+ const found = discoverWorkspacePackages({ monorepoRoot: root });
86
+ const names = found.map((p) => p.name).sort();
87
+ expect(names).toEqual(["@checkstack/alpha", "@checkstack/beta", "@checkstack/release"]);
88
+ expect(found.find((p) => p.name === "@checkstack/release")?.private).toBe(true);
89
+ expect(found.find((p) => p.name === "@checkstack/alpha")?.private).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe("publishWorkspacePackages", () => {
94
+ it("publishes only non-private packages via the injected runner with registry env", () => {
95
+ const packages: WorkspacePackage[] = [
96
+ { name: "@checkstack/a", version: "1.0.0", dir: "/repo/core/a", private: false },
97
+ { name: "@checkstack/priv", version: "1.0.0", dir: "/repo/core/priv", private: true },
98
+ { name: "@checkstack/b", version: "2.0.0", dir: "/repo/core/b", private: false },
99
+ ];
100
+ const calls: { command: string; args: string[]; cwd: string; userconfig?: string; registry?: string }[] = [];
101
+ const outcomes = publishWorkspacePackages({
102
+ packages,
103
+ registryUrl: "http://localhost:4873",
104
+ npmrcPath: "/tmp/.npmrc",
105
+ env: {},
106
+ run: ({ command, args, cwd, env }) => {
107
+ calls.push({
108
+ command,
109
+ args,
110
+ cwd,
111
+ userconfig: env.NPM_CONFIG_USERCONFIG,
112
+ registry: env.BUN_CONFIG_REGISTRY,
113
+ });
114
+ return { status: 0, stderr: "" };
115
+ },
116
+ });
117
+ expect(outcomes.map((o) => o.name)).toEqual(["@checkstack/a", "@checkstack/b"]);
118
+ expect(outcomes.every((o) => o.status === 0)).toBe(true);
119
+ expect(calls).toHaveLength(2);
120
+ expect(calls[0]?.command).toBe("bun");
121
+ expect(calls[0]?.args).toContain("publish");
122
+ expect(calls[0]?.userconfig).toBe("/tmp/.npmrc");
123
+ expect(calls[0]?.registry).toBe("http://localhost:4873");
124
+ });
125
+ });
126
+
127
+ describe("findMonorepoRoot", () => {
128
+ it("walks up to the checkstack-monorepo package.json", () => {
129
+ const root = makeTmpDir();
130
+ fs.writeFileSync(
131
+ path.join(root, "package.json"),
132
+ JSON.stringify({ name: "checkstack-monorepo" }),
133
+ );
134
+ const nested = path.join(root, "core", "create-checkstack-plugin", "src");
135
+ fs.mkdirSync(nested, { recursive: true });
136
+ expect(findMonorepoRoot({ from: nested })).toBe(root);
137
+ });
138
+
139
+ it("throws when no monorepo root is found", () => {
140
+ const root = makeTmpDir();
141
+ expect(() => findMonorepoRoot({ from: root })).toThrow(/checkstack-monorepo/);
142
+ });
143
+ });