@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,111 @@
1
+ /**
2
+ * Shared `workspace:*` -> concrete-version rewriting.
3
+ *
4
+ * Two consumers use this:
5
+ * - `plugin-pack`, which rewrites a package's `workspace:*` deps to the
6
+ * sibling's actual version before packing a tarball (so the published
7
+ * artifact installs outside the monorepo).
8
+ * - the scaffolding engine, which rewrites a freshly rendered template's
9
+ * `workspace:*` deps to concrete published versions when generating a
10
+ * standalone (non-monorepo) plugin repo.
11
+ *
12
+ * Both share the same range-detection shape (`startsWith("workspace:")`),
13
+ * so it lives here once. The *source* of the concrete version differs by
14
+ * caller, so it is injected via a {@link VersionResolver}.
15
+ */
16
+
17
+ /** The dependency sections we rewrite, in a stable order. */
18
+ export const DEPENDENCY_SECTIONS = [
19
+ "dependencies",
20
+ "devDependencies",
21
+ "peerDependencies",
22
+ ] as const;
23
+
24
+ export type DependencySection = (typeof DEPENDENCY_SECTIONS)[number];
25
+
26
+ /**
27
+ * Resolves the concrete semver range to substitute for a `workspace:*`
28
+ * dependency. Implementations decide where the version comes from (a
29
+ * workspace sibling's `package.json`, an npm `latest` dist-tag, a test
30
+ * stub, ...). Returning `undefined` means "cannot resolve" and the caller
31
+ * decides whether that is fatal.
32
+ *
33
+ * The resolver is **async**: the standalone scaffolder's default resolver
34
+ * queries the registry (`npm view <pkg> version`) per dependency, and
35
+ * `@checkstack/*` versions are not lockstepped (0.x, resolved
36
+ * independently), so each lookup is its own network call that the engine
37
+ * fans out concurrently. The monorepo resolver is synchronous in spirit
38
+ * (it reads a sibling's `package.json` on disk) but returns a `Promise`
39
+ * to satisfy this single shared seam.
40
+ *
41
+ * @param packageName the dependency name, e.g. `@checkstack/common`.
42
+ * @param workspaceRange the original range, e.g. `workspace:*`.
43
+ */
44
+ export type VersionResolver = (args: {
45
+ packageName: string;
46
+ workspaceRange: string;
47
+ }) => Promise<string | undefined>;
48
+
49
+ /** A `package.json` shape exposing the dependency sections we rewrite. */
50
+ export type RewritablePackageJson = {
51
+ name?: string;
52
+ } & Partial<Record<DependencySection, Record<string, string>>>;
53
+
54
+ export interface RewriteResult {
55
+ /** Whether any range was changed. */
56
+ rewritten: boolean;
57
+ /** Dependency names whose range began with `workspace:` but resolved to nothing. */
58
+ unresolved: string[];
59
+ }
60
+
61
+ /**
62
+ * Rewrite every `workspace:`-prefixed range in the given package's
63
+ * dependency sections, mutating the package object in place.
64
+ *
65
+ * The resolver is invoked once per `workspace:` dep, and all invocations
66
+ * are fanned out concurrently (the standalone resolver hits the registry
67
+ * per package, so serializing them would be needlessly slow). When a
68
+ * resolver returns `undefined`, the range is left untouched and the name
69
+ * is recorded in {@link RewriteResult.unresolved} so the caller can fail
70
+ * loudly.
71
+ */
72
+ export async function rewriteWorkspaceVersions({
73
+ pkg,
74
+ resolveVersion,
75
+ }: {
76
+ pkg: RewritablePackageJson;
77
+ resolveVersion: VersionResolver;
78
+ }): Promise<RewriteResult> {
79
+ const targets: { section: DependencySection; packageName: string }[] = [];
80
+ for (const section of DEPENDENCY_SECTIONS) {
81
+ const block = pkg[section];
82
+ if (!block) continue;
83
+ for (const [packageName, range] of Object.entries(block)) {
84
+ if (!range.startsWith("workspace:")) continue;
85
+ targets.push({ section, packageName });
86
+ }
87
+ }
88
+
89
+ const resolutions = await Promise.all(
90
+ targets.map(async ({ section, packageName }) => {
91
+ const workspaceRange = pkg[section]?.[packageName] ?? "";
92
+ const resolved = await resolveVersion({ packageName, workspaceRange });
93
+ return { section, packageName, resolved };
94
+ }),
95
+ );
96
+
97
+ let rewritten = false;
98
+ const unresolved: string[] = [];
99
+ for (const { section, packageName, resolved } of resolutions) {
100
+ if (resolved === undefined) {
101
+ unresolved.push(packageName);
102
+ continue;
103
+ }
104
+ const block = pkg[section];
105
+ if (!block) continue;
106
+ block[packageName] = resolved;
107
+ rewritten = true;
108
+ }
109
+
110
+ return { rewritten, unresolved };
111
+ }
@@ -0,0 +1,209 @@
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
+ scaffoldPlugin,
7
+ refreshMonorepoReferences,
8
+ resolveTargetDir,
9
+ type ScaffoldIo,
10
+ } from "./scaffold-plugin";
11
+ import type { VersionResolver } from "./rewrite-workspace-versions";
12
+ import type { copyTemplate } from "../utils/template";
13
+
14
+ const tmpDirs: string[] = [];
15
+
16
+ function makeTmpDir(): string {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scaffold-engine-"));
18
+ tmpDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const dir of tmpDirs.splice(0)) {
24
+ fs.rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ const constantResolver =
29
+ (version: string): VersionResolver =>
30
+ () =>
31
+ Promise.resolve(version);
32
+
33
+ /** Records the copyTemplate args and writes nothing real. */
34
+ function recordingCopyTemplate(): {
35
+ spy: typeof copyTemplate;
36
+ calls: { templateDir: string; targetDir: string }[];
37
+ } {
38
+ const calls: { templateDir: string; targetDir: string }[] = [];
39
+ const spy: typeof copyTemplate = ({ templateDir, targetDir }) => {
40
+ calls.push({ templateDir, targetDir });
41
+ return [];
42
+ };
43
+ return { spy, calls };
44
+ }
45
+
46
+ describe("resolveTargetDir", () => {
47
+ it("uses <rootDir>/<location>/<name> in monorepo mode", () => {
48
+ expect(
49
+ resolveTargetDir({
50
+ mode: { kind: "monorepo", rootDir: "/repo", location: "plugins" },
51
+ pluginName: "widget-backend",
52
+ }),
53
+ ).toBe(path.join("/repo", "plugins", "widget-backend"));
54
+ });
55
+
56
+ it("uses <targetDir>/<name> in standalone mode", () => {
57
+ expect(
58
+ resolveTargetDir({
59
+ mode: { kind: "standalone", targetDir: "/out" },
60
+ pluginName: "widget-backend",
61
+ }),
62
+ ).toBe(path.join("/out", "widget-backend"));
63
+ });
64
+ });
65
+
66
+ describe("scaffoldPlugin — mode parameterization", () => {
67
+ it("renders the matching template dir into the monorepo target", async () => {
68
+ const { spy, calls } = recordingCopyTemplate();
69
+ const io: Partial<ScaffoldIo> = { copyTemplate: spy };
70
+
71
+ const result = await scaffoldPlugin({
72
+ mode: { kind: "monorepo", rootDir: "/repo", location: "core" },
73
+ baseName: "widget",
74
+ description: "Widget plugin",
75
+ pluginType: "backend",
76
+ io,
77
+ });
78
+
79
+ expect(calls).toHaveLength(1);
80
+ expect(calls[0].templateDir).toEndWith(path.join("templates", "backend"));
81
+ expect(calls[0].targetDir).toBe(
82
+ path.join("/repo", "core", "widget-backend"),
83
+ );
84
+ expect(result.templateData.pluginName).toBe("widget-backend");
85
+ expect(result.templateData.pluginId).toBe("widget-backend");
86
+ });
87
+
88
+ it("does not rewrite versions in monorepo mode", async () => {
89
+ const targetRoot = makeTmpDir();
90
+ // Real render so a package.json with workspace:* lands on disk.
91
+ await scaffoldPlugin({
92
+ mode: { kind: "monorepo", rootDir: targetRoot, location: "core" },
93
+ baseName: "widget",
94
+ description: "Widget plugin",
95
+ pluginType: "common",
96
+ });
97
+ const pkg = JSON.parse(
98
+ fs.readFileSync(
99
+ path.join(targetRoot, "core", "widget-common", "package.json"),
100
+ "utf8",
101
+ ),
102
+ ) as { dependencies?: Record<string, string> };
103
+ const ranges = Object.values(pkg.dependencies ?? {});
104
+ expect(ranges.some((r) => r.startsWith("workspace:"))).toBe(true);
105
+ });
106
+
107
+ it("throws in standalone mode when no resolveVersion is supplied", async () => {
108
+ const { spy } = recordingCopyTemplate();
109
+ await expect(
110
+ scaffoldPlugin({
111
+ mode: { kind: "standalone", targetDir: makeTmpDir() },
112
+ baseName: "widget",
113
+ description: "Widget plugin",
114
+ pluginType: "backend",
115
+ io: { copyTemplate: spy },
116
+ }),
117
+ ).rejects.toThrow(/standalone mode requires a `resolveVersion`/);
118
+ });
119
+
120
+ it("rewrites every workspace range to a concrete version in standalone mode", async () => {
121
+ const out = makeTmpDir();
122
+ await scaffoldPlugin({
123
+ mode: { kind: "standalone", targetDir: out },
124
+ baseName: "widget",
125
+ description: "Widget plugin",
126
+ pluginType: "backend",
127
+ resolveVersion: constantResolver("^1.2.3"),
128
+ });
129
+
130
+ const pkg = JSON.parse(
131
+ fs.readFileSync(
132
+ path.join(out, "widget-backend", "package.json"),
133
+ "utf8",
134
+ ),
135
+ ) as Record<string, Record<string, string> | undefined>;
136
+
137
+ const allRanges = [
138
+ ...Object.values(pkg.dependencies ?? {}),
139
+ ...Object.values(pkg.devDependencies ?? {}),
140
+ ...Object.values(pkg.peerDependencies ?? {}),
141
+ ];
142
+ expect(allRanges.some((r) => r.startsWith("workspace:"))).toBe(false);
143
+ // Every @checkstack/* dep is now the concrete resolved version.
144
+ expect(pkg.dependencies?.["@checkstack/common"]).toBe("^1.2.3");
145
+ expect(pkg.devDependencies?.["@checkstack/scripts"]).toBe("^1.2.3");
146
+ });
147
+
148
+ it("reports the rewrite through the io.log seam in standalone mode", async () => {
149
+ const out = makeTmpDir();
150
+ const logs: string[] = [];
151
+ await scaffoldPlugin({
152
+ mode: { kind: "standalone", targetDir: out },
153
+ baseName: "widget",
154
+ description: "Widget plugin",
155
+ pluginType: "backend",
156
+ resolveVersion: constantResolver("^1.2.3"),
157
+ io: { log: (m) => logs.push(m) },
158
+ });
159
+ expect(logs.some((m) => m.includes("Resolved workspace versions"))).toBe(
160
+ true,
161
+ );
162
+ });
163
+
164
+ it("fails loudly if a workspace dep cannot be resolved in standalone mode", async () => {
165
+ const out = makeTmpDir();
166
+ const failing: VersionResolver = () => Promise.resolve(undefined);
167
+ await expect(
168
+ scaffoldPlugin({
169
+ mode: { kind: "standalone", targetDir: out },
170
+ baseName: "widget",
171
+ description: "Widget plugin",
172
+ pluginType: "backend",
173
+ resolveVersion: failing,
174
+ }),
175
+ ).rejects.toThrow(/must not emit 'workspace:\*'/);
176
+ });
177
+ });
178
+
179
+ describe("refreshMonorepoReferences — gated by mode", () => {
180
+ it("invokes the refresh in monorepo mode", () => {
181
+ let called = 0;
182
+ const status = refreshMonorepoReferences({
183
+ mode: { kind: "monorepo", rootDir: "/repo", location: "core" },
184
+ io: {
185
+ refreshReferences: () => {
186
+ called += 1;
187
+ return 0;
188
+ },
189
+ },
190
+ });
191
+ expect(called).toBe(1);
192
+ expect(status).toBe(0);
193
+ });
194
+
195
+ it("is a no-op in standalone mode", () => {
196
+ let called = 0;
197
+ const status = refreshMonorepoReferences({
198
+ mode: { kind: "standalone", targetDir: "/out" },
199
+ io: {
200
+ refreshReferences: () => {
201
+ called += 1;
202
+ return 0;
203
+ },
204
+ },
205
+ });
206
+ expect(called).toBe(0);
207
+ expect(status).toBe(0);
208
+ });
209
+ });
@@ -0,0 +1,309 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ copyTemplate,
7
+ prepareTemplateData,
8
+ registerHelpers,
9
+ type TemplateData,
10
+ } from "../utils/template";
11
+ import {
12
+ rewriteWorkspaceVersions,
13
+ type VersionResolver,
14
+ } from "./rewrite-workspace-versions";
15
+
16
+ /**
17
+ * Monorepo-decoupled scaffolding engine.
18
+ *
19
+ * The engine takes a {@link ScaffoldMode} plus a base name / description /
20
+ * package type and renders the matching `templates/<type>` directory into a
21
+ * target directory. It performs **no** `process.cwd()` reads of its own —
22
+ * the caller supplies `rootDir` (monorepo) or `targetDir` (standalone), so
23
+ * the same code runs both in-monorepo (`create`) and from a standalone
24
+ * bootstrapper (`create-checkstack-plugin`, Phase 2).
25
+ *
26
+ * Mode-specific behaviour:
27
+ * - `monorepo`: writes into `<rootDir>/<location>/<name>`, keeps the
28
+ * template's `workspace:*` ranges verbatim, and refreshes the root
29
+ * TypeScript project references afterwards.
30
+ * - `standalone`: writes into `<targetDir>/<name>` (or a caller-chosen
31
+ * layout) and rewrites every `workspace:*` range to a concrete version
32
+ * via the injected {@link VersionResolver}. No references refresh —
33
+ * standalone repos don't use the root references graph.
34
+ */
35
+
36
+ const SCAFFOLD_DIR = path.dirname(fileURLToPath(import.meta.url));
37
+
38
+ /** Directory holding the per-type template trees. */
39
+ export const TEMPLATES_DIR = path.resolve(SCAFFOLD_DIR, "..", "templates");
40
+
41
+ export type ScaffoldMode =
42
+ | { kind: "monorepo"; rootDir: string; location: "core" | "plugins" }
43
+ | { kind: "standalone"; targetDir: string };
44
+
45
+ /** IO seam so tests can inject fakes for fs/spawn/logging. */
46
+ export interface ScaffoldIo {
47
+ /**
48
+ * Render a template directory into a target directory. Defaults to the
49
+ * real Handlebars-backed {@link copyTemplate}.
50
+ */
51
+ copyTemplate: typeof copyTemplate;
52
+ /**
53
+ * Refresh the monorepo's TypeScript project references. Only invoked in
54
+ * `monorepo` mode. Returns the exit status (0 = success).
55
+ */
56
+ refreshReferences: () => number;
57
+ log: (message: string) => void;
58
+ warn: (message: string) => void;
59
+ }
60
+
61
+ export interface ScaffoldPluginOptions {
62
+ mode: ScaffoldMode;
63
+ /** e.g. "widget" — combined with the type to form "widget-backend". */
64
+ baseName: string;
65
+ description: string;
66
+ pluginType: string;
67
+ /**
68
+ * npm scope (without `@`) for the generated package. Defaults to
69
+ * `checkstack` (the monorepo scope, so the in-monorepo `create` path is
70
+ * unchanged); pass `""` for an unscoped standalone package.
71
+ */
72
+ packageScope?: string;
73
+ /**
74
+ * Resolve a concrete version for an `@checkstack/*` `workspace:*` dep.
75
+ * Required in `standalone` mode; ignored in `monorepo` mode (templates
76
+ * keep their `workspace:*` ranges so the workspace resolves them).
77
+ */
78
+ resolveVersion?: VersionResolver;
79
+ /** Override the IO seam (tests). Defaults to the real filesystem. */
80
+ io?: Partial<ScaffoldIo>;
81
+ }
82
+
83
+ export interface ScaffoldPluginResult {
84
+ /** The directory the package was written into. */
85
+ targetDir: string;
86
+ /** Absolute paths of every file written. */
87
+ createdFiles: string[];
88
+ templateData: TemplateData;
89
+ }
90
+
91
+ /** Default IO seam: real filesystem + a root references refresh via bun. */
92
+ function defaultIo(): ScaffoldIo {
93
+ return {
94
+ copyTemplate,
95
+ refreshReferences: () => {
96
+ const result = spawnSync(
97
+ "bun",
98
+ ["run", "typecheck:references:generate"],
99
+ { stdio: "inherit" },
100
+ );
101
+ return result.status ?? 0;
102
+ },
103
+ log: (message) => {
104
+ console.log(message);
105
+ },
106
+ warn: (message) => {
107
+ console.warn(message);
108
+ },
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Resolve the on-disk target directory for a given mode + plugin name.
114
+ *
115
+ * `monorepo` mirrors the historical `create` layout
116
+ * (`<rootDir>/<location>/<name>`); `standalone` writes `<targetDir>/<name>`.
117
+ */
118
+ export function resolveTargetDir({
119
+ mode,
120
+ pluginName,
121
+ }: {
122
+ mode: ScaffoldMode;
123
+ pluginName: string;
124
+ }): string {
125
+ return mode.kind === "monorepo"
126
+ ? path.join(mode.rootDir, mode.location, pluginName)
127
+ : path.join(mode.targetDir, pluginName);
128
+ }
129
+
130
+ /**
131
+ * Read, rewrite, and write back the `workspace:*` ranges in a rendered
132
+ * `package.json`. Throws (listing the offending names) if any
133
+ * `workspace:` range cannot be resolved — never silently emits
134
+ * `workspace:*`, which the runtime install-time validator rejects.
135
+ */
136
+ async function rewriteRenderedPackageJson({
137
+ targetDir,
138
+ resolveVersion,
139
+ io,
140
+ }: {
141
+ targetDir: string;
142
+ resolveVersion: VersionResolver;
143
+ io: ScaffoldIo;
144
+ }): Promise<void> {
145
+ const pkgJsonPath = path.join(targetDir, "package.json");
146
+ if (!fs.existsSync(pkgJsonPath)) {
147
+ io.warn(
148
+ `No package.json found in ${targetDir}; skipped version rewriting.`,
149
+ );
150
+ return;
151
+ }
152
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as Record<
153
+ string,
154
+ unknown
155
+ >;
156
+ const { rewritten, unresolved } = await rewriteWorkspaceVersions({
157
+ pkg,
158
+ resolveVersion,
159
+ });
160
+ if (unresolved.length > 0) {
161
+ throw new Error(
162
+ `Could not resolve concrete versions for ${unresolved.length} ` +
163
+ `workspace dependenc${unresolved.length === 1 ? "y" : "ies"}: ` +
164
+ `${unresolved.join(", ")}. ` +
165
+ `A standalone scaffold must not emit 'workspace:*' ranges.`,
166
+ );
167
+ }
168
+ if (rewritten) {
169
+ fs.writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, undefined, 2)}\n`);
170
+ io.log(`Resolved workspace versions in ${path.basename(targetDir)}.`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Render one plugin package type into the mode-appropriate target dir.
176
+ *
177
+ * Behaviour-preserving for the in-monorepo `create` command: same target
178
+ * layout, same `copyTemplate`, same references refresh. Standalone mode
179
+ * additionally rewrites `workspace:*` ranges to concrete versions.
180
+ */
181
+ export async function scaffoldPlugin({
182
+ mode,
183
+ baseName,
184
+ description,
185
+ pluginType,
186
+ packageScope = "checkstack",
187
+ resolveVersion,
188
+ io,
189
+ }: ScaffoldPluginOptions): Promise<ScaffoldPluginResult> {
190
+ registerHelpers();
191
+
192
+ const resolvedIo: ScaffoldIo = { ...defaultIo(), ...io };
193
+
194
+ const templateData = prepareTemplateData({
195
+ baseName,
196
+ pluginType,
197
+ description,
198
+ packageScope,
199
+ });
200
+
201
+ const templateDir = path.join(TEMPLATES_DIR, pluginType);
202
+ const targetDir = resolveTargetDir({
203
+ mode,
204
+ pluginName: templateData.pluginName,
205
+ });
206
+
207
+ const createdFiles = resolvedIo.copyTemplate({
208
+ templateDir,
209
+ targetDir,
210
+ data: templateData,
211
+ });
212
+
213
+ if (mode.kind === "standalone") {
214
+ if (!resolveVersion) {
215
+ throw new Error(
216
+ "scaffoldPlugin in standalone mode requires a `resolveVersion` " +
217
+ "resolver to rewrite workspace ranges to concrete versions.",
218
+ );
219
+ }
220
+ await rewriteRenderedPackageJson({
221
+ targetDir,
222
+ resolveVersion,
223
+ io: resolvedIo,
224
+ });
225
+ }
226
+
227
+ return { targetDir, createdFiles, templateData };
228
+ }
229
+
230
+ /**
231
+ * Directory holding the standalone-only root workspace templates (root
232
+ * `package.json`, tsconfig, eslint, README, `.gitignore`, changeset
233
+ * config). Rendered only by {@link scaffoldStandaloneRoot}.
234
+ */
235
+ export const STANDALONE_ROOT_TEMPLATE_DIR = path.join(
236
+ TEMPLATES_DIR,
237
+ "standalone-root",
238
+ );
239
+
240
+ export interface ScaffoldStandaloneRootResult {
241
+ /** The repo root directory the workspace files were written into. */
242
+ rootDir: string;
243
+ createdFiles: string[];
244
+ templateData: TemplateData;
245
+ }
246
+
247
+ /**
248
+ * Render the standalone repo's root workspace files into `rootDir`.
249
+ *
250
+ * This is the wrapper around the per-type trio that turns three loose
251
+ * packages into an installable local Bun workspace: a private root
252
+ * `package.json` with `workspaces: ["packages/*"]` and forwarding scripts
253
+ * (`dev`/`pack`/`typecheck`/`lint`/`test`), a root tsconfig, a
254
+ * self-contained eslint config, a `.gitignore`, a quickstart README, and a
255
+ * changeset config + initial changeset. Standalone-only: the in-monorepo
256
+ * `create` command never calls this.
257
+ */
258
+ export function scaffoldStandaloneRoot({
259
+ rootDir,
260
+ baseName,
261
+ description,
262
+ packageScope = "checkstack",
263
+ io,
264
+ }: {
265
+ rootDir: string;
266
+ baseName: string;
267
+ description: string;
268
+ packageScope?: string;
269
+ io?: Partial<ScaffoldIo>;
270
+ }): ScaffoldStandaloneRootResult {
271
+ registerHelpers();
272
+ const resolvedIo: ScaffoldIo = { ...defaultIo(), ...io };
273
+
274
+ // The root files describe the workspace as a whole, not one plugin type;
275
+ // we reuse prepareTemplateData with a sentinel "plugin" type so the
276
+ // pascal/camel/id derivations are available to the root templates.
277
+ const templateData = prepareTemplateData({
278
+ baseName,
279
+ pluginType: "plugin",
280
+ description,
281
+ packageScope,
282
+ });
283
+
284
+ const createdFiles = resolvedIo.copyTemplate({
285
+ templateDir: STANDALONE_ROOT_TEMPLATE_DIR,
286
+ targetDir: rootDir,
287
+ data: templateData,
288
+ });
289
+
290
+ return { rootDir, createdFiles, templateData };
291
+ }
292
+
293
+ /**
294
+ * Refresh the monorepo's TypeScript project references. No-op outside
295
+ * `monorepo` mode (standalone repos do not use the root references graph).
296
+ * Returns the exit status; non-zero means the refresh failed and the
297
+ * caller should warn the user to run it manually.
298
+ */
299
+ export function refreshMonorepoReferences({
300
+ mode,
301
+ io,
302
+ }: {
303
+ mode: ScaffoldMode;
304
+ io?: Partial<ScaffoldIo>;
305
+ }): number {
306
+ if (mode.kind !== "monorepo") return 0;
307
+ const resolvedIo: ScaffoldIo = { ...defaultIo(), ...io };
308
+ return resolvedIo.refreshReferences();
309
+ }
@@ -1,4 +1,4 @@
1
- --- "@checkstack/{{pluginName}}": patch --- Initial release of
1
+ --- "{{scoped pluginName}}": patch --- Initial release of
2
2
  {{pluginNamePascal}}
3
3
  backend plugin
4
4
 
@@ -0,0 +1,7 @@
1
+ CREATE TABLE "items" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "name" text NOT NULL,
4
+ "description" text,
5
+ "created_at" timestamp DEFAULT now() NOT NULL,
6
+ "updated_at" timestamp DEFAULT now() NOT NULL
7
+ );