@basaltkit/cli 1.5.0 → 1.7.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 +5 -1
- 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 +15 -0
- package/dist/launcher.js +40 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,10 @@ app's, at its pinned versions. Arguments and the exit code pass through
|
|
|
41
41
|
unchanged. Outside an app it says so and exits 1. The launcher loads nothing of
|
|
42
42
|
the framework, only Node built-ins.
|
|
43
43
|
|
|
44
|
+
`basalt new <name> [flags]` is the launcher's own command: it scaffolds an app
|
|
45
|
+
with `create-basalt@latest` (through `pnpm dlx`, else `npx`; `--pm=` picks the
|
|
46
|
+
manager), so it always gets the latest scaffolder.
|
|
47
|
+
|
|
44
48
|
## Get started in 5 minutes
|
|
45
49
|
|
|
46
50
|
1. Create a `bin/basalt.ts` file at the root of the project — this will be the entry point for the `basalt` command:
|
|
@@ -110,7 +114,7 @@ Without registering anything, `runCli` always provides:
|
|
|
110
114
|
| `basalt list` (or `basalt` with no arguments) | Lists all available commands, with their descriptions |
|
|
111
115
|
| `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 |
|
|
112
116
|
| `basalt schedule:list` | Lists scheduled tasks and their cron expressions (read from the `schedule:entries` bucket, populated by `@basaltkit/scheduler`) |
|
|
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`. |
|
|
117
|
+
| `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). |
|
|
114
118
|
| `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 |
|
|
115
119
|
| `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` |
|
|
116
120
|
|
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';
|
package/dist/launcher.d.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* app's own package manager — so the code that runs is always the app's, at
|
|
8
8
|
* the versions its lockfile pins, never the launcher's. Node built-ins only:
|
|
9
9
|
* nothing of the framework is loaded.
|
|
10
|
+
*
|
|
11
|
+
* One command is the launcher's own: `basalt new <name> [flags]` scaffolds an
|
|
12
|
+
* app through `create-basalt@latest` (`pnpm dlx`, else `npx`) — the latest
|
|
13
|
+
* scaffolder, whatever version the launcher is.
|
|
10
14
|
*/
|
|
11
15
|
export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun';
|
|
12
16
|
export interface LauncherProject {
|
|
@@ -41,6 +45,17 @@ export interface RunLauncherOptions {
|
|
|
41
45
|
/** Where the launcher's own lines go (never the app's output). */
|
|
42
46
|
error?: (line: string) => void;
|
|
43
47
|
log?: (line: string) => void;
|
|
48
|
+
/** Whether pnpm is installed — what `basalt new` runs create-basalt with. Default: `pnpm --version` answers. */
|
|
49
|
+
hasPnpm?: () => boolean;
|
|
44
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* How `basalt new` runs `create-basalt@latest`: through the manager `--pm=`
|
|
53
|
+
* names, else pnpm when it is installed, else npx. create-basalt reads the
|
|
54
|
+
* manager it was started with, so the new app uses the same one.
|
|
55
|
+
*/
|
|
56
|
+
export declare function scaffoldCommand(args: readonly string[], hasPnpm: () => boolean): {
|
|
57
|
+
command: string;
|
|
58
|
+
args: string[];
|
|
59
|
+
};
|
|
45
60
|
/** Runs `basalt <argv>`: the app's `basalt` script, through the app's package manager. Resolves to the exit code. */
|
|
46
61
|
export declare function runLauncher(argv: readonly string[], options?: RunLauncherOptions): Promise<number>;
|
package/dist/launcher.js
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* app's own package manager — so the code that runs is always the app's, at
|
|
8
8
|
* the versions its lockfile pins, never the launcher's. Node built-ins only:
|
|
9
9
|
* nothing of the framework is loaded.
|
|
10
|
+
*
|
|
11
|
+
* One command is the launcher's own: `basalt new <name> [flags]` scaffolds an
|
|
12
|
+
* app through `create-basalt@latest` (`pnpm dlx`, else `npx`) — the latest
|
|
13
|
+
* scaffolder, whatever version the launcher is.
|
|
10
14
|
*/
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
12
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
17
|
import { dirname, join, resolve } from 'node:path';
|
|
14
18
|
/** Set on the script's environment: a `basalt` script that calls `basalt` again would loop. */
|
|
@@ -93,9 +97,24 @@ function launcherVersion() {
|
|
|
93
97
|
return 'unknown';
|
|
94
98
|
}
|
|
95
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* How `basalt new` runs `create-basalt@latest`: through the manager `--pm=`
|
|
102
|
+
* names, else pnpm when it is installed, else npx. create-basalt reads the
|
|
103
|
+
* manager it was started with, so the new app uses the same one.
|
|
104
|
+
*/
|
|
105
|
+
export function scaffoldCommand(args, hasPnpm) {
|
|
106
|
+
const pm = args.find((arg) => arg.startsWith('--pm='))?.slice('--pm='.length);
|
|
107
|
+
const pkg = 'create-basalt@latest';
|
|
108
|
+
if (pm === 'pnpm' || (pm === undefined && hasPnpm()))
|
|
109
|
+
return { command: 'pnpm', args: ['--silent', 'dlx', pkg, ...args] };
|
|
110
|
+
if (pm === 'bun')
|
|
111
|
+
return { command: 'bunx', args: [pkg, ...args] };
|
|
112
|
+
return { command: 'npx', args: ['--yes', pkg, ...args] }; // npm, and yarn (create-basalt honours --pm=yarn)
|
|
113
|
+
}
|
|
114
|
+
const pnpmInstalled = () => spawnSync('pnpm', ['--version'], { stdio: 'ignore', shell: process.platform === 'win32' }).status === 0;
|
|
96
115
|
const NOT_AN_APP = [
|
|
97
116
|
'basalt: no Basalt app here — no package.json with a "basalt" script in this directory or above it.',
|
|
98
|
-
' Start one:
|
|
117
|
+
' Start one: basalt new my-app',
|
|
99
118
|
' Or cd into an app first; `basalt <command>` then runs `<pm> basalt <command>` there.',
|
|
100
119
|
];
|
|
101
120
|
/** Runs `basalt <argv>`: the app's `basalt` script, through the app's package manager. Resolves to the exit code. */
|
|
@@ -104,6 +123,11 @@ export async function runLauncher(argv, options = {}) {
|
|
|
104
123
|
const env = options.env ?? process.env;
|
|
105
124
|
const error = options.error ?? ((line) => process.stderr.write(`${line}\n`));
|
|
106
125
|
const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
126
|
+
// The launcher's own command, inside an app too: a new app is never the current one's business.
|
|
127
|
+
if (argv[0] === 'new') {
|
|
128
|
+
const scaffold = scaffoldCommand(argv.slice(1), options.hasPnpm ?? pnpmInstalled);
|
|
129
|
+
return spawnAndWait(scaffold.command, scaffold.args, { cwd, env, error, what: 'create a new app' });
|
|
130
|
+
}
|
|
107
131
|
const project = findProject(cwd);
|
|
108
132
|
if (!project) {
|
|
109
133
|
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
@@ -120,14 +144,24 @@ export async function runLauncher(argv, options = {}) {
|
|
|
120
144
|
return 1;
|
|
121
145
|
}
|
|
122
146
|
const { command, args } = launchCommand(project.pm, argv);
|
|
147
|
+
return spawnAndWait(command, args, {
|
|
148
|
+
cwd: project.root,
|
|
149
|
+
env: { ...env, [LAUNCHER_ENV]: project.root },
|
|
150
|
+
error,
|
|
151
|
+
what: `run this app's basalt script (${project.root})`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Runs one command with the terminal attached; resolves to its exit code. */
|
|
155
|
+
function spawnAndWait(command, args, options) {
|
|
156
|
+
const { error } = options;
|
|
123
157
|
return new Promise((done) => {
|
|
124
158
|
const child = spawn(command, args, {
|
|
125
|
-
cwd:
|
|
126
|
-
env:
|
|
159
|
+
cwd: options.cwd,
|
|
160
|
+
env: options.env,
|
|
127
161
|
stdio: 'inherit',
|
|
128
162
|
shell: process.platform === 'win32',
|
|
129
163
|
});
|
|
130
|
-
// Ctrl-C reaches the whole foreground group: the
|
|
164
|
+
// Ctrl-C reaches the whole foreground group: the child handles it; the
|
|
131
165
|
// launcher only waits for it to finish and reports its code.
|
|
132
166
|
const ignore = () => { };
|
|
133
167
|
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
@@ -140,7 +174,7 @@ export async function runLauncher(argv, options = {}) {
|
|
|
140
174
|
};
|
|
141
175
|
child.on('error', (cause) => {
|
|
142
176
|
error(cause.code === 'ENOENT'
|
|
143
|
-
? `basalt: ${command} is not installed, and
|
|
177
|
+
? `basalt: ${command} is not installed, and it is needed to ${options.what}.`
|
|
144
178
|
: `basalt: could not start ${command}: ${cause.message}`);
|
|
145
179
|
settle(1);
|
|
146
180
|
});
|