@checkstack/scripts 0.0.2
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/CHANGELOG.md +7 -0
- package/package.json +21 -0
- package/src/cli.ts +56 -0
- package/src/commands/create.ts +259 -0
- package/src/sync.ts +125 -0
- package/src/templates/backend/.changeset/initial.md.hbs +5 -0
- package/src/templates/backend/README.md.hbs +46 -0
- package/src/templates/backend/drizzle.config.ts.hbs +10 -0
- package/src/templates/backend/package.json.hbs +11 -0
- package/src/templates/backend/src/index.ts.hbs +35 -0
- package/src/templates/backend/src/router.ts.hbs +46 -0
- package/src/templates/backend/src/schema.ts.hbs +19 -0
- package/src/templates/backend/src/service.ts.hbs +69 -0
- package/src/templates/backend/tsconfig.json +6 -0
- package/src/templates/common/.changeset/initial.md.hbs +5 -0
- package/src/templates/common/README.md.hbs +11 -0
- package/src/templates/common/package.json.hbs +7 -0
- package/src/templates/common/src/index.ts.hbs +14 -0
- package/src/templates/common/src/permissions.ts.hbs +17 -0
- package/src/templates/common/src/plugin-metadata.ts.hbs +6 -0
- package/src/templates/common/src/routes.ts.hbs +6 -0
- package/src/templates/common/src/rpc-contract.ts.hbs +60 -0
- package/src/templates/common/src/schemas.ts.hbs +25 -0
- package/src/templates/common/tsconfig.json +6 -0
- package/src/templates/frontend/.changeset/initial.md.hbs +5 -0
- package/src/templates/frontend/README.md.hbs +29 -0
- package/src/templates/frontend/bunfig.toml.hbs +1 -0
- package/src/templates/frontend/package.json.hbs +12 -0
- package/src/templates/frontend/playwright.config.ts.hbs +3 -0
- package/src/templates/frontend/src/api.ts.hbs +15 -0
- package/src/templates/frontend/src/components/{{pluginNamePascal}}ListPage.tsx.hbs +81 -0
- package/src/templates/frontend/src/index.tsx.hbs +33 -0
- package/src/templates/frontend/tsconfig.json.hbs +1 -0
- package/src/templates/node/.changeset/initial.md.hbs +5 -0
- package/src/templates/node/README.md.hbs +8 -0
- package/src/templates/node/package.json.hbs +3 -0
- package/src/templates/node/src/index.ts.hbs +6 -0
- package/src/templates/node/tsconfig.json +6 -0
- package/src/templates/react/.changeset/initial.md.hbs +5 -0
- package/src/templates/react/README.md.hbs +23 -0
- package/src/templates/react/package.json.hbs +4 -0
- package/src/templates/react/src/components/{{pluginNamePascal}}Component.tsx.hbs +12 -0
- package/src/templates/react/src/index.tsx.hbs +1 -0
- package/src/templates/react/tsconfig.json +6 -0
- package/src/templates.test.ts +134 -0
- package/src/utils/template.ts +154 -0
- package/src/utils/validation.ts +110 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
copyTemplate,
|
|
4
|
+
prepareTemplateData,
|
|
5
|
+
registerHelpers,
|
|
6
|
+
} from "./utils/template";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { rmSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
const TEST_BASE_NAME = "scaffoldtest";
|
|
15
|
+
const TEST_SCAFFOLDS_DIR = "plugins/_test-scaffolds";
|
|
16
|
+
|
|
17
|
+
// Plugin types to test - these have the most template complexity
|
|
18
|
+
// Order matters: common must be scaffolded before frontend (frontend depends on common)
|
|
19
|
+
const PLUGIN_TYPES = ["common", "backend", "frontend"] as const;
|
|
20
|
+
|
|
21
|
+
describe("CLI Template Scaffolding", () => {
|
|
22
|
+
const rootDir = path.resolve(__dirname, "../../..");
|
|
23
|
+
const scaffoldsDir = path.join(rootDir, TEST_SCAFFOLDS_DIR);
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
registerHelpers();
|
|
27
|
+
|
|
28
|
+
// Clean up any previous test scaffolds
|
|
29
|
+
if (existsSync(scaffoldsDir)) {
|
|
30
|
+
rmSync(scaffoldsDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Ensure the test scaffolds directory exists
|
|
34
|
+
mkdirSync(scaffoldsDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
// Scaffold ALL plugins first (order matters for dependencies)
|
|
37
|
+
for (const pluginType of PLUGIN_TYPES) {
|
|
38
|
+
const templateData = prepareTemplateData({
|
|
39
|
+
baseName: TEST_BASE_NAME,
|
|
40
|
+
pluginType,
|
|
41
|
+
description: `Test ${pluginType} plugin for template validation`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const templateDir = path.join(__dirname, "templates", pluginType);
|
|
45
|
+
const targetDir = path.join(scaffoldsDir, templateData.pluginName);
|
|
46
|
+
|
|
47
|
+
copyTemplate({
|
|
48
|
+
templateDir,
|
|
49
|
+
targetDir,
|
|
50
|
+
data: templateData,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Run bun install ONCE after all plugins are scaffolded
|
|
55
|
+
// This ensures workspace dependencies are resolved correctly
|
|
56
|
+
execSync("bun install", {
|
|
57
|
+
cwd: rootDir,
|
|
58
|
+
stdio: "pipe",
|
|
59
|
+
timeout: 120_000,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
// Cleanup all test packages
|
|
65
|
+
if (existsSync(scaffoldsDir)) {
|
|
66
|
+
rmSync(scaffoldsDir, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Re-run bun install to remove stale entries from bun.lock
|
|
70
|
+
execSync("bun install", {
|
|
71
|
+
cwd: rootDir,
|
|
72
|
+
stdio: "pipe",
|
|
73
|
+
timeout: 60_000,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for (const pluginType of PLUGIN_TYPES) {
|
|
78
|
+
const pluginName = `${TEST_BASE_NAME}-${pluginType}`;
|
|
79
|
+
const targetDir = path.join(scaffoldsDir, pluginName);
|
|
80
|
+
|
|
81
|
+
describe(`${pluginType} plugin template`, () => {
|
|
82
|
+
it("should have scaffolded files", () => {
|
|
83
|
+
expect(existsSync(path.join(targetDir, "package.json"))).toBe(true);
|
|
84
|
+
expect(existsSync(path.join(targetDir, "src"))).toBe(true);
|
|
85
|
+
expect(existsSync(path.join(targetDir, "tsconfig.json"))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it(
|
|
89
|
+
"should pass typecheck",
|
|
90
|
+
() => {
|
|
91
|
+
try {
|
|
92
|
+
execSync(
|
|
93
|
+
`bun run --filter '@checkstack/${pluginName}' typecheck`,
|
|
94
|
+
{
|
|
95
|
+
cwd: rootDir,
|
|
96
|
+
stdio: "pipe",
|
|
97
|
+
timeout: 60_000,
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const execError = error as { stderr?: Buffer; stdout?: Buffer };
|
|
102
|
+
const stderr = execError.stderr?.toString() ?? "";
|
|
103
|
+
const stdout = execError.stdout?.toString() ?? "";
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Typecheck failed for ${pluginName}:\n${stderr}\n${stdout}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{ timeout: 30_000 }
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
it(
|
|
113
|
+
"should pass lint",
|
|
114
|
+
() => {
|
|
115
|
+
try {
|
|
116
|
+
execSync(`bun run eslint ${TEST_SCAFFOLDS_DIR}/${pluginName}`, {
|
|
117
|
+
cwd: rootDir,
|
|
118
|
+
stdio: "pipe",
|
|
119
|
+
timeout: 60_000,
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const execError = error as { stderr?: Buffer; stdout?: Buffer };
|
|
123
|
+
const stderr = execError.stderr?.toString() ?? "";
|
|
124
|
+
const stdout = execError.stdout?.toString() ?? "";
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Lint failed for ${pluginName}:\n${stderr}\n${stdout}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{ timeout: 30_000 }
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import Handlebars from "handlebars";
|
|
2
|
+
import {
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface TemplateData {
|
|
12
|
+
pluginName: string;
|
|
13
|
+
pluginBaseName: string;
|
|
14
|
+
pluginNamePascal: string;
|
|
15
|
+
pluginNameCamel: string;
|
|
16
|
+
pluginDescription: string;
|
|
17
|
+
pluginId: string;
|
|
18
|
+
pluginType: string;
|
|
19
|
+
currentYear: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register custom Handlebars helpers for common case transformations
|
|
24
|
+
*/
|
|
25
|
+
export function registerHelpers() {
|
|
26
|
+
Handlebars.registerHelper("pascalCase", (value: string) => {
|
|
27
|
+
return value
|
|
28
|
+
.split("-")
|
|
29
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
30
|
+
.join("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
Handlebars.registerHelper("camelCase", (value: string) => {
|
|
34
|
+
const pascal = value
|
|
35
|
+
.split("-")
|
|
36
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
37
|
+
.join("");
|
|
38
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Handlebars.registerHelper("kebabCase", (value: string) => {
|
|
42
|
+
return value.toLowerCase().replaceAll(/\s+/g, "-");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
Handlebars.registerHelper("year", () => {
|
|
46
|
+
return new Date().getFullYear();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Process a single template file using Handlebars
|
|
52
|
+
*/
|
|
53
|
+
export function processTemplate(content: string, data: TemplateData): string {
|
|
54
|
+
const template = Handlebars.compile(content);
|
|
55
|
+
return template(data);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Recursively copy template directory and process .hbs files
|
|
60
|
+
*/
|
|
61
|
+
export function copyTemplate({
|
|
62
|
+
templateDir,
|
|
63
|
+
targetDir,
|
|
64
|
+
data,
|
|
65
|
+
}: {
|
|
66
|
+
templateDir: string;
|
|
67
|
+
targetDir: string;
|
|
68
|
+
data: TemplateData;
|
|
69
|
+
}): string[] {
|
|
70
|
+
const createdFiles: string[] = [];
|
|
71
|
+
|
|
72
|
+
// Create target directory if it doesn't exist
|
|
73
|
+
if (!statSync(targetDir, { throwIfNoEntry: false })) {
|
|
74
|
+
mkdirSync(targetDir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read all items in template directory
|
|
78
|
+
const items = readdirSync(templateDir);
|
|
79
|
+
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const templatePath = path.join(templateDir, item);
|
|
82
|
+
const stat = statSync(templatePath);
|
|
83
|
+
|
|
84
|
+
if (stat.isDirectory()) {
|
|
85
|
+
// Recursively copy directories
|
|
86
|
+
const newTargetDir = path.join(targetDir, item);
|
|
87
|
+
const subFiles = copyTemplate({
|
|
88
|
+
templateDir: templatePath,
|
|
89
|
+
targetDir: newTargetDir,
|
|
90
|
+
data,
|
|
91
|
+
});
|
|
92
|
+
createdFiles.push(...subFiles);
|
|
93
|
+
} else if (stat.isFile()) {
|
|
94
|
+
// Process files
|
|
95
|
+
const isTemplate = item.endsWith(".hbs");
|
|
96
|
+
let targetFileName = isTemplate ? item.slice(0, -4) : item; // Remove .hbs extension
|
|
97
|
+
|
|
98
|
+
// Process Handlebars patterns in filename
|
|
99
|
+
if (targetFileName.includes("{{")) {
|
|
100
|
+
targetFileName = processTemplate(targetFileName, data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const targetPath = path.join(targetDir, targetFileName);
|
|
104
|
+
|
|
105
|
+
if (isTemplate) {
|
|
106
|
+
// Process Handlebars template
|
|
107
|
+
const content = readFileSync(templatePath, "utf8");
|
|
108
|
+
const processed = processTemplate(content, data);
|
|
109
|
+
writeFileSync(targetPath, processed, "utf8");
|
|
110
|
+
} else {
|
|
111
|
+
// Copy non-template files as-is
|
|
112
|
+
const content = readFileSync(templatePath);
|
|
113
|
+
writeFileSync(targetPath, content);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
createdFiles.push(targetPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return createdFiles;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Prepare template data from user inputs
|
|
125
|
+
*/
|
|
126
|
+
export function prepareTemplateData({
|
|
127
|
+
baseName,
|
|
128
|
+
pluginType,
|
|
129
|
+
description,
|
|
130
|
+
}: {
|
|
131
|
+
baseName: string;
|
|
132
|
+
pluginType: string;
|
|
133
|
+
description: string;
|
|
134
|
+
}): TemplateData {
|
|
135
|
+
const pluginName = `${baseName}-${pluginType}`;
|
|
136
|
+
const pluginNamePascal = baseName
|
|
137
|
+
.split("-")
|
|
138
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
139
|
+
.join("");
|
|
140
|
+
|
|
141
|
+
const pluginNameCamel =
|
|
142
|
+
pluginNamePascal.charAt(0).toLowerCase() + pluginNamePascal.slice(1);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
pluginName,
|
|
146
|
+
pluginBaseName: baseName,
|
|
147
|
+
pluginNamePascal,
|
|
148
|
+
pluginNameCamel,
|
|
149
|
+
pluginDescription: description,
|
|
150
|
+
pluginId: pluginName,
|
|
151
|
+
pluginType,
|
|
152
|
+
currentYear: new Date().getFullYear(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const RESERVED_NAMES = new Set(["checkstack", "core", "api", "common"]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate plugin name follows naming conventions
|
|
8
|
+
*/
|
|
9
|
+
export function validatePluginName(name: string): {
|
|
10
|
+
valid: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
} {
|
|
13
|
+
// Must not be empty
|
|
14
|
+
if (!name || name.trim().length === 0) {
|
|
15
|
+
return { valid: false, error: "Plugin name cannot be empty" };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Must be lowercase
|
|
19
|
+
if (name !== name.toLowerCase()) {
|
|
20
|
+
return {
|
|
21
|
+
valid: false,
|
|
22
|
+
error: "Plugin name must be lowercase (use hyphens for word separation)",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Can only contain lowercase letters, numbers, and hyphens
|
|
27
|
+
if (!/^[\da-z-]+$/.test(name)) {
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
error:
|
|
31
|
+
"Plugin name can only contain lowercase letters, numbers, and hyphens",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Cannot start or end with hyphen
|
|
36
|
+
if (name.startsWith("-") || name.endsWith("-")) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
error: "Plugin name cannot start or end with a hyphen",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cannot have consecutive hyphens
|
|
44
|
+
if (name.includes("--")) {
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
error: "Plugin name cannot contain consecutive hyphens",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check reserved names
|
|
52
|
+
if (RESERVED_NAMES.has(name)) {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
error: `'${name}' is a reserved name and cannot be used`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { valid: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a plugin already exists
|
|
64
|
+
*/
|
|
65
|
+
export function pluginExists({
|
|
66
|
+
baseName,
|
|
67
|
+
pluginType,
|
|
68
|
+
rootDir,
|
|
69
|
+
}: {
|
|
70
|
+
baseName: string;
|
|
71
|
+
pluginType: string;
|
|
72
|
+
rootDir: string;
|
|
73
|
+
}): boolean {
|
|
74
|
+
const pluginName = `${baseName}-${pluginType}`;
|
|
75
|
+
const pluginPath = path.join(rootDir, "plugins", pluginName);
|
|
76
|
+
return existsSync(pluginPath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a package already exists in core/
|
|
81
|
+
*/
|
|
82
|
+
export function packageExists({
|
|
83
|
+
baseName,
|
|
84
|
+
pluginType,
|
|
85
|
+
rootDir,
|
|
86
|
+
}: {
|
|
87
|
+
baseName: string;
|
|
88
|
+
pluginType: string;
|
|
89
|
+
rootDir: string;
|
|
90
|
+
}): boolean {
|
|
91
|
+
const packageName = `${baseName}-${pluginType}`;
|
|
92
|
+
const packagePath = path.join(rootDir, "core", packageName);
|
|
93
|
+
return existsSync(packagePath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract base name from full plugin name if provided
|
|
98
|
+
* e.g., "catalog-backend" -> "catalog"
|
|
99
|
+
*/
|
|
100
|
+
export function extractBaseName(fullName: string): string {
|
|
101
|
+
const parts = fullName.split("-");
|
|
102
|
+
if (parts.length > 1) {
|
|
103
|
+
const lastPart = parts.at(-1);
|
|
104
|
+
const knownTypes = ["backend", "frontend", "common", "node", "react"];
|
|
105
|
+
if (lastPart && knownTypes.includes(lastPart)) {
|
|
106
|
+
return parts.slice(0, -1).join("-");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return fullName;
|
|
110
|
+
}
|