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.
- package/README.md +79 -0
- package/dist/blocks-DYU5Ke29.js +147 -0
- package/dist/cli.js +451 -0
- package/dist/extract.js +133 -0
- package/dist/regions-B3r5LhXl.js +95 -0
- package/dist/sanitize-CKs8pAzZ.js +158 -0
- package/dist/sanitize.js +2 -0
- package/dist/template/AGENTS.md +133 -0
- package/dist/template/README.md +56 -0
- package/dist/template/_env.example +6 -0
- package/dist/template/_gitignore +7 -0
- package/dist/template/_oxfmtrc.json +18 -0
- package/dist/template/index.html +13 -0
- package/dist/template/oxlint.config.ts +43 -0
- package/dist/template/package.json +53 -0
- package/dist/template/public/favicon.svg +10 -0
- package/dist/template/src/components/AppChrome.tsx +201 -0
- package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
- package/dist/template/src/components/AssistantPanel.tsx +96 -0
- package/dist/template/src/components/AssistantToggle.tsx +37 -0
- package/dist/template/src/components/ColorModeToggle.tsx +15 -0
- package/dist/template/src/components/HomePrompt.tsx +35 -0
- package/dist/template/src/components/KpiCard.test.tsx +20 -0
- package/dist/template/src/components/KpiCard.tsx +24 -0
- package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
- package/dist/template/src/components/SelectionFloater.tsx +88 -0
- package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
- package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
- package/dist/template/src/index.css +26 -0
- package/dist/template/src/main.tsx +57 -0
- package/dist/template/src/nav.test.ts +25 -0
- package/dist/template/src/nav.ts +38 -0
- package/dist/template/src/overview-sections.test.tsx +36 -0
- package/dist/template/src/overview-sections.tsx +40 -0
- package/dist/template/src/recipes/Chart.test.tsx +24 -0
- package/dist/template/src/recipes/Chart.tsx +56 -0
- package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
- package/dist/template/src/recipes/DataGrid.tsx +151 -0
- package/dist/template/src/routes/-fixtures.ts +113 -0
- package/dist/template/src/routes/-hooks.ts +15 -0
- package/dist/template/src/routes/-index.test.tsx +123 -0
- package/dist/template/src/routes/__root.tsx +20 -0
- package/dist/template/src/routes/index.tsx +95 -0
- package/dist/template/src/routes/tokens/$id.tsx +95 -0
- package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
- package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
- package/dist/template/src/routes/tokens/-hooks.ts +37 -0
- package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
- package/dist/template/src/routes/tokens/-nav.ts +17 -0
- package/dist/template/src/routes/tokens/-types.ts +22 -0
- package/dist/template/src/routes/tokens/index.tsx +182 -0
- package/dist/template/src/routes/tokens/new.tsx +163 -0
- package/dist/template/src/sections/chart.tsx +34 -0
- package/dist/template/src/sections/grid.tsx +21 -0
- package/dist/template/src/sections/kpi.tsx +36 -0
- package/dist/template/src/slices/assistant.ts +68 -0
- package/dist/template/src/slices/shell.ts +54 -0
- package/dist/template/src/store.test.tsx +92 -0
- package/dist/template/src/store.ts +67 -0
- package/dist/template/src/test-setup.ts +11 -0
- package/dist/template/src/vite-env.d.ts +1 -0
- package/dist/template/templates/agent-safety/README.md +14 -0
- package/dist/template/templates/agent-safety/aiexclude +25 -0
- package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
- package/dist/template/templates/agent-safety/cursorignore +25 -0
- package/dist/template/tsconfig.json +10 -0
- package/dist/template/vercel.json +4 -0
- package/dist/template/vite.config.ts +21 -0
- package/dist/template/vitest.config.ts +21 -0
- package/dist/template/workspace-deps.json +7 -0
- 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 };
|