create-keboola-app 0.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.
Files changed (71) hide show
  1. package/README.md +79 -0
  2. package/dist/blocks-DYU5Ke29.js +147 -0
  3. package/dist/cli.js +451 -0
  4. package/dist/extract.js +133 -0
  5. package/dist/regions-B3r5LhXl.js +95 -0
  6. package/dist/sanitize-CKs8pAzZ.js +158 -0
  7. package/dist/sanitize.js +2 -0
  8. package/dist/template/AGENTS.md +133 -0
  9. package/dist/template/README.md +56 -0
  10. package/dist/template/_env.example +6 -0
  11. package/dist/template/_gitignore +7 -0
  12. package/dist/template/_oxfmtrc.json +18 -0
  13. package/dist/template/index.html +13 -0
  14. package/dist/template/oxlint.config.ts +43 -0
  15. package/dist/template/package.json +53 -0
  16. package/dist/template/public/favicon.svg +10 -0
  17. package/dist/template/src/components/AppChrome.tsx +201 -0
  18. package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
  19. package/dist/template/src/components/AssistantPanel.tsx +96 -0
  20. package/dist/template/src/components/AssistantToggle.tsx +37 -0
  21. package/dist/template/src/components/ColorModeToggle.tsx +15 -0
  22. package/dist/template/src/components/HomePrompt.tsx +35 -0
  23. package/dist/template/src/components/KpiCard.test.tsx +20 -0
  24. package/dist/template/src/components/KpiCard.tsx +24 -0
  25. package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
  26. package/dist/template/src/components/SelectionFloater.tsx +88 -0
  27. package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
  28. package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
  29. package/dist/template/src/index.css +26 -0
  30. package/dist/template/src/main.tsx +57 -0
  31. package/dist/template/src/nav.test.ts +25 -0
  32. package/dist/template/src/nav.ts +38 -0
  33. package/dist/template/src/overview-sections.test.tsx +36 -0
  34. package/dist/template/src/overview-sections.tsx +40 -0
  35. package/dist/template/src/recipes/Chart.test.tsx +24 -0
  36. package/dist/template/src/recipes/Chart.tsx +56 -0
  37. package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
  38. package/dist/template/src/recipes/DataGrid.tsx +151 -0
  39. package/dist/template/src/routes/-fixtures.ts +113 -0
  40. package/dist/template/src/routes/-hooks.ts +15 -0
  41. package/dist/template/src/routes/-index.test.tsx +123 -0
  42. package/dist/template/src/routes/__root.tsx +20 -0
  43. package/dist/template/src/routes/index.tsx +95 -0
  44. package/dist/template/src/routes/tokens/$id.tsx +95 -0
  45. package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
  46. package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
  47. package/dist/template/src/routes/tokens/-hooks.ts +37 -0
  48. package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
  49. package/dist/template/src/routes/tokens/-nav.ts +17 -0
  50. package/dist/template/src/routes/tokens/-types.ts +22 -0
  51. package/dist/template/src/routes/tokens/index.tsx +182 -0
  52. package/dist/template/src/routes/tokens/new.tsx +163 -0
  53. package/dist/template/src/sections/chart.tsx +34 -0
  54. package/dist/template/src/sections/grid.tsx +21 -0
  55. package/dist/template/src/sections/kpi.tsx +36 -0
  56. package/dist/template/src/slices/assistant.ts +68 -0
  57. package/dist/template/src/slices/shell.ts +54 -0
  58. package/dist/template/src/store.test.tsx +92 -0
  59. package/dist/template/src/store.ts +67 -0
  60. package/dist/template/src/test-setup.ts +11 -0
  61. package/dist/template/src/vite-env.d.ts +1 -0
  62. package/dist/template/templates/agent-safety/README.md +14 -0
  63. package/dist/template/templates/agent-safety/aiexclude +25 -0
  64. package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
  65. package/dist/template/templates/agent-safety/cursorignore +25 -0
  66. package/dist/template/tsconfig.json +10 -0
  67. package/dist/template/vercel.json +4 -0
  68. package/dist/template/vite.config.ts +21 -0
  69. package/dist/template/vitest.config.ts +21 -0
  70. package/dist/template/workspace-deps.json +7 -0
  71. package/package.json +64 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # create-keboola-app
