@basaltkit/cli 1.0.0 → 1.1.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -87,8 +87,14 @@ Without registering anything, `runCli` always provides:
87
87
  | `basalt list` (or `basalt` with no arguments) | Lists all available commands, with their descriptions |
88
88
  | `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`) |
89
89
  | `basalt schedule:list` | Lists scheduled tasks and their cron expressions (read from the `schedule:entries` bucket, populated by `@basaltkit/scheduler`) |
90
+ | `basalt dev [--entry=<file>]` | Runs the app with file watching + auto-restart (delegates to `tsx watch` when available, otherwise `node --watch`) |
91
+ | `basalt upgrade [--dry] [--only=<id>]` | Applies framework upgrade codemods (ships the `@machize/*` → `@basaltkit/*` scope rename; `--dry` previews) |
92
+ | `basalt publish [<id>] [--force]` | Copies a bundled stub group into the app — `dockerfile`, `ci`, `editorconfig` (run with no id to list) |
90
93
 
91
- If you also install `@basaltkit/generator`, you gain the `make:*` commands (see the "How it connects to other modules" section).
94
+ If you also install `@basaltkit/generator`, you gain the `make:*` commands. Feature
95
+ plugins register their own: `queue:work|stats|retry` (`@basaltkit/queue`),
96
+ `tenant:list|create|migrate|seed|run` (`@basaltkit/tenancy`), `generate:docs`
97
+ (`@basaltkit/http`), and `mail:preview` (`@basaltkit/mailer`).
92
98
 
93
99
  ### Defining a command with arguments and flags
94
100
 
@@ -211,7 +217,7 @@ Renders the rows as an aligned text table, with no external dependencies. Return
211
217
 
212
218
  ### `builtinCommands(): CommandDefinition[]`
213
219
 
214
- Returns `[routesCommand, scheduleListCommand]`. *(Advanced — `runCli` already includes them automatically.)*
220
+ Returns `[routesCommand, scheduleListCommand, devCommand, upgradeCommand, publishCommand]`. *(Advanced — `runCli` already includes them automatically.)*
215
221
 
216
222
  ### `routesCommand` / `scheduleListCommand`
217
223
 
package/dist/index.d.ts CHANGED
@@ -82,4 +82,112 @@ declare const routesCommand: CommandDefinition;
82
82
  declare const scheduleListCommand: CommandDefinition;
83
83
  declare function builtinCommands(): CommandDefinition[];
84
84
 
