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.
- package/package.json +33 -0
- package/src/cli-args.test.ts +72 -0
- package/src/cli-args.ts +130 -0
- package/src/cli.ts +177 -0
- package/src/external-plugin-lifecycle.it.test.ts +478 -0
- package/src/git.ts +39 -0
- package/src/local-registry.test.ts +143 -0
- package/src/local-registry.ts +313 -0
- package/src/npm-view-resolver.test.ts +140 -0
- package/src/npm-view-resolver.ts +140 -0
- package/src/scaffold-standalone.test.ts +211 -0
- package/src/scaffold-standalone.ts +104 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-checkstack-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a standalone Checkstack plugin workspace (common + backend + frontend) with concrete published versions, ready to `bun install && bun run dev`.",
|
|
5
|
+
"license": "Elastic-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"checkstack": {
|
|
8
|
+
"type": "tooling"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"create-checkstack-plugin": "./src/cli.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsgo -b"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@checkstack/scripts": "0.3.4",
|
|
25
|
+
"inquirer": "^13.4.1",
|
|
26
|
+
"zod": "^4.2.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
30
|
+
"@types/inquirer": "^8.2.10",
|
|
31
|
+
"typescript": "^5.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { parseCliArgs, validateBaseName, validateScope } from "./cli-args";
|
|
3
|
+
|
|
4
|
+
describe("parseCliArgs", () => {
|
|
5
|
+
it("defaults: latest tag, git on, interactive", () => {
|
|
6
|
+
const args = parseCliArgs({ argv: [] });
|
|
7
|
+
expect(args.versionTag).toBe("latest");
|
|
8
|
+
expect(args.git).toBe(true);
|
|
9
|
+
expect(args.yes).toBe(false);
|
|
10
|
+
expect(args.targetDir).toBeUndefined();
|
|
11
|
+
expect(args.scope).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("captures the positional target dir", () => {
|
|
15
|
+
expect(parseCliArgs({ argv: ["my-widget"] }).targetDir).toBe("my-widget");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("parses --scope / --no-scope", () => {
|
|
19
|
+
expect(parseCliArgs({ argv: ["--scope", "acme"] }).scope).toBe("acme");
|
|
20
|
+
expect(parseCliArgs({ argv: ["--no-scope"] }).scope).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses --version-tag, --no-git, --yes", () => {
|
|
24
|
+
const args = parseCliArgs({
|
|
25
|
+
argv: ["w", "--version-tag", "next", "--no-git", "--yes"],
|
|
26
|
+
});
|
|
27
|
+
expect(args.versionTag).toBe("next");
|
|
28
|
+
expect(args.git).toBe(false);
|
|
29
|
+
expect(args.yes).toBe(true);
|
|
30
|
+
expect(args.targetDir).toBe("w");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("registry flag overrides the env fallback", () => {
|
|
34
|
+
const args = parseCliArgs({
|
|
35
|
+
argv: ["--registry", "http://localhost:4873"],
|
|
36
|
+
env: { CHECKSTACK_SCAFFOLD_REGISTRY: "http://env:1111" },
|
|
37
|
+
});
|
|
38
|
+
expect(args.registry).toBe("http://localhost:4873");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to CHECKSTACK_SCAFFOLD_REGISTRY when no flag given", () => {
|
|
42
|
+
const args = parseCliArgs({
|
|
43
|
+
argv: [],
|
|
44
|
+
env: { CHECKSTACK_SCAFFOLD_REGISTRY: "http://env:1111" },
|
|
45
|
+
});
|
|
46
|
+
expect(args.registry).toBe("http://env:1111");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("validateBaseName", () => {
|
|
51
|
+
it("accepts a kebab base name", () => {
|
|
52
|
+
expect(validateBaseName("my-widget").valid).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("rejects uppercase, reserved, and bad shapes", () => {
|
|
55
|
+
expect(validateBaseName("Widget").valid).toBe(false);
|
|
56
|
+
expect(validateBaseName("common").valid).toBe(false);
|
|
57
|
+
expect(validateBaseName("-x").valid).toBe(false);
|
|
58
|
+
expect(validateBaseName("a--b").valid).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("validateScope", () => {
|
|
63
|
+
it("accepts empty (unscoped) and valid scopes", () => {
|
|
64
|
+
expect(validateScope("").valid).toBe(true);
|
|
65
|
+
expect(validateScope("acme").valid).toBe(true);
|
|
66
|
+
expect(validateScope("my-org").valid).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("rejects a leading @ or uppercase", () => {
|
|
69
|
+
expect(validateScope("@acme").valid).toBe(false);
|
|
70
|
+
expect(validateScope("Acme").valid).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/cli-args.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument parsing for the `create-checkstack-plugin` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Kept pure (no IO) so it can be unit-tested. Supports:
|
|
5
|
+
* - a positional target directory (the repo to create);
|
|
6
|
+
* - `--scope <name>` / `--no-scope` to control the npm scope;
|
|
7
|
+
* - `--version-tag <tag>` (default `latest`) for the dist-tag to resolve;
|
|
8
|
+
* - `--registry <url>` (env fallback `CHECKSTACK_SCAFFOLD_REGISTRY`);
|
|
9
|
+
* - `--no-git` to skip `git init`;
|
|
10
|
+
* - `--yes` to accept defaults non-interactively (used by the e2e test).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface CliArgs {
|
|
14
|
+
/** Positional target directory, if given. */
|
|
15
|
+
targetDir?: string;
|
|
16
|
+
/** Explicit scope (without `@`), or "" if `--no-scope`, or undefined. */
|
|
17
|
+
scope?: string;
|
|
18
|
+
versionTag: string;
|
|
19
|
+
registry?: string;
|
|
20
|
+
git: boolean;
|
|
21
|
+
/** Skip prompts and use defaults / provided flags. */
|
|
22
|
+
yes: boolean;
|
|
23
|
+
help: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseCliArgs({
|
|
27
|
+
argv,
|
|
28
|
+
env = {},
|
|
29
|
+
}: {
|
|
30
|
+
argv: string[];
|
|
31
|
+
env?: Record<string, string | undefined>;
|
|
32
|
+
}): CliArgs {
|
|
33
|
+
const args: CliArgs = {
|
|
34
|
+
versionTag: "latest",
|
|
35
|
+
registry: env.CHECKSTACK_SCAFFOLD_REGISTRY,
|
|
36
|
+
git: true,
|
|
37
|
+
yes: false,
|
|
38
|
+
help: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
switch (a) {
|
|
44
|
+
case "--scope": {
|
|
45
|
+
args.scope = argv[++i];
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "--no-scope": {
|
|
49
|
+
args.scope = "";
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case "--version-tag": {
|
|
53
|
+
args.versionTag = argv[++i];
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case "--registry": {
|
|
57
|
+
args.registry = argv[++i];
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case "--no-git": {
|
|
61
|
+
args.git = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "--yes":
|
|
65
|
+
case "-y": {
|
|
66
|
+
args.yes = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "--help":
|
|
70
|
+
case "-h": {
|
|
71
|
+
args.help = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
default: {
|
|
75
|
+
if (!a.startsWith("-") && args.targetDir === undefined) {
|
|
76
|
+
args.targetDir = a;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a base plugin name (lowercase, hyphen-separated, no reserved
|
|
88
|
+
* words). Mirrors the monorepo `validatePluginName` rules.
|
|
89
|
+
*/
|
|
90
|
+
export function validateBaseName(name: string): {
|
|
91
|
+
valid: boolean;
|
|
92
|
+
error?: string;
|
|
93
|
+
} {
|
|
94
|
+
if (!name || name.trim().length === 0) {
|
|
95
|
+
return { valid: false, error: "Plugin name cannot be empty" };
|
|
96
|
+
}
|
|
97
|
+
if (name !== name.toLowerCase()) {
|
|
98
|
+
return { valid: false, error: "Plugin name must be lowercase" };
|
|
99
|
+
}
|
|
100
|
+
if (!/^[\da-z-]+$/.test(name)) {
|
|
101
|
+
return {
|
|
102
|
+
valid: false,
|
|
103
|
+
error: "Plugin name can only contain lowercase letters, numbers, and hyphens",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (name.startsWith("-") || name.endsWith("-")) {
|
|
107
|
+
return { valid: false, error: "Plugin name cannot start or end with a hyphen" };
|
|
108
|
+
}
|
|
109
|
+
if (name.includes("--")) {
|
|
110
|
+
return { valid: false, error: "Plugin name cannot contain consecutive hyphens" };
|
|
111
|
+
}
|
|
112
|
+
if (new Set(["checkstack", "core", "api", "common"]).has(name)) {
|
|
113
|
+
return { valid: false, error: `'${name}' is a reserved name` };
|
|
114
|
+
}
|
|
115
|
+
return { valid: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate an npm scope (without the leading `@`). Empty is allowed (unscoped). */
|
|
119
|
+
export function validateScope(scope: string): { valid: boolean; error?: string } {
|
|
120
|
+
if (scope === "") return { valid: true };
|
|
121
|
+
if (!/^[a-z\d][a-z\d._-]*$/.test(scope)) {
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
error:
|
|
125
|
+
"Scope must start with a lowercase letter or digit and contain only " +
|
|
126
|
+
"lowercase letters, digits, '.', '_', or '-' (no leading '@').",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { valid: true };
|
|
130
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import inquirer from "inquirer";
|
|
5
|
+
import {
|
|
6
|
+
parseCliArgs,
|
|
7
|
+
validateBaseName,
|
|
8
|
+
validateScope,
|
|
9
|
+
type CliArgs,
|
|
10
|
+
} from "./cli-args";
|
|
11
|
+
import {
|
|
12
|
+
scaffoldStandaloneWorkspace,
|
|
13
|
+
localSiblingNames,
|
|
14
|
+
} from "./scaffold-standalone";
|
|
15
|
+
import { createNpmViewResolver } from "./npm-view-resolver";
|
|
16
|
+
import { initGitRepo } from "./git";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `create-checkstack-plugin` — a thin standalone bootstrapper.
|
|
20
|
+
*
|
|
21
|
+
* Resolves a target dir + base name + scope (via flags or interactive
|
|
22
|
+
* prompts), resolves concrete published `@checkstack/*` versions from the
|
|
23
|
+
* registry's `latest` dist-tag, renders the standalone trio workspace via
|
|
24
|
+
* the `@checkstack/scripts` engine, then `git init`s the result. All the
|
|
25
|
+
* heavy lifting lives in `@checkstack/scripts`; this package only adds the
|
|
26
|
+
* prompts, version resolution, and git wiring so `bun create
|
|
27
|
+
* checkstack-plugin` / `bunx create-checkstack-plugin` work ergonomically.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface Answers {
|
|
31
|
+
baseName: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
targetDir: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function gatherAnswers(args: CliArgs): Promise<Answers> {
|
|
37
|
+
if (args.yes) {
|
|
38
|
+
const baseName = args.targetDir
|
|
39
|
+
? path.basename(path.resolve(args.targetDir))
|
|
40
|
+
: "widget";
|
|
41
|
+
const baseCheck = validateBaseName(baseName);
|
|
42
|
+
if (!baseCheck.valid) {
|
|
43
|
+
throw new Error(`Invalid plugin name '${baseName}': ${baseCheck.error}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
baseName,
|
|
47
|
+
scope: args.scope ?? "",
|
|
48
|
+
targetDir: path.resolve(args.targetDir ?? baseName),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { baseName } = await inquirer.prompt<{ baseName: string }>([
|
|
53
|
+
{
|
|
54
|
+
type: "input",
|
|
55
|
+
name: "baseName",
|
|
56
|
+
message: "Plugin base name (e.g. 'widget' for 'widget-backend'):",
|
|
57
|
+
default: args.targetDir
|
|
58
|
+
? path.basename(path.resolve(args.targetDir))
|
|
59
|
+
: undefined,
|
|
60
|
+
validate: (input: string) => {
|
|
61
|
+
const check = validateBaseName(input.trim());
|
|
62
|
+
return check.valid || check.error || false;
|
|
63
|
+
},
|
|
64
|
+
filter: (input: string) => input.trim(),
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
let scope = args.scope;
|
|
69
|
+
if (scope === undefined) {
|
|
70
|
+
const { scopeInput } = await inquirer.prompt<{ scopeInput: string }>([
|
|
71
|
+
{
|
|
72
|
+
type: "input",
|
|
73
|
+
name: "scopeInput",
|
|
74
|
+
message:
|
|
75
|
+
"npm scope without '@' (leave blank to publish unscoped):",
|
|
76
|
+
default: "",
|
|
77
|
+
validate: (input: string) => {
|
|
78
|
+
const check = validateScope(input.trim());
|
|
79
|
+
return check.valid || check.error || false;
|
|
80
|
+
},
|
|
81
|
+
filter: (input: string) => input.trim(),
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
scope = scopeInput;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetDir = path.resolve(args.targetDir ?? baseName);
|
|
88
|
+
return { baseName, scope, targetDir };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function run({
|
|
92
|
+
argv,
|
|
93
|
+
env,
|
|
94
|
+
}: {
|
|
95
|
+
argv: string[];
|
|
96
|
+
env: Record<string, string | undefined>;
|
|
97
|
+
}): Promise<number> {
|
|
98
|
+
const args = parseCliArgs({ argv, env });
|
|
99
|
+
if (args.help) {
|
|
100
|
+
printHelp();
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { baseName, scope, targetDir } = await gatherAnswers(args);
|
|
105
|
+
|
|
106
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
107
|
+
console.error(`Target directory '${targetDir}' already exists and is not empty.`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const description = `${baseName.charAt(0).toUpperCase()}${baseName.slice(1)} plugin`;
|
|
112
|
+
|
|
113
|
+
const resolveVersion = createNpmViewResolver({
|
|
114
|
+
registry: args.registry,
|
|
115
|
+
versionTag: args.versionTag,
|
|
116
|
+
localSiblings: localSiblingNames({ baseName, packageScope: scope }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log(`\nScaffolding ${baseName} plugin in ${targetDir} ...`);
|
|
120
|
+
|
|
121
|
+
await scaffoldStandaloneWorkspace({
|
|
122
|
+
rootDir: targetDir,
|
|
123
|
+
baseName,
|
|
124
|
+
description,
|
|
125
|
+
packageScope: scope,
|
|
126
|
+
resolveVersion,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log("Wrote the workspace root and the common/backend/frontend trio.");
|
|
130
|
+
|
|
131
|
+
if (args.git) {
|
|
132
|
+
initGitRepo({ dir: targetDir });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
printNextSteps({ targetDir, baseName });
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printNextSteps({
|
|
140
|
+
targetDir,
|
|
141
|
+
baseName,
|
|
142
|
+
}: {
|
|
143
|
+
targetDir: string;
|
|
144
|
+
baseName: string;
|
|
145
|
+
}): void {
|
|
146
|
+
const rel = path.relative(process.cwd(), targetDir) || ".";
|
|
147
|
+
console.log("\nDone. Next steps:");
|
|
148
|
+
console.log(` 1. cd ${rel}`);
|
|
149
|
+
console.log(" 2. Start Postgres (see README.md)");
|
|
150
|
+
console.log(" 3. bun install");
|
|
151
|
+
console.log(" 4. bun run dev");
|
|
152
|
+
console.log(` 5. POST http://localhost:3000/api/${baseName}/getItems\n`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function printHelp(): void {
|
|
156
|
+
console.log(`Usage: create-checkstack-plugin [target-dir] [options]
|
|
157
|
+
|
|
158
|
+
Scaffold a standalone Checkstack plugin workspace (common + backend +
|
|
159
|
+
frontend) with concrete published @checkstack/* versions, ready to
|
|
160
|
+
\`bun install && bun run dev\`.
|
|
161
|
+
|
|
162
|
+
Options:
|
|
163
|
+
--scope <name> npm scope without '@' (e.g. acme). Prompted if omitted.
|
|
164
|
+
--no-scope Publish the trio unscoped (widget-backend, ...).
|
|
165
|
+
--version-tag <tag> dist-tag to resolve versions from (default: latest).
|
|
166
|
+
--registry <url> Registry for version resolution
|
|
167
|
+
(env: CHECKSTACK_SCAFFOLD_REGISTRY).
|
|
168
|
+
--no-git Do not run \`git init\`.
|
|
169
|
+
--yes, -y Accept defaults without prompting.
|
|
170
|
+
--help, -h Show this message.
|
|
171
|
+
`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (import.meta.main) {
|
|
175
|
+
const code = await run({ argv: process.argv.slice(2), env: process.env });
|
|
176
|
+
process.exit(code);
|
|
177
|
+
}
|