2
+
3
+ Scaffold a Keboola-shaped frontend app: Vite, React 19, TanStack Router/Query/Form, Zustand,
4
+ `@keboola/design` with `@keboola/brand-registry`, oxlint/oxfmt, vitest.
5
+
6
+ ```bash
7
+ npm create keboola-app
8
+ ```
9
+
10
+ Interactive by default. Fully flag-driven for agents and CI:
11
+
12
+ ```bash
13
+ npm create keboola-app -- --name my-app --blocks kpi,grid --yes --json
14
+ ```
15
+
16
+ ## Options
17
+
18
+ | Flag | Meaning |
19
+ | --------------------- | ----------------------------------------------------------------------------------- |
20
+ | `--name <kebab-name>` | App directory and package name. Required unless prompted. |
21
+ | `--blocks <a,b,c>` | Comma-separated block ids, or `all`, or `none`. Default: all. |
22
+ | `--yes` | Never prompt. Missing input is an error, not a question. |
23
+ | `--json` | Print the machine-readable result on stdout; logs go to stderr. |
24
+ | `--workspace` | Emit into `apps/<name>` of a Keboola monorepo checkout instead of a standalone app. |
25
+
26
+ ## Blocks
27
+
28
+ | Block | What you get |
29
+ | ----------- | -------------------------------------------------------------------------------------- |
30
+ | `kpi` | A KPI card row on the overview. |
31
+ | `chart` | A token-themed line chart card on the overview. |
32
+ | `grid` | An editable per-row budget grid (TanStack Table + design `Table`). |
33
+ | `crud` | A list → detail → form slice over `/tokens`, exercising router params, Query and Form. |
34
+ | `assistant` | A docked assistant rail, a page composer and an explain-on-selection floater. |
35
+
36
+ Every block is independent — none imports another, so any combination compiles. Blocks are
37
+ additive: pick what you need rather than taking everything and deleting, since an unpicked
38
+ block costs no files and pulls none of its npm dependencies.
39
+
40
+ Blocks join the shell through three files the generator writes rather than patches:
41
+ `src/nav.ts` (the nav tuple), `src/overview-sections.tsx` (the ordered section registry) and
42
+ `src/store.ts` (the shell slice plus the selected block slices). Add a section to a generated
43
+ app by editing those, not the app chrome.
44
+
45
+ ## Naming
46
+
47
+ `--name` must match `^[a-z][a-z0-9-]*[a-z0-9]$` — kebab-case, starting with a letter and not
48
+ ending in `-`. It becomes the directory name, the package name, and the prefix on the store's
49
+ devtools and `localStorage` keys, so two apps served from one origin never clobber each other's
50
+ saved preferences. If the target directory already exists, the CLI refuses rather than
51
+ overwriting.
52
+
53
+ ## After scaffolding
54
+
55
+ ```bash
56
+ cd my-app && pnpm install && pnpm dev
57
+ ```
58
+
59
+ Read `AGENTS.md` in the generated app, then
60
+ `node_modules/@keboola/design/AGENTS.md` — the contract for the version you actually installed.
61
+
62
+ `gate:ui` is emitted but deliberately not wired into `test`: it scores the rendered route with
63
+ `@keboola/ui-gen-bench`, which needs a model key, and a first run that fails for a missing
64
+ credential is a worse welcome than an opt-in gate.
65
+
66
+ ## Inside a Keboola monorepo checkout
67
+
68
+ `--workspace` targets a pnpm monorepo root instead of the current directory. It emits
69
+ `apps/<name>` as `@keboola/<name>`, resolves the `@keboola/*` dependencies from the workspace
70
+ rather than the registry, inherits the repo root's `engines`, and registers the new workspace in
71
+ the repo-level `knip.json`. The repo's own `scaffold:app` script is a wrapper over exactly that.
72
+
73
+ ## How the template is built
74
+
75
+ There is no committed copy of the template in this package. Its `build` script extracts the
76
+ shell and every block out of the monorepo's reference app, rewriting workspace dependency
77
+ ranges into published ones, dropping monorepo-only scripts, and replacing the app's identity
78
+ with `__APP_NAME__` / `__APP_TITLE__` placeholders. One copy in git, so the reference app and
79
+ the published template cannot drift.
@@ -0,0 +1,147 @@
1
+ //#region src/blocks.ts
2
+ const BLOCK_IDS = [
3
+ "kpi",
4
+ "chart",
5
+ "grid",
6
+ "crud",
7
+ "assistant"
8
+ ];
9
+ const isBlockId = (value) => BLOCK_IDS.includes(value);
10
+ const BLOCKS = {
11
+ kpi: {
12
+ id: "kpi",
13
+ label: "KPI cards",
14
+ summary: "A row of stat cards on the overview, reflowing on the content column width.",
15
+ files: [
16
+ "src/components/KpiCard.test.tsx",
17
+ "src/components/KpiCard.tsx",
18
+ "src/sections/kpi.tsx"
19
+ ],
20
+ deps: [],
21
+ glue: ["src/overview-sections.tsx"],
22
+ brief: "KPI cards",
23
+ route: null
24
+ },
25
+ chart: {
26
+ id: "chart",
27
+ label: "Line chart",
28
+ summary: "A token-themed line chart card; series colours are `--chart-N` palette slots.",
29
+ files: [
30
+ "src/recipes/Chart.test.tsx",
31
+ "src/recipes/Chart.tsx",
32
+ "src/sections/chart.tsx"
33
+ ],
34
+ deps: [],
35
+ glue: ["src/overview-sections.tsx"],
36
+ brief: "a revenue chart",
37
+ route: null
38
+ },
39
+ grid: {
40
+ id: "grid",
41
+ label: "Editable grid",
42
+ summary: "An editable per-row budget grid: TanStack Table for the model, design `Table` for chrome.",
43
+ files: [
44
+ "src/recipes/DataGrid.test.tsx",
45
+ "src/recipes/DataGrid.tsx",
46
+ "src/sections/grid.tsx"
47
+ ],
48
+ deps: ["@tanstack/react-table"],
49
+ glue: ["src/overview-sections.tsx"],
50
+ brief: "an editable financial grid",
51
+ route: null
52
+ },
53
+ crud: {
54
+ id: "crud",
55
+ label: "CRUD slice",
56
+ summary: "List → detail → form over `/tokens`: router params, Query and Form in one flow.",
57
+ files: [
58
+ "src/routes/tokens/-fixtures.test.ts",
59
+ "src/routes/tokens/-fixtures.ts",
60
+ "src/routes/tokens/-hooks.ts",
61
+ "src/routes/tokens/-index.test.tsx",
62
+ "src/routes/tokens/-nav.ts",
63
+ "src/routes/tokens/-types.ts",
64
+ "src/routes/tokens/$id.tsx",
65
+ "src/routes/tokens/index.tsx",
66
+ "src/routes/tokens/new.tsx"
67
+ ],
68
+ deps: ["@tanstack/react-form", "zod"],
69
+ glue: ["src/nav.ts"],
70
+ brief: "a list page reachable from the rail",
71
+ route: "/tokens"
72
+ },
73
+ assistant: {
74
+ id: "assistant",
75
+ label: "Assistant rail",
76
+ summary: "A docked assistant rail, a page composer and explain-on-selection, sharing one transcript.",
77
+ files: [
78
+ "src/components/AssistantPanel.test.tsx",
79
+ "src/components/AssistantPanel.tsx",
80
+ "src/components/AssistantToggle.tsx",
81
+ "src/components/HomePrompt.tsx",
82
+ "src/components/SelectionFloater.test.tsx",
83
+ "src/components/SelectionFloater.tsx",
84
+ "src/slices/assistant.ts"
85
+ ],
86
+ deps: [],
87
+ glue: ["src/store.ts"],
88
+ brief: "a prompt input and a docked assistant rail",
89
+ route: null
90
+ }
91
+ };
92
+ /** Files every emitted app carries, whatever the selection. */
93
+ const SHELL_FILES = [
94
+ "index.html",
95
+ "oxlint.config.ts",
96
+ "package.json",
97
+ "public/favicon.svg",
98
+ "src/components/AppChrome.tsx",
99
+ "src/components/ColorModeToggle.tsx",
100
+ "src/components/SidebarModeMenu.test.tsx",
101
+ "src/components/SidebarModeMenu.tsx",
102
+ "src/index.css",
103
+ "src/main.tsx",
104
+ "src/nav.test.ts",
105
+ "src/nav.ts",
106
+ "src/overview-sections.test.tsx",
107
+ "src/overview-sections.tsx",
108
+ "src/routes/-fixtures.ts",
109
+ "src/routes/-hooks.ts",
110
+ "src/routes/-index.test.tsx",
111
+ "src/routes/__root.tsx",
112
+ "src/routes/index.tsx",
113
+ "src/slices/shell.ts",
114
+ "src/store.test.tsx",
115
+ "src/store.ts",
116
+ "src/test-setup.ts",
117
+ "src/vite-env.d.ts",
118
+ "templates/agent-safety/README.md",
119
+ "templates/agent-safety/aiexclude",
120
+ "templates/agent-safety/claude-code.settings.json",
121
+ "templates/agent-safety/cursorignore",
122
+ "tsconfig.json",
123
+ "vercel.json",
124
+ "vite.config.ts",
125
+ "vitest.config.ts"
126
+ ];
127
+ /**
128
+ * Tracked boilerplate files the template deliberately does not carry.
129
+ *
130
+ * - `AGENTS.md`, `README.md`, `.gitignore` — replaced by this package's `templates/`, because
131
+ * the boilerplate's versions point at monorepo paths.
132
+ * - `turbo.json` — there is no turbo outside the monorepo.
133
+ * - `src/routeTree.gen.ts` — regenerated per selection by the CLI; the committed one lists
134
+ * routes a shell-only app does not have.
135
+ */
136
+ const NOT_IN_TEMPLATE = [
137
+ ".gitignore",
138
+ "AGENTS.md",
139
+ "README.md",
140
+ "src/routeTree.gen.ts",
141
+ "turbo.json"
142
+ ];
143
+ const OWNER = new Map([...SHELL_FILES.map((file) => [file, "shell"]), ...BLOCK_IDS.flatMap((id) => BLOCKS[id].files.map((file) => [file, id]))]);
144
+ /** The block that owns `path`, `'shell'`, or `null` when nothing claims it. */
145
+ const ownerOf = (path) => NOT_IN_TEMPLATE.includes(path) ? "shell" : OWNER.get(path) ?? null;
146
+ //#endregion
147
+ export { isBlockId as a, SHELL_FILES as i, BLOCK_IDS as n, ownerOf as o, NOT_IN_TEMPLATE as r, BLOCKS as t };
package/dist/cli.js ADDED
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ import { a as isBlockId, n as BLOCK_IDS, o as ownerOf, t as BLOCKS } from "./blocks-DYU5Ke29.js";
3
+ import { a as substitute, i as renderRegions, n as assertNoMarkers, r as blockIdOf } from "./regions-B3r5LhXl.js";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
+ import { argv, cwd, exit, stderr, stdin, stdout } from "node:process";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { fileURLToPath } from "node:url";
9
+ //#region src/args.ts
10
+ /**
11
+ * Accepted app names: kebab-case, starting with a letter, not ending in `-`.
12
+ *
13
+ * - The name becomes a directory, a package name and a `localStorage` key prefix, so it has to
14
+ * be valid in all three.
15
+ */
16
+ const KEBAB_NAME_RE = /^[a-z][a-z0-9-]*[a-z0-9]$/;
17
+ const VALUE_FLAGS = /* @__PURE__ */ new Set(["--name", "--blocks"]);
18
+ const BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
19
+ "--yes",
20
+ "--json",
21
+ "--workspace",
22
+ "--help",
23
+ "-h"
24
+ ]);
25
+ const HELP = `create-keboola-app — scaffold a Keboola-shaped frontend app
26
+
27
+ Usage:
28
+ npm create keboola-app
29
+ npm create keboola-app -- --name <kebab-name> [--blocks <ids>] [--yes] [--json]
30
+
31
+ Options:
32
+ --name <kebab-name> App directory and package name. A bare positional works too.
33
+ --blocks <ids> Comma-separated block ids, or \`all\`, or \`none\`. Default: all.
34
+ --yes Never prompt. Missing input is an error, not a question.
35
+ --json Print the result as JSON on stdout; logs go to stderr.
36
+ --workspace Emit into apps/<name> of a Keboola monorepo checkout.
37
+ --help Show this.
38
+
39
+ Blocks:
40
+ ${BLOCK_IDS.map((id) => ` ${id.padEnd(10)} ${BLOCKS[id].summary}`).join("\n")}
41
+
42
+ Blocks are independent: none imports another, so any combination compiles.
43
+ `;
44
+ const parseBlocks = (raw, errors) => {
45
+ if (raw === "all") return BLOCK_IDS;
46
+ if (raw === "none" || raw.trim() === "") return [];
47
+ const ids = raw.split(",").map((id) => id.trim());
48
+ const seen = /* @__PURE__ */ new Set();
49
+ const blocks = [];
50
+ for (const id of ids) {
51
+ if (!isBlockId(id)) {
52
+ errors.push(`Unknown block \`${id}\`. Valid blocks: ${BLOCK_IDS.join(", ")} (or \`all\`, or \`none\`).`);
53
+ continue;
54
+ }
55
+ if (seen.has(id)) {
56
+ errors.push(`Block \`${id}\` is listed twice in --blocks.`);
57
+ continue;
58
+ }
59
+ seen.add(id);
60
+ blocks.push(id);
61
+ }
62
+ return errors.length > 0 ? null : blocks;
63
+ };
64
+ const parseArgs = (argv) => {
65
+ const errors = [];
66
+ argv = argv.filter((arg) => arg !== "--");
67
+ const options = {
68
+ help: argv.includes("--help") || argv.includes("-h"),
69
+ name: null,
70
+ blocks: null,
71
+ yes: false,
72
+ json: false,
73
+ workspace: false
74
+ };
75
+ if (options.help) return {
76
+ ok: true,
77
+ options
78
+ };
79
+ let rawBlocks = null;
80
+ for (let i = 0; i < argv.length; i += 1) {
81
+ const arg = argv[i] ?? "";
82
+ if (VALUE_FLAGS.has(arg)) {
83
+ const value = argv[i + 1];
84
+ if (value === void 0 || value.startsWith("--")) {
85
+ errors.push(`${arg} needs a value.`);
86
+ continue;
87
+ }
88
+ if (arg === "--name") options.name = value;
89
+ else rawBlocks = value;
90
+ i += 1;
91
+ continue;
92
+ }
93
+ if (BOOLEAN_FLAGS.has(arg)) {
94
+ if (arg === "--yes") options.yes = true;
95
+ if (arg === "--json") options.json = true;
96
+ if (arg === "--workspace") options.workspace = true;
97
+ continue;
98
+ }
99
+ if (arg.startsWith("-")) {
100
+ errors.push(`Unknown flag \`${arg}\`. Run with --help for the full list.`);
101
+ continue;
102
+ }
103
+ if (options.name === null) options.name = arg;
104
+ else errors.push(`Unexpected argument \`${arg}\`. The app name is given once.`);
105
+ }
106
+ if (rawBlocks !== null) options.blocks = parseBlocks(rawBlocks, errors);
107
+ if (options.name !== null && !KEBAB_NAME_RE.test(options.name)) errors.push(`Invalid name \`${options.name}\`. Expected a kebab-case identifier of two or more characters: lower-case letters, digits and single dashes, starting with a letter and not ending in a dash. Examples: catalog, data-explorer.`);
108
+ return errors.length > 0 ? {
109
+ ok: false,
110
+ errors
111
+ } : {
112
+ ok: true,
113
+ options
114
+ };
115
+ };
116
+ //#endregion
117
+ //#region src/scaffold.ts
118
+ /** Template files that describe the template rather than belong to the app. */
119
+ const TEMPLATE_METADATA = /* @__PURE__ */ new Set(["workspace-deps.json", "package.json"]);
120
+ /** Files this package's `templates/` contributes, which the boilerplate manifest does not claim. */
121
+ const TEMPLATE_DOCS = /* @__PURE__ */ new Set([
122
+ "README.md",
123
+ "AGENTS.md",
124
+ "_gitignore",
125
+ "_env.example",
126
+ "_oxfmtrc.json"
127
+ ]);
128
+ const titleCase = (kebab) => kebab.split("-").map((part) => part === "" ? part : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
129
+ const evalCommand = (route, brief) => `npx --yes @keboola/ui-gen-bench eval --serve dist --route ${route} --brief "${brief}" --min-score 90`;
130
+ /**
131
+ * The emitted `gate:ui` script: one `ui-gen-bench eval` per scored route.
132
+ *
133
+ * - The overview's brief names the blocks that are actually on the page, because the score's
134
+ * brief-conformance axis marks down a page for missing what the brief promises.
135
+ * - A block with a `route` adds a second invocation for it.
136
+ * - Not wired into `test`: it needs a model key, and a first run that fails on a missing
137
+ * credential is a worse welcome than an opt-in gate.
138
+ */
139
+ const gateUiScript = (appTitle, blocks) => {
140
+ const clauses = blocks.filter((id) => BLOCKS[id].route === null).map((id) => BLOCKS[id].brief);
141
+ const overview = [
142
+ `${appTitle} overview route: on-brand @keboola/design shell with a full-width header`,
143
+ ...clauses,
144
+ "no runtime or a11y errors"
145
+ ].join(", ");
146
+ const extras = blocks.filter((id) => BLOCKS[id].route !== null).map((id) => evalCommand(BLOCKS[id].route ?? "/", `${appTitle} ${BLOCKS[id].brief}: on-brand @keboola/design shell, searchable table, no runtime or a11y errors`));
147
+ return [evalCommand("/", overview), ...extras].join(" && ");
148
+ };
149
+ const walk = (root, dir, out) => {
150
+ for (const entry of readdirSync(dir)) {
151
+ const path = join(dir, entry);
152
+ if (statSync(path).isDirectory()) walk(root, path, out);
153
+ else out.push(relative(root, path).split(sep).join("/"));
154
+ }
155
+ };
156
+ /**
157
+ * `_gitignore` → `.gitignore`: npm strips a real `.gitignore` out of every tarball.
158
+ *
159
+ * - Applied only to `TEMPLATE_DOCS`, the files this package's `templates/` contributes. The
160
+ * rule exists for them and nothing else.
161
+ * - Not a bare "basename starts with `_`" rule: TanStack's root route is `src/routes/__root.tsx`,
162
+ * which such a rule renames to `.root.tsx` — the route tree then has no root and the emitted
163
+ * app fails to generate one.
164
+ */
165
+ const outputPath = (file) => {
166
+ const name = basename(file);
167
+ if (!TEMPLATE_DOCS.has(file) || !name.startsWith("_")) return file;
168
+ return join(dirname(file), `.${name.slice(1)}`).split(sep).join("/");
169
+ };
170
+ const decideFor = (selected) => (regionId) => {
171
+ if (regionId === "internal") return "drop";
172
+ const id = blockIdOf(regionId);
173
+ if (id === null || !isBlockId(id)) throw new Error(`unknown region id "${regionId}" in the template`);
174
+ return selected.has(id) ? "keep-without-markers" : "drop";
175
+ };
176
+ const writeManifest = (opts) => {
177
+ const { templateDir, targetDir, appName, blocks, workspace, engines, packageName } = opts;
178
+ const manifest = JSON.parse(readFileSync(join(templateDir, "package.json"), "utf8"));
179
+ const workspaceDeps = JSON.parse(readFileSync(join(templateDir, "workspace-deps.json"), "utf8"));
180
+ const pkg = manifest;
181
+ const selected = new Set(blocks);
182
+ const droppedDeps = new Set(BLOCK_IDS.filter((id) => !selected.has(id)).flatMap((id) => BLOCKS[id].deps));
183
+ const restore = new Set(workspaceDeps);
184
+ const resolveGroup = (group) => Object.fromEntries(Object.entries(group).filter(([name]) => !droppedDeps.has(name)).map(([name, range]) => [name, workspace && restore.has(name) ? "workspace:^" : range]));
185
+ pkg.name = packageName;
186
+ if (engines !== void 0) pkg.engines = { ...engines };
187
+ pkg.dependencies = resolveGroup(pkg.dependencies);
188
+ pkg.devDependencies = resolveGroup(pkg.devDependencies);
189
+ pkg.scripts = {
190
+ ...pkg.scripts,
191
+ "gate:ui": gateUiScript(titleCase(appName), blocks),
192
+ ...workspace ? {
193
+ "knip:check": `pnpm --dir ../.. exec knip --workspace apps/${appName}`,
194
+ "knip:fix": `pnpm --dir ../.. exec knip --workspace apps/${appName} --fix-type types,exports,files`
195
+ } : {}
196
+ };
197
+ writeFileSync(join(targetDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
198
+ };
199
+ const scaffold = (opts) => {
200
+ const { templateDir, targetDir, appName, blocks, workspace } = opts;
201
+ if (existsSync(targetDir)) throw new Error(`${targetDir} already exists. Choose another name or remove the directory first.`);
202
+ const selected = new Set(blocks);
203
+ const decide = decideFor(selected);
204
+ const vars = {
205
+ APP_NAME: appName,
206
+ APP_TITLE: titleCase(appName)
207
+ };
208
+ const templateFiles = [];
209
+ walk(templateDir, templateDir, templateFiles);
210
+ mkdirSync(targetDir, { recursive: true });
211
+ const written = [];
212
+ for (const file of templateFiles.sort()) {
213
+ if (TEMPLATE_METADATA.has(file)) continue;
214
+ const owner = ownerOf(file);
215
+ if (owner === null && !TEMPLATE_DOCS.has(file)) throw new Error(`${file} is in the template but no block or the shell claims it — update src/blocks.ts`);
216
+ if (owner !== null && owner !== "shell" && !selected.has(owner)) continue;
217
+ const out = join(targetDir, outputPath(file));
218
+ mkdirSync(dirname(out), { recursive: true });
219
+ const buffer = readFileSync(join(templateDir, file));
220
+ if (buffer.subarray(0, 8e3).includes(0)) writeFileSync(out, buffer);
221
+ else {
222
+ const rendered = substitute(renderRegions(buffer.toString("utf8"), decide), vars);
223
+ assertNoMarkers(outputPath(file), rendered);
224
+ writeFileSync(out, rendered);
225
+ }
226
+ written.push(outputPath(file));
227
+ }
228
+ const packageName = workspace ? `@keboola/${appName}` : appName;
229
+ writeManifest({
230
+ ...opts,
231
+ packageName
232
+ });
233
+ written.push("package.json");
234
+ return {
235
+ path: targetDir,
236
+ appName,
237
+ packageName,
238
+ blocks,
239
+ files: written.sort()
240
+ };
241
+ };
242
+ //#endregion
243
+ //#region src/cli.ts
244
+ const TEMPLATE_DIR = fileURLToPath(new URL("./template/", import.meta.url));
245
+ /**
246
+ * What to do when there is nobody to ask.
247
+ *
248
+ * - A missing `--name` is an error: there is no sane default for it, and a prompt an agent
249
+ * cannot answer is worse than a non-zero exit.
250
+ * - A missing `--blocks` defaults to every block, which is the shape `apps/boilerplate` has and
251
+ * the most useful thing to hand someone who did not choose.
252
+ */
253
+ const resolveNonInteractive = (options) => {
254
+ if (options.name === null) return {
255
+ ok: false,
256
+ errors: ["No app name, and no terminal to prompt on. Pass --name <kebab-name> (or a bare positional)."]
257
+ };
258
+ return {
259
+ ok: true,
260
+ name: options.name,
261
+ blocks: options.blocks ?? BLOCK_IDS
262
+ };
263
+ };
264
+ const nextSteps = (result, workspace) => workspace ? [
265
+ "pnpm install",
266
+ `pnpm dev --filter=${result.packageName}`,
267
+ `pnpm lint --filter=${result.packageName} && pnpm type-check --filter=${result.packageName} && pnpm test --filter=${result.packageName}`,
268
+ `Read apps/${result.appName}/AGENTS.md, then node_modules/@keboola/design/AGENTS.md.`
269
+ ] : [
270
+ `cd ${result.appName}`,
271
+ "pnpm install",
272
+ "pnpm dev",
273
+ "Read AGENTS.md, then node_modules/@keboola/design/AGENTS.md."
274
+ ];
275
+ /**
276
+ * Writes `src/routeTree.gen.ts` for the routes that were actually copied.
277
+ *
278
+ * - Same code path the Vite plugin takes, so the tree matches what `pnpm dev` would write.
279
+ * - Run now rather than left to the first build, so the emitted app type-checks before it is
280
+ * ever built.
281
+ * - A failure is fatal: an app whose `pnpm type-check` fails on a missing route tree is not a
282
+ * deliverable, and the message says how to recover.
283
+ */
284
+ const generateRouteTree = async (appDir) => {
285
+ try {
286
+ const { Generator, getConfig } = await import("@tanstack/router-generator");
287
+ await new Generator({
288
+ config: getConfig({
289
+ quoteStyle: "single",
290
+ semicolons: true
291
+ }, appDir),
292
+ root: appDir
293
+ }).run();
294
+ } catch (error) {
295
+ throw new Error(`Could not generate src/routeTree.gen.ts in ${appDir}: ${error instanceof Error ? error.message : String(error)}\n The app is otherwise complete — run \`pnpm install && pnpm build\` in it (the Vite plugin writes the file), or remove the directory and try again.`);
296
+ }
297
+ };
298
+ /**
299
+ * Adds the new app to the monorepo's knip config.
300
+ *
301
+ * - knip keys its per-workspace entry and project globs by path, so an unregistered app
302
+ * inherits knip's defaults and reports its postcss config as unused.
303
+ * - The boilerplate's block is copied as-is: every scaffolded app has the same `src/` layout.
304
+ */
305
+ const registerKnipWorkspace = (repoRoot, appName) => {
306
+ const knipPath = join(repoRoot, "knip.json");
307
+ if (!existsSync(knipPath)) return;
308
+ const config = JSON.parse(readFileSync(knipPath, "utf8"));
309
+ const workspaces = Reflect.get(config, "workspaces");
310
+ if (typeof workspaces !== "object" || workspaces === null) return;
311
+ const source = Reflect.get(workspaces, "apps/boilerplate");
312
+ if (source === void 0) return;
313
+ Reflect.set(workspaces, `apps/${appName}`, JSON.parse(JSON.stringify(source)));
314
+ writeFileSync(knipPath, `${JSON.stringify(config, null, 2)}\n`);
315
+ };
316
+ /**
317
+ * The monorepo root's own `engines`, for the emitted app to inherit under `--workspace`.
318
+ *
319
+ * - syncpack (`pnpm deps:check`) compares `engines.node` across every workspace, so an app
320
+ * carrying the template's standalone floor makes the whole repo's check red.
321
+ * - `undefined` when the root declares none — `scaffold` then keeps the template's value.
322
+ */
323
+ const monorepoEngines = (root) => {
324
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
325
+ const engines = Reflect.get(manifest, "engines");
326
+ if (typeof engines !== "object" || engines === null) return void 0;
327
+ return Object.fromEntries(Object.entries(engines).filter((entry) => typeof entry[1] === "string"));
328
+ };
329
+ const promptFor = async (options) => {
330
+ const rl = createInterface({
331
+ input: stdin,
332
+ output: stdout
333
+ });
334
+ try {
335
+ let name = options.name;
336
+ while (name === null || !KEBAB_NAME_RE.test(name)) {
337
+ if (name !== null) stdout.write(` \`${name}\` is not a kebab-case name.\n`);
338
+ name = (await rl.question("App name (kebab-case): ")).trim();
339
+ }
340
+ if (options.blocks !== null) return {
341
+ name,
342
+ blocks: options.blocks
343
+ };
344
+ stdout.write("\nBlocks (blank for all):\n");
345
+ for (const id of BLOCK_IDS) stdout.write(` ${id.padEnd(10)} ${BLOCKS[id].summary}\n`);
346
+ const answer = (await rl.question("\nComma-separated ids, or `none`: ")).trim();
347
+ if (answer === "") return {
348
+ name,
349
+ blocks: BLOCK_IDS
350
+ };
351
+ if (answer === "none") return {
352
+ name,
353
+ blocks: []
354
+ };
355
+ const ids = answer.split(",").map((id) => id.trim());
356
+ const unknown = ids.filter((id) => !isBlockId(id));
357
+ if (unknown.length > 0) throw new Error(`Unknown block(s): ${unknown.join(", ")}. Valid blocks: ${BLOCK_IDS.join(", ")}.`);
358
+ return {
359
+ name,
360
+ blocks: ids.filter(isBlockId)
361
+ };
362
+ } finally {
363
+ rl.close();
364
+ }
365
+ };
366
+ const main = async () => {
367
+ const parsed = parseArgs(argv.slice(2));
368
+ if (!parsed.ok) {
369
+ for (const error of parsed.errors) stderr.write(`✗ ${error}\n`);
370
+ stderr.write(" Run with --help for the full flag list.\n");
371
+ exit(1);
372
+ }
373
+ const { options } = parsed;
374
+ if (options.help) {
375
+ stdout.write(HELP);
376
+ return;
377
+ }
378
+ const log = (line) => {
379
+ (options.json ? stderr : stdout).write(`${line}\n`);
380
+ };
381
+ const interactive = !options.yes && stdin.isTTY === true;
382
+ let name;
383
+ let blocks;
384
+ if (interactive) ({name, blocks} = await promptFor(options));
385
+ else {
386
+ const resolved = resolveNonInteractive(options);
387
+ if (!resolved.ok) {
388
+ for (const error of resolved.errors) stderr.write(`✗ ${error}\n`);
389
+ exit(1);
390
+ }
391
+ ({name, blocks} = resolved);
392
+ }
393
+ const root = cwd();
394
+ if (options.workspace && !existsSync(join(root, "pnpm-workspace.yaml"))) {
395
+ stderr.write("✗ --workspace expects the current directory to be a pnpm monorepo root (no pnpm-workspace.yaml here).\n");
396
+ exit(1);
397
+ }
398
+ const targetDir = options.workspace ? resolve(root, "apps", name) : resolve(root, name);
399
+ const result = scaffold({
400
+ templateDir: TEMPLATE_DIR,
401
+ targetDir,
402
+ appName: name,
403
+ blocks,
404
+ workspace: options.workspace,
405
+ engines: options.workspace ? monorepoEngines(root) : void 0
406
+ });
407
+ await generateRouteTree(targetDir);
408
+ if (options.workspace) registerKnipWorkspace(root, name);
409
+ const steps = nextSteps(result, options.workspace);
410
+ if (options.json) stdout.write(`${JSON.stringify({
411
+ path: result.path,
412
+ appName: result.appName,
413
+ packageName: result.packageName,
414
+ blocks: result.blocks,
415
+ files: result.files.length,
416
+ nextSteps: steps
417
+ }, null, 2)}\n`);
418
+ log(`✓ ${result.packageName} — ${result.files.length} files, blocks: ${blocks.join(", ") || "none"}`);
419
+ log("");
420
+ log("Next steps");
421
+ steps.forEach((step, index) => log(` ${index + 1}. ${step}`));
422
+ };
423
+ /**
424
+ * Whether this module is the process entry point.
425
+ *
426
+ * - `realpathSync` on both sides, because the installed `bin` is a symlink: npm and pnpm invoke
427
+ * `node_modules/.bin/create-keboola-app`, so `argv[1]` is the link, not `dist/cli.js`. An
428
+ * `endsWith('cli.js')` check — which is enough for `extract.ts` and `sanitize.ts`, both
429
+ * invoked by path — would answer `false` for every real install.
430
+ * - Guarded at all so importing the module does not run it: `cli.test.ts` imports
431
+ * `resolveNonInteractive` and `nextSteps`, and an unguarded `main()` would scaffold, then
432
+ * `exit(1)` out of the test worker.
433
+ */
434
+ const isEntryPoint = () => {
435
+ const entry = argv[1];
436
+ if (entry === void 0) return false;
437
+ try {
438
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
439
+ } catch (error) {
440
+ stderr.write(`! could not resolve the entry point, not running: ${String(error)}\n`);
441
+ return false;
442
+ }
443
+ };
444
+ if (isEntryPoint()) try {
445
+ await main();
446
+ } catch (error) {
447
+ stderr.write(`✗ ${error instanceof Error ? error.message : String(error)}\n`);
448
+ exit(1);
449
+ }
450
+ //#endregion
451
+ export { nextSteps, resolveNonInteractive };