create-upwind 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { spawn } from "node:child_process";
4
+ import { once } from "node:events";
5
+ import path from "node:path";
6
+ import { cp, lstat, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { fileURLToPath } from "node:url";
9
+ //#region src/package-manager.ts
10
+ /**
11
+ * Which package manager scaffolded this, and the install it runs.
12
+ *
13
+ * `pnpm create upwind`, `npm create upwind`, `yarn create upwind` and `bun create upwind` all reach
14
+ * this same program, and the one thing they leave behind to say which they were is
15
+ * `npm_config_user_agent`. A project installed with the manager its author started from is one whose
16
+ * lockfile is the one they expect.
17
+ */
18
+ const PACKAGE_MANAGERS = [
19
+ "npm",
20
+ "pnpm",
21
+ "yarn",
22
+ "bun"
23
+ ];
24
+ /** `pnpm/12.4.1 npm/? node/? linux x64` — the name is everything before the first slash. */
25
+ function detectPackageManager() {
26
+ const [name] = (process.env["npm_config_user_agent"] ?? "").split("/", 1);
27
+ return PACKAGE_MANAGERS.find((manager) => manager === name) ?? "npm";
28
+ }
29
+ /** The application is on disk by now, whatever went wrong here; say so, and say what to do. */
30
+ function refuse(manager, why) {
31
+ throw new Error(`${why}. The application is written; run \`${manager} install\` in it once you know why.`);
32
+ }
33
+ async function install(manager, cwd) {
34
+ const child = spawn(manager, ["install"], {
35
+ cwd,
36
+ stdio: "inherit",
37
+ shell: process.platform === "win32"
38
+ });
39
+ let code;
40
+ try {
41
+ [code] = await once(child, "exit");
42
+ } catch {
43
+ refuse(manager, `\`${manager}\` could not be run; it may not be installed`);
44
+ }
45
+ if (code !== 0) refuse(manager, `\`${manager} install\` failed`);
46
+ }
47
+ /** The two the bare word does not reach a project script through. */
48
+ const NEEDS_RUN = /* @__PURE__ */ new Set(["bun", "npm"]);
49
+ /**
50
+ * How a developer runs one of the project's scripts with this manager.
51
+ *
52
+ * `npm dev` is not a command at all, and `bun build` is a different one: Bun's own bundler, which
53
+ * answers a project's `build` script with "Missing entrypoints". Both take `run`. `pnpm` and `yarn`
54
+ * pass an unknown word to the scripts, and `pnpm run dev` would only be longer.
55
+ */
56
+ function runCommand(manager, script) {
57
+ return NEEDS_RUN.has(manager) ? `${manager} run ${script}` : `${manager} ${script}`;
58
+ }
59
+ //#endregion
60
+ //#region src/args.ts
61
+ /** At most one `--use-*`: two would be a question about which, and there is no good answer. */
62
+ function packageManagerOf(values) {
63
+ const chosen = PACKAGE_MANAGERS.filter((manager) => values[`use-${manager}`] === true);
64
+ if (chosen.length > 1) throw new Error(`only one package manager can be asked for, and ${chosen.length} were`);
65
+ return chosen[0];
66
+ }
67
+ /** `--help` and `--version` are options here rather than words looked for in the arguments, so that
68
+ * `create-upwind -- --help` makes a directory called `--help` the way `parseArgs` says it should. */
69
+ function answerOf(values) {
70
+ if (values["help"] === true) return "help";
71
+ return values["version"] === true ? "version" : void 0;
72
+ }
73
+ function parseCreateRequest(args) {
74
+ const { values, positionals } = parseArgs({
75
+ args: [...args],
76
+ options: {
77
+ git: {
78
+ type: "boolean",
79
+ default: true
80
+ },
81
+ help: {
82
+ type: "boolean",
83
+ short: "h"
84
+ },
85
+ "skip-install": { type: "boolean" },
86
+ "use-bun": { type: "boolean" },
87
+ "use-npm": { type: "boolean" },
88
+ "use-pnpm": { type: "boolean" },
89
+ "use-yarn": { type: "boolean" },
90
+ version: {
91
+ type: "boolean",
92
+ short: "v"
93
+ }
94
+ },
95
+ allowNegative: true,
96
+ allowPositionals: true,
97
+ strict: true
98
+ });
99
+ const answer = answerOf(values);
100
+ if (answer === void 0 && positionals.length > 1) throw new Error(`one directory at most, and ${positionals.length} were given`);
101
+ return {
102
+ answer,
103
+ directory: positionals[0],
104
+ install: values["skip-install"] !== true,
105
+ git: values.git,
106
+ packageManager: answer === void 0 ? packageManagerOf(values) : void 0
107
+ };
108
+ }
109
+ //#endregion
110
+ //#region src/git.ts
111
+ /**
112
+ * A first commit, where there is a repository to be made.
113
+ *
114
+ * Never fatal. A machine with no git, a git with no identity configured, a directory already inside
115
+ * somebody's repository: none of those is a reason to have failed to scaffold an application, and
116
+ * all of them are things a developer can see for themselves. A repository this started and could not
117
+ * finish is removed again, so what is left is either a clean first commit or no `.git` at all.
118
+ */
119
+ const FIRST_COMMIT = "Initial commit from create-upwind";
120
+ /** Run git, quietly; `undefined` when git itself is not there. */
121
+ async function git(args, cwd) {
122
+ const child = spawn("git", args, {
123
+ cwd,
124
+ stdio: "ignore"
125
+ });
126
+ try {
127
+ const [code] = await once(child, "exit");
128
+ return code ?? 1;
129
+ } catch {
130
+ return;
131
+ }
132
+ }
133
+ /** Is this directory already part of a repository? Then its history is not this program's to start. */
134
+ async function insideRepository(cwd) {
135
+ return await git(["rev-parse", "--is-inside-work-tree"], cwd) === 0;
136
+ }
137
+ /**
138
+ * Does the target already hold a checkout of its own? Then nothing here is this program's.
139
+ *
140
+ * `lstat`, not `stat`: a `.git` that is a symlink — dangling, or pointing at a repository somewhere
141
+ * else — is still an entry this program did not put there. Following it would answer "nothing here"
142
+ * for a broken one, and `git init` would then write through it, into a directory nobody named.
143
+ */
144
+ async function hasGitEntry(target) {
145
+ try {
146
+ await lstat(path.join(target, ".git"));
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+ /** True when a repository was made and committed to. */
153
+ async function initRepository(target) {
154
+ if (await hasGitEntry(target) || await insideRepository(target)) return false;
155
+ if (await git([
156
+ "init",
157
+ "-b",
158
+ "main"
159
+ ], target) !== 0) return false;
160
+ if ((await git(["add", "-A"], target) === 0 ? await git([
161
+ "commit",
162
+ "-m",
163
+ FIRST_COMMIT
164
+ ], target) : void 0) === 0) return true;
165
+ try {
166
+ await rm(path.join(target, ".git"), {
167
+ recursive: true,
168
+ force: true
169
+ });
170
+ } catch {}
171
+ return false;
172
+ }
173
+ //#endregion
174
+ //#region src/version.ts
175
+ /**
176
+ * This scaffolder's own version, and what it asks for of the packages released beside it.
177
+ *
178
+ * `create-upwind@x.y.z` scaffolds `upwind@^x.y.z`: the two are published from one tag, which the
179
+ * release verifies says the version every package says, so a scaffolder and the CLI it wires in are
180
+ * always of the same generation. A version that cannot be read falls back to `latest` — a scaffolded
181
+ * project that installs the newest is a better answer than one that installs nothing.
182
+ */
183
+ const FALLBACK = "latest";
184
+ async function ownRange() {
185
+ try {
186
+ const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
187
+ if (typeof manifest === "object" && manifest !== null && "version" in manifest && typeof manifest.version === "string") return `^${manifest.version}`;
188
+ return FALLBACK;
189
+ } catch {
190
+ return FALLBACK;
191
+ }
192
+ }
193
+ /** The version this reports for `--version`, or nothing when its own manifest could not be read. */
194
+ async function ownVersion() {
195
+ const range = await ownRange();
196
+ return range === FALLBACK ? void 0 : range.slice(1);
197
+ }
198
+ //#endregion
199
+ //#region src/manifest.ts
200
+ /**
201
+ * The `package.json` a scaffolded project starts with.
202
+ *
203
+ * Written rather than shipped in the template, so the name is the directory's and the versions are
204
+ * this release's. Two of them are the whole point of the file:
205
+ *
206
+ * - **`next`, `react`, `react-dom` are dependencies, not suggestions.** upwind runs the *project's*
207
+ * Next.js — `upwind dev` resolves it from the project and refuses to start without it — so an
208
+ * application that upwind can run is one that has Next.js of its own.
209
+ * - **`upwind` and the adapter come from this release.** `create-upwind@x.y.z` asks for `^x.y.z` of
210
+ * both, and the release publishes them from one tag, so a scaffolded project is wired to the
211
+ * generation that scaffolded it.
212
+ *
213
+ * The rest is what Next.js 16 and Tailwind 4 need, and nothing else: no ESLint, no `src/`, no
214
+ * component library. `create-next-app --empty` is the shape.
215
+ */
216
+ /**
217
+ * What the template is written against.
218
+ *
219
+ * `next` is a caret on the version this repository builds against, and stays inside
220
+ * `SUPPORTED_NEXT_RANGE` (`@stayingupwind/adapter`'s `patches/versions.ts`), which is what the
221
+ * adapter's patches are held to. A release that moves that range moves this line with it.
222
+ */
223
+ const VERSIONS = {
224
+ "@tailwindcss/postcss": "^4.3.3",
225
+ "@types/node": "^24.13.4",
226
+ "@types/react": "^19.3.0",
227
+ "@types/react-dom": "^19.3.0",
228
+ next: "^16.3.6",
229
+ react: "^19.3.0",
230
+ "react-dom": "^19.3.0",
231
+ tailwindcss: "^4.3.3",
232
+ typescript: "^5.9.3"
233
+ };
234
+ async function writeManifest(target, name) {
235
+ const upwind = await ownRange();
236
+ const manifest = {
237
+ name,
238
+ version: "0.1.0",
239
+ private: true,
240
+ scripts: {
241
+ dev: "upwind dev",
242
+ build: "upwind build"
243
+ },
244
+ dependencies: {
245
+ next: VERSIONS.next,
246
+ react: VERSIONS.react,
247
+ "react-dom": VERSIONS["react-dom"]
248
+ },
249
+ devDependencies: {
250
+ "@stayingupwind/adapter": upwind,
251
+ "@tailwindcss/postcss": VERSIONS["@tailwindcss/postcss"],
252
+ "@types/node": VERSIONS["@types/node"],
253
+ "@types/react": VERSIONS["@types/react"],
254
+ "@types/react-dom": VERSIONS["@types/react-dom"],
255
+ tailwindcss: VERSIONS.tailwindcss,
256
+ typescript: VERSIONS.typescript,
257
+ upwind
258
+ }
259
+ };
260
+ await writeFile(path.join(target, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
261
+ }
262
+ //#endregion
263
+ //#region src/name.ts
264
+ /**
265
+ * The name a scaffolded project takes, which is the directory it was asked for.
266
+ *
267
+ * npm's rules, as far as one matters here: the manifest this writes is `private`, so nothing will
268
+ * ever publish it — but a name npm refuses is one that `npm install` inside the project refuses too,
269
+ * and finding that out on the first install is worse than finding it out now.
270
+ *
271
+ * `validate-npm-package-name` is the package that knows all of them. It is not a dependency here for
272
+ * the same reason nothing else is: this runs once, from a registry, in somebody else's shell.
273
+ */
274
+ /** npm's own limit. */
275
+ const MAX_LENGTH = 214;
276
+ /** What an unscoped name may be made of, as the registry accepts it. */
277
+ const ALLOWED = /^[a-z0-9._~-]+$/u;
278
+ /** The two names npm refuses outright, whatever else is true of them. */
279
+ const REFUSED = /* @__PURE__ */ new Set(["favicon.ico", "node_modules"]);
280
+ /** What is wrong with `name` as a package name, or nothing when it is a fine one. */
281
+ function nameProblem(name) {
282
+ if (name === "") return "is empty";
283
+ if (REFUSED.has(name)) return "is a name npm will not take, whatever is in the directory";
284
+ if (name.length > MAX_LENGTH) return `is longer than npm's ${MAX_LENGTH} characters`;
285
+ if (name !== name.toLowerCase()) return "has capital letters, which npm does not allow in a package name";
286
+ if (name.startsWith(".") || name.startsWith("_")) return "starts with a dot or an underscore, which npm does not allow in a package name";
287
+ if (!ALLOWED.test(name)) return "has characters npm does not allow in a package name (letters, digits, `.`, `-`, `_` and `~`)";
288
+ }
289
+ //#endregion
290
+ //#region src/prompt.ts
291
+ /**
292
+ * The one question this asks.
293
+ *
294
+ * One, because there is one template: everything else a scaffolder usually asks — TypeScript, the
295
+ * router, the bundler — is already decided by what upwind runs. A question with one possible answer
296
+ * is a keystroke taken from someone who typed `pnpm create upwind` to get an application.
297
+ *
298
+ * Asked only of a terminal. `pnpm create upwind < /dev/null`, or the same from a script or a CI job,
299
+ * takes the default rather than waiting for an answer that is never coming — a scaffolder that hangs
300
+ * on a closed stdin is one that hangs a pipeline.
301
+ */
302
+ /** The default is what an empty answer means, and Ctrl-D is the emptiest answer there is. */
303
+ const NO_ANSWER = "";
304
+ /** The answer an input that closed without one gives. */
305
+ async function untilClosed(rl) {
306
+ await once(rl, "close");
307
+ return NO_ANSWER;
308
+ }
309
+ /**
310
+ * The answer, or none.
311
+ *
312
+ * Ctrl-D does not answer the question, it abandons it: Node rejects the promise with an
313
+ * `AbortError` — "Aborted with Ctrl+D" — rather than resolving it with nothing. Caught here, because
314
+ * an input that has ended has no more answers to give, and the one this asks for has a default.
315
+ */
316
+ async function askOnce(rl, prompt) {
317
+ try {
318
+ return await rl.question(prompt);
319
+ } catch {
320
+ return NO_ANSWER;
321
+ }
322
+ }
323
+ async function askDirectory(fallback) {
324
+ if (!process.stdin.isTTY) return fallback;
325
+ const rl = createInterface({
326
+ input: process.stdin,
327
+ output: process.stdout
328
+ });
329
+ try {
330
+ const trimmed = (await Promise.race([askOnce(rl, `Where should the application go? (${fallback}) `), untilClosed(rl)])).trim();
331
+ return trimmed === NO_ANSWER ? fallback : trimmed;
332
+ } finally {
333
+ rl.close();
334
+ }
335
+ }
336
+ //#endregion
337
+ //#region src/template.ts
338
+ /**
339
+ * The application this copies, and what has to be renamed on the way out.
340
+ *
341
+ * `../templates/default/` reads the same from the sources a workspace links and from the module a
342
+ * registry installs: both sit one directory below the package root, and `templates` travels with the
343
+ * package (`files`).
344
+ *
345
+ * The template carries `gitignore` without its dot because **npm removes a `.gitignore` from every
346
+ * tarball it packs**. A template that kept the dot would arrive with no ignore file at all, and the
347
+ * first commit of every scaffolded project would carry `node_modules`. `create-next-app` renames the
348
+ * same file for the same reason.
349
+ */
350
+ const TEMPLATE = fileURLToPath(new URL("../templates/default/", import.meta.url));
351
+ /** Entries that do not make a directory non-empty: a checkout and a Finder artefact. */
352
+ const IGNORED_ENTRIES = /* @__PURE__ */ new Set([".DS_Store", ".git"]);
353
+ /** Is this the error of a directory that is not there? Anything else is a directory that is. */
354
+ function isMissing(error) {
355
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
356
+ }
357
+ /** What is already in the way, if anything: the names, so the message can say them. */
358
+ async function conflictsIn(target) {
359
+ try {
360
+ return (await readdir(target)).filter((entry) => !IGNORED_ENTRIES.has(entry));
361
+ } catch (error) {
362
+ if (isMissing(error)) return [];
363
+ throw error;
364
+ }
365
+ }
366
+ async function copyTemplate(target) {
367
+ await cp(TEMPLATE, target, { recursive: true });
368
+ await rename(path.join(target, "gitignore"), path.join(target, ".gitignore"));
369
+ }
370
+ /**
371
+ * The README, in the manager the project was made with.
372
+ *
373
+ * The template is written in pnpm because a file has to be written in something. A project installed
374
+ * with npm has an npm lockfile, and a README that tells its reader to run pnpm tells them to install
375
+ * it a second way — or, on a machine without pnpm, to run something that is not there.
376
+ */
377
+ async function retellReadme(target, manager) {
378
+ if (manager === "pnpm") return;
379
+ const readme = path.join(target, "README.md");
380
+ const retold = (await readFile(readme, "utf8")).replaceAll("pnpm dev", () => runCommand(manager, "dev")).replaceAll("pnpm build", () => runCommand(manager, "build"));
381
+ await writeFile(readme, retold);
382
+ }
383
+ //#endregion
384
+ //#region src/create.ts
385
+ /**
386
+ * One run: a directory with a Next.js application in it that upwind can run.
387
+ *
388
+ * The order is the order a developer would do it in. Everything that can be refused is refused
389
+ * before anything is written — a name npm would not take, a directory with something in it — so a
390
+ * run either leaves an application or leaves nothing.
391
+ *
392
+ * The install comes before the first commit so the lockfile is in it: the point of a lockfile is the
393
+ * install somebody else does from it, and one that arrives a commit late is one that arrives after
394
+ * the first person cloned it.
395
+ */
396
+ const DEFAULT_DIRECTORY = "my-upwind-app";
397
+ /** How many of the things in the way to name before saying "and others". */
398
+ const CONFLICTS_SHOWN = 5;
399
+ function refuseConflicts(target, conflicts) {
400
+ const shown = conflicts.slice(0, CONFLICTS_SHOWN).join(", ");
401
+ const rest = conflicts.length > CONFLICTS_SHOWN ? `, and ${conflicts.length - CONFLICTS_SHOWN} more` : "";
402
+ throw new Error(`${target} already has something in it (${shown}${rest})`);
403
+ }
404
+ /** How a single quote is written inside single quotes: close, escape one, open again. */
405
+ const ESCAPED_QUOTE = String.raw`'\''`;
406
+ /** What needs no quoting anywhere, plus the separator each shell writes a path with. */
407
+ const PLAIN = process.platform === "win32" ? /^[\w+,.:=@\\-]+$/u : /^[\w+,./:=@-]+$/u;
408
+ /**
409
+ * A path the shell this was run from reads as one word, and as a path.
410
+ *
411
+ * Two things can go wrong with a next step somebody pastes. A path with a space in it is two
412
+ * arguments — and `cmd.exe` does not read the single quotes a POSIX shell does, so the quoting has
413
+ * to be the one the platform uses. And a relative path that begins with `-` is read as options by
414
+ * every shell there is, which `./` settles.
415
+ *
416
+ * Two things are left, and both are `cmd.exe`'s alone. It expands `%NAME%` inside double quotes and
417
+ * has no escape for it at the prompt, so a directory with a percent sign in its name prints a line
418
+ * that reads as something else there. And a target on another drive needs `cd /d` there, which is
419
+ * not a `cd` PowerShell accepts. Both read correctly in PowerShell, which is where a Windows
420
+ * developer is more likely to be standing, and the alternative would be a line that is wrong in the
421
+ * other shell instead.
422
+ */
423
+ function shellWord(value) {
424
+ const safe = value.startsWith("-") ? `./${value}` : value;
425
+ if (PLAIN.test(safe)) return safe;
426
+ return process.platform === "win32" ? `"${safe}"` : `'${safe.replaceAll("'", () => ESCAPED_QUOTE)}'`;
427
+ }
428
+ function printNextSteps(options) {
429
+ const { manager, target } = options;
430
+ const where = path.relative(process.cwd(), target);
431
+ const dev = runCommand(manager, "dev");
432
+ console.log("");
433
+ console.log(`Created ${path.basename(target)}${options.committed ? " with a first commit" : ""}.`);
434
+ console.log("");
435
+ if (where !== "") console.log(` cd ${shellWord(where)}`);
436
+ if (!options.installed) console.log(` ${manager} install`);
437
+ console.log(` ${dev}`);
438
+ console.log("");
439
+ console.log(`\`${dev}\` runs upwind in front of the project's own Next.js, and /__upwind is answered by`);
440
+ console.log(`upwind itself. \`${runCommand(manager, "build")}\` writes the deployment bundle under .ppr-cdn/.`);
441
+ }
442
+ async function create(request) {
443
+ const directory = request.directory ?? await askDirectory(DEFAULT_DIRECTORY);
444
+ const target = path.resolve(directory);
445
+ const name = path.basename(target);
446
+ const problem = nameProblem(name);
447
+ if (problem !== void 0) throw new Error(`\`${name}\` ${problem}`);
448
+ const conflicts = await conflictsIn(target);
449
+ if (conflicts.length > 0) refuseConflicts(target, conflicts);
450
+ const manager = request.packageManager ?? detectPackageManager();
451
+ console.log(`Creating ${name} in ${target}`);
452
+ await copyTemplate(target);
453
+ await retellReadme(target, manager);
454
+ await writeManifest(target, name);
455
+ if (request.install) {
456
+ console.log("");
457
+ await install(manager, target);
458
+ }
459
+ printNextSteps({
460
+ target,
461
+ manager,
462
+ committed: request.git && await initRepository(target),
463
+ installed: request.install
464
+ });
465
+ }
466
+ //#endregion
467
+ //#region src/cli.ts
468
+ /**
469
+ * `create-upwind` — one command, one template, one question.
470
+ *
471
+ * `pnpm create upwind` (and `npm create upwind`, and the others) reach this program, and it writes a
472
+ * Next.js application that upwind can run: Next.js and React as real dependencies, because upwind
473
+ * runs the *project's* Next.js and has none of its own, the adapter that turns a build into a
474
+ * deployment bundle, and Tailwind, because an application with no way to style it is a demonstration
475
+ * rather than a start.
476
+ */
477
+ const USAGE = `create-upwind — a Next.js application wired to upwind
478
+
479
+ Usage
480
+ pnpm create upwind [directory]
481
+
482
+ Options
483
+ --skip-install Write the application, install nothing
484
+ --no-git Do not make a first commit
485
+ --use-npm Install with npm
486
+ --use-pnpm Install with pnpm
487
+ --use-yarn Install with yarn
488
+ --use-bun Install with bun
489
+ -v, --version Print create-upwind's version
490
+ -h, --help Print this
491
+ -- Everything after this is the directory, even \`--help\`
492
+
493
+ Asked for no directory, it asks for one — or takes \`my-upwind-app\` when nothing is there to ask.
494
+ `;
495
+ function fail(message, withUsage) {
496
+ console.error(`create-upwind: ${message}`);
497
+ if (withUsage) console.error(`\n${USAGE}`);
498
+ process.exit(1);
499
+ }
500
+ async function main() {
501
+ let request;
502
+ try {
503
+ request = parseCreateRequest(process.argv.slice(2));
504
+ } catch (error) {
505
+ fail(error instanceof Error ? error.message : String(error), true);
506
+ }
507
+ if (request.answer === "help") {
508
+ console.log(USAGE);
509
+ return;
510
+ }
511
+ if (request.answer === "version") {
512
+ console.log(await ownVersion() ?? "unknown");
513
+ return;
514
+ }
515
+ await create(request);
516
+ }
517
+ try {
518
+ await main();
519
+ } catch (error) {
520
+ console.error(error instanceof Error ? `create-upwind: ${error.message}` : error);
521
+ process.exitCode = 1;
522
+ }
523
+ //#endregion
524
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "create-upwind",
3
+ "version": "0.2.0",
4
+ "description": "`pnpm create upwind` — a Next.js application wired to upwind, ready to run.",
5
+ "license": "MIT OR Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/arkorlab/upwind.git",
9
+ "directory": "packages/create-upwind"
10
+ },
11
+ "bin": {
12
+ "create-upwind": "./dist/cli.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "templates",
18
+ "LICENSE-APACHE",
19
+ "LICENSE-MIT",
20
+ "NOTICE"
21
+ ],
22
+ "type": "module",
23
+ "devDependencies": {
24
+ "@types/node": "24.13.4",
25
+ "rolldown": "1.2.9",
26
+ "typescript": "5.9.3"
27
+ },
28
+ "engines": {
29
+ "node": ">=24"
30
+ },
31
+ "scripts": {
32
+ "build": "node scripts/build.ts",
33
+ "typecheck": "tsc --noEmit"
34
+ }
35
+ }
package/src/args.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { parseArgs } from 'node:util';
2
+
3
+ import { PACKAGE_MANAGERS, type PackageManager } from './package-manager.ts';
4
+
5
+ /**
6
+ * What `create-upwind` understands.
7
+ *
8
+ * A directory, and three things about what to do after writing it. Everything a scaffolder usually
9
+ * asks about the application itself — the language, the router, the bundler — is decided by what
10
+ * upwind runs, so there is nothing to ask and nothing to flag.
11
+ */
12
+
13
+ export interface CreateRequest {
14
+ /** What was asked for instead of an application, if anything. */
15
+ readonly answer: 'help' | 'version' | undefined;
16
+ /** Where the application goes; nothing when it is still to be asked for. */
17
+ readonly directory: string | undefined;
18
+ readonly install: boolean;
19
+ readonly git: boolean;
20
+ /** The manager to install with, or nothing to use the one this was started from. */
21
+ readonly packageManager: PackageManager | undefined;
22
+ }
23
+
24
+ /** At most one `--use-*`: two would be a question about which, and there is no good answer. */
25
+ function packageManagerOf(values: Readonly<Record<string, unknown>>): PackageManager | undefined {
26
+ const chosen = PACKAGE_MANAGERS.filter((manager) => values[`use-${manager}`] === true);
27
+ if (chosen.length > 1) {
28
+ throw new Error(`only one package manager can be asked for, and ${chosen.length} were`);
29
+ }
30
+ return chosen[0];
31
+ }
32
+
33
+ /** `--help` and `--version` are options here rather than words looked for in the arguments, so that
34
+ * `create-upwind -- --help` makes a directory called `--help` the way `parseArgs` says it should. */
35
+ function answerOf(values: Readonly<Record<string, unknown>>): CreateRequest['answer'] {
36
+ if (values['help'] === true) {
37
+ return 'help';
38
+ }
39
+ return values['version'] === true ? 'version' : undefined;
40
+ }
41
+
42
+ export function parseCreateRequest(args: readonly string[]): CreateRequest {
43
+ const { values, positionals } = parseArgs({
44
+ args: [...args],
45
+ options: {
46
+ git: { type: 'boolean', default: true },
47
+ help: { type: 'boolean', short: 'h' },
48
+ 'skip-install': { type: 'boolean' },
49
+ 'use-bun': { type: 'boolean' },
50
+ 'use-npm': { type: 'boolean' },
51
+ 'use-pnpm': { type: 'boolean' },
52
+ 'use-yarn': { type: 'boolean' },
53
+ version: { type: 'boolean', short: 'v' },
54
+ },
55
+ allowNegative: true,
56
+ allowPositionals: true,
57
+ strict: true,
58
+ });
59
+ const answer = answerOf(values);
60
+ if (answer === undefined && positionals.length > 1) {
61
+ throw new Error(`one directory at most, and ${positionals.length} were given`);
62
+ }
63
+ return {
64
+ answer,
65
+ directory: positionals[0],
66
+ install: values['skip-install'] !== true,
67
+ git: values.git,
68
+ packageManager: answer === undefined ? packageManagerOf(values) : undefined,
69
+ };
70
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import { parseCreateRequest } from './args.ts';
3
+ import { create } from './create.ts';
4
+ import { ownVersion } from './version.ts';
5
+
6
+ /**
7
+ * `create-upwind` — one command, one template, one question.
8
+ *
9
+ * `pnpm create upwind` (and `npm create upwind`, and the others) reach this program, and it writes a
10
+ * Next.js application that upwind can run: Next.js and React as real dependencies, because upwind
11
+ * runs the *project's* Next.js and has none of its own, the adapter that turns a build into a
12
+ * deployment bundle, and Tailwind, because an application with no way to style it is a demonstration
13
+ * rather than a start.
14
+ */
15
+
16
+ const USAGE = `create-upwind — a Next.js application wired to upwind
17
+
18
+ Usage
19
+ pnpm create upwind [directory]
20
+
21
+ Options
22
+ --skip-install Write the application, install nothing
23
+ --no-git Do not make a first commit
24
+ --use-npm Install with npm
25
+ --use-pnpm Install with pnpm
26
+ --use-yarn Install with yarn
27
+ --use-bun Install with bun
28
+ -v, --version Print create-upwind's version
29
+ -h, --help Print this
30
+ -- Everything after this is the directory, even \`--help\`
31
+
32
+ Asked for no directory, it asks for one — or takes \`my-upwind-app\` when nothing is there to ask.
33
+ `;
34
+
35
+ function fail(message: string, withUsage: boolean): never {
36
+ console.error(`create-upwind: ${message}`);
37
+ if (withUsage) {
38
+ console.error(`\n${USAGE}`);
39
+ }
40
+ process.exit(1);
41
+ }
42
+
43
+ async function main(): Promise<void> {
44
+ let request;
45
+ try {
46
+ request = parseCreateRequest(process.argv.slice(2));
47
+ } catch (error) {
48
+ fail(error instanceof Error ? error.message : String(error), true);
49
+ }
50
+ if (request.answer === 'help') {
51
+ console.log(USAGE);
52
+ return;
53
+ }
54
+ if (request.answer === 'version') {
55
+ console.log((await ownVersion()) ?? 'unknown');
56
+ return;
57
+ }
58
+ await create(request);
59
+ }
60
+
61
+ try {
62
+ await main();
63
+ } catch (error) {
64
+ console.error(error instanceof Error ? `create-upwind: ${error.message}` : error);
65
+ process.exitCode = 1;
66
+ }