85
- export { type CommandContext, type CommandDefinition, type CommandIo, type ParsedArgv, type RouteMetadata, type RunCliOptions, type ScheduleMetadata, builtinCommands, commandsPlugin, consoleIo, defineCommand, memoryIo, parseArgv, renderTable, routesCommand, runCli, scheduleListCommand };
85
+ /** Default entry files probed in order when `--entry` is not given. */
86
+ declare const DEV_ENTRY_CANDIDATES: string[];
87
+ /**
88
+ * Picks the first candidate that exists. Pure — `exists` is injected so the
89
+ * resolution is testable without touching disk.
90
+ */
91
+ declare function resolveDevEntry(candidates: string[], exists: (path: string) => boolean): string | undefined;
92
+ interface DevRunner {
93
+ command: string;
94
+ args: string[];
95
+ }
96
+ /**
97
+ * Chooses how to run the entry with watch/restart, delegating the actual
98
+ * watching to the runtime (no bespoke watcher). Pure and testable:
99
+ *
100
+ * - `.ts` with tsx available → `tsx watch <entry>`
101
+ * - `.ts` without tsx → `node --watch --experimental-strip-types <entry>` (Node ≥ 22.6)
102
+ * - `.js` → `node --watch <entry>`
103
+ */
104
+ declare function resolveDevRunner(entry: string, options?: {
105
+ tsx?: boolean;
106
+ }): DevRunner;
107
+ /** `basalt dev [--entry=<file>]` — run the app with watch + restart. */
108
+ declare const devCommand: CommandDefinition;
109
+
110
+ /**
111
+ * Filesystem surface an upgrade migration works against. Injected so migrations
112
+ * (and `runUpgrade`) are testable with an in-memory tree — no disk needed.
113
+ */
114
+ interface UpgradeFs {
115
+ /** Recursively lists files under `dir` (relative paths). Skips node_modules/dist/.git. */
116
+ list(dir: string): Promise<string[]>;
117
+ read(path: string): Promise<string>;
118
+ write(path: string, content: string): Promise<void>;
119
+ }
120
+ /** A single edit a migration wants to make — surfaced for `--dry` before writing. */
121
+ interface Edit {
122
+ path: string;
123
+ before: string;
124
+ after: string;
125
+ }
126
+ /** A versioned, idempotent codemod. `plan` computes edits without writing them. */
127
+ interface Migration {
128
+ id: string;
129
+ description: string;
130
+ plan(fs: UpgradeFs, dir: string): Promise<Edit[]>;
131
+ }
132
+ interface UpgradeReport {
133
+ migration: string;
134
+ changed: string[];
135
+ }
136
+ /**
137
+ * A codemod that rewrites the deprecated `@machize/*` npm scope to `@basaltkit/*`
138
+ * across package.json and source imports. Unambiguous (scoped rename), so a plain
139
+ * text replace is safe and idempotent.
140
+ */
141
+ declare const renameMachizeScope: Migration;
142
+ /** All migrations shipped with the CLI, in application order. */
143
+ declare const MIGRATIONS: Migration[];
144
+ /**
145
+ * Runs the selected migrations. With `dry`, computes and reports edits without
146
+ * writing. Returns a per-migration report of the files it changed.
147
+ */
148
+ declare function runUpgrade(migrations: Migration[], fs: UpgradeFs, options: {
149
+ dir: string;
150
+ dry?: boolean;
151
+ only?: string;
152
+ }): Promise<UpgradeReport[]>;
153
+ /** Node-backed {@link UpgradeFs}. Recursive, skipping build/vcs directories. */
154
+ declare function nodeUpgradeFs(): UpgradeFs;
155
+ /** `basalt upgrade [--dir=<path>] [--dry] [--only=<migration-id>]` */
156
+ declare const upgradeCommand: CommandDefinition;
157
+
158
+ /** One file a publishable drops into the app. */
159
+ interface PublishableFile {
160
+ /** Path relative to the target dir, e.g. `Dockerfile` or `.github/workflows/ci.yml`. */
161
+ path: string;
162
+ content: string;
163
+ }
164
+ /** A named group of stub files an app can copy in and then own (à la vendor:publish). */
165
+ interface Publishable {
166
+ id: string;
167
+ description: string;
168
+ files(): PublishableFile[];
169
+ }
170
+ /** Filesystem surface for publishing — injected so `runPublish` is testable. */
171
+ interface PublishFs {
172
+ exists(path: string): Promise<boolean>;
173
+ write(path: string, content: string): Promise<void>;
174
+ }
175
+ interface PublishResult {
176
+ written: string[];
177
+ skipped: string[];
178
+ }
179
+ /** Stubs bundled with the CLI. Apps can register more via the metadata bucket. */
180
+ declare const PUBLISHABLES: Publishable[];
181
+ /**
182
+ * Copies a publishable's files into the target, skipping any that already exist
183
+ * unless `force` is set. Returns which files were written vs skipped.
184
+ */
185
+ declare function runPublish(publishable: Publishable, fs: PublishFs, options?: {
186
+ force?: boolean;
187
+ }): Promise<PublishResult>;
188
+ /** Node-backed {@link PublishFs} rooted at `dir` (creates parent directories). */
189
+ declare function nodePublishFs(dir: string): PublishFs;
190
+ /** `basalt publish [<id>] [--dir=<path>] [--force]` — list, or copy a stub group. */
191
+ declare const publishCommand: CommandDefinition;
192
+
193
+ export { type CommandContext, type CommandDefinition, type CommandIo, DEV_ENTRY_CANDIDATES, type DevRunner, type Edit, MIGRATIONS, type Migration, PUBLISHABLES, type ParsedArgv, type PublishFs, type PublishResult, type Publishable, type PublishableFile, type RouteMetadata, type RunCliOptions, type ScheduleMetadata, type UpgradeFs, type UpgradeReport, builtinCommands, commandsPlugin, consoleIo, defineCommand, devCommand, memoryIo, nodePublishFs, nodeUpgradeFs, parseArgv, publishCommand, renameMachizeScope, renderTable, resolveDevEntry, resolveDevRunner, routesCommand, runCli, runPublish, runUpgrade, scheduleListCommand, upgradeCommand };
package/dist/index.js CHANGED
@@ -8,6 +8,258 @@ import { ensureMetadata } from "@basaltkit/core";
8
8
 
