Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@toolctrl/create-toolctrl",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"create-toolctrl": "./src/create.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/create.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PACKAGE_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const REPO_ROOT = path.resolve(PACKAGE_DIR, "..", "..");
|
||||
const PROFILES = new Set(["core", "saas", "full"]);
|
||||
|
||||
const EXCLUDED_DIRS = new Set([
|
||||
".bg-shell",
|
||||
".claude",
|
||||
".git",
|
||||
".react-router",
|
||||
".turbo",
|
||||
".next",
|
||||
".tc",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
function isSecretOrRuntimeFile(relativePath) {
|
||||
const base = path.basename(relativePath);
|
||||
if (/^\.env($|\.test$|\.production$|\.local$)/.test(base)) {
|
||||
return true;
|
||||
}
|
||||
if (/token|secret|credential/i.test(base) && !base.endsWith(".example")) {
|
||||
return true;
|
||||
}
|
||||
return base === ".tc-id";
|
||||
}
|
||||
|
||||
function shouldCopy(source, sourceRoot = REPO_ROOT) {
|
||||
const relativePath = path.relative(sourceRoot, source);
|
||||
if (!relativePath) {
|
||||
return true;
|
||||
}
|
||||
if (isSecretOrRuntimeFile(relativePath)) {
|
||||
return false;
|
||||
}
|
||||
return !relativePath.split(path.sep).some((part) => EXCLUDED_DIRS.has(part));
|
||||
}
|
||||
|
||||
function slugifyPackageName(appName) {
|
||||
const slug = appName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "toolctrl-app";
|
||||
}
|
||||
|
||||
function modulesForProfile(profile) {
|
||||
if (profile === "core") {
|
||||
return ["core", "auth-tenancy"];
|
||||
}
|
||||
if (profile === "saas") {
|
||||
return ["core", "auth-tenancy", "admin-app-shell", "billing", "transactional-mail", "rows-entities-runtime"];
|
||||
}
|
||||
return ["core", "auth-tenancy", "admin-app-shell", "billing", "transactional-mail", "rows-entities-runtime", "plugin-candidates"];
|
||||
}
|
||||
|
||||
async function updateJsonFile(filePath, updater) {
|
||||
const data = JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
const updated = updater(data);
|
||||
await fs.writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function rewriteEnvExample(targetDir, appName) {
|
||||
const envPath = path.join(targetDir, ".env.example");
|
||||
let content = await fs.readFile(envPath, "utf8");
|
||||
content = content.replace(/^APP_NAME=.*$/m, `APP_NAME=${appName}`);
|
||||
await fs.writeFile(envPath, content);
|
||||
}
|
||||
|
||||
async function writeScaffoldReadme(targetDir, appName, profile) {
|
||||
const readmePath = path.join(targetDir, "README.md");
|
||||
const original = await fs.readFile(readmePath, "utf8");
|
||||
const header = `# ${appName}\n\nGenerated from the local ToolCTRL boilerplate with the \`${profile}\` module profile.\n\n`;
|
||||
await fs.writeFile(readmePath, `${header}${original}`);
|
||||
}
|
||||
|
||||
async function writeScaffoldProfile(targetDir, appName, profile) {
|
||||
const profilePath = path.join(targetDir, "docs", "feature-manifests", "scaffold-profile.json");
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
profilePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: "scaffold-profile",
|
||||
title: "Scaffold Profile",
|
||||
appName,
|
||||
profile,
|
||||
classification: profile === "core" ? "core" : profile === "saas" ? "default-enabled" : "optional-inclusive",
|
||||
modules: modulesForProfile(profile),
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
export async function createToolctrlProject({ targetDir, appName, profile = "saas", sourceDir = REPO_ROOT }) {
|
||||
if (!targetDir) {
|
||||
throw new Error("targetDir is required.");
|
||||
}
|
||||
if (!PROFILES.has(profile)) {
|
||||
throw new Error(`profile must be one of: ${[...PROFILES].join(", ")}.`);
|
||||
}
|
||||
|
||||
const resolvedTarget = path.resolve(targetDir);
|
||||
const resolvedSource = path.resolve(sourceDir);
|
||||
const finalAppName = appName || path.basename(resolvedTarget);
|
||||
await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
|
||||
|
||||
const existing = await fs.readdir(resolvedTarget).catch((error) => {
|
||||
if (error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
throw new Error(`Target directory is not empty: ${resolvedTarget}`);
|
||||
}
|
||||
|
||||
await fs.cp(resolvedSource, resolvedTarget, {
|
||||
recursive: true,
|
||||
filter: (source) => shouldCopy(source, resolvedSource),
|
||||
});
|
||||
|
||||
await updateJsonFile(path.join(resolvedTarget, "package.json"), (data) => ({
|
||||
...data,
|
||||
name: slugifyPackageName(finalAppName),
|
||||
private: true,
|
||||
}));
|
||||
await rewriteEnvExample(resolvedTarget, finalAppName);
|
||||
await writeScaffoldReadme(resolvedTarget, finalAppName, profile);
|
||||
await writeScaffoldProfile(resolvedTarget, finalAppName, profile);
|
||||
|
||||
return {
|
||||
targetDir: resolvedTarget,
|
||||
appName: finalAppName,
|
||||
packageName: slugifyPackageName(finalAppName),
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const options = { targetDir: "", appName: "", profile: "saas" };
|
||||
while (args.length > 0) {
|
||||
const arg = args.shift();
|
||||
if (arg === "--name") {
|
||||
options.appName = args.shift() ?? "";
|
||||
} else if (arg === "--profile") {
|
||||
options.profile = args.shift() ?? "saas";
|
||||
} else if (!options.targetDir) {
|
||||
options.targetDir = arg ?? "";
|
||||
} else {
|
||||
throw new Error(`Unexpected argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.targetDir) {
|
||||
throw new Error("Usage: create-toolctrl <target-dir> [--name AppName] [--profile core|saas|full]");
|
||||
}
|
||||
const result = await createToolctrlProject(options);
|
||||
// eslint-disable-next-line no-console -- CLI output is the user-visible smoke result.
|
||||
console.log(`Created ${result.appName} at ${result.targetDir} (${result.profile})`);
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (invokedDirectly) {
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console -- CLI must report fatal usage/setup errors.
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createToolctrlProject } from "./create.js";
|
||||
|
||||
const tempRoots = [];
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectFiles(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function createSourceFixture(rootDir) {
|
||||
const sourceDir = path.join(rootDir, "source");
|
||||
await fs.mkdir(path.join(sourceDir, "apps", "web", "build"), { recursive: true });
|
||||
await fs.mkdir(path.join(sourceDir, "docs", "feature-manifests"), { recursive: true });
|
||||
await fs.mkdir(path.join(sourceDir, ".git"), { recursive: true });
|
||||
await fs.writeFile(path.join(sourceDir, "package.json"), JSON.stringify({ name: "toolctrl-boilerplate", private: true, scripts: {} }, null, 2));
|
||||
await fs.writeFile(path.join(sourceDir, ".env.example"), "APP_NAME=ToolCTRL\nSESSION_SECRET=\n");
|
||||
await fs.writeFile(path.join(sourceDir, ".env"), "SESSION_SECRET=secret\n");
|
||||
await fs.writeFile(path.join(sourceDir, "README.md"), "# ToolCTRL\n");
|
||||
await fs.writeFile(path.join(sourceDir, "apps", "web", "build", "artifact.js"), "build");
|
||||
await fs.writeFile(path.join(sourceDir, ".git", "HEAD"), "ref: main\n");
|
||||
return sourceDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("createToolctrlProject", () => {
|
||||
it("scaffolds a local workspace without secrets or build artifacts", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "toolctrl-create-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const sourceDir = await createSourceFixture(tempRoot);
|
||||
const targetDir = path.join(tempRoot, "acme-ops");
|
||||
|
||||
const result = await createToolctrlProject({ sourceDir, targetDir, appName: "Acme Ops", profile: "core" });
|
||||
const packageJson = JSON.parse(await fs.readFile(path.join(targetDir, "package.json"), "utf8"));
|
||||
const envExample = await fs.readFile(path.join(targetDir, ".env.example"), "utf8");
|
||||
const profile = JSON.parse(await fs.readFile(path.join(targetDir, "docs", "feature-manifests", "scaffold-profile.json"), "utf8"));
|
||||
const files = (await collectFiles(targetDir)).map((file) => path.relative(targetDir, file));
|
||||
|
||||
expect(result).toMatchObject({ appName: "Acme Ops", packageName: "acme-ops", profile: "core" });
|
||||
expect(packageJson.name).toBe("acme-ops");
|
||||
expect(envExample).toContain("APP_NAME=Acme Ops");
|
||||
expect(profile).toMatchObject({ appName: "Acme Ops", profile: "core", modules: ["core", "auth-tenancy"] });
|
||||
expect(await exists(path.join(targetDir, ".git"))).toBe(false);
|
||||
expect(await exists(path.join(targetDir, "node_modules"))).toBe(false);
|
||||
expect(await exists(path.join(targetDir, "apps", "web", "build"))).toBe(false);
|
||||
expect(await exists(path.join(targetDir, ".env"))).toBe(false);
|
||||
expect(files.some((file) => /(^|\/)\.env($|\.test$|\.local$|\.production$)/.test(file))).toBe(false);
|
||||
expect(files.some((file) => /(^|\/)(build|dist|coverage)(\/|$)/.test(file))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user