@vlandoss/vland 0.2.1-git-74f39bb.0 → 0.2.1-git-87d22db.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🦉 vland
2
2
 
3
- The CLI to init a new project in [Variable Land](https://variable.land) 👊
3
+ The CLI to init a new project in [Variable Land](https://variable.land)
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -35,11 +35,11 @@ See [`CLI.md`](./CLI.md) for the full reference (auto-generated per release).
35
35
 
36
36
  | Template | What you get |
37
37
  | ---------- | ----------------------------------------------------------------------------------------- |
38
- | `library` | A standalone TypeScript library with tsdown, Vitest, biome, Changesets release workflow. |
38
+ | `library` | A standalone TypeScript library with Vitest + Changesets release workflow. |
39
39
  | `backend` | An Elysia (`@elysiajs/node`) backend with evlog, Vitest, Dockerfile, CI shape. |
40
40
  | `monorepo` | pnpm + Turbo workspace with an Elysia API, a Vite-React SPA, and a few internal packages. |
41
41
 
42
- All templates target Node.js, use pnpm, and extend `@vlandoss/config` for biome and tsconfig.
42
+ All templates target Node.js, use pnpm, and ship [`@rrlab/cli`](https://github.com/variableland/dx/tree/main/run-run) (`rr`) as the single entry point for lint, format, type-check, and build. The per-tool config files (`biome.json`, `tsconfig.json`, `tsdown.config.ts`) are not bundled — run `rr plugins add biome ts tsdown` (one at a time) in your new project to opt in. See the scaffolded README for the exact post-install setup.
43
43
 
44
44
  ## Shell completion
45
45
 
@@ -76,5 +76,5 @@ DEBUG=vland:* vland init my-app
76
76
  To point `init` at local templates instead of fetching from GitHub (useful when developing inside this monorepo):
77
77
 
78
78
  ```sh
79
- VLAND_TEMPLATES_DIR=/absolute/path/to/dx/templates vland init my-app -t library
79
+ VLAND_TEMPLATES_DIR=/absolute/path/to/dx/vland/templates vland init my-app -t library
80
80
  ```
@@ -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-87d22db.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 --visibility help="package visibility (library only)" {
21
+ arg <VISIBILITY> {
22
+ choices private public
23
+ }
24
+ }
25
+ flag "-d --dir" help="target directory (default: ./<name>)" {
26
+ arg <DIR>
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,474 @@
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 { generateToStdout } from "@usage-spec/commander";
4
+ import { Argument, Command, Option, createCommand } from "commander";
5
+ import fs from "node:fs";
6
+ import { createLoggy } from "@vlandoss/loggy";
7
+ import { cp, readFile, readdir, stat, writeFile } from "node:fs/promises";
8
+ import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text as text$1 } from "@clack/prompts";
9
+ import { installDependencies } from "nypm";
10
+ import { downloadTemplate } from "giget";
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/template.ts
68
+ const TEMPLATES = [
69
+ "library",
70
+ "backend",
71
+ "monorepo"
72
+ ];
73
+ const VISIBILITIES = ["private", "public"];
74
+ const TEMPLATE_META = {
75
+ library: {
76
+ placeholder: "my-lib",
77
+ runScript: "test"
78
+ },
79
+ backend: {
80
+ placeholder: "my-api",
81
+ runScript: "dev"
82
+ },
83
+ monorepo: {
84
+ placeholder: "my-mono",
85
+ runScript: "dev"
86
+ }
87
+ };
88
+ /**
89
+ * The npm scope a `library` template's package is published under, indexed by
90
+ * the user-selected visibility. Public libraries live under the open-source
91
+ * `@vlandoss` scope; private libraries under `@variableland`.
92
+ */
93
+ const LIBRARY_SCOPES = {
94
+ private: "@variableland",
95
+ public: "@vlandoss"
96
+ };
97
+ const GITHUB_SOURCE = "github:variableland/dx";
98
+ const GITHUB_REF = "main";
99
+ /**
100
+ * Resolves the template into `dir`. Source order:
101
+ * 1. `VLAND_TEMPLATES_DIR` env var → copy from local path (used by E2E tests
102
+ * against the in-repo `vland/templates/`).
103
+ * 2. Otherwise → download via giget from `github:variableland/dx/vland/templates/<name>`.
104
+ */
105
+ async function fetchTemplate(options) {
106
+ const debug = logger.subdebug("fetch-template");
107
+ const localRoot = process.env.VLAND_TEMPLATES_DIR;
108
+ if (localRoot) {
109
+ const sourceDir = resolve(localRoot, options.template);
110
+ debug("local source: %s", sourceDir);
111
+ await cp(sourceDir, options.dir, {
112
+ recursive: true,
113
+ force: options.force,
114
+ errorOnExist: !options.force,
115
+ filter: (src) => !src.includes("/node_modules") && !src.endsWith("/.turbo") && !src.endsWith("/dist")
116
+ });
117
+ return { source: sourceDir };
118
+ }
119
+ const source = `${GITHUB_SOURCE}/vland/templates/${options.template}#${GITHUB_REF}`;
120
+ debug("remote source: %s", source);
121
+ return { source: (await downloadTemplate(source, {
122
+ dir: options.dir,
123
+ force: options.force,
124
+ install: false
125
+ })).source };
126
+ }
127
+ //#endregion
128
+ //#region src/actions/placeholders.ts
129
+ const TEXT_EXTENSIONS = new Set([
130
+ ".ts",
131
+ ".tsx",
132
+ ".js",
133
+ ".jsx",
134
+ ".mjs",
135
+ ".cjs",
136
+ ".mts",
137
+ ".cts",
138
+ ".json",
139
+ ".jsonc",
140
+ ".md",
141
+ ".mdx",
142
+ ".yml",
143
+ ".yaml",
144
+ ".toml",
145
+ ".css",
146
+ ".html",
147
+ ".sh",
148
+ ".env",
149
+ ".gitignore",
150
+ ".gitattributes",
151
+ ".npmrc",
152
+ ".nvmrc",
153
+ ".node-version",
154
+ ".dockerignore",
155
+ ".editorconfig",
156
+ ".prettierrc"
157
+ ]);
158
+ const TEXT_FILENAMES = new Set([
159
+ "Dockerfile",
160
+ "LICENSE",
161
+ "README",
162
+ "CHANGELOG",
163
+ ".gitignore",
164
+ ".gitattributes",
165
+ ".npmrc",
166
+ ".nvmrc",
167
+ ".node-version",
168
+ ".dockerignore",
169
+ ".editorconfig",
170
+ ".prettierrc",
171
+ "lefthook.yml",
172
+ "mise.toml"
173
+ ]);
174
+ const SKIP_DIRS = new Set([
175
+ "node_modules",
176
+ ".git",
177
+ "dist",
178
+ ".turbo",
179
+ ".next",
180
+ "build",
181
+ "coverage"
182
+ ]);
183
+ function isTextFile(name) {
184
+ if (TEXT_FILENAMES.has(name)) return true;
185
+ return TEXT_EXTENSIONS.has(extname(name));
186
+ }
187
+ async function walk(root, onFile) {
188
+ const entries = await readdir(root, { withFileTypes: true });
189
+ await Promise.all(entries.map(async (entry) => {
190
+ const full = join(root, entry.name);
191
+ if (entry.isDirectory()) {
192
+ if (SKIP_DIRS.has(entry.name)) return;
193
+ await walk(full, onFile);
194
+ return;
195
+ }
196
+ if (entry.isFile()) await onFile(full);
197
+ }));
198
+ }
199
+ function applyPlaceholders(content, values) {
200
+ return content.replaceAll("{{projectName}}", values.projectName).replaceAll("{{author}}", values.author).replaceAll("{{year}}", values.year);
201
+ }
202
+ async function replacePlaceholders(rootDir, values) {
203
+ const debug = logger.subdebug("placeholders");
204
+ let touched = 0;
205
+ await walk(rootDir, async (filePath) => {
206
+ if (!isTextFile(filePath.split("/").pop() ?? "")) return;
207
+ if ((await stat(filePath)).size > 1e6) return;
208
+ const original = await readFile(filePath, "utf8");
209
+ const replaced = applyPlaceholders(original, values);
210
+ if (replaced !== original) {
211
+ await writeFile(filePath, replaced);
212
+ touched += 1;
213
+ }
214
+ });
215
+ debug("placeholders applied to %d file(s)", touched);
216
+ return { touched };
217
+ }
218
+ /**
219
+ * Writes the root `package.json`'s `name` and `private` fields based on the
220
+ * chosen template and visibility. Uses `pkg-types` so field ordering and
221
+ * JSON formatting survive.
222
+ *
223
+ * Naming rules:
224
+ * - `library` private → `@variableland/<projectName>`, `private: true`
225
+ * - `library` public → `@vlandoss/<projectName>`
226
+ * - `backend` → `<projectName>`, `private: true`
227
+ * - `monorepo` → `<projectName>`, `private: true` (root never publishes)
228
+ */
229
+ async function applyRootPackage(rootDir, options) {
230
+ const debug = logger.subdebug("apply-root-package");
231
+ const pkg = await createPkg(fs.realpathSync(rootDir));
232
+ if (!pkg) throw new Error("Could not find package.json");
233
+ const { template, projectName, visibility } = options;
234
+ try {
235
+ if (template === "library") {
236
+ const scope = LIBRARY_SCOPES[visibility ?? "public"];
237
+ pkg.packageJson.name = `${scope}/${projectName}`;
238
+ if (visibility === "private") pkg.packageJson.private = true;
239
+ } else {
240
+ pkg.packageJson.name = projectName;
241
+ pkg.packageJson.private = true;
242
+ }
243
+ await pkg.write(pkg.packageJson);
244
+ } catch (error) {
245
+ debug("skipped %s", error);
246
+ }
247
+ }
248
+ //#endregion
249
+ //#region src/actions/init.ts
250
+ const PACKAGE_MANAGER = "pnpm";
251
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
252
+ function validateProjectName(name) {
253
+ if (!name || !name.trim()) return "Name is required.";
254
+ if (/\s/.test(name)) return "Name cannot contain whitespace.";
255
+ if (name.startsWith(".") || name.startsWith("/") || name.startsWith("\\")) return "Name cannot start with '.', '/' or '\\'.";
256
+ if (name.includes("..")) return "Name cannot contain '..'.";
257
+ if (!NPM_NAME_RE.test(name)) return "Name must be a valid npm package name (lowercase, no spaces).";
258
+ }
259
+ async function isDirEmpty(dir) {
260
+ try {
261
+ return (await readdir(dir)).length === 0;
262
+ } catch (error) {
263
+ if (error.code === "ENOENT") return true;
264
+ throw error;
265
+ }
266
+ }
267
+ async function readGitAuthor(shell) {
268
+ try {
269
+ const [name, email] = await Promise.all([shell.runCaptured("git", [
270
+ "config",
271
+ "--get",
272
+ "user.name"
273
+ ], { throwOnError: false }), shell.runCaptured("git", [
274
+ "config",
275
+ "--get",
276
+ "user.email"
277
+ ], { throwOnError: false })]);
278
+ const trimmedName = name.stdout.trim();
279
+ const trimmedEmail = email.stdout.trim();
280
+ if (!trimmedName) return void 0;
281
+ return trimmedEmail ? `${trimmedName} <${trimmedEmail}>` : trimmedName;
282
+ } catch {
283
+ return;
284
+ }
285
+ }
286
+ function abort(message) {
287
+ cancel(message);
288
+ process.exit(1);
289
+ }
290
+ async function runInit(ctx, options) {
291
+ const debug = logger.subdebug("init");
292
+ debug("options: %O", options);
293
+ const shell = ctx.shell;
294
+ intro(`${palette.label(" vland init ")}`);
295
+ let template = options.template;
296
+ if (!template) {
297
+ if (!hasTTY) abort("Template is required in non-interactive environments. Use --template <library|backend|monorepo>.");
298
+ const choice = await select({
299
+ message: "Pick a template",
300
+ options: TEMPLATES.map((value) => ({
301
+ value,
302
+ label: value
303
+ }))
304
+ });
305
+ if (isCancel(choice)) abort("Cancelled.");
306
+ template = choice;
307
+ }
308
+ let name = options.name;
309
+ if (!name) {
310
+ if (!hasTTY) abort("Project name is required in non-interactive environments. Pass it as the first argument.");
311
+ const value = await text$1({
312
+ message: "Project name",
313
+ placeholder: TEMPLATE_META[template].placeholder,
314
+ validate: (input) => validateProjectName(input ?? "")
315
+ });
316
+ if (isCancel(value)) abort("Cancelled.");
317
+ name = value;
318
+ }
319
+ const nameError = validateProjectName(name);
320
+ if (nameError) abort(nameError);
321
+ let visibility = options.visibility;
322
+ if (template === "library" && !visibility) if (!hasTTY) visibility = "public";
323
+ else {
324
+ const choice = await select({
325
+ message: "Library visibility",
326
+ options: [{
327
+ value: "public",
328
+ label: "public (@vlandoss/<name>)"
329
+ }, {
330
+ value: "private",
331
+ label: "private (@variableland/<name>, private: true)"
332
+ }],
333
+ initialValue: "public"
334
+ });
335
+ if (isCancel(choice)) abort("Cancelled.");
336
+ visibility = choice;
337
+ }
338
+ const dir = options.dir ? isAbsolute(options.dir) ? options.dir : resolve(process.cwd(), options.dir) : resolve(process.cwd(), name);
339
+ debug("target dir: %s", dir);
340
+ if (!await isDirEmpty(dir) && !options.force) abort(`Target directory ${palette.highlight(dir)} is not empty. Re-run with ${palette.highlight("--force")} to overwrite.`);
341
+ let author = await readGitAuthor(shell);
342
+ if (!author) if (!hasTTY) author = "";
343
+ else {
344
+ const value = await text$1({
345
+ message: "Author (used in package.json / LICENSE)",
346
+ placeholder: "Jane Doe <jane@example.com>",
347
+ defaultValue: ""
348
+ });
349
+ if (isCancel(value)) abort("Cancelled.");
350
+ author = value || "";
351
+ }
352
+ debug("author: %s", author || "<empty>");
353
+ const fetchSpin = spinner();
354
+ fetchSpin.start(`Fetching ${palette.highlight(template)} template`);
355
+ try {
356
+ const { source } = await fetchTemplate({
357
+ template,
358
+ dir,
359
+ force: options.force
360
+ });
361
+ fetchSpin.stop(`Fetched template from ${palette.muted(source)}`);
362
+ } catch (error) {
363
+ fetchSpin.stop("Failed to fetch template", 1);
364
+ throw error;
365
+ }
366
+ const placeholderSpin = spinner();
367
+ placeholderSpin.start("Applying placeholders");
368
+ await replacePlaceholders(dir, {
369
+ projectName: name,
370
+ author,
371
+ year: (/* @__PURE__ */ new Date()).getFullYear().toString()
372
+ });
373
+ await applyRootPackage(dir, {
374
+ template,
375
+ projectName: name,
376
+ visibility
377
+ });
378
+ placeholderSpin.stop("Placeholders applied");
379
+ const shouldInstall = await resolveYesNo(options.install, "Install dependencies?");
380
+ const shouldGit = await resolveYesNo(options.git, "Initialise a git repository?");
381
+ if (shouldInstall) {
382
+ const installSpin = spinner();
383
+ installSpin.start(`Installing dependencies with ${palette.highlight(PACKAGE_MANAGER)}`);
384
+ try {
385
+ await installDependencies({
386
+ cwd: dir,
387
+ packageManager: {
388
+ name: PACKAGE_MANAGER,
389
+ command: PACKAGE_MANAGER
390
+ },
391
+ silent: true
392
+ });
393
+ installSpin.stop(`Installed with ${palette.highlight(PACKAGE_MANAGER)}`);
394
+ } catch (error) {
395
+ installSpin.stop("Failed to install dependencies", 1);
396
+ log.warn(`You can install manually later with \`cd <dir> && ${PACKAGE_MANAGER} install\`.`);
397
+ debug("install error: %O", error);
398
+ }
399
+ } else log.info(`Skipping ${palette.highlight("install")}.`);
400
+ if (shouldGit) {
401
+ const gitSpin = spinner();
402
+ gitSpin.start("Initialising git repository");
403
+ try {
404
+ const gitShell = shell.at(dir).child({ env: {
405
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "vland",
406
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "noreply@variable.land",
407
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "vland",
408
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "noreply@variable.land"
409
+ } });
410
+ await gitShell.runCaptured("git", ["init"]);
411
+ await gitShell.runCaptured("git", ["add", "-A"]);
412
+ await gitShell.runCaptured("git", [
413
+ "commit",
414
+ "-m",
415
+ "chore: initial commit from vland"
416
+ ]);
417
+ gitSpin.stop("Initialised git repository");
418
+ } catch (error) {
419
+ gitSpin.stop("Failed to initialise git", 1);
420
+ debug("git error: %O", error);
421
+ }
422
+ } else log.info(`Skipping ${palette.highlight("git init")}.`);
423
+ const runScript = TEMPLATE_META[template].runScript;
424
+ outro([
425
+ palette.success("Done!"),
426
+ "",
427
+ palette.muted("Next steps:"),
428
+ ` cd ${name}`,
429
+ shouldInstall ? ` ${PACKAGE_MANAGER} ${runScript}` : ` ${PACKAGE_MANAGER} install && ${PACKAGE_MANAGER} ${runScript}`
430
+ ].join("\n"));
431
+ }
432
+ async function resolveYesNo(explicit, message) {
433
+ if (typeof explicit === "boolean") return explicit;
434
+ if (!hasTTY) return true;
435
+ const value = await confirm({
436
+ message,
437
+ initialValue: true
438
+ });
439
+ if (isCancel(value)) abort("Cancelled.");
440
+ return value;
441
+ }
442
+ //#endregion
443
+ //#region src/program/commands/init.ts
444
+ function createInitCommand(ctx) {
445
+ 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("--visibility <visibility>", "package visibility (library only)").choices([...VISIBILITIES])).addOption(new Option("-d, --dir <path>", "target directory (default: ./<name>)")).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) {
446
+ console.log(getBannerText(ctx.binPkg.version));
447
+ const installSource = this.getOptionValueSource("install");
448
+ const gitSource = this.getOptionValueSource("git");
449
+ await runInit(ctx, {
450
+ name,
451
+ ...options,
452
+ install: installSource === "cli" ? options.install : void 0,
453
+ git: gitSource === "cli" ? options.git : void 0
454
+ });
455
+ });
456
+ }
457
+ //#endregion
458
+ //#region src/program/index.ts
459
+ async function createProgram(options) {
460
+ const ctx = await createContext(options.binDir);
461
+ const version = ctx.binPkg.version;
462
+ return new Command("vland").version(version, "-v, --version").addOption(new Option("--usage", `print KDL spec for this CLI (${palette.muted(palette.link("https://kdl.dev"))})`)).on("option:usage", function onUsage() {
463
+ generateToStdout(this);
464
+ process.exit(0);
465
+ }).addHelpText("before", getBannerText(version)).addCommand(createCompletionCommand()).addCommand(createInitCommand(ctx));
466
+ }
467
+ //#endregion
468
+ //#region src/run.ts
469
+ const BIN_DIR = path.dirname(dirnameOf(import.meta));
470
+ await run(async () => {
471
+ await (await createProgram({ binDir: BIN_DIR })).parseAsync();
472
+ }, logger);
473
+ //#endregion
474
+ export {};
package/package.json CHANGED
@@ -1,21 +1,18 @@
1
1
  {
2
2
  "name": "@vlandoss/vland",
3
- "version": "0.2.1-git-74f39bb.0",
3
+ "version": "0.2.1-git-87d22db.0",
4
4
  "description": "The CLI to init a new project in Variable Land",
5
- "homepage": "https://github.com/variableland/dx/tree/main/packages/vland#readme",
5
+ "homepage": "https://github.com/variableland/dx/tree/main/vland/cli#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/variableland/dx/issues"
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/variableland/dx.git",
12
- "directory": "packages/vland"
12
+ "directory": "vland/cli"
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-87d22db.0",
39
+ "@vlandoss/loggy": "0.2.1-git-87d22db.0"
43
40
  },
