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,98 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Reporter, Clock, FixedClock, createServices, reporterFromOptions } from "../index.js";
|
|
3
|
+
|
|
4
|
+
const fakeStream = () => ({
|
|
5
|
+
chunks: [],
|
|
6
|
+
isTTY: true,
|
|
7
|
+
write(s) {
|
|
8
|
+
this.chunks.push(s);
|
|
9
|
+
return true;
|
|
10
|
+
},
|
|
11
|
+
get text() {
|
|
12
|
+
return this.chunks.join("");
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("Reporter", () => {
|
|
17
|
+
it("writes info to stdout and errors to stderr in normal mode", () => {
|
|
18
|
+
const out = fakeStream();
|
|
19
|
+
const err = fakeStream();
|
|
20
|
+
const r = new Reporter({ stdout: out, stderr: err, isTTY: true, env: {} });
|
|
21
|
+
expect(r.quiet).toBe(false);
|
|
22
|
+
r.info("hello");
|
|
23
|
+
r.success("done");
|
|
24
|
+
r.error("boom");
|
|
25
|
+
expect(out.text).toMatch(/hello/);
|
|
26
|
+
expect(out.text).toMatch(/done/);
|
|
27
|
+
expect(err.text).toMatch(/boom/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("suppresses info/success in quiet mode but keeps warn/error", () => {
|
|
31
|
+
const out = fakeStream();
|
|
32
|
+
const err = fakeStream();
|
|
33
|
+
const r = new Reporter({ stdout: out, stderr: err, quiet: true, isTTY: true, env: {} });
|
|
34
|
+
r.info("hidden");
|
|
35
|
+
r.success("hidden");
|
|
36
|
+
r.warn("careful");
|
|
37
|
+
r.error("boom");
|
|
38
|
+
expect(out.text).toBe("");
|
|
39
|
+
expect(err.text).toMatch(/careful/);
|
|
40
|
+
expect(err.text).toMatch(/boom/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("auto-enables quiet under CI and when piped (non-TTY)", () => {
|
|
44
|
+
expect(new Reporter({ stdout: fakeStream(), isTTY: true, env: { CI: "true" } }).quiet).toBe(true);
|
|
45
|
+
expect(new Reporter({ stdout: fakeStream(), isTTY: false, env: {} }).quiet).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("emits structured output only through flush() in json mode", () => {
|
|
49
|
+
const out = fakeStream();
|
|
50
|
+
const err = fakeStream();
|
|
51
|
+
const r = new Reporter({ stdout: out, stderr: err, json: true, isTTY: true, env: {} });
|
|
52
|
+
r.info("hello");
|
|
53
|
+
r.error("boom");
|
|
54
|
+
r.result({ files: 3 });
|
|
55
|
+
expect(out.text).toBe("");
|
|
56
|
+
expect(err.text).toBe("");
|
|
57
|
+
r.flush();
|
|
58
|
+
const parsed = JSON.parse(out.text);
|
|
59
|
+
expect(parsed.result).toEqual({ files: 3 });
|
|
60
|
+
expect(parsed.events.some((e) => e.type === "info" && e.message === "hello")).toBe(true);
|
|
61
|
+
expect(parsed.events.some((e) => e.type === "error")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("colours for a TTY but not for NO_COLOR, json, or explicit off", () => {
|
|
65
|
+
expect(new Reporter({ stdout: fakeStream(), isTTY: true, env: {} }).color).toBe(true);
|
|
66
|
+
expect(new Reporter({ stdout: fakeStream(), isTTY: true, env: { NO_COLOR: "1" } }).color).toBe(false);
|
|
67
|
+
expect(new Reporter({ stdout: fakeStream(), isTTY: true, json: true, env: {} }).color).toBe(false);
|
|
68
|
+
expect(new Reporter({ stdout: fakeStream(), isTTY: true, color: false, env: {} }).color).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows debug lines only when debug is enabled", () => {
|
|
72
|
+
const off = fakeStream();
|
|
73
|
+
new Reporter({ stdout: fakeStream(), stderr: off, isTTY: true, env: {} }).debug("trace");
|
|
74
|
+
expect(off.text).toBe("");
|
|
75
|
+
const on = fakeStream();
|
|
76
|
+
new Reporter({ stdout: fakeStream(), stderr: on, isTTY: true, debug: true, env: {} }).debug("trace");
|
|
77
|
+
expect(on.text).toMatch(/trace/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("Clock", () => {
|
|
82
|
+
it("Clock.now() returns a Date; FixedClock is frozen", () => {
|
|
83
|
+
expect(new Clock().now()).toBeInstanceOf(Date);
|
|
84
|
+
expect(new FixedClock("2026-05-14T00:00:00.000Z").iso()).toBe("2026-05-14T00:00:00.000Z");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("service container", () => {
|
|
89
|
+
it("createServices builds a reporter + clock", () => {
|
|
90
|
+
const svc = createServices({ reporterOptions: { isTTY: true, env: {} } });
|
|
91
|
+
expect(svc.reporter).toBeInstanceOf(Reporter);
|
|
92
|
+
expect(svc.clock).toBeInstanceOf(Clock);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reporterFromOptions maps commander-style flags", () => {
|
|
96
|
+
expect(reporterFromOptions({ json: true }).jsonMode).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock — an injectable time source.
|
|
3
|
+
*
|
|
4
|
+
* Generation records timestamps (marker headers, rollback state). Routing them
|
|
5
|
+
* through a Clock keeps those deterministic in tests instead of depending on
|
|
6
|
+
* the wall clock.
|
|
7
|
+
*/
|
|
8
|
+
export class Clock {
|
|
9
|
+
now() {
|
|
10
|
+
return new Date();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
iso() {
|
|
14
|
+
return new Date().toISOString();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A Clock frozen at a fixed instant — for tests and reproducible runs. */
|
|
19
|
+
export class FixedClock {
|
|
20
|
+
constructor(iso = "2026-01-01T00:00:00.000Z") {
|
|
21
|
+
this._iso = iso;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
now() {
|
|
25
|
+
return new Date(this._iso);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
iso() {
|
|
29
|
+
return this._iso;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services — the CLI's injectable collaborators.
|
|
3
|
+
*
|
|
4
|
+
* Commands and the generation engine receive these rather than reaching for
|
|
5
|
+
* `console` / `process` / the wall clock directly. That keeps the core logic
|
|
6
|
+
* testable with fakes (composition over globals) and is the seam the
|
|
7
|
+
* transactional pipeline builds on.
|
|
8
|
+
*/
|
|
9
|
+
import { Reporter } from "./reporter.js";
|
|
10
|
+
import { Clock } from "./clock.js";
|
|
11
|
+
|
|
12
|
+
export { Reporter } from "./reporter.js";
|
|
13
|
+
export { Clock, FixedClock } from "./clock.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a service container. Pass overrides for tests; otherwise sensible
|
|
17
|
+
* real-world defaults are constructed.
|
|
18
|
+
* @param {object} [options]
|
|
19
|
+
* @param {Reporter} [options.reporter]
|
|
20
|
+
* @param {Clock} [options.clock]
|
|
21
|
+
* @param {object} [options.reporterOptions] - forwarded to `new Reporter(...)`
|
|
22
|
+
* @returns {{ reporter: Reporter, clock: Clock }}
|
|
23
|
+
*/
|
|
24
|
+
export function createServices(options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
reporter: options.reporter ?? new Reporter(options.reporterOptions ?? {}),
|
|
27
|
+
clock: options.clock ?? new Clock(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a Reporter from a command's parsed global options (commander style).
|
|
33
|
+
* `--no-color` arrives as `color: false`; CI/non-TTY still auto-quiets.
|
|
34
|
+
* @param {{ quiet?: boolean, json?: boolean, debug?: boolean, color?: boolean }} [opts]
|
|
35
|
+
*/
|
|
36
|
+
export function reporterFromOptions(opts = {}) {
|
|
37
|
+
return new Reporter({
|
|
38
|
+
quiet: Boolean(opts.quiet),
|
|
39
|
+
json: Boolean(opts.json),
|
|
40
|
+
debug: Boolean(opts.debug),
|
|
41
|
+
color: opts.color,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporter — the CLI's single output channel.
|
|
3
|
+
*
|
|
4
|
+
* Replaces scattered console.log / chalk / ora. Every command writes through a
|
|
5
|
+
* Reporter, so output respects --quiet, --json, --no-color and CI/non-TTY
|
|
6
|
+
* auto-detection uniformly. It is injectable: tests pass fake streams + env, so
|
|
7
|
+
* output is asserted, never captured from the real console.
|
|
8
|
+
*
|
|
9
|
+
* Behaviour:
|
|
10
|
+
* - normal → info+ to stdout, success/step shown, ANSI colour on a TTY
|
|
11
|
+
* - --quiet → only warnings + errors (to stderr); also auto-on for CI / non-TTY
|
|
12
|
+
* - --json → no human lines at all; structured events collected, emitted by flush()
|
|
13
|
+
* - --debug → debug lines shown (also via LOOM_DEBUG=true)
|
|
14
|
+
*
|
|
15
|
+
* Colour uses inline ANSI — no chalk dependency — keeping the CLI lightweight.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ANSI = {
|
|
19
|
+
reset: "\x1b[0m",
|
|
20
|
+
red: "\x1b[31m",
|
|
21
|
+
green: "\x1b[32m",
|
|
22
|
+
yellow: "\x1b[33m",
|
|
23
|
+
blue: "\x1b[34m",
|
|
24
|
+
gray: "\x1b[90m",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class Reporter {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {NodeJS.WritableStream} [options.stdout]
|
|
31
|
+
* @param {NodeJS.WritableStream} [options.stderr]
|
|
32
|
+
* @param {boolean} [options.quiet] - errors + warnings only
|
|
33
|
+
* @param {boolean} [options.json] - structured output mode
|
|
34
|
+
* @param {boolean} [options.debug] - show debug lines
|
|
35
|
+
* @param {boolean} [options.color] - force colour on/off (default: auto)
|
|
36
|
+
* @param {boolean} [options.isTTY] - override TTY detection (default: auto)
|
|
37
|
+
* @param {Record<string,string>} [options.env]
|
|
38
|
+
*/
|
|
39
|
+
constructor({
|
|
40
|
+
stdout = process.stdout,
|
|
41
|
+
stderr = process.stderr,
|
|
42
|
+
quiet = false,
|
|
43
|
+
json = false,
|
|
44
|
+
debug = false,
|
|
45
|
+
color,
|
|
46
|
+
isTTY,
|
|
47
|
+
env = process.env,
|
|
48
|
+
} = {}) {
|
|
49
|
+
this.stdout = stdout;
|
|
50
|
+
this.stderr = stderr;
|
|
51
|
+
this.jsonMode = Boolean(json);
|
|
52
|
+
this.events = [];
|
|
53
|
+
this._result = null;
|
|
54
|
+
|
|
55
|
+
const ci = Boolean(env.CI && env.CI !== "false");
|
|
56
|
+
this.isTTY = isTTY ?? Boolean(stdout && stdout.isTTY);
|
|
57
|
+
// Quiet when asked, in JSON mode, under CI, or when piped (non-TTY).
|
|
58
|
+
this.quiet = this.jsonMode || quiet || ci || !this.isTTY;
|
|
59
|
+
this.debugEnabled = Boolean(debug) || env.LOOM_DEBUG === "true";
|
|
60
|
+
this.color = color ?? (!this.jsonMode && this.isTTY && !env.NO_COLOR);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_paint(code, text) {
|
|
64
|
+
return this.color ? `${code}${text}${ANSI.reset}` : text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_line(stream, text) {
|
|
68
|
+
if (!this.jsonMode) stream.write(`${text}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_record(type, message, data) {
|
|
72
|
+
this.events.push({
|
|
73
|
+
type,
|
|
74
|
+
...(message !== undefined ? { message } : {}),
|
|
75
|
+
...(data !== undefined ? { data } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Normal informational output. Hidden in quiet/json mode. */
|
|
80
|
+
info(message, data) {
|
|
81
|
+
this._record("info", message, data);
|
|
82
|
+
if (!this.quiet) this._line(this.stdout, `${this._paint(ANSI.blue, "i")} ${message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A positive outcome. Hidden in quiet/json mode. */
|
|
86
|
+
success(message, data) {
|
|
87
|
+
this._record("success", message, data);
|
|
88
|
+
if (!this.quiet) this._line(this.stdout, `${this._paint(ANSI.green, "✓")} ${message}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A progress step. Hidden in quiet/json mode. */
|
|
92
|
+
step(message, data) {
|
|
93
|
+
this._record("step", message, data);
|
|
94
|
+
if (!this.quiet) this._line(this.stdout, `${this._paint(ANSI.gray, "→")} ${message}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A warning — shown even in quiet mode (suppressed visually only in json mode). */
|
|
98
|
+
warn(message, data) {
|
|
99
|
+
this._record("warn", message, data);
|
|
100
|
+
this._line(this.stderr, `${this._paint(ANSI.yellow, "!")} ${message}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** An error — shown even in quiet mode (suppressed visually only in json mode). */
|
|
104
|
+
error(message, data) {
|
|
105
|
+
this._record("error", message, data);
|
|
106
|
+
this._line(this.stderr, `${this._paint(ANSI.red, "✗")} ${message}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Diagnostic detail — shown only when debug is enabled. */
|
|
110
|
+
debug(message, data) {
|
|
111
|
+
this._record("debug", message, data);
|
|
112
|
+
if (this.debugEnabled && !this.quiet) {
|
|
113
|
+
this._line(this.stderr, this._paint(ANSI.gray, `· ${message}`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Record a structured event with no human-facing line. */
|
|
118
|
+
event(type, data) {
|
|
119
|
+
this._record(type, undefined, data);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Set the command's final structured result (the payload for --json consumers). */
|
|
123
|
+
result(data) {
|
|
124
|
+
this._result = data;
|
|
125
|
+
this._record("result", undefined, data);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** In --json mode, emit the collected structured output. Call once at command end. */
|
|
129
|
+
flush() {
|
|
130
|
+
if (this.jsonMode) {
|
|
131
|
+
this.stdout.write(
|
|
132
|
+
`${JSON.stringify({ events: this.events, result: this._result }, null, 2)}\n`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import axiosInstance from './axiosInstance';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <%= resource.pascalName %> API
|
|
5
|
+
* Auto-generated by Stackloom CLI
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const getAll<%= resource.pluralPascal %> = async (params = {}) => {
|
|
9
|
+
const response = await axiosInstance.get('/<%= resource.pluralKebab %>', { params });
|
|
10
|
+
return response.data;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const get<%= resource.pascalName %>ById = async (id) => {
|
|
14
|
+
const response = await axiosInstance.get(`/<%= resource.pluralKebab %>/${id}`);
|
|
15
|
+
return response.data;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const create<%= resource.pascalName %> = async (data) => {
|
|
19
|
+
const response = await axiosInstance.post('/<%= resource.pluralKebab %>', data);
|
|
20
|
+
return response.data;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const update<%= resource.pascalName %> = async (id, data) => {
|
|
24
|
+
const response = await axiosInstance.put(`/<%= resource.pluralKebab %>/${id}`, data);
|
|
25
|
+
return response.data;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const delete<%= resource.pascalName %> = async (id) => {
|
|
29
|
+
const response = await axiosInstance.delete(`/<%= resource.pluralKebab %>/${id}`);
|
|
30
|
+
return response.data;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
getAll: getAll<%= resource.pluralPascal %>,
|
|
35
|
+
getById: get<%= resource.pascalName %>ById,
|
|
36
|
+
create: create<%= resource.pascalName %>,
|
|
37
|
+
update: update<%= resource.pascalName %>,
|
|
38
|
+
delete: delete<%= resource.pascalName %>,
|
|
39
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useForm } from "react-hook-form";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
Form,
|
|
6
|
+
FormControl,
|
|
7
|
+
FormField,
|
|
8
|
+
FormItem,
|
|
9
|
+
FormLabel,
|
|
10
|
+
FormMessage,
|
|
11
|
+
} from "@/components/ui/form";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from "@/components/ui/select";
|
|
21
|
+
|
|
22
|
+
// NOTE: the EJS loop variable is `f` (the resource field definition); the
|
|
23
|
+
// react-hook-form render prop is `field` (the runtime form field). Keeping them
|
|
24
|
+
// distinct avoids the shadowing bug where `f.type` checks silently read the RHF
|
|
25
|
+
// object instead of the field definition.
|
|
26
|
+
export const <%= resource.pascalName %>Form = ({ initialData, onSubmit, onCancel, isLoading }) => {
|
|
27
|
+
const form = useForm({
|
|
28
|
+
defaultValues: initialData || {
|
|
29
|
+
<% resource.fields.forEach(function(f) { -%>
|
|
30
|
+
<%- f.name %>: <%- f.type === 'boolean' ? 'false' : f.type === 'number' ? '0' : "''" %>,
|
|
31
|
+
<% }); -%>
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Form {...form}>
|
|
37
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
38
|
+
<% resource.fields.forEach(function(f) { -%>
|
|
39
|
+
<FormField
|
|
40
|
+
control={form.control}
|
|
41
|
+
name="<%- f.name %>"
|
|
42
|
+
render={({ field }) => (
|
|
43
|
+
<FormItem>
|
|
44
|
+
<FormLabel><%- f.name.charAt(0).toUpperCase() + f.name.slice(1) %></FormLabel>
|
|
45
|
+
<FormControl>
|
|
46
|
+
<% if (f.type === 'boolean') { -%>
|
|
47
|
+
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
|
48
|
+
<% } else if ((f.type === 'select' || f.type === 'multiselect') && f.special && f.special.options) { -%>
|
|
49
|
+
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
50
|
+
<SelectTrigger>
|
|
51
|
+
<SelectValue placeholder="Select <%- f.name %>" />
|
|
52
|
+
</SelectTrigger>
|
|
53
|
+
<SelectContent>
|
|
54
|
+
<% f.special.options.forEach(function(opt) { -%>
|
|
55
|
+
<SelectItem value="<%- opt %>"><%- opt %></SelectItem>
|
|
56
|
+
<% }); -%>
|
|
57
|
+
</SelectContent>
|
|
58
|
+
</Select>
|
|
59
|
+
<% } else { -%>
|
|
60
|
+
<Input type="<%- f.formInputType %>" {...field} disabled={isLoading} />
|
|
61
|
+
<% } -%>
|
|
62
|
+
</FormControl>
|
|
63
|
+
<FormMessage />
|
|
64
|
+
</FormItem>
|
|
65
|
+
)}
|
|
66
|
+
/>
|
|
67
|
+
<% }); -%>
|
|
68
|
+
<div className="flex justify-end gap-2 pt-4">
|
|
69
|
+
{onCancel && (
|
|
70
|
+
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
|
|
71
|
+
Cancel
|
|
72
|
+
</Button>
|
|
73
|
+
)}
|
|
74
|
+
<Button type="submit" disabled={isLoading}>
|
|
75
|
+
{initialData ? 'Update' : 'Create'} <%= resource.name %>
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
</Form>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Table,
|
|
4
|
+
TableBody,
|
|
5
|
+
TableCell,
|
|
6
|
+
TableHead,
|
|
7
|
+
TableHeader,
|
|
8
|
+
TableRow
|
|
9
|
+
} from "@/components/ui/table";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Edit, Trash2, Eye } from "lucide-react";
|
|
12
|
+
|
|
13
|
+
export const <%= resource.pascalName %>Table = ({ data, onEdit, onDelete, onView }) => {
|
|
14
|
+
if (!data || data.length === 0) {
|
|
15
|
+
return <div className="text-center py-10 text-muted-foreground">No <%= resource.pluralPascal.toLowerCase() %> found.</div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="rounded-md border">
|
|
20
|
+
<Table>
|
|
21
|
+
<TableHeader>
|
|
22
|
+
<TableRow>
|
|
23
|
+
<% resource.fields.filter(f => f.ui.showInTable !== false).forEach(function(field) { %>
|
|
24
|
+
<TableHead><%- field.name.charAt(0).toUpperCase() + field.name.slice(1) %></TableHead>
|
|
25
|
+
<% }); %>
|
|
26
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
27
|
+
</TableRow>
|
|
28
|
+
</TableHeader>
|
|
29
|
+
<TableBody>
|
|
30
|
+
{data.map((item) => (
|
|
31
|
+
<TableRow key={item._id}>
|
|
32
|
+
<% resource.fields.filter(f => f.ui.showInTable !== false).forEach(function(field) { %>
|
|
33
|
+
<TableCell>
|
|
34
|
+
{<% if (field.type === 'boolean') { %>
|
|
35
|
+
item.<%- field.name %> ? 'Yes' : 'No'
|
|
36
|
+
<% } else if (field.type === 'date' || field.type === 'datetime') { %>
|
|
37
|
+
new Date(item.<%- field.name %>).toLocaleDateString()
|
|
38
|
+
<% } else { %>
|
|
39
|
+
item.<%- field.name %>
|
|
40
|
+
<% } %>}
|
|
41
|
+
</TableCell>
|
|
42
|
+
<% }); %>
|
|
43
|
+
<TableCell className="text-right">
|
|
44
|
+
<div className="flex justify-end gap-2">
|
|
45
|
+
{onView && (
|
|
46
|
+
<Button variant="ghost" size="icon" onClick={() => onView(item)}>
|
|
47
|
+
<Eye className="h-4 w-4" />
|
|
48
|
+
</Button>
|
|
49
|
+
)}
|
|
50
|
+
{onEdit && (
|
|
51
|
+
<Button variant="ghost" size="icon" onClick={() => onEdit(item)}>
|
|
52
|
+
<Edit className="h-4 w-4" />
|
|
53
|
+
</Button>
|
|
54
|
+
)}
|
|
55
|
+
{onDelete && (
|
|
56
|
+
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => onDelete(item)}>
|
|
57
|
+
<Trash2 className="h-4 w-4" />
|
|
58
|
+
</Button>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
</TableCell>
|
|
62
|
+
</TableRow>
|
|
63
|
+
))}
|
|
64
|
+
</TableBody>
|
|
65
|
+
</Table>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<% if (options.architecture === 'lightweight') { -%>
|
|
2
|
+
// Lightweight architecture — CRUD logic lives inline here; no separate service layer.
|
|
3
|
+
const <%= resource.pascalName %> = require('../models/<%= resource.name %>');
|
|
4
|
+
const ApiResponse = require('../../../utils/ApiResponse');
|
|
5
|
+
const ApiError = require('../../../utils/ApiError');
|
|
6
|
+
|
|
7
|
+
// ── Create ───────────────────────────────────────────────────────────────────
|
|
8
|
+
const create = async (req, res, next) => {
|
|
9
|
+
try {
|
|
10
|
+
<% if (resource.features && resource.features.auditLog) { -%>
|
|
11
|
+
req.body.createdBy = req.user?._id;
|
|
12
|
+
<% } -%>
|
|
13
|
+
const result = await <%= resource.pascalName %>.create(req.body);
|
|
14
|
+
return res.status(201).json(new ApiResponse(201, '<%= resource.pascalName %> created', { data: result }).body);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return next(err);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ── List (paginated) ─────────────────────────────────────────────────────────
|
|
21
|
+
const list = async (req, res, next) => {
|
|
22
|
+
try {
|
|
23
|
+
const page = parseInt(req.query.page, 10) || 1;
|
|
24
|
+
const limit = parseInt(req.query.limit, 10) || 20;
|
|
25
|
+
const [data, total] = await Promise.all([
|
|
26
|
+
<%= resource.pascalName %>.find()
|
|
27
|
+
.sort({ [req.query.sort || 'createdAt']: req.query.order === 'asc' ? 1 : -1 })
|
|
28
|
+
.skip((page - 1) * limit)
|
|
29
|
+
.limit(limit)
|
|
30
|
+
.lean(),
|
|
31
|
+
<%= resource.pascalName %>.countDocuments(),
|
|
32
|
+
]);
|
|
33
|
+
return res.status(200).json(
|
|
34
|
+
new ApiResponse(200, 'Fetched', { data, total, page, limit, pages: Math.ceil(total / limit) }).body,
|
|
35
|
+
);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return next(err);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ── Get One ──────────────────────────────────────────────────────────────────
|
|
42
|
+
const getOne = async (req, res, next) => {
|
|
43
|
+
try {
|
|
44
|
+
const result = await <%= resource.pascalName %>.findById(req.params.id);
|
|
45
|
+
if (!result) throw new ApiError(404, '<%= resource.pascalName %> not found');
|
|
46
|
+
return res.status(200).json(new ApiResponse(200, 'Fetched', { data: result }).body);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return next(err);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── Update ───────────────────────────────────────────────────────────────────
|
|
53
|
+
const update = async (req, res, next) => {
|
|
54
|
+
try {
|
|
55
|
+
<% if (resource.features && resource.features.auditLog) { -%>
|
|
56
|
+
req.body.updatedBy = req.user?._id;
|
|
57
|
+
<% } -%>
|
|
58
|
+
const result = await <%= resource.pascalName %>.findByIdAndUpdate(req.params.id, req.body, {
|
|
59
|
+
new: true,
|
|
60
|
+
runValidators: true,
|
|
61
|
+
});
|
|
62
|
+
if (!result) throw new ApiError(404, '<%= resource.pascalName %> not found');
|
|
63
|
+
return res.status(200).json(new ApiResponse(200, 'Updated', { data: result }).body);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return next(err);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
70
|
+
const remove = async (req, res, next) => {
|
|
71
|
+
try {
|
|
72
|
+
const result = await <%= resource.pascalName %>.findByIdAndDelete(req.params.id);
|
|
73
|
+
if (!result) throw new ApiError(404, '<%= resource.pascalName %> not found');
|
|
74
|
+
return res.status(200).json(new ApiResponse(200, 'Deleted').body);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return next(err);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
module.exports = { create, list, getOne, update, remove };
|
|
81
|
+
<% } else { -%>
|
|
82
|
+
// Layered architecture — the controller is thin; domain logic lives in the service.
|
|
83
|
+
const service = require('../services/<%= resource.name %>.service');
|
|
84
|
+
const ApiResponse = require('../../../utils/ApiResponse');
|
|
85
|
+
|
|
86
|
+
// ── Create ───────────────────────────────────────────────────────────────────
|
|
87
|
+
const create = async (req, res, next) => {
|
|
88
|
+
try {
|
|
89
|
+
<% if (resource.features && resource.features.auditLog) { -%>
|
|
90
|
+
req.body.createdBy = req.user?._id;
|
|
91
|
+
<% } -%>
|
|
92
|
+
const result = await service.create<%= resource.pascalName %>(req.body);
|
|
93
|
+
return res.status(201).json(new ApiResponse(201, '<%= resource.pascalName %> created', { data: result }).body);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return next(err);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ── List (with pagination, filtering, sorting) ───────────────────────────────
|
|
100
|
+
const list = async (req, res, next) => {
|
|
101
|
+
try {
|
|
102
|
+
const { page = 1, limit = 20, sort = 'createdAt', order = 'desc' } = req.query;
|
|
103
|
+
const filters = {
|
|
104
|
+
page: parseInt(page, 10),
|
|
105
|
+
limit: parseInt(limit, 10),
|
|
106
|
+
sort,
|
|
107
|
+
order,
|
|
108
|
+
<% resource.fields.forEach(function(f) { -%>
|
|
109
|
+
<%= f.name %>: req.query.<%= f.name %>,
|
|
110
|
+
<% }); -%>
|
|
111
|
+
populate: req.query.populate ? req.query.populate.split(',') : [],
|
|
112
|
+
};
|
|
113
|
+
const result = await service.getAll<%= resource.pascalName %>s(filters);
|
|
114
|
+
return res.status(200).json(new ApiResponse(200, 'Fetched', result).body);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return next(err);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ── Get One ──────────────────────────────────────────────────────────────────
|
|
121
|
+
const getOne = async (req, res, next) => {
|
|
122
|
+
try {
|
|
123
|
+
const result = await service.get<%= resource.pascalName %>ById(req.params.id);
|
|
124
|
+
return res.status(200).json(new ApiResponse(200, 'Fetched', { data: result }).body);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return next(err);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── Update ───────────────────────────────────────────────────────────────────
|
|
131
|
+
const update = async (req, res, next) => {
|
|
132
|
+
try {
|
|
133
|
+
<% if (resource.features && resource.features.auditLog) { -%>
|
|
134
|
+
req.body.updatedBy = req.user?._id;
|
|
135
|
+
<% } -%>
|
|
136
|
+
const result = await service.update<%= resource.pascalName %>(req.params.id, req.body);
|
|
137
|
+
return res.status(200).json(new ApiResponse(200, 'Updated', { data: result }).body);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return next(err);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
144
|
+
const remove = async (req, res, next) => {
|
|
145
|
+
try {
|
|
146
|
+
await service.delete<%= resource.pascalName %>(req.params.id);
|
|
147
|
+
return res.status(200).json(new ApiResponse(200, 'Deleted').body);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return next(err);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
module.exports = { create, list, getOne, update, remove };
|
|
154
|
+
<% } -%>
|