@checkstack/scripts 0.3.3 → 0.4.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.
Files changed (69) hide show
  1. package/package.json +15 -5
  2. package/src/commands/create.ts +16 -23
  3. package/src/commands/plugin-pack.ts +17 -28
  4. package/src/dev-tui/App.render.test.tsx +135 -0
  5. package/src/dev-tui/App.smoke.test.tsx +142 -0
  6. package/src/dev-tui/App.tsx +522 -0
  7. package/src/dev-tui/alert-buffer.test.ts +62 -0
  8. package/src/dev-tui/alert-buffer.ts +51 -0
  9. package/src/dev-tui/alt-screen.test.ts +66 -0
  10. package/src/dev-tui/alt-screen.ts +65 -0
  11. package/src/dev-tui/cli.tsx +89 -0
  12. package/src/dev-tui/fake-supervisor.ts +76 -0
  13. package/src/dev-tui/graceful-shutdown.test.ts +61 -0
  14. package/src/dev-tui/graceful-shutdown.ts +32 -0
  15. package/src/dev-tui/kill-tree.test.ts +47 -0
  16. package/src/dev-tui/kill-tree.ts +64 -0
  17. package/src/dev-tui/layout.test.ts +89 -0
  18. package/src/dev-tui/layout.ts +126 -0
  19. package/src/dev-tui/log-level.test.ts +94 -0
  20. package/src/dev-tui/log-level.ts +104 -0
  21. package/src/dev-tui/plain-runner.ts +60 -0
  22. package/src/dev-tui/process-config.test.ts +42 -0
  23. package/src/dev-tui/process-config.ts +61 -0
  24. package/src/dev-tui/readiness.test.ts +54 -0
  25. package/src/dev-tui/readiness.ts +44 -0
  26. package/src/dev-tui/scrollback.test.ts +83 -0
  27. package/src/dev-tui/scrollback.ts +82 -0
  28. package/src/dev-tui/supervisor.ts +231 -0
  29. package/src/dev-tui/text.test.ts +72 -0
  30. package/src/dev-tui/text.ts +101 -0
  31. package/src/dev-tui/types.ts +29 -0
  32. package/src/scaffold/index.ts +22 -0
  33. package/src/scaffold/resolve-versions.test.ts +49 -0
  34. package/src/scaffold/resolve-versions.ts +55 -0
  35. package/src/scaffold/rewrite-workspace-versions.test.ts +102 -0
  36. package/src/scaffold/rewrite-workspace-versions.ts +111 -0
  37. package/src/scaffold/scaffold-plugin.test.ts +209 -0
  38. package/src/scaffold/scaffold-plugin.ts +309 -0
  39. package/src/templates/backend/.changeset/initial.md.hbs +1 -1
  40. package/src/templates/backend/drizzle/0000_init.sql +7 -0
  41. package/src/templates/backend/drizzle/meta/0000_snapshot.json +65 -0
  42. package/src/templates/backend/drizzle/meta/_journal.json +13 -0
  43. package/src/templates/backend/drizzle.config.ts.hbs +5 -1
  44. package/src/templates/backend/package.json.hbs +7 -3
  45. package/src/templates/backend/src/index.ts.hbs +1 -1
  46. package/src/templates/backend/src/router.ts.hbs +1 -1
  47. package/src/templates/backend/src/service.ts.hbs +1 -1
  48. package/src/templates/common/.changeset/initial.md.hbs +1 -1
  49. package/src/templates/common/README.md.hbs +28 -11
  50. package/src/templates/common/package.json.hbs +1 -1
  51. package/src/templates/common/src/plugin-metadata.ts.hbs +1 -1
  52. package/src/templates/frontend/.changeset/initial.md.hbs +1 -1
  53. package/src/templates/frontend/package.json.hbs +2 -2
  54. package/src/templates/frontend/src/api.ts.hbs +2 -2
  55. package/src/templates/frontend/src/components/{{pluginNamePascal}}ListPage.tsx.hbs +1 -1
  56. package/src/templates/frontend/src/index.tsx.hbs +10 -4
  57. package/src/templates/standalone-root/.changeset/config.json.hbs +11 -0
  58. package/src/templates/standalone-root/.changeset/initial.md.hbs +9 -0
  59. package/src/templates/standalone-root/README.md.hbs +75 -0
  60. package/src/templates/standalone-root/eslint.config.mjs.hbs +37 -0
  61. package/src/templates/standalone-root/package.json.hbs +27 -0
  62. package/src/templates/standalone-root/tsconfig.json.hbs +13 -0
  63. package/src/templates.test.ts +20 -0
  64. package/src/tui/components.test.tsx +28 -0
  65. package/src/tui/components.tsx +159 -0
  66. package/src/tui/index.ts +31 -0
  67. package/src/tui/theme.test.ts +54 -0
  68. package/src/tui/theme.ts +60 -0
  69. package/src/utils/template.ts +42 -0