44
41
  "publishConfig": {
45
42
  "access": "public"
@@ -48,10 +45,8 @@
48
45
  "node": ">=20.0.0"
49
46
  },
50
47
  "devDependencies": {
51
- "@vlandoss/tsdown-config": "^0.0.1"
48
+ "@rrlab/tsdown-config": "^0.0.1-git-87d22db.0"
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
+ }
@@ -2,22 +2,24 @@ import { readdir } from "node:fs/promises";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
4
4
  import { hasTTY, palette } from "@vlandoss/clibuddy";
5
- import { detectPackageManager, installDependencies } from "nypm";
5
+ import { installDependencies } from "nypm";
6
6
  import type { Context } from "#src/services/ctx.ts";
7
7
  import { logger } from "#src/services/logger.ts";
8
- import { replacePlaceholders, updateRootPackageName } from "./placeholders.ts";
9
- import { fetchTemplate, TEMPLATE_META, TEMPLATES, type TemplateName } from "./template.ts";
8
+ import { applyRootPackage, replacePlaceholders } from "./placeholders.ts";
9
+ import { fetchTemplate, TEMPLATE_META, TEMPLATES, type TemplateName, type Visibility } from "./template.ts";
10
10
 
11
11
  export type InitOptions = {
12
12
  name?: string;
13
13
  template?: TemplateName;
14
+ visibility?: Visibility;
14
15
  dir?: string;
15
- pm?: "npm" | "pnpm" | "yarn" | "bun";
16
16
  install?: boolean;
17
17
  git?: boolean;
18
18
  force: boolean;
19
19
  };