9
9
  // src/builtins.ts
10
10
  import { METADATA } from "@basaltkit/core";
11
+
12
+ // src/dev.ts
13
+ var DEV_ENTRY_CANDIDATES = ["src/main.ts", "src/server.ts", "src/index.ts", "src/main.js", "src/index.js"];
14
+ function resolveDevEntry(candidates, exists) {
15
+ return candidates.find(exists);
16
+ }
17
+ function resolveDevRunner(entry, options = {}) {
18
+ const isTs = /\.tsx?$/.test(entry);
19
+ if (isTs && options.tsx) return { command: "tsx", args: ["watch", entry] };
20
+ if (isTs) return { command: "node", args: ["--watch", "--experimental-strip-types", entry] };
21
+ return { command: "node", args: ["--watch", entry] };
22
+ }
23
+ var devCommand = defineCommand({
24
+ name: "dev",
25
+ description: "Run the app with file watching and auto-restart",
26
+ async handle({ io, flags }) {
27
+ const { existsSync } = await import("fs");
28
+ const entry = typeof flags["entry"] === "string" ? flags["entry"] : resolveDevEntry(DEV_ENTRY_CANDIDATES, existsSync);
29
+ if (!entry) {
30
+ io.error(
31
+ `No entry file found. Looked for ${DEV_ENTRY_CANDIDATES.join(", ")}. Pass --entry=<file>.`
32
+ );
33
+ return 1;
34
+ }
35
+ const tsx = await canResolve("tsx");
36
+ const runner = resolveDevRunner(entry, { tsx });
37
+ io.log(`Starting: ${runner.command} ${runner.args.join(" ")}`);
38
+ const { spawn } = await import("child_process");
39
+ return await new Promise((resolve) => {
40
+ const child = spawn(runner.command, runner.args, { stdio: "inherit" });
41
+ const stop = () => child.kill("SIGINT");
42
+ process.once("SIGINT", stop);
43
+ child.on("exit", (code) => {
44
+ process.removeListener("SIGINT", stop);
45
+ resolve(code ?? 0);
46
+ });
47
+ child.on("error", (error) => {
48
+ io.error(`Failed to start dev runner: ${error.message}`);
49
+ resolve(1);
50
+ });
51
+ });
52
+ }
53
+ });
54
+ async function canResolve(specifier) {
55
+ try {
56
+ const { createRequire } = await import("module");
57
+ createRequire(`${process.cwd()}/`).resolve(specifier);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ // src/upgrade.ts
65
+ var renameMachizeScope = {
66
+ id: "rename-machize-scope",
67
+ description: "Rewrite the deprecated @machize/* scope to @basaltkit/*",
68
+ async plan(fs, dir) {
69
+ const files = (await fs.list(dir)).filter((p) => /\.(t|j)sx?$|\.json$/.test(p));
70
+ const edits = [];
71
+ for (const path of files) {
72
+ const before = await fs.read(path);
73
+ if (!before.includes("@machize/")) continue;
74
+ const after = before.split("@machize/").join("@basaltkit/");
75
+ if (after !== before) edits.push({ path, before, after });
76
+ }
77
+ return edits;
78
+ }
79
+ };
80
+ var MIGRATIONS = [renameMachizeScope];
81
+ async function runUpgrade(migrations, fs, options) {
82
+ const selected = options.only ? migrations.filter((m) => m.id === options.only) : migrations;
83
+ const reports = [];
84
+ for (const migration of selected) {
85
+ const edits = await migration.plan(fs, options.dir);
86
+ if (!options.dry) {
87
+ for (const edit of edits) await fs.write(edit.path, edit.after);
88
+ }
89
+ reports.push({ migration: migration.id, changed: edits.map((e) => e.path) });
90
+ }
91
+ return reports;
92
+ }
93
+ function nodeUpgradeFs() {
94
+ const SKIP = /* @__PURE__ */ new Set(["node_modules", "dist", ".git", ".next", "build", "coverage"]);
95
+ return {
96
+ async list(dir) {
97
+ const { readdir } = await import("fs/promises");
98
+ const { join, relative } = await import("path");
99
+ const out = [];
100
+ const walk = async (current) => {
101
+ const entries = await readdir(current, { withFileTypes: true });
102
+ for (const entry of entries) {
103
+ if (entry.isDirectory()) {
104
+ if (!SKIP.has(entry.name)) await walk(join(current, entry.name));
105
+ } else {
106
+ out.push(relative(dir, join(current, entry.name)));
107
+ }
108
+ }
109
+ };
110
+ await walk(dir);
111
+ return out;
112
+ },
113
+ async read(path) {
114
+ const { readFile } = await import("fs/promises");
115
+ const { isAbsolute, join } = await import("path");
116
+ return readFile(isAbsolute(path) ? path : join(process.cwd(), path), "utf8");
117
+ },
118
+ async write(path, content) {
119
+ const { writeFile } = await import("fs/promises");
120
+ const { isAbsolute, join } = await import("path");
121
+ await writeFile(isAbsolute(path) ? path : join(process.cwd(), path), content, "utf8");
122
+ }
123
+ };
124
+ }
125
+ var upgradeCommand = defineCommand({
126
+ name: "upgrade",
127
+ description: "Apply framework upgrade codemods (--dry to preview, --only=<id>)",
128
+ async handle({ io, flags }) {
129
+ const dir = typeof flags["dir"] === "string" ? flags["dir"] : process.cwd();
130
+ const dry = flags["dry"] === true;
131
+ const only = typeof flags["only"] === "string" ? flags["only"] : void 0;
132
+ if (only && !MIGRATIONS.some((m) => m.id === only)) {
133
+ io.error(`Unknown migration "${only}". Available: ${MIGRATIONS.map((m) => m.id).join(", ")}.`);
134
+ return 1;
135
+ }
136
+ const reports = await runUpgrade(MIGRATIONS, nodeUpgradeFs(), {
137
+ dir,
138
+ dry,
139
+ ...only ? { only } : {}
140
+ });
141
+ let total = 0;
142
+ for (const report of reports) {
143
+ if (report.changed.length === 0) continue;
144
+ total += report.changed.length;
145
+ io.log(`${dry ? "[dry] " : ""}${report.migration}: ${report.changed.length} file(s)`);
146
+ for (const path of report.changed) io.log(` ${path}`);
147
+ }
148
+ if (total === 0) io.log("Nothing to upgrade \u2014 everything is up to date.");
149
+ else if (dry) io.log(`
150
+ ${total} file(s) would change. Re-run without --dry to apply.`);
151
+ else io.log(`
152
+ Applied ${total} change(s).`);
153
+ return 0;
154
+ }
155
+ });
156
+
157
+ // src/publish.ts
158
+ var DOCKERFILE = `# syntax=docker/dockerfile:1
159
+ FROM node:22-slim AS base
160
+ ENV NODE_ENV=production
161
+ WORKDIR /app
162
+
163
+ FROM base AS deps
164
+ COPY package.json pnpm-lock.yaml* ./
165
+ RUN corepack enable && pnpm install --prod --frozen-lockfile
166
+
167
+ FROM base AS run
168
+ COPY --from=deps /app/node_modules ./node_modules
169
+ COPY . .
170
+ EXPOSE 3000
171
+ CMD ["node", "dist/main.js"]
172
+ `;
173
+ var CI_WORKFLOW = `name: ci
174
+ on:
175
+ push: { branches: [main] }
176
+ pull_request:
177
+ jobs:
178
+ build:
179
+ runs-on: ubuntu-latest
180
+ steps:
181
+ - uses: actions/checkout@v4
182
+ - uses: pnpm/action-setup@v4
183
+ - uses: actions/setup-node@v4
184
+ with: { node-version: 22, cache: pnpm }
185
+ - run: pnpm install --frozen-lockfile
186
+ - run: pnpm run build
187
+ - run: pnpm run test
188
+ `;
189
+ var EDITORCONFIG = `root = true
190
+
191
+ [*]
192
+ charset = utf-8
193
+ end_of_line = lf
194
+ insert_final_newline = true
195
+ indent_style = space
196
+ indent_size = 2
197
+ trim_trailing_whitespace = true
198
+ `;
199
+ var PUBLISHABLES = [
200
+ { id: "dockerfile", description: "Production multi-stage Dockerfile", files: () => [{ path: "Dockerfile", content: DOCKERFILE }] },
201
+ { id: "ci", description: "GitHub Actions CI workflow", files: () => [{ path: ".github/workflows/ci.yml", content: CI_WORKFLOW }] },
202
+ { id: "editorconfig", description: "Shared .editorconfig", files: () => [{ path: ".editorconfig", content: EDITORCONFIG }] }
203
+ ];
204
+ async function runPublish(publishable, fs, options = {}) {
205
+ const written = [];
206
+ const skipped = [];
207
+ for (const file of publishable.files()) {
208
+ if (!options.force && await fs.exists(file.path)) {
209
+ skipped.push(file.path);
210
+ continue;
211
+ }
212
+ await fs.write(file.path, file.content);
213
+ written.push(file.path);
214
+ }
215
+ return { written, skipped };
216
+ }
217
+ function nodePublishFs(dir) {
218
+ return {
219
+ async exists(path) {
220
+ const { access } = await import("fs/promises");
221
+ const { join } = await import("path");
222
+ return access(join(dir, path)).then(
223
+ () => true,
224
+ () => false
225
+ );
226
+ },
227
+ async write(path, content) {
228
+ const { mkdir, writeFile } = await import("fs/promises");
229
+ const { dirname, join } = await import("path");
230
+ const target = join(dir, path);
231
+ await mkdir(dirname(target), { recursive: true });
232
+ await writeFile(target, content, "utf8");
233
+ }
234
+ };
235
+ }
236
+ var publishCommand = defineCommand({
237
+ name: "publish",
238
+ description: "Copy a publishable stub group into the app (run with no id to list)",
239
+ async handle({ io, args, flags }) {
240
+ const id = args[0];
241
+ if (!id) {
242
+ io.log("Publishable groups:");
243
+ io.table(PUBLISHABLES.map((p) => ({ id: p.id, description: p.description })));
244
+ return 0;
245
+ }
246
+ const publishable = PUBLISHABLES.find((p) => p.id === id);
247
+ if (!publishable) {
248
+ io.error(`Unknown publishable "${id}". Available: ${PUBLISHABLES.map((p) => p.id).join(", ")}.`);
249
+ return 1;
250
+ }
251
+ const dir = typeof flags["dir"] === "string" ? flags["dir"] : process.cwd();
252
+ const result = await runPublish(publishable, nodePublishFs(dir), { force: flags["force"] === true });
253
+ for (const path of result.written) io.log(` wrote ${path}`);
254
+ for (const path of result.skipped) io.log(` skipped ${path} (exists \u2014 use --force to overwrite)`);
255
+ if (result.written.length === 0 && result.skipped.length > 0) {
256
+ io.log("Nothing written. Re-run with --force to overwrite existing files.");
257
+ }
258
+ return 0;
259
+ }
260
+ });
261
+
262
+ // src/builtins.ts
11
263
  var routesCommand = defineCommand({
12
264
  name: "routes",
13
265
  description: "List the HTTP routes registered by the app",
@@ -33,7 +285,7 @@ var scheduleListCommand = defineCommand({
33
285
  }
34
286
  });
35
287
  function builtinCommands() {
36
- return [routesCommand, scheduleListCommand];
288
+ return [routesCommand, scheduleListCommand, devCommand, upgradeCommand, publishCommand];
37
289
  }
38
290
 
39
291
  // src/io.ts
@@ -142,14 +394,27 @@ function commandsPlugin(commands) {
142
394
  });
143
395
  }
144
396
  export {
397
+ DEV_ENTRY_CANDIDATES,
398
+ MIGRATIONS,
399
+ PUBLISHABLES,
145
400
  builtinCommands,
146
401
  commandsPlugin,
147
402
  consoleIo,
148
403
  defineCommand,
404
+ devCommand,
149
405
  memoryIo,
406
+ nodePublishFs,
407
+ nodeUpgradeFs,
150
408
  parseArgv,
409
+ publishCommand,
410
+ renameMachizeScope,
151
411
  renderTable,
412
+ resolveDevEntry,
413
+ resolveDevRunner,
152
414
  routesCommand,
153
415
  runCli,
154
- scheduleListCommand
416
+ runPublish,
417
+ runUpgrade,
418
+ scheduleListCommand,
419
+ upgradeCommand
155
420
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "The 'basalt' command framework for Basalt apps: define commands, run them against a booted app, and inspect routes and schedules.",
5
5
  "license": "MIT",
6
6
  "type": "module",