@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.
- package/LICENCE.md +25 -0
- package/README.md +94 -0
- package/bin/index.js +8 -0
- package/package.json +40 -0
- package/src/cli.js +279 -0
- package/src/git.js +48 -0
- package/src/names.js +91 -0
- package/src/render.js +90 -0
- package/template/.env.example +33 -0
- package/template/.github/dependabot.yml +56 -0
- package/template/.github/workflows/ci.yml +103 -0
- package/template/Dockerfile +46 -0
- package/template/Makefile +67 -0
- package/template/README.md +124 -0
- package/template/backend/alembic/env.py +116 -0
- package/template/backend/alembic/script.py.mako +27 -0
- package/template/backend/alembic/versions/0001_init.py +92 -0
- package/template/backend/alembic.ini +50 -0
- package/template/backend/pyproject.toml +17 -0
- package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
- package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
- package/template/backend/src/__HOST_PKG__/models.py +51 -0
- package/template/backend/src/__HOST_PKG__/router.py +73 -0
- package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
- package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
- package/template/backend/tests/test_smoke.py +47 -0
- package/template/compose.yaml +96 -0
- package/template/frontend/package.json +36 -0
- package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
- package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
- package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
- package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
- package/template/frontend/src/api.ts +38 -0
- package/template/frontend/src/main.tsx +55 -0
- package/template/frontend/src/queryClient.ts +11 -0
- package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
- package/template/frontend/src/test/setup.ts +21 -0
- package/template/frontend/tsconfig.json +21 -0
- package/template/frontend/vite.config.ts +7 -0
- 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
|
+
}
|