@vlandoss/vland 0.2.1-git-74f39bb.0 → 0.2.1-git-a1181c2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ // @generated by @usage-spec/commander from Commander.js metadata
2
+ name vland
3
+ bin vland
4
+ version "0.2.1-git-a1181c2.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 🐚 (usage)" {
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 🚀 (giget)" {
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 --install help="install dependencies (skip prompt)"
29
+ flag --no-install help="skip dependency installation" negate=--install
30
+ flag --git help="initialise git repository (skip prompt)"
31
+ flag --no-git help="skip git init" negate=--git
32
+ flag "-f --force" help="overwrite existing directory"
33
+ arg "[name]" help="project name (also used as the target directory)" required=#false
34
+ }
package/dist/run.mjs ADDED
@@ -0,0 +1,443 @@
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, confirm, 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();
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 TEMPLATE_META = {
184
+ library: {
185
+ placeholder: "my-lib",
186
+ runScript: "test"
187
+ },
188
+ backend: {
189
+ placeholder: "my-api",
190
+ runScript: "dev"
191
+ },
192
+ monorepo: {
193
+ placeholder: "my-mono",
194
+ runScript: "dev"
195
+ }
196
+ };
197
+ const GITHUB_SOURCE = "github:variableland/dx";
198
+ const GITHUB_REF = "main";
199
+ /**
200
+ * Resolves the template into `dir`. Source order:
201
+ * 1. `VLAND_TEMPLATES_DIR` env var → copy from local path (used by E2E tests
202
+ * against the in-repo `templates/`).
203
+ * 2. Otherwise → download via giget from `github:variableland/dx/templates/<name>`.
204
+ */
205
+ async function fetchTemplate(options) {
206
+ const debug = logger.subdebug("fetch-template");
207
+ const localRoot = process.env.VLAND_TEMPLATES_DIR;
208
+ if (localRoot) {
209
+ const sourceDir = resolve(localRoot, options.template);
210
+ debug("local source: %s", sourceDir);
211
+ await cp(sourceDir, options.dir, {
212
+ recursive: true,
213
+ force: options.force,
214
+ errorOnExist: !options.force,
215
+ filter: (src) => !src.includes("/node_modules") && !src.endsWith("/.turbo") && !src.endsWith("/dist")
216
+ });
217
+ return { source: sourceDir };
218
+ }
219
+ const source = `${GITHUB_SOURCE}/templates/${options.template}#${GITHUB_REF}`;
220
+ debug("remote source: %s", source);
221
+ return { source: (await downloadTemplate(source, {
222
+ dir: options.dir,
223
+ force: options.force,
224
+ install: false
225
+ })).source };
226
+ }
227
+ //#endregion
228
+ //#region src/actions/init.ts
229
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
230
+ function validateProjectName(name) {
231
+ if (!name || !name.trim()) return "Name is required.";
232
+ if (/\s/.test(name)) return "Name cannot contain whitespace.";
233
+ if (name.startsWith(".") || name.startsWith("/") || name.startsWith("\\")) return "Name cannot start with '.', '/' or '\\'.";
234
+ if (name.includes("..")) return "Name cannot contain '..'.";
235
+ if (!NPM_NAME_RE.test(name)) return "Name must be a valid npm package name (lowercase, no spaces).";
236
+ }
237
+ async function isDirEmpty(dir) {
238
+ try {
239
+ return (await readdir(dir)).length === 0;
240
+ } catch (error) {
241
+ if (error.code === "ENOENT") return true;
242
+ throw error;
243
+ }
244
+ }
245
+ async function readGitAuthor(shell) {
246
+ try {
247
+ const [name, email] = await Promise.all([shell.runCaptured("git", [
248
+ "config",
249
+ "--get",
250
+ "user.name"
251
+ ], { throwOnError: false }), shell.runCaptured("git", [
252
+ "config",
253
+ "--get",
254
+ "user.email"
255
+ ], { throwOnError: false })]);
256
+ const trimmedName = name.stdout.trim();
257
+ const trimmedEmail = email.stdout.trim();
258
+ if (!trimmedName) return void 0;
259
+ return trimmedEmail ? `${trimmedName} <${trimmedEmail}>` : trimmedName;
260
+ } catch {
261
+ return;
262
+ }
263
+ }
264
+ function abort(message) {
265
+ cancel(message);
266
+ process.exit(1);
267
+ }
268
+ async function runInit(ctx, options) {
269
+ const debug = logger.subdebug("init");
270
+ debug("options: %O", options);
271
+ const shell = ctx.shell;
272
+ intro(`${palette.label(" vland init ")}`);
273
+ let template = options.template;
274
+ if (!template) {
275
+ if (!hasTTY) abort("Template is required in non-interactive environments. Use --template <library|backend|monorepo>.");
276
+ const choice = await select({
277
+ message: "Pick a template",
278
+ options: TEMPLATES.map((value) => ({
279
+ value,
280
+ label: value
281
+ }))
282
+ });
283
+ if (isCancel(choice)) abort("Cancelled.");
284
+ template = choice;
285
+ }
286
+ let name = options.name;
287
+ if (!name) {
288
+ if (!hasTTY) abort("Project name is required in non-interactive environments. Pass it as the first argument.");
289
+ const value = await text$1({
290
+ message: "Project name",
291
+ placeholder: TEMPLATE_META[template].placeholder,
292
+ validate: (input) => validateProjectName(input ?? "")
293
+ });
294
+ if (isCancel(value)) abort("Cancelled.");
295
+ name = value;
296
+ }
297
+ const nameError = validateProjectName(name);
298
+ if (nameError) abort(nameError);
299
+ const dir = options.dir ? isAbsolute(options.dir) ? options.dir : resolve(process.cwd(), options.dir) : resolve(process.cwd(), name);
300
+ debug("target dir: %s", dir);
301
+ if (!await isDirEmpty(dir) && !options.force) abort(`Target directory ${palette.highlight(dir)} is not empty. Re-run with ${palette.highlight("--force")} to overwrite.`);
302
+ let author = await readGitAuthor(shell);
303
+ if (!author) if (!hasTTY) author = "";
304
+ else {
305
+ const value = await text$1({
306
+ message: "Author (used in package.json / LICENSE)",
307
+ placeholder: "Jane Doe <jane@example.com>",
308
+ defaultValue: ""
309
+ });
310
+ if (isCancel(value)) abort("Cancelled.");
311
+ author = value || "";
312
+ }
313
+ debug("author: %s", author || "<empty>");
314
+ const fetchSpin = spinner();
315
+ fetchSpin.start(`Fetching ${palette.highlight(template)} template`);
316
+ try {
317
+ const { source } = await fetchTemplate({
318
+ template,
319
+ dir,
320
+ force: options.force
321
+ });
322
+ fetchSpin.stop(`Fetched template from ${palette.muted(source)}`);
323
+ } catch (error) {
324
+ fetchSpin.stop("Failed to fetch template", 1);
325
+ throw error;
326
+ }
327
+ const placeholderSpin = spinner();
328
+ placeholderSpin.start("Applying placeholders");
329
+ await replacePlaceholders(dir, {
330
+ projectName: name,
331
+ author,
332
+ year: (/* @__PURE__ */ new Date()).getFullYear().toString()
333
+ });
334
+ await updateRootPackageName(dir, name);
335
+ placeholderSpin.stop("Placeholders applied");
336
+ const shouldInstall = await resolveYesNo(options.install, "Install dependencies?");
337
+ const shouldGit = await resolveYesNo(options.git, "Initialise a git repository?");
338
+ if (shouldInstall) {
339
+ const detected = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
340
+ const installSpin = spinner();
341
+ installSpin.start(`Installing dependencies with ${palette.highlight(detected)}`);
342
+ try {
343
+ await installDependencies({
344
+ cwd: dir,
345
+ packageManager: {
346
+ name: detected,
347
+ command: detected
348
+ },
349
+ silent: true
350
+ });
351
+ installSpin.stop(`Installed with ${palette.highlight(detected)}`);
352
+ } catch (error) {
353
+ installSpin.stop("Failed to install dependencies", 1);
354
+ log.warn("You can install manually later with `cd <dir> && <pm> install`.");
355
+ debug("install error: %O", error);
356
+ }
357
+ } else log.info(`Skipping ${palette.highlight("install")}.`);
358
+ if (shouldGit) {
359
+ const gitSpin = spinner();
360
+ gitSpin.start("Initialising git repository");
361
+ try {
362
+ const gitShell = shell.at(dir).child({ env: {
363
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "vland",
364
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "noreply@variable.land",
365
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "vland",
366
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "noreply@variable.land"
367
+ } });
368
+ await gitShell.runCaptured("git", ["init"]);
369
+ await gitShell.runCaptured("git", ["add", "-A"]);
370
+ await gitShell.runCaptured("git", [
371
+ "commit",
372
+ "-m",
373
+ "chore: initial commit from vland"
374
+ ]);
375
+ gitSpin.stop("Initialised git repository");
376
+ } catch (error) {
377
+ gitSpin.stop("Failed to initialise git", 1);
378
+ debug("git error: %O", error);
379
+ }
380
+ } else log.info(`Skipping ${palette.highlight("git init")}.`);
381
+ const detectedPm = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
382
+ const runScript = TEMPLATE_META[template].runScript;
383
+ outro([
384
+ palette.success("Done!"),
385
+ "",
386
+ palette.muted("Next steps:"),
387
+ ` cd ${name}`,
388
+ shouldInstall ? ` ${detectedPm} ${runScript}` : ` ${detectedPm} install && ${detectedPm} ${runScript}`
389
+ ].join("\n"));
390
+ }
391
+ async function resolveYesNo(explicit, message) {
392
+ if (typeof explicit === "boolean") return explicit;
393
+ if (!hasTTY) return true;
394
+ const value = await confirm({
395
+ message,
396
+ initialValue: true
397
+ });
398
+ if (isCancel(value)) abort("Cancelled.");
399
+ return value;
400
+ }
401
+ //#endregion
402
+ //#region src/program/commands/init.ts
403
+ function createInitCommand(ctx) {
404
+ 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([
405
+ "npm",
406
+ "pnpm",
407
+ "yarn",
408
+ "bun"
409
+ ])).addOption(new Option("--install", "install dependencies (skip prompt)")).addOption(new Option("--no-install", "skip dependency installation")).addOption(new Option("--git", "initialise git repository (skip prompt)")).addOption(new Option("--no-git", "skip git init")).addOption(new Option("-f, --force", "overwrite existing directory").default(false)).action(async function(name, options) {
410
+ console.log(getBannerText(ctx.binPkg.version));
411
+ const installSource = this.getOptionValueSource("install");
412
+ const gitSource = this.getOptionValueSource("git");
413
+ await runInit(ctx, {
414
+ name,
415
+ ...options,
416
+ install: installSource === "cli" ? options.install : void 0,
417
+ git: gitSource === "cli" ? options.git : void 0
418
+ });
419
+ });
420
+ }
421
+ //#endregion
422
+ //#region src/program/commands/usage.ts
423
+ function addUsage(program) {
424
+ return program.addOption(new Option("--usage", "print KDL spec for this CLI (https://kdl.dev)")).on("option:usage", () => {
425
+ generateToStdout(program);
426
+ process.exit(0);
427
+ });
428
+ }
429
+ //#endregion
430
+ //#region src/program/index.ts
431
+ async function createProgram(options) {
432
+ const ctx = await createContext(options.binDir);
433
+ const version = ctx.binPkg.version;
434
+ return addUsage(new Command("vland").version(version, "-v, --version").addHelpText("before", getBannerText(version)).addCommand(createCompletionCommand()).addCommand(createInitCommand(ctx)));
435
+ }
436
+ //#endregion
437
+ //#region src/run.ts
438
+ const BIN_DIR = path.dirname(dirnameOf(import.meta));
439
+ await run(async () => {
440
+ await (await createProgram({ binDir: BIN_DIR })).parseAsync();
441
+ }, logger);
442
+ //#endregion
443
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vlandoss/vland",
3
- "version": "0.2.1-git-74f39bb.0",
3
+ "version": "0.2.1-git-a1181c2.0",
4
4
  "description": "The CLI to init a new project in Variable Land",
