@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 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 (read from the `http:routes` metadata bucket, populated by HTTP adapters such as `@basaltkit/fastify`) |
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`, `editorconfig` (run with no id to list) |
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.`**
@@ -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 the HTTP routes registered by the app',
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
- if (routes.length === 0) {
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(routes.map(({ method, url }) => ({ method, url })));
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
- const DOCKERFILE = `# syntax=docker/dockerfile:1
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
  /**
@@ -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.0",
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.5.0"
21
+ "@basaltkit/core": "^1.6.0",
22
+ "@basaltkit/http": "^2.8.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^26.6.1",