@basaltkit/cli 1.1.0 → 1.2.1
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 +7 -1
- package/dist/builtins.d.ts +16 -0
- package/dist/builtins.js +36 -0
- package/dist/command.d.ts +25 -0
- package/dist/command.js +3 -0
- package/dist/dev.d.ts +49 -0
- package/dist/dev.js +126 -0
- package/dist/index.d.ts +9 -193
- package/dist/index.js +9 -420
- package/dist/io.d.ts +14 -0
- package/dist/io.js +49 -0
- package/dist/parse.d.ts +7 -0
- package/dist/parse.js +23 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +12 -0
- package/dist/publish.d.ts +35 -0
- package/dist/publish.js +110 -0
- package/dist/runner.d.ts +16 -0
- package/dist/runner.js +36 -0
- package/dist/upgrade.d.ts +48 -0
- package/dist/upgrade.js +112 -0
- package/package.json +9 -10
package/dist/index.js
CHANGED
|
@@ -1,420 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
263
|
-
var routesCommand = defineCommand({
|
|
264
|
-
name: "routes",
|
|
265
|
-
description: "List the HTTP routes registered by the app",
|
|
266
|
-
handle({ container, io }) {
|
|
267
|
-
const routes = container.has(METADATA) ? container.get(METADATA).get("http:routes") : [];
|
|
268
|
-
if (routes.length === 0) {
|
|
269
|
-
io.log("No routes registered.");
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
io.table(routes.map(({ method, url }) => ({ method, url })));
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
var scheduleListCommand = defineCommand({
|
|
276
|
-
name: "schedule:list",
|
|
277
|
-
description: "List scheduled tasks and their cron expressions",
|
|
278
|
-
handle({ container, io }) {
|
|
279
|
-
const entries = container.has(METADATA) ? container.get(METADATA).get("schedule:entries") : [];
|
|
280
|
-
if (entries.length === 0) {
|
|
281
|
-
io.log("No scheduled tasks.");
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
io.table(entries.map(({ name, cron, timezone }) => ({ name, cron, timezone })));
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
function builtinCommands() {
|
|
288
|
-
return [routesCommand, scheduleListCommand, devCommand, upgradeCommand, publishCommand];
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// src/io.ts
|
|
292
|
-
import { createInterface } from "readline/promises";
|
|
293
|
-
function renderTable(rows) {
|
|
294
|
-
if (rows.length === 0) return "(empty)";
|
|
295
|
-
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
296
|
-
const cell = (value) => value === void 0 || value === null ? "" : String(value);
|
|
297
|
-
const widths = columns.map(
|
|
298
|
-
(column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length))
|
|
299
|
-
);
|
|
300
|
-
const line = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ");
|
|
301
|
-
return [
|
|
302
|
-
line(columns),
|
|
303
|
-
line(widths.map((width) => "-".repeat(width))),
|
|
304
|
-
...rows.map((row) => line(columns.map((column) => cell(row[column]))))
|
|
305
|
-
].join("\n");
|
|
306
|
-
}
|
|
307
|
-
function consoleIo() {
|
|
308
|
-
return {
|
|
309
|
-
log: (message) => console.log(message),
|
|
310
|
-
error: (message) => console.error(message),
|
|
311
|
-
table: (rows) => console.log(renderTable(rows)),
|
|
312
|
-
confirm: async (question) => {
|
|
313
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
314
|
-
try {
|
|
315
|
-
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
316
|
-
return answer === "y" || answer === "yes";
|
|
317
|
-
} finally {
|
|
318
|
-
rl.close();
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
function memoryIo(options = {}) {
|
|
324
|
-
const lines = [];
|
|
325
|
-
const errors = [];
|
|
326
|
-
const answers = [...options.answers ?? []];
|
|
327
|
-
return {
|
|
328
|
-
lines,
|
|
329
|
-
errors,
|
|
330
|
-
log: (message) => void lines.push(message),
|
|
331
|
-
error: (message) => void errors.push(message),
|
|
332
|
-
table: (rows) => void lines.push(renderTable(rows)),
|
|
333
|
-
confirm: async () => answers.shift() ?? true
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// src/parse.ts
|
|
338
|
-
function parseArgv(argv) {
|
|
339
|
-
let command;
|
|
340
|
-
const args = [];
|
|
341
|
-
const flags = {};
|
|
342
|
-
for (const token of argv) {
|
|
343
|
-
if (token.startsWith("--")) {
|
|
344
|
-
const body = token.slice(2);
|
|
345
|
-
const eq = body.indexOf("=");
|
|
346
|
-
if (eq === -1) flags[body] = true;
|
|
347
|
-
else flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
348
|
-
} else if (command === void 0) {
|
|
349
|
-
command = token;
|
|
350
|
-
} else {
|
|
351
|
-
args.push(token);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
return { command, args, flags };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// src/runner.ts
|
|
358
|
-
async function runCli(options) {
|
|
359
|
-
const { app } = options;
|
|
360
|
-
const io = options.io ?? consoleIo();
|
|
361
|
-
const { command, args, flags } = parseArgv(options.argv ?? process.argv.slice(2));
|
|
362
|
-
if (app.phase === "created") await app.boot();
|
|
363
|
-
try {
|
|
364
|
-
const registered = ensureMetadata(app.container).get("commands");
|
|
365
|
-
const commands = [...builtinCommands(), ...registered];
|
|
366
|
-
if (command === void 0 || command === "list") {
|
|
367
|
-
io.log("Available commands:");
|
|
368
|
-
io.table(
|
|
369
|
-
commands.map(({ name, description }) => ({ command: name, description: description ?? "" }))
|
|
370
|
-
);
|
|
371
|
-
return 0;
|
|
372
|
-
}
|
|
373
|
-
const found = commands.find((candidate) => candidate.name === command);
|
|
374
|
-
if (!found) {
|
|
375
|
-
io.error(`Unknown command "${command}". Run "basalt list" to see what is available.`);
|
|
376
|
-
return 1;
|
|
377
|
-
}
|
|
378
|
-
const code = await found.handle({ app, container: app.container, io, args, flags });
|
|
379
|
-
return code ?? 0;
|
|
380
|
-
} finally {
|
|
381
|
-
await app.shutdown();
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// src/plugin.ts
|
|
386
|
-
import { definePlugin, ensureMetadata as ensureMetadata2 } from "@basaltkit/core";
|
|
387
|
-
function commandsPlugin(commands) {
|
|
388
|
-
return definePlugin({
|
|
389
|
-
name: "basalt:commands",
|
|
390
|
-
register({ container }) {
|
|
391
|
-
const metadata = ensureMetadata2(container);
|
|
392
|
-
for (const command of commands) metadata.add("commands", command);
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
export {
|
|
397
|
-
DEV_ENTRY_CANDIDATES,
|
|
398
|
-
MIGRATIONS,
|
|
399
|
-
PUBLISHABLES,
|
|
400
|
-
builtinCommands,
|
|
401
|
-
commandsPlugin,
|
|
402
|
-
consoleIo,
|
|
403
|
-
defineCommand,
|
|
404
|
-
devCommand,
|
|
405
|
-
memoryIo,
|
|
406
|
-
nodePublishFs,
|
|
407
|
-
nodeUpgradeFs,
|
|
408
|
-
parseArgv,
|
|
409
|
-
publishCommand,
|
|
410
|
-
renameMachizeScope,
|
|
411
|
-
renderTable,
|
|
412
|
-
resolveDevEntry,
|
|
413
|
-
resolveDevRunner,
|
|
414
|
-
routesCommand,
|
|
415
|
-
runCli,
|
|
416
|
-
runPublish,
|
|
417
|
-
runUpgrade,
|
|
418
|
-
scheduleListCommand,
|
|
419
|
-
upgradeCommand
|
|
420
|
-
};
|
|
1
|
+
export { defineCommand } from './command.js';
|
|
2
|
+
export { runCli } from './runner.js';
|
|
3
|
+
export { commandsPlugin } from './plugin.js';
|
|
4
|
+
export { consoleIo, memoryIo, renderTable } from './io.js';
|
|
5
|
+
export { parseArgv } from './parse.js';
|
|
6
|
+
export { builtinCommands, routesCommand, scheduleListCommand, } from './builtins.js';
|
|
7
|
+
export { devCommand, resolveDevEntry, resolveDevRunner, devRouteRows, DEV_ENTRY_CANDIDATES, } from './dev.js';
|
|
8
|
+
export { upgradeCommand, runUpgrade, MIGRATIONS, renameMachizeScope, nodeUpgradeFs, } from './upgrade.js';
|
|
9
|
+
export { publishCommand, runPublish, PUBLISHABLES, nodePublishFs, } from './publish.js';
|
package/dist/io.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CommandIo } from './command.js';
|
|
2
|
+
/** Renders rows as an aligned text table (no external dependencies). */
|
|
3
|
+
export declare function renderTable(rows: Record<string, unknown>[]): string;
|
|
4
|
+
export declare function consoleIo(): CommandIo;
|
|
5
|
+
/**
|
|
6
|
+
* In-memory IO for tests. `answers` feeds `confirm()` in order (defaults to yes
|
|
7
|
+
* once the queue is drained), so a test can drive an interactive command.
|
|
8
|
+
*/
|
|
9
|
+
export declare function memoryIo(options?: {
|
|
10
|
+
answers?: boolean[];
|
|
11
|
+
}): CommandIo & {
|
|
12
|
+
lines: string[];
|
|
13
|
+
errors: string[];
|
|
14
|
+
};
|
package/dist/io.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
/** Renders rows as an aligned text table (no external dependencies). */
|
|
3
|
+
export function renderTable(rows) {
|
|
4
|
+
if (rows.length === 0)
|
|
5
|
+
return '(empty)';
|
|
6
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
7
|
+
const cell = (value) => value === undefined || value === null ? '' : String(value);
|
|
8
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
|
|
9
|
+
const line = (values) => values.map((value, index) => value.padEnd(widths[index])).join(' ');
|
|
10
|
+
return [
|
|
11
|
+
line(columns),
|
|
12
|
+
line(widths.map((width) => '-'.repeat(width))),
|
|
13
|
+
...rows.map((row) => line(columns.map((column) => cell(row[column])))),
|
|
14
|
+
].join('\n');
|
|
15
|
+
}
|
|
16
|
+
export function consoleIo() {
|
|
17
|
+
return {
|
|
18
|
+
log: (message) => console.log(message),
|
|
19
|
+
error: (message) => console.error(message),
|
|
20
|
+
table: (rows) => console.log(renderTable(rows)),
|
|
21
|
+
confirm: async (question) => {
|
|
22
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
23
|
+
try {
|
|
24
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
25
|
+
return answer === 'y' || answer === 'yes';
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
rl.close();
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* In-memory IO for tests. `answers` feeds `confirm()` in order (defaults to yes
|
|
35
|
+
* once the queue is drained), so a test can drive an interactive command.
|
|
36
|
+
*/
|
|
37
|
+
export function memoryIo(options = {}) {
|
|
38
|
+
const lines = [];
|
|
39
|
+
const errors = [];
|
|
40
|
+
const answers = [...(options.answers ?? [])];
|
|
41
|
+
return {
|
|
42
|
+
lines,
|
|
43
|
+
errors,
|
|
44
|
+
log: (message) => void lines.push(message),
|
|
45
|
+
error: (message) => void errors.push(message),
|
|
46
|
+
table: (rows) => void lines.push(renderTable(rows)),
|
|
47
|
+
confirm: async () => answers.shift() ?? true,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ParsedArgv {
|
|
2
|
+
command: string | undefined;
|
|
3
|
+
args: string[];
|
|
4
|
+
flags: Record<string, string | boolean>;
|
|
5
|
+
}
|
|
6
|
+
/** Minimal argv parser: first bare word is the command; `--key=value` and `--flag` become flags. */
|
|
7
|
+
export declare function parseArgv(argv: string[]): ParsedArgv;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Minimal argv parser: first bare word is the command; `--key=value` and `--flag` become flags. */
|
|
2
|
+
export function parseArgv(argv) {
|
|
3
|
+
let command;
|
|
4
|
+
const args = [];
|
|
5
|
+
const flags = {};
|
|
6
|
+
for (const token of argv) {
|
|
7
|
+
if (token.startsWith('--')) {
|
|
8
|
+
const body = token.slice(2);
|
|
9
|
+
const eq = body.indexOf('=');
|
|
10
|
+
if (eq === -1)
|
|
11
|
+
flags[body] = true;
|
|
12
|
+
else
|
|
13
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
14
|
+
}
|
|
15
|
+
else if (command === undefined) {
|
|
16
|
+
command = token;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
args.push(token);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { command, args, flags };
|
|
23
|
+
}
|
package/dist/plugin.d.ts
ADDED
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { definePlugin, ensureMetadata } from '@basaltkit/core';
|
|
2
|
+
/** Registers app-defined commands into the 'commands' metadata bucket. */
|
|
3
|
+
export function commandsPlugin(commands) {
|
|
4
|
+
return definePlugin({
|
|
5
|
+
name: 'basalt:commands',
|
|
6
|
+
register({ container }) {
|
|
7
|
+
const metadata = ensureMetadata(container);
|
|
8
|
+
for (const command of commands)
|
|
9
|
+
metadata.add('commands', command);
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type CommandDefinition } from './command.js';
|
|
2
|
+
/** One file a publishable drops into the app. */
|
|
3
|
+
export interface PublishableFile {
|
|
4
|
+
/** Path relative to the target dir, e.g. `Dockerfile` or `.github/workflows/ci.yml`. */
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
/** A named group of stub files an app can copy in and then own (à la vendor:publish). */
|
|
9
|
+
export interface Publishable {
|
|
10
|
+
id: string;
|
|
11
|
+
description: string;
|
|
12
|
+
files(): PublishableFile[];
|
|
13
|
+
}
|
|
14
|
+
/** Filesystem surface for publishing — injected so `runPublish` is testable. */
|
|
15
|
+
export interface PublishFs {
|
|
16
|
+
exists(path: string): Promise<boolean>;
|
|
17
|
+
write(path: string, content: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface PublishResult {
|
|
20
|
+
written: string[];
|
|
21
|
+
skipped: string[];
|
|
22
|
+
}
|
|
23
|
+
/** Stubs bundled with the CLI. Apps can register more via the metadata bucket. */
|
|
24
|
+
export declare const PUBLISHABLES: Publishable[];
|
|
25
|
+
/**
|
|
26
|
+
* Copies a publishable's files into the target, skipping any that already exist
|
|
27
|
+
* unless `force` is set. Returns which files were written vs skipped.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runPublish(publishable: Publishable, fs: PublishFs, options?: {
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}): Promise<PublishResult>;
|
|
32
|
+
/** Node-backed {@link PublishFs} rooted at `dir` (creates parent directories). */
|
|
33
|
+
export declare function nodePublishFs(dir: string): PublishFs;
|
|
34
|
+
/** `basalt publish [<id>] [--dir=<path>] [--force]` — list, or copy a stub group. */
|
|
35
|
+
export declare const publishCommand: CommandDefinition;
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
const CI_WORKFLOW = `name: ci
|
|
18
|
+
on:
|
|
19
|
+
push: { branches: [main] }
|
|
20
|
+
pull_request:
|
|
21
|
+
jobs:
|
|
22
|
+
build:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: pnpm/action-setup@v4
|
|
27
|
+
- uses: actions/setup-node@v4
|
|
28
|
+
with: { node-version: 22, cache: pnpm }
|
|
29
|
+
- run: pnpm install --frozen-lockfile
|
|
30
|
+
- run: pnpm run build
|
|
31
|
+
- run: pnpm run test
|
|
32
|
+
`;
|
|
33
|
+
const EDITORCONFIG = `root = true
|
|
34
|
+
|
|
35
|
+
[*]
|
|
36
|
+
charset = utf-8
|
|
37
|
+
end_of_line = lf
|
|
38
|
+
insert_final_newline = true
|
|
39
|
+
indent_style = space
|
|
40
|
+
indent_size = 2
|
|
41
|
+
trim_trailing_whitespace = true
|
|
42
|
+
`;
|
|
43
|
+
/** Stubs bundled with the CLI. Apps can register more via the metadata bucket. */
|
|
44
|
+
export const PUBLISHABLES = [
|
|
45
|
+
{ id: 'dockerfile', description: 'Production multi-stage Dockerfile', files: () => [{ path: 'Dockerfile', content: DOCKERFILE }] },
|
|
46
|
+
{ id: 'ci', description: 'GitHub Actions CI workflow', files: () => [{ path: '.github/workflows/ci.yml', content: CI_WORKFLOW }] },
|
|
47
|
+
{ id: 'editorconfig', description: 'Shared .editorconfig', files: () => [{ path: '.editorconfig', content: EDITORCONFIG }] },
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Copies a publishable's files into the target, skipping any that already exist
|
|
51
|
+
* unless `force` is set. Returns which files were written vs skipped.
|
|
52
|
+
*/
|
|
53
|
+
export async function runPublish(publishable, fs, options = {}) {
|
|
54
|
+
const written = [];
|
|
55
|
+
const skipped = [];
|
|
56
|
+
for (const file of publishable.files()) {
|
|
57
|
+
if (!options.force && (await fs.exists(file.path))) {
|
|
58
|
+
skipped.push(file.path);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
await fs.write(file.path, file.content);
|
|
62
|
+
written.push(file.path);
|
|
63
|
+
}
|
|
64
|
+
return { written, skipped };
|
|
65
|
+
}
|
|
66
|
+
/** Node-backed {@link PublishFs} rooted at `dir` (creates parent directories). */
|
|
67
|
+
export function nodePublishFs(dir) {
|
|
68
|
+
return {
|
|
69
|
+
async exists(path) {
|
|
70
|
+
const { access } = await import('node:fs/promises');
|
|
71
|
+
const { join } = await import('node:path');
|
|
72
|
+
return access(join(dir, path)).then(() => true, () => false);
|
|
73
|
+
},
|
|
74
|
+
async write(path, content) {
|
|
75
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
76
|
+
const { dirname, join } = await import('node:path');
|
|
77
|
+
const target = join(dir, path);
|
|
78
|
+
await mkdir(dirname(target), { recursive: true });
|
|
79
|
+
await writeFile(target, content, 'utf8');
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** `basalt publish [<id>] [--dir=<path>] [--force]` — list, or copy a stub group. */
|
|
84
|
+
export const publishCommand = defineCommand({
|
|
85
|
+
name: 'publish',
|
|
86
|
+
description: 'Copy a publishable stub group into the app (run with no id to list)',
|
|
87
|
+
async handle({ io, args, flags }) {
|
|
88
|
+
const id = args[0];
|
|
89
|
+
if (!id) {
|
|
90
|
+
io.log('Publishable groups:');
|
|
91
|
+
io.table(PUBLISHABLES.map((p) => ({ id: p.id, description: p.description })));
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
const publishable = PUBLISHABLES.find((p) => p.id === id);
|
|
95
|
+
if (!publishable) {
|
|
96
|
+
io.error(`Unknown publishable "${id}". Available: ${PUBLISHABLES.map((p) => p.id).join(', ')}.`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const dir = typeof flags['dir'] === 'string' ? flags['dir'] : process.cwd();
|
|
100
|
+
const result = await runPublish(publishable, nodePublishFs(dir), { force: flags['force'] === true });
|
|
101
|
+
for (const path of result.written)
|
|
102
|
+
io.log(` wrote ${path}`);
|
|
103
|
+
for (const path of result.skipped)
|
|
104
|
+
io.log(` skipped ${path} (exists — use --force to overwrite)`);
|
|
105
|
+
if (result.written.length === 0 && result.skipped.length > 0) {
|
|
106
|
+
io.log('Nothing written. Re-run with --force to overwrite existing files.');
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
},
|
|
110
|
+
});
|