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/src/create.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import type { CreateRequest } from './args.ts';
|
|
4
|
+
import { initRepository } from './git.ts';
|
|
5
|
+
import { writeManifest } from './manifest.ts';
|
|
6
|
+
import { nameProblem } from './name.ts';
|
|
7
|
+
import {
|
|
8
|
+
detectPackageManager,
|
|
9
|
+
install,
|
|
10
|
+
type PackageManager,
|
|
11
|
+
runCommand,
|
|
12
|
+
} from './package-manager.ts';
|
|
13
|
+
import { askDirectory } from './prompt.ts';
|
|
14
|
+
import { conflictsIn, copyTemplate, retellReadme } from './template.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One run: a directory with a Next.js application in it that upwind can run.
|
|
18
|
+
*
|
|
19
|
+
* The order is the order a developer would do it in. Everything that can be refused is refused
|
|
20
|
+
* before anything is written — a name npm would not take, a directory with something in it — so a
|
|
21
|
+
* run either leaves an application or leaves nothing.
|
|
22
|
+
*
|
|
23
|
+
* The install comes before the first commit so the lockfile is in it: the point of a lockfile is the
|
|
24
|
+
* install somebody else does from it, and one that arrives a commit late is one that arrives after
|
|
25
|
+
* the first person cloned it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DEFAULT_DIRECTORY = 'my-upwind-app';
|
|
29
|
+
/** How many of the things in the way to name before saying "and others". */
|
|
30
|
+
const CONFLICTS_SHOWN = 5;
|
|
31
|
+
|
|
32
|
+
function refuseConflicts(target: string, conflicts: readonly string[]): never {
|
|
33
|
+
const shown = conflicts.slice(0, CONFLICTS_SHOWN).join(', ');
|
|
34
|
+
const rest =
|
|
35
|
+
conflicts.length > CONFLICTS_SHOWN ? `, and ${conflicts.length - CONFLICTS_SHOWN} more` : '';
|
|
36
|
+
throw new Error(`${target} already has something in it (${shown}${rest})`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How a single quote is written inside single quotes: close, escape one, open again. */
|
|
40
|
+
const ESCAPED_QUOTE = String.raw`'\''`;
|
|
41
|
+
/** What needs no quoting anywhere, plus the separator each shell writes a path with. */
|
|
42
|
+
const PLAIN = process.platform === 'win32' ? /^[\w+,.:=@\\-]+$/u : /^[\w+,./:=@-]+$/u;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A path the shell this was run from reads as one word, and as a path.
|
|
46
|
+
*
|
|
47
|
+
* Two things can go wrong with a next step somebody pastes. A path with a space in it is two
|
|
48
|
+
* arguments — and `cmd.exe` does not read the single quotes a POSIX shell does, so the quoting has
|
|
49
|
+
* to be the one the platform uses. And a relative path that begins with `-` is read as options by
|
|
50
|
+
* every shell there is, which `./` settles.
|
|
51
|
+
*
|
|
52
|
+
* Two things are left, and both are `cmd.exe`'s alone. It expands `%NAME%` inside double quotes and
|
|
53
|
+
* has no escape for it at the prompt, so a directory with a percent sign in its name prints a line
|
|
54
|
+
* that reads as something else there. And a target on another drive needs `cd /d` there, which is
|
|
55
|
+
* not a `cd` PowerShell accepts. Both read correctly in PowerShell, which is where a Windows
|
|
56
|
+
* developer is more likely to be standing, and the alternative would be a line that is wrong in the
|
|
57
|
+
* other shell instead.
|
|
58
|
+
*/
|
|
59
|
+
function shellWord(value: string): string {
|
|
60
|
+
const safe = value.startsWith('-') ? `./${value}` : value;
|
|
61
|
+
if (PLAIN.test(safe)) {
|
|
62
|
+
return safe;
|
|
63
|
+
}
|
|
64
|
+
return process.platform === 'win32'
|
|
65
|
+
? `"${safe}"`
|
|
66
|
+
: `'${safe.replaceAll("'", () => ESCAPED_QUOTE)}'`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printNextSteps(options: {
|
|
70
|
+
readonly target: string;
|
|
71
|
+
readonly manager: PackageManager;
|
|
72
|
+
readonly committed: boolean;
|
|
73
|
+
readonly installed: boolean;
|
|
74
|
+
}): void {
|
|
75
|
+
const { manager, target } = options;
|
|
76
|
+
const where = path.relative(process.cwd(), target);
|
|
77
|
+
const dev = runCommand(manager, 'dev');
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(
|
|
80
|
+
`Created ${path.basename(target)}${options.committed ? ' with a first commit' : ''}.`,
|
|
81
|
+
);
|
|
82
|
+
console.log('');
|
|
83
|
+
if (where !== '') {
|
|
84
|
+
console.log(` cd ${shellWord(where)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!options.installed) {
|
|
87
|
+
// Nothing was installed, so `dev` would be a command that is not there yet.
|
|
88
|
+
console.log(` ${manager} install`);
|
|
89
|
+
}
|
|
90
|
+
console.log(` ${dev}`);
|
|
91
|
+
console.log('');
|
|
92
|
+
console.log(
|
|
93
|
+
`\`${dev}\` runs upwind in front of the project's own Next.js, and /__upwind is answered by`,
|
|
94
|
+
);
|
|
95
|
+
console.log(
|
|
96
|
+
`upwind itself. \`${runCommand(manager, 'build')}\` writes the deployment bundle under .ppr-cdn/.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function create(request: CreateRequest): Promise<void> {
|
|
101
|
+
const directory = request.directory ?? (await askDirectory(DEFAULT_DIRECTORY));
|
|
102
|
+
const target = path.resolve(directory);
|
|
103
|
+
const name = path.basename(target);
|
|
104
|
+
const problem = nameProblem(name);
|
|
105
|
+
if (problem !== undefined) {
|
|
106
|
+
throw new Error(`\`${name}\` ${problem}`);
|
|
107
|
+
}
|
|
108
|
+
const conflicts = await conflictsIn(target);
|
|
109
|
+
if (conflicts.length > 0) {
|
|
110
|
+
refuseConflicts(target, conflicts);
|
|
111
|
+
}
|
|
112
|
+
const manager = request.packageManager ?? detectPackageManager();
|
|
113
|
+
console.log(`Creating ${name} in ${target}`);
|
|
114
|
+
await copyTemplate(target);
|
|
115
|
+
await retellReadme(target, manager);
|
|
116
|
+
await writeManifest(target, name);
|
|
117
|
+
if (request.install) {
|
|
118
|
+
console.log('');
|
|
119
|
+
await install(manager, target);
|
|
120
|
+
}
|
|
121
|
+
const committed = request.git && (await initRepository(target));
|
|
122
|
+
printNextSteps({ target, manager, committed, installed: request.install });
|
|
123
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { lstat, rm } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A first commit, where there is a repository to be made.
|
|
8
|
+
*
|
|
9
|
+
* Never fatal. A machine with no git, a git with no identity configured, a directory already inside
|
|
10
|
+
* somebody's repository: none of those is a reason to have failed to scaffold an application, and
|
|
11
|
+
* all of them are things a developer can see for themselves. A repository this started and could not
|
|
12
|
+
* finish is removed again, so what is left is either a clean first commit or no `.git` at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FIRST_COMMIT = 'Initial commit from create-upwind';
|
|
16
|
+
|
|
17
|
+
/** Run git, quietly; `undefined` when git itself is not there. */
|
|
18
|
+
async function git(args: readonly string[], cwd: string): Promise<number | undefined> {
|
|
19
|
+
const child = spawn('git', args, { cwd, stdio: 'ignore' });
|
|
20
|
+
try {
|
|
21
|
+
const [code] = (await once(child, 'exit')) as [number | null, NodeJS.Signals | null];
|
|
22
|
+
return code ?? 1;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Is this directory already part of a repository? Then its history is not this program's to start. */
|
|
29
|
+
async function insideRepository(cwd: string): Promise<boolean> {
|
|
30
|
+
return (await git(['rev-parse', '--is-inside-work-tree'], cwd)) === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does the target already hold a checkout of its own? Then nothing here is this program's.
|
|
35
|
+
*
|
|
36
|
+
* `lstat`, not `stat`: a `.git` that is a symlink — dangling, or pointing at a repository somewhere
|
|
37
|
+
* else — is still an entry this program did not put there. Following it would answer "nothing here"
|
|
38
|
+
* for a broken one, and `git init` would then write through it, into a directory nobody named.
|
|
39
|
+
*/
|
|
40
|
+
async function hasGitEntry(target: string): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
await lstat(path.join(target, '.git'));
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when a repository was made and committed to. */
|
|
50
|
+
export async function initRepository(target: string): Promise<boolean> {
|
|
51
|
+
// Asked before anything: a `.git` that was already there is somebody's history, whatever git says
|
|
52
|
+
// about it. `rev-parse` can refuse a repository it will not touch — a checkout owned by another
|
|
53
|
+
// user is the usual one — and an `init` after that answer would re-initialize the repository this
|
|
54
|
+
// program would then delete in its own cleanup, remotes, config, history and all.
|
|
55
|
+
if ((await hasGitEntry(target)) || (await insideRepository(target))) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if ((await git(['init', '-b', 'main'], target)) !== 0) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const added = await git(['add', '-A'], target);
|
|
62
|
+
const committed = added === 0 ? await git(['commit', '-m', FIRST_COMMIT], target) : undefined;
|
|
63
|
+
if (committed === 0) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
// Half a repository is worse than none: an `init` nobody asked to be left behind — and one this
|
|
67
|
+
// run made, which is what the check above is for. A cleanup that cannot be done is still not a
|
|
68
|
+
// reason to have failed to write an application; this answers "no repository" either way.
|
|
69
|
+
try {
|
|
70
|
+
await rm(path.join(target, '.git'), { recursive: true, force: true });
|
|
71
|
+
} catch {
|
|
72
|
+
// Nothing to do about it here, and nothing that depends on it.
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ownRange } from './version.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `package.json` a scaffolded project starts with.
|
|
8
|
+
*
|
|
9
|
+
* Written rather than shipped in the template, so the name is the directory's and the versions are
|
|
10
|
+
* this release's. Two of them are the whole point of the file:
|
|
11
|
+
*
|
|
12
|
+
* - **`next`, `react`, `react-dom` are dependencies, not suggestions.** upwind runs the *project's*
|
|
13
|
+
* Next.js — `upwind dev` resolves it from the project and refuses to start without it — so an
|
|
14
|
+
* application that upwind can run is one that has Next.js of its own.
|
|
15
|
+
* - **`upwind` and the adapter come from this release.** `create-upwind@x.y.z` asks for `^x.y.z` of
|
|
16
|
+
* both, and the release publishes them from one tag, so a scaffolded project is wired to the
|
|
17
|
+
* generation that scaffolded it.
|
|
18
|
+
*
|
|
19
|
+
* The rest is what Next.js 16 and Tailwind 4 need, and nothing else: no ESLint, no `src/`, no
|
|
20
|
+
* component library. `create-next-app --empty` is the shape.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the template is written against.
|
|
25
|
+
*
|
|
26
|
+
* `next` is a caret on the version this repository builds against, and stays inside
|
|
27
|
+
* `SUPPORTED_NEXT_RANGE` (`@stayingupwind/adapter`'s `patches/versions.ts`), which is what the
|
|
28
|
+
* adapter's patches are held to. A release that moves that range moves this line with it.
|
|
29
|
+
*/
|
|
30
|
+
const VERSIONS = {
|
|
31
|
+
'@tailwindcss/postcss': '^4.3.3',
|
|
32
|
+
'@types/node': '^24.13.4',
|
|
33
|
+
'@types/react': '^19.3.0',
|
|
34
|
+
'@types/react-dom': '^19.3.0',
|
|
35
|
+
next: '^16.3.6',
|
|
36
|
+
react: '^19.3.0',
|
|
37
|
+
'react-dom': '^19.3.0',
|
|
38
|
+
tailwindcss: '^4.3.3',
|
|
39
|
+
typescript: '^5.9.3',
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export async function writeManifest(target: string, name: string): Promise<void> {
|
|
43
|
+
const upwind = await ownRange();
|
|
44
|
+
const manifest = {
|
|
45
|
+
name,
|
|
46
|
+
version: '0.1.0',
|
|
47
|
+
// Nothing here is meant for a registry, and npm refuses to publish what says so.
|
|
48
|
+
private: true,
|
|
49
|
+
scripts: {
|
|
50
|
+
dev: 'upwind dev',
|
|
51
|
+
build: 'upwind build',
|
|
52
|
+
},
|
|
53
|
+
dependencies: {
|
|
54
|
+
next: VERSIONS.next,
|
|
55
|
+
react: VERSIONS.react,
|
|
56
|
+
'react-dom': VERSIONS['react-dom'],
|
|
57
|
+
},
|
|
58
|
+
devDependencies: {
|
|
59
|
+
'@stayingupwind/adapter': upwind,
|
|
60
|
+
'@tailwindcss/postcss': VERSIONS['@tailwindcss/postcss'],
|
|
61
|
+
'@types/node': VERSIONS['@types/node'],
|
|
62
|
+
'@types/react': VERSIONS['@types/react'],
|
|
63
|
+
'@types/react-dom': VERSIONS['@types/react-dom'],
|
|
64
|
+
tailwindcss: VERSIONS.tailwindcss,
|
|
65
|
+
typescript: VERSIONS.typescript,
|
|
66
|
+
upwind,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
await writeFile(path.join(target, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
70
|
+
}
|
package/src/name.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name a scaffolded project takes, which is the directory it was asked for.
|
|
3
|
+
*
|
|
4
|
+
* npm's rules, as far as one matters here: the manifest this writes is `private`, so nothing will
|
|
5
|
+
* ever publish it — but a name npm refuses is one that `npm install` inside the project refuses too,
|
|
6
|
+
* and finding that out on the first install is worse than finding it out now.
|
|
7
|
+
*
|
|
8
|
+
* `validate-npm-package-name` is the package that knows all of them. It is not a dependency here for
|
|
9
|
+
* the same reason nothing else is: this runs once, from a registry, in somebody else's shell.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** npm's own limit. */
|
|
13
|
+
const MAX_LENGTH = 214;
|
|
14
|
+
/** What an unscoped name may be made of, as the registry accepts it. */
|
|
15
|
+
const ALLOWED = /^[a-z0-9._~-]+$/u;
|
|
16
|
+
/** The two names npm refuses outright, whatever else is true of them. */
|
|
17
|
+
const REFUSED: ReadonlySet<string> = new Set(['favicon.ico', 'node_modules']);
|
|
18
|
+
|
|
19
|
+
/** What is wrong with `name` as a package name, or nothing when it is a fine one. */
|
|
20
|
+
export function nameProblem(name: string): string | undefined {
|
|
21
|
+
if (name === '') {
|
|
22
|
+
return 'is empty';
|
|
23
|
+
}
|
|
24
|
+
if (REFUSED.has(name)) {
|
|
25
|
+
return 'is a name npm will not take, whatever is in the directory';
|
|
26
|
+
}
|
|
27
|
+
if (name.length > MAX_LENGTH) {
|
|
28
|
+
return `is longer than npm's ${MAX_LENGTH} characters`;
|
|
29
|
+
}
|
|
30
|
+
if (name !== name.toLowerCase()) {
|
|
31
|
+
return 'has capital letters, which npm does not allow in a package name';
|
|
32
|
+
}
|
|
33
|
+
if (name.startsWith('.') || name.startsWith('_')) {
|
|
34
|
+
return 'starts with a dot or an underscore, which npm does not allow in a package name';
|
|
35
|
+
}
|
|
36
|
+
if (!ALLOWED.test(name)) {
|
|
37
|
+
return 'has characters npm does not allow in a package name (letters, digits, `.`, `-`, `_` and `~`)';
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which package manager scaffolded this, and the install it runs.
|
|
6
|
+
*
|
|
7
|
+
* `pnpm create upwind`, `npm create upwind`, `yarn create upwind` and `bun create upwind` all reach
|
|
8
|
+
* this same program, and the one thing they leave behind to say which they were is
|
|
9
|
+
* `npm_config_user_agent`. A project installed with the manager its author started from is one whose
|
|
10
|
+
* lockfile is the one they expect.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const;
|
|
14
|
+
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
15
|
+
|
|
16
|
+
/** `pnpm/12.4.1 npm/? node/? linux x64` — the name is everything before the first slash. */
|
|
17
|
+
export function detectPackageManager(): PackageManager {
|
|
18
|
+
const agent = process.env['npm_config_user_agent'] ?? '';
|
|
19
|
+
const [name] = agent.split('/', 1);
|
|
20
|
+
const known = PACKAGE_MANAGERS.find((manager) => manager === name);
|
|
21
|
+
return known ?? 'npm';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The application is on disk by now, whatever went wrong here; say so, and say what to do. */
|
|
25
|
+
function refuse(manager: PackageManager, why: string): never {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${why}. The application is written; run \`${manager} install\` in it once you know why.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function install(manager: PackageManager, cwd: string): Promise<void> {
|
|
32
|
+
const child = spawn(manager, ['install'], {
|
|
33
|
+
cwd,
|
|
34
|
+
stdio: 'inherit',
|
|
35
|
+
// Windows reaches `pnpm.cmd` and friends through the shell and not otherwise.
|
|
36
|
+
//
|
|
37
|
+
// Elsewhere in this repository a command is named for the platform instead — `npm.cmd` on
|
|
38
|
+
// Windows — and started without one, because what those pass on the line is a version string
|
|
39
|
+
// read off a registry. This passes the word `install` and nothing else, and the command is one
|
|
40
|
+
// of four names of this program's own choosing; and of those four, `bun` is on PATH as an
|
|
41
|
+
// `.exe` or a `.cmd` depending on how it was installed, which no single name covers. So the
|
|
42
|
+
// shell here is what finds the launcher, and it is handed nothing to interpret.
|
|
43
|
+
shell: process.platform === 'win32',
|
|
44
|
+
});
|
|
45
|
+
let code;
|
|
46
|
+
try {
|
|
47
|
+
[code] = (await once(child, 'exit')) as [number | null, NodeJS.Signals | null];
|
|
48
|
+
} catch {
|
|
49
|
+
// `events.once` rejects on `error`, which is what a command that is not on the PATH raises — a
|
|
50
|
+
// `--use-bun` on a machine with no bun. The raw `spawn bun ENOENT` says less than this does.
|
|
51
|
+
refuse(manager, `\`${manager}\` could not be run; it may not be installed`);
|
|
52
|
+
}
|
|
53
|
+
if (code !== 0) {
|
|
54
|
+
refuse(manager, `\`${manager} install\` failed`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The two the bare word does not reach a project script through. */
|
|
59
|
+
const NEEDS_RUN: ReadonlySet<PackageManager> = new Set<PackageManager>(['bun', 'npm']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How a developer runs one of the project's scripts with this manager.
|
|
63
|
+
*
|
|
64
|
+
* `npm dev` is not a command at all, and `bun build` is a different one: Bun's own bundler, which
|
|
65
|
+
* answers a project's `build` script with "Missing entrypoints". Both take `run`. `pnpm` and `yarn`
|
|
66
|
+
* pass an unknown word to the scripts, and `pnpm run dev` would only be longer.
|
|
67
|
+
*/
|
|
68
|
+
export function runCommand(manager: PackageManager, script: string): string {
|
|
69
|
+
return NEEDS_RUN.has(manager) ? `${manager} run ${script}` : `${manager} ${script}`;
|
|
70
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { createInterface, type Interface } from 'node:readline/promises';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one question this asks.
|
|
6
|
+
*
|
|
7
|
+
* One, because there is one template: everything else a scaffolder usually asks — TypeScript, the
|
|
8
|
+
* router, the bundler — is already decided by what upwind runs. A question with one possible answer
|
|
9
|
+
* is a keystroke taken from someone who typed `pnpm create upwind` to get an application.
|
|
10
|
+
*
|
|
11
|
+
* Asked only of a terminal. `pnpm create upwind < /dev/null`, or the same from a script or a CI job,
|
|
12
|
+
* takes the default rather than waiting for an answer that is never coming — a scaffolder that hangs
|
|
13
|
+
* on a closed stdin is one that hangs a pipeline.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The default is what an empty answer means, and Ctrl-D is the emptiest answer there is. */
|
|
17
|
+
const NO_ANSWER = '';
|
|
18
|
+
|
|
19
|
+
/** The answer an input that closed without one gives. */
|
|
20
|
+
async function untilClosed(rl: Interface): Promise<string> {
|
|
21
|
+
await once(rl, 'close');
|
|
22
|
+
return NO_ANSWER;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The answer, or none.
|
|
27
|
+
*
|
|
28
|
+
* Ctrl-D does not answer the question, it abandons it: Node rejects the promise with an
|
|
29
|
+
* `AbortError` — "Aborted with Ctrl+D" — rather than resolving it with nothing. Caught here, because
|
|
30
|
+
* an input that has ended has no more answers to give, and the one this asks for has a default.
|
|
31
|
+
*/
|
|
32
|
+
async function askOnce(rl: Interface, prompt: string): Promise<string> {
|
|
33
|
+
try {
|
|
34
|
+
return await rl.question(prompt);
|
|
35
|
+
} catch {
|
|
36
|
+
return NO_ANSWER;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function askDirectory(fallback: string): Promise<string> {
|
|
41
|
+
if (!process.stdin.isTTY) {
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
// `using` is not erasable syntax, and the lib this is typed against has no `Symbol.dispose`.
|
|
46
|
+
// eslint-disable-next-line unicorn/prefer-dispose -- see above
|
|
47
|
+
try {
|
|
48
|
+
// An input that ends means the default, whichever way it ends: `askOnce` catches the rejection
|
|
49
|
+
// Ctrl-D raises, and the race catches a close that leaves the question unanswered and
|
|
50
|
+
// unrejected. Either way this returns, because a scaffolder that ends having written nothing is
|
|
51
|
+
// not an answer to `pnpm create upwind`.
|
|
52
|
+
const answer = await Promise.race([
|
|
53
|
+
askOnce(rl, `Where should the application go? (${fallback}) `),
|
|
54
|
+
untilClosed(rl),
|
|
55
|
+
]);
|
|
56
|
+
const trimmed = answer.trim();
|
|
57
|
+
return trimmed === NO_ANSWER ? fallback : trimmed;
|
|
58
|
+
} finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { cp, readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { type PackageManager, runCommand } from './package-manager.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The application this copies, and what has to be renamed on the way out.
|
|
9
|
+
*
|
|
10
|
+
* `../templates/default/` reads the same from the sources a workspace links and from the module a
|
|
11
|
+
* registry installs: both sit one directory below the package root, and `templates` travels with the
|
|
12
|
+
* package (`files`).
|
|
13
|
+
*
|
|
14
|
+
* The template carries `gitignore` without its dot because **npm removes a `.gitignore` from every
|
|
15
|
+
* tarball it packs**. A template that kept the dot would arrive with no ignore file at all, and the
|
|
16
|
+
* first commit of every scaffolded project would carry `node_modules`. `create-next-app` renames the
|
|
17
|
+
* same file for the same reason.
|
|
18
|
+
*/
|
|
19
|
+
const TEMPLATE = fileURLToPath(new URL('../templates/default/', import.meta.url));
|
|
20
|
+
|
|
21
|
+
/** Entries that do not make a directory non-empty: a checkout and a Finder artefact. */
|
|
22
|
+
const IGNORED_ENTRIES: ReadonlySet<string> = new Set(['.DS_Store', '.git']);
|
|
23
|
+
|
|
24
|
+
/** Is this the error of a directory that is not there? Anything else is a directory that is. */
|
|
25
|
+
function isMissing(error: unknown): boolean {
|
|
26
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What is already in the way, if anything: the names, so the message can say them. */
|
|
30
|
+
export async function conflictsIn(target: string): Promise<readonly string[]> {
|
|
31
|
+
try {
|
|
32
|
+
const entries = await readdir(target);
|
|
33
|
+
return entries.filter((entry) => !IGNORED_ENTRIES.has(entry));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (isMissing(error)) {
|
|
36
|
+
// Nothing there to conflict with; `cp` makes the directory.
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
// A directory that cannot be read is not an empty one. Taking it for empty would write an
|
|
40
|
+
// application into a place this could not even look at first.
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function copyTemplate(target: string): Promise<void> {
|
|
46
|
+
await cp(TEMPLATE, target, { recursive: true });
|
|
47
|
+
await rename(path.join(target, 'gitignore'), path.join(target, '.gitignore'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The README, in the manager the project was made with.
|
|
52
|
+
*
|
|
53
|
+
* The template is written in pnpm because a file has to be written in something. A project installed
|
|
54
|
+
* with npm has an npm lockfile, and a README that tells its reader to run pnpm tells them to install
|
|
55
|
+
* it a second way — or, on a machine without pnpm, to run something that is not there.
|
|
56
|
+
*/
|
|
57
|
+
export async function retellReadme(target: string, manager: PackageManager): Promise<void> {
|
|
58
|
+
if (manager === 'pnpm') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const readme = path.join(target, 'README.md');
|
|
62
|
+
const written = await readFile(readme, 'utf8');
|
|
63
|
+
// Replacement functions, not strings: what a manager's command is has no `$` in it, and a
|
|
64
|
+
// replacement that is taken for a pattern is a bug nobody would look for here.
|
|
65
|
+
const retold = written
|
|
66
|
+
.replaceAll('pnpm dev', () => runCommand(manager, 'dev'))
|
|
67
|
+
.replaceAll('pnpm build', () => runCommand(manager, 'build'));
|
|
68
|
+
await writeFile(readme, retold);
|
|
69
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This scaffolder's own version, and what it asks for of the packages released beside it.
|
|
5
|
+
*
|
|
6
|
+
* `create-upwind@x.y.z` scaffolds `upwind@^x.y.z`: the two are published from one tag, which the
|
|
7
|
+
* release verifies says the version every package says, so a scaffolder and the CLI it wires in are
|
|
8
|
+
* always of the same generation. A version that cannot be read falls back to `latest` — a scaffolded
|
|
9
|
+
* project that installs the newest is a better answer than one that installs nothing.
|
|
10
|
+
*/
|
|
11
|
+
const FALLBACK = 'latest';
|
|
12
|
+
|
|
13
|
+
export async function ownRange(): Promise<string> {
|
|
14
|
+
try {
|
|
15
|
+
const manifest: unknown = JSON.parse(
|
|
16
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8'),
|
|
17
|
+
);
|
|
18
|
+
if (
|
|
19
|
+
typeof manifest === 'object' &&
|
|
20
|
+
manifest !== null &&
|
|
21
|
+
'version' in manifest &&
|
|
22
|
+
typeof manifest.version === 'string'
|
|
23
|
+
) {
|
|
24
|
+
return `^${manifest.version}`;
|
|
25
|
+
}
|
|
26
|
+
return FALLBACK;
|
|
27
|
+
} catch {
|
|
28
|
+
return FALLBACK;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The version this reports for `--version`, or nothing when its own manifest could not be read. */
|
|
33
|
+
export async function ownVersion(): Promise<string | undefined> {
|
|
34
|
+
const range = await ownRange();
|
|
35
|
+
return range === FALLBACK ? undefined : range.slice(1);
|
|
36
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# upwind app
|
|
2
|
+
|
|
3
|
+
A [Next.js](https://nextjs.org) application, run by [upwind](https://github.com/arkorlab/upwind).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm dev
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`pnpm dev` is `upwind dev`: upwind holds the port and hands every request to the project's own
|
|
10
|
+
Next.js development server — the same dev bundler, the same HMR, the same error overlay — except for
|
|
11
|
+
`/__upwind`, which upwind answers itself. Open
|
|
12
|
+
[http://localhost:3000/__upwind](http://localhost:3000/__upwind) to see what it says about the run;
|
|
13
|
+
it answers while the application is still compiling, and while a compile of it is failing.
|
|
14
|
+
|
|
15
|
+
Edit `app/page.tsx` and the page updates.
|
|
16
|
+
|
|
17
|
+
## Building
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm build
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`pnpm build` is `upwind build`: the project's own `next build`, with the deployment adapter named. It
|
|
24
|
+
writes a deployment bundle under `.ppr-cdn/` — every route, prerender and static file by content,
|
|
25
|
+
and the Functions that run the application's code. `next.config.ts` names the same adapter, so a
|
|
26
|
+
plain `next build` from CI or from any other tool produces the same bundle.
|
|
27
|
+
|
|
28
|
+
There is no `start` script on purpose. What a deployment runs is the bundle, not a Node server of
|
|
29
|
+
this project's own.
|
|
30
|
+
|
|
31
|
+
## Scheduled work
|
|
32
|
+
|
|
33
|
+
Cron jobs are declared beside `next.config.ts`, in an `upwind.config.ts` (or `upwind.json`) of your
|
|
34
|
+
own:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export default {
|
|
38
|
+
crons: [{ path: '/api/nightly', schedule: '0 3 * * *' }],
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The build reads it, refuses a schedule it cannot run, and carries what it accepted into the bundle.
|
|
43
|
+
|
|
44
|
+
## Learn more
|
|
45
|
+
|
|
46
|
+
- [Next.js documentation](https://nextjs.org/docs)
|
|
47
|
+
- [Tailwind CSS](https://tailwindcss.com/docs) — already set up, in `app/globals.css`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
import './globals.css';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'upwind app',
|
|
7
|
+
description: 'A Next.js application, run by upwind.',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
11
|
+
return (
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<body className="antialiased">{children}</body>
|
|
14
|
+
</html>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default function Home() {
|
|
2
|
+
return (
|
|
3
|
+
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
|
4
|
+
<h1 className="text-2xl font-semibold">upwind</h1>
|
|
5
|
+
<p className="text-sm text-gray-500">
|
|
6
|
+
Edit <code className="font-mono">app/page.tsx</code> to get started.
|
|
7
|
+
</p>
|
|
8
|
+
<a className="text-sm underline underline-offset-4" href="/__upwind">
|
|
9
|
+
/__upwind
|
|
10
|
+
</a>
|
|
11
|
+
</main>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
/node_modules
|
|
3
|
+
/.pnp
|
|
4
|
+
.pnp.*
|
|
5
|
+
.yarn/*
|
|
6
|
+
!.yarn/patches
|
|
7
|
+
!.yarn/plugins
|
|
8
|
+
!.yarn/releases
|
|
9
|
+
!.yarn/versions
|
|
10
|
+
|
|
11
|
+
# next.js
|
|
12
|
+
/.next/
|
|
13
|
+
/out/
|
|
14
|
+
|
|
15
|
+
# the deployment bundle a build writes
|
|
16
|
+
/.ppr-cdn/
|
|
17
|
+
|
|
18
|
+
# misc
|
|
19
|
+
.DS_Store
|
|
20
|
+
*.pem
|
|
21
|
+
|
|
22
|
+
# debug
|
|
23
|
+
npm-debug.log*
|
|
24
|
+
yarn-debug.log*
|
|
25
|
+
yarn-error.log*
|
|
26
|
+
.pnpm-debug.log*
|
|
27
|
+
|
|
28
|
+
# env files
|
|
29
|
+
.env*
|
|
30
|
+
|
|
31
|
+
# typescript
|
|
32
|
+
*.tsbuildinfo
|
|
33
|
+
next-env.d.ts
|