create-astroid 0.10.0 → 0.11.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/index.mjs +403 -25
- package/into.mjs +203 -0
- package/package.json +5 -4
- package/template/_app/README.md +62 -0
- package/template/_app/_env.example +12 -0
- package/template/_app/astro.config.mjs +37 -0
- package/template/_app/src/env.d.ts +33 -0
- package/template/_app/src/layouts/App.astro +49 -0
- package/template/_app/src/pages/api/v1/index.ts +22 -0
- package/template/_app/src/pages/index.astro +18 -0
- package/template/_app/src/pages/sitemap.xml.ts +24 -0
- package/template/src/layouts/Site.astro +10 -0
package/index.mjs
CHANGED
|
@@ -13,8 +13,17 @@
|
|
|
13
13
|
// wrangler) fills them in. The generators are the SAME ones `astroid generate`
|
|
14
14
|
// uses, so a fresh project is already in sync.
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
realpathSync,
|
|
23
|
+
statSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
18
27
|
import { createInterface } from "node:readline/promises";
|
|
19
28
|
import { fileURLToPath } from "node:url";
|
|
20
29
|
import {
|
|
@@ -31,8 +40,41 @@ import {
|
|
|
31
40
|
generateAstroidSecretsEnv,
|
|
32
41
|
generateAstroidWrangler,
|
|
33
42
|
} from "astroidjs";
|
|
43
|
+
import {
|
|
44
|
+
addWorkspacePackage,
|
|
45
|
+
INTO_REPOSITORY_FILES,
|
|
46
|
+
intoPathProblem,
|
|
47
|
+
intoRootScripts,
|
|
48
|
+
intoScriptName,
|
|
49
|
+
mergeRootScripts,
|
|
50
|
+
workersBuildsSettings,
|
|
51
|
+
} from "./into.mjs";
|
|
34
52
|
import { toolkitRanges } from "./toolkit-ranges.mjs";
|
|
35
53
|
|
|
54
|
+
/** The repository root holding `cwd`, or null outside a git checkout. */
|
|
55
|
+
function gitRoot(cwd) {
|
|
56
|
+
try {
|
|
57
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
58
|
+
cwd,
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
+
}).trim();
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether git ignores `path` (relative to `root`); null when git can't say. */
|
|
68
|
+
function gitIgnores(root, path) {
|
|
69
|
+
try {
|
|
70
|
+
execFileSync("git", ["check-ignore", "-q", path], { cwd: root, stdio: "ignore" });
|
|
71
|
+
return true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Exit 1 is "not ignored"; anything else is git failing to answer.
|
|
74
|
+
return error?.status === 1 ? false : null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
36
78
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "template");
|
|
37
79
|
|
|
38
80
|
// Files (and dirs) whose leading `_` is stripped on copy (npm strips real
|
|
@@ -45,6 +87,24 @@ const DOTFILE_RENAMES = {
|
|
|
45
87
|
_github: ".github",
|
|
46
88
|
};
|
|
47
89
|
|
|
90
|
+
// The editor-free app shape (`--app`, `editor: false`). It starts from the same
|
|
91
|
+
// template, leaves out the editor's files, and lays `template/_app/` over the
|
|
92
|
+
// rest, so the two shapes share every file that doesn't depend on an editor.
|
|
93
|
+
const APP_OVERLAY = "_app";
|
|
94
|
+
const APP_SKIP = new Set([
|
|
95
|
+
"migrations/0000_content.sql",
|
|
96
|
+
"scripts/seed-editors.mjs",
|
|
97
|
+
"src/auth.ts",
|
|
98
|
+
"src/components/Hero.astro",
|
|
99
|
+
"src/components/LouiseEdit.astro",
|
|
100
|
+
"src/layouts/Site.astro",
|
|
101
|
+
"src/lib/pages.ts",
|
|
102
|
+
"src/pages/[...slug].astro",
|
|
103
|
+
"src/pages/api/auth/[...all].ts",
|
|
104
|
+
"src/pages/contact.astro",
|
|
105
|
+
"src/pages/login.astro",
|
|
106
|
+
]);
|
|
107
|
+
|
|
48
108
|
// Archetype → default editable home sections. Imported from astroidjs rather
|
|
49
109
|
// than duplicated here: as a literal in this file it could name a section that
|
|
50
110
|
// doesn't exist and nothing would say so (it did—`marquee`, `featured`,
|
|
@@ -99,15 +159,21 @@ async function prompt(question, fallback) {
|
|
|
99
159
|
}
|
|
100
160
|
|
|
101
161
|
// --- scaffold --------------------------------------------------------------
|
|
102
|
-
|
|
103
|
-
|
|
162
|
+
// `skip` holds template-relative POSIX paths to leave out. Directories are made
|
|
163
|
+
// only when a file lands in them, so skipping a directory's every file leaves
|
|
164
|
+
// no empty directory behind.
|
|
165
|
+
function copyTemplate(srcDir, destDir, tokens, { skip = new Set(), rel = "" } = {}) {
|
|
104
166
|
for (const entry of readdirSync(srcDir)) {
|
|
167
|
+
// The app overlay is copied on its own, over the rest, and only for `--app`.
|
|
168
|
+
if (rel === "" && entry === APP_OVERLAY) continue;
|
|
105
169
|
const src = join(srcDir, entry);
|
|
170
|
+
const path = rel ? `${rel}/${entry}` : entry;
|
|
106
171
|
const renamed = DOTFILE_RENAMES[entry] ?? entry;
|
|
107
172
|
const dest = join(destDir, renamed);
|
|
108
173
|
if (statSync(src).isDirectory()) {
|
|
109
|
-
copyTemplate(src, dest, tokens);
|
|
110
|
-
} else {
|
|
174
|
+
copyTemplate(src, dest, tokens, { skip, rel: path });
|
|
175
|
+
} else if (!skip.has(path)) {
|
|
176
|
+
mkdirSync(destDir, { recursive: true });
|
|
111
177
|
const raw = readFileSync(src, "utf8");
|
|
112
178
|
writeFileSync(dest, applyTokens(raw, tokens));
|
|
113
179
|
}
|
|
@@ -128,12 +194,15 @@ function astroidConfigSource(config) {
|
|
|
128
194
|
"export default defineAstroid({",
|
|
129
195
|
` key: ${JSON.stringify(config.key)},`,
|
|
130
196
|
` archetype: ${JSON.stringify(config.archetype)},`,
|
|
197
|
+
// Must be emitted: every generator reads the shape from THIS file, so a
|
|
198
|
+
// config without it would regenerate an editor this app has no seam for.
|
|
199
|
+
...(config.editor === false ? [" editor: false,"] : []),
|
|
131
200
|
...(config.hosts?.length ? [` hosts: ${JSON.stringify(config.hosts)},`] : []),
|
|
132
201
|
" theme: {",
|
|
133
202
|
` name: ${JSON.stringify(config.theme.name)},`,
|
|
134
203
|
` colors: { brand: ${JSON.stringify(config.theme.colors.brand)} },`,
|
|
135
204
|
" },",
|
|
136
|
-
` sections: ${JSON.stringify(config.sections)}
|
|
205
|
+
...(config.sections ? [` sections: ${JSON.stringify(config.sections)},`] : []),
|
|
137
206
|
// `square` must be emitted too: `astroid doctor` derives the required
|
|
138
207
|
// secrets from THIS file, so a multi-location project whose config lost the
|
|
139
208
|
// option would be told it is missing a SQUARE_LOCATION_ID it must not have.
|
|
@@ -169,6 +238,15 @@ function astroidConfigSource(config) {
|
|
|
169
238
|
" },",
|
|
170
239
|
]
|
|
171
240
|
: []),
|
|
241
|
+
// Must be emitted: the scaffold's layout reads `credit` from THIS file at
|
|
242
|
+
// render time, so a config without it renders no footer.
|
|
243
|
+
...(config.credit
|
|
244
|
+
? [
|
|
245
|
+
` credit: { name: ${JSON.stringify(config.credit.name)}, href: ${JSON.stringify(
|
|
246
|
+
config.credit.href,
|
|
247
|
+
)} },`,
|
|
248
|
+
]
|
|
249
|
+
: []),
|
|
172
250
|
' deploy: { platform: "cloudflare" },',
|
|
173
251
|
"});",
|
|
174
252
|
"",
|
|
@@ -206,11 +284,24 @@ Options:
|
|
|
206
284
|
with presence, field sync, and a rich-text soft-lock
|
|
207
285
|
--portal Add a customer/member portal: a second, isolated auth
|
|
208
286
|
instance plus role-gated routes
|
|
287
|
+
--into <path> Scaffold one app into an existing repository at <path>
|
|
288
|
+
(for example, workers/order), beside the app already
|
|
289
|
+
there: adds it to the root pnpm-workspace.yaml and adds
|
|
290
|
+
namespaced root scripts, and leaves the rest alone
|
|
291
|
+
--app Scaffold an app with no pages to edit (editor: false):
|
|
292
|
+
no editor, sign-in, or content tables, and a versioned
|
|
293
|
+
JSON API under /api/v1. Its settings stay in the editor
|
|
294
|
+
of a site that has one
|
|
295
|
+
--credit-name <name> Credit who built the site in the footer ("Site by <name>")
|
|
296
|
+
--credit-href <url> Where the credit links; needs --credit-name, and the
|
|
297
|
+
other way round
|
|
209
298
|
-h, --help Show this help
|
|
210
299
|
-v, --version Show the create-astroid version
|
|
211
300
|
|
|
212
301
|
Anything not passed as a flag is prompted for; in a non-TTY every prompt takes
|
|
213
302
|
its default, so the command is CI-safe. The target directory must be empty.
|
|
303
|
+
With --app there is no archetype prompt; --archetype still sets the business
|
|
304
|
+
type in structured data.
|
|
214
305
|
`;
|
|
215
306
|
|
|
216
307
|
async function main() {
|
|
@@ -231,15 +322,45 @@ async function main() {
|
|
|
231
322
|
return;
|
|
232
323
|
}
|
|
233
324
|
|
|
234
|
-
|
|
325
|
+
// `--into <path>`: one app into a repository that already holds one. The
|
|
326
|
+
// path is the app's directory, relative to where this runs, and has to land
|
|
327
|
+
// inside the repository, whose root gets the workspace entry and scripts.
|
|
328
|
+
const intoArg = flags.into;
|
|
329
|
+
if (intoArg !== undefined && (typeof intoArg !== "string" || positionals[0] || flags.dir)) {
|
|
330
|
+
process.stderr.write(
|
|
331
|
+
typeof intoArg !== "string"
|
|
332
|
+
? "create-astroid: --into needs a path, for example --into workers/order\n"
|
|
333
|
+
: "create-astroid: --into names the directory, so it can't go with --dir or a positional one\n",
|
|
334
|
+
);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
const into = typeof intoArg === "string";
|
|
338
|
+
// Real paths on both sides: git reports the root through symlinks (macOS's
|
|
339
|
+
// /tmp is /private/tmp), and a relative path across the two would climb out.
|
|
340
|
+
const cwd = realpathSync(process.cwd());
|
|
341
|
+
const repoRoot = into ? (gitRoot(cwd) ?? cwd) : null;
|
|
342
|
+
const intoPath = into ? relative(repoRoot, resolve(cwd, intoArg)).split(sep).join("/") : null;
|
|
343
|
+
if (into) {
|
|
344
|
+
const problem = intoPathProblem(intoPath);
|
|
345
|
+
if (problem) {
|
|
346
|
+
process.stderr.write(`create-astroid: ${problem}\n`);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const dirArg = into ? intoArg : (positionals[0] ?? flags.dir);
|
|
235
352
|
const rawName = flags.name || (dirArg ? basename(resolve(dirArg)) : undefined);
|
|
236
353
|
const name = await prompt("Brand / site name", rawName || "My Astroid Site");
|
|
237
354
|
const key = slugify(
|
|
238
355
|
flags.key || (await prompt("Project key (slug)", slugify(name) || "my-site")),
|
|
239
356
|
);
|
|
240
357
|
const dir = resolve(dirArg || (await prompt("Directory", key)) || key);
|
|
358
|
+
// An app with no pages to edit (`editor: false`). Its archetype chooses no
|
|
359
|
+
// sections, so it isn't prompted for; it only sets the structured-data type.
|
|
360
|
+
const app = flags.app === true || flags.app === "true";
|
|
241
361
|
const archetypeRaw = (
|
|
242
|
-
flags.archetype ||
|
|
362
|
+
flags.archetype ||
|
|
363
|
+
(app ? "marketing" : await prompt(`Archetype (${ARCHETYPES.join("/")})`, "marketing"))
|
|
243
364
|
).toLowerCase();
|
|
244
365
|
const archetype = ARCHETYPES.includes(archetypeRaw) ? archetypeRaw : "marketing";
|
|
245
366
|
const color = flags.color || (await prompt("Brand color (hex)", "#5b4bff"));
|
|
@@ -247,7 +368,9 @@ async function main() {
|
|
|
247
368
|
// The customer PORTAL is opt-in via --portal, but a storefront IMPLIES one—a
|
|
248
369
|
// shop has customers who sign in, reorder, and track orders—so enable it there
|
|
249
370
|
// by default. (Commerce below stays opt-in: infra a marketing site shouldn't carry.)
|
|
250
|
-
|
|
371
|
+
// An app gets one only when asked: its customers may sign in on the site.
|
|
372
|
+
const portal =
|
|
373
|
+
flags.portal === true || flags.portal === "true" || (archetype === "storefront" && !app);
|
|
251
374
|
// The map module is opt-in and pulls real weight (maplibre-gl is ~1 MB), so
|
|
252
375
|
// it is never on by default.
|
|
253
376
|
const map = flags.map === true || flags.map === "true";
|
|
@@ -289,18 +412,70 @@ async function main() {
|
|
|
289
412
|
process.exit(1);
|
|
290
413
|
}
|
|
291
414
|
|
|
415
|
+
// A pair or nothing: a credit with no link, or a link with nothing to show,
|
|
416
|
+
// is a half-typed flag rather than a choice.
|
|
417
|
+
const creditName = typeof flags["credit-name"] === "string" ? flags["credit-name"] : undefined;
|
|
418
|
+
const creditHref = typeof flags["credit-href"] === "string" ? flags["credit-href"] : undefined;
|
|
419
|
+
if (
|
|
420
|
+
(flags["credit-name"] !== undefined || flags["credit-href"] !== undefined) &&
|
|
421
|
+
!(creditName && creditHref)
|
|
422
|
+
) {
|
|
423
|
+
process.stderr.write("create-astroid: --credit-name and --credit-href go together\n");
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Refused here with the flag's name, rather than by `defineAstroid` with a
|
|
428
|
+
// stack trace: live editing needs an editor to edit with.
|
|
429
|
+
if (app && realtime) {
|
|
430
|
+
process.stderr.write("create-astroid: --realtime needs an editor, so it can't go with --app\n");
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
|
|
292
434
|
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
293
435
|
process.stderr.write(`create-astroid: target directory is not empty: ${dir}\n`);
|
|
294
436
|
process.exit(1);
|
|
295
437
|
}
|
|
296
438
|
|
|
439
|
+
// Every root edit `--into` makes, worked out before anything is written, so
|
|
440
|
+
// a root file it can't edit stops the scaffold rather than leaving half of it.
|
|
441
|
+
const rootEdits = [];
|
|
442
|
+
if (into) {
|
|
443
|
+
const workspacePath = join(repoRoot, "pnpm-workspace.yaml");
|
|
444
|
+
if (existsSync(workspacePath)) {
|
|
445
|
+
const current = readFileSync(workspacePath, "utf8");
|
|
446
|
+
const next = addWorkspacePackage(current, intoPath);
|
|
447
|
+
if (next === null) {
|
|
448
|
+
process.stderr.write(
|
|
449
|
+
`create-astroid: can't add ${intoPath} to pnpm-workspace.yaml's \`packages\` safely. ` +
|
|
450
|
+
"Add it by hand, then run this again.\n",
|
|
451
|
+
);
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
if (next !== current) {
|
|
455
|
+
rootEdits.push({
|
|
456
|
+
path: workspacePath,
|
|
457
|
+
contents: next,
|
|
458
|
+
what: "pnpm-workspace.yaml (added the app)",
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
// The app's install runs from the root, under the root's build approvals.
|
|
462
|
+
if (!/workerd/.test(current)) {
|
|
463
|
+
rootEdits.push({
|
|
464
|
+
note:
|
|
465
|
+
"pnpm-workspace.yaml approves no `workerd` build, so `pnpm install` may refuse it. " +
|
|
466
|
+
"Add `allowBuilds: { esbuild: true, workerd: true }`.",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
297
472
|
// Validate + normalize through the real config surface (throws on a bad shape).
|
|
298
473
|
const config = defineAstroid({
|
|
299
474
|
key,
|
|
300
475
|
archetype,
|
|
301
476
|
...(host ? { hosts: [host] } : {}),
|
|
302
477
|
theme: { name, colors: { brand: color } },
|
|
303
|
-
sections: ARCHETYPE_SECTIONS[archetype],
|
|
478
|
+
...(app ? { editor: false } : { sections: ARCHETYPE_SECTIONS[archetype] }),
|
|
304
479
|
...(commerce
|
|
305
480
|
? {
|
|
306
481
|
commerce: {
|
|
@@ -317,6 +492,7 @@ async function main() {
|
|
|
317
492
|
// : {})` spreads would let the later one overwrite the earlier, silently
|
|
318
493
|
// dropping a module whenever both were passed.
|
|
319
494
|
...(modules.length > 0 ? { modules } : {}),
|
|
495
|
+
...(creditName && creditHref ? { credit: { name: creditName, href: creditHref } } : {}),
|
|
320
496
|
deploy: { platform: "cloudflare" },
|
|
321
497
|
});
|
|
322
498
|
|
|
@@ -328,6 +504,21 @@ async function main() {
|
|
|
328
504
|
const realtimeEnv = generateAstroidRealtimeEnv(config);
|
|
329
505
|
// The Square Web Payments public vars, or nothing.
|
|
330
506
|
const checkoutEnv = generateAstroidCheckoutEnv(config);
|
|
507
|
+
// An app's portal is the only thing in it that signs anyone in or sends mail,
|
|
508
|
+
// so the session secret, the mail binding, and the sender come with it. The
|
|
509
|
+
// editor shape's env.d.ts and .env.example declare these for every project.
|
|
510
|
+
const appPortalEnv =
|
|
511
|
+
app && portal
|
|
512
|
+
? [
|
|
513
|
+
" /** Cloudflare Email Sending: the portal's password-reset mail. */",
|
|
514
|
+
' EMAIL: import("louise-toolkit/email").EmailSender;',
|
|
515
|
+
" /** Signs the portal's Better Auth sessions (`wrangler secret put SESSION_SECRET`). */",
|
|
516
|
+
" SESSION_SECRET: string;",
|
|
517
|
+
" /** `from` address for the portal's mail. */",
|
|
518
|
+
" MAIL_FROM: string;",
|
|
519
|
+
].join("\n")
|
|
520
|
+
: "";
|
|
521
|
+
const envMembers = [appPortalEnv, envBindings, realtimeEnv, checkoutEnv].filter(Boolean);
|
|
331
522
|
const tokens = {
|
|
332
523
|
KEY: key,
|
|
333
524
|
BRAND_NAME: name,
|
|
@@ -337,9 +528,7 @@ async function main() {
|
|
|
337
528
|
// Extra CloudflareEnv members the queue pipeline needs, or nothing. A
|
|
338
529
|
// declaration is a promise—a marketing site must not claim a binding its
|
|
339
530
|
// wrangler.jsonc never creates.
|
|
340
|
-
ASTROID_ENV_BINDINGS:
|
|
341
|
-
? `\n${[envBindings, realtimeEnv, checkoutEnv].filter(Boolean).join("\n")}`
|
|
342
|
-
: "",
|
|
531
|
+
ASTROID_ENV_BINDINGS: envMembers.length > 0 ? `\n${envMembers.join("\n")}` : "",
|
|
343
532
|
// The portal session on App.Locals, or nothing—a project that types a
|
|
344
533
|
// local it never sets invites a null-check nobody needs.
|
|
345
534
|
ASTROID_PORTAL_LOCALS: portalLocals ? `\n${portalLocals}` : "",
|
|
@@ -348,10 +537,84 @@ async function main() {
|
|
|
348
537
|
// module takes its dormant path deliberately rather than tripping over
|
|
349
538
|
// an undefined binding. Empty for a project with no credentialed module.
|
|
350
539
|
ASTROID_MODULE_SECRETS: generateAstroidSecretsEnv(config),
|
|
540
|
+
// The app shape's .env.example: the portal's secrets, or nothing.
|
|
541
|
+
ASTROID_APP_SECRETS:
|
|
542
|
+
app && portal
|
|
543
|
+
? [
|
|
544
|
+
"",
|
|
545
|
+
"# --- portal ---------------------------------------------------------------",
|
|
546
|
+
"#",
|
|
547
|
+
"# Signs the portal's Better Auth sessions. Generate: `openssl rand -base64 32`.",
|
|
548
|
+
"# Empty is fine under `pnpm dev`, which serves on localhost.",
|
|
549
|
+
"SESSION_SECRET=",
|
|
550
|
+
"",
|
|
551
|
+
"# `from` address for the portal's password-reset mail.",
|
|
552
|
+
`MAIL_FROM=no-reply@${key}.example`,
|
|
553
|
+
].join("\n")
|
|
554
|
+
: "",
|
|
351
555
|
};
|
|
352
556
|
|
|
353
557
|
// 1. The static floor (Astro app, auth seam, config files) with tokens filled.
|
|
354
|
-
|
|
558
|
+
// An app leaves out the editor's files and lays its own over the rest.
|
|
559
|
+
// `--into` leaves out the repository's files too; see 1a.
|
|
560
|
+
const skip = new Set([...(app ? APP_SKIP : []), ...(into ? INTO_REPOSITORY_FILES : [])]);
|
|
561
|
+
copyTemplate(TEMPLATE_DIR, dir, tokens, { skip });
|
|
562
|
+
if (app) copyTemplate(join(TEMPLATE_DIR, APP_OVERLAY), dir, tokens);
|
|
563
|
+
|
|
564
|
+
// 1a. `--into`: the repository's files. Each is the root's, so it's written
|
|
565
|
+
// there only when the root has none, and an existing one is left alone
|
|
566
|
+
// apart from the edits worked out above.
|
|
567
|
+
const intoNotes = [];
|
|
568
|
+
const intoWrote = [];
|
|
569
|
+
if (into) {
|
|
570
|
+
const fromTemplate = (rel) =>
|
|
571
|
+
applyTokens(readFileSync(join(TEMPLATE_DIR, rel), "utf8"), tokens);
|
|
572
|
+
for (const edit of rootEdits) {
|
|
573
|
+
if (edit.note) intoNotes.push(edit.note);
|
|
574
|
+
else {
|
|
575
|
+
writeFileSync(edit.path, edit.contents);
|
|
576
|
+
intoWrote.push(edit.what);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (!existsSync(join(repoRoot, "pnpm-workspace.yaml"))) {
|
|
580
|
+
// The template's header says it isn't a workspace, which this one is.
|
|
581
|
+
const workspace = fromTemplate("pnpm-workspace.yaml").replace(
|
|
582
|
+
/^# Not a workspace — .*\n# .*\n/m,
|
|
583
|
+
"# The workspace for this repository's apps. pnpm reads its settings from\n" +
|
|
584
|
+
"# THIS file, and `overrides` in package.json is silently ignored.\n",
|
|
585
|
+
);
|
|
586
|
+
write(repoRoot, "pnpm-workspace.yaml", addWorkspacePackage(workspace, intoPath));
|
|
587
|
+
intoWrote.push("pnpm-workspace.yaml");
|
|
588
|
+
}
|
|
589
|
+
for (const doc of ["docs/ARCHITECTURE.md", "docs/DECISIONS.md", "docs/RUNBOOK.md"]) {
|
|
590
|
+
if (existsSync(join(repoRoot, doc))) continue;
|
|
591
|
+
write(repoRoot, doc, fromTemplate(doc));
|
|
592
|
+
intoWrote.push(doc);
|
|
593
|
+
}
|
|
594
|
+
// The root's .gitignore when there is one, as long as it keeps this app's
|
|
595
|
+
// secrets out. A pattern anchored to the root (`/.dev.vars`) wouldn't, so
|
|
596
|
+
// git is asked, and when it can't say, the app gets its own.
|
|
597
|
+
if (!existsSync(join(repoRoot, ".gitignore"))) {
|
|
598
|
+
write(repoRoot, ".gitignore", fromTemplate("_gitignore"));
|
|
599
|
+
intoWrote.push(".gitignore");
|
|
600
|
+
} else {
|
|
601
|
+
const ignored = gitIgnores(repoRoot, `${intoPath}/.dev.vars`);
|
|
602
|
+
if (ignored !== true) {
|
|
603
|
+
write(dir, ".gitignore", fromTemplate("_gitignore"));
|
|
604
|
+
intoNotes.push(
|
|
605
|
+
ignored === false
|
|
606
|
+
? `The root .gitignore doesn't ignore ${intoPath}/.dev.vars, so the app has its own .gitignore.`
|
|
607
|
+
: `git couldn't say whether ${intoPath}/.dev.vars is ignored, so the app has its own .gitignore.`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// The workflow runs the root app's checks from the root, so the second
|
|
612
|
+
// app's would be new steps in it; they're the repository's to add.
|
|
613
|
+
intoNotes.push(
|
|
614
|
+
`CI: add \`pnpm run doctor:${intoScriptName(intoPath)}\` and ` +
|
|
615
|
+
`\`pnpm run build:${intoScriptName(intoPath)}\` to the repository's workflow.`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
355
618
|
|
|
356
619
|
// 1b. Toolkit versions + module dependencies, merged into the copied package.json.
|
|
357
620
|
//
|
|
@@ -374,9 +637,38 @@ async function main() {
|
|
|
374
637
|
pkg.dependencies = Object.fromEntries(
|
|
375
638
|
Object.entries({ ...pkg.dependencies, ...extraDeps }).sort(([a], [b]) => a.localeCompare(b)),
|
|
376
639
|
);
|
|
640
|
+
// An app has no editors to seed, and no script to seed them with.
|
|
641
|
+
if (app) delete pkg.scripts["seed:editors"];
|
|
642
|
+
// The root's pnpm runs the workspace, so the app doesn't pin its own.
|
|
643
|
+
if (into) delete pkg.packageManager;
|
|
377
644
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
378
645
|
}
|
|
379
646
|
|
|
647
|
+
// 1c. `--into`: the root scripts that run this app from the root, added
|
|
648
|
+
// beside the existing ones and never over one. A root with no
|
|
649
|
+
// package.json gets a private one, pinning pnpm the way a scaffold does.
|
|
650
|
+
let intoScripts = { added: [], skipped: [] };
|
|
651
|
+
if (into) {
|
|
652
|
+
const rootPkgPath = join(repoRoot, "package.json");
|
|
653
|
+
const template = JSON.parse(readFileSync(join(TEMPLATE_DIR, "package.json"), "utf8"));
|
|
654
|
+
const rootPkg = existsSync(rootPkgPath)
|
|
655
|
+
? JSON.parse(readFileSync(rootPkgPath, "utf8"))
|
|
656
|
+
: {
|
|
657
|
+
name: slugify(basename(repoRoot)) || "workspace",
|
|
658
|
+
private: true,
|
|
659
|
+
packageManager: template.packageManager,
|
|
660
|
+
};
|
|
661
|
+
intoScripts = mergeRootScripts(
|
|
662
|
+
rootPkg.scripts ?? {},
|
|
663
|
+
intoRootScripts(intoScriptName(intoPath), intoPath),
|
|
664
|
+
);
|
|
665
|
+
rootPkg.scripts = intoScripts.scripts;
|
|
666
|
+
writeFileSync(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
|
|
667
|
+
for (const name of intoScripts.skipped) {
|
|
668
|
+
intoNotes.push(`The root already has a \`${name}\` script, so it was left as it was.`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
380
672
|
// 2. The typed config the generators + the app read.
|
|
381
673
|
write(dir, "astroid.config.ts", astroidConfigSource(config));
|
|
382
674
|
|
|
@@ -387,7 +679,8 @@ async function main() {
|
|
|
387
679
|
// 3a. The home page seed, built from the config's own `sections`. It used to be
|
|
388
680
|
// a fixed template file that seeded the marketing sections for every
|
|
389
681
|
// archetype, and token substitution can't escape a brand name for SQL.
|
|
390
|
-
|
|
682
|
+
// An app has no pages, so it seeds none.
|
|
683
|
+
if (!app) write(dir, "seed/home.seed.sql", generateAstroidHomeSeed(config));
|
|
391
684
|
|
|
392
685
|
// 3b. Every scaffold-once module file this config implies—the queue seam and
|
|
393
686
|
// webhook receivers, the portfolio gallery page, the PWA service worker +
|
|
@@ -420,13 +713,19 @@ async function main() {
|
|
|
420
713
|
// resolvable at scaffold time. If not, leave a stub + a one-liner to generate
|
|
421
714
|
// it after install (the project has `louise` on its path then).
|
|
422
715
|
let authMigrationOk = false;
|
|
716
|
+
// An app has no editor instance, so its portal's tables are its first.
|
|
717
|
+
const portalMigration = app
|
|
718
|
+
? "migrations/0001_portal_auth.sql"
|
|
719
|
+
: "migrations/0002_portal_auth.sql";
|
|
423
720
|
try {
|
|
424
721
|
const { generateAuthSchemaSql } = await import("louise-toolkit/auth");
|
|
425
722
|
// The EDITOR instance's tables—`louise_`-prefixed (the editor convention),
|
|
426
723
|
// leaving the unprefixed `user`/`session` names free for a second/portal
|
|
427
724
|
// instance. Must match the `tablePrefix` in src/auth.ts and the `louise_user`
|
|
428
725
|
// table the generated `editorsRoute` reads.
|
|
429
|
-
|
|
726
|
+
if (!app) {
|
|
727
|
+
write(dir, "migrations/0001_auth.sql", generateAuthSchemaSql({ tablePrefix: "louise_" }));
|
|
728
|
+
}
|
|
430
729
|
// The portal's own auth tables—a SECOND Better Auth instance sharing one D1
|
|
431
730
|
// but never a row, so a portal account can't sign into the studio and an
|
|
432
731
|
// editor doesn't appear in the portal. `customers: true` (email + password)
|
|
@@ -436,31 +735,110 @@ async function main() {
|
|
|
436
735
|
if (config.portal?.enabled) {
|
|
437
736
|
write(
|
|
438
737
|
dir,
|
|
439
|
-
|
|
738
|
+
portalMigration,
|
|
440
739
|
generateAuthSchemaSql({ customers: true, tablePrefix: config.portal.tablePrefix ?? "" }),
|
|
441
740
|
);
|
|
442
741
|
}
|
|
443
742
|
authMigrationOk = true;
|
|
444
743
|
} catch {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
744
|
+
if (!app) {
|
|
745
|
+
write(
|
|
746
|
+
dir,
|
|
747
|
+
"migrations/0001_auth.sql",
|
|
748
|
+
"-- Better Auth tables (editor, louise_ prefix) — generate after install:\n-- pnpm exec louise gen-auth-schema --table-prefix louise_ --out migrations/0001_auth.sql\n",
|
|
749
|
+
);
|
|
750
|
+
}
|
|
450
751
|
// Same stub for the portal's prefixed set. Without it a portal scaffold
|
|
451
752
|
// looks complete, builds, and fails on the first sign-in with a missing
|
|
452
753
|
// table—the one failure mode a stub exists to prevent.
|
|
453
754
|
if (config.portal?.enabled) {
|
|
454
755
|
write(
|
|
455
756
|
dir,
|
|
456
|
-
|
|
757
|
+
portalMigration,
|
|
457
758
|
"-- Portal Better Auth tables (customers, unprefixed) — generate after install:\n" +
|
|
458
|
-
|
|
759
|
+
`-- pnpm exec louise gen-auth-schema --out ${portalMigration}\n`,
|
|
459
760
|
);
|
|
460
761
|
}
|
|
461
762
|
}
|
|
462
763
|
|
|
764
|
+
// An app with no tables of its own still gets the directory, because
|
|
765
|
+
// `astroid ship` applies migrations from it on every deploy until the config
|
|
766
|
+
// says another app owns the database (`deploy.migrations: false`).
|
|
767
|
+
if (app && !existsSync(join(dir, "migrations"))) write(dir, "migrations/.gitkeep", "");
|
|
768
|
+
|
|
769
|
+
if (into) {
|
|
770
|
+
// Everything runs from the root: one install, one lockfile, and the root
|
|
771
|
+
// scripts added above.
|
|
772
|
+
const scriptName = intoScriptName(intoPath);
|
|
773
|
+
const inApp = `pnpm --dir ${intoPath}`;
|
|
774
|
+
const settings = workersBuildsSettings(intoPath);
|
|
775
|
+
const width = Math.max(...settings.map(([label]) => label.length));
|
|
776
|
+
process.stdout.write(
|
|
777
|
+
[
|
|
778
|
+
"",
|
|
779
|
+
`✓ Scaffolded ${name} → ${intoPath}${app ? " (an app with no editor)" : ""}`,
|
|
780
|
+
...(intoWrote.length ? [` at the repository root: ${intoWrote.join(", ")}`] : []),
|
|
781
|
+
...(intoScripts.added.length ? [` root scripts: ${intoScripts.added.join(", ")}`] : []),
|
|
782
|
+
"",
|
|
783
|
+
"Next steps, from the repository root:",
|
|
784
|
+
" pnpm install",
|
|
785
|
+
...(authMigrationOk
|
|
786
|
+
? []
|
|
787
|
+
: [
|
|
788
|
+
" # generate the Better Auth migration(s), as the stub files in its",
|
|
789
|
+
" # migrations/ directory say, before applying migrations",
|
|
790
|
+
]),
|
|
791
|
+
` ${inApp} exec astroid provision`,
|
|
792
|
+
...(app
|
|
793
|
+
? []
|
|
794
|
+
: [
|
|
795
|
+
` ${inApp} exec wrangler d1 migrations apply DB --remote`,
|
|
796
|
+
` ${inApp} exec wrangler d1 execute DB --remote --file seed/home.seed.sql`,
|
|
797
|
+
` OWNER_EMAIL=you@example.com ${inApp} run seed:editors`,
|
|
798
|
+
]),
|
|
799
|
+
` pnpm run dev:${scriptName}`,
|
|
800
|
+
` pnpm run doctor:${scriptName}`,
|
|
801
|
+
"",
|
|
802
|
+
"A second app is a second Workers Builds project. Create one with:",
|
|
803
|
+
...settings.map(([label, value]) => ` ${`${label}:`.padEnd(width + 2)}${value}`),
|
|
804
|
+
"Both projects deploy from the same release tag, through the one",
|
|
805
|
+
".github/workflows/release.yml `astroid generate` writes at the root.",
|
|
806
|
+
...(intoNotes.length ? ["", ...intoNotes.map((note) => `Note: ${note}`)] : []),
|
|
807
|
+
"",
|
|
808
|
+
].join("\n"),
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
463
813
|
const rel = dir === process.cwd() ? "." : basename(dir);
|
|
814
|
+
if (app) {
|
|
815
|
+
process.stdout.write(
|
|
816
|
+
[
|
|
817
|
+
"",
|
|
818
|
+
`✓ Scaffolded ${name} → ${rel} (an app with no editor)`,
|
|
819
|
+
"",
|
|
820
|
+
"Next steps:",
|
|
821
|
+
` cd ${rel}`,
|
|
822
|
+
" pnpm install",
|
|
823
|
+
...(authMigrationOk || !config.portal?.enabled
|
|
824
|
+
? []
|
|
825
|
+
: [
|
|
826
|
+
" # generate the portal's Better Auth migration:",
|
|
827
|
+
` pnpm exec louise gen-auth-schema --out ${portalMigration}`,
|
|
828
|
+
]),
|
|
829
|
+
" # create the Cloudflare resources wrangler.jsonc names, filling in their ids.",
|
|
830
|
+
" # To share another app's database instead, bind it by id and set",
|
|
831
|
+
" # `deploy: { migrations: false }` in astroid.config.ts.",
|
|
832
|
+
" pnpm exec astroid provision",
|
|
833
|
+
" # develop / ship:",
|
|
834
|
+
" pnpm dev # astroid dev (regenerates, then astro dev)",
|
|
835
|
+
" pnpm run doctor # validate config + bindings (`run` is required)",
|
|
836
|
+
" pnpm exec astroid ship production",
|
|
837
|
+
"",
|
|
838
|
+
].join("\n"),
|
|
839
|
+
);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
464
842
|
process.stdout.write(
|
|
465
843
|
[
|
|
466
844
|
"",
|
package/into.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// `create-astroid --into <path>`: one app scaffolded into a repository that
|
|
4
|
+
// already holds one. The pure half of it, a module of its own so the test suite
|
|
5
|
+
// can import it without running the scaffolder: which workspace globs cover a
|
|
6
|
+
// path, how to add one, the root scripts, and the Workers Builds settings.
|
|
7
|
+
//
|
|
8
|
+
// Every edit here is text, not a parse and re-serialize. These are files a
|
|
9
|
+
// person owns, with their comments and their formatting, and an edit that
|
|
10
|
+
// rewrote the whole file to add one line would bury that line in a diff nobody
|
|
11
|
+
// reads.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Template-relative paths that belong to the repository rather than an app.
|
|
15
|
+
* `--into` never writes them into the app; each is written at the root only
|
|
16
|
+
* when the root has none.
|
|
17
|
+
*/
|
|
18
|
+
export const INTO_REPOSITORY_FILES = [
|
|
19
|
+
"pnpm-workspace.yaml",
|
|
20
|
+
"_gitignore",
|
|
21
|
+
"_github/workflows/ci.yml",
|
|
22
|
+
"docs/ARCHITECTURE.md",
|
|
23
|
+
"docs/DECISIONS.md",
|
|
24
|
+
"docs/RUNBOOK.md",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Why a path can't be scaffolded into, or null when it can. The path goes into
|
|
29
|
+
* root scripts and a workspace glob unquoted, so it's held to characters that
|
|
30
|
+
* need no quoting in either.
|
|
31
|
+
*/
|
|
32
|
+
export function intoPathProblem(path) {
|
|
33
|
+
if (!path || path === ".")
|
|
34
|
+
return "--into needs a path inside the repository, such as workers/order";
|
|
35
|
+
if (path.startsWith("..") || path.startsWith("/")) {
|
|
36
|
+
return `--into path "${path}" is outside the repository`;
|
|
37
|
+
}
|
|
38
|
+
if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(path)) {
|
|
39
|
+
return `--into path "${path}" may use only letters, digits, ".", "_", "-", and "/"`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A workspace glob as a regular expression over a POSIX path. */
|
|
45
|
+
function globToRegExp(glob) {
|
|
46
|
+
const clean = glob.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
47
|
+
let source = "";
|
|
48
|
+
for (let i = 0; i < clean.length; i++) {
|
|
49
|
+
const c = clean[i];
|
|
50
|
+
if (c === "*" && clean[i + 1] === "*") {
|
|
51
|
+
source += ".*";
|
|
52
|
+
i++;
|
|
53
|
+
} else if (c === "*") source += "[^/]*";
|
|
54
|
+
else if (c === "?") source += "[^/]";
|
|
55
|
+
else source += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
}
|
|
57
|
+
return new RegExp(`^${source}$`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The `packages` list of a pnpm-workspace.yaml, or null when it has none. Reads
|
|
62
|
+
* the two shapes pnpm's own docs use, a block list and a flow list.
|
|
63
|
+
*/
|
|
64
|
+
export function workspacePackages(yaml) {
|
|
65
|
+
const lines = yaml.split("\n");
|
|
66
|
+
const at = lines.findIndex((line) => /^packages\s*:/.test(line));
|
|
67
|
+
if (at === -1) return null;
|
|
68
|
+
const unquote = (s) => s.trim().replace(/^["']|["']$/g, "");
|
|
69
|
+
const rest = lines[at]
|
|
70
|
+
.replace(/^packages\s*:/, "")
|
|
71
|
+
.replace(/\s+#.*$/, "")
|
|
72
|
+
.trim();
|
|
73
|
+
if (rest.startsWith("[")) {
|
|
74
|
+
return rest
|
|
75
|
+
.replace(/^\[|\]$/g, "")
|
|
76
|
+
.split(",")
|
|
77
|
+
.map(unquote)
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
const items = [];
|
|
81
|
+
for (const line of lines.slice(at + 1)) {
|
|
82
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
83
|
+
const item = line.match(/^\s+-\s*(.+?)\s*(#.*)?$/);
|
|
84
|
+
if (!item) break;
|
|
85
|
+
items.push(unquote(item[1]));
|
|
86
|
+
}
|
|
87
|
+
return items;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether a workspace's `packages` globs already include `path`. */
|
|
91
|
+
export function workspaceCovers(patterns, path) {
|
|
92
|
+
const matches = (glob) => globToRegExp(glob).test(path);
|
|
93
|
+
const included = patterns.filter((p) => !p.startsWith("!")).some(matches);
|
|
94
|
+
const excluded = patterns.filter((p) => p.startsWith("!")).some((p) => matches(p.slice(1)));
|
|
95
|
+
return included && !excluded;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The workspace file with `path` added to its `packages`, the same text when
|
|
100
|
+
* a glob already covers it, or null when the list is in a shape this can't
|
|
101
|
+
* edit safely (the caller then asks for it by hand).
|
|
102
|
+
*/
|
|
103
|
+
export function addWorkspacePackage(yaml, path) {
|
|
104
|
+
const patterns = workspacePackages(yaml);
|
|
105
|
+
if (patterns && workspaceCovers(patterns, path)) return yaml;
|
|
106
|
+
const lines = yaml.split("\n");
|
|
107
|
+
|
|
108
|
+
if (patterns === null) {
|
|
109
|
+
// No list yet. Insert one before the first top-level key, and before the
|
|
110
|
+
// comment that introduces that key, so the comment stays with it.
|
|
111
|
+
let at = lines.findIndex((line) => /^[A-Za-z]/.test(line));
|
|
112
|
+
if (at === -1) at = lines.length;
|
|
113
|
+
while (at > 0 && lines[at - 1].startsWith("#")) at--;
|
|
114
|
+
const block = [
|
|
115
|
+
"# The workspace. pnpm always includes the root project; each app",
|
|
116
|
+
"# `create-astroid --into` scaffolds is listed here.",
|
|
117
|
+
"packages:",
|
|
118
|
+
` - ${path}`,
|
|
119
|
+
"",
|
|
120
|
+
];
|
|
121
|
+
return [...lines.slice(0, at), ...block, ...lines.slice(at)].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const at = lines.findIndex((line) => /^packages\s*:/.test(line));
|
|
125
|
+
const rest = lines[at].replace(/^packages\s*:/, "").trim();
|
|
126
|
+
if (rest.startsWith("[")) {
|
|
127
|
+
// A flow list with no comment after it is one line to rewrite; anything
|
|
128
|
+
// fancier is left to a person.
|
|
129
|
+
if (!/^\[[^\]]*\]$/.test(rest)) return null;
|
|
130
|
+
const inner = rest.slice(1, -1).trim();
|
|
131
|
+
lines[at] = `packages: [${inner ? `${inner}, ` : ""}${JSON.stringify(path)}]`;
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
|
134
|
+
if (rest && !rest.startsWith("#")) return null;
|
|
135
|
+
|
|
136
|
+
// A block list: append after its last item, at its indentation.
|
|
137
|
+
let last = at;
|
|
138
|
+
let indent = " ";
|
|
139
|
+
for (let i = at + 1; i < lines.length; i++) {
|
|
140
|
+
if (/^\s*(#.*)?$/.test(lines[i])) continue;
|
|
141
|
+
const item = lines[i].match(/^(\s+)-/);
|
|
142
|
+
if (!item) break;
|
|
143
|
+
last = i;
|
|
144
|
+
indent = item[1];
|
|
145
|
+
}
|
|
146
|
+
lines.splice(last + 1, 0, `${indent}- ${path}`);
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The name an app's root scripts are namespaced by: its directory's name. */
|
|
151
|
+
export function intoScriptName(path) {
|
|
152
|
+
return path
|
|
153
|
+
.split("/")
|
|
154
|
+
.pop()
|
|
155
|
+
.toLowerCase()
|
|
156
|
+
.replace(/[^a-z0-9-]+/g, "-");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The root scripts that run one app's commands from the repository root. */
|
|
160
|
+
export function intoRootScripts(name, path) {
|
|
161
|
+
const inApp = `pnpm --dir ${path}`;
|
|
162
|
+
return {
|
|
163
|
+
[`dev:${name}`]: `${inApp} run dev`,
|
|
164
|
+
[`build:${name}`]: `${inApp} run build`,
|
|
165
|
+
[`doctor:${name}`]: `${inApp} run doctor`,
|
|
166
|
+
[`ship:${name}:production`]: `${inApp} exec astroid ship production`,
|
|
167
|
+
[`ship:${name}:preview`]: `${inApp} exec astroid ship preview`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The root package.json's scripts with the app's added, never replacing one
|
|
173
|
+
* that exists: `skipped` names each that was already taken, so the caller can
|
|
174
|
+
* say so rather than overwrite someone's script.
|
|
175
|
+
*/
|
|
176
|
+
export function mergeRootScripts(existing, scripts) {
|
|
177
|
+
const merged = { ...existing };
|
|
178
|
+
const added = [];
|
|
179
|
+
const skipped = [];
|
|
180
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
181
|
+
if (name in merged) skipped.push(name);
|
|
182
|
+
else {
|
|
183
|
+
merged[name] = command;
|
|
184
|
+
added.push(name);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { scripts: merged, added, skipped };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The settings of the new app's Workers Builds project. A second app is a
|
|
192
|
+
* second project, building from its own directory and deploying through
|
|
193
|
+
* `astroid ship`, so the deploy steps stay in the repository.
|
|
194
|
+
*/
|
|
195
|
+
export function workersBuildsSettings(path) {
|
|
196
|
+
return [
|
|
197
|
+
["Root directory", path],
|
|
198
|
+
["Build command", "pnpm run build"],
|
|
199
|
+
["Deploy command", "pnpm exec astroid ship production"],
|
|
200
|
+
["Non-production branch deploy command", "pnpm exec astroid ship preview"],
|
|
201
|
+
["Production branch", "deploy/production"],
|
|
202
|
+
];
|
|
203
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astroid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Scaffold a new Astroid site — an editable, multi-editor Astro app on Cloudflare Workers — in one command.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"index.mjs",
|
|
26
|
+
"into.mjs",
|
|
26
27
|
"template",
|
|
27
28
|
"toolkit-ranges.mjs"
|
|
28
29
|
],
|
|
@@ -32,10 +33,10 @@
|
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@better-auth/passkey": "^1.7.2",
|
|
35
|
-
"@louise-toolkit/astro": "^0.
|
|
36
|
-
"astroidjs": "0.
|
|
36
|
+
"@louise-toolkit/astro": "^0.6.0",
|
|
37
|
+
"astroidjs": "0.20.0",
|
|
37
38
|
"better-auth": "^1.7.2",
|
|
38
|
-
"louise-toolkit": "^0.
|
|
39
|
+
"louise-toolkit": "^0.37.0"
|
|
39
40
|
},
|
|
40
41
|
"engines": {
|
|
41
42
|
"node": ">=26.0.0"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# __BRAND_NAME__
|
|
2
|
+
|
|
3
|
+
An app on Cloudflare Workers with no pages to edit, scaffolded with
|
|
4
|
+
[Astroid](https://docs.astroidjs.org) (Astro + Louise Toolkit) in its
|
|
5
|
+
editor-free shape (`editor: false`).
|
|
6
|
+
|
|
7
|
+
It has no Louise editor: no sign-in for editors, no content tables, no media
|
|
8
|
+
library. Its settings, if it reads any, belong to a site that has an editor, and
|
|
9
|
+
that site stays the only place they're edited. What Astroid still generates
|
|
10
|
+
here is the rate limiter, the Content-Security-Policy, the security headers,
|
|
11
|
+
the public status route, and whatever modules the config switches on: a
|
|
12
|
+
customer portal, an installable app (PWA), or commerce.
|
|
13
|
+
|
|
14
|
+
The whole shape lives in one typed config, [`astroid.config.ts`](./astroid.config.ts).
|
|
15
|
+
`src/schema.ts`, `src/worker.ts`, and `src/middleware.ts` are **generated** from
|
|
16
|
+
it (they carry a "do not hand-edit" banner). Run `pnpm generate` after any
|
|
17
|
+
config change, or use `pnpm dev` and `pnpm build`, which regenerate first.
|
|
18
|
+
|
|
19
|
+
## Develop
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm install
|
|
23
|
+
cp .env.example .dev.vars # local secrets for `astro dev`
|
|
24
|
+
pnpm dev # astroid dev: regenerate, then astro dev
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The API
|
|
28
|
+
|
|
29
|
+
The web app is the first client of a versioned JSON API under `/api/v1`
|
|
30
|
+
(`src/pages/api/v1/`). A native client later calls the same routes, so each
|
|
31
|
+
one answers JSON, reads its input from the body or the URL, and needs no
|
|
32
|
+
browser-only header. The middleware rate-limits every POST under `/api/v1`.
|
|
33
|
+
|
|
34
|
+
## Deploy
|
|
35
|
+
|
|
36
|
+
Astroid wrote `wrangler.jsonc` with placeholder binding ids:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pnpm astroid provision # create the D1 database and the RL namespace, filling in their ids
|
|
40
|
+
pnpm run doctor # validate config, bindings, and generated-file freshness
|
|
41
|
+
pnpm astroid ship production
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Sharing another app's database
|
|
45
|
+
|
|
46
|
+
To read tables another app owns, such as its `site_settings`, bind its D1
|
|
47
|
+
database by id in `wrangler.jsonc` and set `deploy: { migrations: false }` in
|
|
48
|
+
`astroid.config.ts`. One app owns a database's schema: this one then applies no
|
|
49
|
+
migrations, and an additive migration in the other app ships in a release
|
|
50
|
+
before this app's code reads it.
|
|
51
|
+
|
|
52
|
+
## Layout
|
|
53
|
+
|
|
54
|
+
| Path | What |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `astroid.config.ts` | The one typed config. |
|
|
57
|
+
| `src/schema.ts` · `src/worker.ts` · `src/middleware.ts` | **Generated**—don't hand-edit. |
|
|
58
|
+
| `src/schema.site.ts` | This app's own Drizzle tables, if it has any. |
|
|
59
|
+
| `wrangler.jsonc` | Yours to edit—real binding ids, routes, secrets. |
|
|
60
|
+
| `src/pages/api/v1/` | The versioned JSON API. |
|
|
61
|
+
| `src/pages/` · `src/components/` · `src/layouts/` | Your Astro app. |
|
|
62
|
+
| `docs/` | ARCHITECTURE · RUNBOOK · DECISIONS—stubs to fill in as you go. |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Local dev secrets (wrangler reads .dev.vars; copy this there for `astro dev`).
|
|
2
|
+
# In production these are set with `wrangler secret put`, NOT committed.
|
|
3
|
+
#
|
|
4
|
+
# Astroid's convention: an unprovisioned secret leaves its feature DORMANT, never
|
|
5
|
+
# broken. A secret that is empty, or still holds the DUMMY_REPLACE_ME sentinel,
|
|
6
|
+
# reads as "not configured", so a fresh clone boots and runs with no external
|
|
7
|
+
# accounts at all. Replace a value to switch that feature on.
|
|
8
|
+
#
|
|
9
|
+
# This app has no editor, so it needs no editor sign-in secrets. Anything below
|
|
10
|
+
# belongs to a module your config switched on.
|
|
11
|
+
__ASTROID_APP_SECRETS__
|
|
12
|
+
__ASTROID_MODULE_SECRETS__
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import cloudflare from "@astrojs/cloudflare";
|
|
3
|
+
import { cacheCloudflare } from "@astrojs/cloudflare/cache";
|
|
4
|
+
import solid from "@astrojs/solid-js";
|
|
5
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
6
|
+
import { ASTROID_VITE_BUILD, astroidSecurity } from "astroidjs/astro";
|
|
7
|
+
import { defineConfig } from "astro/config";
|
|
8
|
+
import astroidConfig from "./astroid.config.ts";
|
|
9
|
+
|
|
10
|
+
// SSR (`output: server`) because an app answers per request: its JSON API under
|
|
11
|
+
// /api/v1, and pages that read live data. Solid islands for the interactive UI.
|
|
12
|
+
// Tailwind v4 + daisyUI drive the theme (src/styles/site.css). Cloudflare
|
|
13
|
+
// *bindings* are read via `import { env } from "cloudflare:workers"` (typed in
|
|
14
|
+
// src/env.d.ts), so there is no astro:env schema here.
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
site: "__SITE_URL__",
|
|
17
|
+
output: "server",
|
|
18
|
+
adapter: cloudflare(),
|
|
19
|
+
integrations: [solid()],
|
|
20
|
+
vite: {
|
|
21
|
+
plugins: [tailwindcss()],
|
|
22
|
+
build: { ...ASTROID_VITE_BUILD },
|
|
23
|
+
},
|
|
24
|
+
// Route caching (ADR 0004). This provider is what turns `Astro.cache.set(...)`
|
|
25
|
+
// into a `Cloudflare-CDN-Cache-Control` header, which the generated worker's
|
|
26
|
+
// `withEdgeCache` layer reads as its "store this" signal and then strips, so
|
|
27
|
+
// Cloudflare's own cookie-blind edge cache never sees it. Nothing is cached
|
|
28
|
+
// unless a route opts in, and a route that reads a signed-in customer never
|
|
29
|
+
// should.
|
|
30
|
+
cache: { provider: cacheCloudflare() },
|
|
31
|
+
// Content-Security-Policy, composed by Astroid from your config: the origins
|
|
32
|
+
// your modules need (a commerce provider's card SDK) plus the hash of Solid's
|
|
33
|
+
// hydration bootstrap. Astro owns `script-src`, so avoid is:inline and
|
|
34
|
+
// define:vars scripts, which can't be hashed and would be blocked. Need
|
|
35
|
+
// another origin? Add it to `security.cspOrigins` in astroid.config.ts.
|
|
36
|
+
security: astroidSecurity(astroidConfig),
|
|
37
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/// <reference path="../.astro/types.d.ts" />
|
|
2
|
+
/// <reference types="astro/client" />
|
|
3
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
4
|
+
|
|
5
|
+
// The Cloudflare bindings this app's Worker exposes (wrangler.jsonc), read via
|
|
6
|
+
// `import { env } from "cloudflare:workers"`. An app with no editor binds only
|
|
7
|
+
// what it uses. Add a binding here when you add one to wrangler.jsonc (a KV
|
|
8
|
+
// namespace for a cache, a Queue, a Durable Object).
|
|
9
|
+
type CloudflareEnv = {
|
|
10
|
+
/** D1: this app's own tables, or the database of the app that owns the schema. */
|
|
11
|
+
DB: D1Database;
|
|
12
|
+
/** The app's public origin, declared in wrangler.jsonc `vars`. */
|
|
13
|
+
SITE_URL: string;
|
|
14
|
+
/** KV: the security rate limiter. */
|
|
15
|
+
RL: KVNamespace;
|
|
16
|
+
/** Static assets (bound by the @astrojs/cloudflare adapter). */
|
|
17
|
+
ASSETS: Fetcher;__ASTROID_ENV_BINDINGS__
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// `env` from `cloudflare:workers` is typed as the augmentable `Cloudflare.Env`.
|
|
21
|
+
declare namespace Cloudflare {
|
|
22
|
+
interface Env extends CloudflareEnv {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Middleware sets these; bindings themselves come from `cloudflare:workers`.
|
|
26
|
+
declare namespace App {
|
|
27
|
+
interface Locals {
|
|
28
|
+
/** Always null: this app has no editor, so no request resolves to one. */
|
|
29
|
+
editor: null;
|
|
30
|
+
/** Always false, for the same reason. The shared middleware still sets it. */
|
|
31
|
+
editMode: boolean;__ASTROID_PORTAL_LOCALS__
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The base HTML shell. This app has no editor, so there's no settings row it
|
|
3
|
+
// owns and no edit mode: the head comes from astroid.config.ts, and a page
|
|
4
|
+
// overrides the title or description through props.
|
|
5
|
+
//
|
|
6
|
+
// Reading another app's settings (opening hours, a tagline) is a query against
|
|
7
|
+
// its table: import `siteSettings` from "louise-toolkit/db" where you need it.
|
|
8
|
+
import Credit from "astroidjs/components/Credit.astro";
|
|
9
|
+
import Seo from "astroidjs/components/Seo.astro";
|
|
10
|
+
import astroidConfig from "../../astroid.config.js";
|
|
11
|
+
import "../styles/site.css";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Keep this page out of search indexes (account, order status). */
|
|
17
|
+
noindex?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { title, description, noindex } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<!doctype html>
|
|
24
|
+
<html lang="en" data-theme="light">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
+
<Seo
|
|
29
|
+
settings={{ siteName: astroidConfig.theme.name }}
|
|
30
|
+
title={title}
|
|
31
|
+
description={description}
|
|
32
|
+
noindex={noindex}
|
|
33
|
+
titleTemplate={astroidConfig.seo?.titleTemplate}
|
|
34
|
+
twitterHandle={astroidConfig.seo?.twitterHandle}
|
|
35
|
+
locale={astroidConfig.seo?.locale}
|
|
36
|
+
/>
|
|
37
|
+
</head>
|
|
38
|
+
<body class="min-h-screen bg-base-100 text-base-content">
|
|
39
|
+
<slot />
|
|
40
|
+
{
|
|
41
|
+
/* The agency credit, when astroid.config.ts sets `credit`. */
|
|
42
|
+
astroidConfig.credit && (
|
|
43
|
+
<footer class="mx-auto flex max-w-6xl justify-center px-6 py-8">
|
|
44
|
+
<Credit config={astroidConfig} />
|
|
45
|
+
</footer>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// GET /api/v1—the root of this app's versioned JSON API.
|
|
2
|
+
//
|
|
3
|
+
// Every route the app's clients call lives under /api/v1: the web app first,
|
|
4
|
+
// and a native client later against the same routes. So each one answers JSON,
|
|
5
|
+
// reads its input from the body or the URL (never an HTML form post), and
|
|
6
|
+
// needs no browser-only header. A breaking change is a new prefix, /api/v2,
|
|
7
|
+
// beside this one, because a native client on a customer's phone updates on
|
|
8
|
+
// its own schedule.
|
|
9
|
+
//
|
|
10
|
+
// The generated middleware rate-limits every POST under /api/v1 per client IP.
|
|
11
|
+
// Add a tighter rule for one route with `security.rateRules` in
|
|
12
|
+
// astroid.config.ts.
|
|
13
|
+
import type { APIRoute } from "astro";
|
|
14
|
+
import astroidConfig from "../../../../astroid.config.js";
|
|
15
|
+
|
|
16
|
+
export const prerender = false;
|
|
17
|
+
|
|
18
|
+
export const GET: APIRoute = () =>
|
|
19
|
+
Response.json(
|
|
20
|
+
{ name: astroidConfig.theme.name, version: "v1" },
|
|
21
|
+
{ headers: { "cache-control": "no-store" } },
|
|
22
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The app's first screen. Replace it with yours. It reads its data from the
|
|
3
|
+
// same versioned JSON API a native client would call (src/pages/api/v1/), so
|
|
4
|
+
// the web app stays that API's first client rather than a special case.
|
|
5
|
+
import App from "../layouts/App.astro";
|
|
6
|
+
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<App>
|
|
11
|
+
<main class="mx-auto flex min-h-screen max-w-xl flex-col justify-center gap-4 px-6 py-16">
|
|
12
|
+
<h1 class="text-4xl font-bold">__BRAND_NAME__</h1>
|
|
13
|
+
<p class="text-base-content/70">
|
|
14
|
+
This app has no pages to edit. Build its screens here, and its API under
|
|
15
|
+
<code>/api/v1</code>.
|
|
16
|
+
</p>
|
|
17
|
+
</main>
|
|
18
|
+
</App>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// sitemap.xml—the app's public screens. Scaffolded once and yours to edit: add
|
|
2
|
+
// each screen a search engine should find to `entries`.
|
|
3
|
+
//
|
|
4
|
+
// It doesn't read a `pages` table, even when this app shares a database with a
|
|
5
|
+
// site that has one: those pages are served from the site's origin, not this
|
|
6
|
+
// app's. `astroidSitemapXml` drops anything matching the config's noindex
|
|
7
|
+
// prefixes, so this file and robots.txt can never disagree.
|
|
8
|
+
import type { APIRoute } from "astro";
|
|
9
|
+
import { astroidSitemapXml, type SitemapEntry } from "astroidjs";
|
|
10
|
+
import astroidConfig from "../../astroid.config.js";
|
|
11
|
+
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
|
|
14
|
+
export const GET: APIRoute = (context) => {
|
|
15
|
+
const origin = new URL(context.request.url).origin;
|
|
16
|
+
const entries: SitemapEntry[] = [{ path: "/" }];
|
|
17
|
+
|
|
18
|
+
return new Response(astroidSitemapXml(astroidConfig, entries, { origin }), {
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": "application/xml; charset=utf-8",
|
|
21
|
+
"cache-control": "public, max-age=3600",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// description, and OG image, and a page overrides any of them via props.
|
|
7
7
|
// <StructuredData> emits the schema.org graph (business + WebSite), with the
|
|
8
8
|
// business `@type` chosen from your archetype.
|
|
9
|
+
import Credit from "astroidjs/components/Credit.astro";
|
|
9
10
|
import Seo from "astroidjs/components/Seo.astro";
|
|
10
11
|
import StructuredData from "astroidjs/components/StructuredData.astro";
|
|
11
12
|
import { env } from "cloudflare:workers";
|
|
@@ -96,6 +97,15 @@ const settings = {
|
|
|
96
97
|
</head>
|
|
97
98
|
<body class="min-h-screen bg-base-100 text-base-content" data-edit-mode={editMode ? "" : undefined}>
|
|
98
99
|
<slot />
|
|
100
|
+
{
|
|
101
|
+
/* The agency credit, when astroid.config.ts sets `credit`. A site with
|
|
102
|
+
its own footer can move <Credit config={astroidConfig} /> into it. */
|
|
103
|
+
astroidConfig.credit && (
|
|
104
|
+
<footer class="mx-auto flex max-w-6xl justify-center px-6 py-8">
|
|
105
|
+
<Credit config={astroidConfig} />
|
|
106
|
+
</footer>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
99
109
|
<LouiseEdit versionedPageId={versionedPageId} sections={sections} />
|
|
100
110
|
{
|
|
101
111
|
/* Real-visitor Core Web Vitals. A static file from public/, so it is
|