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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/NOTICE +9 -0
- package/README.md +79 -0
- package/dist/cli.js +524 -0
- package/package.json +35 -0
- package/src/args.ts +70 -0
- package/src/cli.ts +66 -0
- package/src/create.ts +123 -0
- package/src/git.ts +75 -0
- package/src/manifest.ts +70 -0
- package/src/name.ts +40 -0
- package/src/package-manager.ts +70 -0
- package/src/prompt.ts +61 -0
- package/src/template.ts +69 -0
- package/src/version.ts +36 -0
- package/templates/default/README.md +47 -0
- package/templates/default/app/globals.css +1 -0
- package/templates/default/app/layout.tsx +16 -0
- package/templates/default/app/page.tsx +13 -0
- package/templates/default/gitignore +33 -0
- package/templates/default/next.config.ts +26 -0
- package/templates/default/postcss.config.mjs +7 -0
- package/templates/default/tsconfig.json +28 -0
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
|
+
}
|