@basaltkit/cli 1.3.0 → 1.4.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 +58 -3
- package/dist/builtins.d.ts +20 -0
- package/dist/builtins.js +92 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/publish.js +3 -63
- package/dist/stubs.d.ts +30 -0
- package/dist/stubs.js +122 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -91,11 +91,11 @@ Without registering anything, `runCli` always provides:
|
|
|
91
91
|
| Command | What it does |
|
|
92
92
|
| --- | --- |
|
|
93
93
|
| `basalt list` (or `basalt` with no arguments) | Lists all available commands, with their descriptions |
|
|
94
|
-
| `basalt routes` | Lists the HTTP routes registered by the application (
|
|
94
|
+
| `basalt routes [--json] [--unguarded --require=auth,can [--allow=<glob,…>]]` | Lists the HTTP routes registered by the application with the guards each declares in `meta` — columns `auth`, `can`, `rateLimit`, `tenant` and `guards` (`mfa`, `teamRole`, `scopes`, `subscribed`, `feature`). `--json` prints one JSON array of route rows. `--unguarded` lists the routes that do not declare the `--require`d guards and exits `1` when there is any (see [Route security review](#route-security-review)). Read from the `http:routes` metadata bucket, populated by every HTTP adapter |
|
|
95
95
|
| `basalt schedule:list` | Lists scheduled tasks and their cron expressions (read from the `schedule:entries` bucket, populated by `@basaltkit/scheduler`) |
|
|
96
96
|
| `basalt dev [--entry] [--worker] [--queue] [--no-routes]` | Runs the app with watch + restart, **prints the route table on boot**, and with `--worker` also starts a watched `queue:work` alongside it (server + worker in one command). Delegates watching to `tsx watch` / `node --watch`. |
|
|
97
97
|
| `basalt upgrade [--dry] [--only=<id>] [--dir=<path>]` | Applies framework upgrade codemods (ships the `@machize/*` → `@basaltkit/*` scope rename; `--dry` previews). `create-basalt update` runs them for you after an install. `nodeUpgradeFs(baseDir)` resolves relative paths against the directory being upgraded, so `--dir=` reads the tree it lists |
|
|
98
|
-
| `basalt publish [<id>] [--force]` | Copies a bundled stub group into the app — `dockerfile` (plus a `.dockerignore` keeping `.env` and keys out of the image), `ci
|
|
98
|
+
| `basalt publish [<id>] [--force]` | Copies a bundled stub group into the app — `dockerfile` (a multi-stage build that runs `pnpm build` and ships `dist/src/server.js` on plain node as `USER node`, plus a `.dockerignore` keeping `.env` and keys out of the image — the same files create-basalt scaffolds), `ci` (install, typecheck, build, test), `editorconfig` (run with no id to list). The stub contents are exported too: `DOCKERFILE`, `DOCKERIGNORE`, `CI_WORKFLOW`, `EDITORCONFIG`, `PRODUCTION_ENTRY` |
|
|
99
99
|
|
|
100
100
|
Not served by `runCli`: the **project commands** `update`, `add`, `doctor` and
|
|
101
101
|
`info` belong to `create-basalt` — the scaffolded `bin/basalt.ts` forwards them
|
|
@@ -237,15 +237,70 @@ Returns `[routesCommand, scheduleListCommand, devCommand, upgradeCommand, publis
|
|
|
237
237
|
|
|
238
238
|
The definitions of the built-in `routes` and `schedule:list` commands. *(Advanced — useful only if you want to run them directly or compose your own list.)*
|
|
239
239
|
|
|
240
|
+
`routeAllowPattern(glob)` compiles an `--allow` pattern into a `(row) => boolean`; `UNGUARDED_SCOPE_NOTE` is the "route meta only" caveat `--unguarded` prints.
|
|
241
|
+
|
|
240
242
|
### Exported types
|
|
241
243
|
|
|
242
244
|
| Type | Description |
|
|
243
245
|
| --- | --- |
|
|
244
246
|
| `CommandDefinition`, `CommandContext`, `CommandIo` | Described above |
|
|
245
247
|
| `RunCliOptions`, `ParsedArgv` | Described above |
|
|
246
|
-
| `RouteMetadata` | `{ method: string; url: string; [key: string]: unknown }` — entries from the `http:routes` bucket |
|
|
248
|
+
| `RouteMetadata` | `{ method: string; url: string; meta?: Record<string, unknown>; [key: string]: unknown }` — entries from the `http:routes` bucket |
|
|
247
249
|
| `ScheduleMetadata` | `{ name: string; cron: string; timezone: string }` — entries from the `schedule:entries` bucket |
|
|
248
250
|
|
|
251
|
+
## Route security review
|
|
252
|
+
|
|
253
|
+
`basalt routes` answers "which route is unprotected?", not just "which routes exist":
|
|
254
|
+
|
|
255
|
+
```text
|
|
256
|
+
method url auth can rateLimit tenant guards
|
|
257
|
+
------ ----------------- ----- --------------- -------------- -------- ------
|
|
258
|
+
GET /health false
|
|
259
|
+
GET /projects true projects:read
|
|
260
|
+
POST /projects true projects:create 10/1m per user required
|
|
261
|
+
POST /webhooks/stripe
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Gate it in CI — the command exits `1` and names each offender and what it misses:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
pnpm basalt routes --unguarded --require=auth,can --allow='POST /webhooks/*'
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- `--require` is mandatory (`auth`, `can`, or both). An explicit opt-out is
|
|
271
|
+
intentional and never reported: `auth: false` (or `public: true`) satisfies
|
|
272
|
+
both, and `can: false` satisfies `can`. Only `auth: true` counts as `auth` —
|
|
273
|
+
the one value `authPlugin` enforces.
|
|
274
|
+
- `--allow` takes comma-separated globs: `*` matches within one path segment,
|
|
275
|
+
`**` across segments; a leading method (`'POST /webhooks/*'`) restricts it to
|
|
276
|
+
that method.
|
|
277
|
+
- `--json` combines with `--unguarded` to print the offenders with a `missing`
|
|
278
|
+
array.
|
|
279
|
+
- The `tenant` column shows the route's tenancy declaration: `required`
|
|
280
|
+
(`meta.tenant: true`), `exempt` (`meta.tenant: false`), `central-only`
|
|
281
|
+
(`meta.tenant: 'never'` — a request that resolves a tenant is rejected),
|
|
282
|
+
or blank when the route inherits the app-wide default. It reflects
|
|
283
|
+
`meta.tenant` only; `meta.central: true` (the teams membership bypass)
|
|
284
|
+
shows as `central` among the guards.
|
|
285
|
+
|
|
286
|
+
**It checks route meta only.** An app-wide rate limit (`securityPlugin({ rateLimit })`),
|
|
287
|
+
URL-based tenancy (`tenancyPlugin({ required: { except } })`), app hooks or
|
|
288
|
+
middleware, and the edge routes plugins add through `HTTP_SERVER.addRoute()`
|
|
289
|
+
(`healthPlugin`, `metricsPlugin`, `openapiPlugin`) are invisible to it. Routes
|
|
290
|
+
from `authRoutes()`, `mcpRoutes()` and any other `BasaltRoute[]` you pass to the
|
|
291
|
+
adapter are listed. The same rows are available to a test, without listening —
|
|
292
|
+
`describeRoutes()` and `findUnguardedRoutes()` from `@basaltkit/http`:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { ensureMetadata } from '@basaltkit/core'
|
|
296
|
+
import { describeRoutes, findUnguardedRoutes } from '@basaltkit/http'
|
|
297
|
+
|
|
298
|
+
const app = await buildApp().boot()
|
|
299
|
+
const rows = describeRoutes(ensureMetadata(app.container).get('http:routes'))
|
|
300
|
+
expect(findUnguardedRoutes(rows, { require: ['auth', 'can'] })).toEqual([])
|
|
301
|
+
await app.shutdown()
|
|
302
|
+
```
|
|
303
|
+
|
|
249
304
|
## Common errors and solutions (FAQ)
|
|
250
305
|
|
|
251
306
|
**`Unknown command "x". Run "basalt list" to see what is available.`**
|
package/dist/builtins.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type RouteRow } from '@basaltkit/http/route-table';
|
|
1
2
|
import { type CommandDefinition } from './command.js';
|
|
2
3
|
/** Route entries written by HTTP adapters into the 'http:routes' bucket. */
|
|
3
4
|
export interface RouteMetadata {
|
|
4
5
|
method: string;
|
|
5
6
|
url: string;
|
|
7
|
+
/** The route's declared `meta` (guards, rate limit, tenancy…). */
|
|
8
|
+
meta?: Record<string, unknown> | undefined;
|
|
6
9
|
[key: string]: unknown;
|
|
7
10
|
}
|
|
8
11
|
/** Schedule entries written by the scheduler into the 'schedule:entries' bucket. */
|
|
@@ -11,6 +14,23 @@ export interface ScheduleMetadata {
|
|
|
11
14
|
cron: string;
|
|
12
15
|
timezone: string;
|
|
13
16
|
}
|
|
17
|
+
/** What `--unguarded` cannot see — printed with its result and repeated in the docs. */
|
|
18
|
+
export declare const UNGUARDED_SCOPE_NOTE: string;
|
|
19
|
+
/**
|
|
20
|
+
* Compiles an `--allow` pattern. `*` matches within one path segment, `**`
|
|
21
|
+
* across segments. A pattern with a space (`'POST /webhooks/*'`) also matches
|
|
22
|
+
* the method; one without matches the URL on any method.
|
|
23
|
+
*/
|
|
24
|
+
export declare function routeAllowPattern(pattern: string): (row: Pick<RouteRow, 'method' | 'url'>) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* `basalt routes [--json] [--unguarded --require=auth,can [--allow=<glob,...>]]`
|
|
27
|
+
*
|
|
28
|
+
* Lists the HTTP routes the app registered with the guards each declares in
|
|
29
|
+
* `meta` (auth, can, rateLimit, tenant, and the other guarded keys). `--json`
|
|
30
|
+
* prints the {@link RouteRow} array for CI and scripts. `--unguarded` lists
|
|
31
|
+
* the routes that do not declare the `--require`d guards and exits 1 when
|
|
32
|
+
* there is any — route META only (see {@link UNGUARDED_SCOPE_NOTE}).
|
|
33
|
+
*/
|
|
14
34
|
export declare const routesCommand: CommandDefinition;
|
|
15
35
|
export declare const scheduleListCommand: CommandDefinition;
|
|
16
36
|
export declare function builtinCommands(): CommandDefinition[];
|
package/dist/builtins.js
CHANGED
|
@@ -1,20 +1,108 @@
|
|
|
1
1
|
import { METADATA } from '@basaltkit/core';
|
|
2
|
+
// The zod-free subpath: `basalt routes` must not pull the HTTP runtime in.
|
|
3
|
+
import { describeRoutes, findUnguardedRoutes } from '@basaltkit/http/route-table';
|
|
2
4
|
import { defineCommand } from './command.js';
|
|
3
5
|
import { devCommand } from './dev.js';
|
|
4
6
|
import { upgradeCommand } from './upgrade.js';
|
|
5
7
|
import { publishCommand } from './publish.js';
|
|
8
|
+
const REQUIREMENTS = ['auth', 'can'];
|
|
9
|
+
/** What `--unguarded` cannot see — printed with its result and repeated in the docs. */
|
|
10
|
+
export const UNGUARDED_SCOPE_NOTE = 'Checks route meta only: an app-wide rate limit, URL-based tenancy (tenancyPlugin `required.except`), ' +
|
|
11
|
+
'app hooks/middleware and edge routes (health, metrics, openapi) are invisible to it.';
|
|
12
|
+
/**
|
|
13
|
+
* Compiles an `--allow` pattern. `*` matches within one path segment, `**`
|
|
14
|
+
* across segments. A pattern with a space (`'POST /webhooks/*'`) also matches
|
|
15
|
+
* the method; one without matches the URL on any method.
|
|
16
|
+
*/
|
|
17
|
+
export function routeAllowPattern(pattern) {
|
|
18
|
+
const trimmed = pattern.trim();
|
|
19
|
+
const space = trimmed.indexOf(' ');
|
|
20
|
+
const method = space === -1 ? undefined : trimmed.slice(0, space).toUpperCase();
|
|
21
|
+
const glob = space === -1 ? trimmed : trimmed.slice(space + 1).trim();
|
|
22
|
+
const source = glob
|
|
23
|
+
.split('**')
|
|
24
|
+
.map((part) => part
|
|
25
|
+
.split('*')
|
|
26
|
+
.map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
27
|
+
.join('[^/]*'))
|
|
28
|
+
.join('.*');
|
|
29
|
+
const regex = new RegExp(`^${source}$`);
|
|
30
|
+
return (row) => (method === undefined || row.method === method) && regex.test(row.url);
|
|
31
|
+
}
|
|
32
|
+
const cell = (value) => {
|
|
33
|
+
if (value === null)
|
|
34
|
+
return '';
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return value.join(', ');
|
|
37
|
+
return String(value);
|
|
38
|
+
};
|
|
39
|
+
/** Table rows for `basalt routes`: one column per guard, blank when undeclared. */
|
|
40
|
+
function tableRows(rows) {
|
|
41
|
+
return rows.map((row) => ({
|
|
42
|
+
method: row.method,
|
|
43
|
+
url: row.url,
|
|
44
|
+
auth: cell(row.auth),
|
|
45
|
+
can: cell(row.can),
|
|
46
|
+
rateLimit: cell(row.rateLimit),
|
|
47
|
+
tenant: cell(row.tenant),
|
|
48
|
+
guards: cell(row.guards),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `basalt routes [--json] [--unguarded --require=auth,can [--allow=<glob,...>]]`
|
|
53
|
+
*
|
|
54
|
+
* Lists the HTTP routes the app registered with the guards each declares in
|
|
55
|
+
* `meta` (auth, can, rateLimit, tenant, and the other guarded keys). `--json`
|
|
56
|
+
* prints the {@link RouteRow} array for CI and scripts. `--unguarded` lists
|
|
57
|
+
* the routes that do not declare the `--require`d guards and exits 1 when
|
|
58
|
+
* there is any — route META only (see {@link UNGUARDED_SCOPE_NOTE}).
|
|
59
|
+
*/
|
|
6
60
|
export const routesCommand = defineCommand({
|
|
7
61
|
name: 'routes',
|
|
8
|
-
description: 'List
|
|
9
|
-
handle({ container, io }) {
|
|
62
|
+
description: 'List HTTP routes with their declared guards (--json, --unguarded --require=auth,can)',
|
|
63
|
+
handle({ container, io, flags }) {
|
|
10
64
|
const routes = container.has(METADATA)
|
|
11
65
|
? container.get(METADATA).get('http:routes')
|
|
12
66
|
: [];
|
|
13
|
-
|
|
67
|
+
const rows = describeRoutes(routes);
|
|
68
|
+
const json = flags['json'] === true;
|
|
69
|
+
if (flags['unguarded'] === true) {
|
|
70
|
+
const required = typeof flags['require'] === 'string' ? flags['require'].split(',').map((r) => r.trim()).filter(Boolean) : [];
|
|
71
|
+
const unknown = required.filter((r) => !REQUIREMENTS.includes(r));
|
|
72
|
+
if (required.length === 0 || unknown.length > 0) {
|
|
73
|
+
io.error(unknown.length > 0
|
|
74
|
+
? `Unknown --require value(s): ${unknown.join(', ')}. Use auth and/or can.`
|
|
75
|
+
: '--unguarded needs an explicit --require=auth,can (or just auth / can).');
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
const patterns = typeof flags['allow'] === 'string' ? flags['allow'].split(',').filter((p) => p.trim()).map(routeAllowPattern) : [];
|
|
79
|
+
const offenders = findUnguardedRoutes(rows, {
|
|
80
|
+
require: required,
|
|
81
|
+
allow: (row) => patterns.some((matches) => matches(row)),
|
|
82
|
+
});
|
|
83
|
+
if (json) {
|
|
84
|
+
io.log(JSON.stringify(offenders.map(({ row, missing }) => ({ ...row, missing }))));
|
|
85
|
+
}
|
|
86
|
+
else if (offenders.length === 0) {
|
|
87
|
+
io.log(`All ${rows.length} route(s) declare ${required.join(' + ')} (or opt out explicitly).`);
|
|
88
|
+
io.log(UNGUARDED_SCOPE_NOTE);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
io.error(`${offenders.length} route(s) do not declare ${required.join(' + ')}:`);
|
|
92
|
+
io.table(offenders.map(({ row, missing }) => ({ method: row.method, url: row.url, missing: missing.join(', ') })));
|
|
93
|
+
io.log(UNGUARDED_SCOPE_NOTE);
|
|
94
|
+
}
|
|
95
|
+
return offenders.length > 0 ? 1 : 0;
|
|
96
|
+
}
|
|
97
|
+
if (json) {
|
|
98
|
+
io.log(JSON.stringify(rows));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (rows.length === 0) {
|
|
14
102
|
io.log('No routes registered.');
|
|
15
103
|
return;
|
|
16
104
|
}
|
|
17
|
-
io.table(
|
|
105
|
+
io.table(tableRows(rows));
|
|
18
106
|
},
|
|
19
107
|
});
|
|
20
108
|
export const scheduleListCommand = defineCommand({
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ export { runCli, type RunCliOptions } from './runner.js';
|
|
|
3
3
|
export { commandsPlugin } from './plugin.js';
|
|
4
4
|
export { consoleIo, memoryIo, renderTable } from './io.js';
|
|
5
5
|
export { parseArgv, type ParsedArgv } from './parse.js';
|
|
6
|
-
export { builtinCommands, routesCommand, scheduleListCommand, type RouteMetadata, type ScheduleMetadata, } from './builtins.js';
|
|
6
|
+
export { builtinCommands, routesCommand, routeAllowPattern, UNGUARDED_SCOPE_NOTE, scheduleListCommand, type RouteMetadata, type ScheduleMetadata, } from './builtins.js';
|
|
7
7
|
export { devCommand, resolveDevEntry, resolveDevRunner, devRouteRows, DEV_ENTRY_CANDIDATES, type DevRunner, type DevRoute, } from './dev.js';
|
|
8
8
|
export { upgradeCommand, runUpgrade, MIGRATIONS, renameMachizeScope, nodeUpgradeFs, type Migration, type Edit, type UpgradeFs, type UpgradeReport, } from './upgrade.js';
|
|
9
9
|
export { publishCommand, runPublish, PUBLISHABLES, nodePublishFs, type Publishable, type PublishableFile, type PublishFs, type PublishResult, } from './publish.js';
|
|
10
|
+
export { DOCKERFILE, DOCKERIGNORE, CI_WORKFLOW, EDITORCONFIG, PRODUCTION_ENTRY } from './stubs.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ export { runCli } from './runner.js';
|
|
|
3
3
|
export { commandsPlugin } from './plugin.js';
|
|
4
4
|
export { consoleIo, memoryIo, renderTable } from './io.js';
|
|
5
5
|
export { parseArgv } from './parse.js';
|
|
6
|
-
export { builtinCommands, routesCommand, scheduleListCommand, } from './builtins.js';
|
|
6
|
+
export { builtinCommands, routesCommand, routeAllowPattern, UNGUARDED_SCOPE_NOTE, scheduleListCommand, } from './builtins.js';
|
|
7
7
|
export { devCommand, resolveDevEntry, resolveDevRunner, devRouteRows, DEV_ENTRY_CANDIDATES, } from './dev.js';
|
|
8
8
|
export { upgradeCommand, runUpgrade, MIGRATIONS, renameMachizeScope, nodeUpgradeFs, } from './upgrade.js';
|
|
9
9
|
export { publishCommand, runPublish, PUBLISHABLES, nodePublishFs, } from './publish.js';
|
|
10
|
+
export { DOCKERFILE, DOCKERIGNORE, CI_WORKFLOW, EDITORCONFIG, PRODUCTION_ENTRY } from './stubs.js';
|
package/dist/publish.js
CHANGED
|
@@ -1,76 +1,16 @@
|
|
|
1
1
|
import { defineCommand } from './command.js';
|
|
2
|
-
|
|
3
|
-
FROM node:22-slim AS base
|
|
4
|
-
ENV NODE_ENV=production
|
|
5
|
-
WORKDIR /app
|
|
6
|
-
|
|
7
|
-
FROM base AS deps
|
|
8
|
-
COPY package.json pnpm-lock.yaml* ./
|
|
9
|
-
RUN corepack enable && pnpm install --prod --frozen-lockfile
|
|
10
|
-
|
|
11
|
-
FROM base AS run
|
|
12
|
-
COPY --from=deps /app/node_modules ./node_modules
|
|
13
|
-
COPY . .
|
|
14
|
-
EXPOSE 3000
|
|
15
|
-
CMD ["node", "dist/main.js"]
|
|
16
|
-
`;
|
|
17
|
-
/**
|
|
18
|
-
* Published with the Dockerfile: its \`COPY . .\` would otherwise bake \`.env\`,
|
|
19
|
-
* private keys and VCS metadata into an image layer. Every rule is \`**\/\`-prefixed:
|
|
20
|
-
* .dockerignore patterns are anchored at the context root, so a bare \`.env\`
|
|
21
|
-
* or \`*.pem\` would still let \`prisma/.env\` or \`certs/server.key\` through.
|
|
22
|
-
*/
|
|
23
|
-
const DOCKERIGNORE = `**/.env
|
|
24
|
-
**/.env.*
|
|
25
|
-
!**/.env.example
|
|
26
|
-
**/.npmrc
|
|
27
|
-
**/.git
|
|
28
|
-
**/node_modules
|
|
29
|
-
**/coverage
|
|
30
|
-
**/*.log
|
|
31
|
-
**/*.pem
|
|
32
|
-
**/*.key
|
|
33
|
-
**/*.p12
|
|
34
|
-
**/*.pfx
|
|
35
|
-
**/.DS_Store
|
|
36
|
-
`;
|
|
37
|
-
const CI_WORKFLOW = `name: ci
|
|
38
|
-
on:
|
|
39
|
-
push: { branches: [main] }
|
|
40
|
-
pull_request:
|
|
41
|
-
jobs:
|
|
42
|
-
build:
|
|
43
|
-
runs-on: ubuntu-latest
|
|
44
|
-
steps:
|
|
45
|
-
- uses: actions/checkout@v4
|
|
46
|
-
- uses: pnpm/action-setup@v4
|
|
47
|
-
- uses: actions/setup-node@v4
|
|
48
|
-
with: { node-version: 22, cache: pnpm }
|
|
49
|
-
- run: pnpm install --frozen-lockfile
|
|
50
|
-
- run: pnpm run build
|
|
51
|
-
- run: pnpm run test
|
|
52
|
-
`;
|
|
53
|
-
const EDITORCONFIG = `root = true
|
|
54
|
-
|
|
55
|
-
[*]
|
|
56
|
-
charset = utf-8
|
|
57
|
-
end_of_line = lf
|
|
58
|
-
insert_final_newline = true
|
|
59
|
-
indent_style = space
|
|
60
|
-
indent_size = 2
|
|
61
|
-
trim_trailing_whitespace = true
|
|
62
|
-
`;
|
|
2
|
+
import { CI_WORKFLOW, DOCKERFILE, DOCKERIGNORE, EDITORCONFIG } from './stubs.js';
|
|
63
3
|
/** Stubs bundled with the CLI. Apps can register more via the metadata bucket. */
|
|
64
4
|
export const PUBLISHABLES = [
|
|
65
5
|
{
|
|
66
6
|
id: 'dockerfile',
|
|
67
|
-
description: 'Production multi-stage Dockerfile (+ .dockerignore keeping secrets out of the image)',
|
|
7
|
+
description: 'Production multi-stage Dockerfile — build stage + plain-node runtime (+ .dockerignore keeping secrets out of the image)',
|
|
68
8
|
files: () => [
|
|
69
9
|
{ path: 'Dockerfile', content: DOCKERFILE },
|
|
70
10
|
{ path: '.dockerignore', content: DOCKERIGNORE },
|
|
71
11
|
],
|
|
72
12
|
},
|
|
73
|
-
{ id: 'ci', description: 'GitHub Actions CI workflow', files: () => [{ path: '.github/workflows/ci.yml', content: CI_WORKFLOW }] },
|
|
13
|
+
{ id: 'ci', description: 'GitHub Actions CI workflow (install, typecheck, build, test)', files: () => [{ path: '.github/workflows/ci.yml', content: CI_WORKFLOW }] },
|
|
74
14
|
{ id: 'editorconfig', description: 'Shared .editorconfig', files: () => [{ path: '.editorconfig', content: EDITORCONFIG }] },
|
|
75
15
|
];
|
|
76
16
|
/**
|
package/dist/stubs.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production stubs `basalt publish` copies into an app — the SINGLE source
|
|
3
|
+
* of truth. create-basalt ships the same files in every new scaffold and offers
|
|
4
|
+
* them from `create-basalt update`: its build copies this file verbatim to
|
|
5
|
+
* `packages/create-app/src/stubs.ts` (scripts/sync-stubs.mjs), and a test there
|
|
6
|
+
* fails if the copy drifts. Keep this module free of imports for that reason.
|
|
7
|
+
*
|
|
8
|
+
* The layout these stubs assume is the create-basalt scaffold's (BK-026):
|
|
9
|
+
* `pnpm build` runs `tsc -p tsconfig.build.json` (rootDir `.`, outDir `dist`),
|
|
10
|
+
* so `src/server.ts` compiles to {@link PRODUCTION_ENTRY}; `pnpm start` runs it
|
|
11
|
+
* with plain `node` — no tsx, no TypeScript at runtime. A Prisma client, when
|
|
12
|
+
* there is one, is generated OUTSIDE `src/` (`generated/prisma`) and imported
|
|
13
|
+
* through the package.json `imports` alias `#db/*`, so it resolves the same
|
|
14
|
+
* from `src/` (tsx) and from `dist/src/` (node).
|
|
15
|
+
*/
|
|
16
|
+
/** The compiled server entry the Dockerfile runs and the scaffold's `start` script points at. */
|
|
17
|
+
export declare const PRODUCTION_ENTRY = "dist/src/server.js";
|
|
18
|
+
/** Multi-stage production image: build with the dev toolchain, run on plain node as a non-root user. */
|
|
19
|
+
export declare const DOCKERFILE = "# syntax=docker/dockerfile:1\n# Production image for a Basalt app (the create-basalt layout): `pnpm build`\n# compiles src/ to dist/ and the image runs dist/src/server.js on plain\n# node \u2014 no tsx, no TypeScript at runtime.\n#\n# docker build -t my-app .\n# docker run -p 3000:3000 -e APP_SECRET=\u2026 -e DATABASE_URL=\u2026 my-app\n#\n# Configuration comes from the environment (nothing loads .env here, and\n# .dockerignore keeps it out of the image). Database migrations are NOT run\n# by the image: apply them before rolling out (`pnpm db:deploy` in CI).\n#\n# Installs with pnpm from pnpm-lock.yaml. An app on npm/yarn/bun replaces the\n# pnpm install / exec / run / prune lines with its own package manager's.\nARG NODE_VERSION=22\nARG PNPM_VERSION=11\n\nFROM node:${NODE_VERSION}-slim AS build\nARG PNPM_VERSION\n# Non-interactive pnpm: without it `pnpm prune` in a workspace (a --ui app's\n# web/) aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.\nENV CI=true\nWORKDIR /app\nRUN npm install -g pnpm@${PNPM_VERSION}\nCOPY . .\n# Full install: the build needs the devDependencies (typescript, prisma).\nRUN pnpm install --frozen-lockfile\n# The Prisma client (when the app has a schema) is generated into ./generated.\nRUN if [ -f prisma/schema.prisma ]; then pnpm exec prisma generate; fi && mkdir -p generated\nRUN pnpm run build\n# Drop the devDependencies (tsx, typescript, prisma CLI, generators, the AI\n# bridge): none of them may ship. --ignore-scripts keeps the app's postinstall\n# (`prisma generate`, a dev tool) from running again.\nRUN pnpm prune --prod --ignore-scripts\n\nFROM node:${NODE_VERSION}-slim AS run\nENV NODE_ENV=production\nENV PORT=3000\n# Path the HEALTHCHECK probes: the scaffold's /health route by default; set\n# /readyz when the app registers healthPlugin().\nENV HEALTHCHECK_PATH=/health\nWORKDIR /app\nCOPY --from=build --chown=node:node /app/package.json ./package.json\nCOPY --from=build --chown=node:node /app/node_modules ./node_modules\nCOPY --from=build --chown=node:node /app/generated ./generated\nCOPY --from=build --chown=node:node /app/dist ./dist\nUSER node\nEXPOSE 3000\nHEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\\n CMD node -e \"fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + process.env.HEALTHCHECK_PATH).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))\"\nCMD [\"node\", \"--enable-source-maps\", \"dist/src/server.js\"]\n";
|
|
20
|
+
/**
|
|
21
|
+
* Published with the Dockerfile: its \`COPY . .\` would otherwise bake \`.env\`,
|
|
22
|
+
* private keys and VCS metadata into an image layer. Every secret rule is
|
|
23
|
+
* \`**\/\`-prefixed: .dockerignore patterns are anchored at the context root, so
|
|
24
|
+
* a bare \`.env\` or \`*.pem\` would still let \`prisma/.env\` or
|
|
25
|
+
* \`certs/server.key\` through. \`dist\` is rebuilt inside the image.
|
|
26
|
+
*/
|
|
27
|
+
export declare const DOCKERIGNORE = "**/.env\n**/.env.*\n!**/.env.example\n**/.npmrc\n**/.git\n**/node_modules\n**/coverage\n**/*.log\n**/*.pem\n**/*.key\n**/*.p12\n**/*.pfx\n**/.DS_Store\ndist\n";
|
|
28
|
+
/** GitHub Actions CI: install, typecheck, build (the production path), test. */
|
|
29
|
+
export declare const CI_WORKFLOW = "name: ci\non:\n push: { branches: [main] }\n pull_request:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n # Drop `with: version` if package.json declares a packageManager.\n - uses: pnpm/action-setup@v4\n with: { version: 11 }\n - uses: actions/setup-node@v4\n with: { node-version: 22, cache: pnpm }\n - run: pnpm install --frozen-lockfile\n - run: pnpm run typecheck\n - run: pnpm run build\n - run: pnpm run test\n";
|
|
30
|
+
export declare const EDITORCONFIG = "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\n";
|
package/dist/stubs.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production stubs `basalt publish` copies into an app — the SINGLE source
|
|
3
|
+
* of truth. create-basalt ships the same files in every new scaffold and offers
|
|
4
|
+
* them from `create-basalt update`: its build copies this file verbatim to
|
|
5
|
+
* `packages/create-app/src/stubs.ts` (scripts/sync-stubs.mjs), and a test there
|
|
6
|
+
* fails if the copy drifts. Keep this module free of imports for that reason.
|
|
7
|
+
*
|
|
8
|
+
* The layout these stubs assume is the create-basalt scaffold's (BK-026):
|
|
9
|
+
* `pnpm build` runs `tsc -p tsconfig.build.json` (rootDir `.`, outDir `dist`),
|
|
10
|
+
* so `src/server.ts` compiles to {@link PRODUCTION_ENTRY}; `pnpm start` runs it
|
|
11
|
+
* with plain `node` — no tsx, no TypeScript at runtime. A Prisma client, when
|
|
12
|
+
* there is one, is generated OUTSIDE `src/` (`generated/prisma`) and imported
|
|
13
|
+
* through the package.json `imports` alias `#db/*`, so it resolves the same
|
|
14
|
+
* from `src/` (tsx) and from `dist/src/` (node).
|
|
15
|
+
*/
|
|
16
|
+
/** The compiled server entry the Dockerfile runs and the scaffold's `start` script points at. */
|
|
17
|
+
export const PRODUCTION_ENTRY = 'dist/src/server.js';
|
|
18
|
+
/** Multi-stage production image: build with the dev toolchain, run on plain node as a non-root user. */
|
|
19
|
+
export const DOCKERFILE = `# syntax=docker/dockerfile:1
|
|
20
|
+
# Production image for a Basalt app (the create-basalt layout): \`pnpm build\`
|
|
21
|
+
# compiles src/ to dist/ and the image runs ${PRODUCTION_ENTRY} on plain
|
|
22
|
+
# node — no tsx, no TypeScript at runtime.
|
|
23
|
+
#
|
|
24
|
+
# docker build -t my-app .
|
|
25
|
+
# docker run -p 3000:3000 -e APP_SECRET=… -e DATABASE_URL=… my-app
|
|
26
|
+
#
|
|
27
|
+
# Configuration comes from the environment (nothing loads .env here, and
|
|
28
|
+
# .dockerignore keeps it out of the image). Database migrations are NOT run
|
|
29
|
+
# by the image: apply them before rolling out (\`pnpm db:deploy\` in CI).
|
|
30
|
+
#
|
|
31
|
+
# Installs with pnpm from pnpm-lock.yaml. An app on npm/yarn/bun replaces the
|
|
32
|
+
# pnpm install / exec / run / prune lines with its own package manager's.
|
|
33
|
+
ARG NODE_VERSION=22
|
|
34
|
+
ARG PNPM_VERSION=11
|
|
35
|
+
|
|
36
|
+
FROM node:\${NODE_VERSION}-slim AS build
|
|
37
|
+
ARG PNPM_VERSION
|
|
38
|
+
# Non-interactive pnpm: without it \`pnpm prune\` in a workspace (a --ui app's
|
|
39
|
+
# web/) aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
|
|
40
|
+
ENV CI=true
|
|
41
|
+
WORKDIR /app
|
|
42
|
+
RUN npm install -g pnpm@\${PNPM_VERSION}
|
|
43
|
+
COPY . .
|
|
44
|
+
# Full install: the build needs the devDependencies (typescript, prisma).
|
|
45
|
+
RUN pnpm install --frozen-lockfile
|
|
46
|
+
# The Prisma client (when the app has a schema) is generated into ./generated.
|
|
47
|
+
RUN if [ -f prisma/schema.prisma ]; then pnpm exec prisma generate; fi && mkdir -p generated
|
|
48
|
+
RUN pnpm run build
|
|
49
|
+
# Drop the devDependencies (tsx, typescript, prisma CLI, generators, the AI
|
|
50
|
+
# bridge): none of them may ship. --ignore-scripts keeps the app's postinstall
|
|
51
|
+
# (\`prisma generate\`, a dev tool) from running again.
|
|
52
|
+
RUN pnpm prune --prod --ignore-scripts
|
|
53
|
+
|
|
54
|
+
FROM node:\${NODE_VERSION}-slim AS run
|
|
55
|
+
ENV NODE_ENV=production
|
|
56
|
+
ENV PORT=3000
|
|
57
|
+
# Path the HEALTHCHECK probes: the scaffold's /health route by default; set
|
|
58
|
+
# /readyz when the app registers healthPlugin().
|
|
59
|
+
ENV HEALTHCHECK_PATH=/health
|
|
60
|
+
WORKDIR /app
|
|
61
|
+
COPY --from=build --chown=node:node /app/package.json ./package.json
|
|
62
|
+
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
|
63
|
+
COPY --from=build --chown=node:node /app/generated ./generated
|
|
64
|
+
COPY --from=build --chown=node:node /app/dist ./dist
|
|
65
|
+
USER node
|
|
66
|
+
EXPOSE 3000
|
|
67
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\
|
|
68
|
+
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + process.env.HEALTHCHECK_PATH).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
|
|
69
|
+
CMD ["node", "--enable-source-maps", "${PRODUCTION_ENTRY}"]
|
|
70
|
+
`;
|
|
71
|
+
/**
|
|
72
|
+
* Published with the Dockerfile: its \`COPY . .\` would otherwise bake \`.env\`,
|
|
73
|
+
* private keys and VCS metadata into an image layer. Every secret rule is
|
|
74
|
+
* \`**\/\`-prefixed: .dockerignore patterns are anchored at the context root, so
|
|
75
|
+
* a bare \`.env\` or \`*.pem\` would still let \`prisma/.env\` or
|
|
76
|
+
* \`certs/server.key\` through. \`dist\` is rebuilt inside the image.
|
|
77
|
+
*/
|
|
78
|
+
export const DOCKERIGNORE = `**/.env
|
|
79
|
+
**/.env.*
|
|
80
|
+
!**/.env.example
|
|
81
|
+
**/.npmrc
|
|
82
|
+
**/.git
|
|
83
|
+
**/node_modules
|
|
84
|
+
**/coverage
|
|
85
|
+
**/*.log
|
|
86
|
+
**/*.pem
|
|
87
|
+
**/*.key
|
|
88
|
+
**/*.p12
|
|
89
|
+
**/*.pfx
|
|
90
|
+
**/.DS_Store
|
|
91
|
+
dist
|
|
92
|
+
`;
|
|
93
|
+
/** GitHub Actions CI: install, typecheck, build (the production path), test. */
|
|
94
|
+
export const CI_WORKFLOW = `name: ci
|
|
95
|
+
on:
|
|
96
|
+
push: { branches: [main] }
|
|
97
|
+
pull_request:
|
|
98
|
+
jobs:
|
|
99
|
+
build:
|
|
100
|
+
runs-on: ubuntu-latest
|
|
101
|
+
steps:
|
|
102
|
+
- uses: actions/checkout@v4
|
|
103
|
+
# Drop \`with: version\` if package.json declares a packageManager.
|
|
104
|
+
- uses: pnpm/action-setup@v4
|
|
105
|
+
with: { version: 11 }
|
|
106
|
+
- uses: actions/setup-node@v4
|
|
107
|
+
with: { node-version: 22, cache: pnpm }
|
|
108
|
+
- run: pnpm install --frozen-lockfile
|
|
109
|
+
- run: pnpm run typecheck
|
|
110
|
+
- run: pnpm run build
|
|
111
|
+
- run: pnpm run test
|
|
112
|
+
`;
|
|
113
|
+
export const EDITORCONFIG = `root = true
|
|
114
|
+
|
|
115
|
+
[*]
|
|
116
|
+
charset = utf-8
|
|
117
|
+
end_of_line = lf
|
|
118
|
+
insert_final_newline = true
|
|
119
|
+
indent_style = space
|
|
120
|
+
indent_size = 2
|
|
121
|
+
trim_trailing_whitespace = true
|
|
122
|
+
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22.5.0"
|
|
6
6
|
},
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"dist"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@basaltkit/core": "^1.
|
|
21
|
+
"@basaltkit/core": "^1.6.0",
|
|
22
|
+
"@basaltkit/http": "^2.8.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/node": "^26.6.1",
|