@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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/index.mjs +511 -0
- package/dist/index.mjs.map +1 -0
- package/dist/template/.editorconfig +10 -0
- package/dist/template/.markdownlint.json +10 -0
- package/dist/template/README.md +169 -0
- package/dist/template/astro.config.ts +15 -0
- package/dist/template/cspell.config.ts +6 -0
- package/dist/template/oxfmt.config.ts +9 -0
- package/dist/template/package-lock.json +10896 -0
- package/dist/template/package.json +61 -0
- package/dist/template/playwright.config.ts +24 -0
- package/dist/template/public/favicon.svg +4 -0
- package/dist/template/public/social-card.svg +8 -0
- package/dist/template/src/components/navigation/Footer.astro +33 -0
- package/dist/template/src/components/navigation/Header.astro +11 -0
- package/dist/template/src/components/navigation/Navbar.astro +60 -0
- package/dist/template/src/components/navigation/Pagination.astro +37 -0
- package/dist/template/src/components/seo/SeoHead.astro +48 -0
- package/dist/template/src/components/theme/ThemeHead.astro +6 -0
- package/dist/template/src/components/ui/Card.astro +26 -0
- package/dist/template/src/components/ui/Container.astro +17 -0
- package/dist/template/src/config/content-models/articles.ts +39 -0
- package/dist/template/src/config/content-models/registry.ts +5 -0
- package/dist/template/src/config/site.test.ts +37 -0
- package/dist/template/src/config/site.ts +95 -0
- package/dist/template/src/content/articles/accessible-by-default.md +22 -0
- package/dist/template/src/content/articles/content-that-travels-well.md +17 -0
- package/dist/template/src/content/articles/designing-a-calm-starting-point.md +22 -0
- package/dist/template/src/content/articles/future-draft.md +11 -0
- package/dist/template/src/content.config.ts +5 -0
- package/dist/template/src/integrations/sveltia/config.test.ts +35 -0
- package/dist/template/src/integrations/sveltia/config.ts +28 -0
- package/dist/template/src/integrations/sveltia/preview-collections.test.ts +8 -0
- package/dist/template/src/integrations/sveltia/preview-collections.ts +3 -0
- package/dist/template/src/integrations/sveltia/previews.ts +13 -0
- package/dist/template/src/layouts/BaseLayout.astro +41 -0
- package/dist/template/src/lib/articles.ts +20 -0
- package/dist/template/src/lib/navigation.test.ts +20 -0
- package/dist/template/src/lib/navigation.ts +22 -0
- package/dist/template/src/pages/404.astro +17 -0
- package/dist/template/src/pages/about.astro +22 -0
- package/dist/template/src/pages/admin/index.astro +24 -0
- package/dist/template/src/pages/articles/[slug].astro +58 -0
- package/dist/template/src/pages/articles/index.astro +34 -0
- package/dist/template/src/pages/index.astro +64 -0
- package/dist/template/src/pages/robots.txt.ts +8 -0
- package/dist/template/src/pages/rss.xml.ts +17 -0
- package/dist/template/src/pages/site.webmanifest.ts +19 -0
- package/dist/template/src/styles/global.css +73 -0
- package/dist/template/src/styles/print.css +34 -0
- package/dist/template/src/themes/catppuccinTheme.frappe.ts +5 -0
- package/dist/template/src/themes/catppuccinTheme.latte.ts +5 -0
- package/dist/template/src/themes/catppuccinTheme.macchiato.ts +5 -0
- package/dist/template/src/themes/catppuccinTheme.mocha.ts +5 -0
- package/dist/template/src/themes/site-theme.test.ts +19 -0
- package/dist/template/src/themes/site-theme.ts +13 -0
- package/dist/template/stylelint.config.ts +19 -0
- package/dist/template/tests/accessibility.spec.ts +47 -0
- package/dist/template/tests/routes.spec.ts +87 -0
- package/dist/template/tests/theme.spec.ts +47 -0
- package/dist/template/tsconfig.json +5 -0
- package/dist/template/vitest.config.ts +8 -0
- 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
|