5
5
  "homepage": "https://github.com/variableland/dx/tree/main/packages/vland#readme",
6
6
  "bugs": {
@@ -12,10 +12,7 @@
12
12
  "directory": "packages/vland"
13
13
  },
14
14
  "license": "MIT",
15
- "author": {
16
- "name": "rcrd",
17
- "email": "rcrd@variable.land"
18
- },
15
+ "author": "rcrd <rcrd@variable.land>",
19
16
  "type": "module",
20
17
  "imports": {
21
18
  "#src/*": "./src/*",
@@ -35,11 +32,11 @@
35
32
  "dependencies": {
36
33
  "@clack/prompts": "0.11.0",
37
34
  "@usage-spec/commander": "1.1.0",
38
- "@vlandoss/clibuddy": "0.6.1-git-74f39bb.0",
39
- "@vlandoss/loggy": "0.2.1-git-74f39bb.0",
40
35
  "commander": "14.0.3",
41
36
  "giget": "2.0.0",
42
- "nypm": "0.6.0"
37
+ "nypm": "0.6.0",
38
+ "@vlandoss/clibuddy": "0.6.1-git-a1181c2.0",
39
+ "@vlandoss/loggy": "0.2.1-git-a1181c2.0"
43
40
  },
44
41
  "publishConfig": {
45
42
  "access": "public"
@@ -50,8 +47,6 @@
50
47
  "devDependencies": {
51
48
  "@vlandoss/tsdown-config": "^0.0.1"
52
49
  },
53
- "readme": "ERROR: No README data found!",
54
- "_id": "@vlandoss/vland@0.2.0",
55
50
  "scripts": {
56
51
  "build": "tsdown && pnpm build:kdl",
57
52
  "build:kdl": "./bin --usage > dist/cli.usage.kdl",
@@ -60,4 +55,4 @@
60
55
  "test:integration": "vitest run --project integration",
61
56
  "test:types": "rr tsc"
62
57
  }
63
- }
58
+ }