@brendanbank/create-atrium-host 0.15.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 (40) hide show
  1. package/LICENCE.md +25 -0
  2. package/README.md +94 -0
  3. package/bin/index.js +8 -0
  4. package/package.json +40 -0
  5. package/src/cli.js +279 -0
  6. package/src/git.js +48 -0
  7. package/src/names.js +91 -0
  8. package/src/render.js +90 -0
  9. package/template/.env.example +33 -0
  10. package/template/.github/dependabot.yml +56 -0
  11. package/template/.github/workflows/ci.yml +103 -0
  12. package/template/Dockerfile +46 -0
  13. package/template/Makefile +67 -0
  14. package/template/README.md +124 -0
  15. package/template/backend/alembic/env.py +116 -0
  16. package/template/backend/alembic/script.py.mako +27 -0
  17. package/template/backend/alembic/versions/0001_init.py +92 -0
  18. package/template/backend/alembic.ini +50 -0
  19. package/template/backend/pyproject.toml +17 -0
  20. package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
  21. package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
  22. package/template/backend/src/__HOST_PKG__/models.py +51 -0
  23. package/template/backend/src/__HOST_PKG__/router.py +73 -0
  24. package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
  25. package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
  26. package/template/backend/tests/test_smoke.py +47 -0
  27. package/template/compose.yaml +96 -0
  28. package/template/frontend/package.json +36 -0
  29. package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
  30. package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
  31. package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
  32. package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
  33. package/template/frontend/src/api.ts +38 -0
  34. package/template/frontend/src/main.tsx +55 -0
  35. package/template/frontend/src/queryClient.ts +11 -0
  36. package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
  37. package/template/frontend/src/test/setup.ts +21 -0
  38. package/template/frontend/tsconfig.json +21 -0
  39. package/template/frontend/vite.config.ts +7 -0
  40. package/template/frontend/vitest.config.ts +15 -0
