inferative 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,878 @@
1
+ import { createRequire } from "node:module";
2
+ import { defineCommand } from "citty";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { installDependencies } from "nypm";
5
+ import { x } from "tinyexec";
6
+ import { cp, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
7
+ //#region src/cli-version.ts
8
+ /** The version in this package's own `package.json`. */
9
+ var CliVersion = class {
10
+ /** The version. citty throws on `--version` without one. */
11
+ static current() {
12
+ return createRequire(import.meta.url)("../package.json").version;
13
+ }
14
+ /**
15
+ * The `major.minor` part of a version, which names the branch of the starters repository.
16
+ *
17
+ * @param version - A version such as `0.1.0` or `0.2.0-snapshot.20261009`.
18
+ */
19
+ static minor(version) {
20
+ const [major = "0", minor = "0"] = version.split(".");
21
+ return `${major}.${minor}`;
22
+ }
23
+ };
24
+ //#endregion
25
+ //#region src/error-code.ts
26
+ /** Reads the `code` of an error that Node raised, such as `ENOENT`. */
27
+ var ErrorCode = class ErrorCode {
28
+ /**
29
+ * @param error - A caught value of any type.
30
+ * @returns The `code` of the error, or `undefined` when the value is not an error with a text code.
31
+ */
32
+ static of(error) {
33
+ if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
34
+ }
35
+ /**
36
+ * @param error - A caught value of any type.
37
+ * @param code - The code to look for, for example `"ENOENT"`.
38
+ * @returns Whether the error carries exactly that code.
39
+ */
40
+ static is(error, code) {
41
+ return ErrorCode.of(error) === code;
42
+ }
43
+ };
44
+ //#endregion
45
+ //#region src/init/dependency-installer.ts
46
+ /** How an install ended. */
47
+ const InstallStatus = {
48
+ /** The user asked to skip the install. */
49
+ Skipped: "skipped",
50
+ /** The install finished. */
51
+ Installed: "installed",
52
+ /** `pnpm` is not installed. */
53
+ PackageManagerMissing: "package-manager-missing",
54
+ /** `pnpm install` exited with an error. */
55
+ Failed: "failed"
56
+ };
57
+ /** Installs the dependencies of a new project with pnpm. */
58
+ var DependencyInstaller = class DependencyInstaller {
59
+ /** The package manager. The starters pin their dependencies for pnpm. */
60
+ static command = "pnpm";
61
+ run;
62
+ /** @param run - Runs the install. nypm runs pnpm, through Corepack when Corepack exists, by default. */
63
+ constructor(run = (directory) => DependencyInstaller.installWithNypm(directory)) {
64
+ this.run = run;
65
+ }
66
+ /**
67
+ * Runs `pnpm install` in a project folder. It never throws for a failed install: the project is complete
68
+ * and the caller keeps it.
69
+ *
70
+ * @param directory - The project folder.
71
+ */
72
+ async install(directory) {
73
+ try {
74
+ await this.run(directory);
75
+ return { status: InstallStatus.Installed };
76
+ } catch (error) {
77
+ if (DependencyInstaller.isMissingProgram(error)) return { status: InstallStatus.PackageManagerMissing };
78
+ return {
79
+ status: InstallStatus.Failed,
80
+ reason: error instanceof Error ? error.message : String(error)
81
+ };
82
+ }
83
+ }
84
+ /** Whether an error says that the program does not exist: `spawn pnpm ENOENT`. */
85
+ static isMissingProgram(error) {
86
+ return ErrorCode.is(error, "ENOENT");
87
+ }
88
+ /** The default {@link InstallRunner}. The output goes to the terminal of the user. */
89
+ static async installWithNypm(directory) {
90
+ await installDependencies({
91
+ cwd: directory,
92
+ packageManager: DependencyInstaller.command
93
+ });
94
+ }
95
+ };
96
+ //#endregion
97
+ //#region src/init/init-messages.ts
98
+ /** The text for the end of an `init` run. */
99
+ var InitMessages = class InitMessages {
100
+ /** The command that starts the development server of a starter. */
101
+ static devCommand = "pnpm dev";
102
+ /** The command that writes the first migration of a starter. A starter commits no migration. */
103
+ static generateCommand = "pnpm db:generate";
104
+ /**
105
+ * The next steps after a successful run.
106
+ *
107
+ * @param changeDirectory - The folder to enter, relative to the current folder. Absent when the project is
108
+ * in the current folder.
109
+ * @param isInstalled - Whether the dependencies are installed.
110
+ */
111
+ static nextSteps(changeDirectory, isInstalled) {
112
+ return [
113
+ "Your Inferative project is ready.",
114
+ "Next steps:",
115
+ ...[
116
+ ...changeDirectory === void 0 ? [] : [`cd ${InitMessages.quote(changeDirectory)}`],
117
+ ...isInstalled ? [] : [`${DependencyInstaller.command} install`],
118
+ "cp .env.example .env",
119
+ InitMessages.generateCommand,
120
+ InitMessages.devCommand
121
+ ].map((step) => ` ${step}`),
122
+ "Fill in the values of .env first. README.md in the project explains the first run, with its first manifest sync (pnpm sync:manifest --first-sync)."
123
+ ].join("\n");
124
+ }
125
+ /** The text when pnpm is not installed. The project is complete. */
126
+ static packageManagerMissing(directory) {
127
+ return [
128
+ `The project is ready in ${directory}, but pnpm is not installed.`,
129
+ "Install pnpm: npm install --global pnpm",
130
+ `Then run "${DependencyInstaller.command} install" in the project folder.`
131
+ ].join("\n");
132
+ }
133
+ /** The text when `pnpm install` fails. The project is complete. */
134
+ static installFailed(directory, reason) {
135
+ return [`The project is ready in ${directory}, but "${DependencyInstaller.command} install" failed${reason === void 0 ? "" : `: ${reason}`}.`, "Read the messages above. Fix the cause, then run the command again in the project folder."].join("\n");
136
+ }
137
+ /** A folder name in quotes when it holds a space. */
138
+ static quote(path) {
139
+ return /\s/u.test(path) ? JSON.stringify(path) : path;
140
+ }
141
+ };
142
+ //#endregion
143
+ //#region src/process/npm-runner.ts
144
+ /**
145
+ * Starts a command with tinyexec and captures its output.
146
+ *
147
+ * It sets no `shell` option. On Windows tinyexec finds the `.cmd` shim of the command and escapes each argument for
148
+ * cmd.exe, so a folder with a space or an `&` in its name reaches npm as one argument.
149
+ */
150
+ var NpmRunner = class NpmRunner {
151
+ /** The exit code of cmd.exe for a command that it cannot find, in every language of Windows. */
152
+ static windowsCommandNotFoundExitCode = 9009;
153
+ execute;
154
+ platform;
155
+ /**
156
+ * @param execute - Starts the process. tinyexec's `x` by default.
157
+ * @param platform - The operating system. `process.platform` by default.
158
+ */
159
+ constructor(execute = x, platform = process.platform) {
160
+ this.execute = execute;
161
+ this.platform = platform;
162
+ }
163
+ /**
164
+ * Runs a command and captures its output. It never throws: a command that cannot start answers exit code 1 with
165
+ * the reason in `stderr` and `commandMissing` when the program does not exist.
166
+ *
167
+ * @param command - The program.
168
+ * @param commandArguments - The arguments, one element for each argument.
169
+ * @param workingDirectory - The directory to run in.
170
+ * @param environment - Variables added to the environment of the process.
171
+ */
172
+ async run(command, commandArguments, workingDirectory, environment) {
173
+ try {
174
+ const result = await this.execute(command, commandArguments, { nodeOptions: {
175
+ cwd: workingDirectory,
176
+ env: environment
177
+ } });
178
+ return {
179
+ exitCode: result.exitCode ?? 1,
180
+ stdout: result.stdout,
181
+ stderr: result.stderr,
182
+ ...this.isMissingInShell(result) ? { commandMissing: true } : {}
183
+ };
184
+ } catch (error) {
185
+ return {
186
+ exitCode: 1,
187
+ stdout: "",
188
+ stderr: error instanceof Error ? error.message : String(error),
189
+ commandMissing: NpmRunner.isMissingProgram(error)
190
+ };
191
+ }
192
+ }
193
+ /** Whether an error says that the program does not exist: `spawn npm ENOENT`. */
194
+ static isMissingProgram(error) {
195
+ return ErrorCode.is(error, "ENOENT");
196
+ }
197
+ /**
198
+ * Whether cmd.exe said that the program does not exist. On Windows tinyexec runs a command that it cannot find
199
+ * through cmd.exe, which exits with code 9009, where other systems throw `ENOENT`. The message is in the language
200
+ * of Windows, so the English text is only a second signal.
201
+ */
202
+ isMissingInShell(result) {
203
+ if (this.platform !== "win32" || result.exitCode === 0) return false;
204
+ return result.exitCode === NpmRunner.windowsCommandNotFoundExitCode || /is not recognized as an internal or external command/iu.test(result.stderr);
205
+ }
206
+ };
207
+ //#endregion
208
+ //#region src/registry/registry-access.ts
209
+ /** The verdict {@link RegistryAccess.probe} reaches. */
210
+ const RegistryAccessStatus = {
211
+ /** Both scopes reach GitHub Packages and the registry answered for each probe package. */
212
+ Ok: "ok",
213
+ /** A scope does not point at GitHub Packages. */
214
+ ScopeNotRouted: "scope-not-routed",
215
+ /** The registry refused the request for lacking, or holding invalid, credentials. */
216
+ Unauthorized: "unauthorized",
217
+ /** npm cannot be started: Node.js is installed without it, or not at all. */
218
+ NpmNotFound: "npm-not-found",
219
+ /** The registry could not be reached (DNS, network, timeout or an unknown failure). */
220
+ Unreachable: "unreachable",
221
+ /** The registry authorized the request, but the probe package is not there. */
222
+ NotFound: "not-found"
223
+ };
224
+ /**
225
+ * Probes whether `@inferative/*` and the `@heximon/*` packages it depends on are readable from GitHub
226
+ * Packages.
227
+ *
228
+ * It asks npm itself, so the verdict follows the same `.npmrc` files an install reads: the project's
229
+ * and the user's.
230
+ */
231
+ var RegistryAccess = class RegistryAccess {
232
+ /** The registry the `@inferative/*` and `@heximon/*` packages publish to. */
233
+ static gitHubPackagesRegistry = "https://npm.pkg.github.com";
234
+ /**
235
+ * The scopes that must resolve to {@link gitHubPackagesRegistry}, each with the package the probe reads. A
236
+ * token can read one organization and not the other, so each scope gets its own read.
237
+ */
238
+ static scopes = [{
239
+ scope: "@inferative",
240
+ probePackage: "@inferative/site"
241
+ }, {
242
+ scope: "@heximon",
243
+ probePackage: "@heximon/runtime"
244
+ }];
245
+ /** The scope line a project's `.npmrc` carries for a scope. */
246
+ static scopeLine(scope) {
247
+ return `${scope}:registry=${RegistryAccess.gitHubPackagesRegistry}`;
248
+ }
249
+ run;
250
+ workingDirectory;
251
+ isProjectRoot;
252
+ /**
253
+ * @param workingDirectory - The directory npm reads its project `.npmrc` from.
254
+ * @param run - The process runner. It spawns `npm` by default.
255
+ * @param isProjectRoot - Whether `workingDirectory` is the project root, even when it holds no `package.json`
256
+ * yet. npm then reads its `.npmrc`, and not the one of an ancestor project (`--prefix`). A caller that has
257
+ * just made the folder sets it.
258
+ */
259
+ constructor(workingDirectory = process.cwd(), run = (command, commandArguments, directory, environment) => new NpmRunner().run(command, commandArguments, directory, environment), isProjectRoot = false) {
260
+ this.workingDirectory = workingDirectory;
261
+ this.run = run;
262
+ this.isProjectRoot = isProjectRoot;
263
+ }
264
+ /**
265
+ * Checks that every scope points at GitHub Packages, then reads one probe package through each scope.
266
+ *
267
+ * @param environment - The environment npm runs with. An `.npmrc` that reads `${NODE_AUTH_TOKEN}` finds the
268
+ * token here, so a caller passes the environment it loaded from the site's `.env`.
269
+ * @returns The verdict, with the scope it is about and the raw output as `detail`.
270
+ */
271
+ async probe(environment = process.env) {
272
+ for (const { scope } of RegistryAccess.scopes) {
273
+ const configured = await this.run("npm", this.npmArguments([
274
+ "config",
275
+ "get",
276
+ `${scope}:registry`
277
+ ]), this.workingDirectory, environment);
278
+ if (configured.commandMissing === true) return {
279
+ status: RegistryAccessStatus.NpmNotFound,
280
+ detail: configured.stderr.trim()
281
+ };
282
+ if (RegistryAccess.withoutTrailingSlash(configured.stdout) !== RegistryAccess.gitHubPackagesRegistry) return {
283
+ status: RegistryAccessStatus.ScopeNotRouted,
284
+ scope,
285
+ detail: configured.stdout.trim()
286
+ };
287
+ }
288
+ for (const { scope, probePackage } of RegistryAccess.scopes) {
289
+ const result = await this.run("npm", this.npmArguments([
290
+ "view",
291
+ probePackage,
292
+ "version",
293
+ "--json",
294
+ "--fetch-retries=0",
295
+ "--fetch-timeout=15000"
296
+ ]), this.workingDirectory, environment);
297
+ if (result.commandMissing === true) return {
298
+ status: RegistryAccessStatus.NpmNotFound,
299
+ detail: result.stderr.trim()
300
+ };
301
+ if (result.exitCode !== 0) return RegistryAccess.failure(scope, `${result.stdout}\n${result.stderr}`);
302
+ }
303
+ return { status: RegistryAccessStatus.Ok };
304
+ }
305
+ /** The arguments of one npm call, with the prefix that pins the project root when the caller asked for it. */
306
+ npmArguments(commandArguments) {
307
+ return this.isProjectRoot ? [
308
+ ...commandArguments,
309
+ "--prefix",
310
+ this.workingDirectory
311
+ ] : commandArguments;
312
+ }
313
+ /**
314
+ * The lines that tell a user how to fix a verdict.
315
+ *
316
+ * @param report - The report that is not {@link RegistryAccessStatus.Ok}.
317
+ */
318
+ static fixInstructions(report) {
319
+ if (report.status === RegistryAccessStatus.NpmNotFound) return [
320
+ "Cannot start npm.",
321
+ "Install Node.js (version 24 or later) with npm, from https://nodejs.org, and make sure npm is on your PATH.",
322
+ "Then run the command again."
323
+ ].join("\n");
324
+ if (report.status === RegistryAccessStatus.Unreachable) return [
325
+ `Cannot reach ${RegistryAccess.gitHubPackagesRegistry}.`,
326
+ "Check your network connection.",
327
+ "Then run the command again."
328
+ ].join("\n");
329
+ const scope = report.scope ?? RegistryAccess.scopes[0]?.scope ?? "@inferative";
330
+ return [
331
+ report.status === RegistryAccessStatus.ScopeNotRouted ? `The scope ${scope} does not point at GitHub Packages.` : `Cannot read ${scope}/* from GitHub Packages.`,
332
+ "Add these lines to ~/.npmrc, or to the .npmrc of the project:",
333
+ ` ${RegistryAccess.scopeLine(scope)}`,
334
+ " //npm.pkg.github.com/:_authToken=<a GitHub token with the read:packages scope>",
335
+ "Then run the command again."
336
+ ].join("\n");
337
+ }
338
+ /** The verdict for a failed read of the probe package of `scope`. */
339
+ static failure(scope, output) {
340
+ const detail = output.trim();
341
+ if (/E401|E403|ENEEDAUTH/u.test(output)) return {
342
+ status: RegistryAccessStatus.Unauthorized,
343
+ scope,
344
+ detail
345
+ };
346
+ if (/E404/u.test(output)) return {
347
+ status: RegistryAccessStatus.NotFound,
348
+ scope,
349
+ detail
350
+ };
351
+ return {
352
+ status: RegistryAccessStatus.Unreachable,
353
+ scope,
354
+ detail
355
+ };
356
+ }
357
+ /** A registry address without the slash npm may add at its end. */
358
+ static withoutTrailingSlash(address) {
359
+ return address.trim().replace(/\/+$/u, "");
360
+ }
361
+ };
362
+ //#endregion
363
+ //#region src/init/init-error.ts
364
+ /** A failure that `inferative init` reports in plain words, without a stack trace. */
365
+ var InitError = class extends Error {
366
+ constructor(message, options) {
367
+ super(message, options);
368
+ this.name = "InitError";
369
+ }
370
+ };
371
+ //#endregion
372
+ //#region src/init/init-target.ts
373
+ /**
374
+ * An accepted target folder. It is absent or empty when accepted, so every entry in it later belongs to this run.
375
+ */
376
+ var InitTarget = class InitTarget {
377
+ /** The package name of a folder whose own name has no usable character. */
378
+ static fallbackPackageName = "inferative-app";
379
+ static packageNamePattern = /^[a-z0-9][a-z0-9._-]*$/u;
380
+ /** The absolute folder. */
381
+ directory;
382
+ /** The `name` that goes into the project's `package.json`. */
383
+ packageName;
384
+ /** The parents that this run created, the nearest first. Empty when the folder existed. */
385
+ createdParents;
386
+ isCreated;
387
+ constructor(directory, packageName, isCreated, createdParents) {
388
+ this.directory = directory;
389
+ this.packageName = packageName;
390
+ this.isCreated = isCreated;
391
+ this.createdParents = createdParents;
392
+ }
393
+ /**
394
+ * Checks a package name.
395
+ *
396
+ * @param name - The candidate name.
397
+ * @returns `undefined` when the name is valid, otherwise the problem in plain words.
398
+ */
399
+ static validatePackageName(name) {
400
+ if (InitTarget.packageNamePattern.test(name)) return;
401
+ return `"${name}" is not a valid package name. Use lowercase letters, digits, and . _ - only, and start with a letter or a digit.`;
402
+ }
403
+ /**
404
+ * Turns the name of an existing folder into a valid package name: lowercase, with each run of other
405
+ * characters replaced by one `-`.
406
+ *
407
+ * @param folderName - The base name of the folder.
408
+ */
409
+ static normalizePackageName(folderName) {
410
+ const normalized = folderName.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^[^a-z0-9]+/u, "").replace(/[^a-z0-9]+$/u, "");
411
+ return normalized === "" ? InitTarget.fallbackPackageName : normalized;
412
+ }
413
+ /**
414
+ * Accepts the target folder, and creates it when it does not exist.
415
+ *
416
+ * A folder that the user names must have a valid package name as its base name. The current folder keeps
417
+ * its name on disk, so its package name is normalised instead.
418
+ *
419
+ * @param request - Where the folder is.
420
+ * @throws {InitError} When the name is invalid, the path is a file, or the folder is not empty.
421
+ */
422
+ static async prepare(request) {
423
+ const base = resolve(request.workingDirectory);
424
+ const directory = request.directory === void 0 ? base : resolve(base, request.directory);
425
+ const isBaseFolder = directory === base;
426
+ const folderName = basename(directory);
427
+ const packageName = isBaseFolder ? InitTarget.normalizePackageName(folderName) : folderName;
428
+ const problem = InitTarget.validatePackageName(packageName);
429
+ if (problem !== void 0) throw new InitError(problem);
430
+ const entries = await InitTarget.readEntries(directory);
431
+ if (entries === void 0) {
432
+ const createdParents = await InitTarget.missingAncestors(directory);
433
+ await mkdir(directory, { recursive: true });
434
+ return new InitTarget(directory, packageName, true, createdParents);
435
+ }
436
+ if (entries.length > 0) throw new InitError(`The folder ${directory} is not empty. Choose an empty folder or a new name. Nothing was changed.`);
437
+ return new InitTarget(directory, packageName, false, []);
438
+ }
439
+ /**
440
+ * Removes everything this run wrote: the folder that it created, or else every entry of an existing folder.
441
+ * A parent that this run created goes too, but only while it is empty: another process may have put a
442
+ * sibling project there.
443
+ */
444
+ async discard() {
445
+ if (!this.isCreated) {
446
+ await this.clearExcept();
447
+ return;
448
+ }
449
+ await rm(this.directory, {
450
+ recursive: true,
451
+ force: true
452
+ });
453
+ for (const parent of this.createdParents) try {
454
+ await rmdir(parent);
455
+ } catch {
456
+ return;
457
+ }
458
+ }
459
+ /**
460
+ * Removes the entries of the folder, for example the half-written files of a failed download.
461
+ *
462
+ * @param keep - The name of one entry to keep.
463
+ */
464
+ async clearExcept(keep) {
465
+ for (const entry of await InitTarget.readEntries(this.directory) ?? []) if (entry !== keep) await rm(join(this.directory, entry), {
466
+ recursive: true,
467
+ force: true
468
+ });
469
+ }
470
+ /** The parents of `directory` that do not exist yet, the nearest first. */
471
+ static async missingAncestors(directory) {
472
+ const missing = [];
473
+ let current = dirname(directory);
474
+ while (current !== dirname(current) && await InitTarget.readEntries(current) === void 0) {
475
+ missing.push(current);
476
+ current = dirname(current);
477
+ }
478
+ return missing;
479
+ }
480
+ /** The entries of a folder, or `undefined` when it does not exist. */
481
+ static async readEntries(directory) {
482
+ try {
483
+ return await readdir(directory);
484
+ } catch (error) {
485
+ const code = ErrorCode.of(error);
486
+ if (code === "ENOENT") return;
487
+ if (code === "ENOTDIR") throw new InitError(`${directory} is a file, not a folder.`);
488
+ throw error;
489
+ }
490
+ }
491
+ };
492
+ //#endregion
493
+ //#region src/init/manifest-stamper.ts
494
+ /** Rewrites the `package.json` that a starter ships. */
495
+ var ManifestStamper = class ManifestStamper {
496
+ /** The dependency sections whose Inferative ranges follow the command line's version. */
497
+ static sections = ["dependencies", "devDependencies"];
498
+ /** The command line itself, which a starter may list beside the `@inferative/*` packages. */
499
+ static commandLineName = "inferative";
500
+ /** The scope prefix of the Inferative libraries. */
501
+ static scopePrefix = "@inferative/";
502
+ /**
503
+ * Sets `name`, and sets the range of `inferative` and of each `@inferative/*` dependency to `^<version>`.
504
+ * Every other range stays as the starter pins it.
505
+ *
506
+ * @param directory - The project folder.
507
+ * @param packageName - The new `name`.
508
+ * @param version - The version of the command line.
509
+ * @throws {InitError} When the starter has no readable `package.json`.
510
+ */
511
+ static async stamp(directory, packageName, version) {
512
+ const path = join(directory, "package.json");
513
+ const manifest = ManifestStamper.parse(await ManifestStamper.read(path), path);
514
+ manifest["name"] = packageName;
515
+ for (const section of ManifestStamper.sections) {
516
+ const dependencies = manifest[section];
517
+ if (!ManifestStamper.isRecord(dependencies)) continue;
518
+ for (const dependency of Object.keys(dependencies)) if (ManifestStamper.isInferative(dependency)) dependencies[dependency] = `^${version}`;
519
+ }
520
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
521
+ }
522
+ /** Whether a dependency name is `inferative` or one of the `@inferative/*` libraries. */
523
+ static isInferative(name) {
524
+ return name === ManifestStamper.commandLineName || name.startsWith(ManifestStamper.scopePrefix);
525
+ }
526
+ static async read(path) {
527
+ try {
528
+ return await readFile(path, "utf8");
529
+ } catch (error) {
530
+ if (ErrorCode.is(error, "ENOENT")) throw new InitError("The starter has no package.json.");
531
+ throw error;
532
+ }
533
+ }
534
+ static parse(text, path) {
535
+ let parsed;
536
+ try {
537
+ parsed = JSON.parse(text);
538
+ } catch (error) {
539
+ throw new InitError(`${path} is not valid JSON.`, { cause: error });
540
+ }
541
+ if (!ManifestStamper.isRecord(parsed)) throw new InitError(`${path} does not hold a JSON object.`);
542
+ return parsed;
543
+ }
544
+ static isRecord(value) {
545
+ return typeof value === "object" && value !== null && !Array.isArray(value);
546
+ }
547
+ };
548
+ //#endregion
549
+ //#region src/init/npmrc-scopes.ts
550
+ /** Writes the scope lines of `@inferative` and `@heximon` into a project's `.npmrc`. It never writes a token. */
551
+ var NpmrcScopes = class NpmrcScopes {
552
+ /** The file name. */
553
+ static filename = ".npmrc";
554
+ /** The scope lines, one for each scope in {@link RegistryAccess.scopes}. */
555
+ static lines() {
556
+ return RegistryAccess.scopes.map((entry) => RegistryAccess.scopeLine(entry.scope));
557
+ }
558
+ /**
559
+ * Adds the missing scope lines to the text of an `.npmrc`. It keeps every existing line.
560
+ *
561
+ * @param content - The current text. It may be empty.
562
+ * @returns The text with each missing line appended, or `content` itself when none is missing.
563
+ */
564
+ static merge(content) {
565
+ const present = new Set(content.split(/\r?\n/u).map((line) => line.trim()));
566
+ const missing = NpmrcScopes.lines().filter((line) => !present.has(line));
567
+ if (missing.length === 0) return content;
568
+ return `${content}${content === "" || content.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`;
569
+ }
570
+ /**
571
+ * Creates the `.npmrc` of a project folder, or completes the one that exists.
572
+ *
573
+ * @param directory - The project folder.
574
+ */
575
+ static async apply(directory) {
576
+ const path = join(directory, NpmrcScopes.filename);
577
+ const current = await NpmrcScopes.read(path);
578
+ const merged = NpmrcScopes.merge(current);
579
+ if (merged !== current) await writeFile(path, merged);
580
+ }
581
+ /** The text of a file, or an empty text when the file does not exist. */
582
+ static async read(path) {
583
+ try {
584
+ return await readFile(path, "utf8");
585
+ } catch (error) {
586
+ if (ErrorCode.is(error, "ENOENT")) return "";
587
+ throw error;
588
+ }
589
+ }
590
+ };
591
+ //#endregion
592
+ //#region src/init/starter-source.ts
593
+ /** A download that failed. Only this failure makes {@link StarterSource.fetch} try the next source. */
594
+ var StarterDownloadError = class StarterDownloadError extends InitError {
595
+ /** The giget sources that failed. */
596
+ sources;
597
+ /** Why the download failed, in one line. */
598
+ reason;
599
+ constructor(sources, reason, options) {
600
+ super(sources.length === 1 ? `Cannot download the starter from ${sources[0] ?? ""}: ${reason}` : `Cannot download the starter. ${reason}`, options);
601
+ this.name = "StarterDownloadError";
602
+ this.sources = sources;
603
+ this.reason = reason;
604
+ }
605
+ /**
606
+ * The failure of one source.
607
+ *
608
+ * @param source - The giget source.
609
+ * @param cause - What the download threw.
610
+ */
611
+ static of(source, cause) {
612
+ return new StarterDownloadError([source], cause instanceof Error ? cause.message : String(cause), { cause });
613
+ }
614
+ };
615
+ /** Fetches the files of a starter. */
616
+ var StarterSource = class StarterSource {
617
+ /** The public repository that holds the starters, one folder for each. */
618
+ static repository = "inferative/starters";
619
+ /** The starter that `init` uses when none is named. */
620
+ static defaultStarter = "nuxt-cloudflare";
621
+ /** The branch that the default sources fall back to. */
622
+ static fallbackBranch = "main";
623
+ static starterNamePattern = /^[a-z0-9][a-z0-9-]*$/u;
624
+ download;
625
+ /** @param download - The download function. It calls giget by default. */
626
+ constructor(download = (source, directory) => StarterSource.downloadWithGiget(source, directory)) {
627
+ this.download = download;
628
+ }
629
+ /**
630
+ * The giget sources to try, in order: the branch for this minor version, then `main`.
631
+ *
632
+ * @param starter - The starter folder name.
633
+ * @param version - The version of the command line.
634
+ */
635
+ static defaultSources(starter, version) {
636
+ const base = `gh:${StarterSource.repository}/${starter}`;
637
+ return [`${base}#v${CliVersion.minor(version)}`, `${base}#${StarterSource.fallbackBranch}`];
638
+ }
639
+ /**
640
+ * Checks a starter name, which becomes a path segment.
641
+ *
642
+ * @throws {InitError} When the name is not a plain lowercase folder name.
643
+ */
644
+ static assertStarterName(starter) {
645
+ if (!StarterSource.starterNamePattern.test(starter)) throw new InitError(`"${starter}" is not a starter name. Use lowercase letters, digits, and - only.`);
646
+ }
647
+ /**
648
+ * Downloads the starter from the first source that works.
649
+ *
650
+ * Only a failed download moves on to the next source. Any other error stops the run, because the next
651
+ * source would fail the same way.
652
+ *
653
+ * @param sources - The giget sources, in order.
654
+ * @param directory - The folder to fill.
655
+ * @param resetDirectory - Called before each later source, to remove the half-written files of the last one.
656
+ * @returns The source that worked.
657
+ * @throws {StarterDownloadError} When every source failed. The message holds each failure.
658
+ */
659
+ async fetch(sources, directory, resetDirectory) {
660
+ const failures = [];
661
+ for (const source of sources) try {
662
+ await this.download(source, directory);
663
+ return source;
664
+ } catch (error) {
665
+ failures.push(StarterDownloadError.of(source, error));
666
+ await resetDirectory();
667
+ }
668
+ const [first] = failures;
669
+ if (failures.length === 1 && first !== void 0) throw first;
670
+ throw new StarterDownloadError(sources, `Tried ${failures.map((failure) => `${failure.sources.join("")} (${failure.reason})`).join(", then ")}.`);
671
+ }
672
+ /**
673
+ * Copies a starter from a local starters folder.
674
+ *
675
+ * @param localDirectory - The folder that holds the starters.
676
+ * @param starter - The starter folder name.
677
+ * @param directory - The folder to fill.
678
+ * @throws {InitError} When the starter folder does not exist.
679
+ */
680
+ async copyLocal(localDirectory, starter, directory) {
681
+ const source = resolve(localDirectory, starter);
682
+ try {
683
+ if (!(await stat(source)).isDirectory()) throw new InitError(`${source} is not a folder.`);
684
+ } catch (error) {
685
+ if (ErrorCode.is(error, "ENOENT")) throw new InitError(`The starter folder ${source} does not exist.`);
686
+ throw error;
687
+ }
688
+ await cp(source, directory, {
689
+ recursive: true,
690
+ filter: (path) => !path.split(/[\\/]/u).includes("node_modules")
691
+ });
692
+ return source;
693
+ }
694
+ /** The default {@link StarterDownload}. It loads giget on first use, so a local copy needs no giget. */
695
+ static async downloadWithGiget(source, directory) {
696
+ const { downloadTemplate } = await import("giget");
697
+ await downloadTemplate(source, {
698
+ dir: directory,
699
+ force: true
700
+ });
701
+ }
702
+ };
703
+ //#endregion
704
+ //#region src/init/project-initializer.ts
705
+ /** Creates a project from a starter. */
706
+ var ProjectInitializer = class ProjectInitializer {
707
+ starters;
708
+ probeRegistry;
709
+ installer;
710
+ /** @param dependencies - Replacements for the download, the registry probe and the install. */
711
+ constructor(dependencies = {}) {
712
+ this.starters = new StarterSource(dependencies.download);
713
+ this.probeRegistry = dependencies.probeRegistry ?? ((directory) => new RegistryAccess(directory, dependencies.runNpm, true).probe());
714
+ this.installer = new DependencyInstaller(dependencies.runInstall);
715
+ }
716
+ /**
717
+ * Creates the project.
718
+ *
719
+ * Before the install, any failure removes what this run wrote and throws. A failed install keeps the
720
+ * project, because it is complete, and reports the failure in the result.
721
+ *
722
+ * @param request - What to create, and where.
723
+ * @throws {InitError} When the folder, the registry, the starter or the request is not usable.
724
+ */
725
+ async initialize(request) {
726
+ ProjectInitializer.assertRequest(request);
727
+ const target = await InitTarget.prepare(request);
728
+ let source;
729
+ try {
730
+ await this.assertRegistryAccess(target);
731
+ source = await this.fetchStarter(request, target);
732
+ await NpmrcScopes.apply(target.directory);
733
+ await ManifestStamper.stamp(target.directory, target.packageName, request.version);
734
+ } catch (error) {
735
+ await target.discard();
736
+ throw error;
737
+ }
738
+ const base = {
739
+ projectDirectory: target.directory,
740
+ packageName: target.packageName,
741
+ source
742
+ };
743
+ if (!request.install) return {
744
+ ...base,
745
+ install: InstallStatus.Skipped
746
+ };
747
+ const outcome = await this.installer.install(target.directory);
748
+ return {
749
+ ...base,
750
+ install: outcome.status,
751
+ ...outcome.reason === void 0 ? {} : { installError: outcome.reason }
752
+ };
753
+ }
754
+ /** Writes the scope lines, then fails when the registry is not readable. The download has not started. */
755
+ async assertRegistryAccess(target) {
756
+ await NpmrcScopes.apply(target.directory);
757
+ const report = await this.probeRegistry(target.directory);
758
+ if (report.status !== RegistryAccessStatus.Ok) throw new InitError(`${RegistryAccess.fixInstructions(report)}\nNothing was changed.`);
759
+ }
760
+ /** Fills the folder from the local copy or the download, and answers where the files came from. */
761
+ fetchStarter(request, target) {
762
+ if (request.local !== void 0) return this.starters.copyLocal(request.local, request.starter, target.directory);
763
+ const sources = request.source === void 0 ? StarterSource.defaultSources(request.starter, request.version) : [request.source];
764
+ return this.starters.fetch(sources, target.directory, () => target.clearExcept(NpmrcScopes.filename));
765
+ }
766
+ /** Refuses a request that cannot work before any file exists. */
767
+ static assertRequest(request) {
768
+ if (request.local !== void 0 && request.source !== void 0) throw new InitError("Use --local or --source, not both.");
769
+ StarterSource.assertStarterName(request.starter);
770
+ }
771
+ };
772
+ //#endregion
773
+ //#region src/init/init-command.ts
774
+ /** The `init` command. */
775
+ var InitCommand = class InitCommand {
776
+ /** The name it is registered under. */
777
+ static name = "init";
778
+ /** The exit code of a run that made a usable project. */
779
+ static okExitCode = 0;
780
+ /** The exit code of a run that failed, including a failed install. */
781
+ static failureExitCode = 1;
782
+ /** The command definition. */
783
+ static definition() {
784
+ return defineCommand({
785
+ meta: {
786
+ name: InitCommand.name,
787
+ description: "Create a new Inferative project from a starter."
788
+ },
789
+ args: InitCommand.args(),
790
+ run: async (context) => {
791
+ const args = context.args;
792
+ process.exitCode = await InitCommand.execute({
793
+ ...typeof args["directory"] === "string" ? { directory: args["directory"] } : {},
794
+ ...typeof args["starter"] === "string" ? { starter: args["starter"] } : {},
795
+ ...typeof args["source"] === "string" ? { source: args["source"] } : {},
796
+ ...typeof args["local"] === "string" ? { local: args["local"] } : {},
797
+ ...typeof args["cwd"] === "string" ? { cwd: args["cwd"] } : {},
798
+ install: args["install"] !== false
799
+ });
800
+ }
801
+ });
802
+ }
803
+ /** The argument definition, which `create-inferative` shares. */
804
+ static args() {
805
+ return {
806
+ directory: {
807
+ type: "positional",
808
+ description: "The folder to create. The current folder, which must be empty, when absent.",
809
+ required: false
810
+ },
811
+ starter: {
812
+ type: "string",
813
+ description: `The starter to use. ${StarterSource.defaultStarter} by default.`,
814
+ default: StarterSource.defaultStarter,
815
+ valueHint: "name"
816
+ },
817
+ source: {
818
+ type: "string",
819
+ description: "A giget source to download instead of the default starters.",
820
+ valueHint: "giget-source"
821
+ },
822
+ local: {
823
+ type: "string",
824
+ description: "Copy the starter from this starters folder. Nothing is downloaded.",
825
+ valueHint: "path"
826
+ },
827
+ install: {
828
+ type: "boolean",
829
+ description: "Run pnpm install after the files are written. Use --no-install to skip.",
830
+ default: true
831
+ },
832
+ cwd: {
833
+ type: "string",
834
+ description: "The folder that a named directory is created in. The current folder by default.",
835
+ valueHint: "path"
836
+ }
837
+ };
838
+ }
839
+ /**
840
+ * Creates one project and answers the exit code.
841
+ *
842
+ * @param args - What the invocation was asked to do.
843
+ * @param options - The working directory, the version and the replaceable parts. A test supplies them.
844
+ * @returns {@link okExitCode} when the project is usable, otherwise {@link failureExitCode}.
845
+ */
846
+ static async execute(args, options = {}) {
847
+ const print = options.print ?? ((line) => console.log(line));
848
+ const fail = options.fail ?? ((line) => void process.stderr.write(`${line}\n`));
849
+ const currentDirectory = options.cwd ?? process.cwd();
850
+ try {
851
+ const result = await new ProjectInitializer(options.dependencies).initialize({
852
+ ...args.directory === void 0 ? {} : { directory: args.directory },
853
+ workingDirectory: resolve(currentDirectory, args.cwd ?? "."),
854
+ starter: args.starter ?? StarterSource.defaultStarter,
855
+ ...args.source === void 0 ? {} : { source: args.source },
856
+ ...args.local === void 0 ? {} : { local: resolve(currentDirectory, args.local) },
857
+ install: args.install !== false,
858
+ version: options.version ?? CliVersion.current()
859
+ });
860
+ if (result.install === InstallStatus.PackageManagerMissing) {
861
+ fail(InitMessages.packageManagerMissing(result.projectDirectory));
862
+ return InitCommand.failureExitCode;
863
+ }
864
+ if (result.install === InstallStatus.Failed) {
865
+ fail(InitMessages.installFailed(result.projectDirectory, result.installError));
866
+ return InitCommand.failureExitCode;
867
+ }
868
+ const changeDirectory = relative(currentDirectory, result.projectDirectory);
869
+ print(InitMessages.nextSteps(changeDirectory === "" ? void 0 : changeDirectory, result.install === InstallStatus.Installed));
870
+ return InitCommand.okExitCode;
871
+ } catch (error) {
872
+ fail(error instanceof Error ? error.message : String(error));
873
+ return InitCommand.failureExitCode;
874
+ }
875
+ }
876
+ };
877
+ //#endregion
878
+ export { CliVersion as i, RegistryAccess as n, RegistryAccessStatus as r, InitCommand as t };