create-astroid 0.9.2 → 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 +404 -26
- package/into.mjs +203 -0
- package/package.json +5 -4
- package/template/README.md +7 -5
- 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/template/src/lib/pages.ts +118 -0
- package/template/src/pages/[...slug].astro +76 -0
- package/template/src/pages/index.astro +16 -69
package/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `create-astroid`—scaffold a new Astroid site in one command:
|
|
5
5
|
//
|
|
6
6
|
// pnpm create astroid@latest my-site
|
|
7
|
-
// pnpm create astroid@latest my-site --key
|
|
7
|
+
// pnpm create astroid@latest my-site --key example --name "Example Organization" --color "#5b4bff" --host example.com
|
|
8
8
|
//
|
|
9
9
|
// It writes the floor: the `defineAstroid` config, the generated
|
|
10
10
|
// schema/worker/middleware trio + wrangler.jsonc (via astroidjs), the Better Auth
|
|
@@ -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
|
"",
|