@saacms/cli 0.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/README.md +23 -0
- package/dist/commands/codegen.d.ts +29 -0
- package/dist/commands/codegen.d.ts.map +1 -0
- package/dist/commands/codegen.js +24 -0
- package/dist/commands/dev.d.ts +54 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +26 -0
- package/dist/commands/doctor.d.ts +55 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +25 -0
- package/dist/commands/init.d.ts +35 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +106 -0
- package/dist/commands/logs.d.ts +55 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +30 -0
- package/dist/commands/migrate.d.ts +95 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +81 -0
- package/dist/commands/publish.d.ts +53 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +32 -0
- package/dist/main.d.ts +13 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +1634 -0
- package/package.json +31 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,1634 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/main.ts
|
|
5
|
+
import { defineCommand as defineCommand8, runMain } from "citty";
|
|
6
|
+
|
|
7
|
+
// src/commands/init.ts
|
|
8
|
+
import { defineCommand } from "citty";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
var ASTRO_CONFIG_FILES = [
|
|
18
|
+
"astro.config.ts",
|
|
19
|
+
"astro.config.mjs",
|
|
20
|
+
"astro.config.js",
|
|
21
|
+
"astro.config.cjs"
|
|
22
|
+
];
|
|
23
|
+
var NEXTJS_CONFIG_FILES = [
|
|
24
|
+
"next.config.ts",
|
|
25
|
+
"next.config.mjs",
|
|
26
|
+
"next.config.js"
|
|
27
|
+
];
|
|
28
|
+
var SVELTEKIT_CONFIG_FILES = ["svelte.config.js", "svelte.config.ts"];
|
|
29
|
+
var NUXT_CONFIG_FILES = ["nuxt.config.ts", "nuxt.config.js"];
|
|
30
|
+
function readPackageJson(cwd) {
|
|
31
|
+
const path = join(cwd, "package.json");
|
|
32
|
+
if (!existsSync(path))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function hasDep(pkg, name) {
|
|
41
|
+
if (!pkg)
|
|
42
|
+
return false;
|
|
43
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]);
|
|
44
|
+
}
|
|
45
|
+
function hasAnyFile(cwd, files) {
|
|
46
|
+
return files.some((file) => existsSync(join(cwd, file)));
|
|
47
|
+
}
|
|
48
|
+
function detectHost(cwd) {
|
|
49
|
+
const pkg = readPackageJson(cwd);
|
|
50
|
+
if (hasAnyFile(cwd, ASTRO_CONFIG_FILES) || hasDep(pkg, "astro"))
|
|
51
|
+
return "astro";
|
|
52
|
+
if (hasAnyFile(cwd, NEXTJS_CONFIG_FILES) || hasDep(pkg, "next"))
|
|
53
|
+
return "nextjs";
|
|
54
|
+
if (hasAnyFile(cwd, SVELTEKIT_CONFIG_FILES) || hasDep(pkg, "@sveltejs/kit"))
|
|
55
|
+
return "sveltekit";
|
|
56
|
+
if (hasAnyFile(cwd, NUXT_CONFIG_FILES) || hasDep(pkg, "nuxt"))
|
|
57
|
+
return "nuxt";
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
function locateApiMountTemplate(cwd) {
|
|
61
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const candidates = [
|
|
63
|
+
resolve(here, "..", "..", "..", "host-astro", "templates", "api-mount.ts.txt"),
|
|
64
|
+
resolve(cwd, "packages", "host-astro", "templates", "api-mount.ts.txt")
|
|
65
|
+
];
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
if (existsSync(candidate))
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function renderSaacmsConfig(cwd) {
|
|
73
|
+
const title = basename(resolve(cwd));
|
|
74
|
+
return [
|
|
75
|
+
`import { defineConfig } from "@saacms/core"`,
|
|
76
|
+
`import { astroHostAdapter } from "@saacms/host-astro"`,
|
|
77
|
+
``,
|
|
78
|
+
`export default defineConfig({`,
|
|
79
|
+
` host: astroHostAdapter(),`,
|
|
80
|
+
` title: ${JSON.stringify(title)},`,
|
|
81
|
+
` version: "0.0.0",`,
|
|
82
|
+
` // collections, blocks, pages, plugins are added as you import them`,
|
|
83
|
+
` collections: [],`,
|
|
84
|
+
` blocks: [],`,
|
|
85
|
+
` pages: [],`,
|
|
86
|
+
`})`,
|
|
87
|
+
``
|
|
88
|
+
].join(`
|
|
89
|
+
`);
|
|
90
|
+
}
|
|
91
|
+
function writeIfMissing(path, contents, label, log) {
|
|
92
|
+
if (existsSync(path)) {
|
|
93
|
+
log(`[saacms init] ${label} already exists; skipping`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
writeFileSync(path, contents);
|
|
97
|
+
log(`[saacms init] wrote ${label}`);
|
|
98
|
+
}
|
|
99
|
+
var initCommand = defineCommand({
|
|
100
|
+
meta: {
|
|
101
|
+
name: "init",
|
|
102
|
+
description: "Bootstrap saacms into an existing host-framework project (v1 alpha: Astro only)."
|
|
103
|
+
},
|
|
104
|
+
args: {
|
|
105
|
+
host: {
|
|
106
|
+
type: "string",
|
|
107
|
+
description: "Host framework to scaffold for. Defaults to auto-detection from package.json + config files. v1 alpha only accepts 'astro'.",
|
|
108
|
+
required: false
|
|
109
|
+
},
|
|
110
|
+
storage: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "Storage adapter to wire into saacms.config.ts. v1 alpha defaults to 'r2' (Cloudflare R2).",
|
|
113
|
+
default: "r2"
|
|
114
|
+
},
|
|
115
|
+
cwd: {
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "Working directory to scaffold into. Defaults to process.cwd().",
|
|
118
|
+
required: false
|
|
119
|
+
},
|
|
120
|
+
"dry-run": {
|
|
121
|
+
type: "boolean",
|
|
122
|
+
description: "Print the actions without writing files (the v0.1 default behaviour).",
|
|
123
|
+
default: false
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
run({ args }) {
|
|
127
|
+
const cwd = args.cwd ?? process.cwd();
|
|
128
|
+
const host = args.host ?? detectHost(cwd);
|
|
129
|
+
const storage = args.storage;
|
|
130
|
+
const dryRun = Boolean(args["dry-run"]);
|
|
131
|
+
console.log(`saacms init — target dir: ${cwd}`);
|
|
132
|
+
console.log(`detected host: ${host}`);
|
|
133
|
+
console.log(`storage adapter: ${storage}`);
|
|
134
|
+
if (host !== "astro") {
|
|
135
|
+
throw new Error(`init for host '${host}' not supported in v1 alpha. ` + "Per ADR 0024, v1 alpha only ships @saacms/host-astro. " + "Other hosts (Next.js, SvelteKit, Nuxt) are deferred to v1.x. " + "If this is an Astro project, pass --host=astro explicitly.");
|
|
136
|
+
}
|
|
137
|
+
if (dryRun) {
|
|
138
|
+
console.log("would create: saacms.config.ts (root)");
|
|
139
|
+
console.log("would create: saacms/collections/.gitkeep");
|
|
140
|
+
console.log("would create: saacms/blocks/.gitkeep");
|
|
141
|
+
console.log("would create: saacms/pages/.gitkeep");
|
|
142
|
+
console.log("would create: src/pages/api/saacms/[...slug].ts (mount point)");
|
|
143
|
+
console.log("would create: src/pages/admin/[...slug].astro (admin mount)");
|
|
144
|
+
console.log("would add: predeploy script -> 'saacms migrate check --strict'");
|
|
145
|
+
console.log(`would install peer deps: @saacms/core @saacms/host-astro @saacms/storage-${storage}`);
|
|
146
|
+
console.log("dry-run — no filesystem changes performed.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const log = (line) => {
|
|
150
|
+
console.log(line);
|
|
151
|
+
};
|
|
152
|
+
mkdirSync(join(cwd, "saacms"), { recursive: true });
|
|
153
|
+
mkdirSync(join(cwd, "saacms", "collections"), { recursive: true });
|
|
154
|
+
mkdirSync(join(cwd, "saacms", "blocks"), { recursive: true });
|
|
155
|
+
mkdirSync(join(cwd, "saacms", "pages"), { recursive: true });
|
|
156
|
+
mkdirSync(join(cwd, "src", "pages", "api", "saacms"), { recursive: true });
|
|
157
|
+
writeIfMissing(join(cwd, "saacms.config.ts"), renderSaacmsConfig(cwd), "saacms.config.ts", log);
|
|
158
|
+
const apiMountDest = join(cwd, "src", "pages", "api", "saacms", "[...slug].ts");
|
|
159
|
+
if (existsSync(apiMountDest)) {
|
|
160
|
+
log("[saacms init] src/pages/api/saacms/[...slug].ts already exists; skipping");
|
|
161
|
+
} else {
|
|
162
|
+
const templatePath = locateApiMountTemplate(cwd);
|
|
163
|
+
if (templatePath === null) {
|
|
164
|
+
throw new Error("Could not locate api-mount.ts.txt template. " + "Expected it under @saacms/host-astro/templates/ or " + "packages/host-astro/templates/. Is @saacms/host-astro installed?");
|
|
165
|
+
}
|
|
166
|
+
const template = readFileSync(templatePath, "utf8");
|
|
167
|
+
writeFileSync(apiMountDest, template);
|
|
168
|
+
log("[saacms init] wrote src/pages/api/saacms/[...slug].ts");
|
|
169
|
+
}
|
|
170
|
+
writeIfMissing(join(cwd, "saacms", "collections", ".gitkeep"), "", "saacms/collections/.gitkeep", log);
|
|
171
|
+
writeIfMissing(join(cwd, "saacms", "blocks", ".gitkeep"), "", "saacms/blocks/.gitkeep", log);
|
|
172
|
+
writeIfMissing(join(cwd, "saacms", "pages", ".gitkeep"), "", "saacms/pages/.gitkeep", log);
|
|
173
|
+
console.log("[saacms init] next steps:");
|
|
174
|
+
console.log(" 1. bun add @saacms/core @saacms/host-astro @saacms/storage-d1 @saacms/storage-r2");
|
|
175
|
+
console.log(" 2. edit saacms.config.ts to register your first Collection");
|
|
176
|
+
console.log(" 3. bunx saacms codegen");
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// src/commands/codegen.ts
|
|
181
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
182
|
+
import {
|
|
183
|
+
existsSync as existsSync2,
|
|
184
|
+
mkdirSync as mkdirSync2,
|
|
185
|
+
watch as fsWatch,
|
|
186
|
+
writeFileSync as writeFileSync2
|
|
187
|
+
} from "node:fs";
|
|
188
|
+
import { dirname as dirname2, resolve as resolve2 } from "node:path";
|
|
189
|
+
import { pathToFileURL } from "node:url";
|
|
190
|
+
import {
|
|
191
|
+
collectionToOpenApiPaths,
|
|
192
|
+
schemaToD1Migration,
|
|
193
|
+
schemaToPuckFields
|
|
194
|
+
} from "@saacms/core";
|
|
195
|
+
var LOG_PREFIX = "[saacms codegen]";
|
|
196
|
+
function pascalCase(slug) {
|
|
197
|
+
return slug.split(/[-_\s]+/).filter((s) => s.length > 0).map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
198
|
+
}
|
|
199
|
+
function resolvePaths(args) {
|
|
200
|
+
const cwd = resolve2(args.cwd ?? process.cwd());
|
|
201
|
+
const out = resolve2(args.out ?? `${cwd}/.saacms`);
|
|
202
|
+
const configPath = resolve2(cwd, "saacms.config.ts");
|
|
203
|
+
return { cwd, out, configPath };
|
|
204
|
+
}
|
|
205
|
+
async function loadConfig(configPath) {
|
|
206
|
+
if (!existsSync2(configPath)) {
|
|
207
|
+
throw new Error(`${LOG_PREFIX} no saacms.config.ts found at ${configPath}; run 'saacms init' first`);
|
|
208
|
+
}
|
|
209
|
+
const url = `${pathToFileURL(configPath).href}?t=${Date.now()}`;
|
|
210
|
+
const mod = await import(url);
|
|
211
|
+
if (mod.default === undefined) {
|
|
212
|
+
throw new Error(`${LOG_PREFIX} ${configPath} has no default export; export your defineConfig({...}) as default`);
|
|
213
|
+
}
|
|
214
|
+
return mod.default;
|
|
215
|
+
}
|
|
216
|
+
function ensureDir(path) {
|
|
217
|
+
if (!existsSync2(path)) {
|
|
218
|
+
mkdirSync2(path, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function writeFileLogged(filePath, content) {
|
|
222
|
+
ensureDir(dirname2(filePath));
|
|
223
|
+
writeFileSync2(filePath, content);
|
|
224
|
+
console.log(`${LOG_PREFIX} wrote ${filePath}`);
|
|
225
|
+
}
|
|
226
|
+
function renderDrizzleStub(slug) {
|
|
227
|
+
const pascal = pascalCase(slug);
|
|
228
|
+
return `// generated stub: drizzle table for "${slug}" — see schemaToDrizzle in @saacms/core
|
|
229
|
+
import { schemaToDrizzle } from "@saacms/core"
|
|
230
|
+
import type { Schema } from "effect"
|
|
231
|
+
|
|
232
|
+
export const make${pascal}Table = (
|
|
233
|
+
schema: Schema.Schema<any, any, any>,
|
|
234
|
+
) => schemaToDrizzle("${slug}", schema)
|
|
235
|
+
`;
|
|
236
|
+
}
|
|
237
|
+
function emitForCollection(coll, out, agg) {
|
|
238
|
+
const slug = String(coll.slug);
|
|
239
|
+
const { paths, components } = collectionToOpenApiPaths(coll);
|
|
240
|
+
const sql = schemaToD1Migration(coll);
|
|
241
|
+
const puck = schemaToPuckFields(coll);
|
|
242
|
+
const drizzleStub = renderDrizzleStub(slug);
|
|
243
|
+
writeFileLogged(`${out}/openapi/${slug}.json`, JSON.stringify(paths, null, 2));
|
|
244
|
+
writeFileLogged(`${out}/drizzle/${slug}.ts`, drizzleStub);
|
|
245
|
+
writeFileLogged(`${out}/migrations/${slug}.sql`, `${sql}
|
|
246
|
+
`);
|
|
247
|
+
writeFileLogged(`${out}/puck/${slug}.json`, JSON.stringify(puck, null, 2));
|
|
248
|
+
for (const [k, v] of Object.entries(paths)) {
|
|
249
|
+
agg.paths[k] = v;
|
|
250
|
+
}
|
|
251
|
+
for (const [k, v] of Object.entries(components.schemas)) {
|
|
252
|
+
agg.schemas[k] = v;
|
|
253
|
+
}
|
|
254
|
+
return 4;
|
|
255
|
+
}
|
|
256
|
+
async function runOnce(args) {
|
|
257
|
+
const { out, configPath } = resolvePaths(args);
|
|
258
|
+
const config = await loadConfig(configPath);
|
|
259
|
+
const collections = config.collections ?? [];
|
|
260
|
+
ensureDir(out);
|
|
261
|
+
const agg = {
|
|
262
|
+
paths: {},
|
|
263
|
+
schemas: {}
|
|
264
|
+
};
|
|
265
|
+
let filesWritten = 0;
|
|
266
|
+
let errored = false;
|
|
267
|
+
for (const coll of collections) {
|
|
268
|
+
const slug = String(coll.slug);
|
|
269
|
+
try {
|
|
270
|
+
filesWritten += emitForCollection(coll, out, agg);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
errored = true;
|
|
273
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
+
console.error(`${LOG_PREFIX} error in ${slug}: ${msg}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const aggregate = {
|
|
278
|
+
openapi: "3.1.0",
|
|
279
|
+
info: {
|
|
280
|
+
title: config.title ?? "saacms",
|
|
281
|
+
version: config.version ?? "0.0.0",
|
|
282
|
+
...config.description !== undefined ? { description: config.description } : {}
|
|
283
|
+
},
|
|
284
|
+
paths: agg.paths,
|
|
285
|
+
components: { schemas: agg.schemas }
|
|
286
|
+
};
|
|
287
|
+
writeFileLogged(`${out}/openapi.json`, JSON.stringify(aggregate, null, 2));
|
|
288
|
+
filesWritten++;
|
|
289
|
+
console.log(`${LOG_PREFIX} wrote ${filesWritten} files for ${collections.length} collections`);
|
|
290
|
+
return { filesWritten, collectionsTouched: collections.length, errored };
|
|
291
|
+
}
|
|
292
|
+
var codegenCommand = defineCommand2({
|
|
293
|
+
meta: {
|
|
294
|
+
name: "codegen",
|
|
295
|
+
description: "Run Schema -> Drizzle / OpenAPI / TS-types / Puck-fields projections (writes to .saacms/)."
|
|
296
|
+
},
|
|
297
|
+
args: {
|
|
298
|
+
cwd: {
|
|
299
|
+
type: "string",
|
|
300
|
+
description: "Working directory (where saacms.config.ts lives). Defaults to process.cwd().",
|
|
301
|
+
required: false
|
|
302
|
+
},
|
|
303
|
+
out: {
|
|
304
|
+
type: "string",
|
|
305
|
+
description: "Output directory for generated artefacts. Defaults to <cwd>/.saacms.",
|
|
306
|
+
required: false
|
|
307
|
+
},
|
|
308
|
+
watch: {
|
|
309
|
+
type: "boolean",
|
|
310
|
+
description: "Re-run on changes to saacms.config.ts (best-effort).",
|
|
311
|
+
default: false
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
async run({ args }) {
|
|
315
|
+
const { configPath } = resolvePaths(args);
|
|
316
|
+
const result = await runOnce(args);
|
|
317
|
+
if (result.errored) {
|
|
318
|
+
process.exitCode = 1;
|
|
319
|
+
}
|
|
320
|
+
if (!args.watch)
|
|
321
|
+
return;
|
|
322
|
+
let watcher;
|
|
323
|
+
try {
|
|
324
|
+
let debounce;
|
|
325
|
+
watcher = fsWatch(configPath, () => {
|
|
326
|
+
if (debounce !== undefined)
|
|
327
|
+
clearTimeout(debounce);
|
|
328
|
+
debounce = setTimeout(() => {
|
|
329
|
+
if (!existsSync2(configPath)) {
|
|
330
|
+
watcher?.close();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
console.log(`${LOG_PREFIX} re-running due to change in ${configPath}`);
|
|
334
|
+
runOnce(args).then((r) => {
|
|
335
|
+
if (r.errored)
|
|
336
|
+
process.exitCode = 1;
|
|
337
|
+
}).catch((err) => {
|
|
338
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
339
|
+
console.error(`${LOG_PREFIX} watch re-run failed: ${msg}`);
|
|
340
|
+
process.exitCode = 1;
|
|
341
|
+
});
|
|
342
|
+
}, 25);
|
|
343
|
+
});
|
|
344
|
+
console.log(`${LOG_PREFIX} watching ${configPath} for changes`);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
347
|
+
console.warn(`${LOG_PREFIX} fs.watch unsupported: ${msg}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// src/commands/dev.ts
|
|
353
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
354
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
|
355
|
+
import { join as join2 } from "node:path";
|
|
356
|
+
var ASTRO_CONFIG_FILES2 = [
|
|
357
|
+
"astro.config.ts",
|
|
358
|
+
"astro.config.mjs",
|
|
359
|
+
"astro.config.js",
|
|
360
|
+
"astro.config.cjs"
|
|
361
|
+
];
|
|
362
|
+
var NEXTJS_CONFIG_FILES2 = [
|
|
363
|
+
"next.config.ts",
|
|
364
|
+
"next.config.mjs",
|
|
365
|
+
"next.config.js"
|
|
366
|
+
];
|
|
367
|
+
var SVELTEKIT_CONFIG_FILES2 = ["svelte.config.js", "svelte.config.ts"];
|
|
368
|
+
var NUXT_CONFIG_FILES2 = ["nuxt.config.ts", "nuxt.config.js"];
|
|
369
|
+
function readPackageJson2(cwd) {
|
|
370
|
+
const path = join2(cwd, "package.json");
|
|
371
|
+
if (!existsSync3(path))
|
|
372
|
+
return null;
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function hasDep2(pkg, name) {
|
|
380
|
+
if (!pkg)
|
|
381
|
+
return false;
|
|
382
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]);
|
|
383
|
+
}
|
|
384
|
+
function hasAnyFile2(cwd, files) {
|
|
385
|
+
return files.some((file) => existsSync3(join2(cwd, file)));
|
|
386
|
+
}
|
|
387
|
+
function detectHost2(cwd) {
|
|
388
|
+
const pkg = readPackageJson2(cwd);
|
|
389
|
+
if (hasAnyFile2(cwd, ASTRO_CONFIG_FILES2) || hasDep2(pkg, "astro"))
|
|
390
|
+
return "astro";
|
|
391
|
+
if (hasAnyFile2(cwd, NEXTJS_CONFIG_FILES2) || hasDep2(pkg, "next"))
|
|
392
|
+
return "nextjs";
|
|
393
|
+
if (hasAnyFile2(cwd, SVELTEKIT_CONFIG_FILES2) || hasDep2(pkg, "@sveltejs/kit"))
|
|
394
|
+
return "sveltekit";
|
|
395
|
+
if (hasAnyFile2(cwd, NUXT_CONFIG_FILES2) || hasDep2(pkg, "nuxt"))
|
|
396
|
+
return "nuxt";
|
|
397
|
+
return "unknown";
|
|
398
|
+
}
|
|
399
|
+
function resolveHostCommand(host) {
|
|
400
|
+
if (host === "astro")
|
|
401
|
+
return ["bun", "run", "astro", "dev"];
|
|
402
|
+
if (host === "unknown") {
|
|
403
|
+
throw new Error("[saacms dev] no host detected — make sure you are in a host-framework project root.");
|
|
404
|
+
}
|
|
405
|
+
throw new Error(`[saacms dev] host '${host}' not supported in v1 alpha. ` + "Per ADR 0024, v1 alpha is Astro-only. " + "Next.js, SvelteKit, and Nuxt are deferred to v1.x.");
|
|
406
|
+
}
|
|
407
|
+
async function readStream(stream) {
|
|
408
|
+
if (stream === undefined || stream === null)
|
|
409
|
+
return "";
|
|
410
|
+
return new Response(stream).text();
|
|
411
|
+
}
|
|
412
|
+
async function startDev(opts = {}) {
|
|
413
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
414
|
+
const codegenEnabled = opts.noCodegen !== true;
|
|
415
|
+
const stdio = opts.stdio ?? "inherit";
|
|
416
|
+
const host = detectHost2(cwd);
|
|
417
|
+
const hostCmd = resolveHostCommand(host);
|
|
418
|
+
console.log(`[saacms dev] starting host: ${hostCmd.join(" ")} | codegen watcher: ${codegenEnabled ? "enabled" : "disabled"}`);
|
|
419
|
+
const hostProc = Bun.spawn({
|
|
420
|
+
cmd: [...hostCmd],
|
|
421
|
+
cwd,
|
|
422
|
+
stdin: "inherit",
|
|
423
|
+
stdout: stdio === "pipe" ? "pipe" : "inherit",
|
|
424
|
+
stderr: stdio === "pipe" ? "pipe" : "inherit"
|
|
425
|
+
});
|
|
426
|
+
let codegenProc = null;
|
|
427
|
+
if (codegenEnabled) {
|
|
428
|
+
const cliPath = process.argv[1] ?? "";
|
|
429
|
+
codegenProc = Bun.spawn({
|
|
430
|
+
cmd: ["bun", "run", cliPath, "codegen", "--watch", "--cwd", cwd],
|
|
431
|
+
cwd,
|
|
432
|
+
stdin: "inherit",
|
|
433
|
+
stdout: stdio === "pipe" ? "pipe" : "inherit",
|
|
434
|
+
stderr: stdio === "pipe" ? "pipe" : "inherit"
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
let signalReceived = false;
|
|
438
|
+
let hostExitedFirst = false;
|
|
439
|
+
const forward = (sig) => {
|
|
440
|
+
signalReceived = true;
|
|
441
|
+
try {
|
|
442
|
+
hostProc.kill(sig);
|
|
443
|
+
} catch {}
|
|
444
|
+
if (codegenProc !== null) {
|
|
445
|
+
try {
|
|
446
|
+
codegenProc.kill(sig);
|
|
447
|
+
} catch {}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const onSigint = () => forward("SIGINT");
|
|
451
|
+
const onSigterm = () => forward("SIGTERM");
|
|
452
|
+
process.on("SIGINT", onSigint);
|
|
453
|
+
process.on("SIGTERM", onSigterm);
|
|
454
|
+
const hostStdoutP = stdio === "pipe" ? readStream(hostProc.stdout) : Promise.resolve("");
|
|
455
|
+
const hostStderrP = stdio === "pipe" ? readStream(hostProc.stderr) : Promise.resolve("");
|
|
456
|
+
const codegenStdoutP = codegenProc !== null && stdio === "pipe" ? readStream(codegenProc.stdout) : Promise.resolve("");
|
|
457
|
+
const codegenStderrP = codegenProc !== null && stdio === "pipe" ? readStream(codegenProc.stderr) : Promise.resolve("");
|
|
458
|
+
const hostExitP = hostProc.exited.then((code) => {
|
|
459
|
+
if (!signalReceived) {
|
|
460
|
+
hostExitedFirst = true;
|
|
461
|
+
console.log(`[saacms dev] host dev exited with code ${code}; shutting down codegen watcher`);
|
|
462
|
+
if (codegenProc !== null) {
|
|
463
|
+
try {
|
|
464
|
+
codegenProc.kill("SIGTERM");
|
|
465
|
+
} catch {}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return code;
|
|
469
|
+
});
|
|
470
|
+
const codegenExitP = codegenProc === null ? Promise.resolve(null) : codegenProc.exited.then((code) => {
|
|
471
|
+
if (!signalReceived && !hostExitedFirst && code !== 0) {
|
|
472
|
+
console.warn(`[saacms dev] codegen watcher exited non-zero with code ${code} (non-fatal; host dev continues)`);
|
|
473
|
+
}
|
|
474
|
+
return code;
|
|
475
|
+
});
|
|
476
|
+
const hostExitCode = await hostExitP;
|
|
477
|
+
const codegenExitCode = await codegenExitP;
|
|
478
|
+
process.off("SIGINT", onSigint);
|
|
479
|
+
process.off("SIGTERM", onSigterm);
|
|
480
|
+
const [hostStdout, hostStderr, codegenStdout, codegenStderr] = await Promise.all([
|
|
481
|
+
hostStdoutP,
|
|
482
|
+
hostStderrP,
|
|
483
|
+
codegenStdoutP,
|
|
484
|
+
codegenStderrP
|
|
485
|
+
]);
|
|
486
|
+
console.log("[saacms dev] all children exited");
|
|
487
|
+
return {
|
|
488
|
+
hostExitCode,
|
|
489
|
+
codegenExitCode,
|
|
490
|
+
hostStdout,
|
|
491
|
+
hostStderr,
|
|
492
|
+
codegenStdout,
|
|
493
|
+
codegenStderr
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
var devCommand = defineCommand3({
|
|
497
|
+
meta: {
|
|
498
|
+
name: "dev",
|
|
499
|
+
description: "Run the host's dev command alongside `saacms codegen --watch` (DX sugar; not a server)."
|
|
500
|
+
},
|
|
501
|
+
args: {
|
|
502
|
+
cwd: {
|
|
503
|
+
type: "string",
|
|
504
|
+
description: "Working directory. Defaults to process.cwd().",
|
|
505
|
+
required: false
|
|
506
|
+
},
|
|
507
|
+
"no-codegen": {
|
|
508
|
+
type: "boolean",
|
|
509
|
+
description: "Skip the codegen --watch subprocess (run host dev only).",
|
|
510
|
+
default: false
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
async run({ args }) {
|
|
514
|
+
const cwd = args.cwd ?? process.cwd();
|
|
515
|
+
const noCodegen = Boolean(args["no-codegen"]);
|
|
516
|
+
const result = await startDev({ cwd, noCodegen });
|
|
517
|
+
process.exit(result.hostExitCode);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// src/commands/migrate.ts
|
|
522
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
523
|
+
import {
|
|
524
|
+
existsSync as existsSync4,
|
|
525
|
+
mkdirSync as mkdirSync3,
|
|
526
|
+
readdirSync,
|
|
527
|
+
readFileSync as readFileSync3,
|
|
528
|
+
rmSync,
|
|
529
|
+
writeFileSync as writeFileSync3
|
|
530
|
+
} from "node:fs";
|
|
531
|
+
import { dirname as dirname3, resolve as resolve3 } from "node:path";
|
|
532
|
+
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
533
|
+
import {
|
|
534
|
+
schemaToD1Migration as schemaToD1Migration2,
|
|
535
|
+
fingerprintBlockSchema,
|
|
536
|
+
fingerprintToString,
|
|
537
|
+
diffBlockSchemas,
|
|
538
|
+
contentMigrationModuleSource,
|
|
539
|
+
detectPendingContentMigrations
|
|
540
|
+
} from "@saacms/core";
|
|
541
|
+
var LOG_PREFIX2 = "[saacms migrate]";
|
|
542
|
+
var BREAKGLASS_AUDIT_PREFIX = "[saacms audit]";
|
|
543
|
+
function resolveBreakGlassReason(args) {
|
|
544
|
+
const raw = args["break-glass"] ?? process.env.SAACMS_BREAKGLASS_REASON;
|
|
545
|
+
const attempted = raw !== undefined;
|
|
546
|
+
const trimmed = (raw ?? "").trim();
|
|
547
|
+
return trimmed.length > 0 ? { reason: trimmed, attempted: true } : { attempted };
|
|
548
|
+
}
|
|
549
|
+
var BLOCKS_KEY = "$blocks";
|
|
550
|
+
function resolveCwd(args) {
|
|
551
|
+
return resolve3(args.cwd ?? process.cwd());
|
|
552
|
+
}
|
|
553
|
+
async function loadConfig2(cwd) {
|
|
554
|
+
const configPath = resolve3(cwd, "saacms.config.ts");
|
|
555
|
+
if (!existsSync4(configPath)) {
|
|
556
|
+
throw new Error(`${LOG_PREFIX2} no saacms.config.ts; run 'saacms init' first`);
|
|
557
|
+
}
|
|
558
|
+
const shadow = resolve3(cwd, `.saacms.config.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.ts`);
|
|
559
|
+
writeFileSync3(shadow, readFileSync3(configPath, "utf8"));
|
|
560
|
+
try {
|
|
561
|
+
const mod = await import(pathToFileURL2(shadow).href);
|
|
562
|
+
if (mod.default === undefined) {
|
|
563
|
+
throw new Error(`${LOG_PREFIX2} ${configPath} has no default export; export your defineConfig({...}) as default`);
|
|
564
|
+
}
|
|
565
|
+
return mod.default;
|
|
566
|
+
} finally {
|
|
567
|
+
rmSync(shadow, { force: true });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function snapshotPath(cwd) {
|
|
571
|
+
return resolve3(cwd, ".saacms/migrations/snapshot.json");
|
|
572
|
+
}
|
|
573
|
+
function readRawSnapshot(cwd) {
|
|
574
|
+
const path = snapshotPath(cwd);
|
|
575
|
+
if (!existsSync4(path))
|
|
576
|
+
return {};
|
|
577
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
578
|
+
}
|
|
579
|
+
function readSnapshot(cwd) {
|
|
580
|
+
const raw = readRawSnapshot(cwd);
|
|
581
|
+
const { [BLOCKS_KEY]: _blocks, ...collections } = raw;
|
|
582
|
+
return collections;
|
|
583
|
+
}
|
|
584
|
+
function readBlockSnapshot(cwd) {
|
|
585
|
+
const raw = readRawSnapshot(cwd);
|
|
586
|
+
const blocks = raw[BLOCKS_KEY];
|
|
587
|
+
return blocks !== undefined ? blocks : {};
|
|
588
|
+
}
|
|
589
|
+
function ensureDir2(path) {
|
|
590
|
+
if (!existsSync4(path)) {
|
|
591
|
+
mkdirSync3(path, { recursive: true });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function utcTimestamp(d) {
|
|
595
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
596
|
+
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
|
|
597
|
+
}
|
|
598
|
+
function sanitizeName(name) {
|
|
599
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, "-");
|
|
600
|
+
}
|
|
601
|
+
function computeDdls(config) {
|
|
602
|
+
return (config.collections ?? []).map((coll) => [String(coll.slug), schemaToD1Migration2(coll)]);
|
|
603
|
+
}
|
|
604
|
+
function computeBlockFingerprints(config) {
|
|
605
|
+
return (config.blocks ?? []).map((block) => [
|
|
606
|
+
String(block.slug),
|
|
607
|
+
fingerprintBlockSchema(block),
|
|
608
|
+
block.version ?? 1
|
|
609
|
+
]);
|
|
610
|
+
}
|
|
611
|
+
var generateSubCommand = defineCommand4({
|
|
612
|
+
meta: {
|
|
613
|
+
name: "generate",
|
|
614
|
+
description: "Project Schemas to D1 DDL and emit a SQL migration per new/changed Collection (saacms/migrations/db/)."
|
|
615
|
+
},
|
|
616
|
+
args: {
|
|
617
|
+
name: {
|
|
618
|
+
type: "positional",
|
|
619
|
+
description: "Human-readable name for the migration (becomes the file slug).",
|
|
620
|
+
required: false
|
|
621
|
+
},
|
|
622
|
+
cwd: {
|
|
623
|
+
type: "string",
|
|
624
|
+
description: "Working directory (where saacms.config.ts lives). Defaults to process.cwd().",
|
|
625
|
+
required: false
|
|
626
|
+
},
|
|
627
|
+
rename: {
|
|
628
|
+
type: "string",
|
|
629
|
+
description: "Explicit rename hint, e.g. --rename=posts.body:posts.content. Echoed back; full rename-aware diffing is v1.x.",
|
|
630
|
+
required: false
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
async run({ args }) {
|
|
634
|
+
const cwd = resolveCwd(args);
|
|
635
|
+
const name = args.name ?? "unnamed";
|
|
636
|
+
const config = await loadConfig2(cwd);
|
|
637
|
+
if (args.rename) {
|
|
638
|
+
console.log(`${LOG_PREFIX2} rename hint acknowledged: ${args.rename} (full rename-aware diffing is v1.x)`);
|
|
639
|
+
}
|
|
640
|
+
const ddls = computeDdls(config);
|
|
641
|
+
const snapshot = readSnapshot(cwd);
|
|
642
|
+
const dbDir = resolve3(cwd, "saacms/migrations/db");
|
|
643
|
+
const now = new Date;
|
|
644
|
+
const iso = now.toISOString();
|
|
645
|
+
const ts = utcTimestamp(now);
|
|
646
|
+
const sanitized = sanitizeName(name);
|
|
647
|
+
const nextSnapshot = {};
|
|
648
|
+
let generated = 0;
|
|
649
|
+
for (const [slug, currentDdl] of ddls) {
|
|
650
|
+
const prev = snapshot[slug];
|
|
651
|
+
const isNew = prev === undefined;
|
|
652
|
+
const changed = prev !== undefined && prev.ddl !== currentDdl;
|
|
653
|
+
if (!isNew && !changed) {
|
|
654
|
+
console.log(`${LOG_PREFIX2} ${slug}: no change`);
|
|
655
|
+
nextSnapshot[slug] = prev;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const file = resolve3(dbDir, `${ts}_${sanitized}_${slug}.sql`);
|
|
659
|
+
const header = `-- saacms migration: ${slug} | generated ${iso} | name: ${name}
|
|
660
|
+
-- NOTE: v1 emits full desired state; ALTER-diffing lands in v1.x
|
|
661
|
+
`;
|
|
662
|
+
ensureDir2(dirname3(file));
|
|
663
|
+
writeFileSync3(file, `${header}
|
|
664
|
+
${currentDdl}
|
|
665
|
+
`);
|
|
666
|
+
console.log(`${LOG_PREFIX2} ${slug}: ${isNew ? "new table" : "schema changed"} — wrote ${file}`);
|
|
667
|
+
nextSnapshot[slug] = {
|
|
668
|
+
ddl: currentDdl,
|
|
669
|
+
migrationCount: (prev?.migrationCount ?? 0) + 1
|
|
670
|
+
};
|
|
671
|
+
generated++;
|
|
672
|
+
}
|
|
673
|
+
const blockFps = computeBlockFingerprints(config);
|
|
674
|
+
const blockSnapshot = readBlockSnapshot(cwd);
|
|
675
|
+
const contentDir = resolve3(cwd, "saacms/migrations/content");
|
|
676
|
+
const nextBlockSnapshot = {};
|
|
677
|
+
let contentGenerated = 0;
|
|
678
|
+
for (const [slug, fp, authoredVersion] of blockFps) {
|
|
679
|
+
const prev = blockSnapshot[slug];
|
|
680
|
+
const currentFp = fingerprintToString(fp);
|
|
681
|
+
if (prev === undefined) {
|
|
682
|
+
console.log(`${LOG_PREFIX2} block ${slug}: registered (no content migration)`);
|
|
683
|
+
nextBlockSnapshot[slug] = {
|
|
684
|
+
fingerprint: currentFp,
|
|
685
|
+
version: authoredVersion,
|
|
686
|
+
migrationCount: 0
|
|
687
|
+
};
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (prev.fingerprint === currentFp) {
|
|
691
|
+
console.log(`${LOG_PREFIX2} block ${slug}: no change`);
|
|
692
|
+
nextBlockSnapshot[slug] = prev;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const diff = diffBlockSchemas({ fields: JSON.parse(prev.fingerprint) }, fp);
|
|
696
|
+
const fromVersion = prev.version;
|
|
697
|
+
const toVersion = prev.version + 1;
|
|
698
|
+
const source = contentMigrationModuleSource(slug, fromVersion, toVersion, diff);
|
|
699
|
+
const file = resolve3(contentDir, `${ts}_${sanitized}_${slug}.ts`);
|
|
700
|
+
ensureDir2(dirname3(file));
|
|
701
|
+
writeFileSync3(file, source);
|
|
702
|
+
console.log(`${LOG_PREFIX2} block ${slug}: schema changed (v${fromVersion} → v${toVersion}) — wrote ${file}`);
|
|
703
|
+
console.log(`${LOG_PREFIX2} ACTION REQUIRED: set \`version: ${toVersion}\` on the defineBlock({...}) for "${slug}" in your Block source — saacms does ` + `not edit your authored structure (ADR 0012)`);
|
|
704
|
+
nextBlockSnapshot[slug] = {
|
|
705
|
+
fingerprint: currentFp,
|
|
706
|
+
version: toVersion,
|
|
707
|
+
migrationCount: prev.migrationCount + 1
|
|
708
|
+
};
|
|
709
|
+
contentGenerated++;
|
|
710
|
+
}
|
|
711
|
+
const snapPath = snapshotPath(cwd);
|
|
712
|
+
ensureDir2(dirname3(snapPath));
|
|
713
|
+
const hadBlocks = Object.keys(nextBlockSnapshot).length > 0 || Object.keys(blockSnapshot).length > 0;
|
|
714
|
+
const merged = hadBlocks ? { ...nextSnapshot, [BLOCKS_KEY]: nextBlockSnapshot } : { ...nextSnapshot };
|
|
715
|
+
writeFileSync3(snapPath, `${JSON.stringify(merged, null, 2)}
|
|
716
|
+
`);
|
|
717
|
+
console.log(`${LOG_PREFIX2} generated ${contentGenerated} content migration(s) for ${blockFps.length} block(s)`);
|
|
718
|
+
console.log(`${LOG_PREFIX2} generated ${generated} migration(s) for ${ddls.length} collection(s)`);
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
var RUN_PREFIX = "[saacms migrate run]";
|
|
722
|
+
async function defaultExec(cmd, cwd) {
|
|
723
|
+
const [bin, ...rest] = cmd;
|
|
724
|
+
const proc = Bun.spawn({
|
|
725
|
+
cmd: [bin, ...rest],
|
|
726
|
+
cwd,
|
|
727
|
+
env: { ...process.env },
|
|
728
|
+
stdout: "pipe",
|
|
729
|
+
stderr: "pipe"
|
|
730
|
+
});
|
|
731
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
732
|
+
new Response(proc.stdout).text(),
|
|
733
|
+
new Response(proc.stderr).text(),
|
|
734
|
+
proc.exited
|
|
735
|
+
]);
|
|
736
|
+
return { code, stdout, stderr };
|
|
737
|
+
}
|
|
738
|
+
function ledgerPath(cwd) {
|
|
739
|
+
return resolve3(cwd, ".saacms/migrations/applied.json");
|
|
740
|
+
}
|
|
741
|
+
function readLedger(cwd) {
|
|
742
|
+
const path = ledgerPath(cwd);
|
|
743
|
+
if (!existsSync4(path))
|
|
744
|
+
return { applied: [] };
|
|
745
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
746
|
+
return { applied: Array.isArray(parsed.applied) ? parsed.applied : [] };
|
|
747
|
+
}
|
|
748
|
+
function writeLedger(cwd, ledger) {
|
|
749
|
+
const path = ledgerPath(cwd);
|
|
750
|
+
ensureDir2(dirname3(path));
|
|
751
|
+
writeFileSync3(path, `${JSON.stringify(ledger, null, 2)}
|
|
752
|
+
`);
|
|
753
|
+
}
|
|
754
|
+
async function runMigrateRun(args, deps = {}) {
|
|
755
|
+
const cwd = resolve3(args.cwd ?? process.cwd());
|
|
756
|
+
const target = args.target === "remote" ? "remote" : "local";
|
|
757
|
+
const exec = deps.exec ?? defaultExec;
|
|
758
|
+
const dbDir = resolve3(cwd, "saacms/migrations/db");
|
|
759
|
+
if (!existsSync4(dbDir)) {
|
|
760
|
+
console.log(`${RUN_PREFIX} no migrations directory; run 'saacms migrate generate' first`);
|
|
761
|
+
return 0;
|
|
762
|
+
}
|
|
763
|
+
const candidates = readdirSync(dbDir).filter((f) => f.endsWith(".sql")).sort();
|
|
764
|
+
const ledger = readLedger(cwd);
|
|
765
|
+
const appliedSet = new Set(ledger.applied);
|
|
766
|
+
const pending = candidates.filter((f) => !appliedSet.has(f));
|
|
767
|
+
if (pending.length === 0) {
|
|
768
|
+
console.log(`${RUN_PREFIX} up to date (${ledger.applied.length} applied, 0 pending)`);
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
const database = args.database ?? process.env.SAACMS_D1_DATABASE;
|
|
772
|
+
if (!database) {
|
|
773
|
+
throw new Error(`${RUN_PREFIX} no D1 database — pass --database or set $SAACMS_D1_DATABASE`);
|
|
774
|
+
}
|
|
775
|
+
const targetFlag = target === "remote" ? "--remote" : "--local";
|
|
776
|
+
const wranglerCmd = (file) => [
|
|
777
|
+
"wrangler",
|
|
778
|
+
"d1",
|
|
779
|
+
"execute",
|
|
780
|
+
database,
|
|
781
|
+
targetFlag,
|
|
782
|
+
`--file=saacms/migrations/db/${file}`,
|
|
783
|
+
"--yes"
|
|
784
|
+
];
|
|
785
|
+
if (args.dry) {
|
|
786
|
+
console.log(`${RUN_PREFIX} would apply ${pending.length} pending: ${pending.join(", ")}`);
|
|
787
|
+
for (const file of pending) {
|
|
788
|
+
console.log(`${RUN_PREFIX} would run: ${wranglerCmd(file).join(" ")}`);
|
|
789
|
+
}
|
|
790
|
+
return 0;
|
|
791
|
+
}
|
|
792
|
+
let appliedNow = 0;
|
|
793
|
+
for (const file of pending) {
|
|
794
|
+
const result = await exec(wranglerCmd(file), cwd);
|
|
795
|
+
if (result.code !== 0) {
|
|
796
|
+
writeLedger(cwd, ledger);
|
|
797
|
+
console.log(`${RUN_PREFIX} FAILED applying ${file}: ${result.stderr.slice(0, 300)}`);
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
ledger.applied.push(file);
|
|
801
|
+
writeLedger(cwd, ledger);
|
|
802
|
+
appliedNow++;
|
|
803
|
+
console.log(`${RUN_PREFIX} applied ${file}`);
|
|
804
|
+
}
|
|
805
|
+
console.log(`${RUN_PREFIX} applied ${appliedNow} migration(s); ledger now ${ledger.applied.length} total`);
|
|
806
|
+
console.log(`${RUN_PREFIX} content migrations: none applied (saacms/migrations/content/ transforms are a v1.x dispatch)`);
|
|
807
|
+
return 0;
|
|
808
|
+
}
|
|
809
|
+
var runSubCommand = defineCommand4({
|
|
810
|
+
meta: {
|
|
811
|
+
name: "run",
|
|
812
|
+
description: "Apply pending DB migrations to a Cloudflare D1 database via wrangler (idempotent; ledger-tracked)."
|
|
813
|
+
},
|
|
814
|
+
args: {
|
|
815
|
+
target: {
|
|
816
|
+
type: "string",
|
|
817
|
+
description: "Migration target: 'local' (default) or 'remote'.",
|
|
818
|
+
default: "local"
|
|
819
|
+
},
|
|
820
|
+
cwd: {
|
|
821
|
+
type: "string",
|
|
822
|
+
description: "Working directory (where saacms/migrations/db/ lives). Defaults to process.cwd().",
|
|
823
|
+
required: false
|
|
824
|
+
},
|
|
825
|
+
database: {
|
|
826
|
+
type: "string",
|
|
827
|
+
description: "D1 database/binding name. Falls back to $SAACMS_D1_DATABASE.",
|
|
828
|
+
required: false
|
|
829
|
+
},
|
|
830
|
+
dry: {
|
|
831
|
+
type: "boolean",
|
|
832
|
+
description: "Print the apply plan and the wrangler commands; touch nothing.",
|
|
833
|
+
default: false
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
async run({ args }) {
|
|
837
|
+
process.exitCode = await runMigrateRun({
|
|
838
|
+
cwd: args.cwd,
|
|
839
|
+
target: args.target,
|
|
840
|
+
database: args.database,
|
|
841
|
+
dry: Boolean(args.dry)
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
var checkSubCommand = defineCommand4({
|
|
846
|
+
meta: {
|
|
847
|
+
name: "check",
|
|
848
|
+
description: "Read-only predeploy gate: report per-Collection drift; exit non-zero with --strict if any is pending."
|
|
849
|
+
},
|
|
850
|
+
args: {
|
|
851
|
+
strict: {
|
|
852
|
+
type: "boolean",
|
|
853
|
+
description: "Treat any pending migration or Schema drift as an error (exit code 1).",
|
|
854
|
+
default: false
|
|
855
|
+
},
|
|
856
|
+
cwd: {
|
|
857
|
+
type: "string",
|
|
858
|
+
description: "Working directory (where saacms.config.ts lives). Defaults to process.cwd().",
|
|
859
|
+
required: false
|
|
860
|
+
},
|
|
861
|
+
"break-glass": {
|
|
862
|
+
type: "string",
|
|
863
|
+
description: "Emergency override (ADR 0032): with a NON-EMPTY <reason>, a --strict drift gate exits 0 instead of 1 under explicit operator authority. Drift is still printed in full; the override is loud and ALWAYS audited (banner + structured stderr record). An empty/missing reason is NOT a break-glass — the gate still fails. Also accepts $SAACMS_BREAKGLASS_REASON.",
|
|
864
|
+
required: false
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
async run({ args }) {
|
|
868
|
+
const cwd = resolveCwd(args);
|
|
869
|
+
const config = await loadConfig2(cwd);
|
|
870
|
+
const ddls = computeDdls(config);
|
|
871
|
+
const snapshot = readSnapshot(cwd);
|
|
872
|
+
const drifted = [];
|
|
873
|
+
for (const [slug, currentDdl] of ddls) {
|
|
874
|
+
const prev = snapshot[slug];
|
|
875
|
+
if (prev === undefined) {
|
|
876
|
+
console.log(`${LOG_PREFIX2} ${slug}: NEW (never migrated)`);
|
|
877
|
+
drifted.push(slug);
|
|
878
|
+
} else if (prev.ddl !== currentDdl) {
|
|
879
|
+
console.log(`${LOG_PREFIX2} ${slug}: DRIFT (schema changed since last migrate generate)`);
|
|
880
|
+
drifted.push(slug);
|
|
881
|
+
} else {
|
|
882
|
+
console.log(`${LOG_PREFIX2} ${slug}: in-sync`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (drifted.length === 0) {
|
|
886
|
+
console.log(`${LOG_PREFIX2} check: all collections in sync`);
|
|
887
|
+
}
|
|
888
|
+
const contentReport = detectPendingContentMigrations({
|
|
889
|
+
blocks: config.blocks ?? [],
|
|
890
|
+
snapshot: readBlockSnapshot(cwd)
|
|
891
|
+
});
|
|
892
|
+
for (const p of contentReport.pending) {
|
|
893
|
+
console.log(`${LOG_PREFIX2} block ${p.slug}: DRIFT — ${p.reason}`);
|
|
894
|
+
}
|
|
895
|
+
if (contentReport.clean && (config.blocks ?? []).length > 0) {
|
|
896
|
+
console.log(`${LOG_PREFIX2} check: all blocks in sync`);
|
|
897
|
+
}
|
|
898
|
+
const collectionsPending = drifted.length > 0;
|
|
899
|
+
const contentPending = !contentReport.clean;
|
|
900
|
+
if (!collectionsPending && !contentPending) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const parts = [];
|
|
904
|
+
if (collectionsPending)
|
|
905
|
+
parts.push(`${drifted.length} collection(s): ${drifted.join(", ")}`);
|
|
906
|
+
if (contentPending) {
|
|
907
|
+
parts.push(`${contentReport.pending.length} block(s): ${contentReport.pending.map((p) => p.slug).join(", ")}`);
|
|
908
|
+
}
|
|
909
|
+
const summary = parts.join("; ");
|
|
910
|
+
if (args.strict) {
|
|
911
|
+
console.log(`${LOG_PREFIX2} check FAILED: ${summary} pending`);
|
|
912
|
+
console.log(`${LOG_PREFIX2} check: run 'saacms migrate generate <name>'`);
|
|
913
|
+
process.exitCode = 1;
|
|
914
|
+
const bg = resolveBreakGlassReason(args);
|
|
915
|
+
if (bg.reason !== undefined) {
|
|
916
|
+
const at = new Date().toISOString();
|
|
917
|
+
const pending = [
|
|
918
|
+
...drifted,
|
|
919
|
+
...contentReport.pending.map((p) => p.slug)
|
|
920
|
+
];
|
|
921
|
+
const record = {
|
|
922
|
+
kind: "breakglass",
|
|
923
|
+
surface: "migrate-check",
|
|
924
|
+
reason: bg.reason,
|
|
925
|
+
pending,
|
|
926
|
+
at
|
|
927
|
+
};
|
|
928
|
+
console.error(`${LOG_PREFIX2} ============================================================`);
|
|
929
|
+
console.error(`${LOG_PREFIX2} *** BREAK-GLASS OVERRIDE — fail-closed migration gate bypassed ***`);
|
|
930
|
+
console.error(`${LOG_PREFIX2} BREAK-GLASS reason : ${bg.reason}`);
|
|
931
|
+
console.error(`${LOG_PREFIX2} BREAK-GLASS pending: ${pending.join(", ")}`);
|
|
932
|
+
console.error(`${LOG_PREFIX2} BREAK-GLASS at : ${at}`);
|
|
933
|
+
console.error(`${LOG_PREFIX2} BREAK-GLASS: exit overridden 1 → 0 under explicit operator authority — the drift above is NOT resolved; review this record post-hoc`);
|
|
934
|
+
console.error(`${LOG_PREFIX2} ============================================================`);
|
|
935
|
+
console.error(`${BREAKGLASS_AUDIT_PREFIX} ${JSON.stringify(record)}`);
|
|
936
|
+
process.exitCode = 0;
|
|
937
|
+
} else if (bg.attempted) {
|
|
938
|
+
console.error(`${LOG_PREFIX2} BREAK-GLASS REJECTED: a break-glass requires a non-empty --break-glass="<reason>" (or $SAACMS_BREAKGLASS_REASON). Gate still FAILED (exit 1).`);
|
|
939
|
+
}
|
|
940
|
+
} else {
|
|
941
|
+
console.warn(`${LOG_PREFIX2} check: ${summary} pending (advisory)`);
|
|
942
|
+
console.warn(`${LOG_PREFIX2} check: run 'saacms migrate generate <name>' (non-strict — exit 0)`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
var migrateCommand = defineCommand4({
|
|
947
|
+
meta: {
|
|
948
|
+
name: "migrate",
|
|
949
|
+
description: "Generate, run, or check DB + content migrations (per ADR 0012)."
|
|
950
|
+
},
|
|
951
|
+
subCommands: {
|
|
952
|
+
generate: generateSubCommand,
|
|
953
|
+
run: runSubCommand,
|
|
954
|
+
check: checkSubCommand
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
// src/commands/publish.ts
|
|
959
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
960
|
+
import { resolve as resolve4 } from "node:path";
|
|
961
|
+
import { pathToFileURL as pathToFileURL3 } from "node:url";
|
|
962
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
963
|
+
import { compileForPublish } from "@saacms/core";
|
|
964
|
+
var SAACMS_PATHSPECS = [
|
|
965
|
+
"saacms",
|
|
966
|
+
"saacms.config.ts",
|
|
967
|
+
"src/pages/api/saacms"
|
|
968
|
+
];
|
|
969
|
+
var REDACTED = "[REDACTED]";
|
|
970
|
+
var TOKEN_SHAPES = [
|
|
971
|
+
/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
|
|
972
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
973
|
+
/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g,
|
|
974
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
975
|
+
/\bAKIA[0-9A-Z]{16}\b/g
|
|
976
|
+
];
|
|
977
|
+
var LABELED_SECRET = /((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd|credential|key)["'`]?\s*[:=]\s*["'`]?)([A-Za-z0-9_\-./+=]{12,})/gi;
|
|
978
|
+
var ENV_SECRET = /\b([A-Z0-9]*(?:TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*\s*=\s*)(\S{6,})/g;
|
|
979
|
+
function redactSecrets(text) {
|
|
980
|
+
let out = text;
|
|
981
|
+
for (const re of TOKEN_SHAPES)
|
|
982
|
+
out = out.replace(re, REDACTED);
|
|
983
|
+
out = out.replace(LABELED_SECRET, (_m, label) => `${label}${REDACTED}`);
|
|
984
|
+
out = out.replace(ENV_SECRET, (_m, label) => `${label}${REDACTED}`);
|
|
985
|
+
return out;
|
|
986
|
+
}
|
|
987
|
+
function emit(line) {
|
|
988
|
+
console.log(redactSecrets(line));
|
|
989
|
+
}
|
|
990
|
+
function isSaacmsOwned(path, manifestWritten) {
|
|
991
|
+
for (const spec of SAACMS_PATHSPECS) {
|
|
992
|
+
if (path === spec || path.startsWith(`${spec}/`))
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
return manifestWritten.includes(path);
|
|
996
|
+
}
|
|
997
|
+
async function git(cwd, args) {
|
|
998
|
+
const proc = Bun.spawn({
|
|
999
|
+
cmd: ["git", "-C", cwd, ...args],
|
|
1000
|
+
env: { ...process.env },
|
|
1001
|
+
stdout: "pipe",
|
|
1002
|
+
stderr: "pipe"
|
|
1003
|
+
});
|
|
1004
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
1005
|
+
new Response(proc.stdout).text(),
|
|
1006
|
+
new Response(proc.stderr).text(),
|
|
1007
|
+
proc.exited
|
|
1008
|
+
]);
|
|
1009
|
+
return { code, stdout, stderr };
|
|
1010
|
+
}
|
|
1011
|
+
function parsePorcelain(out) {
|
|
1012
|
+
const entries = [];
|
|
1013
|
+
for (const raw of out.split(`
|
|
1014
|
+
`)) {
|
|
1015
|
+
if (raw.length < 4)
|
|
1016
|
+
continue;
|
|
1017
|
+
const xy = raw.slice(0, 2);
|
|
1018
|
+
let path = raw.slice(3);
|
|
1019
|
+
let changeType;
|
|
1020
|
+
if (xy === "??")
|
|
1021
|
+
changeType = "??";
|
|
1022
|
+
else if (xy.includes("D"))
|
|
1023
|
+
changeType = "D";
|
|
1024
|
+
else if (xy.includes("R"))
|
|
1025
|
+
changeType = "R";
|
|
1026
|
+
else if (xy.includes("A"))
|
|
1027
|
+
changeType = "A";
|
|
1028
|
+
else if (xy.includes("M"))
|
|
1029
|
+
changeType = "M";
|
|
1030
|
+
else
|
|
1031
|
+
changeType = xy.trim()[0] || "?";
|
|
1032
|
+
if (xy.includes("R") && path.includes(" -> ")) {
|
|
1033
|
+
path = path.slice(path.indexOf(" -> ") + 4);
|
|
1034
|
+
}
|
|
1035
|
+
entries.push({ path, changeType });
|
|
1036
|
+
}
|
|
1037
|
+
return entries;
|
|
1038
|
+
}
|
|
1039
|
+
function stagedForeignPaths(fullStatus, manifestWritten) {
|
|
1040
|
+
const foreign = [];
|
|
1041
|
+
for (const raw of fullStatus.split(`
|
|
1042
|
+
`)) {
|
|
1043
|
+
if (raw.length < 4)
|
|
1044
|
+
continue;
|
|
1045
|
+
const x = raw[0];
|
|
1046
|
+
if (x === " " || x === "?" || x === "!")
|
|
1047
|
+
continue;
|
|
1048
|
+
let path = raw.slice(3);
|
|
1049
|
+
if (path.includes(" -> "))
|
|
1050
|
+
path = path.slice(path.indexOf(" -> ") + 4);
|
|
1051
|
+
if (!isSaacmsOwned(path, manifestWritten))
|
|
1052
|
+
foreign.push(path);
|
|
1053
|
+
}
|
|
1054
|
+
return foreign;
|
|
1055
|
+
}
|
|
1056
|
+
function deriveMessage(entries) {
|
|
1057
|
+
const paths = entries.map((e) => e.path);
|
|
1058
|
+
const shown = paths.slice(0, 5).join(", ");
|
|
1059
|
+
const suffix = paths.length > 5 ? `${shown}, +${paths.length - 5} more` : shown;
|
|
1060
|
+
return `saacms: publish (${paths.length} file(s): ${suffix})`;
|
|
1061
|
+
}
|
|
1062
|
+
async function loadSaacmsConfig(cwd) {
|
|
1063
|
+
const configPath = resolve4(cwd, "saacms.config.ts");
|
|
1064
|
+
if (!existsSync5(configPath))
|
|
1065
|
+
return null;
|
|
1066
|
+
const shadow = resolve4(cwd, `.saacms.config.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.ts`);
|
|
1067
|
+
writeFileSync4(shadow, readFileSync4(configPath, "utf8"));
|
|
1068
|
+
try {
|
|
1069
|
+
const mod = await import(pathToFileURL3(shadow).href);
|
|
1070
|
+
return mod.default ?? null;
|
|
1071
|
+
} finally {
|
|
1072
|
+
rmSync2(shadow, { force: true });
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
var publishCommand = defineCommand5({
|
|
1076
|
+
meta: {
|
|
1077
|
+
name: "publish",
|
|
1078
|
+
description: "Compile + commit the saacms source set to your Git repo (v1 alpha: local git only; --push optional)."
|
|
1079
|
+
},
|
|
1080
|
+
args: {
|
|
1081
|
+
dry: {
|
|
1082
|
+
type: "boolean",
|
|
1083
|
+
description: "Print the commit plan without staging or committing anything.",
|
|
1084
|
+
default: false
|
|
1085
|
+
},
|
|
1086
|
+
message: {
|
|
1087
|
+
type: "string",
|
|
1088
|
+
description: "Override the commit message saacms would otherwise derive from the changed set.",
|
|
1089
|
+
required: false
|
|
1090
|
+
},
|
|
1091
|
+
cwd: {
|
|
1092
|
+
type: "string",
|
|
1093
|
+
description: "Repository directory to publish from. Defaults to process.cwd().",
|
|
1094
|
+
required: false
|
|
1095
|
+
},
|
|
1096
|
+
push: {
|
|
1097
|
+
type: "boolean",
|
|
1098
|
+
description: "After committing, run `git push`. A push failure does not lose the local commit.",
|
|
1099
|
+
default: false
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
async run({ args }) {
|
|
1103
|
+
const cwd = args.cwd ?? process.cwd();
|
|
1104
|
+
const dry = Boolean(args.dry);
|
|
1105
|
+
const push = Boolean(args.push);
|
|
1106
|
+
const insideTree = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
1107
|
+
if (insideTree.code !== 0 || insideTree.stdout.trim() !== "true") {
|
|
1108
|
+
throw new Error(`[saacms publish] not a git repository at ${cwd}; saacms publishes by committing to your repo`);
|
|
1109
|
+
}
|
|
1110
|
+
let manifest = null;
|
|
1111
|
+
const config = await loadSaacmsConfig(cwd);
|
|
1112
|
+
if (config !== null) {
|
|
1113
|
+
manifest = await compileForPublish(config, { cwd, dryRun: dry });
|
|
1114
|
+
}
|
|
1115
|
+
const allPathspecs = [
|
|
1116
|
+
...SAACMS_PATHSPECS,
|
|
1117
|
+
...manifest?.written ?? []
|
|
1118
|
+
];
|
|
1119
|
+
const status = await git(cwd, [
|
|
1120
|
+
"status",
|
|
1121
|
+
"--porcelain",
|
|
1122
|
+
"--",
|
|
1123
|
+
...allPathspecs
|
|
1124
|
+
]);
|
|
1125
|
+
if (status.code !== 0) {
|
|
1126
|
+
throw new Error(redactSecrets(`[saacms publish] git status failed: ${status.stderr.trim() || `exit ${status.code}`}`));
|
|
1127
|
+
}
|
|
1128
|
+
const entries = parsePorcelain(status.stdout);
|
|
1129
|
+
const manifestWritten = manifest?.written ?? [];
|
|
1130
|
+
const message = redactSecrets(typeof args.message === "string" && args.message.length > 0 ? args.message : deriveMessage(entries));
|
|
1131
|
+
if (dry) {
|
|
1132
|
+
if (manifest !== null && manifest.written.length > 0) {
|
|
1133
|
+
emit(`[saacms publish] compile (dry-run): ${manifest.routes.length} route(s), ${manifest.content.length} content file(s), ${manifest.media.length} media file(s) would be emitted`);
|
|
1134
|
+
for (const p of manifest.written) {
|
|
1135
|
+
emit(` ${p}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
emit(`[saacms publish] would stage ${entries.length} path(s):`);
|
|
1139
|
+
for (const entry of entries) {
|
|
1140
|
+
emit(` ${entry.changeType.padEnd(2)} ${entry.path}`);
|
|
1141
|
+
}
|
|
1142
|
+
emit(`[saacms publish] commit message: ${message}`);
|
|
1143
|
+
emit("[saacms publish] dry-run — no commit made");
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (entries.length === 0) {
|
|
1147
|
+
emit("[saacms publish] nothing to publish (working tree clean for saacms paths)");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const fullStatus = await git(cwd, ["status", "--porcelain"]);
|
|
1151
|
+
if (fullStatus.code !== 0) {
|
|
1152
|
+
throw new Error(redactSecrets(`[saacms publish] git status failed: ${fullStatus.stderr.trim() || `exit ${fullStatus.code}`}`));
|
|
1153
|
+
}
|
|
1154
|
+
const foreign = stagedForeignPaths(fullStatus.stdout, manifestWritten);
|
|
1155
|
+
if (foreign.length > 0) {
|
|
1156
|
+
throw new Error([
|
|
1157
|
+
"[saacms publish] refusing to publish — staged changes outside the saacms-owned path set",
|
|
1158
|
+
` What: ${foreign.length} non-saacms path(s) are staged: ${foreign.slice(0, 5).join(", ")}${foreign.length > 5 ? `, +${foreign.length - 5} more` : ""}`,
|
|
1159
|
+
" Why: publish commits the whole index; non-saacms staged changes would ride along in the publish commit (blast-radius containment, ADR 0028 §D3).",
|
|
1160
|
+
" Fix: unstage them — `git restore --staged <path>` — then re-run `saacms publish`, or commit them separately first.",
|
|
1161
|
+
" Learn more: docs/adr/0028-publish-credential-and-blast-radius-model.md"
|
|
1162
|
+
].join(`
|
|
1163
|
+
`));
|
|
1164
|
+
}
|
|
1165
|
+
const addPaths = Array.from(new Set(entries.map((e) => e.path)));
|
|
1166
|
+
const outOfScope = addPaths.filter((p) => !isSaacmsOwned(p, manifestWritten));
|
|
1167
|
+
if (outOfScope.length > 0) {
|
|
1168
|
+
throw new Error([
|
|
1169
|
+
"[saacms publish] refusing to publish — path(s) outside the saacms-owned manifest scope",
|
|
1170
|
+
` What: ${outOfScope.slice(0, 5).join(", ")}${outOfScope.length > 5 ? `, +${outOfScope.length - 5} more` : ""}`,
|
|
1171
|
+
" Why: publish stages only SAACMS_PATHSPECS + exact compile-manifest paths (ADR 0028 §D3 publish-scope invariant).",
|
|
1172
|
+
" Fix: this is a saacms bug if it fires — file an issue; do not bypass by staging manually.",
|
|
1173
|
+
" Learn more: docs/adr/0028-publish-credential-and-blast-radius-model.md"
|
|
1174
|
+
].join(`
|
|
1175
|
+
`));
|
|
1176
|
+
}
|
|
1177
|
+
const add = await git(cwd, ["add", "--", ...addPaths]);
|
|
1178
|
+
if (add.code !== 0) {
|
|
1179
|
+
throw new Error(redactSecrets(`[saacms publish] git add failed: ${add.stderr.trim() || `exit ${add.code}`}`));
|
|
1180
|
+
}
|
|
1181
|
+
const authorName = process.env.GIT_AUTHOR_NAME;
|
|
1182
|
+
const authorEmail = process.env.GIT_AUTHOR_EMAIL;
|
|
1183
|
+
const identityArgs = authorName && authorEmail ? ["-c", `user.name=${authorName}`, "-c", `user.email=${authorEmail}`] : [];
|
|
1184
|
+
const commit = await git(cwd, [
|
|
1185
|
+
...identityArgs,
|
|
1186
|
+
"commit",
|
|
1187
|
+
"-m",
|
|
1188
|
+
message
|
|
1189
|
+
]);
|
|
1190
|
+
if (commit.code !== 0) {
|
|
1191
|
+
throw new Error(redactSecrets(`[saacms publish] git commit failed: ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.code}`}`));
|
|
1192
|
+
}
|
|
1193
|
+
const head = await git(cwd, ["rev-parse", "--short", "HEAD"]);
|
|
1194
|
+
const sha = head.code === 0 ? head.stdout.trim() : "(unknown)";
|
|
1195
|
+
emit(`[saacms publish] committed ${sha}: ${message}`);
|
|
1196
|
+
if (push) {
|
|
1197
|
+
const pushed = await git(cwd, ["push"]);
|
|
1198
|
+
if (pushed.code !== 0) {
|
|
1199
|
+
emit(`[saacms publish] push failed: ${pushed.stderr.trim() || `exit ${pushed.code}`}`);
|
|
1200
|
+
process.exitCode = 1;
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
emit("[saacms publish] pushed to remote");
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
// src/commands/logs.ts
|
|
1209
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
1210
|
+
var LOG_PREFIX3 = "[saacms logs]";
|
|
1211
|
+
var CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
1212
|
+
var POLL_INTERVAL_MS = 3000;
|
|
1213
|
+
var TERMINAL_STATUSES = ["success", "failure", "canceled"];
|
|
1214
|
+
function isTerminalStatus(s) {
|
|
1215
|
+
return s !== undefined && TERMINAL_STATUSES.includes(s);
|
|
1216
|
+
}
|
|
1217
|
+
function resolveCreds(args) {
|
|
1218
|
+
const token = args.token ?? process.env.CLOUDFLARE_API_TOKEN;
|
|
1219
|
+
if (!token) {
|
|
1220
|
+
throw new Error(`${LOG_PREFIX3} missing Cloudflare API token — pass --token or set $CLOUDFLARE_API_TOKEN`);
|
|
1221
|
+
}
|
|
1222
|
+
const account = args.account ?? process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
1223
|
+
if (!account) {
|
|
1224
|
+
throw new Error(`${LOG_PREFIX3} missing Cloudflare account id — pass --account or set $CLOUDFLARE_ACCOUNT_ID`);
|
|
1225
|
+
}
|
|
1226
|
+
const project = args.project ?? process.env.CLOUDFLARE_PAGES_PROJECT;
|
|
1227
|
+
if (!project) {
|
|
1228
|
+
throw new Error(`${LOG_PREFIX3} missing Cloudflare Pages project — pass --project or set $CLOUDFLARE_PAGES_PROJECT`);
|
|
1229
|
+
}
|
|
1230
|
+
return { token, account, project };
|
|
1231
|
+
}
|
|
1232
|
+
async function cfGet(url, token, fetchFn) {
|
|
1233
|
+
const res = await fetchFn(url, {
|
|
1234
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1235
|
+
});
|
|
1236
|
+
if (!res.ok) {
|
|
1237
|
+
let snippet = "";
|
|
1238
|
+
try {
|
|
1239
|
+
snippet = (await res.text()).slice(0, 200);
|
|
1240
|
+
} catch {
|
|
1241
|
+
snippet = "<unreadable body>";
|
|
1242
|
+
}
|
|
1243
|
+
throw new Error(`${LOG_PREFIX3} Cloudflare API HTTP ${res.status}: ${snippet}`);
|
|
1244
|
+
}
|
|
1245
|
+
const env = await res.json();
|
|
1246
|
+
if (!env.success) {
|
|
1247
|
+
const msg = env.errors?.[0]?.message ?? "unknown error";
|
|
1248
|
+
throw new Error(`${LOG_PREFIX3} Cloudflare API error: ${msg}`);
|
|
1249
|
+
}
|
|
1250
|
+
return env.result;
|
|
1251
|
+
}
|
|
1252
|
+
function deploymentUrl(c, deployId) {
|
|
1253
|
+
return `${CF_API_BASE}/accounts/${c.account}/pages/projects/${c.project}/deployments/${deployId}`;
|
|
1254
|
+
}
|
|
1255
|
+
function logsUrl(c, deployId) {
|
|
1256
|
+
return `${deploymentUrl(c, deployId)}/history/logs`;
|
|
1257
|
+
}
|
|
1258
|
+
function defaultSleep(ms) {
|
|
1259
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1260
|
+
}
|
|
1261
|
+
function printLines(entries) {
|
|
1262
|
+
for (const e of entries)
|
|
1263
|
+
console.log(`${e.ts} ${e.line}`);
|
|
1264
|
+
}
|
|
1265
|
+
async function runLogs(args, deps = {}) {
|
|
1266
|
+
const creds = resolveCreds(args);
|
|
1267
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
1268
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
1269
|
+
const depUrl = deploymentUrl(creds, args.deployId);
|
|
1270
|
+
const lgUrl = logsUrl(creds, args.deployId);
|
|
1271
|
+
if (!args.follow) {
|
|
1272
|
+
await cfGet(depUrl, creds.token, fetchFn);
|
|
1273
|
+
const logs = await cfGet(lgUrl, creds.token, fetchFn);
|
|
1274
|
+
printLines(logs.data ?? []);
|
|
1275
|
+
return 0;
|
|
1276
|
+
}
|
|
1277
|
+
let printed = 0;
|
|
1278
|
+
let interrupted = false;
|
|
1279
|
+
const onSigint = () => {
|
|
1280
|
+
interrupted = true;
|
|
1281
|
+
};
|
|
1282
|
+
process.on("SIGINT", onSigint);
|
|
1283
|
+
try {
|
|
1284
|
+
while (true) {
|
|
1285
|
+
const dep = await cfGet(depUrl, creds.token, fetchFn);
|
|
1286
|
+
const logs = await cfGet(lgUrl, creds.token, fetchFn);
|
|
1287
|
+
const data = logs.data ?? [];
|
|
1288
|
+
printLines(data.slice(printed));
|
|
1289
|
+
printed = data.length;
|
|
1290
|
+
if (interrupted)
|
|
1291
|
+
return 0;
|
|
1292
|
+
const status = dep.latest_stage?.status;
|
|
1293
|
+
if (isTerminalStatus(status)) {
|
|
1294
|
+
console.log(`${LOG_PREFIX3} deployment ${status}`);
|
|
1295
|
+
return status === "success" ? 0 : 1;
|
|
1296
|
+
}
|
|
1297
|
+
await sleep(POLL_INTERVAL_MS);
|
|
1298
|
+
if (interrupted)
|
|
1299
|
+
return 0;
|
|
1300
|
+
}
|
|
1301
|
+
} finally {
|
|
1302
|
+
process.removeListener("SIGINT", onSigint);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
var logsCommand = defineCommand6({
|
|
1306
|
+
meta: {
|
|
1307
|
+
name: "logs",
|
|
1308
|
+
description: "Fetch build + runtime logs for a deploy (v1 alpha: Cloudflare Pages)."
|
|
1309
|
+
},
|
|
1310
|
+
args: {
|
|
1311
|
+
deployId: {
|
|
1312
|
+
type: "positional",
|
|
1313
|
+
description: "Deploy identifier as reported by Cloudflare Pages.",
|
|
1314
|
+
required: true
|
|
1315
|
+
},
|
|
1316
|
+
follow: {
|
|
1317
|
+
type: "boolean",
|
|
1318
|
+
description: "Stream logs (tail mode) by polling instead of one-shot fetch.",
|
|
1319
|
+
default: false
|
|
1320
|
+
},
|
|
1321
|
+
project: {
|
|
1322
|
+
type: "string",
|
|
1323
|
+
description: "Cloudflare Pages project name. Falls back to $CLOUDFLARE_PAGES_PROJECT.",
|
|
1324
|
+
required: false
|
|
1325
|
+
},
|
|
1326
|
+
account: {
|
|
1327
|
+
type: "string",
|
|
1328
|
+
description: "Cloudflare account id. Falls back to $CLOUDFLARE_ACCOUNT_ID.",
|
|
1329
|
+
required: false
|
|
1330
|
+
},
|
|
1331
|
+
token: {
|
|
1332
|
+
type: "string",
|
|
1333
|
+
description: "Cloudflare API token. Falls back to $CLOUDFLARE_API_TOKEN.",
|
|
1334
|
+
required: false
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
async run({ args }) {
|
|
1338
|
+
process.exitCode = await runLogs({
|
|
1339
|
+
deployId: args.deployId,
|
|
1340
|
+
follow: args.follow,
|
|
1341
|
+
project: args.project,
|
|
1342
|
+
account: args.account,
|
|
1343
|
+
token: args.token
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// src/commands/doctor.ts
|
|
1349
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
1350
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "node:fs";
|
|
1351
|
+
import { join as join3, resolve as resolve5 } from "node:path";
|
|
1352
|
+
var LOG_PREFIX4 = "[saacms doctor]";
|
|
1353
|
+
var MIN_BUN = [1, 3, 0];
|
|
1354
|
+
var STATUS_ICON = {
|
|
1355
|
+
pass: "v",
|
|
1356
|
+
info: "i",
|
|
1357
|
+
warn: "W",
|
|
1358
|
+
fail: "x"
|
|
1359
|
+
};
|
|
1360
|
+
var ASTRO_CONFIG_FILES3 = [
|
|
1361
|
+
"astro.config.ts",
|
|
1362
|
+
"astro.config.mjs",
|
|
1363
|
+
"astro.config.js",
|
|
1364
|
+
"astro.config.cjs"
|
|
1365
|
+
];
|
|
1366
|
+
var NEXTJS_CONFIG_FILES3 = [
|
|
1367
|
+
"next.config.ts",
|
|
1368
|
+
"next.config.mjs",
|
|
1369
|
+
"next.config.js"
|
|
1370
|
+
];
|
|
1371
|
+
var SVELTEKIT_CONFIG_FILES3 = ["svelte.config.js", "svelte.config.ts"];
|
|
1372
|
+
var NUXT_CONFIG_FILES3 = ["nuxt.config.ts", "nuxt.config.js"];
|
|
1373
|
+
function loadPackageJson(cwd) {
|
|
1374
|
+
const path = join3(cwd, "package.json");
|
|
1375
|
+
if (!existsSync6(path))
|
|
1376
|
+
return { kind: "absent" };
|
|
1377
|
+
try {
|
|
1378
|
+
return { kind: "ok", pkg: JSON.parse(readFileSync5(path, "utf8")) };
|
|
1379
|
+
} catch {
|
|
1380
|
+
return { kind: "invalid" };
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function hasDep3(pkg, name) {
|
|
1384
|
+
if (!pkg)
|
|
1385
|
+
return false;
|
|
1386
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]);
|
|
1387
|
+
}
|
|
1388
|
+
function hasAnyFile3(cwd, files) {
|
|
1389
|
+
return files.some((file) => existsSync6(join3(cwd, file)));
|
|
1390
|
+
}
|
|
1391
|
+
function detectHost3(cwd, pkg) {
|
|
1392
|
+
if (hasAnyFile3(cwd, ASTRO_CONFIG_FILES3) || hasDep3(pkg, "astro"))
|
|
1393
|
+
return "astro";
|
|
1394
|
+
if (hasAnyFile3(cwd, NEXTJS_CONFIG_FILES3) || hasDep3(pkg, "next"))
|
|
1395
|
+
return "nextjs";
|
|
1396
|
+
if (hasAnyFile3(cwd, SVELTEKIT_CONFIG_FILES3) || hasDep3(pkg, "@sveltejs/kit"))
|
|
1397
|
+
return "sveltekit";
|
|
1398
|
+
if (hasAnyFile3(cwd, NUXT_CONFIG_FILES3) || hasDep3(pkg, "nuxt"))
|
|
1399
|
+
return "nuxt";
|
|
1400
|
+
return "unknown";
|
|
1401
|
+
}
|
|
1402
|
+
function parseSemver(version) {
|
|
1403
|
+
const core = version.split(/[-+]/, 1)[0] ?? "";
|
|
1404
|
+
const parts = core.split(".").map((p) => {
|
|
1405
|
+
const n = Number.parseInt(p, 10);
|
|
1406
|
+
return Number.isNaN(n) ? 0 : n;
|
|
1407
|
+
});
|
|
1408
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
1409
|
+
}
|
|
1410
|
+
function isBunVersionOk(version) {
|
|
1411
|
+
const got = parseSemver(version);
|
|
1412
|
+
for (let i = 0;i < 3; i++) {
|
|
1413
|
+
const g = got[i] ?? 0;
|
|
1414
|
+
const m = MIN_BUN[i] ?? 0;
|
|
1415
|
+
if (g > m)
|
|
1416
|
+
return true;
|
|
1417
|
+
if (g < m)
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
return true;
|
|
1421
|
+
}
|
|
1422
|
+
var MIN_BUN_STR = MIN_BUN.join(".");
|
|
1423
|
+
function runChecks(cwd) {
|
|
1424
|
+
const results = [];
|
|
1425
|
+
const bunVersion = Bun.version;
|
|
1426
|
+
if (isBunVersionOk(bunVersion)) {
|
|
1427
|
+
results.push({
|
|
1428
|
+
name: "bun-version",
|
|
1429
|
+
status: "pass",
|
|
1430
|
+
message: `Bun ${bunVersion} (>= ${MIN_BUN_STR} required)`
|
|
1431
|
+
});
|
|
1432
|
+
} else {
|
|
1433
|
+
results.push({
|
|
1434
|
+
name: "bun-version",
|
|
1435
|
+
status: "fail",
|
|
1436
|
+
message: `Bun version ${bunVersion} below required >= ${MIN_BUN_STR}`,
|
|
1437
|
+
why: "saacms uses bun:test + Bun.file in core paths.",
|
|
1438
|
+
fix: "bun upgrade",
|
|
1439
|
+
learn: "https://bun.sh/docs"
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
const pkgState = loadPackageJson(cwd);
|
|
1443
|
+
const pkg = pkgState.kind === "ok" ? pkgState.pkg : null;
|
|
1444
|
+
if (pkgState.kind === "ok") {
|
|
1445
|
+
results.push({
|
|
1446
|
+
name: "package-json",
|
|
1447
|
+
status: "pass",
|
|
1448
|
+
message: `package.json found at ${join3(cwd, "package.json")}`
|
|
1449
|
+
});
|
|
1450
|
+
} else {
|
|
1451
|
+
results.push({
|
|
1452
|
+
name: "package-json",
|
|
1453
|
+
status: "fail",
|
|
1454
|
+
message: pkgState.kind === "invalid" ? `package.json at ${cwd} is not valid JSON` : `no package.json at ${cwd}`,
|
|
1455
|
+
why: "saacms scaffolding expects a host project.",
|
|
1456
|
+
fix: "bun init in this directory, then 'saacms init'.",
|
|
1457
|
+
learn: "https://saacms.dev/init"
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
const host = detectHost3(cwd, pkg);
|
|
1461
|
+
if (host === "astro") {
|
|
1462
|
+
results.push({
|
|
1463
|
+
name: "host-adapter",
|
|
1464
|
+
status: "pass",
|
|
1465
|
+
message: "host: astro (supported in v1 alpha)"
|
|
1466
|
+
});
|
|
1467
|
+
} else if (host === "unknown") {
|
|
1468
|
+
results.push({
|
|
1469
|
+
name: "host-adapter",
|
|
1470
|
+
status: "fail",
|
|
1471
|
+
message: "no host framework detected",
|
|
1472
|
+
why: "saacms needs to know which host adapter to wire.",
|
|
1473
|
+
fix: "add astro to package.json (npm i astro), then 'saacms init'.",
|
|
1474
|
+
learn: "https://saacms.dev/hosts"
|
|
1475
|
+
});
|
|
1476
|
+
} else {
|
|
1477
|
+
results.push({
|
|
1478
|
+
name: "host-adapter",
|
|
1479
|
+
status: "info",
|
|
1480
|
+
message: `host: ${host} (deferred to v1.x — only @saacms/host-astro ships in v1 alpha)`
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
if (existsSync6(join3(cwd, "saacms.config.ts"))) {
|
|
1484
|
+
results.push({
|
|
1485
|
+
name: "saacms-config",
|
|
1486
|
+
status: "pass",
|
|
1487
|
+
message: "saacms.config.ts found"
|
|
1488
|
+
});
|
|
1489
|
+
} else {
|
|
1490
|
+
results.push({
|
|
1491
|
+
name: "saacms-config",
|
|
1492
|
+
status: "fail",
|
|
1493
|
+
message: `no saacms.config.ts at ${cwd}`,
|
|
1494
|
+
why: "the config registers Collections, Blocks, host adapter.",
|
|
1495
|
+
fix: "'saacms init' to scaffold one.",
|
|
1496
|
+
learn: "https://saacms.dev/config"
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
const requiredPeers = [
|
|
1500
|
+
{
|
|
1501
|
+
pkg: "@saacms/core",
|
|
1502
|
+
why: "required to import defineConfig in saacms.config.ts"
|
|
1503
|
+
}
|
|
1504
|
+
];
|
|
1505
|
+
if (host === "astro") {
|
|
1506
|
+
requiredPeers.push({
|
|
1507
|
+
pkg: "@saacms/host-astro",
|
|
1508
|
+
why: "required to wire the Astro host adapter in saacms.config.ts"
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
const missingPeers = requiredPeers.filter((p) => !hasDep3(pkg, p.pkg));
|
|
1512
|
+
if (missingPeers.length === 0) {
|
|
1513
|
+
results.push({
|
|
1514
|
+
name: "peer-deps",
|
|
1515
|
+
status: "pass",
|
|
1516
|
+
message: `peer deps installed: ${requiredPeers.map((p) => p.pkg).join(", ")}`
|
|
1517
|
+
});
|
|
1518
|
+
} else {
|
|
1519
|
+
for (const m of missingPeers) {
|
|
1520
|
+
results.push({
|
|
1521
|
+
name: "peer-deps",
|
|
1522
|
+
status: "fail",
|
|
1523
|
+
message: `missing peer dependency '${m.pkg}'`,
|
|
1524
|
+
why: m.why,
|
|
1525
|
+
fix: `bun add ${m.pkg}`,
|
|
1526
|
+
learn: "https://saacms.dev/peer-deps"
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
const storageAdapters = ["@saacms/storage-d1", "@saacms/storage-r2"].filter((s) => hasDep3(pkg, s));
|
|
1531
|
+
if (storageAdapters.length > 0) {
|
|
1532
|
+
results.push({
|
|
1533
|
+
name: "storage-adapter",
|
|
1534
|
+
status: "pass",
|
|
1535
|
+
message: `storage adapter installed: ${storageAdapters.join(", ")}`
|
|
1536
|
+
});
|
|
1537
|
+
} else {
|
|
1538
|
+
results.push({
|
|
1539
|
+
name: "storage-adapter",
|
|
1540
|
+
status: "warn",
|
|
1541
|
+
message: "no storage adapter installed",
|
|
1542
|
+
why: "Collections need row + (optionally) media storage.",
|
|
1543
|
+
fix: "'bun add @saacms/storage-d1 @saacms/storage-r2'",
|
|
1544
|
+
learn: "https://saacms.dev/storage"
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
if (existsSync6(join3(cwd, "tsconfig.json"))) {
|
|
1548
|
+
results.push({
|
|
1549
|
+
name: "tsconfig",
|
|
1550
|
+
status: "pass",
|
|
1551
|
+
message: "tsconfig.json found"
|
|
1552
|
+
});
|
|
1553
|
+
} else {
|
|
1554
|
+
results.push({
|
|
1555
|
+
name: "tsconfig",
|
|
1556
|
+
status: "warn",
|
|
1557
|
+
message: "no tsconfig.json",
|
|
1558
|
+
why: "saacms.config.ts uses TS imports.",
|
|
1559
|
+
fix: "'bunx astro init --tsconfig' or copy tsconfig from a fresh Astro project.",
|
|
1560
|
+
learn: "https://saacms.dev/tsconfig"
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
return results;
|
|
1564
|
+
}
|
|
1565
|
+
function summarize(results) {
|
|
1566
|
+
const summary = { pass: 0, info: 0, warn: 0, fail: 0 };
|
|
1567
|
+
for (const r of results)
|
|
1568
|
+
summary[r.status]++;
|
|
1569
|
+
return summary;
|
|
1570
|
+
}
|
|
1571
|
+
function printHuman(cwd, results, summary) {
|
|
1572
|
+
console.log(`${LOG_PREFIX4} checking ${cwd}`);
|
|
1573
|
+
const nameWidth = Math.max(...results.map((r) => r.name.length));
|
|
1574
|
+
for (const r of results) {
|
|
1575
|
+
const icon = STATUS_ICON[r.status];
|
|
1576
|
+
console.log(` ${icon} ${r.name.padEnd(nameWidth)} ${r.message}`);
|
|
1577
|
+
const indent = " ".repeat(2 + 1 + 1 + nameWidth + 2);
|
|
1578
|
+
if (r.why !== undefined)
|
|
1579
|
+
console.log(`${indent}Why: ${r.why}`);
|
|
1580
|
+
if (r.fix !== undefined)
|
|
1581
|
+
console.log(`${indent}Fix: ${r.fix}`);
|
|
1582
|
+
if (r.learn !== undefined)
|
|
1583
|
+
console.log(`${indent}Learn: ${r.learn}`);
|
|
1584
|
+
}
|
|
1585
|
+
console.log(`${LOG_PREFIX4} ${summary.pass} pass, ${summary.info} info, ${summary.warn} warn, ${summary.fail} fail`);
|
|
1586
|
+
}
|
|
1587
|
+
var doctorCommand = defineCommand7({
|
|
1588
|
+
meta: {
|
|
1589
|
+
name: "doctor",
|
|
1590
|
+
description: "Report environment health: bun version, package.json, host adapter, config, peer deps, storage adapter, tsconfig."
|
|
1591
|
+
},
|
|
1592
|
+
args: {
|
|
1593
|
+
cwd: {
|
|
1594
|
+
type: "string",
|
|
1595
|
+
description: "Working directory to inspect. Defaults to process.cwd().",
|
|
1596
|
+
required: false
|
|
1597
|
+
},
|
|
1598
|
+
json: {
|
|
1599
|
+
type: "boolean",
|
|
1600
|
+
description: "Emit machine-readable JSON instead of the human-readable report.",
|
|
1601
|
+
default: false
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
run({ args }) {
|
|
1605
|
+
const cwd = resolve5(args.cwd ?? process.cwd());
|
|
1606
|
+
const results = runChecks(cwd);
|
|
1607
|
+
const summary = summarize(results);
|
|
1608
|
+
if (args.json) {
|
|
1609
|
+
console.log(JSON.stringify({ results, summary }, null, 2));
|
|
1610
|
+
} else {
|
|
1611
|
+
printHuman(cwd, results, summary);
|
|
1612
|
+
}
|
|
1613
|
+
process.exitCode = summary.fail > 0 ? 1 : 0;
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// src/main.ts
|
|
1618
|
+
var main = defineCommand8({
|
|
1619
|
+
meta: {
|
|
1620
|
+
name: "saacms",
|
|
1621
|
+
version: "0.1.0",
|
|
1622
|
+
description: "saacms \u2014 framework-agnostic content compiler. v1 alpha: Astro + Cloudflare Pages + D1 + R2."
|
|
1623
|
+
},
|
|
1624
|
+
subCommands: {
|
|
1625
|
+
init: initCommand,
|
|
1626
|
+
codegen: codegenCommand,
|
|
1627
|
+
dev: devCommand,
|
|
1628
|
+
migrate: migrateCommand,
|
|
1629
|
+
publish: publishCommand,
|
|
1630
|
+
logs: logsCommand,
|
|
1631
|
+
doctor: doctorCommand
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
runMain(main);
|