stackloom-cli 1.0.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/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/cli.js +306 -0
- package/branding.json +8 -0
- package/package.json +72 -0
- package/src/__tests__/cli-smoke.test.js +46 -0
- package/src/blueprint/__tests__/blueprint.test.js +116 -0
- package/src/blueprint/blueprint.js +181 -0
- package/src/blueprint/default.blueprint.json +78 -0
- package/src/blueprint/index.js +10 -0
- package/src/blueprint/loader.js +101 -0
- package/src/blueprint/schema-kit.js +161 -0
- package/src/blueprint/schema.js +78 -0
- package/src/branding/__tests__/branding.test.js +49 -0
- package/src/branding/index.js +48 -0
- package/src/commands/__tests__/commands.test.js +83 -0
- package/src/commands/check.js +71 -0
- package/src/commands/cleanup.js +347 -0
- package/src/commands/customize.js +263 -0
- package/src/commands/doctor.js +84 -0
- package/src/commands/env.js +75 -0
- package/src/commands/finalize.js +68 -0
- package/src/commands/generate/ci-cd.js +378 -0
- package/src/commands/generate/deploy-advanced.js +253 -0
- package/src/commands/generate/deploy.js +99 -0
- package/src/commands/generate/env.template.js +221 -0
- package/src/commands/generate/index.js +7 -0
- package/src/commands/generate/module.js +836 -0
- package/src/commands/generate/page.js +1415 -0
- package/src/commands/generate/test-scaffold.js +279 -0
- package/src/commands/generate/theme.js +67 -0
- package/src/commands/generate-resource.js +133 -0
- package/src/commands/index.js +9 -0
- package/src/commands/init.js +350 -0
- package/src/commands/make/resource.js +298 -0
- package/src/commands/preset.js +57 -0
- package/src/commands/remove.js +170 -0
- package/src/commands/rename.js +54 -0
- package/src/commands/rollback.js +90 -0
- package/src/commands/wizard.js +303 -0
- package/src/core/__tests__/generator.test.js +67 -0
- package/src/core/__tests__/marker-strategy.test.js +57 -0
- package/src/core/__tests__/resource-definition.test.js +32 -0
- package/src/core/generator.js +542 -0
- package/src/core/marker-strategy.js +138 -0
- package/src/core/resource-definition.js +346 -0
- package/src/core/state-tracker.js +67 -0
- package/src/core/template-loader.js +163 -0
- package/src/engine/__tests__/engine.test.js +306 -0
- package/src/engine/index.js +21 -0
- package/src/engine/injector.js +198 -0
- package/src/engine/pipeline.js +138 -0
- package/src/engine/transaction.js +105 -0
- package/src/engine/validator.js +190 -0
- package/src/index.js +4 -0
- package/src/recipes/__tests__/recipe.test.js +128 -0
- package/src/recipes/builtin/module.json +22 -0
- package/src/recipes/builtin/page.json +21 -0
- package/src/recipes/builtin/resource.json +35 -0
- package/src/recipes/condition.js +147 -0
- package/src/recipes/index.js +11 -0
- package/src/recipes/loader.js +95 -0
- package/src/recipes/recipe.js +89 -0
- package/src/recipes/schema.js +47 -0
- package/src/schemas/__tests__/schemas.test.js +67 -0
- package/src/schemas/index.js +18 -0
- package/src/schemas/options.js +38 -0
- package/src/schemas/resource.js +112 -0
- package/src/services/__tests__/reporter.test.js +98 -0
- package/src/services/clock.js +31 -0
- package/src/services/index.js +43 -0
- package/src/services/reporter.js +136 -0
- package/src/templates/resource/api.js.ejs +39 -0
- package/src/templates/resource/components/form.jsx.ejs +81 -0
- package/src/templates/resource/components/table.jsx.ejs +68 -0
- package/src/templates/resource/controller.js.ejs +154 -0
- package/src/templates/resource/hooks.js.ejs +46 -0
- package/src/templates/resource/model.js.ejs +64 -0
- package/src/templates/resource/page-detail.jsx.ejs +55 -0
- package/src/templates/resource/page-form.jsx.ejs +30 -0
- package/src/templates/resource/page-inline.jsx.ejs +74 -0
- package/src/templates/resource/page-modal.jsx.ejs +98 -0
- package/src/templates/resource/page-page.jsx.ejs +99 -0
- package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
- package/src/templates/resource/routes.js.ejs +35 -0
- package/src/templates/resource/service.js.ejs +132 -0
- package/src/templates/resource/test.ejs +71 -0
- package/src/templates/resource/types.ts.ejs +17 -0
- package/src/templates/resource/validator.js.ejs +26 -0
- package/src/templates/snippets/lazy-import.ejs +1 -0
- package/src/templates/snippets/nav-entry.ejs +1 -0
- package/src/templates/snippets/route-entry.ejs +5 -0
- package/src/templates/snippets/route-mount.ejs +1 -0
- package/src/utils/fieldValidators.js +371 -0
- package/src/utils/logging/logger.js +47 -0
- package/src/utils/namingUtils.js +38 -0
- package/src/utils/sanitize.js +200 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blueprint schema — the contract a Starter Template publishes so the CLI can
|
|
3
|
+
* scaffold into it without hardcoding any one architecture.
|
|
4
|
+
*
|
|
5
|
+
* A blueprint lives at `<project>/.loom/blueprint.json`. The CLI also ships a
|
|
6
|
+
* built-in default (`default.blueprint.json`) describing the MERN kit, used as
|
|
7
|
+
* a fallback when a project has no blueprint of its own.
|
|
8
|
+
*/
|
|
9
|
+
import { object, record, string, enumOf, arrayOf } from "./schema-kit.js";
|
|
10
|
+
|
|
11
|
+
/** Schema versions this CLI build understands. Bump on breaking blueprint changes. */
|
|
12
|
+
export const SUPPORTED_SCHEMA_VERSIONS = ["1.0"];
|
|
13
|
+
|
|
14
|
+
const namingStyle = () => enumOf("kebab", "camel", "pascal", "snake");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A named root directory (e.g. "backend", "frontend"). The CLI walks `detect`
|
|
18
|
+
* candidates, confirms each with `marker`, and falls back to `default`.
|
|
19
|
+
*/
|
|
20
|
+
const rootSchema = object({
|
|
21
|
+
detect: arrayOf(string(), { min: 1 }),
|
|
22
|
+
marker: string(),
|
|
23
|
+
default: string(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An injection point. `strategy` describes *how* the CLI splices generated
|
|
28
|
+
* snippets in; `pattern` is the regex/literal the strategy keys off. `comment`
|
|
29
|
+
* is the literal anchor comment a template may carry so future versions can
|
|
30
|
+
* migrate from pattern-matching to explicit markers.
|
|
31
|
+
*/
|
|
32
|
+
const anchorSchema = object({
|
|
33
|
+
file: string(),
|
|
34
|
+
strategy: enumOf(
|
|
35
|
+
"before-line",
|
|
36
|
+
"before-match",
|
|
37
|
+
"after-last-match",
|
|
38
|
+
"array-append",
|
|
39
|
+
"marker-comment",
|
|
40
|
+
),
|
|
41
|
+
pattern: string(),
|
|
42
|
+
comment: string().optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const conventionsSchema = object({
|
|
46
|
+
naming: object({
|
|
47
|
+
module: namingStyle().default("kebab"),
|
|
48
|
+
component: namingStyle().default("pascal"),
|
|
49
|
+
route: namingStyle().default("kebab"),
|
|
50
|
+
}).default({}),
|
|
51
|
+
language: object({
|
|
52
|
+
detect: enumOf("typescript", "none").default("typescript"),
|
|
53
|
+
default: enumOf("javascript", "typescript").default("javascript"),
|
|
54
|
+
}).default({}),
|
|
55
|
+
}).default({});
|
|
56
|
+
|
|
57
|
+
export const blueprintSchema = object({
|
|
58
|
+
schemaVersion: string(),
|
|
59
|
+
architecture: object({
|
|
60
|
+
id: string(),
|
|
61
|
+
name: string(),
|
|
62
|
+
description: string().optional(),
|
|
63
|
+
}),
|
|
64
|
+
engine: object({
|
|
65
|
+
minCliVersion: string().optional(),
|
|
66
|
+
}).optional(),
|
|
67
|
+
/** Named directory roots, resolved per-project. */
|
|
68
|
+
roots: record(rootSchema),
|
|
69
|
+
conventions: conventionsSchema,
|
|
70
|
+
/** Named path templates. Tokens like `{backend}` resolve against `roots`. */
|
|
71
|
+
paths: record(string()),
|
|
72
|
+
/** Named injection points for mounting routes, nav entries, etc. */
|
|
73
|
+
anchors: record(anchorSchema).default({}),
|
|
74
|
+
/** Named recipe references (path to a recipe manifest, relative to project root). */
|
|
75
|
+
recipes: record(string()).default({}),
|
|
76
|
+
/** Named preset file locations (themes, layouts, ...). */
|
|
77
|
+
presets: record(string()).default({}),
|
|
78
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { branding, defaultBranding, loadBrandingFrom, saveBrandingTo } from "../index.js";
|
|
6
|
+
|
|
7
|
+
const tmp = () =>
|
|
8
|
+
path.join(os.tmpdir(), `branding-${Math.random().toString(36).slice(2)}.json`);
|
|
9
|
+
|
|
10
|
+
describe("branding", () => {
|
|
11
|
+
it("ships sensible defaults", () => {
|
|
12
|
+
expect(defaultBranding.binName).toBe("loom");
|
|
13
|
+
expect(defaultBranding.stateDirName).toBe(".loom");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("loads the effective branding from branding.json", () => {
|
|
17
|
+
expect(branding.binName).toBe("loom");
|
|
18
|
+
expect(branding.displayName).toBe("Stackloom");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to defaults when the file is missing", () => {
|
|
22
|
+
expect(loadBrandingFrom(tmp())).toEqual({ ...defaultBranding });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to defaults on invalid JSON instead of throwing", () => {
|
|
26
|
+
const file = tmp();
|
|
27
|
+
writeFileSync(file, "{ not json");
|
|
28
|
+
expect(loadBrandingFrom(file)).toEqual({ ...defaultBranding });
|
|
29
|
+
rmSync(file, { force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("layers a partial file over the defaults", () => {
|
|
33
|
+
const file = tmp();
|
|
34
|
+
writeFileSync(file, JSON.stringify({ binName: "acme" }));
|
|
35
|
+
const loaded = loadBrandingFrom(file);
|
|
36
|
+
expect(loaded.binName).toBe("acme");
|
|
37
|
+
expect(loaded.stateDirName).toBe(".loom");
|
|
38
|
+
rmSync(file, { force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("round-trips through saveBrandingTo", () => {
|
|
42
|
+
const file = tmp();
|
|
43
|
+
saveBrandingTo(file, { binName: "acme", displayName: "ACME" }, defaultBranding);
|
|
44
|
+
const loaded = loadBrandingFrom(file);
|
|
45
|
+
expect(loaded.binName).toBe("acme");
|
|
46
|
+
expect(loaded.displayName).toBe("ACME");
|
|
47
|
+
rmSync(file, { force: true });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branding — the single source of truth for the CLI's own identity.
|
|
3
|
+
*
|
|
4
|
+
* Every place that would otherwise hardcode "loom" (the bin name, help text,
|
|
5
|
+
* output prefixes, the state-dir name) reads from here instead, so the whole
|
|
6
|
+
* tool can be rebranded by editing one JSON file or running `loom rename`.
|
|
7
|
+
*
|
|
8
|
+
* `loadBrandingFrom` / `saveBrandingTo` are pure (path-injected) so they can be
|
|
9
|
+
* tested without touching the real config.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/** branding.json lives at the CLI package root (two levels up from src/branding/). */
|
|
18
|
+
export const brandingPath = path.join(__dirname, "..", "..", "branding.json");
|
|
19
|
+
|
|
20
|
+
/** Shipped defaults — also the fallback when branding.json is missing or invalid. */
|
|
21
|
+
export const defaultBranding = Object.freeze({
|
|
22
|
+
binName: "loom",
|
|
23
|
+
displayName: "Stackloom",
|
|
24
|
+
description: "Stackloom — weave production-ready full-stack apps from a single command",
|
|
25
|
+
tagline: "Weave full-stack apps from a single command",
|
|
26
|
+
stateDirName: ".loom",
|
|
27
|
+
packageName: "stackloom",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Load branding from a file, layered over the shipped defaults. Never throws. */
|
|
31
|
+
export function loadBrandingFrom(file) {
|
|
32
|
+
if (!existsSync(file)) return { ...defaultBranding };
|
|
33
|
+
try {
|
|
34
|
+
return { ...defaultBranding, ...JSON.parse(readFileSync(file, "utf-8")) };
|
|
35
|
+
} catch {
|
|
36
|
+
return { ...defaultBranding };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Persist branding updates to a file, layered over `base`. Returns the written object. */
|
|
41
|
+
export function saveBrandingTo(file, updates, base = defaultBranding) {
|
|
42
|
+
const next = { ...base, ...updates };
|
|
43
|
+
writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The effective branding for this CLI install. */
|
|
48
|
+
export const branding = loadBrandingFrom(brandingPath);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { Reporter } from "../../services/index.js";
|
|
6
|
+
import check from "../check.js";
|
|
7
|
+
import env, { parseEnvKeys } from "../env.js";
|
|
8
|
+
|
|
9
|
+
const tmp = (label) =>
|
|
10
|
+
path.join(os.tmpdir(), `${label}-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
// A Reporter that swallows output — commands stay silent under test.
|
|
12
|
+
const silent = () =>
|
|
13
|
+
new Reporter({ stdout: { write() {}, isTTY: false }, stderr: { write() {} }, env: {} });
|
|
14
|
+
|
|
15
|
+
// Commands legitimately set process.exitCode to signal CI failures; reset between tests.
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
process.exitCode = 0;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("loom check", () => {
|
|
21
|
+
it("passes on a well-formed project", async () => {
|
|
22
|
+
const root = tmp("chk-ok");
|
|
23
|
+
const write = (rel, content) => {
|
|
24
|
+
const abs = path.join(root, rel);
|
|
25
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
26
|
+
writeFileSync(abs, content);
|
|
27
|
+
};
|
|
28
|
+
write("backend/package.json", "{}");
|
|
29
|
+
write("backend/src/routes/index.js", "module.exports = router;\n");
|
|
30
|
+
write("frontend/src/main.jsx", "");
|
|
31
|
+
write("frontend/src/routes/AppRouter.jsx", 'const X = lazy(() => 0);\n<Route path="*" />\n');
|
|
32
|
+
write("frontend/src/config/app-preset.js", "const p = { navigation: [] };\n");
|
|
33
|
+
|
|
34
|
+
const result = await check({ projectRoot: root, reporter: silent() });
|
|
35
|
+
expect(result.ok).toBe(true);
|
|
36
|
+
rmSync(root, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("fails when a blueprint anchor file is missing", async () => {
|
|
40
|
+
const root = tmp("chk-bad");
|
|
41
|
+
mkdirSync(path.join(root, "backend"), { recursive: true });
|
|
42
|
+
writeFileSync(path.join(root, "backend", "package.json"), "{}");
|
|
43
|
+
const result = await check({ projectRoot: root, reporter: silent() });
|
|
44
|
+
expect(result.ok).toBe(false);
|
|
45
|
+
expect(result.checks.some((c) => c.name.startsWith("anchor:") && !c.ok)).toBe(true);
|
|
46
|
+
rmSync(root, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("loom env", () => {
|
|
51
|
+
it("parseEnvKeys ignores comments and blank lines", () => {
|
|
52
|
+
expect(parseEnvKeys("# c\n\nPORT=3000\nDB_URL=mongodb://x\n \n")).toEqual(["PORT", "DB_URL"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reports missing keys and appends them with --sync", async () => {
|
|
56
|
+
const root = tmp("env-sync");
|
|
57
|
+
mkdirSync(root, { recursive: true });
|
|
58
|
+
writeFileSync(path.join(root, ".env.example"), "PORT=3000\nDB_URL=\nJWT_SECRET=\n");
|
|
59
|
+
writeFileSync(path.join(root, ".env"), "PORT=4000\n");
|
|
60
|
+
|
|
61
|
+
const before = await env({ projectRoot: root, reporter: silent() });
|
|
62
|
+
expect(before.missing).toEqual(["DB_URL", "JWT_SECRET"]);
|
|
63
|
+
expect(before.synced).toBe(false);
|
|
64
|
+
|
|
65
|
+
const after = await env({ projectRoot: root, sync: true, reporter: silent() });
|
|
66
|
+
expect(after.synced).toBe(true);
|
|
67
|
+
const envText = readFileSync(path.join(root, ".env"), "utf-8");
|
|
68
|
+
expect(envText).toMatch(/DB_URL=/);
|
|
69
|
+
expect(envText).toMatch(/JWT_SECRET=/);
|
|
70
|
+
|
|
71
|
+
const final = await env({ projectRoot: root, reporter: silent() });
|
|
72
|
+
expect(final.missing).toEqual([]);
|
|
73
|
+
rmSync(root, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("is a graceful no-op when there is no .env.example", async () => {
|
|
77
|
+
const root = tmp("env-none");
|
|
78
|
+
mkdirSync(root, { recursive: true });
|
|
79
|
+
const result = await env({ projectRoot: root, reporter: silent() });
|
|
80
|
+
expect(result.missing).toEqual([]);
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loom check` — project + environment health check.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the things that silently break generation later: a stale Node, a
|
|
5
|
+
* malformed blueprint, or a blueprint anchor that points at a file the project
|
|
6
|
+
* no longer has. Pure (path + reporter injectable) so it is fully testable.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { blueprintLoader } from "../blueprint/index.js";
|
|
11
|
+
import { reporterFromOptions } from "../services/index.js";
|
|
12
|
+
|
|
13
|
+
const MIN_NODE_MAJOR = 18;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run the health check.
|
|
17
|
+
* @param {object} [options] - global flags (quiet/json/...) plus:
|
|
18
|
+
* @param {string} [options.projectRoot] - defaults to cwd
|
|
19
|
+
* @param {object} [options.reporter] - injected Reporter (tests)
|
|
20
|
+
* @returns {Promise<{ ok: boolean, checks: Array<{name,ok,detail}> }>}
|
|
21
|
+
*/
|
|
22
|
+
export default async function check(options = {}) {
|
|
23
|
+
const reporter = options.reporter ?? reporterFromOptions(options);
|
|
24
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
25
|
+
const checks = [];
|
|
26
|
+
const record = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
27
|
+
|
|
28
|
+
// 1. Node runtime.
|
|
29
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
30
|
+
record(
|
|
31
|
+
"node-version",
|
|
32
|
+
nodeMajor >= MIN_NODE_MAJOR,
|
|
33
|
+
`Node ${process.versions.node} (requires >= ${MIN_NODE_MAJOR})`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// 2. Blueprint loads + validates.
|
|
37
|
+
let blueprint = null;
|
|
38
|
+
try {
|
|
39
|
+
blueprint = await blueprintLoader.load(projectRoot);
|
|
40
|
+
record("blueprint", true, blueprint.describe());
|
|
41
|
+
} catch (err) {
|
|
42
|
+
record("blueprint", false, err.message);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. Anchor integrity — every declared injection point must exist.
|
|
46
|
+
if (blueprint) {
|
|
47
|
+
for (const anchorName of Object.keys(blueprint.data.anchors)) {
|
|
48
|
+
const file = blueprint.resolveAnchorFile(anchorName, projectRoot);
|
|
49
|
+
record(`anchor:${anchorName}`, existsSync(file), path.relative(projectRoot, file));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 4. Env file present when an example exists.
|
|
54
|
+
if (existsSync(path.join(projectRoot, ".env.example"))) {
|
|
55
|
+
record("env-file", existsSync(path.join(projectRoot, ".env")), ".env (copy from .env.example)");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const failed = checks.filter((c) => !c.ok);
|
|
59
|
+
for (const c of checks) {
|
|
60
|
+
if (c.ok) reporter.info(`${c.name}: ${c.detail}`);
|
|
61
|
+
else reporter.error(`${c.name}: ${c.detail}`);
|
|
62
|
+
}
|
|
63
|
+
reporter.result({ ok: failed.length === 0, checks });
|
|
64
|
+
if (failed.length === 0) reporter.success(`All ${checks.length} checks passed`);
|
|
65
|
+
else {
|
|
66
|
+
reporter.error(`${failed.length} of ${checks.length} checks failed`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
reporter.flush();
|
|
70
|
+
return { ok: failed.length === 0, checks };
|
|
71
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `loom cleanup` — prepare a generated project for handoff.
|
|
5
|
+
*
|
|
6
|
+
* The `production` preset performs a *full de-brand*: after it runs, nothing in
|
|
7
|
+
* the project reveals that it started life as the starter kit — no `.loom/`
|
|
8
|
+
* metadata, no STARTER-KIT/TODO-Customize comments, no AUTO-GENERATED marker
|
|
9
|
+
* blocks, no starter meta-docs, no bundled CLI package, generic package names.
|
|
10
|
+
*
|
|
11
|
+
* Safety: cleanup refuses to run unless the working directory looks like a
|
|
12
|
+
* project root (has both `backend/` and `frontend/`). It is destructive — it
|
|
13
|
+
* must never run against an arbitrary directory.
|
|
14
|
+
*/
|
|
15
|
+
import path from "path";
|
|
16
|
+
import fs from "fs-extra";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import ora from "ora";
|
|
19
|
+
import inquirer from "inquirer";
|
|
20
|
+
|
|
21
|
+
const CLEANUP_PRESETS = {
|
|
22
|
+
minimal: {
|
|
23
|
+
description: "Remove demo content and obvious branding",
|
|
24
|
+
actions: ["removeDemoContent", "replaceBranding", "resetPackageIdentity"],
|
|
25
|
+
},
|
|
26
|
+
production: {
|
|
27
|
+
description: "Full de-brand — no trace of the starter kit remains",
|
|
28
|
+
// removeStarterMetadata runs first so later steps never walk the bundled CLI.
|
|
29
|
+
actions: [
|
|
30
|
+
"removeStarterMetadata",
|
|
31
|
+
"removeDemoContent",
|
|
32
|
+
"stripSourceComments",
|
|
33
|
+
"replaceBranding",
|
|
34
|
+
"resetPackageIdentity",
|
|
35
|
+
"rewriteReadme",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
template: {
|
|
39
|
+
description: "Extract reusable parts into a .template/ archive",
|
|
40
|
+
actions: ["createTemplateArchive", "resetPackageIdentity"],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Files / dirs that exist only because this is the starter kit. Relative to root.
|
|
45
|
+
const STARTER_METADATA = [
|
|
46
|
+
".loom",
|
|
47
|
+
".fsk",
|
|
48
|
+
"packages",
|
|
49
|
+
"CHANGELOG.md",
|
|
50
|
+
"CONTRIBUTING.md",
|
|
51
|
+
"CODE_OF_CONDUCT.md",
|
|
52
|
+
"SPLIT.md",
|
|
53
|
+
"frontend/src/config/DESIGN_PRESETS.md",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Branding tokens, assembled from parts so this source file never contains the
|
|
57
|
+
// contiguous strings it searches for (it cannot corrupt itself if ever walked).
|
|
58
|
+
const KIT = ["MERN Fullstack", "Fullstack", "MERN"]
|
|
59
|
+
.map((prefix) => [new RegExp(`${prefix} Starter Kit`, "g"), "Project"])
|
|
60
|
+
.concat([
|
|
61
|
+
[/MERN Starter/g, "Project"],
|
|
62
|
+
[new RegExp(["Starter", "Kit"].join(" "), "g"), "Project"],
|
|
63
|
+
]);
|
|
64
|
+
const BRANDING_REPLACEMENTS = [
|
|
65
|
+
...KIT,
|
|
66
|
+
[/fullstack-starter-kit/g, "my-project"],
|
|
67
|
+
[/mern-starter-backend/g, "backend"],
|
|
68
|
+
[/mern-starter-frontend/g, "frontend"],
|
|
69
|
+
[new RegExp("@fullstack-starter\\/cli", "g"), "my-project-cli"],
|
|
70
|
+
[/\bstackloom\b/g, "my-project-cli"],
|
|
71
|
+
[/\bStackloom\b/g, "Project"],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Comment lines that should never ship to a handed-off project.
|
|
75
|
+
const STARTER_COMMENT_PATTERNS = [
|
|
76
|
+
/^\s*\/\/\s*STARTER-KIT:.*$/gm,
|
|
77
|
+
/^\s*\/\/\s*TODO:\s*Customize.*$/gm,
|
|
78
|
+
/^\s*\/\/\s*fsk:anchor.*$/gm,
|
|
79
|
+
/^\s*\{\/\*\s*fsk:anchor.*?\*\/\}\s*$/gm,
|
|
80
|
+
/^\s*\/\/\s*loom:anchor.*$/gm,
|
|
81
|
+
/^\s*\{\/\*\s*loom:anchor.*?\*\/\}\s*$/gm,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const TEXT_EXTENSIONS = new Set([
|
|
85
|
+
".js", ".jsx", ".ts", ".tsx", ".json", ".md", ".yaml", ".yml", ".env", ".html", ".css",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** Guard: a destructive command must only run at a real project root. */
|
|
89
|
+
async function assertProjectRoot(projectRoot) {
|
|
90
|
+
const hasBackend = await fs.pathExists(path.join(projectRoot, "backend"));
|
|
91
|
+
const hasFrontend = await fs.pathExists(path.join(projectRoot, "frontend"));
|
|
92
|
+
if (!hasBackend || !hasFrontend) {
|
|
93
|
+
console.log(
|
|
94
|
+
chalk.red(
|
|
95
|
+
`✖ cleanup must run from a project root (with backend/ and frontend/).\n Current directory: ${projectRoot}`,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default async function cleanupCmd(preset) {
|
|
105
|
+
const spinner = ora();
|
|
106
|
+
const projectRoot = process.cwd();
|
|
107
|
+
|
|
108
|
+
if (!(await assertProjectRoot(projectRoot))) return;
|
|
109
|
+
|
|
110
|
+
if (!preset || !CLEANUP_PRESETS[preset]) {
|
|
111
|
+
const answers = await inquirer.prompt([
|
|
112
|
+
{
|
|
113
|
+
type: "list",
|
|
114
|
+
name: "preset",
|
|
115
|
+
message: "Select cleanup mode:",
|
|
116
|
+
choices: Object.entries(CLEANUP_PRESETS).map(([key, val]) => ({
|
|
117
|
+
name: `${key} — ${val.description}`,
|
|
118
|
+
value: key,
|
|
119
|
+
})),
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
preset = answers.preset;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const config = CLEANUP_PRESETS[preset];
|
|
126
|
+
if (!config) {
|
|
127
|
+
console.log(
|
|
128
|
+
chalk.red(
|
|
129
|
+
`✖ Unknown cleanup preset: "${preset}". Use one of: ${Object.keys(CLEANUP_PRESETS).join(", ")}`,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (preset === "production") {
|
|
137
|
+
const { confirm } = await inquirer.prompt([
|
|
138
|
+
{
|
|
139
|
+
type: "confirm",
|
|
140
|
+
name: "confirm",
|
|
141
|
+
message:
|
|
142
|
+
"Production cleanup permanently removes .loom/, the bundled CLI, and starter docs. Continue?",
|
|
143
|
+
default: false,
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
if (!confirm) {
|
|
147
|
+
console.log(chalk.gray("✖ cancelled."));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
spinner.start(`Running ${preset} cleanup...`);
|
|
153
|
+
let processed = 0;
|
|
154
|
+
for (const action of config.actions) {
|
|
155
|
+
processed += await runCleanupAction(projectRoot, action, spinner);
|
|
156
|
+
}
|
|
157
|
+
spinner.succeed(`Cleanup complete — ${processed} item(s) processed`);
|
|
158
|
+
console.log(chalk.green("\n✓ Project cleaned successfully"));
|
|
159
|
+
printNextSteps(preset);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runCleanupAction(projectRoot, action, spinner) {
|
|
163
|
+
const actions = {
|
|
164
|
+
removeDemoContent,
|
|
165
|
+
replaceBranding,
|
|
166
|
+
resetPackageIdentity,
|
|
167
|
+
removeStarterMetadata,
|
|
168
|
+
stripSourceComments,
|
|
169
|
+
rewriteReadme,
|
|
170
|
+
createTemplateArchive,
|
|
171
|
+
};
|
|
172
|
+
const fn = actions[action];
|
|
173
|
+
if (!fn) return 0;
|
|
174
|
+
spinner.text = `Running ${action}...`;
|
|
175
|
+
return fn(projectRoot);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Recursively collect files matching a predicate, skipping node_modules / .git. */
|
|
179
|
+
async function findFiles(dir, predicate) {
|
|
180
|
+
const results = [];
|
|
181
|
+
if (!(await fs.pathExists(dir))) return results;
|
|
182
|
+
for (const entry of await fs.readdir(dir)) {
|
|
183
|
+
if (entry === "node_modules" || entry === ".git") continue;
|
|
184
|
+
const full = path.join(dir, entry);
|
|
185
|
+
const stat = await fs.stat(full);
|
|
186
|
+
if (stat.isDirectory()) results.push(...(await findFiles(full, predicate)));
|
|
187
|
+
else if (predicate(full)) results.push(full);
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Remove demo pages, example components, and the demo `products` module. */
|
|
193
|
+
async function removeDemoContent(projectRoot) {
|
|
194
|
+
const targets = [
|
|
195
|
+
"frontend/src/pages/demo",
|
|
196
|
+
"frontend/src/pages/examples",
|
|
197
|
+
"frontend/src/components/demo",
|
|
198
|
+
"backend/src/modules/products",
|
|
199
|
+
"frontend/src/api/products.api.js",
|
|
200
|
+
];
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const rel of targets) {
|
|
203
|
+
const full = path.join(projectRoot, rel);
|
|
204
|
+
if (await fs.pathExists(full)) {
|
|
205
|
+
await fs.remove(full);
|
|
206
|
+
count++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return count;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Replace every starter-kit branding string across all text files. */
|
|
213
|
+
async function replaceBranding(projectRoot) {
|
|
214
|
+
const files = await findFiles(projectRoot, (f) => TEXT_EXTENSIONS.has(path.extname(f)));
|
|
215
|
+
let count = 0;
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
let content = await fs.readFile(file, "utf-8");
|
|
218
|
+
const original = content;
|
|
219
|
+
for (const [pattern, replacement] of BRANDING_REPLACEMENTS) {
|
|
220
|
+
content = content.replace(pattern, replacement);
|
|
221
|
+
}
|
|
222
|
+
if (content !== original) {
|
|
223
|
+
await fs.writeFile(file, content);
|
|
224
|
+
count++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return count;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Reset package.json `name` fields (and remove descriptions) to generic values. */
|
|
231
|
+
async function resetPackageIdentity(projectRoot) {
|
|
232
|
+
const pkgFiles = [
|
|
233
|
+
["package.json", "my-project"],
|
|
234
|
+
["backend/package.json", "backend"],
|
|
235
|
+
["frontend/package.json", "frontend"],
|
|
236
|
+
];
|
|
237
|
+
let count = 0;
|
|
238
|
+
for (const [rel, name] of pkgFiles) {
|
|
239
|
+
const full = path.join(projectRoot, rel);
|
|
240
|
+
if (!(await fs.pathExists(full))) continue;
|
|
241
|
+
const pkg = await fs.readJson(full);
|
|
242
|
+
if (pkg.name !== name || pkg.description) {
|
|
243
|
+
pkg.name = name;
|
|
244
|
+
delete pkg.description;
|
|
245
|
+
await fs.writeJson(full, pkg, { spaces: 2 });
|
|
246
|
+
count++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return count;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Delete everything that only exists because this is the starter kit. */
|
|
253
|
+
async function removeStarterMetadata(projectRoot) {
|
|
254
|
+
let count = 0;
|
|
255
|
+
for (const rel of STARTER_METADATA) {
|
|
256
|
+
const full = path.join(projectRoot, rel);
|
|
257
|
+
if (await fs.pathExists(full)) {
|
|
258
|
+
await fs.remove(full);
|
|
259
|
+
count++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return count;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Strip STARTER-KIT / TODO-Customize / loom:anchor (legacy fsk:anchor) comments and marker blocks. */
|
|
266
|
+
async function stripSourceComments(projectRoot) {
|
|
267
|
+
const roots = [path.join(projectRoot, "backend", "src"), path.join(projectRoot, "frontend", "src")];
|
|
268
|
+
let count = 0;
|
|
269
|
+
for (const root of roots) {
|
|
270
|
+
const files = await findFiles(root, (f) =>
|
|
271
|
+
[".js", ".jsx", ".ts", ".tsx"].includes(path.extname(f)),
|
|
272
|
+
);
|
|
273
|
+
for (const file of files) {
|
|
274
|
+
let content = await fs.readFile(file, "utf-8");
|
|
275
|
+
const original = content;
|
|
276
|
+
for (const pattern of STARTER_COMMENT_PATTERNS) {
|
|
277
|
+
content = content.replace(pattern, "");
|
|
278
|
+
}
|
|
279
|
+
// Remove AUTO-GENERATED marker rules/headers (keep the code between them).
|
|
280
|
+
content = content
|
|
281
|
+
.replace(/^\s*\/\/═+\s*$/gm, "")
|
|
282
|
+
.replace(/^\s*\/\/\s*AUTO-GENERATED.*$/gm, "")
|
|
283
|
+
.replace(/^\s*\/\/\s*END AUTO-GENERATED.*$/gm, "")
|
|
284
|
+
.replace(/^\s*\/\/\s*✎ CUSTOM CODE ZONE.*$/gm, "")
|
|
285
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
286
|
+
if (content !== original) {
|
|
287
|
+
await fs.writeFile(file, content);
|
|
288
|
+
count++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return count;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Replace the starter-kit README with a minimal project README. */
|
|
296
|
+
async function rewriteReadme(projectRoot) {
|
|
297
|
+
const readmePath = path.join(projectRoot, "README.md");
|
|
298
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
299
|
+
let name = "My Project";
|
|
300
|
+
if (await fs.pathExists(pkgPath)) {
|
|
301
|
+
const pkg = await fs.readJson(pkgPath);
|
|
302
|
+
if (pkg.name) name = pkg.name;
|
|
303
|
+
}
|
|
304
|
+
const content = `# ${name}
|
|
305
|
+
|
|
306
|
+
A full-stack web application.
|
|
307
|
+
|
|
308
|
+
## Getting started
|
|
309
|
+
|
|
310
|
+
\`\`\`bash
|
|
311
|
+
pnpm install
|
|
312
|
+
pnpm dev
|
|
313
|
+
\`\`\`
|
|
314
|
+
|
|
315
|
+
## Structure
|
|
316
|
+
|
|
317
|
+
- \`backend/\` — Express + MongoDB API
|
|
318
|
+
- \`frontend/\` — React + Vite client
|
|
319
|
+
`;
|
|
320
|
+
await fs.writeFile(readmePath, content);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Extract reusable building blocks into a .template/ archive directory. */
|
|
325
|
+
async function createTemplateArchive(projectRoot) {
|
|
326
|
+
const templateDir = path.join(projectRoot, ".template");
|
|
327
|
+
await fs.ensureDir(templateDir);
|
|
328
|
+
for (const rel of ["frontend/src/components/ui", "frontend/src/lib", "backend/src/utils"]) {
|
|
329
|
+
const src = path.join(projectRoot, rel);
|
|
330
|
+
if (await fs.pathExists(src)) {
|
|
331
|
+
await fs.copy(src, path.join(templateDir, rel), { overwrite: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function printNextSteps(preset) {
|
|
338
|
+
console.log(chalk.cyan("\nNext steps:"));
|
|
339
|
+
if (preset === "template") {
|
|
340
|
+
console.log(chalk.gray(" 1. Review .template/ for reusable components"));
|
|
341
|
+
console.log(chalk.gray(" 2. Distribute the archive to your team"));
|
|
342
|
+
} else {
|
|
343
|
+
console.log(chalk.gray(" 1. Review remaining files for any customizations"));
|
|
344
|
+
console.log(chalk.gray(" 2. Run 'pnpm install' to refresh dependencies"));
|
|
345
|
+
console.log(chalk.gray(" 3. Commit — the project is now fully your own"));
|
|
346
|
+
}
|
|
347
|
+
}
|