@checkstack/scripts 0.3.4 → 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.
- package/package.json +15 -5
- package/src/commands/create.ts +16 -23
- package/src/commands/plugin-pack.ts +17 -28
- package/src/dev-tui/App.render.test.tsx +135 -0
- package/src/dev-tui/App.smoke.test.tsx +142 -0
- package/src/dev-tui/App.tsx +522 -0
- package/src/dev-tui/alert-buffer.test.ts +62 -0
- package/src/dev-tui/alert-buffer.ts +51 -0
- package/src/dev-tui/alt-screen.test.ts +66 -0
- package/src/dev-tui/alt-screen.ts +65 -0
- package/src/dev-tui/cli.tsx +89 -0
- package/src/dev-tui/fake-supervisor.ts +76 -0
- package/src/dev-tui/graceful-shutdown.test.ts +61 -0
- package/src/dev-tui/graceful-shutdown.ts +32 -0
- package/src/dev-tui/kill-tree.test.ts +47 -0
- package/src/dev-tui/kill-tree.ts +64 -0
- package/src/dev-tui/layout.test.ts +89 -0
- package/src/dev-tui/layout.ts +126 -0
- package/src/dev-tui/log-level.test.ts +94 -0
- package/src/dev-tui/log-level.ts +104 -0
- package/src/dev-tui/plain-runner.ts +60 -0
- package/src/dev-tui/process-config.test.ts +42 -0
- package/src/dev-tui/process-config.ts +61 -0
- package/src/dev-tui/readiness.test.ts +54 -0
- package/src/dev-tui/readiness.ts +44 -0
- package/src/dev-tui/scrollback.test.ts +83 -0
- package/src/dev-tui/scrollback.ts +82 -0
- package/src/dev-tui/supervisor.ts +231 -0
- package/src/dev-tui/text.test.ts +72 -0
- package/src/dev-tui/text.ts +101 -0
- package/src/dev-tui/types.ts +29 -0
- package/src/scaffold/index.ts +22 -0
- package/src/scaffold/resolve-versions.test.ts +49 -0
- package/src/scaffold/resolve-versions.ts +55 -0
- package/src/scaffold/rewrite-workspace-versions.test.ts +102 -0
- package/src/scaffold/rewrite-workspace-versions.ts +111 -0
- package/src/scaffold/scaffold-plugin.test.ts +209 -0
- package/src/scaffold/scaffold-plugin.ts +309 -0
- package/src/templates/backend/.changeset/initial.md.hbs +1 -1
- package/src/templates/backend/drizzle/0000_init.sql +7 -0
- package/src/templates/backend/drizzle/meta/0000_snapshot.json +65 -0
- package/src/templates/backend/drizzle/meta/_journal.json +13 -0
- package/src/templates/backend/drizzle.config.ts.hbs +5 -1
- package/src/templates/backend/package.json.hbs +7 -3
- package/src/templates/backend/src/index.ts.hbs +1 -1
- package/src/templates/backend/src/router.ts.hbs +1 -1
- package/src/templates/backend/src/service.ts.hbs +1 -1
- package/src/templates/common/.changeset/initial.md.hbs +1 -1
- package/src/templates/common/README.md.hbs +28 -11
- package/src/templates/common/package.json.hbs +1 -1
- package/src/templates/common/src/plugin-metadata.ts.hbs +1 -1
- package/src/templates/frontend/.changeset/initial.md.hbs +1 -1
- package/src/templates/frontend/package.json.hbs +2 -2
- package/src/templates/frontend/src/api.ts.hbs +2 -2
- package/src/templates/frontend/src/components/{{pluginNamePascal}}ListPage.tsx.hbs +1 -1
- package/src/templates/frontend/src/index.tsx.hbs +10 -4
- package/src/templates/standalone-root/.changeset/config.json.hbs +11 -0
- package/src/templates/standalone-root/.changeset/initial.md.hbs +9 -0
- package/src/templates/standalone-root/README.md.hbs +75 -0
- package/src/templates/standalone-root/eslint.config.mjs.hbs +37 -0
- package/src/templates/standalone-root/package.json.hbs +27 -0
- package/src/templates/standalone-root/tsconfig.json.hbs +13 -0
- package/src/templates.test.ts +20 -0
- package/src/tui/components.test.tsx +28 -0
- package/src/tui/components.tsx +159 -0
- package/src/tui/index.ts +31 -0
- package/src/tui/theme.test.ts +54 -0
- package/src/tui/theme.ts +60 -0
- 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
|
+
}
|