20
20
 
21
+ const PACKAGE_MANAGER = "pnpm" as const;
22
+
21
23
  const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
22
24
 
23
25
  function validateProjectName(name: string): string | undefined {
@@ -71,7 +73,6 @@ export async function runInit(ctx: Context, options: InitOptions) {
71
73
 
72
74
  intro(`${palette.label(" vland init ")}`);
73
75
 
74
- // 1. Resolve template
75
76
  let template = options.template;
76
77
  if (!template) {
77
78
  if (!hasTTY) abort("Template is required in non-interactive environments. Use --template <library|backend|monorepo>.");
@@ -86,7 +87,6 @@ export async function runInit(ctx: Context, options: InitOptions) {
86
87
  template = choice as TemplateName;
87
88
  }
88
89
 
89
- // 2. Resolve project name
90
90
  let name = options.name;
91
91
  if (!name) {
92
92
  if (!hasTTY) abort("Project name is required in non-interactive environments. Pass it as the first argument.");
@@ -101,7 +101,29 @@ export async function runInit(ctx: Context, options: InitOptions) {
101
101
  const nameError = validateProjectName(name);
102
102
  if (nameError) abort(nameError);
103
103
 
104
- // 3. Resolve target dir
104
+ // Visibility only meaningfully changes the library template (scope +
105
+ // private flag). For backend/monorepo we ignore it — those are always
106
+ // private at root. Prompt for it interactively when building a library
107
+ // and the user didn't pass --visibility; non-interactive default is
108
+ // "public" (the OSS-style scope, @vlandoss).
109
+ let visibility = options.visibility;
110
+ if (template === "library" && !visibility) {
111
+ if (!hasTTY) {
112
+ visibility = "public";
113
+ } else {
114
+ const choice = await select<Visibility>({
115
+ message: "Library visibility",
116
+ options: [
117
+ { value: "public", label: "public (@vlandoss/<name>)" },
118
+ { value: "private", label: "private (@variableland/<name>, private: true)" },
119
+ ],
120
+ initialValue: "public",
121
+ });
122
+ if (isCancel(choice)) abort("Cancelled.");
123
+ visibility = choice as Visibility;
124
+ }
125
+ }
126
+
105
127
  const dir = options.dir
106
128
  ? isAbsolute(options.dir)
107
129
  ? options.dir
@@ -113,7 +135,6 @@ export async function runInit(ctx: Context, options: InitOptions) {
113
135
  abort(`Target directory ${palette.highlight(dir)} is not empty. Re-run with ${palette.highlight("--force")} to overwrite.`);
114
136
  }
115
137
 
116
- // 4. Resolve author
117
138
  let author = await readGitAuthor(shell);
118
139
  if (!author) {
119
140
  if (!hasTTY) {
@@ -130,7 +151,6 @@ export async function runInit(ctx: Context, options: InitOptions) {
130
151
  }
131
152
  debug("author: %s", author || "<empty>");
132
153
 
133
- // 5. Download template
134
154
  const fetchSpin = spinner();
135
155
  fetchSpin.start(`Fetching ${palette.highlight(template)} template`);
136
156
  try {
@@ -141,7 +161,6 @@ export async function runInit(ctx: Context, options: InitOptions) {
141
161
  throw error;
142
162
  }
143
163
 
144
- // 6. Replace placeholders
145
164
  const placeholderSpin = spinner();
146
165
  placeholderSpin.start("Applying placeholders");
147
166
  await replacePlaceholders(dir, {
@@ -149,31 +168,31 @@ export async function runInit(ctx: Context, options: InitOptions) {
149
168
  author,
150
169
  year: new Date().getFullYear().toString(),
151
170
  });
152
- await updateRootPackageName(dir, name);
171
+ await applyRootPackage(dir, { template, projectName: name, visibility });
153
172
  placeholderSpin.stop("Placeholders applied");
154
173
 
155
- // 7. Resolve install / git decisions (prompt with default-yes when not set on CLI)
156
174
  const shouldInstall = await resolveYesNo(options.install, "Install dependencies?");
157
175
  const shouldGit = await resolveYesNo(options.git, "Initialise a git repository?");
158
176
 
159
- // 8. Install deps
160
177
  if (shouldInstall) {
161
- const detected = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
162
178
  const installSpin = spinner();
163
- installSpin.start(`Installing dependencies with ${palette.highlight(detected)}`);
179
+ installSpin.start(`Installing dependencies with ${palette.highlight(PACKAGE_MANAGER)}`);
164
180
  try {
165
- await installDependencies({ cwd: dir, packageManager: { name: detected, command: detected }, silent: true });
166
- installSpin.stop(`Installed with ${palette.highlight(detected)}`);
181
+ await installDependencies({
182
+ cwd: dir,
183
+ packageManager: { name: PACKAGE_MANAGER, command: PACKAGE_MANAGER },
184
+ silent: true,
185
+ });
186
+ installSpin.stop(`Installed with ${palette.highlight(PACKAGE_MANAGER)}`);
167
187
  } catch (error) {
168
188
  installSpin.stop("Failed to install dependencies", 1);
169
- log.warn("You can install manually later with `cd <dir> && <pm> install`.");
189
+ log.warn(`You can install manually later with \`cd <dir> && ${PACKAGE_MANAGER} install\`.`);
170
190
  debug("install error: %O", error);
171
191
  }
172
192
  } else {
173
193
  log.info(`Skipping ${palette.highlight("install")}.`);
174
194
  }
175
195
 
176
- // 9. Git init
177
196
  if (shouldGit) {
178
197
  const gitSpin = spinner();
179
198
  gitSpin.start("Initialising git repository");
@@ -198,8 +217,6 @@ export async function runInit(ctx: Context, options: InitOptions) {
198
217
  log.info(`Skipping ${palette.highlight("git init")}.`);
199
218
  }
200
219
 
201
- // 10. Outro with next steps
202
- const detectedPm = options.pm ?? (await detectPackageManager(dir, { ignorePackageJSON: false }))?.name ?? "pnpm";
203
220
  const runScript = TEMPLATE_META[template].runScript;
204
221
  outro(
205
222
  [
@@ -207,7 +224,7 @@ export async function runInit(ctx: Context, options: InitOptions) {
207
224
  "",
208
225
  palette.muted("Next steps:"),
209
226
  ` cd ${name}`,
210
- shouldInstall ? ` ${detectedPm} ${runScript}` : ` ${detectedPm} install && ${detectedPm} ${runScript}`,
227
+ shouldInstall ? ` ${PACKAGE_MANAGER} ${runScript}` : ` ${PACKAGE_MANAGER} install && ${PACKAGE_MANAGER} ${runScript}`,
211
228
  ].join("\n"),
212
229
  );
213
230
  }
@@ -3,6 +3,7 @@ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { extname, join } from "node:path";
4
4
  import { createPkg } from "@vlandoss/clibuddy";
5
5
  import { logger } from "#src/services/logger.ts";
6
+ import { LIBRARY_SCOPES, type TemplateName, type Visibility } from "./template.ts";
6
7
 
7
8
  export type Placeholders = {
8
9
  projectName: string;
@@ -111,27 +112,44 @@ export async function replacePlaceholders(rootDir: string, values: Placeholders)
111
112
  return { touched };
112
113
  }
113
114
 
115
+ export type ApplyRootPackageOptions = {
116
+ template: TemplateName;
117
+ projectName: string;
118
+ visibility?: Visibility;
119
+ };
120
+
114
121
  /**
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`.
122
+ * Writes the root `package.json`'s `name` and `private` fields based on the
123
+ * chosen template and visibility. Uses `pkg-types` so field ordering and
124
+ * JSON formatting survive.
125
+ *
126
+ * Naming rules:
127
+ * - `library` private → `@variableland/<projectName>`, `private: true`
128
+ * - `library` public → `@vlandoss/<projectName>`
129
+ * - `backend` → `<projectName>`, `private: true`
130
+ * - `monorepo` → `<projectName>`, `private: true` (root never publishes)
118
131
  */
119
- export async function updateRootPackageName(rootDir: string, projectName: string) {
120
- const debug = logger.subdebug("update-root-package-name");
132
+ export async function applyRootPackage(rootDir: string, options: ApplyRootPackageOptions) {
133
+ const debug = logger.subdebug("apply-root-package");
121
134
 
122
135
  const rootPath = fs.realpathSync(rootDir);
123
-
124
- debug("root path:", rootPath);
125
- debug("process cwd:", process.cwd());
126
-
127
136
  const pkg = await createPkg(rootPath);
137
+ if (!pkg) throw new Error("Could not find package.json");
128
138
 
129
- if (!pkg) {
130
- throw new Error("Could not find package.json");
131
- }
139
+ const { template, projectName, visibility } = options;
132
140
 
133
141
  try {
134
- pkg.packageJson.name = projectName;
142
+ if (template === "library") {
143
+ const scope = LIBRARY_SCOPES[visibility ?? "public"];
144
+ pkg.packageJson.name = `${scope}/${projectName}`;
145
+ if (visibility === "private") {
146
+ pkg.packageJson.private = true;
147
+ }
148
+ } else {
149
+ // backend + monorepo: bare project name, private root.
150
+ pkg.packageJson.name = projectName;
151
+ pkg.packageJson.private = true;
152
+ }
135
153
  await pkg.write(pkg.packageJson);
136
154
  } catch (error) {
137
155
  debug("skipped %s", error);
@@ -6,12 +6,25 @@ import { logger } from "#src/services/logger.ts";
6
6
  export const TEMPLATES = ["library", "backend", "monorepo"] as const;
7
7
  export type TemplateName = (typeof TEMPLATES)[number];
8
8
 
9
+ export const VISIBILITIES = ["private", "public"] as const;
10
+ export type Visibility = (typeof VISIBILITIES)[number];
11
+
9
12
  export const TEMPLATE_META: Record<TemplateName, { placeholder: string; runScript: string }> = {
10
13
  library: { placeholder: "my-lib", runScript: "test" },
11
14
  backend: { placeholder: "my-api", runScript: "dev" },
12
15
  monorepo: { placeholder: "my-mono", runScript: "dev" },
13
16
  };
14
17
 
18
+ /**
19
+ * The npm scope a `library` template's package is published under, indexed by
20
+ * the user-selected visibility. Public libraries live under the open-source
21
+ * `@vlandoss` scope; private libraries under `@variableland`.
22
+ */
23
+ export const LIBRARY_SCOPES: Record<Visibility, string> = {
24
+ private: "@variableland",
25
+ public: "@vlandoss",
26
+ };
27
+
15
28
  const GITHUB_SOURCE = "github:variableland/dx";
16
29
  const GITHUB_REF = "main";
17
30
 
@@ -24,8 +37,8 @@ type ResolveOptions = {
24
37
  /**
25
38
  * Resolves the template into `dir`. Source order:
26
39
  * 1. `VLAND_TEMPLATES_DIR` env var → copy from local path (used by E2E tests
27
- * against the in-repo `templates/`).
28
- * 2. Otherwise → download via giget from `github:variableland/dx/templates/<name>`.
40
+ * against the in-repo `vland/templates/`).
41
+ * 2. Otherwise → download via giget from `github:variableland/dx/vland/templates/<name>`.
29
42
  */
30
43
  export async function fetchTemplate(options: ResolveOptions): Promise<{ source: string }> {
31
44
  const debug = logger.subdebug("fetch-template");
@@ -43,7 +56,7 @@ export async function fetchTemplate(options: ResolveOptions): Promise<{ source:
43
56
  return { source: sourceDir };
44
57
  }
45
58
 
46
- const source = `${GITHUB_SOURCE}/templates/${options.template}#${GITHUB_REF}`;
59
+ const source = `${GITHUB_SOURCE}/vland/templates/${options.template}#${GITHUB_REF}`;
47
60
  debug("remote source: %s", source);
48
61
  const result = await downloadTemplate(source, {
49
62
  dir: options.dir,
@@ -9,7 +9,7 @@ const SHELLS = ["bash", "zsh", "fish"] as const;
9
9
  // dispatcher, which intercepts `vland completion <shell>` before reaching Node.
10
10
  export function createCompletionCommand() {
11
11
  return createCommand("completion")
12
- .summary(`print shell completion script 🐚 (${TOOL_LABELS.USAGE})`)
12
+ .summary(`print shell completion script (${TOOL_LABELS.USAGE})`)
13
13
  .description(
14
14
  `Prints a shell completion script for vland. Add to your shell rc file:
15
15
 
@@ -1,13 +1,13 @@
1
1
  import { Argument, createCommand, Option } from "commander";
2
2
  import { runInit } from "#src/actions/init.ts";
3
- import { TEMPLATES, type TemplateName } from "#src/actions/template.ts";
3
+ import { TEMPLATES, type TemplateName, VISIBILITIES, type Visibility } from "#src/actions/template.ts";
4
4
  import type { Context } from "#src/services/ctx.ts";
5
5
  import { getBannerText, TOOL_LABELS } from "../ui.ts";
6
6
 
7
7
  type InitOptions = {
8
8
  dir?: string;
9
9
  template?: TemplateName;
10
- pm?: "npm" | "pnpm" | "yarn" | "bun";
10
+ visibility?: Visibility;
11
11
  install: boolean;
12
12
  git: boolean;
13
13
  force: boolean;
@@ -15,12 +15,12 @@ type InitOptions = {
15
15
 
16
16
  export function createInitCommand(ctx: Context) {
17
17
  return createCommand("init")
18
- .summary(`init a new project 🚀 (${TOOL_LABELS.GIGET})`)
18
+ .summary(`init a new project (${TOOL_LABELS.GIGET})`)
19
19
  .description("Scaffold a new variableland project from one of the official templates.")
20
20
  .addArgument(new Argument("[name]", "project name (also used as the target directory)"))
21
21
  .addOption(new Option("-t, --template <name>", "template to use").choices([...TEMPLATES]))
22
+ .addOption(new Option("--visibility <visibility>", "package visibility (library only)").choices([...VISIBILITIES]))
22
23
  .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
24
  .addOption(new Option("--install", "install dependencies (skip prompt)"))
25
25
  .addOption(new Option("--no-install", "skip dependency installation"))
26
26
  .addOption(new Option("--git", "initialise git repository (skip prompt)"))
@@ -1,8 +1,9 @@
1
- import { Command } from "commander";
1
+ import { generateToStdout } from "@usage-spec/commander";
2
+ import { palette } from "@vlandoss/clibuddy";
3
+ import { Command, Option } from "commander";
2
4
  import { createContext } from "#src/services/ctx.ts";
3
5
  import { createCompletionCommand } from "./commands/completion.ts";
4
6
  import { createInitCommand } from "./commands/init.ts";
5
- import { addUsage } from "./commands/usage.ts";
6
7
  import { getBannerText } from "./ui.ts";
7
8
 
8
9
  export type Options = {
@@ -13,11 +14,14 @@ export async function createProgram(options: Options) {
13
14
  const ctx = await createContext(options.binDir);
14
15
  const version = ctx.binPkg.version;
15
16
 
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
- );
17
+ return new Command("vland")
18
+ .version(version, "-v, --version")
19
+ .addOption(new Option("--usage", `print KDL spec for this CLI (${palette.muted(palette.link("https://kdl.dev"))})`))
20
+ .on("option:usage", function onUsage(this: Command) {
21
+ generateToStdout(this);
22
+ process.exit(0);
23
+ })
24
+ .addHelpText("before", getBannerText(version))
25
+ .addCommand(createCompletionCommand())
26
+ .addCommand(createInitCommand(ctx));
23
27
  }
package/tsconfig.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "extends": ["@vlandoss/config/ts/no-dom/app"]
2
+ "extends": ["@rrlab/ts-config/no-dom/app"]
3
3
  }
@@ -1,9 +0,0 @@
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
- }