@@ -0,0 +1,231 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import path from "node:path";
3
+ import { detectLogLevel, type LogLevel } from "./log-level.ts";
4
+ import { createLineSplitter } from "./text.ts";
5
+ import { matchesReady } from "./readiness.ts";
6
+ import { planKill } from "./kill-tree.ts";
7
+ import { PROCESS_DEFS, type ProcessDef } from "./process-config.ts";
8
+ import type { ProcessId, ProcessStatus } from "./types.ts";
9
+
10
+ /** A single captured output line with its detected level and a sequence id. */
11
+ export interface CapturedLine {
12
+ readonly source: ProcessId;
13
+ readonly level: LogLevel;
14
+ readonly text: string;
15
+ readonly seq: number;
16
+ }
17
+
18
+ export interface SupervisorEvents {
19
+ /** A new output line was captured from any process. */
20
+ line: (line: CapturedLine) => void;
21
+ /** A process changed status. */
22
+ status: (input: { id: ProcessId; status: ProcessStatus }) => void;
23
+ }
24
+
25
+ export interface Supervisor {
26
+ /** Start all processes. The deps task is started first. */
27
+ start(): void;
28
+ /** Restart a single process (kills its tree, then respawns). */
29
+ restart(id: ProcessId): void;
30
+ /**
31
+ * Stop the long-running children (backend + frontend) and their trees.
32
+ * Leaves the `-d` docker deps running, matching the existing `dev` script.
33
+ */
34
+ shutdown(): Promise<void>;
35
+ /** Current status of a process. */
36
+ statusOf(id: ProcessId): ProcessStatus;
37
+ /** Subscribe to captured output lines. */
38
+ onLine(listener: SupervisorEvents["line"]): void;
39
+ /** Subscribe to status transitions. */
40
+ onStatus(listener: SupervisorEvents["status"]): void;
41
+ }
42
+
43
+ interface RunningProcess {
44
+ readonly def: ProcessDef;
45
+ child?: ChildProcess;
46
+ status: ProcessStatus;
47
+ }
48
+
49
+ export interface CreateSupervisorInput {
50
+ /** Working directory to spawn children in (the repo root). */
51
+ cwd: string;
52
+ }
53
+
54
+ /**
55
+ * Spawns and supervises the dev processes. Children are spawned in their own
56
+ * process group (POSIX) so the entire watcher tree can be torn down cleanly.
57
+ * Output is split into lines, level-tagged, and emitted via `line` events;
58
+ * status transitions are emitted via `status` events. All UI state derives from
59
+ * these events, keeping the ink components thin.
60
+ */
61
+ export function createSupervisor({ cwd }: CreateSupervisorInput): Supervisor {
62
+ const processes = new Map<ProcessId, RunningProcess>();
63
+ for (const def of PROCESS_DEFS) {
64
+ processes.set(def.id, { def, status: "stopped" });
65
+ }
66
+
67
+ const lineListeners: SupervisorEvents["line"][] = [];
68
+ const statusListeners: SupervisorEvents["status"][] = [];
69
+ let seq = 0;
70
+
71
+ const emitLine = (input: {
72
+ source: ProcessId;
73
+ text: string;
74
+ }): void => {
75
+ seq += 1;
76
+ const line: CapturedLine = {
77
+ source: input.source,
78
+ text: input.text,
79
+ level: detectLogLevel({ line: input.text }),
80
+ seq,
81
+ };
82
+ for (const listener of lineListeners) {
83
+ listener(line);
84
+ }
85
+ };
86
+
87
+ const setStatus = (id: ProcessId, status: ProcessStatus): void => {
88
+ const proc = processes.get(id);
89
+ if (!proc || proc.status === status) {
90
+ return;
91
+ }
92
+ proc.status = status;
93
+ for (const listener of statusListeners) {
94
+ listener({ id, status });
95
+ }
96
+ };
97
+
98
+ const handleOutput = (id: ProcessId, oneShot: boolean) => {
99
+ const split = createLineSplitter();
100
+ return (chunk: Buffer): void => {
101
+ const text = chunk.toString("utf8");
102
+ for (const text_ of split(text)) {
103
+ emitLine({ source: id, text: text_ });
104
+ if (!oneShot && matchesReady({ id, line: text_ })) {
105
+ setStatus(id, "ready");
106
+ }
107
+ }
108
+ };
109
+ };
110
+
111
+ const spawnProcess = (id: ProcessId): void => {
112
+ const proc = processes.get(id);
113
+ if (!proc) {
114
+ return;
115
+ }
116
+ const { def } = proc;
117
+
118
+ setStatus(id, "starting");
119
+
120
+ const child = spawn(def.command, [...def.args], {
121
+ // Each process runs in its own package dir (relative to the root cwd) so
122
+ // it streams that package's raw output; deps runs at the root.
123
+ cwd: def.cwd === undefined ? cwd : path.join(cwd, def.cwd),
124
+ // New process group on POSIX so we can kill the whole watcher tree.
125
+ // On Windows this is ignored; taskkill /T handles the tree instead.
126
+ detached: process.platform !== "win32",
127
+ env: process.env,
128
+ stdio: ["ignore", "pipe", "pipe"],
129
+ });
130
+ proc.child = child;
131
+
132
+ const onData = handleOutput(id, def.oneShot);
133
+ child.stdout?.on("data", onData);
134
+ child.stderr?.on("data", onData);
135
+
136
+ child.on("error", (error: Error) => {
137
+ emitLine({ source: id, text: `error: failed to spawn ${def.command}: ${error.message}` });
138
+ setStatus(id, "errored");
139
+ });
140
+
141
+ child.on("exit", (code: number | null) => {
142
+ proc.child = undefined;
143
+ if (def.oneShot) {
144
+ // deps: zero exit = ready; anything else = errored.
145
+ setStatus(id, code === 0 ? "ready" : "errored");
146
+ } else if (code === 0 || code === null) {
147
+ setStatus(id, "stopped");
148
+ } else {
149
+ setStatus(id, "errored");
150
+ }
151
+ });
152
+ };
153
+
154
+ const killProcess = (id: ProcessId): void => {
155
+ const proc = processes.get(id);
156
+ const child = proc?.child;
157
+ if (!proc || !child || child.pid === undefined) {
158
+ return;
159
+ }
160
+ const plan = planKill({ platform: process.platform, pid: child.pid });
161
+ try {
162
+ if (plan.kind === "signal") {
163
+ process.kill(plan.target, plan.signal);
164
+ } else {
165
+ spawn(plan.command, [...plan.args], { stdio: "ignore" });
166
+ }
167
+ } catch {
168
+ // Process already gone or group missing; nothing to do.
169
+ }
170
+ };
171
+
172
+ return {
173
+ start(): void {
174
+ for (const def of PROCESS_DEFS) {
175
+ spawnProcess(def.id);
176
+ }
177
+ },
178
+
179
+ restart(id: ProcessId): void {
180
+ killProcess(id);
181
+ // Respawn on the next tick so the old child's exit is processed first.
182
+ setTimeout(() => spawnProcess(id), 150);
183
+ },
184
+
185
+ statusOf(id: ProcessId): ProcessStatus {
186
+ return processes.get(id)?.status ?? "stopped";
187
+ },
188
+
189
+ onLine(listener: SupervisorEvents["line"]): void {
190
+ lineListeners.push(listener);
191
+ },
192
+
193
+ onStatus(listener: SupervisorEvents["status"]): void {
194
+ statusListeners.push(listener);
195
+ },
196
+
197
+ async shutdown(): Promise<void> {
198
+ // Only tear down the long-running children; leave docker deps (-d) up.
199
+ const longRunning = PROCESS_DEFS.filter((def) => !def.oneShot);
200
+ for (const def of longRunning) {
201
+ killProcess(def.id);
202
+ }
203
+ // Give children a moment to exit on SIGTERM, then force-kill survivors.
204
+ await new Promise<void>((resolve) => {
205
+ setTimeout(() => {
206
+ for (const def of longRunning) {
207
+ const proc = processes.get(def.id);
208
+ const child = proc?.child;
209
+ if (child?.pid !== undefined) {
210
+ const plan = planKill({
211
+ platform: process.platform,
212
+ pid: child.pid,
213
+ signal: "SIGKILL",
214
+ });
215
+ try {
216
+ if (plan.kind === "signal") {
217
+ process.kill(plan.target, plan.signal);
218
+ } else {
219
+ spawn(plan.command, [...plan.args], { stdio: "ignore" });
220
+ }
221
+ } catch {
222
+ // Already gone.
223
+ }
224
+ }
225
+ }
226
+ resolve();
227
+ }, 600);
228
+ });
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { stripAnsi, createLineSplitter } from "./text.ts";
3
+
4
+ const ESC = String.fromCharCode(27);
5
+
6
+ describe("stripAnsi", () => {
7
+ it("removes SGR color codes", () => {
8
+ expect(stripAnsi(`${ESC}[32minfo${ESC}[39m`)).toBe("info");
9
+ });
10
+
11
+ it("removes cursor and erase sequences", () => {
12
+ expect(stripAnsi(`${ESC}[2Khello${ESC}[1G`)).toBe("hello");
13
+ });
14
+
15
+ it("removes a two-character escape", () => {
16
+ expect(stripAnsi(`a${ESC}cb`)).toBe("ab");
17
+ });
18
+
19
+ it("leaves plain text untouched", () => {
20
+ expect(stripAnsi("no codes here")).toBe("no codes here");
21
+ });
22
+
23
+ it("handles an empty string", () => {
24
+ expect(stripAnsi("")).toBe("");
25
+ });
26
+ });
27
+
28
+ describe("createLineSplitter", () => {
29
+ it("splits a single chunk ending in a newline into one line", () => {
30
+ const split = createLineSplitter();
31
+ expect(split("hello\n")).toEqual(["hello"]);
32
+ });
33
+
34
+ it("buffers a partial line until the newline arrives", () => {
35
+ const split = createLineSplitter();
36
+ expect(split("hel")).toEqual([]);
37
+ expect(split("lo\n")).toEqual(["hello"]);
38
+ });
39
+
40
+ it("emits multiple complete lines from one chunk", () => {
41
+ const split = createLineSplitter();
42
+ expect(split("a\nb\nc\n")).toEqual(["a", "b", "c"]);
43
+ });
44
+
45
+ it("keeps the trailing partial line buffered across chunks", () => {
46
+ const split = createLineSplitter();
47
+ expect(split("a\nb")).toEqual(["a"]);
48
+ expect(split("c\n")).toEqual(["bc"]);
49
+ });
50
+
51
+ it("normalizes CRLF line endings", () => {
52
+ const split = createLineSplitter();
53
+ expect(split("a\r\nb\r\n")).toEqual(["a", "b"]);
54
+ });
55
+
56
+ it("strips a lone trailing carriage return inside a chunk", () => {
57
+ const split = createLineSplitter();
58
+ expect(split("progress\rdone\n")).toEqual(["progressdone"]);
59
+ });
60
+
61
+ it("flush() emits any buffered remainder without a newline", () => {
62
+ const split = createLineSplitter();
63
+ expect(split("dangling")).toEqual([]);
64
+ expect(split.flush()).toEqual(["dangling"]);
65
+ });
66
+
67
+ it("flush() emits nothing when the buffer is empty", () => {
68
+ const split = createLineSplitter();
69
+ expect(split("a\n")).toEqual(["a"]);
70
+ expect(split.flush()).toEqual([]);
71
+ });
72
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pure text helpers for the dev runner. DOM-free and dependency-free so they
3
+ * can be unit-tested with `bun:test` and reused by the (thin) ink components.
4
+ */
5
+
6
+ const ESC = String.fromCodePoint(27); // ASCII 27, start of every ANSI escape sequence.
7
+
8
+ /**
9
+ * Remove ANSI escape sequences from a string so downstream parsing (log-level
10
+ * detection, width measurement) sees plain text.
11
+ *
12
+ * Implemented as a small scanner rather than a control-character regex: it
13
+ * skips `ESC [ ... <final>` CSI sequences (colors, cursor moves, erases) and
14
+ * the shorter `ESC <byte>` two-character escapes, copying everything else
15
+ * verbatim.
16
+ */
17
+ export function stripAnsi(input: string): string {
18
+ if (!input.includes(ESC)) {
19
+ return input;
20
+ }
21
+
22
+ let result = "";
23
+ let index = 0;
24
+ while (index < input.length) {
25
+ const char = input[index];
26
+ if (char !== ESC) {
27
+ result += char;
28
+ index += 1;
29
+ continue;
30
+ }
31
+
32
+ const next = input[index + 1];
33
+ if (next === "[") {
34
+ // CSI sequence: parameters/intermediates until a final byte in 0x40..0x7E.
35
+ let cursor = index + 2;
36
+ while (cursor < input.length) {
37
+ const code = input.codePointAt(cursor) ?? 0;
38
+ cursor += 1;
39
+ if (code >= 0x40 && code <= 0x7E) {
40
+ // CSI final byte reached.
41
+ break;
42
+ }
43
+ }
44
+ index = cursor;
45
+ continue;
46
+ }
47
+
48
+ // Two-character escape (e.g. ESC c). Skip the ESC and the following byte.
49
+ index += next === undefined ? 1 : 2;
50
+ }
51
+
52
+ return result;
53
+ }
54
+
55
+ export interface LineSplitter {
56
+ (chunk: string): string[];
57
+ /** Emit any buffered remainder that never terminated with a newline. */
58
+ flush(): string[];
59
+ }
60
+
61
+ /**
62
+ * Create a stateful splitter that turns an arbitrary stream of chunks into
63
+ * whole lines. Partial lines are buffered across chunks and CRLF / lone CR are
64
+ * normalized away so terminal redraw sequences do not produce phantom lines.
65
+ */
66
+ export function createLineSplitter(): LineSplitter {
67
+ let buffer = "";
68
+
69
+ const split: LineSplitter = (chunk: string): string[] => {
70
+ buffer += chunk;
71
+ const lines: string[] = [];
72
+ let newlineIndex = buffer.indexOf("\n");
73
+ while (newlineIndex !== -1) {
74
+ const rawLine = buffer.slice(0, newlineIndex);
75
+ lines.push(normalizeLine(rawLine));
76
+ buffer = buffer.slice(newlineIndex + 1);
77
+ newlineIndex = buffer.indexOf("\n");
78
+ }
79
+ return lines;
80
+ };
81
+
82
+ split.flush = (): string[] => {
83
+ if (buffer.length === 0) {
84
+ return [];
85
+ }
86
+ const remainder = normalizeLine(buffer);
87
+ buffer = "";
88
+ return [remainder];
89
+ };
90
+
91
+ return split;
92
+ }
93
+
94
+ /**
95
+ * Drop carriage returns from a single (already newline-split) line. A trailing
96
+ * CR is the CRLF tail; an interior CR is a redraw cursor-return that we collapse
97
+ * so the visible text is preserved.
98
+ */
99
+ function normalizeLine(line: string): string {
100
+ return line.replaceAll("\r", "");
101
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import type { LogLevel } from "./log-level.ts";
3
+
4
+ /**
5
+ * Stable identifier for one supervised process. Used as the alert source tag
6
+ * and the sidebar key, so it must be unique per process.
7
+ */
8
+ export const processIdSchema = z.enum(["deps", "backend", "frontend"]);
9
+ export type ProcessId = z.infer<typeof processIdSchema>;
10
+
11
+ /**
12
+ * Lifecycle status of a supervised process, surfaced as a colored status dot.
13
+ */
14
+ export const processStatusSchema = z.enum([
15
+ "starting",
16
+ "ready",
17
+ "errored",
18
+ "stopped",
19
+ ]);
20
+ export type ProcessStatus = z.infer<typeof processStatusSchema>;
21
+
22
+ /** A single aggregated alert (a warn/error line from any process). */
23
+ export interface AlertEntry {
24
+ readonly source: ProcessId;
25
+ readonly level: LogLevel;
26
+ readonly text: string;
27
+ /** Monotonic sequence number used for stable newest-first ordering. */
28
+ readonly seq: number;
29
+ }
@@ -0,0 +1,22 @@
1
+ export {
2
+ rewriteWorkspaceVersions,
3
+ DEPENDENCY_SECTIONS,
4
+ type DependencySection,
5
+ type VersionResolver,
6
+ type RewritablePackageJson,
7
+ type RewriteResult,
8
+ } from "./rewrite-workspace-versions";
9
+ export { createWorkspaceMapResolver } from "./resolve-versions";
10
+ export {
11
+ scaffoldPlugin,
12
+ scaffoldStandaloneRoot,
13
+ refreshMonorepoReferences,
14
+ resolveTargetDir,
15
+ TEMPLATES_DIR,
16
+ STANDALONE_ROOT_TEMPLATE_DIR,
17
+ type ScaffoldMode,
18
+ type ScaffoldIo,
19
+ type ScaffoldPluginOptions,
20
+ type ScaffoldPluginResult,
21
+ type ScaffoldStandaloneRootResult,
22
+ } from "./scaffold-plugin";
@@ -0,0 +1,49 @@
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 { createWorkspaceMapResolver } from "./resolve-versions";
6
+
7
+ const tmpDirs: string[] = [];
8
+
9
+ function makeSiblingDir({ version }: { version: string }): string {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "resolve-versions-"));
11
+ tmpDirs.push(dir);
12
+ fs.writeFileSync(
13
+ path.join(dir, "package.json"),
14
+ JSON.stringify({ name: "@checkstack/common", version }),
15
+ );
16
+ return dir;
17
+ }
18
+
19
+ afterEach(() => {
20
+ for (const dir of tmpDirs.splice(0)) {
21
+ fs.rmSync(dir, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ describe("createWorkspaceMapResolver", () => {
26
+ it("resolves a workspace dep to a caret on the sibling's version", async () => {
27
+ const dir = makeSiblingDir({ version: "0.12.0" });
28
+ const resolve = createWorkspaceMapResolver({
29
+ workspaceMap: new Map([["@checkstack/common", dir]]),
30
+ });
31
+
32
+ expect(
33
+ await resolve({
34
+ packageName: "@checkstack/common",
35
+ workspaceRange: "workspace:*",
36
+ }),
37
+ ).toBe("^0.12.0");
38
+ });
39
+
40
+ it("returns undefined for a name not in the map", async () => {
41
+ const resolve = createWorkspaceMapResolver({ workspaceMap: new Map() });
42
+ expect(
43
+ await resolve({
44
+ packageName: "@checkstack/missing",
45
+ workspaceRange: "workspace:*",
46
+ }),
47
+ ).toBeUndefined();
48
+ });
49
+ });
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { VersionResolver } from "./rewrite-workspace-versions";
4
+
5
+ /**
6
+ * Version-resolution seam for {@link rewriteWorkspaceVersions}.
7
+ *
8
+ * The scaffolding engine and `plugin-pack` both need to turn a
9
+ * `workspace:*` range into a concrete one, but they source the concrete
10
+ * version differently:
11
+ *
12
+ * - In a monorepo, the version is read from the sibling package's own
13
+ * `package.json` on disk (a `name -> dir` workspace map). This is the
14
+ * behaviour `plugin-pack` has always had.
15
+ * - In a standalone scaffold (Phase 2), the version is resolved from the
16
+ * registry's `latest` dist-tag via `npm view`. That resolver lives in
17
+ * `create-checkstack-plugin`; the engine only sees the injected
18
+ * {@link VersionResolver} interface, so it stays registry-agnostic.
19
+ *
20
+ * Keeping the resolver injected (rather than hardcoded) is also what lets
21
+ * the integration test point it at a local Verdaccio registry without any
22
+ * network access.
23
+ */
24
+
25
+ function readJson<T>(filePath: string): T {
26
+ return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
27
+ }
28
+
29
+ /**
30
+ * Build a {@link VersionResolver} backed by a workspace `name -> dir` map.
31
+ * Each resolved range is a caret on the sibling's current version
32
+ * (`^<version>`), matching how `plugin-pack` has always rewritten and what
33
+ * the runtime compatibility checker's `semver.satisfies` expects.
34
+ *
35
+ * Returns `undefined` for names not present in the map; the caller decides
36
+ * whether an unresolved dep is fatal (`plugin-pack` throws, mirroring its
37
+ * previous behaviour).
38
+ */
39
+ export function createWorkspaceMapResolver({
40
+ workspaceMap,
41
+ }: {
42
+ workspaceMap: Map<string, string>;
43
+ }): VersionResolver {
44
+ // The map read is synchronous, but the shared VersionResolver seam is
45
+ // async (the standalone npm-view resolver needs it), so this resolver is
46
+ // declared `async` to satisfy the shared Promise-returning signature.
47
+ return async ({ packageName }) => {
48
+ const dir = workspaceMap.get(packageName);
49
+ if (!dir) return;
50
+ const sibling = readJson<{ version: string }>(
51
+ path.join(dir, "package.json"),
52
+ );
53
+ return `^${sibling.version}`;
54
+ };
55
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ rewriteWorkspaceVersions,
4
+ type RewritablePackageJson,
5
+ type VersionResolver,
6
+ } from "./rewrite-workspace-versions";
7
+
8
+ const constantResolver =
9
+ (version: string): VersionResolver =>
10
+ () =>
11
+ Promise.resolve(version);
12
+
13
+ describe("rewriteWorkspaceVersions", () => {
14
+ it("rewrites workspace ranges across all dependency sections", async () => {
15
+ const pkg: RewritablePackageJson = {
16
+ name: "@scope/widget-backend",
17
+ dependencies: {
18
+ "@checkstack/common": "workspace:*",
19
+ "@orpc/server": "^1.13.2",
20
+ },
21
+ devDependencies: {
22
+ "@checkstack/scripts": "workspace:^",
23
+ },
24
+ peerDependencies: {
25
+ "@checkstack/backend": "workspace:*",
26
+ },
27
+ };
28
+
29
+ const result = await rewriteWorkspaceVersions({
30
+ pkg,
31
+ resolveVersion: constantResolver("^1.2.3"),
32
+ });
33
+
34
+ expect(result.rewritten).toBe(true);
35
+ expect(result.unresolved).toEqual([]);
36
+ expect(pkg.dependencies).toEqual({
37
+ "@checkstack/common": "^1.2.3",
38
+ "@orpc/server": "^1.13.2",
39
+ });
40
+ expect(pkg.devDependencies).toEqual({ "@checkstack/scripts": "^1.2.3" });
41
+ expect(pkg.peerDependencies).toEqual({ "@checkstack/backend": "^1.2.3" });
42
+ });
43
+
44
+ it("leaves non-workspace ranges untouched and reports rewritten=false", async () => {
45
+ const pkg: RewritablePackageJson = {
46
+ name: "@scope/widget-backend",
47
+ dependencies: {
48
+ "@checkstack/common": "^0.12.0",
49
+ "drizzle-orm": "^0.45.1",
50
+ },
51
+ };
52
+
53
+ const result = await rewriteWorkspaceVersions({
54
+ pkg,
55
+ resolveVersion: constantResolver("^9.9.9"),
56
+ });
57
+
58
+ expect(result.rewritten).toBe(false);
59
+ expect(pkg.dependencies).toEqual({
60
+ "@checkstack/common": "^0.12.0",
61
+ "drizzle-orm": "^0.45.1",
62
+ });
63
+ });
64
+
65
+ it("records unresolved deps and does not mutate them", async () => {
66
+ const pkg: RewritablePackageJson = {
67
+ dependencies: {
68
+ "@checkstack/common": "workspace:*",
69
+ "@checkstack/missing": "workspace:*",
70
+ },
71
+ };
72
+ const resolver: VersionResolver = ({ packageName }) =>
73
+ Promise.resolve(
74
+ packageName === "@checkstack/common" ? "^1.0.0" : undefined,
75
+ );
76
+
77
+ const result = await rewriteWorkspaceVersions({
78
+ pkg,
79
+ resolveVersion: resolver,
80
+ });
81
+
82
+ expect(result.rewritten).toBe(true);
83
+ expect(result.unresolved).toEqual(["@checkstack/missing"]);
84
+ expect(pkg.dependencies?.["@checkstack/common"]).toBe("^1.0.0");
85
+ expect(pkg.dependencies?.["@checkstack/missing"]).toBe("workspace:*");
86
+ });
87
+
88
+ it("passes the original workspace range to the resolver", async () => {
89
+ const seen: string[] = [];
90
+ const pkg: RewritablePackageJson = {
91
+ dependencies: { "@checkstack/common": "workspace:^1.0.0" },
92
+ };
93
+ await rewriteWorkspaceVersions({
94
+ pkg,
95
+ resolveVersion: ({ workspaceRange }) => {
96
+ seen.push(workspaceRange);
97
+ return Promise.resolve("^1.0.0");
98
+ },
99
+ });
100
+ expect(seen).toEqual(["workspace:^1.0.0"]);
101
+ });
102
+ });