@vlandoss/vland 0.0.1-git-53b6b02.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/README.md +80 -0
- package/bin +40 -0
- package/dist/cli.usage.kdl +32 -0
- package/dist/run.mjs +399 -0
- package/package.json +58 -0
- package/src/actions/init.ts +210 -0
- package/src/actions/placeholders.ts +139 -0
- package/src/actions/template.ts +48 -0
- package/src/program/commands/completion.ts +26 -0
- package/src/program/commands/init.ts +33 -0
- package/src/program/commands/usage.ts +9 -0
- package/src/program/index.ts +23 -0
- package/src/program/ui.ts +29 -0
- package/src/run.ts +11 -0
- package/src/services/ctx.ts +32 -0
- package/src/services/logger.ts +5 -0
- package/tsconfig.json +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# π¦ vland
|
|
2
|
+
|
|
3
|
+
The CLI to init a new project in Variable Land π
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js >= 20.0.0
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Run it directly with `npx`:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx @vlandoss/vland init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
β¦or install it globally:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add -g @vlandoss/vland
|
|
21
|
+
vland init
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
vland init # interactive
|
|
28
|
+
vland init my-app -t library # explicit template
|
|
29
|
+
vland init my-app -t backend --no-install --no-git
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
See [`CLI.md`](./CLI.md) for the full reference (auto-generated per release).
|
|
33
|
+
|
|
34
|
+
### Templates
|
|
35
|
+
|
|
36
|
+
| Template | What you get |
|
|
37
|
+
|------------|--------------|
|
|
38
|
+
| `library` | A standalone TypeScript library with tsdown, Vitest, biome, Changesets release workflow. |
|
|
39
|
+
| `backend` | An Elysia (`@elysiajs/node`) backend with evlog, Vitest, Dockerfile, CI shape. |
|
|
40
|
+
| `monorepo` | pnpm + Turbo workspace with an Elysia API, a Vite-React SPA, and a few internal packages. |
|
|
41
|
+
|
|
42
|
+
All templates target Node.js, use pnpm, and extend `@vlandoss/config` for biome and tsconfig.
|
|
43
|
+
|
|
44
|
+
## Shell completion
|
|
45
|
+
|
|
46
|
+
`vland` ships a `completion` subcommand that prints a shell-specific script. Add it to your shell rc file:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
# zsh β ~/.zshrc
|
|
50
|
+
eval "$(vland completion zsh)"
|
|
51
|
+
|
|
52
|
+
# bash β ~/.bashrc
|
|
53
|
+
eval "$(vland completion bash)"
|
|
54
|
+
|
|
55
|
+
# fish β ~/.config/fish/config.fish
|
|
56
|
+
vland completion fish | source
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Prerequisite:** the [`usage`](https://usage.jdx.dev) CLI must be on your `PATH` (it powers completion at runtime). Install via one of:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
mise use -g usage
|
|
63
|
+
brew install usage
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
When you upgrade `@vlandoss/vland`, the next shell session will pick up new commands automatically β no need to re-run anything.
|
|
67
|
+
|
|
68
|
+
## Troubleshooting
|
|
69
|
+
|
|
70
|
+
To enable debug mode, set the `DEBUG` environment variable to `vland:*` before running *any* command.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
DEBUG=vland:* vland init my-app
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To point `init` at local templates instead of fetching from GitHub (useful when developing inside this monorepo):
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
VLAND_TEMPLATES_DIR=/absolute/path/to/dx/templates vland init my-app -t library
|
|
80
|
+
```
|
package/bin
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
5
|
+
while [ -L "$SOURCE" ]; do
|
|
6
|
+
TARGET="$(readlink "$SOURCE")"
|
|
7
|
+
case "$TARGET" in
|
|
8
|
+
/*) SOURCE="$TARGET" ;;
|
|
9
|
+
*) SOURCE="$(dirname "$SOURCE")/$TARGET" ;;
|
|
10
|
+
esac
|
|
11
|
+
done
|
|
12
|
+
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
|
|
13
|
+
|
|
14
|
+
if [ "$1" = "completion" ]; then
|
|
15
|
+
case "$2" in
|
|
16
|
+
bash | zsh | fish)
|
|
17
|
+
kdl="$DIR/dist/cli.usage.kdl"
|
|
18
|
+
if [ ! -f "$kdl" ]; then
|
|
19
|
+
echo "vland completion: missing $kdl. Reinstall @vlandoss/vland or run \`pnpm build\`." >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
if ! command -v usage >/dev/null 2>&1; then
|
|
23
|
+
echo "vland completion: 'usage' CLI not found in PATH." >&2
|
|
24
|
+
echo "Install via: mise use -g usage | brew install usage" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
exec usage generate completion "$2" vland --file "$kdl"
|
|
28
|
+
;;
|
|
29
|
+
esac
|
|
30
|
+
# Unknown shell or `--help`: fall through to Node so Commander prints help/errors.
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# In the source repo (tsdown.config.ts present, NOT shipped in the npm tarball),
|
|
34
|
+
# always run from src/ so live edits show up. In a published install, use the
|
|
35
|
+
# bundled dist/run.mjs.
|
|
36
|
+
if [ -f "$DIR/tsdown.config.ts" ]; then
|
|
37
|
+
exec node "$DIR/src/run.ts" "$@"
|
|
38
|
+
else
|
|
39
|
+
exec node "$DIR/dist/run.mjs" "$@"
|
|
40
|
+
fi
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @generated by @usage-spec/commander from Commander.js metadata
|
|
2
|
+
name vland
|
|
3
|
+
bin vland
|
|
4
|
+
version "0.0.1-git-53b6b02.0"
|
|
5
|
+
usage "[options] [command]"
|
|
6
|
+
flag --usage help="print KDL spec for this CLI (https://kdl.dev)"
|
|
7
|
+
cmd completion help="print shell completion script π (\u{1b}[38;2;36;197;94musage\u{1b}[39m)" {
|
|
8
|
+
long_help "Prints a shell completion script for vland. Add to your shell rc file:\n\n bash: eval \"$(vland completion bash)\"\n zsh: eval \"$(vland completion zsh)\"\n fish: vland completion fish | source"
|
|
9
|
+
arg <shell> help="target shell" {
|
|
10
|
+
choices bash zsh fish
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
cmd init help="init a new project π (\u{1b}[38;2;244;114;182mgiget\u{1b}[39m)" {
|
|
14
|
+
long_help "Scaffold a new variableland project from one of the official templates."
|
|
15
|
+
flag "-t --template" help="template to use" {
|
|
16
|
+
arg <TEMPLATE> {
|
|
17
|
+
choices library backend monorepo
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
flag "-d --dir" help="target directory (default: ./<name>)" {
|
|
21
|
+
arg <DIR>
|
|
22
|
+
}
|
|
23
|
+
flag --pm help="package manager to use" {
|
|
24
|
+
arg <PM> {
|
|
25
|
+
choices npm pnpm yarn bun
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
flag --no-install help="skip dependency installation" negate=--install
|
|
29
|
+
flag --no-git help="skip git init" negate=--git
|
|
30
|
+
flag "-f --force" help="overwrite existing directory"
|
|
31
|
+
arg "[name]" help="project name (also used as the target directory)" required=#false
|
|
32
|
+
}
|
package/dist/run.mjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import path, { extname, isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { colorize, createPkg, createShellService, dirnameOf, hasTTY, palette, run, text } from "@vlandoss/clibuddy";
|
|
3
|
+
import { Argument, Command, Option, createCommand } from "commander";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { createLoggy } from "@vlandoss/loggy";
|
|
6
|
+
import { cp, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { cancel, intro, isCancel, log, outro, select, spinner, text as text$1 } from "@clack/prompts";
|
|
8
|
+
import { detectPackageManager, installDependencies } from "nypm";
|
|
9
|
+
import { downloadTemplate } from "giget";
|
|
10
|
+
import { generateToStdout } from "@usage-spec/commander";
|
|
11
|
+
//#region src/services/logger.ts
|
|
12
|
+
const logger = createLoggy({ namespace: "vland" });
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/services/ctx.ts
|
|
15
|
+
async function createContext(binDir) {
|
|
16
|
+
const debug = logger.subdebug("create-context");
|
|
17
|
+
const binPath = fs.realpathSync(binDir);
|
|
18
|
+
debug("bin path:", binPath);
|
|
19
|
+
const binPkg = await createPkg(binPath);
|
|
20
|
+
if (!binPkg) throw new Error("Could not find bin package.json");
|
|
21
|
+
debug("bin pkg info: %O", binPkg.info());
|
|
22
|
+
const shell = createShellService({ localBaseBinPath: [binDir] });
|
|
23
|
+
debug("shell service options: %O", shell.options);
|
|
24
|
+
return {
|
|
25
|
+
binPkg,
|
|
26
|
+
shell
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/program/ui.ts
|
|
31
|
+
const vlandColor = colorize("#a78bfa");
|
|
32
|
+
const usageColor = colorize("#24C55E");
|
|
33
|
+
const gigetColor = colorize("#F472B6");
|
|
34
|
+
const TOOL_LABELS = {
|
|
35
|
+
USAGE: usageColor("usage"),
|
|
36
|
+
GIGET: gigetColor("giget")
|
|
37
|
+
};
|
|
38
|
+
function getBannerText(version) {
|
|
39
|
+
return `
|
|
40
|
+
${vlandColor(`
|
|
41
|
+
βββ ββββββ ββββββ ββββ ββββββββββ
|
|
42
|
+
βββ ββββββ βββββββββββββ βββββββββββ
|
|
43
|
+
βββ ββββββ ββββββββββββββ ββββββ βββ
|
|
44
|
+
ββββ βββββββ βββββββββββββββββββββ βββ
|
|
45
|
+
βββββββ βββββββββββ ββββββ ββββββββββββββ
|
|
46
|
+
βββββ βββββββββββ ββββββ ββββββββββββ ${text.version(version)}
|
|
47
|
+
`.trim())}
|
|
48
|
+
|
|
49
|
+
π¦ ${palette.italic(palette.muted("The CLI to init a new project in"))} ${text.vland}\n`.trimStart();
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/program/commands/completion.ts
|
|
53
|
+
const SHELLS = [
|
|
54
|
+
"bash",
|
|
55
|
+
"zsh",
|
|
56
|
+
"fish"
|
|
57
|
+
];
|
|
58
|
+
function createCompletionCommand() {
|
|
59
|
+
return createCommand("completion").summary(`print shell completion script π (${TOOL_LABELS.USAGE})`).description(`Prints a shell completion script for vland. Add to your shell rc file:
|
|
60
|
+
|
|
61
|
+
bash: eval "$(vland completion bash)"
|
|
62
|
+
zsh: eval "$(vland completion zsh)"
|
|
63
|
+
fish: vland completion fish | source`).addArgument(new Argument("<shell>", `target shell`).choices(SHELLS)).addHelpText("afterAll", `\nUnder the hood, this command uses ${TOOL_LABELS.USAGE} (https://usage.jdx.dev).
|
|
64
|
+
Make sure to have it installed and available in your PATH.`);
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/actions/placeholders.ts
|
|
68
|
+
const TEXT_EXTENSIONS = new Set([
|
|
69
|
+
".ts",
|
|
70
|
+
".tsx",
|
|
71
|
+
".js",
|
|
72
|
+
".jsx",
|
|
73
|
+
".mjs",
|
|
74
|
+
".cjs",
|
|
75
|
+
".mts",
|
|
76
|
+
".cts",
|
|
77
|
+
".json",
|
|
78
|
+
".jsonc",
|
|
79
|
+
".md",
|
|
80
|
+
".mdx",
|
|
81
|
+
".yml",
|
|
82
|
+
".yaml",
|
|
83
|
+
".toml",
|
|
84
|
+
".css",
|
|
85
|
+
".html",
|
|
86
|
+
".sh",
|
|
87
|
+
".env",
|
|
88
|
+
".gitignore",
|
|
89
|
+
".gitattributes",
|
|
90
|
+
".npmrc",
|
|
91
|
+
".nvmrc",
|
|
92
|
+
".node-version",
|
|
93
|
+
".dockerignore",
|
|
94
|
+
".editorconfig",
|
|
95
|
+
".prettierrc"
|
|
96
|
+
]);
|
|
97
|
+
const TEXT_FILENAMES = new Set([
|
|
98
|
+
"Dockerfile",
|
|
99
|
+
"LICENSE",
|
|
100
|
+
"README",
|
|
101
|
+
"CHANGELOG",
|
|
102
|
+
".gitignore",
|
|
103
|
+
".gitattributes",
|
|
104
|
+
".npmrc",
|
|
105
|
+
".nvmrc",
|
|
106
|
+
".node-version",
|
|
107
|
+
".dockerignore",
|
|
108
|
+
".editorconfig",
|
|
109
|
+
".prettierrc",
|
|
110
|
+
"lefthook.yml",
|
|
111
|
+
"mise.toml"
|
|
112
|
+
]);
|
|
113
|
+
const SKIP_DIRS = new Set([
|
|
114
|
+
"node_modules",
|
|
115
|
+
".git",
|
|
116
|
+
"dist",
|
|
117
|
+
".turbo",
|
|
118
|
+
".next",
|
|
119
|
+
"build",
|
|
120
|
+
"coverage"
|
|
121
|
+
]);
|
|
122
|
+
function isTextFile(name) {
|
|
123
|
+
if (TEXT_FILENAMES.has(name)) return true;
|
|
124
|
+
return TEXT_EXTENSIONS.has(extname(name));
|
|
125
|
+
}
|
|
126
|
+
async function walk(root, onFile) {
|
|
127
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
128
|
+
await Promise.all(entries.map(async (entry) => {
|
|
129
|
+
const full = join(root, entry.name);
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
if (SKIP_DIRS.has(entry.name)) return;
|
|
132
|
+
await walk(full, onFile);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (entry.isFile()) await onFile(full);
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
function applyPlaceholders(content, values) {
|
|
139
|
+
return content.replaceAll("{{projectName}}", values.projectName).replaceAll("{{author}}", values.author).replaceAll("{{year}}", values.year);
|
|
140
|
+
}
|
|
141
|
+
async function replacePlaceholders(rootDir, values) {
|
|
142
|
+
const debug = logger.subdebug("placeholders");
|
|
143
|
+
let touched = 0;
|
|
144
|
+
await walk(rootDir, async (filePath) => {
|
|
145
|
+
if (!isTextFile(filePath.split("/").pop() ?? "")) return;
|
|
146
|
+
if ((await stat(filePath)).size > 1e6) return;
|
|
147
|
+
const original = await readFile(filePath, "utf8");
|
|
148
|
+
const replaced = applyPlaceholders(original, values);
|
|
149
|
+
if (replaced !== original) {
|
|
150
|
+
await writeFile(filePath, replaced);
|
|
151
|
+
touched += 1;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
debug("placeholders applied to %d file(s)", touched);
|
|
155
|
+
return { touched };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Updates the root `package.json` `name` field via `pkg-types`. Safer than a
|
|
159
|
+
* regex pass because it preserves field ordering and JSON formatting handled
|
|
160
|
+
* by `pkg-types`.
|
|
161
|
+
*/
|
|
162
|
+
async function updateRootPackageName(rootDir, projectName) {
|
|
163
|
+
const debug = logger.subdebug("update-root-package-name");
|
|
164
|
+
const rootPath = fs.realpathSync(rootDir);
|
|
165
|
+
debug("root path:", rootPath);
|
|
166
|
+
debug("process cwd:", process.cwd());
|
|
167
|
+
const pkg = await createPkg(rootPath);
|
|
168
|
+
if (!pkg) throw new Error("Could not find package.json");
|
|
169
|
+
try {
|
|
170
|
+
pkg.packageJson.name = projectName;
|
|
171
|
+
await pkg.write(pkg.packageJson);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
debug("skipped %s", error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/actions/template.ts
|
|
178
|
+
const TEMPLATES = [
|
|
179
|
+
"library",
|
|
180
|
+
"backend",
|
|
181
|
+
"monorepo"
|
|
182
|
+
];
|
|
183
|
+
const GITHUB_SOURCE = "github:variableland/dx";
|
|
184
|
+
const GITHUB_REF = "main";
|
|
185
|
+
/**
|
|
186
|
+
* Resolves the template into `dir`. Source order:
|
|
187
|
+
* 1. `VLAND_TEMPLATES_DIR` env var β copy from local path (used by E2E tests
|
|
188
|
+
* against the in-repo `templates/`).
|
|
189
|
+
* 2. Otherwise β download via giget from `github:variableland/dx/templates/<name>`.
|
|
190
|
+
*/
|
|
191
|
+
async function fetchTemplate(options) {
|
|
192
|
+
const debug = logger.subdebug("fetch-template");
|
|
193
|
+
const localRoot = process.env.VLAND_TEMPLATES_DIR;
|
|
194
|
+
if (localRoot) {
|
|
195
|
+
const sourceDir = resolve(localRoot, options.template);
|
|
196
|
+
debug("local source: %s", sourceDir);
|
|
197
|
+
await cp(sourceDir, options.dir, {
|
|
198
|
+
recursive: true,
|
|
199
|
+
force: options.force,
|
|
200
|
+
errorOnExist: !options.force,
|
|
201
|
+
filter: (src) => !src.includes("/node_modules") && !src.endsWith("/.turbo") && !src.endsWith("/dist")
|
|
202
|
+
});
|
|
203
|
+
return { source: sourceDir };
|
|
204
|
+
}
|
|
205
|
+
const source = `${GITHUB_SOURCE}/templates/${options.template}#${GITHUB_REF}`;
|
|
206
|
+
debug("remote source: %s", source);
|
|
207
|
+
return { source: (await downloadTemplate(source, {
|
|
208
|
+
dir: options.dir,
|
|
209
|
+
force: options.force,
|
|
210
|
+
install: false
|
|
211
|
+
})).source };
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/actions/init.ts
|
|
215
|
+
const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
216
|
+
function validateProjectName(name) {
|
|
217
|
+
if (!name || !name.trim()) return "Name is required.";
|
|
218
|
+
if (/\s/.test(name)) return "Name cannot contain whitespace.";
|
|
219
|
+
if (name.startsWith(".") || name.startsWith("/") || name.startsWith("\\")) return "Name cannot start with '.', '/' or '\\'.";
|
|
220
|
+
if (name.includes("..")) return "Name cannot contain '..'.";
|
|
221
|
+
if (!NPM_NAME_RE.test(name)) return "Name must be a valid npm package name (lowercase, no spaces).";
|
|
222
|
+
}
|
|
223
|
+
async function isDirEmpty(dir) {
|
|
224
|
+
try {
|
|
225
|
+
return (await readdir(dir)).length === 0;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error.code === "ENOENT") return true;
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function readGitAuthor(shell) {
|
|
232
|
+
try {
|
|
233
|
+
const [name, email] = await Promise.all([shell.$`git config --get user.name`.nothrow(), shell.$`git config --get user.email`.nothrow()]);
|
|
234
|
+
const trimmedName = name.stdout.trim();
|
|
235
|
+
const trimmedEmail = email.stdout.trim();
|
|
236
|
+
if (!trimmedName) return void 0;
|
|
237
|
+
return trimmedEmail ? `${trimmedName} <${trimmedEmail}>` : trimmedName;
|
|
238
|
+
} catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function abort(message) {
|
|
243
|
+
cancel(message);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
async function runInit(ctx, options) {
|
|
247
|
+
const debug = logger.subdebug("init");
|
|
248
|
+
debug("options: %O", options);
|
|
249
|
+
const shell = ctx.shell.mute();
|
|
250
|
+
intro(`${palette.label(" vland init ")}`);
|
|
251
|
+
let template = options.template;
|
|
252
|
+
if (!template) {
|
|
253
|
+
if (!hasTTY) abort("Template is required in non-interactive environments. Use --template <library|backend|monorepo>.");
|
|
254
|
+
const choice = await select({
|
|
255
|
+
message: "Pick a template",
|
|
256
|
+
options: TEMPLATES.map((value) => ({
|
|
257
|
+
value,
|
|
258
|
+
label: value
|
|
259
|
+
}))
|
|
260
|
+
});
|
|
261
|
+
if (isCancel(choice)) abort("Cancelled.");
|
|
262
|
+
template = choice;
|
|
263
|
+
}
|
|
264
|
+
let name = options.name;
|
|
265
|
+
if (!name) {
|
|
266
|
+
if (!hasTTY) abort("Project name is required in non-interactive environments. Pass it as the first argument.");
|
|
267
|
+
const value = await text$1({
|
|
268
|
+
message: "Project name",
|
|
269
|
+
placeholder: "my-app",
|
|
270
|
+
validate: (input) => validateProjectName(input ?? "")
|
|
271
|
+
});
|
|
272
|
+
if (isCancel(value)) abort("Cancelled.");
|
|
273
|
+
name = value;
|
|
274
|
+
}
|
|
275
|
+
const nameError = validateProjectName(name);
|
|
276
|
+
if (nameError) abort(nameError);
|
|
277
|
+
const dir = options.dir ? isAbsolute(options.dir) ? options.dir : resolve(process.cwd(), options.dir) : resolve(process.cwd(), name);
|
|
278
|
+
debug("target dir: %s", dir);
|
|
279
|
+
if (!await isDirEmpty(dir) && !options.force) abort(`Target directory ${palette.highlight(dir)} is not empty. Re-run with ${palette.highlight("--force")} to overwrite.`);
|
|
280
|
+
let author = await readGitAuthor(shell);
|
|
281
|
+
if (!author) if (!hasTTY) author = "";
|
|
282
|
+
else {
|
|
283
|
+
const value = await text$1({
|
|
284
|
+
message: "Author (used in package.json / LICENSE)",
|
|
285
|
+
placeholder: "Jane Doe <jane@example.com>",
|
|
286
|
+
defaultValue: ""
|
|
287
|
+
});
|
|
288
|
+
if (isCancel(value)) abort("Cancelled.");
|
|
289
|
+
author = value || "";
|
|
290
|
+
}
|
|
291
|
+
debug("author: %s", author || "<empty>");
|
|
292
|
+
const fetchSpin = spinner();
|
|
293
|
+
fetchSpin.start(`Fetching ${palette.highlight(template)} template`);
|
|
294
|
+
try {
|
|
295
|
+
const { source } = await fetchTemplate({
|
|
296
|
+
template,
|
|
297
|
+
dir,
|
|
298
|
+
force: options.force
|
|
299
|
+
});
|
|
300
|
+
fetchSpin.stop(`Fetched template from ${palette.muted(source)}`);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
fetchSpin.stop("Failed to fetch template", 1);
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
const placeholderSpin = spinner();
|
|
306
|
+
placeholderSpin.start("Applying placeholders");
|
|
307
|
+
await replacePlaceholders(dir, {
|
|
308
|
+
projectName: name,
|
|
309
|
+
author,
|
|
310
|
+
year: (/* @__PURE__ */ new Date()).getFullYear().toString()
|
|
311
|
+
});
|
|
312
|
+
await updateRootPackageName(dir, name);
|
|
313
|
+
placeholderSpin.stop("Placeholders applied");
|
|
314
|
+
if (options.install) {
|
|
315
|
+
const detected = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
|
|
316
|
+
const installSpin = spinner();
|
|
317
|
+
installSpin.start(`Installing dependencies with ${palette.highlight(detected)}`);
|
|
318
|
+
try {
|
|
319
|
+
await installDependencies({
|
|
320
|
+
cwd: dir,
|
|
321
|
+
packageManager: {
|
|
322
|
+
name: detected,
|
|
323
|
+
command: detected
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
installSpin.stop(`Installed with ${palette.highlight(detected)}`);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
installSpin.stop("Failed to install dependencies", 1);
|
|
329
|
+
log.warn("You can install manually later with `cd <dir> && <pm> install`.");
|
|
330
|
+
debug("install error: %O", error);
|
|
331
|
+
}
|
|
332
|
+
} else log.info(`Skipping ${palette.highlight("install")} (--no-install).`);
|
|
333
|
+
if (options.git) {
|
|
334
|
+
const gitSpin = spinner();
|
|
335
|
+
gitSpin.start("Initialising git repository");
|
|
336
|
+
try {
|
|
337
|
+
const gitShell = shell.at(dir).child({ env: {
|
|
338
|
+
...process.env,
|
|
339
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "vland",
|
|
340
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "noreply@variable.land",
|
|
341
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "vland",
|
|
342
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "noreply@variable.land"
|
|
343
|
+
} });
|
|
344
|
+
await gitShell.$`git init`;
|
|
345
|
+
await gitShell.$`git add -A`;
|
|
346
|
+
await gitShell.$`git commit -m ${"chore: initial commit from vland"}`;
|
|
347
|
+
gitSpin.stop("Initialised git repository");
|
|
348
|
+
} catch (error) {
|
|
349
|
+
gitSpin.stop("Failed to initialise git", 1);
|
|
350
|
+
debug("git error: %O", error);
|
|
351
|
+
}
|
|
352
|
+
} else log.info(`Skipping ${palette.highlight("git init")} (--no-git).`);
|
|
353
|
+
const detectedPm = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
|
|
354
|
+
outro([
|
|
355
|
+
palette.success("Done!"),
|
|
356
|
+
"",
|
|
357
|
+
palette.muted("Next steps:"),
|
|
358
|
+
` cd ${name}`,
|
|
359
|
+
options.install ? ` ${detectedPm} dev` : ` ${detectedPm} install && ${detectedPm} dev`
|
|
360
|
+
].join("\n"));
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/program/commands/init.ts
|
|
364
|
+
function createInitCommand(ctx) {
|
|
365
|
+
return createCommand("init").summary(`init a new project π (${TOOL_LABELS.GIGET})`).description("Scaffold a new variableland project from one of the official templates.").addArgument(new Argument("[name]", "project name (also used as the target directory)")).addOption(new Option("-t, --template <name>", "template to use").choices([...TEMPLATES])).addOption(new Option("-d, --dir <path>", "target directory (default: ./<name>)")).addOption(new Option("--pm <manager>", "package manager to use").choices([
|
|
366
|
+
"npm",
|
|
367
|
+
"pnpm",
|
|
368
|
+
"yarn",
|
|
369
|
+
"bun"
|
|
370
|
+
])).addOption(new Option("--no-install", "skip dependency installation")).addOption(new Option("--no-git", "skip git init")).addOption(new Option("-f, --force", "overwrite existing directory").default(false)).action(async (name, options) => {
|
|
371
|
+
await runInit(ctx, {
|
|
372
|
+
name,
|
|
373
|
+
...options
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/program/commands/usage.ts
|
|
379
|
+
function addUsage(program) {
|
|
380
|
+
return program.addOption(new Option("--usage", "print KDL spec for this CLI (https://kdl.dev)")).on("option:usage", () => {
|
|
381
|
+
generateToStdout(program);
|
|
382
|
+
process.exit(0);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/program/index.ts
|
|
387
|
+
async function createProgram(options) {
|
|
388
|
+
const ctx = await createContext(options.binDir);
|
|
389
|
+
const version = ctx.binPkg.version;
|
|
390
|
+
return addUsage(new Command("vland").version(version, "-v, --version").addHelpText("before", getBannerText(version)).addCommand(createCompletionCommand()).addCommand(createInitCommand(ctx)));
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/run.ts
|
|
394
|
+
const BIN_DIR = path.dirname(dirnameOf(import.meta));
|
|
395
|
+
await run(async () => {
|
|
396
|
+
await (await createProgram({ binDir: BIN_DIR })).parseAsync();
|
|
397
|
+
}, logger);
|
|
398
|
+
//#endregion
|
|
399
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vlandoss/vland",
|
|
3
|
+
"version": "0.0.1-git-53b6b02.0",
|
|
4
|
+
"description": "The CLI to init a new project in Variable Land",
|
|
5
|
+
"homepage": "https://github.com/variableland/dx/tree/main/packages/vland#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/variableland/dx/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/variableland/dx.git",
|
|
12
|
+
"directory": "packages/vland"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "rcrd <rcrd@variable.land>",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"imports": {
|
|
18
|
+
"#src/*": "./src/*",
|
|
19
|
+
"#test/*": "./test/*"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"vland": "./bin"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"!src/**/__tests__",
|
|
29
|
+
"!src/**/*.test.*",
|
|
30
|
+
"tsconfig.json"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@clack/prompts": "0.11.0",
|
|
34
|
+
"@usage-spec/commander": "1.1.0",
|
|
35
|
+
"commander": "14.0.3",
|
|
36
|
+
"giget": "2.0.0",
|
|
37
|
+
"nypm": "0.6.0",
|
|
38
|
+
"@vlandoss/clibuddy": "0.4.1-git-53b6b02.0",
|
|
39
|
+
"@vlandoss/loggy": "0.2.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@vlandoss/tsdown-config": "^0.0.1"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown && pnpm build:kdl",
|
|
52
|
+
"build:kdl": "./bin --usage > dist/cli.usage.kdl",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:unit": "vitest run --project unit",
|
|
55
|
+
"test:integration": "vitest run --project integration",
|
|
56
|
+
"test:types": "rr tsc"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { cancel, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
|
|
4
|
+
import { hasTTY, palette } from "@vlandoss/clibuddy";
|
|
5
|
+
import { detectPackageManager, installDependencies } from "nypm";
|
|
6
|
+
import type { Context } from "#src/services/ctx.ts";
|
|
7
|
+
import { logger } from "#src/services/logger.ts";
|
|
8
|
+
import { replacePlaceholders, updateRootPackageName } from "./placeholders.ts";
|
|
9
|
+
import { fetchTemplate, TEMPLATES, type TemplateName } from "./template.ts";
|
|
10
|
+
|
|
11
|
+
export type InitOptions = {
|
|
12
|
+
name?: string;
|
|
13
|
+
template?: TemplateName;
|
|
14
|
+
dir?: string;
|
|
15
|
+
pm?: "npm" | "pnpm" | "yarn" | "bun";
|
|
16
|
+
install: boolean;
|
|
17
|
+
git: boolean;
|
|
18
|
+
force: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
22
|
+
|
|
23
|
+
function validateProjectName(name: string): string | undefined {
|
|
24
|
+
if (!name || !name.trim()) return "Name is required.";
|
|
25
|
+
if (/\s/.test(name)) return "Name cannot contain whitespace.";
|
|
26
|
+
if (name.startsWith(".") || name.startsWith("/") || name.startsWith("\\")) {
|
|
27
|
+
return "Name cannot start with '.', '/' or '\\'.";
|
|
28
|
+
}
|
|
29
|
+
if (name.includes("..")) return "Name cannot contain '..'.";
|
|
30
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
31
|
+
return "Name must be a valid npm package name (lowercase, no spaces).";
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function isDirEmpty(dir: string): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
const entries = await readdir(dir);
|
|
39
|
+
return entries.length === 0;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readGitAuthor(shell: Context["shell"]): Promise<string | undefined> {
|
|
47
|
+
try {
|
|
48
|
+
const [name, email] = await Promise.all([
|
|
49
|
+
shell.$`git config --get user.name`.nothrow(),
|
|
50
|
+
shell.$`git config --get user.email`.nothrow(),
|
|
51
|
+
]);
|
|
52
|
+
const trimmedName = name.stdout.trim();
|
|
53
|
+
const trimmedEmail = email.stdout.trim();
|
|
54
|
+
if (!trimmedName) return undefined;
|
|
55
|
+
return trimmedEmail ? `${trimmedName} <${trimmedEmail}>` : trimmedName;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function abort(message: string): never {
|
|
62
|
+
cancel(message);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function runInit(ctx: Context, options: InitOptions) {
|
|
67
|
+
const debug = logger.subdebug("init");
|
|
68
|
+
debug("options: %O", options);
|
|
69
|
+
|
|
70
|
+
// Mute zx so the @clack/prompts UI stays clean while git output is captured.
|
|
71
|
+
const shell = ctx.shell.mute();
|
|
72
|
+
|
|
73
|
+
intro(`${palette.label(" vland init ")}`);
|
|
74
|
+
|
|
75
|
+
// 1. Resolve template
|
|
76
|
+
let template = options.template;
|
|
77
|
+
if (!template) {
|
|
78
|
+
if (!hasTTY) abort("Template is required in non-interactive environments. Use --template <library|backend|monorepo>.");
|
|
79
|
+
const choice = await select({
|
|
80
|
+
message: "Pick a template",
|
|
81
|
+
options: TEMPLATES.map((value) => ({
|
|
82
|
+
value,
|
|
83
|
+
label: value,
|
|
84
|
+
})),
|
|
85
|
+
});
|
|
86
|
+
if (isCancel(choice)) abort("Cancelled.");
|
|
87
|
+
template = choice as TemplateName;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Resolve project name
|
|
91
|
+
let name = options.name;
|
|
92
|
+
if (!name) {
|
|
93
|
+
if (!hasTTY) abort("Project name is required in non-interactive environments. Pass it as the first argument.");
|
|
94
|
+
const value = await text({
|
|
95
|
+
message: "Project name",
|
|
96
|
+
placeholder: "my-app",
|
|
97
|
+
validate: (input) => validateProjectName(input ?? ""),
|
|
98
|
+
});
|
|
99
|
+
if (isCancel(value)) abort("Cancelled.");
|
|
100
|
+
name = value as string;
|
|
101
|
+
}
|
|
102
|
+
const nameError = validateProjectName(name);
|
|
103
|
+
if (nameError) abort(nameError);
|
|
104
|
+
|
|
105
|
+
// 3. Resolve target dir
|
|
106
|
+
const dir = options.dir
|
|
107
|
+
? isAbsolute(options.dir)
|
|
108
|
+
? options.dir
|
|
109
|
+
: resolve(process.cwd(), options.dir)
|
|
110
|
+
: resolve(process.cwd(), name);
|
|
111
|
+
debug("target dir: %s", dir);
|
|
112
|
+
|
|
113
|
+
if (!(await isDirEmpty(dir)) && !options.force) {
|
|
114
|
+
abort(`Target directory ${palette.highlight(dir)} is not empty. Re-run with ${palette.highlight("--force")} to overwrite.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 4. Resolve author
|
|
118
|
+
let author = await readGitAuthor(shell);
|
|
119
|
+
if (!author) {
|
|
120
|
+
if (!hasTTY) {
|
|
121
|
+
author = "";
|
|
122
|
+
} else {
|
|
123
|
+
const value = await text({
|
|
124
|
+
message: "Author (used in package.json / LICENSE)",
|
|
125
|
+
placeholder: "Jane Doe <jane@example.com>",
|
|
126
|
+
defaultValue: "",
|
|
127
|
+
});
|
|
128
|
+
if (isCancel(value)) abort("Cancelled.");
|
|
129
|
+
author = (value as string) || "";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
debug("author: %s", author || "<empty>");
|
|
133
|
+
|
|
134
|
+
// 5. Download template
|
|
135
|
+
const fetchSpin = spinner();
|
|
136
|
+
fetchSpin.start(`Fetching ${palette.highlight(template)} template`);
|
|
137
|
+
try {
|
|
138
|
+
const { source } = await fetchTemplate({ template, dir, force: options.force });
|
|
139
|
+
fetchSpin.stop(`Fetched template from ${palette.muted(source)}`);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
fetchSpin.stop("Failed to fetch template", 1);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 6. Replace placeholders
|
|
146
|
+
const placeholderSpin = spinner();
|
|
147
|
+
placeholderSpin.start("Applying placeholders");
|
|
148
|
+
await replacePlaceholders(dir, {
|
|
149
|
+
projectName: name,
|
|
150
|
+
author,
|
|
151
|
+
year: new Date().getFullYear().toString(),
|
|
152
|
+
});
|
|
153
|
+
await updateRootPackageName(dir, name);
|
|
154
|
+
placeholderSpin.stop("Placeholders applied");
|
|
155
|
+
|
|
156
|
+
// 7. Install deps
|
|
157
|
+
if (options.install) {
|
|
158
|
+
const detected = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
|
|
159
|
+
const installSpin = spinner();
|
|
160
|
+
installSpin.start(`Installing dependencies with ${palette.highlight(detected)}`);
|
|
161
|
+
try {
|
|
162
|
+
await installDependencies({ cwd: dir, packageManager: { name: detected, command: detected } });
|
|
163
|
+
installSpin.stop(`Installed with ${palette.highlight(detected)}`);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
installSpin.stop("Failed to install dependencies", 1);
|
|
166
|
+
log.warn("You can install manually later with `cd <dir> && <pm> install`.");
|
|
167
|
+
debug("install error: %O", error);
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
log.info(`Skipping ${palette.highlight("install")} (--no-install).`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 8. Git init
|
|
174
|
+
if (options.git) {
|
|
175
|
+
const gitSpin = spinner();
|
|
176
|
+
gitSpin.start("Initialising git repository");
|
|
177
|
+
try {
|
|
178
|
+
const gitShell = shell.at(dir).child({
|
|
179
|
+
env: {
|
|
180
|
+
...process.env,
|
|
181
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "vland",
|
|
182
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "noreply@variable.land",
|
|
183
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "vland",
|
|
184
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "noreply@variable.land",
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await gitShell.$`git init`;
|
|
188
|
+
await gitShell.$`git add -A`;
|
|
189
|
+
await gitShell.$`git commit -m ${"chore: initial commit from vland"}`;
|
|
190
|
+
gitSpin.stop("Initialised git repository");
|
|
191
|
+
} catch (error) {
|
|
192
|
+
gitSpin.stop("Failed to initialise git", 1);
|
|
193
|
+
debug("git error: %O", error);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
log.info(`Skipping ${palette.highlight("git init")} (--no-git).`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 9. Outro with next steps
|
|
200
|
+
const detectedPm = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
|
|
201
|
+
outro(
|
|
202
|
+
[
|
|
203
|
+
palette.success("Done!"),
|
|
204
|
+
"",
|
|
205
|
+
palette.muted("Next steps:"),
|
|
206
|
+
` cd ${name}`,
|
|
207
|
+
options.install ? ` ${detectedPm} dev` : ` ${detectedPm} install && ${detectedPm} dev`,
|
|
208
|
+
].join("\n"),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { createPkg } from "@vlandoss/clibuddy";
|
|
5
|
+
import { logger } from "#src/services/logger.ts";
|
|
6
|
+
|
|
7
|
+
export type Placeholders = {
|
|
8
|
+
projectName: string;
|
|
9
|
+
author: string;
|
|
10
|
+
year: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const TEXT_EXTENSIONS = new Set([
|
|
14
|
+
".ts",
|
|
15
|
+
".tsx",
|
|
16
|
+
".js",
|
|
17
|
+
".jsx",
|
|
18
|
+
".mjs",
|
|
19
|
+
".cjs",
|
|
20
|
+
".mts",
|
|
21
|
+
".cts",
|
|
22
|
+
".json",
|
|
23
|
+
".jsonc",
|
|
24
|
+
".md",
|
|
25
|
+
".mdx",
|
|
26
|
+
".yml",
|
|
27
|
+
".yaml",
|
|
28
|
+
".toml",
|
|
29
|
+
".css",
|
|
30
|
+
".html",
|
|
31
|
+
".sh",
|
|
32
|
+
".env",
|
|
33
|
+
".gitignore",
|
|
34
|
+
".gitattributes",
|
|
35
|
+
".npmrc",
|
|
36
|
+
".nvmrc",
|
|
37
|
+
".node-version",
|
|
38
|
+
".dockerignore",
|
|
39
|
+
".editorconfig",
|
|
40
|
+
".prettierrc",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const TEXT_FILENAMES = new Set([
|
|
44
|
+
"Dockerfile",
|
|
45
|
+
"LICENSE",
|
|
46
|
+
"README",
|
|
47
|
+
"CHANGELOG",
|
|
48
|
+
".gitignore",
|
|
49
|
+
".gitattributes",
|
|
50
|
+
".npmrc",
|
|
51
|
+
".nvmrc",
|
|
52
|
+
".node-version",
|
|
53
|
+
".dockerignore",
|
|
54
|
+
".editorconfig",
|
|
55
|
+
".prettierrc",
|
|
56
|
+
"lefthook.yml",
|
|
57
|
+
"mise.toml",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".turbo", ".next", "build", "coverage"]);
|
|
61
|
+
|
|
62
|
+
function isTextFile(name: string) {
|
|
63
|
+
if (TEXT_FILENAMES.has(name)) return true;
|
|
64
|
+
return TEXT_EXTENSIONS.has(extname(name));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function walk(root: string, onFile: (path: string) => Promise<void>) {
|
|
68
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
69
|
+
await Promise.all(
|
|
70
|
+
entries.map(async (entry) => {
|
|
71
|
+
const full = join(root, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (SKIP_DIRS.has(entry.name)) return;
|
|
74
|
+
await walk(full, onFile);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (entry.isFile()) {
|
|
78
|
+
await onFile(full);
|
|
79
|
+
}
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function applyPlaceholders(content: string, values: Placeholders) {
|
|
85
|
+
return content
|
|
86
|
+
.replaceAll("{{projectName}}", values.projectName)
|
|
87
|
+
.replaceAll("{{author}}", values.author)
|
|
88
|
+
.replaceAll("{{year}}", values.year);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function replacePlaceholders(rootDir: string, values: Placeholders) {
|
|
92
|
+
const debug = logger.subdebug("placeholders");
|
|
93
|
+
let touched = 0;
|
|
94
|
+
|
|
95
|
+
await walk(rootDir, async (filePath) => {
|
|
96
|
+
const name = filePath.split("/").pop() ?? "";
|
|
97
|
+
if (!isTextFile(name)) return;
|
|
98
|
+
|
|
99
|
+
const fileStat = await stat(filePath);
|
|
100
|
+
if (fileStat.size > 1_000_000) return; // skip files >1MB; templates shouldn't have those
|
|
101
|
+
|
|
102
|
+
const original = await readFile(filePath, "utf8");
|
|
103
|
+
const replaced = applyPlaceholders(original, values);
|
|
104
|
+
if (replaced !== original) {
|
|
105
|
+
await writeFile(filePath, replaced);
|
|
106
|
+
touched += 1;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
debug("placeholders applied to %d file(s)", touched);
|
|
111
|
+
return { touched };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Updates the root `package.json` `name` field via `pkg-types`. Safer than a
|
|
116
|
+
* regex pass because it preserves field ordering and JSON formatting handled
|
|
117
|
+
* by `pkg-types`.
|
|
118
|
+
*/
|
|
119
|
+
export async function updateRootPackageName(rootDir: string, projectName: string) {
|
|
120
|
+
const debug = logger.subdebug("update-root-package-name");
|
|
121
|
+
|
|
122
|
+
const rootPath = fs.realpathSync(rootDir);
|
|
123
|
+
|
|
124
|
+
debug("root path:", rootPath);
|
|
125
|
+
debug("process cwd:", process.cwd());
|
|
126
|
+
|
|
127
|
+
const pkg = await createPkg(rootPath);
|
|
128
|
+
|
|
129
|
+
if (!pkg) {
|
|
130
|
+
throw new Error("Could not find package.json");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
pkg.packageJson.name = projectName;
|
|
135
|
+
await pkg.write(pkg.packageJson);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
debug("skipped %s", error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cp } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { downloadTemplate } from "giget";
|
|
4
|
+
import { logger } from "#src/services/logger.ts";
|
|
5
|
+
|
|
6
|
+
export const TEMPLATES = ["library", "backend", "monorepo"] as const;
|
|
7
|
+
export type TemplateName = (typeof TEMPLATES)[number];
|
|
8
|
+
|
|
9
|
+
const GITHUB_SOURCE = "github:variableland/dx";
|
|
10
|
+
const GITHUB_REF = "main";
|
|
11
|
+
|
|
12
|
+
type ResolveOptions = {
|
|
13
|
+
template: TemplateName;
|
|
14
|
+
dir: string;
|
|
15
|
+
force: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the template into `dir`. Source order:
|
|
20
|
+
* 1. `VLAND_TEMPLATES_DIR` env var β copy from local path (used by E2E tests
|
|
21
|
+
* against the in-repo `templates/`).
|
|
22
|
+
* 2. Otherwise β download via giget from `github:variableland/dx/templates/<name>`.
|
|
23
|
+
*/
|
|
24
|
+
export async function fetchTemplate(options: ResolveOptions): Promise<{ source: string }> {
|
|
25
|
+
const debug = logger.subdebug("fetch-template");
|
|
26
|
+
const localRoot = process.env.VLAND_TEMPLATES_DIR;
|
|
27
|
+
|
|
28
|
+
if (localRoot) {
|
|
29
|
+
const sourceDir = resolve(localRoot, options.template);
|
|
30
|
+
debug("local source: %s", sourceDir);
|
|
31
|
+
await cp(sourceDir, options.dir, {
|
|
32
|
+
recursive: true,
|
|
33
|
+
force: options.force,
|
|
34
|
+
errorOnExist: !options.force,
|
|
35
|
+
filter: (src) => !src.includes("/node_modules") && !src.endsWith("/.turbo") && !src.endsWith("/dist"),
|
|
36
|
+
});
|
|
37
|
+
return { source: sourceDir };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const source = `${GITHUB_SOURCE}/templates/${options.template}#${GITHUB_REF}`;
|
|
41
|
+
debug("remote source: %s", source);
|
|
42
|
+
const result = await downloadTemplate(source, {
|
|
43
|
+
dir: options.dir,
|
|
44
|
+
force: options.force,
|
|
45
|
+
install: false,
|
|
46
|
+
});
|
|
47
|
+
return { source: result.source };
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Argument, createCommand } from "commander";
|
|
2
|
+
import { TOOL_LABELS } from "../ui.ts";
|
|
3
|
+
|
|
4
|
+
const SHELLS = ["bash", "zsh", "fish"] as const;
|
|
5
|
+
|
|
6
|
+
// Ghost command: registered with Commander purely for discoverability β it surfaces
|
|
7
|
+
// in `vland --help` and is baked into dist/cli.usage.kdl so the completion itself can
|
|
8
|
+
// suggest "completion" after `vland <TAB>`. The actual handler lives in the bash bin
|
|
9
|
+
// dispatcher, which intercepts `vland completion <shell>` before reaching Node.
|
|
10
|
+
export function createCompletionCommand() {
|
|
11
|
+
return createCommand("completion")
|
|
12
|
+
.summary(`print shell completion script π (${TOOL_LABELS.USAGE})`)
|
|
13
|
+
.description(
|
|
14
|
+
`Prints a shell completion script for vland. Add to your shell rc file:
|
|
15
|
+
|
|
16
|
+
bash: eval "$(vland completion bash)"
|
|
17
|
+
zsh: eval "$(vland completion zsh)"
|
|
18
|
+
fish: vland completion fish | source`,
|
|
19
|
+
)
|
|
20
|
+
.addArgument(new Argument("<shell>", `target shell`).choices(SHELLS))
|
|
21
|
+
.addHelpText(
|
|
22
|
+
"afterAll",
|
|
23
|
+
`\nUnder the hood, this command uses ${TOOL_LABELS.USAGE} (https://usage.jdx.dev).
|
|
24
|
+
Make sure to have it installed and available in your PATH.`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Argument, createCommand, Option } from "commander";
|
|
2
|
+
import { runInit } from "#src/actions/init.ts";
|
|
3
|
+
import { TEMPLATES, type TemplateName } from "#src/actions/template.ts";
|
|
4
|
+
import type { Context } from "#src/services/ctx.ts";
|
|
5
|
+
import { TOOL_LABELS } from "../ui.ts";
|
|
6
|
+
|
|
7
|
+
type InitOptions = {
|
|
8
|
+
dir?: string;
|
|
9
|
+
template?: TemplateName;
|
|
10
|
+
pm?: "npm" | "pnpm" | "yarn" | "bun";
|
|
11
|
+
install: boolean;
|
|
12
|
+
git: boolean;
|
|
13
|
+
force: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createInitCommand(ctx: Context) {
|
|
17
|
+
return createCommand("init")
|
|
18
|
+
.summary(`init a new project π (${TOOL_LABELS.GIGET})`)
|
|
19
|
+
.description("Scaffold a new variableland project from one of the official templates.")
|
|
20
|
+
.addArgument(new Argument("[name]", "project name (also used as the target directory)"))
|
|
21
|
+
.addOption(new Option("-t, --template <name>", "template to use").choices([...TEMPLATES]))
|
|
22
|
+
.addOption(new Option("-d, --dir <path>", "target directory (default: ./<name>)"))
|
|
23
|
+
.addOption(new Option("--pm <manager>", "package manager to use").choices(["npm", "pnpm", "yarn", "bun"]))
|
|
24
|
+
.addOption(new Option("--no-install", "skip dependency installation"))
|
|
25
|
+
.addOption(new Option("--no-git", "skip git init"))
|
|
26
|
+
.addOption(new Option("-f, --force", "overwrite existing directory").default(false))
|
|
27
|
+
.action(async (name: string | undefined, options: InitOptions) => {
|
|
28
|
+
await runInit(ctx, {
|
|
29
|
+
name,
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { generateToStdout } from "@usage-spec/commander";
|
|
2
|
+
import { type Command, Option } from "commander";
|
|
3
|
+
|
|
4
|
+
export function addUsage(program: Command) {
|
|
5
|
+
return program.addOption(new Option("--usage", "print KDL spec for this CLI (https://kdl.dev)")).on("option:usage", () => {
|
|
6
|
+
generateToStdout(program);
|
|
7
|
+
process.exit(0);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { createContext } from "#src/services/ctx.ts";
|
|
3
|
+
import { createCompletionCommand } from "./commands/completion.ts";
|
|
4
|
+
import { createInitCommand } from "./commands/init.ts";
|
|
5
|
+
import { addUsage } from "./commands/usage.ts";
|
|
6
|
+
import { getBannerText } from "./ui.ts";
|
|
7
|
+
|
|
8
|
+
export type Options = {
|
|
9
|
+
binDir: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function createProgram(options: Options) {
|
|
13
|
+
const ctx = await createContext(options.binDir);
|
|
14
|
+
const version = ctx.binPkg.version;
|
|
15
|
+
|
|
16
|
+
return addUsage(
|
|
17
|
+
new Command("vland")
|
|
18
|
+
.version(version, "-v, --version")
|
|
19
|
+
.addHelpText("before", getBannerText(version))
|
|
20
|
+
.addCommand(createCompletionCommand())
|
|
21
|
+
.addCommand(createInitCommand(ctx)),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { colorize, palette, text } from "@vlandoss/clibuddy";
|
|
2
|
+
|
|
3
|
+
const vlandColor = colorize("#a78bfa");
|
|
4
|
+
const usageColor = colorize("#24C55E");
|
|
5
|
+
const gigetColor = colorize("#F472B6");
|
|
6
|
+
|
|
7
|
+
export const TOOL_LABELS = {
|
|
8
|
+
USAGE: usageColor("usage"),
|
|
9
|
+
GIGET: gigetColor("giget"),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// npx figlet -f "ANSI Shadow" "vland"
|
|
13
|
+
export function getBannerText(version: string) {
|
|
14
|
+
const uiLogo = vlandColor(
|
|
15
|
+
`
|
|
16
|
+
βββ ββββββ ββββββ ββββ ββββββββββ
|
|
17
|
+
βββ ββββββ βββββββββββββ βββββββββββ
|
|
18
|
+
βββ ββββββ ββββββββββββββ ββββββ βββ
|
|
19
|
+
ββββ βββββββ βββββββββββββββββββββ βββ
|
|
20
|
+
βββββββ βββββββββββ ββββββ ββββββββββββββ
|
|
21
|
+
βββββ βββββββββββ ββββββ ββββββββββββ ${text.version(version)}
|
|
22
|
+
`.trim(),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return `
|
|
26
|
+
${uiLogo}
|
|
27
|
+
|
|
28
|
+
π¦ ${palette.italic(palette.muted("The CLI to init a new project in"))} ${text.vland}\n`.trimStart();
|
|
29
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { dirnameOf, run } from "@vlandoss/clibuddy";
|
|
3
|
+
import { createProgram } from "./program/index.ts";
|
|
4
|
+
import { logger } from "./services/logger.ts";
|
|
5
|
+
|
|
6
|
+
const BIN_DIR = path.dirname(dirnameOf(import.meta));
|
|
7
|
+
|
|
8
|
+
await run(async () => {
|
|
9
|
+
const program = await createProgram({ binDir: BIN_DIR });
|
|
10
|
+
await program.parseAsync();
|
|
11
|
+
}, logger);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createPkg, createShellService, type Pkg, type ShellService } from "@vlandoss/clibuddy";
|
|
3
|
+
import { logger } from "./logger.ts";
|
|
4
|
+
|
|
5
|
+
export type Context = {
|
|
6
|
+
binPkg: Pkg;
|
|
7
|
+
shell: ShellService;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export async function createContext(binDir: string): Promise<Context> {
|
|
11
|
+
const debug = logger.subdebug("create-context");
|
|
12
|
+
|
|
13
|
+
const binPath = fs.realpathSync(binDir);
|
|
14
|
+
|
|
15
|
+
debug("bin path:", binPath);
|
|
16
|
+
|
|
17
|
+
const binPkg = await createPkg(binPath);
|
|
18
|
+
|
|
19
|
+
if (!binPkg) {
|
|
20
|
+
throw new Error("Could not find bin package.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
debug("bin pkg info: %O", binPkg.info());
|
|
24
|
+
|
|
25
|
+
const shell = createShellService({
|
|
26
|
+
localBaseBinPath: [binDir],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
debug("shell service options: %O", shell.options);
|
|
30
|
+
|
|
31
|
+
return { binPkg, shell };
|
|
32
|
+
}
|
package/tsconfig.json
ADDED