@rm-industries/create-forge 0.3.0-alpha.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/index.mjs +511 -0
  4. package/dist/index.mjs.map +1 -0
  5. package/dist/template/.editorconfig +10 -0
  6. package/dist/template/.markdownlint.json +10 -0
  7. package/dist/template/README.md +169 -0
  8. package/dist/template/astro.config.ts +15 -0
  9. package/dist/template/cspell.config.ts +6 -0
  10. package/dist/template/oxfmt.config.ts +9 -0
  11. package/dist/template/package-lock.json +10896 -0
  12. package/dist/template/package.json +61 -0
  13. package/dist/template/playwright.config.ts +24 -0
  14. package/dist/template/public/favicon.svg +4 -0
  15. package/dist/template/public/social-card.svg +8 -0
  16. package/dist/template/src/components/navigation/Footer.astro +33 -0
  17. package/dist/template/src/components/navigation/Header.astro +11 -0
  18. package/dist/template/src/components/navigation/Navbar.astro +60 -0
  19. package/dist/template/src/components/navigation/Pagination.astro +37 -0
  20. package/dist/template/src/components/seo/SeoHead.astro +48 -0
  21. package/dist/template/src/components/theme/ThemeHead.astro +6 -0
  22. package/dist/template/src/components/ui/Card.astro +26 -0
  23. package/dist/template/src/components/ui/Container.astro +17 -0
  24. package/dist/template/src/config/content-models/articles.ts +39 -0
  25. package/dist/template/src/config/content-models/registry.ts +5 -0
  26. package/dist/template/src/config/site.test.ts +37 -0
  27. package/dist/template/src/config/site.ts +95 -0
  28. package/dist/template/src/content/articles/accessible-by-default.md +22 -0
  29. package/dist/template/src/content/articles/content-that-travels-well.md +17 -0
  30. package/dist/template/src/content/articles/designing-a-calm-starting-point.md +22 -0
  31. package/dist/template/src/content/articles/future-draft.md +11 -0
  32. package/dist/template/src/content.config.ts +5 -0
  33. package/dist/template/src/integrations/sveltia/config.test.ts +35 -0
  34. package/dist/template/src/integrations/sveltia/config.ts +28 -0
  35. package/dist/template/src/integrations/sveltia/preview-collections.test.ts +8 -0
  36. package/dist/template/src/integrations/sveltia/preview-collections.ts +3 -0
  37. package/dist/template/src/integrations/sveltia/previews.ts +13 -0
  38. package/dist/template/src/layouts/BaseLayout.astro +41 -0
  39. package/dist/template/src/lib/articles.ts +20 -0
  40. package/dist/template/src/lib/navigation.test.ts +20 -0
  41. package/dist/template/src/lib/navigation.ts +22 -0
  42. package/dist/template/src/pages/404.astro +17 -0
  43. package/dist/template/src/pages/about.astro +22 -0
  44. package/dist/template/src/pages/admin/index.astro +24 -0
  45. package/dist/template/src/pages/articles/[slug].astro +58 -0
  46. package/dist/template/src/pages/articles/index.astro +34 -0
  47. package/dist/template/src/pages/index.astro +64 -0
  48. package/dist/template/src/pages/robots.txt.ts +8 -0
  49. package/dist/template/src/pages/rss.xml.ts +17 -0
  50. package/dist/template/src/pages/site.webmanifest.ts +19 -0
  51. package/dist/template/src/styles/global.css +73 -0
  52. package/dist/template/src/styles/print.css +34 -0
  53. package/dist/template/src/themes/catppuccinTheme.frappe.ts +5 -0
  54. package/dist/template/src/themes/catppuccinTheme.latte.ts +5 -0
  55. package/dist/template/src/themes/catppuccinTheme.macchiato.ts +5 -0
  56. package/dist/template/src/themes/catppuccinTheme.mocha.ts +5 -0
  57. package/dist/template/src/themes/site-theme.test.ts +19 -0
  58. package/dist/template/src/themes/site-theme.ts +13 -0
  59. package/dist/template/stylelint.config.ts +19 -0
  60. package/dist/template/tests/accessibility.spec.ts +47 -0
  61. package/dist/template/tests/routes.spec.ts +87 -0
  62. package/dist/template/tests/theme.spec.ts +47 -0
  63. package/dist/template/tsconfig.json +5 -0
  64. package/dist/template/vitest.config.ts +8 -0
  65. package/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RM Industries
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # `@rm-industries/create-forge`
2
+
3
+ The publishable Forge project initializer. Once the generator milestone is
4
+ complete, npm will resolve the primary command to this scoped package:
5
+
6
+ ```sh
7
+ npm create @rm-industries/forge@next
8
+ ```
9
+
10
+ The initializer accepts interactive answers, documented defaults through
11
+ `--yes`, or a fully specified set of flags. Run `create-forge --help` for the
12
+ complete input contract. Unless disabled with `--no-install` or `--no-git`, it
13
+ runs `npm install` and initializes a `main`-branch Git repository after files are
14
+ created. It never stages files, reads Git identity, or creates a commit.
15
+
16
+ The selected template is copied with dotfiles and file modes intact. Forge
17
+ customizes only reviewed metadata files and refuses parent-traversal paths,
18
+ symbolic-link collisions, filesystem roots, and unconfirmed non-empty
19
+ destinations. If copying or customization fails, files created by that
20
+ invocation are removed and overwritten files are restored from temporary
21
+ backups. The error includes recovery guidance if automatic rollback is
22
+ incomplete.
23
+
24
+ Installation and Git commands inherit the terminal so their progress and errors
25
+ remain visible. A failed or interrupted command exits non-zero and leaves the
26
+ generated project in place with instructions to inspect and retry it.
27
+
28
+ After successful creation, Forge reports the created path, whether dependency
29
+ and Git setup ran or was skipped, and only the commands still needed to start
30
+ development. Output remains readable when color is disabled and contains no
31
+ telemetry or promotional messages.
32
+
33
+ ## Package verification
34
+
35
+ From the Forge repository root, build and inspect every workspace package:
36
+
37
+ ```sh
38
+ npm run quality
39
+ ```
40
+
41
+ To build this package, create its actual tarball, install it into an isolated OS
42
+ temporary directory, and exercise the installed executable:
43
+
44
+ ```sh
45
+ npm run verify:package --workspace @rm-industries/create-forge
46
+ ```
47
+
48
+ The verification confirms `--help`, `--version`, and packaged template assets,
49
+ then removes the temporary fixture.
50
+
51
+ To exercise the packed CLI across the complete generator fixture matrix:
52
+
53
+ ```sh
54
+ npm run test:generator:e2e
55
+ ```
56
+
57
+ This generates default, fully specified, scoped-package-name, no-install, and
58
+ conflict fixtures in an OS temporary directory. The default fixture installs,
59
+ type-checks, and builds outside the Forge workspace. The conflict fixture also
60
+ proves that a failed invocation leaves existing files unchanged. Every fixture
61
+ is removed after the run.
package/dist/index.mjs ADDED
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
3
+ import { confirm, isCancel, text } from "@clack/prompts";
4
+ import { Command, CommanderError, Option } from "commander";
5
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
6
+ import validateNpmPackageName from "validate-npm-package-name";
7
+ import { tmpdir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
9
+ import { spawn } from "node:child_process";
10
+ //#region src/prompts.ts
11
+ const clackPrompts = {
12
+ text: (options) => text({
13
+ message: options.message,
14
+ ...options.initialValue === void 0 ? {} : { initialValue: options.initialValue },
15
+ ...options.placeholder === void 0 ? {} : { placeholder: options.placeholder },
16
+ ...options.validate === void 0 ? {} : { validate: options.validate }
17
+ }),
18
+ confirm,
19
+ isCancel
20
+ };
21
+ const derivePackageName = (destination) => {
22
+ return basename(resolve(destination)) || "forge-site";
23
+ };
24
+ const humanizePackageName = (packageName) => {
25
+ return (packageName.split("/").at(-1) ?? packageName).split(/[-_\s]+/u).filter(Boolean).map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(" ");
26
+ };
27
+ //#endregion
28
+ //#region src/validation.ts
29
+ var InputError = class extends Error {};
30
+ const invalid = (option, value, reason) => {
31
+ throw new InputError(`Invalid ${option} value ${JSON.stringify(value)}: ${reason}`);
32
+ };
33
+ const validateDestination = (value) => {
34
+ const destination = value.trim();
35
+ if (!destination) invalid("destination", value, "provide a non-empty path");
36
+ if (destination.includes("\0")) invalid("destination", value, "paths cannot contain null bytes");
37
+ return destination;
38
+ };
39
+ const validatePackageName = (value) => {
40
+ const packageName = value.trim();
41
+ const result = validateNpmPackageName(packageName);
42
+ if (!result.validForNewPackages) {
43
+ const reason = [...result.errors ?? [], ...result.warnings ?? []].join("; ");
44
+ invalid("--name", value, reason || "provide a valid npm package name");
45
+ }
46
+ return packageName;
47
+ };
48
+ const validatePlainText = (option, value, required = false) => {
49
+ const text = value.trim();
50
+ if (required && !text) invalid(option, value, "provide non-empty text");
51
+ if (/\p{Cc}/u.test(text)) invalid(option, value, "control characters are not allowed");
52
+ return text;
53
+ };
54
+ const validateUrl = (value) => {
55
+ let url;
56
+ try {
57
+ url = new URL(value);
58
+ } catch {
59
+ return invalid("--url", value, "provide an absolute HTTP(S) URL");
60
+ }
61
+ if (!["http:", "https:"].includes(url.protocol)) invalid("--url", value, "provide an absolute HTTP(S) URL");
62
+ return url.toString();
63
+ };
64
+ const validateRepository = (value) => {
65
+ const repository = value.trim();
66
+ if (!repository) return repository;
67
+ if (!/^[\w.-]+\/[\w.-]+$/u.test(repository)) invalid("--repository", value, "use the GitHub owner/repository format or leave it empty");
68
+ return repository;
69
+ };
70
+ //#endregion
71
+ //#region src/resolve-options.ts
72
+ var CancellationError = class extends Error {};
73
+ const textValidation = (validator) => (value) => {
74
+ try {
75
+ validator(value ?? "");
76
+ return;
77
+ } catch (error) {
78
+ return error instanceof Error ? error.message : "Invalid value";
79
+ }
80
+ };
81
+ const promptText = async (prompts, options) => {
82
+ const value = await prompts.text(options);
83
+ if (prompts.isCancel(value)) throw new CancellationError("Project creation cancelled.");
84
+ return String(value);
85
+ };
86
+ const promptConfirm = async (prompts, message) => {
87
+ const value = await prompts.confirm({
88
+ message,
89
+ initialValue: true
90
+ });
91
+ if (prompts.isCancel(value)) throw new CancellationError("Project creation cancelled.");
92
+ return Boolean(value);
93
+ };
94
+ const hasEveryExplicitValue = (options) => options.destination !== void 0 && options.packageName !== void 0 && options.siteName !== void 0 && options.description !== void 0 && options.author !== void 0 && options.url !== void 0 && options.repository !== void 0 && options.install !== void 0 && options.git !== void 0;
95
+ const resolveOptions = async (provided, { interactive, prompts }) => {
96
+ const useDefaults = provided.yes;
97
+ if (!interactive && !useDefaults && !hasEveryExplicitValue(provided)) throw new InputError("Non-interactive use requires --yes or explicit values for every generator option. Run create-forge --help for details.");
98
+ const destination = validateDestination(provided.destination ?? (useDefaults ? "." : await promptText(prompts, {
99
+ message: "Where should we create your project?",
100
+ placeholder: ".",
101
+ initialValue: ".",
102
+ validate: textValidation(validateDestination)
103
+ })));
104
+ const derivedPackageName = derivePackageName(destination);
105
+ const packageName = validatePackageName(provided.packageName ?? (useDefaults ? derivedPackageName : await promptText(prompts, {
106
+ message: "What should the npm package be named?",
107
+ initialValue: derivedPackageName,
108
+ validate: textValidation(validatePackageName)
109
+ })));
110
+ const defaultSiteName = humanizePackageName(packageName);
111
+ return {
112
+ destination,
113
+ packageName,
114
+ siteName: validatePlainText("--site-name", provided.siteName ?? (useDefaults ? defaultSiteName : await promptText(prompts, {
115
+ message: "What should the site be called?",
116
+ initialValue: defaultSiteName,
117
+ validate: textValidation((value) => validatePlainText("--site-name", value, true))
118
+ })), true),
119
+ description: validatePlainText("--description", provided.description ?? (useDefaults ? "A content-driven website built with Forge" : await promptText(prompts, {
120
+ message: "How would you describe the site?",
121
+ initialValue: "A content-driven website built with Forge",
122
+ validate: textValidation((value) => validatePlainText("--description", value))
123
+ }))),
124
+ author: validatePlainText("--author", provided.author ?? (useDefaults ? "" : await promptText(prompts, {
125
+ message: "Who is the site author?",
126
+ placeholder: "Optional",
127
+ validate: textValidation((value) => validatePlainText("--author", value))
128
+ }))),
129
+ url: validateUrl(provided.url ?? (useDefaults ? "https://example.com" : await promptText(prompts, {
130
+ message: "What is the canonical site URL?",
131
+ initialValue: "https://example.com",
132
+ validate: textValidation(validateUrl)
133
+ }))),
134
+ repository: validateRepository(provided.repository ?? (useDefaults ? "" : await promptText(prompts, {
135
+ message: "What is the GitHub repository?",
136
+ placeholder: "owner/repository (optional)",
137
+ validate: textValidation(validateRepository)
138
+ }))),
139
+ install: provided.install ?? (useDefaults ? true : await promptConfirm(prompts, "Install dependencies?")),
140
+ git: provided.git ?? (useDefaults ? true : await promptConfirm(prompts, "Initialize a Git repository?"))
141
+ };
142
+ };
143
+ //#endregion
144
+ //#region src/cli.ts
145
+ const hasBoth = (args, positive, negative) => args.includes(positive) && args.includes(negative);
146
+ const parseArguments = (args, version) => {
147
+ let output = "";
148
+ const program = new Command().name("create-forge").description("Create a content-driven Forge website").argument("[destination]", "directory where the project will be created").option("--name <name>", "npm package name (defaults to the directory basename)").option("--site-name <name>", "site name").option("--description <text>", "site description").option("--author <name>", "site author").option("--url <url>", "absolute canonical HTTP(S) URL").option("--repository <owner/repository>", "GitHub repository or an empty value").addOption(new Option("--install", "install dependencies")).addOption(new Option("--no-install", "do not install dependencies")).addOption(new Option("--git", "initialize a Git repository")).addOption(new Option("--no-git", "do not initialize a Git repository")).option("-y, --yes", "use documented defaults without prompting", false).version(version, "-v, --version", "show the installed package version").helpOption("-h, --help", "show this help message").showHelpAfterError().allowExcessArguments(false).exitOverride().configureOutput({
149
+ writeOut: (value) => {
150
+ output += value;
151
+ },
152
+ writeErr: (value) => {
153
+ output += value;
154
+ }
155
+ });
156
+ try {
157
+ program.parse(args, { from: "user" });
158
+ } catch (error) {
159
+ if (error instanceof CommanderError) return {
160
+ output,
161
+ exitCode: error.exitCode
162
+ };
163
+ throw error;
164
+ }
165
+ if (hasBoth(args, "--install", "--no-install")) return {
166
+ output: "error: --install cannot be used with --no-install\n",
167
+ exitCode: 1
168
+ };
169
+ if (hasBoth(args, "--git", "--no-git")) return {
170
+ output: "error: --git cannot be used with --no-git\n",
171
+ exitCode: 1
172
+ };
173
+ const raw = program.opts();
174
+ return { provided: {
175
+ destination: program.args[0],
176
+ packageName: raw.name,
177
+ siteName: raw.siteName,
178
+ description: raw.description,
179
+ author: raw.author,
180
+ url: raw.url,
181
+ repository: raw.repository,
182
+ install: raw.install,
183
+ git: raw.git,
184
+ yes: raw.yes
185
+ } };
186
+ };
187
+ const runCli = async (args, version, context = {}) => {
188
+ const parsed = parseArguments(args, version);
189
+ if (!("provided" in parsed)) return parsed;
190
+ try {
191
+ return {
192
+ output: "",
193
+ exitCode: 0,
194
+ options: await resolveOptions(parsed.provided, {
195
+ interactive: context.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY),
196
+ prompts: context.prompts ?? clackPrompts
197
+ })
198
+ };
199
+ } catch (error) {
200
+ if (error instanceof CancellationError) return {
201
+ output: `${error.message}\n`,
202
+ exitCode: 1
203
+ };
204
+ if (error instanceof Error) return {
205
+ output: `error: ${error.message}\n`,
206
+ exitCode: 1
207
+ };
208
+ throw error;
209
+ }
210
+ };
211
+ //#endregion
212
+ //#region src/template-tokens.ts
213
+ const prefix = "__RM_INDUSTRIES_FORGE_TEMPLATE_V1_";
214
+ const templateTokens = Object.freeze({
215
+ packageName: `${prefix}PACKAGE_NAME__`,
216
+ siteName: `${prefix}SITE_NAME__`,
217
+ description: `${prefix}DESCRIPTION__`,
218
+ author: `${prefix}AUTHOR__`,
219
+ url: `${prefix}URL__`,
220
+ repository: `${prefix}REPOSITORY__`
221
+ });
222
+ const templateTokenPrefix = prefix;
223
+ //#endregion
224
+ //#region src/materialize.ts
225
+ var MaterializationError = class extends Error {};
226
+ const pathExists = async (path) => {
227
+ try {
228
+ await lstat(path);
229
+ return true;
230
+ } catch (error) {
231
+ if (error.code === "ENOENT") return false;
232
+ throw error;
233
+ }
234
+ };
235
+ const getPathStat = async (path) => {
236
+ try {
237
+ return await lstat(path);
238
+ } catch (error) {
239
+ if (error.code === "ENOENT") return void 0;
240
+ throw error;
241
+ }
242
+ };
243
+ const assertNotAborted = (signal) => {
244
+ if (signal?.aborted) throw new Error("Project creation was interrupted.");
245
+ };
246
+ const resolveTemplateDirectory = (directory) => directory instanceof URL ? fileURLToPath(directory) : resolve(directory);
247
+ const resolveDestination = (destination, cwd) => {
248
+ if (destination.split(/[\\/]/u).includes("..")) throw new MaterializationError(`Unsafe destination ${JSON.stringify(destination)}: parent traversal is not allowed.`);
249
+ const resolved = isAbsolute(destination) ? resolve(destination) : resolve(cwd, destination);
250
+ if (resolved === parse(resolved).root) throw new MaterializationError(`Unsafe destination ${JSON.stringify(destination)}: a filesystem root is not allowed.`);
251
+ return resolved;
252
+ };
253
+ const listTemplateFiles = async (root, directory = root) => {
254
+ const entries = await readdir(directory, { withFileTypes: true });
255
+ const files = [];
256
+ for (const entry of entries) {
257
+ const source = join(directory, entry.name);
258
+ if (entry.isDirectory()) {
259
+ files.push(...await listTemplateFiles(root, source));
260
+ continue;
261
+ }
262
+ if (!entry.isFile()) throw new MaterializationError(`Template entry ${source} is not a regular file.`);
263
+ files.push({
264
+ source,
265
+ relativePath: relative(root, source),
266
+ mode: (await stat(source)).mode
267
+ });
268
+ }
269
+ return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
270
+ };
271
+ const isDirectoryEmpty = async (directory) => (await readdir(directory)).length === 0;
272
+ const ensureDirectory = async (directory, created) => {
273
+ const existing = await getPathStat(directory);
274
+ if (existing) {
275
+ if (existing.isSymbolicLink() || !existing.isDirectory()) throw new Error(`Unsafe destination entry ${directory}: expected a regular directory.`);
276
+ return;
277
+ }
278
+ const parent = dirname(directory);
279
+ if (parent !== directory) await ensureDirectory(parent, created);
280
+ await mkdir(directory);
281
+ created.add(directory);
282
+ };
283
+ const replaceToken = (source, token, value, field) => {
284
+ const quotedToken = `'${token}'`;
285
+ if (!source.includes(quotedToken)) throw new Error(`Packaged template is missing the ${field} token.`);
286
+ return source.replaceAll(quotedToken, JSON.stringify(value));
287
+ };
288
+ const customizeTemplate = async (destination, options) => {
289
+ const packagePath = join(destination, "package.json");
290
+ const packageLockPath = join(destination, "package-lock.json");
291
+ const packageMetadata = JSON.parse(await readFile(packagePath, "utf8"));
292
+ const packageLock = JSON.parse(await readFile(packageLockPath, "utf8"));
293
+ if (packageMetadata.name !== templateTokens.packageName || packageLock.name !== templateTokens.packageName || packageLock.packages?.[""]?.name !== templateTokens.packageName) throw new Error("Packaged template contains unexpected package metadata tokens.");
294
+ packageMetadata.name = options.packageName;
295
+ packageMetadata.version = "0.0.0";
296
+ packageMetadata.private = true;
297
+ packageLock.name = options.packageName;
298
+ if (!packageLock.packages?.[""]) throw new Error("Packaged template lockfile is missing its root package metadata.");
299
+ packageLock.packages[""].name = options.packageName;
300
+ packageLock.packages[""].version = "0.0.0";
301
+ await writeFile(packagePath, `${JSON.stringify(packageMetadata, void 0, 2)}\n`);
302
+ await writeFile(packageLockPath, `${JSON.stringify(packageLock, void 0, 2)}\n`);
303
+ const siteConfigPath = join(destination, "src", "config", "site.ts");
304
+ let siteConfig = await readFile(siteConfigPath, "utf8");
305
+ siteConfig = replaceToken(siteConfig, templateTokens.siteName, options.siteName, "site name");
306
+ siteConfig = replaceToken(siteConfig, templateTokens.description, options.description, "description");
307
+ siteConfig = replaceToken(siteConfig, templateTokens.author, options.author, "author");
308
+ siteConfig = replaceToken(siteConfig, templateTokens.url, options.url, "URL");
309
+ siteConfig = replaceToken(siteConfig, templateTokens.repository, options.repository, "repository");
310
+ if (siteConfig.includes("__RM_INDUSTRIES_FORGE_TEMPLATE_V1_")) throw new Error("Packaged template contains unresolved tokens.");
311
+ await writeFile(siteConfigPath, siteConfig);
312
+ };
313
+ const materializeProject = async (options, context) => {
314
+ const cwd = context.cwd ?? process.cwd();
315
+ const destination = resolveDestination(options.destination, cwd);
316
+ const templateDirectory = resolveTemplateDirectory(context.templateDirectory);
317
+ const destinationExisted = await pathExists(destination);
318
+ if (destinationExisted) {
319
+ const destinationStat = await lstat(destination);
320
+ if (destinationStat.isSymbolicLink() || !destinationStat.isDirectory()) throw new MaterializationError(`Unsafe destination ${destination}: expected a regular directory.`);
321
+ }
322
+ if (destinationExisted && !await isDirectoryEmpty(destination)) {
323
+ if (!await context.confirmOverwrite?.(destination)) throw new MaterializationError(`Destination ${destination} is not empty. No files were changed.`);
324
+ }
325
+ const files = await listTemplateFiles(templateDirectory);
326
+ const backupDirectory = await mkdtemp(join(tmpdir(), "create-forge-backup-"));
327
+ const createdFiles = /* @__PURE__ */ new Set();
328
+ const createdDirectories = /* @__PURE__ */ new Set();
329
+ const backups = /* @__PURE__ */ new Map();
330
+ try {
331
+ assertNotAborted(context.signal);
332
+ await ensureDirectory(destination, createdDirectories);
333
+ for (const file of files) {
334
+ assertNotAborted(context.signal);
335
+ const target = join(destination, file.relativePath);
336
+ const targetRelative = relative(destination, target);
337
+ if (targetRelative.startsWith("..") || isAbsolute(targetRelative)) throw new Error(`Unsafe template path ${file.relativePath}.`);
338
+ await ensureDirectory(dirname(target), createdDirectories);
339
+ const targetStat = await getPathStat(target);
340
+ if (targetStat) {
341
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) throw new Error(`Unsafe destination entry ${target}: expected a regular file.`);
342
+ const backup = join(backupDirectory, file.relativePath);
343
+ await mkdir(dirname(backup), { recursive: true });
344
+ await copyFile(target, backup);
345
+ backups.set(target, backup);
346
+ } else createdFiles.add(target);
347
+ await copyFile(file.source, target);
348
+ await chmod(target, file.mode);
349
+ context.onFileCopied?.(target);
350
+ }
351
+ assertNotAborted(context.signal);
352
+ await customizeTemplate(destination, options);
353
+ const tokenBytes = Buffer.from(templateTokenPrefix);
354
+ for (const file of files) {
355
+ assertNotAborted(context.signal);
356
+ if ((await readFile(join(destination, file.relativePath))).includes(tokenBytes)) throw new Error(`Generated file ${file.relativePath} contains an unresolved template token.`);
357
+ }
358
+ return {
359
+ destination,
360
+ filesCopied: files.length
361
+ };
362
+ } catch (error) {
363
+ const cleanupErrors = [];
364
+ for (const [target, backup] of backups) try {
365
+ await copyFile(backup, target);
366
+ } catch (cleanupError) {
367
+ cleanupErrors.push(cleanupError);
368
+ }
369
+ for (const file of createdFiles) try {
370
+ await rm(file, { force: true });
371
+ } catch (cleanupError) {
372
+ cleanupErrors.push(cleanupError);
373
+ }
374
+ for (const directory of [...createdDirectories].sort((left, right) => right.length - left.length)) try {
375
+ await rmdir(directory);
376
+ } catch (cleanupError) {
377
+ if (!["ENOENT", "ENOTEMPTY"].includes(cleanupError.code ?? "")) cleanupErrors.push(cleanupError);
378
+ }
379
+ throw new MaterializationError(`${error instanceof Error ? error.message : String(error)} ${cleanupErrors.length ? `Recovery: review ${destination}; some changes could not be rolled back automatically.` : `Recovery: changes from this invocation were rolled back; resolve the error and retry.`}`, { cause: error });
380
+ } finally {
381
+ await rm(backupDirectory, {
382
+ recursive: true,
383
+ force: true
384
+ });
385
+ }
386
+ };
387
+ //#endregion
388
+ //#region src/process.ts
389
+ var ProcessStepError = class extends Error {
390
+ exitCode;
391
+ constructor(message, exitCode = 1, options) {
392
+ super(message, options);
393
+ this.exitCode = exitCode;
394
+ }
395
+ };
396
+ const signalExitCode = (signal) => {
397
+ if (signal === "SIGINT") return 130;
398
+ if (signal === "SIGTERM") return 143;
399
+ return 1;
400
+ };
401
+ const executeCommand = ({ executable, arguments: args, cwd, signal, environment }) => new Promise((resolve, reject) => {
402
+ const child = spawn(executable, args, {
403
+ cwd,
404
+ stdio: "inherit",
405
+ ...signal ? { signal } : {},
406
+ ...environment ? { env: environment } : {}
407
+ });
408
+ child.once("error", (error) => {
409
+ const interrupted = signal?.aborted;
410
+ reject(new ProcessStepError(interrupted ? `${executable} was interrupted. The generated project remains at ${cwd}.` : `Could not start ${executable}: ${error.message}. The generated project remains at ${cwd}.`, interrupted ? 130 : 1, { cause: error }));
411
+ });
412
+ child.once("close", (code, processSignal) => {
413
+ if (code === 0) {
414
+ resolve();
415
+ return;
416
+ }
417
+ reject(new ProcessStepError(`${executable} failed with ${processSignal ? `signal ${processSignal}` : `exit code ${code ?? "unknown"}`}. The generated project remains at ${cwd}; review the output above and retry the command there.`, processSignal ? signalExitCode(processSignal) : code ?? 1));
418
+ });
419
+ });
420
+ const runProjectSetup = async (options, context) => {
421
+ const execute = context.execute ?? executeCommand;
422
+ const command = (executable, args) => execute({
423
+ executable,
424
+ arguments: args,
425
+ cwd: context.destination,
426
+ ...context.signal ? { signal: context.signal } : {},
427
+ ...context.environment ? { environment: context.environment } : {}
428
+ });
429
+ if (options.install) await command("npm", ["install"]);
430
+ if (options.git) await command("git", ["init", "--initial-branch=main"]);
431
+ };
432
+ //#endregion
433
+ //#region src/reporter.ts
434
+ const ansi = {
435
+ bold: (value) => `\u001B[1m${value}\u001B[22m`,
436
+ green: (value) => `\u001B[32m${value}\u001B[39m`
437
+ };
438
+ const commandPath = (path) => {
439
+ if (/^[\w./@-]+$/u.test(path)) return path;
440
+ return `"${path.replaceAll("\"", "\\\"")}"`;
441
+ };
442
+ const displayDestination = (destination, cwd) => {
443
+ const path = relative(cwd, destination);
444
+ if (!path) return ".";
445
+ return isAbsolute(path) ? destination : path;
446
+ };
447
+ const supportsColor = (environment = process.env, terminal = Boolean(process.stdout.isTTY)) => terminal && environment.NO_COLOR === void 0;
448
+ const formatCompletion = (options, context) => {
449
+ const cwd = context.cwd ?? process.cwd();
450
+ const decorate = context.color ?? supportsColor() ? ansi : {
451
+ bold: (value) => value,
452
+ green: (value) => value
453
+ };
454
+ const destination = displayDestination(context.destination, cwd);
455
+ const commands = [
456
+ ...destination === "." ? [] : [`cd ${commandPath(destination)}`],
457
+ ...options.install ? [] : ["npm install"],
458
+ "npm run dev"
459
+ ];
460
+ return `${decorate.green("✓")} Created ${options.siteName} in ${destination}\n\n${decorate.bold("Setup")}\n Dependencies: ${options.install ? "installed" : "skipped"}\n Git repository: ${options.git ? "initialized" : "skipped"}\n\n${decorate.bold("Next steps")}\n${commands.map((command) => ` ${command}`).join("\n")}\n`;
461
+ };
462
+ //#endregion
463
+ //#region src/index.ts
464
+ const packageJson = new URL("../package.json", import.meta.url);
465
+ const metadata = JSON.parse(await readFile(packageJson, "utf8"));
466
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
467
+ const result = await runCli(process.argv.slice(2), metadata.version, { interactive });
468
+ if (result.options) {
469
+ const controller = new AbortController();
470
+ let interruptedExitCode;
471
+ const interrupt = () => {
472
+ interruptedExitCode = 130;
473
+ controller.abort();
474
+ };
475
+ const terminate = () => {
476
+ interruptedExitCode = 143;
477
+ controller.abort();
478
+ };
479
+ process.once("SIGINT", interrupt);
480
+ process.once("SIGTERM", terminate);
481
+ try {
482
+ const materialized = await materializeProject(result.options, {
483
+ templateDirectory: new URL("./template/", import.meta.url),
484
+ signal: controller.signal,
485
+ ...interactive ? { confirmOverwrite: async (destination) => {
486
+ const answer = await confirm({
487
+ message: `${destination} is not empty. Continue and overwrite conflicting template files?`,
488
+ initialValue: false
489
+ });
490
+ return !isCancel(answer) && answer;
491
+ } } : {}
492
+ });
493
+ await runProjectSetup(result.options, {
494
+ destination: materialized.destination,
495
+ signal: controller.signal
496
+ });
497
+ result.output = formatCompletion(result.options, { destination: materialized.destination });
498
+ } catch (error) {
499
+ result.output = `error: ${error instanceof Error ? error.message : String(error)}\n`;
500
+ result.exitCode = interruptedExitCode ?? (error instanceof ProcessStepError ? error.exitCode : 1);
501
+ } finally {
502
+ process.off("SIGINT", interrupt);
503
+ process.off("SIGTERM", terminate);
504
+ }
505
+ }
506
+ process.stdout.write(result.output);
507
+ process.exitCode = result.exitCode;
508
+ //#endregion
509
+ export {};
510
+
511
+ //# sourceMappingURL=index.mjs.map