@basaltkit/cli 1.4.2 → 1.6.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 +18 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/dev.d.ts +33 -2
- package/dist/dev.js +139 -55
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/launcher.d.ts +46 -0
- package/dist/launcher.js +154 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -24,6 +24,23 @@ pnpm add @basaltkit/cli
|
|
|
24
24
|
|
|
25
25
|
> Note: the package depends on `@basaltkit/core` (the heart of the framework, where `createApp` and the dependency container live). If you created the project with `create-basalt --cli`, both are already installed.
|
|
26
26
|
|
|
27
|
+
### The global `basalt` launcher
|
|
28
|
+
|
|
29
|
+
Inside an app, `basalt` is a `package.json` script: `pnpm basalt <command>`.
|
|
30
|
+
Install the launcher once to type `basalt <command>` instead:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm i -g @basaltkit/cli
|
|
34
|
+
basalt routes # anywhere inside the app = pnpm basalt routes, in the app's root
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It finds the nearest `package.json` with a `basalt` script, from the current
|
|
38
|
+
directory up, and runs that script with the app's package manager
|
|
39
|
+
(`packageManager`, else the lockfile). So the code that runs is always the
|
|
40
|
+
app's, at its pinned versions. Arguments and the exit code pass through
|
|
41
|
+
unchanged. Outside an app it says so and exits 1. The launcher loads nothing of
|
|
42
|
+
the framework, only Node built-ins.
|
|
43
|
+
|
|
27
44
|
## Get started in 5 minutes
|
|
28
45
|
|
|
29
46
|
1. Create a `bin/basalt.ts` file at the root of the project — this will be the entry point for the `basalt` command:
|
|
@@ -93,7 +110,7 @@ Without registering anything, `runCli` always provides:
|
|
|
93
110
|
| `basalt list` (or `basalt` with no arguments) | Lists all available commands, with their descriptions |
|
|
94
111
|
| `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
112
|
| `basalt schedule:list` | Lists scheduled tasks and their cron expressions (read from the `schedule:entries` bucket, populated by `@basaltkit/scheduler`) |
|
|
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`. |
|
|
113
|
+
| `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`. Runs `src/dev.ts` when present (else `src/main.ts`, `src/server.ts`, …), and shuts the CLI's own boot down before the server starts: one app per session. A create-basalt `bin/basalt.ts` routes `dev` to `runDev({ loadApp })` before booting anything, so it starts even while the app cannot boot (the reason is printed, the watcher restarts on save). |
|
|
97
114
|
| `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
115
|
| `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
116
|
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
package/dist/dev.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
|
|
1
|
+
import { type BasaltApp } from '@basaltkit/core';
|
|
2
|
+
import { type CommandDefinition, type CommandIo } from './command.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default entry files probed in order when `--entry` is not given. A
|
|
5
|
+
* create-basalt app's `src/dev.ts` comes first: it is what `pnpm dev` runs
|
|
6
|
+
* (it loads `.env` and opts into NODE_ENV=development before the server).
|
|
7
|
+
*/
|
|
3
8
|
export declare const DEV_ENTRY_CANDIDATES: string[];
|
|
4
9
|
/**
|
|
5
10
|
* Picks the first candidate that exists. Pure — `exists` is injected so the
|
|
@@ -45,5 +50,31 @@ export declare function devRouteRows(routes: DevRoute[]): {
|
|
|
45
50
|
* runs the server with watch/restart — and, with `--worker`, also starts a
|
|
46
51
|
* watched queue worker alongside it (server + worker in one command, the real
|
|
47
52
|
* producer/worker topology).
|
|
53
|
+
*
|
|
54
|
+
* The CLI's own boot exists only to read the route table: it is shut down
|
|
55
|
+
* before the server starts, so a dev session holds one app (one database
|
|
56
|
+
* pool, one set of connections), not two. A create-basalt `bin/basalt.ts`
|
|
57
|
+
* sends `dev` to {@link runDev} before booting anything, so the session also
|
|
58
|
+
* starts while the app cannot boot; this command is the path for a bin
|
|
59
|
+
* without that prelude.
|
|
48
60
|
*/
|
|
49
61
|
export declare const devCommand: CommandDefinition;
|
|
62
|
+
export interface RunDevOptions {
|
|
63
|
+
/** Defaults to process.argv.slice(2) — `dev` first, as typed. */
|
|
64
|
+
argv?: string[];
|
|
65
|
+
/** Imports and builds the app, unbooted — only to read its route table. */
|
|
66
|
+
loadApp: () => BasaltApp | Promise<BasaltApp>;
|
|
67
|
+
io?: CommandIo;
|
|
68
|
+
/** How long the route table may wait for the boot before the server starts without it. @default 10000 */
|
|
69
|
+
bootTimeoutMs?: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `basalt dev` without depending on the app booting. The app is loaded and
|
|
73
|
+
* booted once, only to print the route table, then shut down. When that fails
|
|
74
|
+
* — a variable missing, the database down, code mid-edit — the reason is
|
|
75
|
+
* printed and the watcher starts anyway: it restarts the server on every save,
|
|
76
|
+
* as `pnpm dev` does, so you fix the problem and the session carries on.
|
|
77
|
+
*/
|
|
78
|
+
export declare function runDev(options: RunDevOptions): Promise<number>;
|
|
79
|
+
/** The reason a boot failed, in a few lines: the env report when there is one, else the first line of the message. */
|
|
80
|
+
export declare function bootFailureLines(error: unknown): string[];
|
package/dist/dev.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { ensureMetadata } from '@basaltkit/core';
|
|
2
2
|
import { defineCommand } from './command.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { consoleIo } from './io.js';
|
|
4
|
+
import { parseArgv } from './parse.js';
|
|
5
|
+
/**
|
|
6
|
+
* Default entry files probed in order when `--entry` is not given. A
|
|
7
|
+
* create-basalt app's `src/dev.ts` comes first: it is what `pnpm dev` runs
|
|
8
|
+
* (it loads `.env` and opts into NODE_ENV=development before the server).
|
|
9
|
+
*/
|
|
10
|
+
export const DEV_ENTRY_CANDIDATES = ['src/dev.ts', 'src/main.ts', 'src/server.ts', 'src/index.ts', 'src/main.js', 'src/index.js'];
|
|
5
11
|
/**
|
|
6
12
|
* Picks the first candidate that exists. Pure — `exists` is injected so the
|
|
7
13
|
* resolution is testable without touching disk.
|
|
@@ -53,66 +59,144 @@ export function devRouteRows(routes) {
|
|
|
53
59
|
* runs the server with watch/restart — and, with `--worker`, also starts a
|
|
54
60
|
* watched queue worker alongside it (server + worker in one command, the real
|
|
55
61
|
* producer/worker topology).
|
|
62
|
+
*
|
|
63
|
+
* The CLI's own boot exists only to read the route table: it is shut down
|
|
64
|
+
* before the server starts, so a dev session holds one app (one database
|
|
65
|
+
* pool, one set of connections), not two. A create-basalt `bin/basalt.ts`
|
|
66
|
+
* sends `dev` to {@link runDev} before booting anything, so the session also
|
|
67
|
+
* starts while the app cannot boot; this command is the path for a bin
|
|
68
|
+
* without that prelude.
|
|
56
69
|
*/
|
|
57
70
|
export const devCommand = defineCommand({
|
|
58
71
|
name: 'dev',
|
|
59
72
|
description: 'Run the app with watch + restart, print the route table, and (--worker) an embedded queue worker',
|
|
60
|
-
async handle({ container, io, flags }) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
? flags['entry']
|
|
64
|
-
: resolveDevEntry(DEV_ENTRY_CANDIDATES, existsSync);
|
|
65
|
-
if (!entry) {
|
|
66
|
-
io.error(`No entry file found. Looked for ${DEV_ENTRY_CANDIDATES.join(', ')}. Pass --entry=<file>.`);
|
|
73
|
+
async handle({ app, container, io, flags }) {
|
|
74
|
+
const entry = await devEntry(flags, io);
|
|
75
|
+
if (!entry)
|
|
67
76
|
return 1;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
io.log(`Routes (${routes.length}):`);
|
|
75
|
-
io.table(devRouteRows(routes));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const tsx = await canResolve('tsx');
|
|
79
|
-
const server = resolveDevRunner(entry, { tsx });
|
|
80
|
-
const runners = [{ label: 'server', runner: server }];
|
|
81
|
-
// Embedded worker: a watched `queue:work` on the same CLI bin, so jobs
|
|
82
|
-
// process in dev without a second terminal. Separate process (correct
|
|
83
|
-
// producer/worker split) — each restarts independently on file change.
|
|
84
|
-
if (flags['worker'] === true || flags['w'] === true) {
|
|
85
|
-
const bin = process.argv[1] ?? entry;
|
|
86
|
-
const workerBase = resolveDevRunner(bin, { tsx });
|
|
87
|
-
const queue = typeof flags['queue'] === 'string' ? [`--queue=${flags['queue']}`] : [];
|
|
88
|
-
runners.push({ label: 'worker', runner: { command: workerBase.command, args: [...workerBase.args, 'queue:work', ...queue] } });
|
|
89
|
-
}
|
|
90
|
-
for (const { label, runner } of runners) {
|
|
91
|
-
io.log(`▶ ${label}: ${runner.command} ${runner.args.join(' ')}`);
|
|
92
|
-
}
|
|
93
|
-
const { spawn } = await import('node:child_process');
|
|
94
|
-
return await new Promise((resolve) => {
|
|
95
|
-
const children = runners.map(({ runner }) => spawn(runner.command, runner.args, { stdio: 'inherit' }));
|
|
96
|
-
const stop = () => children.forEach((c) => c.kill('SIGINT'));
|
|
97
|
-
process.once('SIGINT', stop);
|
|
98
|
-
let done = false;
|
|
99
|
-
const finish = (code) => {
|
|
100
|
-
if (done)
|
|
101
|
-
return;
|
|
102
|
-
done = true;
|
|
103
|
-
process.removeListener('SIGINT', stop);
|
|
104
|
-
children.forEach((c) => c.kill('SIGINT'));
|
|
105
|
-
resolve(code);
|
|
106
|
-
};
|
|
107
|
-
// The server is primary — when it exits, tear everything down.
|
|
108
|
-
children[0]?.on('exit', (code) => finish(code ?? 0));
|
|
109
|
-
children.forEach((child, i) => child.on('error', (error) => {
|
|
110
|
-
io.error(`Failed to start ${runners[i]?.label}: ${error.message}`);
|
|
111
|
-
finish(1);
|
|
112
|
-
}));
|
|
113
|
-
});
|
|
77
|
+
if (flags['routes'] !== false)
|
|
78
|
+
printRoutes(container, io);
|
|
79
|
+
// Done with the CLI's boot: the server below is the app from here on.
|
|
80
|
+
// runCli's own shutdown afterwards is a no-op.
|
|
81
|
+
await app.shutdown();
|
|
82
|
+
return startDevProcesses(entry, flags, io);
|
|
114
83
|
},
|
|
115
84
|
});
|
|
85
|
+
/**
|
|
86
|
+
* `basalt dev` without depending on the app booting. The app is loaded and
|
|
87
|
+
* booted once, only to print the route table, then shut down. When that fails
|
|
88
|
+
* — a variable missing, the database down, code mid-edit — the reason is
|
|
89
|
+
* printed and the watcher starts anyway: it restarts the server on every save,
|
|
90
|
+
* as `pnpm dev` does, so you fix the problem and the session carries on.
|
|
91
|
+
*/
|
|
92
|
+
export async function runDev(options) {
|
|
93
|
+
const io = options.io ?? consoleIo();
|
|
94
|
+
const { flags } = parseArgv(options.argv ?? process.argv.slice(2));
|
|
95
|
+
const entry = await devEntry(flags, io);
|
|
96
|
+
if (!entry)
|
|
97
|
+
return 1;
|
|
98
|
+
if (flags['routes'] !== false)
|
|
99
|
+
await routesIfItBoots(options, io);
|
|
100
|
+
return startDevProcesses(entry, flags, io);
|
|
101
|
+
}
|
|
102
|
+
async function routesIfItBoots(options, io) {
|
|
103
|
+
let app;
|
|
104
|
+
let timer;
|
|
105
|
+
const timeoutMs = options.bootTimeoutMs ?? 10_000;
|
|
106
|
+
const boot = (async () => {
|
|
107
|
+
app = await options.loadApp();
|
|
108
|
+
if (app.phase === 'created')
|
|
109
|
+
await app.boot();
|
|
110
|
+
return app;
|
|
111
|
+
})();
|
|
112
|
+
const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), timeoutMs); });
|
|
113
|
+
try {
|
|
114
|
+
const booted = await Promise.race([boot, timedOut]);
|
|
115
|
+
if (booted === 'timeout') {
|
|
116
|
+
io.error(`! The app did not boot within ${timeoutMs / 1000}s, so no route table — starting the server anyway.`);
|
|
117
|
+
// It may still finish: shut it down then, so the session holds one app.
|
|
118
|
+
boot.then((late) => late.shutdown()).catch(() => { });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
printRoutes(booted.container, io);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
io.error('! The app does not boot yet, so no route table — starting the server anyway; it restarts on every save:');
|
|
125
|
+
for (const line of bootFailureLines(error))
|
|
126
|
+
io.error(` ${line}`);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
if (app && app.phase !== 'created')
|
|
131
|
+
await app.shutdown().catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** The reason a boot failed, in a few lines: the env report when there is one, else the first line of the message. */
|
|
135
|
+
export function bootFailureLines(error) {
|
|
136
|
+
const failure = (error ?? {});
|
|
137
|
+
if (process.env['BASALT_DEBUG'] === '1' && typeof failure.stack === 'string')
|
|
138
|
+
return failure.stack.split('\n');
|
|
139
|
+
if (failure.code === 'ENV_INVALID' && Array.isArray(failure.report)) {
|
|
140
|
+
return ['invalid environment variables:', ...failure.report.map((line) => `- ${String(line)}`)];
|
|
141
|
+
}
|
|
142
|
+
const first = String(failure.message ?? error).split('\n')[0] ?? '';
|
|
143
|
+
return [first, '(BASALT_DEBUG=1 shows the stack trace)'];
|
|
144
|
+
}
|
|
145
|
+
async function devEntry(flags, io) {
|
|
146
|
+
if (typeof flags['entry'] === 'string')
|
|
147
|
+
return flags['entry'];
|
|
148
|
+
const { existsSync } = await import('node:fs');
|
|
149
|
+
const entry = resolveDevEntry(DEV_ENTRY_CANDIDATES, existsSync);
|
|
150
|
+
if (!entry)
|
|
151
|
+
io.error(`No entry file found. Looked for ${DEV_ENTRY_CANDIDATES.join(', ')}. Pass --entry=<file>.`);
|
|
152
|
+
return entry;
|
|
153
|
+
}
|
|
154
|
+
/** The route table, from the `http:routes` metadata whichever adapter published. Adapter-agnostic. */
|
|
155
|
+
function printRoutes(container, io) {
|
|
156
|
+
const routes = ensureMetadata(container).get('http:routes');
|
|
157
|
+
if (routes.length === 0)
|
|
158
|
+
return;
|
|
159
|
+
io.log(`Routes (${routes.length}):`);
|
|
160
|
+
io.table(devRouteRows(routes));
|
|
161
|
+
}
|
|
162
|
+
async function startDevProcesses(entry, flags, io) {
|
|
163
|
+
const tsx = await canResolve('tsx');
|
|
164
|
+
const server = resolveDevRunner(entry, { tsx });
|
|
165
|
+
const runners = [{ label: 'server', runner: server }];
|
|
166
|
+
// Embedded worker: a watched `queue:work` on the same CLI bin, so jobs
|
|
167
|
+
// process in dev without a second terminal. Separate process (correct
|
|
168
|
+
// producer/worker split) — each restarts independently on file change.
|
|
169
|
+
if (flags['worker'] === true || flags['w'] === true) {
|
|
170
|
+
const bin = process.argv[1] ?? entry;
|
|
171
|
+
const workerBase = resolveDevRunner(bin, { tsx });
|
|
172
|
+
const queue = typeof flags['queue'] === 'string' ? [`--queue=${flags['queue']}`] : [];
|
|
173
|
+
runners.push({ label: 'worker', runner: { command: workerBase.command, args: [...workerBase.args, 'queue:work', ...queue] } });
|
|
174
|
+
}
|
|
175
|
+
for (const { label, runner } of runners) {
|
|
176
|
+
io.log(`▶ ${label}: ${runner.command} ${runner.args.join(' ')}`);
|
|
177
|
+
}
|
|
178
|
+
const { spawn } = await import('node:child_process');
|
|
179
|
+
return await new Promise((resolve) => {
|
|
180
|
+
const children = runners.map(({ runner }) => spawn(runner.command, runner.args, { stdio: 'inherit' }));
|
|
181
|
+
const stop = () => children.forEach((c) => c.kill('SIGINT'));
|
|
182
|
+
process.once('SIGINT', stop);
|
|
183
|
+
let done = false;
|
|
184
|
+
const finish = (code) => {
|
|
185
|
+
if (done)
|
|
186
|
+
return;
|
|
187
|
+
done = true;
|
|
188
|
+
process.removeListener('SIGINT', stop);
|
|
189
|
+
children.forEach((c) => c.kill('SIGINT'));
|
|
190
|
+
resolve(code);
|
|
191
|
+
};
|
|
192
|
+
// The server is primary — when it exits, tear everything down.
|
|
193
|
+
children[0]?.on('exit', (code) => finish(code ?? 0));
|
|
194
|
+
children.forEach((child, i) => child.on('error', (error) => {
|
|
195
|
+
io.error(`Failed to start ${runners[i]?.label}: ${error.message}`);
|
|
196
|
+
finish(1);
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
116
200
|
/** True if a module specifier resolves from the app's node_modules. */
|
|
117
201
|
async function canResolve(specifier) {
|
|
118
202
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { commandsPlugin } from './plugin.js';
|
|
|
4
4
|
export { consoleIo, memoryIo, renderTable } from './io.js';
|
|
5
5
|
export { parseArgv, type ParsedArgv } from './parse.js';
|
|
6
6
|
export { builtinCommands, routesCommand, routeAllowPattern, UNGUARDED_SCOPE_NOTE, scheduleListCommand, type RouteMetadata, type ScheduleMetadata, } from './builtins.js';
|
|
7
|
-
export { devCommand, resolveDevEntry, resolveDevRunner, devRouteRows, DEV_ENTRY_CANDIDATES, type DevRunner, type DevRoute, } from './dev.js';
|
|
7
|
+
export { devCommand, runDev, bootFailureLines, type RunDevOptions, 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
10
|
export { DOCKERFILE, DOCKERIGNORE, CI_WORKFLOW, EDITORCONFIG, PRODUCTION_ENTRY } from './stubs.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { commandsPlugin } from './plugin.js';
|
|
|
4
4
|
export { consoleIo, memoryIo, renderTable } from './io.js';
|
|
5
5
|
export { parseArgv } from './parse.js';
|
|
6
6
|
export { builtinCommands, routesCommand, routeAllowPattern, UNGUARDED_SCOPE_NOTE, scheduleListCommand, } from './builtins.js';
|
|
7
|
-
export { devCommand, resolveDevEntry, resolveDevRunner, devRouteRows, DEV_ENTRY_CANDIDATES, } from './dev.js';
|
|
7
|
+
export { devCommand, runDev, bootFailureLines, 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
10
|
export { DOCKERFILE, DOCKERIGNORE, CI_WORKFLOW, EDITORCONFIG, PRODUCTION_ENTRY } from './stubs.js';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global `basalt` launcher: `npm i -g @basaltkit/cli`, then `basalt <command>`
|
|
3
|
+
* anywhere inside an app does what `pnpm basalt <command>` does there.
|
|
4
|
+
*
|
|
5
|
+
* It never runs commands itself. It finds the app (the nearest package.json
|
|
6
|
+
* with a `basalt` script) and hands the arguments to that script through the
|
|
7
|
+
* app's own package manager — so the code that runs is always the app's, at
|
|
8
|
+
* the versions its lockfile pins, never the launcher's. Node built-ins only:
|
|
9
|
+
* nothing of the framework is loaded.
|
|
10
|
+
*/
|
|
11
|
+
export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun';
|
|
12
|
+
export interface LauncherProject {
|
|
13
|
+
/** The directory whose package.json declares the `basalt` script. */
|
|
14
|
+
root: string;
|
|
15
|
+
/** The script itself, e.g. `tsx bin/basalt.ts`. */
|
|
16
|
+
script: string;
|
|
17
|
+
pm: PackageManager;
|
|
18
|
+
}
|
|
19
|
+
/** Set on the script's environment: a `basalt` script that calls `basalt` again would loop. */
|
|
20
|
+
export declare const LAUNCHER_ENV = "BASALT_LAUNCHER_ROOT";
|
|
21
|
+
/** The nearest app at or above `from`: a package.json with a `basalt` script. */
|
|
22
|
+
export declare function findProject(from: string): LauncherProject | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* `packageManager` in the nearest package.json that has one, else the nearest
|
|
25
|
+
* lockfile — both walking up, so an app inside a workspace uses the
|
|
26
|
+
* workspace's manager. Nothing found: npm.
|
|
27
|
+
*/
|
|
28
|
+
export declare function detectPackageManager(root: string): PackageManager;
|
|
29
|
+
/**
|
|
30
|
+
* The command that runs the app's `basalt` script with `args`, as given:
|
|
31
|
+
* pnpm, yarn and bun pass everything after the script name through (a `--`
|
|
32
|
+
* included); npm needs a `--` first.
|
|
33
|
+
*/
|
|
34
|
+
export declare function launchCommand(pm: PackageManager, args: readonly string[]): {
|
|
35
|
+
command: string;
|
|
36
|
+
args: string[];
|
|
37
|
+
};
|
|
38
|
+
export interface RunLauncherOptions {
|
|
39
|
+
cwd?: string;
|
|
40
|
+
env?: NodeJS.ProcessEnv;
|
|
41
|
+
/** Where the launcher's own lines go (never the app's output). */
|
|
42
|
+
error?: (line: string) => void;
|
|
43
|
+
log?: (line: string) => void;
|
|
44
|
+
}
|
|
45
|
+
/** Runs `basalt <argv>`: the app's `basalt` script, through the app's package manager. Resolves to the exit code. */
|
|
46
|
+
export declare function runLauncher(argv: readonly string[], options?: RunLauncherOptions): Promise<number>;
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global `basalt` launcher: `npm i -g @basaltkit/cli`, then `basalt <command>`
|
|
3
|
+
* anywhere inside an app does what `pnpm basalt <command>` does there.
|
|
4
|
+
*
|
|
5
|
+
* It never runs commands itself. It finds the app (the nearest package.json
|
|
6
|
+
* with a `basalt` script) and hands the arguments to that script through the
|
|
7
|
+
* app's own package manager — so the code that runs is always the app's, at
|
|
8
|
+
* the versions its lockfile pins, never the launcher's. Node built-ins only:
|
|
9
|
+
* nothing of the framework is loaded.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
/** Set on the script's environment: a `basalt` script that calls `basalt` again would loop. */
|
|
15
|
+
export const LAUNCHER_ENV = 'BASALT_LAUNCHER_ROOT';
|
|
16
|
+
function readManifest(dir) {
|
|
17
|
+
const file = join(dir, 'package.json');
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
return undefined;
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
22
|
+
return typeof value === 'object' && value !== null ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined; // an unreadable package.json is not an app: keep walking
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Every directory from `from` up to the filesystem root, nearest first. */
|
|
29
|
+
function* ancestors(from) {
|
|
30
|
+
let dir = resolve(from);
|
|
31
|
+
while (true) {
|
|
32
|
+
yield dir;
|
|
33
|
+
const parent = dirname(dir);
|
|
34
|
+
if (parent === dir)
|
|
35
|
+
return;
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The nearest app at or above `from`: a package.json with a `basalt` script. */
|
|
40
|
+
export function findProject(from) {
|
|
41
|
+
for (const dir of ancestors(from)) {
|
|
42
|
+
const script = readManifest(dir)?.scripts?.['basalt'];
|
|
43
|
+
if (typeof script === 'string' && script.trim() !== '') {
|
|
44
|
+
return { root: dir, script, pm: detectPackageManager(dir) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const LOCKFILES = [
|
|
50
|
+
['pnpm', ['pnpm-lock.yaml']],
|
|
51
|
+
['bun', ['bun.lock', 'bun.lockb']],
|
|
52
|
+
['yarn', ['yarn.lock']],
|
|
53
|
+
['npm', ['package-lock.json']],
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* `packageManager` in the nearest package.json that has one, else the nearest
|
|
57
|
+
* lockfile — both walking up, so an app inside a workspace uses the
|
|
58
|
+
* workspace's manager. Nothing found: npm.
|
|
59
|
+
*/
|
|
60
|
+
export function detectPackageManager(root) {
|
|
61
|
+
for (const dir of ancestors(root)) {
|
|
62
|
+
const declared = readManifest(dir)?.packageManager;
|
|
63
|
+
if (typeof declared === 'string') {
|
|
64
|
+
const name = declared.split('@')[0];
|
|
65
|
+
if (name === 'pnpm' || name === 'npm' || name === 'yarn' || name === 'bun')
|
|
66
|
+
return name;
|
|
67
|
+
}
|
|
68
|
+
for (const [pm, files] of LOCKFILES)
|
|
69
|
+
if (files.some((file) => existsSync(join(dir, file))))
|
|
70
|
+
return pm;
|
|
71
|
+
}
|
|
72
|
+
return 'npm';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The command that runs the app's `basalt` script with `args`, as given:
|
|
76
|
+
* pnpm, yarn and bun pass everything after the script name through (a `--`
|
|
77
|
+
* included); npm needs a `--` first.
|
|
78
|
+
*/
|
|
79
|
+
export function launchCommand(pm, args) {
|
|
80
|
+
switch (pm) {
|
|
81
|
+
case 'pnpm': return { command: 'pnpm', args: ['run', '--silent', 'basalt', ...args] };
|
|
82
|
+
case 'npm': return { command: 'npm', args: ['run', '--silent', 'basalt', '--', ...args] };
|
|
83
|
+
case 'yarn': return { command: 'yarn', args: ['run', 'basalt', ...args] };
|
|
84
|
+
case 'bun': return { command: 'bun', args: ['run', 'basalt', ...args] };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function launcherVersion() {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
90
|
+
return manifest.version ?? 'unknown';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return 'unknown';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const NOT_AN_APP = [
|
|
97
|
+
'basalt: no Basalt app here — no package.json with a "basalt" script in this directory or above it.',
|
|
98
|
+
' Start one: npm create basalt@latest my-app',
|
|
99
|
+
' Or cd into an app first; `basalt <command>` then runs `<pm> basalt <command>` there.',
|
|
100
|
+
];
|
|
101
|
+
/** Runs `basalt <argv>`: the app's `basalt` script, through the app's package manager. Resolves to the exit code. */
|
|
102
|
+
export async function runLauncher(argv, options = {}) {
|
|
103
|
+
const cwd = options.cwd ?? process.cwd();
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
const error = options.error ?? ((line) => process.stderr.write(`${line}\n`));
|
|
106
|
+
const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
107
|
+
const project = findProject(cwd);
|
|
108
|
+
if (!project) {
|
|
109
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
110
|
+
log(`basalt launcher ${launcherVersion()} (@basaltkit/cli)`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
for (const line of NOT_AN_APP)
|
|
114
|
+
error(line);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
if (env[LAUNCHER_ENV] === project.root) {
|
|
118
|
+
error(`basalt: the "basalt" script in ${join(project.root, 'package.json')} runs \`basalt\` again (${project.script}).`);
|
|
119
|
+
error(' Point it at the app\'s own CLI instead, e.g. "basalt": "tsx bin/basalt.ts".');
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const { command, args } = launchCommand(project.pm, argv);
|
|
123
|
+
return new Promise((done) => {
|
|
124
|
+
const child = spawn(command, args, {
|
|
125
|
+
cwd: project.root,
|
|
126
|
+
env: { ...env, [LAUNCHER_ENV]: project.root },
|
|
127
|
+
stdio: 'inherit',
|
|
128
|
+
shell: process.platform === 'win32',
|
|
129
|
+
});
|
|
130
|
+
// Ctrl-C reaches the whole foreground group: the app handles it; the
|
|
131
|
+
// launcher only waits for it to finish and reports its code.
|
|
132
|
+
const ignore = () => { };
|
|
133
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
134
|
+
for (const signal of signals)
|
|
135
|
+
process.on(signal, ignore);
|
|
136
|
+
const settle = (code) => {
|
|
137
|
+
for (const signal of signals)
|
|
138
|
+
process.off(signal, ignore);
|
|
139
|
+
done(code);
|
|
140
|
+
};
|
|
141
|
+
child.on('error', (cause) => {
|
|
142
|
+
error(cause.code === 'ENOENT'
|
|
143
|
+
? `basalt: ${command} is not installed, and this app uses it (${project.root}).`
|
|
144
|
+
: `basalt: could not start ${command}: ${cause.message}`);
|
|
145
|
+
settle(1);
|
|
146
|
+
});
|
|
147
|
+
child.on('exit', (code, signal) => {
|
|
148
|
+
settle(code ?? (signal ? 128 + signalNumber(signal) : 1));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function signalNumber(signal) {
|
|
153
|
+
return { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }[signal] ?? 1;
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22.5.0"
|
|
6
6
|
},
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"import": "./dist/index.js"
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"basalt": "./dist/bin.js"
|
|
19
|
+
},
|
|
17
20
|
"files": [
|
|
18
21
|
"dist"
|
|
19
22
|
],
|
|
@@ -22,9 +25,9 @@
|
|
|
22
25
|
"@basaltkit/http": "^3.0.0"
|
|
23
26
|
},
|
|
24
27
|
"devDependencies": {
|
|
25
|
-
"@types/node": "^26.6.
|
|
28
|
+
"@types/node": "^26.6.5",
|
|
26
29
|
"typescript": "^7.0.2",
|
|
27
|
-
"vitest": "^5.0.
|
|
30
|
+
"vitest": "^5.0.5",
|
|
28
31
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
29
32
|
},
|
|
30
33
|
"publishConfig": {
|