package/LICENCE.md ADDED
@@ -0,0 +1,25 @@
1
+ # BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Brendan Bank
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice,
9
+ this list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
19
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
20
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
21
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
22
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
24
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
25
+ POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @brendanbank/create-atrium-host
2
+
3
+ Scaffolder for a new [atrium](https://github.com/brendanbank/atrium)
4
+ host extension. One command, working repo: backend Python package +
5
+ frontend Vite host bundle + compose stack + CI, all wired against
6
+ atrium's published image and host SDK packages.
7
+
8
+ ## Usage
9
+
10
+ ```sh
11
+ npx @brendanbank/create-atrium-host casa-del-leone
12
+ ```
13
+
14
+ Walks you through a short prompt sequence (Python module name, brand
15
+ display name, primary colour) and emits a git-initialised repo that's
16
+ ready to `make dev-bootstrap`.
17
+
18
+ For non-interactive use:
19
+
20
+ ```sh
21
+ npx @brendanbank/create-atrium-host test-host --yes-defaults
22
+ ```
23
+
24
+ ### Options
25
+
26
+ | Flag | Effect |
27
+ |-------------------------|-------------------------------------------------------|
28
+ | `--yes-defaults` | Skip prompts; derive everything from the project name |
29
+ | `--no-git` | Skip `git init` + initial commit |
30
+ | `--out <dir>` | Output directory (default: `./<name>`) |
31
+ | `--atrium <version>` | Atrium image / SDK version pin (default: `0.15`) |
32
+ | `-h`, `--help` | Show help |
33
+
34
+ ## What it emits
35
+
36
+ ```
37
+ casa-del-leone/
38
+ Dockerfile frontend-builder + FROM atrium runtime
39
+ compose.yaml api + worker + mysql
40
+ .env.example secrets template (copy to .env)
41
+ Makefile dev-bootstrap / migrate / seed-* / test
42
+ backend/ Python host package (`casa_del_leone`)
43
+ pyproject.toml
44
+ alembic.ini
45
+ alembic/ alembic_version_app chain
46
+ src/casa_del_leone/
47
+ bootstrap.py init_app + init_worker
48
+ models.py HostBase + demo singleton
49
+ router.py /casa_del_leone/state + /casa_del_leone/bump
50
+ scripts/seed_host_bundle.py
51
+ tests/ pytest smoke tests
52
+ frontend/ Vite library project (single main.js)
53
+ package.json @brendanbank/atrium-host-bundle-utils + -types + -test-utils
54
+ vite.config.ts hostBundleConfig({ entry: 'src/main.tsx' })
55
+ src/
56
+ main.tsx ~10 lines of registry calls — no wrapper element code
57
+ api.ts plain fetch with credentials: include
58
+ queryClient.ts
59
+ CasaDelLeoneWidget.tsx home widget + admin tab + page demo
60
+ CasaDelLeonePage.tsx
61
+ CasaDelLeoneAdminTab.tsx
62
+ CasaDelLeoneProfileItem.tsx
63
+ src/test/ vitest setup + worked example using @brendanbank/atrium-test-utils
64
+ .github/
65
+ workflows/ci.yml typecheck + tests + smoke
66
+ dependabot.yml weekly grouped bumps
67
+ ```
68
+
69
+ The seeded BrandConfig (in the first migration) materialises the
70
+ brand name + primary colour into `app_settings[brand]` so the SPA
71
+ renders with the host's identity from the first page load.
72
+
73
+ ## What atrium gives you (don't reimplement)
74
+
75
+ The host repo's README enumerates this — auth, RBAC, audit, email
76
+ pipeline, scheduled jobs, notifications, admin shell, theme, i18n,
77
+ maintenance mode, account deletion. Atrium ships all of it; the
78
+ scaffolder leaves only the domain-specific surface for you to fill
79
+ in.
80
+
81
+ ## Updating the template
82
+
83
+ The template lives under `template/` with `__HOST_NAME__`,
84
+ `__HOST_PKG__`, `__BRAND_NAME__`, `__BRAND_PASCAL__`, `__BRAND_PRIMARY__`,
85
+ `__ATRIUM_VERSION__` placeholders. Add a new placeholder by:
86
+
87
+ 1. Use it in template files / paths.
88
+ 2. Add a derivation + validator in `src/names.js`.
89
+ 3. Add it to the prompt list (or default block) in `src/cli.js`.
90
+ 4. Map it to the upper-snake key in `buildVars()`.
91
+
92
+ The CI smoke target is `pnpm smoke` — it scaffolds `__smoke_test_host__`
93
+ into `/tmp` non-interactively and reports failure if anything in the
94
+ template ends up unparseable.
package/bin/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 Brendan Bank
3
+ // SPDX-License-Identifier: BSD-2-Clause
4
+
5
+ // Thin shim that forwards into the CLI module. Lives separately so
6
+ // `package.json#bin` can stay pointed at a stable path while the
7
+ // implementation moves.
8
+ import('../src/cli.js').then(({ run }) => run(process.argv.slice(2)));
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@brendanbank/create-atrium-host",
3
+ "version": "0.15.0",
4
+ "description": "Scaffolder for new atrium host extensions: backend Python package + frontend Vite bundle + compose stack + CI, all wired against atrium's published image and host SDK packages. Run via `npx @brendanbank/create-atrium-host <name>`.",
5
+ "license": "BSD-2-Clause",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-atrium-host": "bin/index.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "template",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "dependencies": {
20
+ "kleur": "^4.1.5",
21
+ "prompts": "^2.4.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^25.6.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/brendanbank/atrium.git",
32
+ "directory": "packages/create-atrium-host"
33
+ },
34
+ "scripts": {
35
+ "build": "echo 'create-atrium-host is plain ESM JS; no build step.'",
36
+ "typecheck": "echo 'create-atrium-host is plain ESM JS; no typecheck step.'",
37
+ "test": "node --test test/names.test.js test/render.test.js",
38
+ "smoke": "node bin/index.js smoke-test-host --yes-defaults --no-git --out /tmp/atrium-scaffold-smoke && echo 'smoke ok'"
39
+ }
40
+ }
package/src/cli.js ADDED
@@ -0,0 +1,279 @@
1
+ // Copyright (c) 2026 Brendan Bank
2
+ // SPDX-License-Identifier: BSD-2-Clause
3
+
4
+ // CLI entry point.
5
+ //
6
+ // create-atrium-host <name> [--yes-defaults] [--no-git] [--out <dir>]
7
+ //
8
+ // Without --yes-defaults the user is prompted for the host package
9
+ // name, brand display name, primary colour, and atrium image
10
+ // version. `--yes-defaults` derives everything from the project name
11
+ // and skips prompts (used by CI smoke tests and anyone who just wants
12
+ // the baseline).
13
+
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, resolve, isAbsolute } from 'node:path';
16
+ import { stat } from 'node:fs/promises';
17
+
18
+ import kleur from 'kleur';
19
+ import prompts from 'prompts';
20
+
21
+ import {
22
+ brandPascal,
23
+ defaultBrandName,
24
+ defaultHostPkg,
25
+ validateBrandName,
26
+ validateBrandPrimary,
27
+ validateHostName,
28
+ validateHostPkg,
29
+ } from './names.js';
30
+ import { ensureEmptyDir, pathExists, renderTemplate } from './render.js';
31
+ import { gitAvailable, initRepo } from './git.js';
32
+
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = dirname(__filename);
35
+ const TEMPLATE_DIR = resolve(__dirname, '..', 'template');
36
+
37
+ // The atrium image + SDK packages version in lockstep. Bump this when
38
+ // cutting a new atrium release so freshly scaffolded hosts pin against
39
+ // the right line.
40
+ const DEFAULT_ATRIUM_VERSION = '0.15';
41
+
42
+ const HELP = `
43
+ ${kleur.bold('create-atrium-host')} — scaffold a new atrium host extension
44
+
45
+ Usage:
46
+ npx @brendanbank/create-atrium-host <name> [options]
47
+
48
+ Arguments:
49
+ <name> Project name (lowercase, dashes; e.g. casa-del-leone)
50
+
51
+ Options:
52
+ --yes-defaults Skip prompts; derive everything from <name>
53
+ --no-git Skip ${kleur.cyan('git init')} + initial commit
54
+ --out <dir> Output directory (default: ./<name>)
55
+ --atrium <version> Atrium image / SDK version pin (default: ${DEFAULT_ATRIUM_VERSION})
56
+ -h, --help Show this help
57
+
58
+ The scaffolder emits a working repo: ${kleur.bold('make dev-bootstrap')} brings up the
59
+ stack, ${kleur.bold('make seed-admin')} + ${kleur.bold('make seed-bundle')} finish wiring it, and
60
+ ${kleur.bold('make test')} is green out of the box.
61
+ `;
62
+
63
+ function parseArgs(argv) {
64
+ const args = { positional: [], flags: {}, options: {} };
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const a = argv[i];
67
+ if (a === '-h' || a === '--help') {
68
+ args.flags.help = true;
69
+ } else if (a === '--yes-defaults') {
70
+ args.flags.yesDefaults = true;
71
+ } else if (a === '--no-git') {
72
+ args.flags.noGit = true;
73
+ } else if (a === '--out') {
74
+ args.options.out = argv[++i];
75
+ } else if (a.startsWith('--out=')) {
76
+ args.options.out = a.slice('--out='.length);
77
+ } else if (a === '--atrium') {
78
+ args.options.atrium = argv[++i];
79
+ } else if (a.startsWith('--atrium=')) {
80
+ args.options.atrium = a.slice('--atrium='.length);
81
+ } else if (a.startsWith('--')) {
82
+ throw new Error(`unknown option: ${a}`);
83
+ } else {
84
+ args.positional.push(a);
85
+ }
86
+ }
87
+ return args;
88
+ }
89
+
90
+ async function gatherAnswers({ hostNameArg, yesDefaults, atriumVersion }) {
91
+ // Fixed answers regardless of mode — derived from CLI arg so tests
92
+ // can pass them in deterministically.
93
+ const fixed = {
94
+ hostName: hostNameArg,
95
+ atriumVersion,
96
+ };
97
+
98
+ if (yesDefaults) {
99
+ const hostPkg = defaultHostPkg(hostNameArg);
100
+ const brandName = defaultBrandName(hostNameArg);
101
+ return {
102
+ ...fixed,
103
+ hostPkg,
104
+ brandName,
105
+ brandPrimary: 'blue',
106
+ };
107
+ }
108
+
109
+ const responses = await prompts([
110
+ {
111
+ type: 'text',
112
+ name: 'hostPkg',
113
+ message: 'Python module name',
114
+ initial: defaultHostPkg(hostNameArg),
115
+ validate: validateHostPkg,
116
+ },
117
+ {
118
+ type: 'text',
119
+ name: 'brandName',
120
+ message: 'Brand display name',
121
+ initial: defaultBrandName(hostNameArg),
122
+ validate: validateBrandName,
123
+ },
124
+ {
125
+ type: 'select',
126
+ name: 'brandPrimary',
127
+ message: 'Primary colour (Mantine palette)',
128
+ choices: [
129
+ { title: 'blue', value: 'blue' },
130
+ { title: 'teal', value: 'teal' },
131
+ { title: 'indigo', value: 'indigo' },
132
+ { title: 'violet', value: 'violet' },
133
+ { title: 'grape', value: 'grape' },
134
+ { title: 'pink', value: 'pink' },
135
+ { title: 'red', value: 'red' },
136
+ { title: 'orange', value: 'orange' },
137
+ { title: 'green', value: 'green' },
138
+ { title: 'gray', value: 'gray' },
139
+ ],
140
+ initial: 0,
141
+ },
142
+ ], {
143
+ onCancel: () => {
144
+ console.error(kleur.red('cancelled.'));
145
+ process.exit(130);
146
+ },
147
+ });
148
+
149
+ return { ...fixed, ...responses };
150
+ }
151
+
152
+ function buildVars({ hostName, hostPkg, brandName, brandPrimary, atriumVersion }) {
153
+ return {
154
+ HOST_NAME: hostName,
155
+ HOST_PKG: hostPkg,
156
+ BRAND_NAME: brandName,
157
+ BRAND_PASCAL: brandPascal(brandName),
158
+ BRAND_PRIMARY: brandPrimary,
159
+ ATRIUM_VERSION: atriumVersion,
160
+ };
161
+ }
162
+
163
+ function printNextSteps({ outDir, hostName }) {
164
+ const cd = `cd ${outDir}`;
165
+ console.log();
166
+ console.log(kleur.green().bold('done.'));
167
+ console.log();
168
+ console.log(`Next steps:`);
169
+ console.log(` ${kleur.cyan(cd)}`);
170
+ console.log(` ${kleur.cyan('cp .env.example .env')} # fill in secrets`);
171
+ console.log(` ${kleur.cyan('make dev-bootstrap')} # build + start + migrate`);
172
+ console.log(` ${kleur.cyan(`make seed-admin EMAIL=you@example.com PASSWORD='good-password'`)}`);
173
+ console.log(` ${kleur.cyan('make seed-bundle')} # point atrium at /host/main.js`);
174
+ console.log(` ${kleur.cyan('open http://localhost:8000')}`);
175
+ console.log();
176
+ console.log(kleur.dim(`The ${kleur.bold(hostName)} card will appear on the home page after login.`));
177
+ console.log();
178
+ }
179
+
180
+ export async function run(argv) {
181
+ let parsed;
182
+ try {
183
+ parsed = parseArgs(argv);
184
+ } catch (err) {
185
+ console.error(kleur.red(err.message));
186
+ console.error(HELP);
187
+ process.exit(2);
188
+ }
189
+
190
+ if (parsed.flags.help || parsed.positional.length === 0) {
191
+ console.log(HELP);
192
+ process.exit(parsed.flags.help ? 0 : 2);
193
+ }
194
+
195
+ if (parsed.positional.length > 1) {
196
+ console.error(kleur.red(`unexpected extra args: ${parsed.positional.slice(1).join(' ')}`));
197
+ process.exit(2);
198
+ }
199
+
200
+ const hostName = parsed.positional[0];
201
+ const nameOk = validateHostName(hostName);
202
+ if (nameOk !== true) {
203
+ console.error(kleur.red(`invalid project name: ${nameOk}`));
204
+ process.exit(2);
205
+ }
206
+
207
+ const atriumVersion = parsed.options.atrium ?? DEFAULT_ATRIUM_VERSION;
208
+
209
+ const outDirArg = parsed.options.out ?? hostName;
210
+ const outDir = isAbsolute(outDirArg) ? outDirArg : resolve(process.cwd(), outDirArg);
211
+
212
+ if (await pathExists(outDir)) {
213
+ const info = await stat(outDir);
214
+ if (!info.isDirectory()) {
215
+ console.error(kleur.red(`destination exists and is not a directory: ${outDir}`));
216
+ process.exit(1);
217
+ }
218
+ try {
219
+ await ensureEmptyDir(outDir);
220
+ } catch {
221
+ console.error(kleur.red(`destination ${outDir} exists and is not empty.`));
222
+ console.error(kleur.dim('pick a different --out, or delete the existing tree.'));
223
+ process.exit(1);
224
+ }
225
+ }
226
+
227
+ const answers = await gatherAnswers({
228
+ hostNameArg: hostName,
229
+ yesDefaults: parsed.flags.yesDefaults === true,
230
+ atriumVersion,
231
+ });
232
+
233
+ // Re-validate the prompted answers in case a non-interactive caller
234
+ // passed bad values via stdin (or the user accepted a bad default).
235
+ for (const [key, validator] of [
236
+ ['hostPkg', validateHostPkg],
237
+ ['brandName', validateBrandName],
238
+ ['brandPrimary', validateBrandPrimary],
239
+ ]) {
240
+ const v = validator(answers[key]);
241
+ if (v !== true) {
242
+ console.error(kleur.red(`invalid ${key}: ${v}`));
243
+ process.exit(2);
244
+ }
245
+ }
246
+
247
+ const vars = buildVars(answers);
248
+
249
+ console.log();
250
+ console.log(kleur.bold(`Scaffolding ${hostName} into ${outDir}`));
251
+ console.log(kleur.dim(` python module : ${vars.HOST_PKG}`));
252
+ console.log(kleur.dim(` brand name : ${vars.BRAND_NAME}`));
253
+ console.log(kleur.dim(` primary : ${vars.BRAND_PRIMARY}`));
254
+ console.log(kleur.dim(` atrium image : ghcr.io/brendanbank/atrium:${vars.ATRIUM_VERSION}`));
255
+ console.log();
256
+
257
+ const written = await renderTemplate({
258
+ templateDir: TEMPLATE_DIR,
259
+ outDir,
260
+ vars,
261
+ });
262
+
263
+ console.log(kleur.green(`✓ wrote ${written.length} files`));
264
+
265
+ if (!parsed.flags.noGit) {
266
+ if (await gitAvailable()) {
267
+ try {
268
+ await initRepo(outDir);
269
+ console.log(kleur.green('✓ initialised git, made initial commit'));
270
+ } catch (err) {
271
+ console.warn(kleur.yellow(`! git init failed (${err.message.split('\n')[0]}); skipping`));
272
+ }
273
+ } else {
274
+ console.warn(kleur.yellow('! git not on PATH; skipping repo init'));
275
+ }
276
+ }
277
+
278
+ printNextSteps({ outDir, hostName });
279
+ }
package/src/git.js ADDED
@@ -0,0 +1,48 @@
1
+ // Copyright (c) 2026 Brendan Bank
2
+ // SPDX-License-Identifier: BSD-2-Clause
3
+
4
+ import { spawn } from 'node:child_process';
5
+
6
+ function run(cmd, args, opts = {}) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(cmd, args, {
9
+ stdio: ['ignore', 'pipe', 'pipe'],
10
+ ...opts,
11
+ });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
15
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
16
+ child.on('error', reject);
17
+ child.on('close', (code) => {
18
+ if (code === 0) resolve({ stdout, stderr });
19
+ else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr}`));
20
+ });
21
+ });
22
+ }
23
+
24
+ export async function gitAvailable() {
25
+ try {
26
+ await run('git', ['--version']);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export async function initRepo(cwd) {
34
+ // -b main so first-time users get the modern default branch name
35
+ // without having to set init.defaultBranch globally.
36
+ await run('git', ['init', '-b', 'main'], { cwd });
37
+ await run('git', ['add', '.'], { cwd });
38
+ // -n bypasses commit hooks (the freshly scaffolded repo has none
39
+ // yet, but a parent .gitconfig-installed pre-commit could fire on
40
+ // any subdirectory). --no-gpg-sign because the user hasn't decided
41
+ // whether they want signing on this repo yet; they can amend the
42
+ // initial commit if they do. Both stay non-interactive so the
43
+ // scaffolder doesn't hang behind a pinentry prompt.
44
+ await run('git', ['commit', '-n', '--no-gpg-sign', '-m', 'Initial scaffold'], {
45
+ cwd,
46
+ env: { ...process.env, GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? process.env.USER ?? 'scaffold', GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? 'scaffold@local', GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? process.env.USER ?? 'scaffold', GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? 'scaffold@local' },
47
+ });
48
+ }
package/src/names.js ADDED
@@ -0,0 +1,91 @@
1
+ // Copyright (c) 2026 Brendan Bank
2
+ // SPDX-License-Identifier: BSD-2-Clause
3
+
4
+ // Name derivations and validators.
5
+ //
6
+ // `create-atrium-host casa-del-leone` should give:
7
+ // hostName = casa-del-leone (CLI/image tag, lowercase, dash-separated)
8
+ // hostPkg = casa_del_leone (Python module, lowercase, underscores)
9
+ // brandName = Casa Del Leone (display, title-cased words)
10
+ // brandPascal = CasaDelLeone (component prefix in TSX)
11
+
12
+ const PYTHON_KEYWORDS = new Set([
13
+ 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await',
14
+ 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
15
+ 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is',
16
+ 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try',
17
+ 'while', 'with', 'yield', 'match', 'case',
18
+ ]);
19
+
20
+ // Atrium owns these top-level names — collisions break import resolution
21
+ // (`app` is the FastAPI app module; the others would shadow stdlib).
22
+ const RESERVED_PKGS = new Set(['app', 'alembic', 'fastapi', 'sqlalchemy', 'pydantic']);
23
+
24
+ export function validateHostName(name) {
25
+ if (!name) return 'name is required';
26
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name)) {
27
+ return 'use lowercase letters, digits, and dashes (e.g. casa-del-leone). ' +
28
+ 'must start with a letter and end with a letter or digit.';
29
+ }
30
+ if (name.length > 50) return 'name is too long (max 50 chars)';
31
+ if (name.includes('--')) return 'no consecutive dashes';
32
+ return true;
33
+ }
34
+
35
+ export function validateHostPkg(pkg) {
36
+ if (!pkg) return 'python module name is required';
37
+ if (!/^[a-z][a-z0-9_]*$/.test(pkg)) {
38
+ return 'use lowercase letters, digits, and underscores; must start with a letter';
39
+ }
40
+ if (PYTHON_KEYWORDS.has(pkg)) return `${pkg} is a Python keyword`;
41
+ if (RESERVED_PKGS.has(pkg)) {
42
+ return `${pkg} collides with a reserved name (atrium owns 'app'; others would shadow common libs)`;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ export function validateBrandName(name) {
48
+ if (!name) return 'brand name is required';
49
+ if (name.length > 60) return 'brand name is too long (max 60 chars)';
50
+ return true;
51
+ }
52
+
53
+ const MANTINE_COLORS = new Set([
54
+ 'dark', 'gray', 'red', 'pink', 'grape', 'violet', 'indigo', 'blue',
55
+ 'cyan', 'teal', 'green', 'lime', 'yellow', 'orange',
56
+ ]);
57
+
58
+ export function validateBrandPrimary(value) {
59
+ if (!value) return 'primary colour is required';
60
+ if (!MANTINE_COLORS.has(value)) {
61
+ return `must be a Mantine palette name (${[...MANTINE_COLORS].join(', ')})`;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ export function defaultHostPkg(hostName) {
67
+ return hostName.replace(/-/g, '_');
68
+ }
69
+
70
+ export function defaultBrandName(hostName) {
71
+ return hostName
72
+ .split('-')
73
+ .filter(Boolean)
74
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
75
+ .join(' ');
76
+ }
77
+
78
+ export function brandPascal(brandName) {
79
+ // Strip non-alphanumeric, capitalize each word, drop spaces. The
80
+ // brand name is human-friendly ("Casa del Leone") so we have to
81
+ // re-derive a JSX-safe identifier from it.
82
+ const cleaned = brandName.replace(/[^A-Za-z0-9 ]/g, ' ');
83
+ const pascal = cleaned
84
+ .split(/\s+/)
85
+ .filter(Boolean)
86
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
87
+ .join('');
88
+ // Identifier can't start with a digit. Prepend "Host" if it does
89
+ // (rare — would require a brand name starting with a digit).
90
+ return /^[A-Za-z]/.test(pascal) ? pascal : `Host${pascal}`;
91
+ }
package/src/render.js ADDED
@@ -0,0 +1,90 @@
1
+ // Copyright (c) 2026 Brendan Bank
2
+ // SPDX-License-Identifier: BSD-2-Clause
3
+
4
+ // Walk the template tree, substitute placeholders in both file paths
5
+ // and file contents, write into the destination.
6
+ //
7
+ // Placeholder convention is `__UPPER_SNAKE__` so a `grep -r __HOST_`
8
+ // inside the emitted repo finds anything we forgot to substitute.
9
+ // Binary files are passed through unchanged.
10
+
11
+ import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
12
+ import { join, dirname, relative } from 'node:path';
13
+
14
+ const BINARY_EXTENSIONS = new Set([
15
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.woff', '.woff2',
16
+ '.ttf', '.otf', '.eot', '.pdf',
17
+ ]);
18
+
19
+ function isBinaryPath(path) {
20
+ const dot = path.lastIndexOf('.');
21
+ if (dot < 0) return false;
22
+ return BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
23
+ }
24
+
25
+ export function substitute(text, vars) {
26
+ let out = text;
27
+ for (const [key, value] of Object.entries(vars)) {
28
+ // Match the literal placeholder. Using split+join sidesteps regex
29
+ // escaping; the placeholder set is tiny so the cost is negligible.
30
+ out = out.split(`__${key}__`).join(value);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ async function* walk(dir) {
36
+ const entries = await readdir(dir, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ const full = join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ yield* walk(full);
41
+ } else if (entry.isFile()) {
42
+ yield full;
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function renderTemplate({ templateDir, outDir, vars }) {
48
+ const written = [];
49
+ for await (const srcPath of walk(templateDir)) {
50
+ const relPath = relative(templateDir, srcPath);
51
+ const renamedRel = substitute(relPath, vars);
52
+ const destPath = join(outDir, renamedRel);
53
+ await mkdir(dirname(destPath), { recursive: true });
54
+ if (isBinaryPath(srcPath)) {
55
+ // Copy binary as-is.
56
+ const data = await readFile(srcPath);
57
+ await writeFile(destPath, data);
58
+ } else {
59
+ const content = await readFile(srcPath, 'utf8');
60
+ await writeFile(destPath, substitute(content, vars));
61
+ }
62
+ written.push(renamedRel);
63
+ }
64
+ return written;
65
+ }
66
+
67
+ export async function ensureEmptyDir(dir) {
68
+ try {
69
+ const entries = await readdir(dir);
70
+ if (entries.length > 0) {
71
+ throw new Error(`destination ${dir} is not empty`);
72
+ }
73
+ } catch (err) {
74
+ if (err.code === 'ENOENT') {
75
+ await mkdir(dir, { recursive: true });
76
+ return;
77
+ }
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ export async function pathExists(p) {
83
+ try {
84
+ await stat(p);
85
+ return true;
86
+ } catch (err) {
87
+ if (err.code === 'ENOENT') return false;
88
+ throw err;
89
+ }
90
+ }