Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vland-init-git-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@variablelab/vland": patch
---

`vland init` runs `git init` before installing dependencies, so the git hooks a dependency sets up on install (the templates' lefthook ones) land in the new repo. Before, the install ran first, found no repo and skipped them, so a generated project's `lefthook.yml` never ran until a manual `lefthook install`. The first commit skips those hooks, because it's the template as generated.
5 changes: 5 additions & 0 deletions .changeset/vland-init-git-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@variablelab/vland": patch
---

`vland init` makes the first commit as your own git identity, the `user.name` and `user.email` git resolves for the new repo. Before, it always committed as `vland <noreply@variable.land>`, so the commit wasn't yours and a signed one showed as "Unverified" on GitHub. It still falls back to `vland` and `noreply@variable.land` for whichever of the two git has no value for.
16 changes: 16 additions & 0 deletions .changeset/vland-templates-org-standard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@variablelab/vland": minor
---

Projects from `vland init` follow Variable Lab's current standard:

- mise pins Node.js 24 and pnpm 10.30.3, with a `mise.lock` that needs mise 2026.9.16 or later. `.node-version` is gone.
- `engines.node` is `>=22.18.0`, the first Node.js that strips types without a flag, which `rr` needs to load `run-run.config.mts`.
- CI sets up with `jdx/mise-action` and runs `rr check`. Every action is pinned by SHA, checkout keeps no credentials, and a new `workflow-audit.yml` runs zizmor. The monorepo gets a CI workflow, which tests and builds only what a PR affects.
- Changesets 3, with `commit: true` and `format: false`. The library's release workflow runs `changesets/action` v2 as vland-bot, so its repo needs the `VLAND_BOT_CLIENT_ID` variable and the `VLAND_BOT_PRIVATE_KEY` secret.
- The changelog links to `withvariablelab/<name>` for a public library and `variableland/<name>` otherwise, not `<name>/<name>`.
- pnpm waits a day before installing a new release, and runs lefthook's postinstall on every install, so the git hooks get installed.
- `@rrlab/cli` is `^1.3.4`. The `^0.1.0` the templates asked for doesn't exist, so `pnpm install` failed.
- The backend's `pnpm dev` runs `node --watch` and loads `.env`, without `tsx`.
- The library's published `exports` point at what `rr pack` builds (`index.mjs` and `index.d.mts`), and its README at the scoped package name.
- Every generated file passes both oxfmt and Biome (with `@rrlab/biome-config`), including `package.json` key order with `private` after `version`, so `rr check` starts green with either. The READMEs explain how to pair the oxc plugin with `ts`.
3 changes: 2 additions & 1 deletion .github/workflows/workflow-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ jobs:
persist-credentials: false

# pipx ships with the runner image: no third-party setup action.
# Repo root, not .github/: it also scans the workflows vland/templates/* generate.
- name: 🔒 Scan workflows
run: pipx run --spec zizmor==1.30.1 zizmor --format=github .github/
run: pipx run --spec zizmor==1.30.1 zizmor --format=github .
env:
# Enables zizmor's online audits (impostor commits, known-vulnerable actions).
GH_TOKEN: ${{ github.token }}
2 changes: 1 addition & 1 deletion vland/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ See [`CLI.md`](./CLI.md) for the full reference (auto-generated per release).
| `backend` | An Elysia (`@elysiajs/node`) backend with evlog, Vitest, Dockerfile, CI shape. |
| `monorepo` | pnpm + Turbo workspace with an Elysia API, a Vite-React SPA, and a few internal packages. |

All templates target Node.js, use pnpm, and ship [`@rrlab/cli`](https://github.com/withvariablelab/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.
All templates target Node.js, pin Node.js and pnpm with [mise](https://mise.jdx.dev) (`mise.toml` and `mise.lock`), run their GitHub Actions on SHA-pinned actions checked by [zizmor](https://docs.zizmor.sh), and ship [`@rrlab/cli`](https://github.com/withvariablelab/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.

## Shell completion

Expand Down
66 changes: 48 additions & 18 deletions vland/cli/src/actions/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { hasTTY, palette } from "@variablelab/clibuddy";
import { installDependencies } from "nypm";
import type { Context } from "#src/services/ctx.ts";
import { logger } from "#src/services/logger.ts";
import { applyRootPackage, replacePlaceholders } from "./placeholders.ts";
import { fetchTemplate, TEMPLATE_META, TEMPLATES, type TemplateName, type Visibility } from "./template.ts";
import { applyRootPackage, replacePlaceholders, rootPackageName } from "./placeholders.ts";
import { fetchTemplate, GITHUB_OWNERS, TEMPLATE_META, TEMPLATES, type TemplateName, type Visibility } from "./template.ts";

export type InitOptions = {
name?: string;
Expand Down Expand Up @@ -45,21 +45,42 @@ async function isDirEmpty(dir: string): Promise<boolean> {
}
}

async function readGitAuthor(shell: Context["shell"]): Promise<string | undefined> {
type GitIdentity = { name: string; email: string };

const FALLBACK_GIT_IDENTITY: GitIdentity = { name: "vland", email: "noreply@variable.land" };

/** The `user.name` and `user.email` git resolves from the shell's cwd. Each is empty when unset. */
async function readGitIdentity(shell: Context["shell"]): Promise<GitIdentity> {
try {
const [name, email] = await Promise.all([
shell.runCaptured("git", ["config", "--get", "user.name"], { throwOnError: false }),
shell.runCaptured("git", ["config", "--get", "user.email"], { throwOnError: false }),
]);
const trimmedName = name.stdout.trim();
const trimmedEmail = email.stdout.trim();
if (!trimmedName) return undefined;
return trimmedEmail ? `${trimmedName} <${trimmedEmail}>` : trimmedName;
return { name: name.stdout.trim(), email: email.stdout.trim() };
} catch {
return undefined;
return { name: "", email: "" };
}
}

async function readGitAuthor(shell: Context["shell"]): Promise<string | undefined> {
const { name, email } = await readGitIdentity(shell);
if (!name) return undefined;
return email ? `${name} <${email}>` : name;
}

/**
* `-c` flags that fill in only the half of the identity git can't resolve. A
* configured identity is left alone, so `commit.gpgsign` signs with the user's
* own email.
*/
async function gitIdentityFallbackArgs(shell: Context["shell"]): Promise<string[]> {
const { name, email } = await readGitIdentity(shell);
return [
...(name ? [] : ["-c", `user.name=${FALLBACK_GIT_IDENTITY.name}`]),
...(email ? [] : ["-c", `user.email=${FALLBACK_GIT_IDENTITY.email}`]),
];
}

function abort(message: string): never {
cancel(message);
process.exit(1);
Expand Down Expand Up @@ -167,13 +188,27 @@ export async function runInit(ctx: Context, options: InitOptions) {
projectName: name,
author,
year: new Date().getFullYear().toString(),
packageName: rootPackageName({ template, projectName: name, visibility }),
// Only a library picks its visibility: backend and monorepo roots are always private.
githubOwner: GITHUB_OWNERS[template === "library" ? (visibility ?? "public") : "private"],
});
await applyRootPackage(dir, { template, projectName: name, visibility });
placeholderSpin.stop("Placeholders applied");

const shouldInstall = await resolveYesNo(options.install, "Install dependencies?");
const shouldGit = await resolveYesNo(options.git, "Initialise a git repository?");

const gitShell = shell.at(dir);
let gitInitError: unknown;
if (shouldGit) {
// Before the install, so a postinstall that sets up git hooks (lefthook's) finds the repo.
try {
await gitShell.runCaptured("git", ["init"]);
} catch (error) {
gitInitError = error;
}
}

if (shouldInstall) {
const installSpin = spinner();
installSpin.start(`Installing dependencies with ${palette.highlight(PACKAGE_MANAGER)}`);
Expand All @@ -197,17 +232,12 @@ export async function runInit(ctx: Context, options: InitOptions) {
const gitSpin = spinner();
gitSpin.start("Initialising git repository");
try {
const gitShell = shell.at(dir).child({
env: {
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "vland",
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "noreply@variable.land",
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "vland",
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "noreply@variable.land",
},
});
await gitShell.runCaptured("git", ["init"]);
if (gitInitError) throw gitInitError;
await gitShell.runCaptured("git", ["add", "-A"]);
await gitShell.runCaptured("git", ["commit", "-m", "chore: initial commit from vland"]);
// Read after `git init`, so an `includeIf "gitdir:…"` identity for the new repo applies.
const identityArgs = await gitIdentityFallbackArgs(gitShell);
// --no-verify: the hooks the install just set up are for the user's commits, not the template as generated.
await gitShell.runCaptured("git", [...identityArgs, "commit", "--no-verify", "-m", "chore: initial commit from vland"]);
gitSpin.stop("Initialised git repository");
} catch (error) {
gitSpin.stop("Failed to initialise git", 1);
Expand Down
65 changes: 43 additions & 22 deletions vland/cli/src/actions/placeholders.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import fs from "node:fs";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { createPkg } from "@variablelab/clibuddy";
import { createPkg, type Pkg } from "@variablelab/clibuddy";
import { logger } from "#src/services/logger.ts";
import { LIBRARY_SCOPES, type TemplateName, type Visibility } from "./template.ts";

type PackageJson = Pkg["packageJson"];

export type Placeholders = {
projectName: string;
author: string;
year: string;
packageName: string;
githubOwner: string;
};

const TEXT_EXTENSIONS = new Set([
Expand Down Expand Up @@ -86,7 +90,9 @@ function applyPlaceholders(content: string, values: Placeholders) {
return content
.replaceAll("{{projectName}}", values.projectName)
.replaceAll("{{author}}", values.author)
.replaceAll("{{year}}", values.year);
.replaceAll("{{year}}", values.year)
.replaceAll("{{packageName}}", values.packageName)
.replaceAll("{{githubOwner}}", values.githubOwner);
}

export async function replacePlaceholders(rootDir: string, values: Placeholders) {
Expand Down Expand Up @@ -119,15 +125,39 @@ export type ApplyRootPackageOptions = {
};

/**
* Writes the root `package.json`'s `name` and `private` fields based on the
* chosen template and visibility. Uses `pkg-types` so field ordering and
* JSON formatting survive.
*
* Naming rules:
* - `library` private → `@variableland/<projectName>`, `private: true`
* The root package's name:
* - `library` private → `@variableland/<projectName>`
* - `library` public → `@variablelab/<projectName>`
* - `backend` → `<projectName>`, `private: true`
* - `monorepo` → `<projectName>`, `private: true` (root never publishes)
* - `backend`, `monorepo` → `<projectName>`
*/
export function rootPackageName({ template, projectName, visibility }: ApplyRootPackageOptions): string {
return template === "library" ? `${LIBRARY_SCOPES[visibility ?? "public"]}/${projectName}` : projectName;
}

/**
* Sets `private: true` right after `version`, where package.json sorters
* (oxfmt's among them) put it, so the file passes their format check as
* generated. Mutates in place: a copy would drop the formatting pkg-types
* remembers on the object.
*/
function setPrivate(packageJson: PackageJson): void {
const keys = Object.keys(packageJson);
const moved = keys
.slice(keys.indexOf("version") + 1)
.filter((key) => key !== "private")
.map((key) => [key, packageJson[key]] as const);
for (const [key] of moved) delete packageJson[key];
delete packageJson.private;
packageJson.private = true;
for (const [key, value] of moved) packageJson[key] = value;
}

/**
* Writes the root `package.json`'s `name` ({@link rootPackageName}) and
* `private` fields based on the chosen template and visibility. Uses
* `pkg-types` so field ordering and JSON formatting survive. `private: true`
* goes on a private library and on every backend and monorepo root, which
* never publishes.
*/
export async function applyRootPackage(rootDir: string, options: ApplyRootPackageOptions) {
const debug = logger.subdebug("apply-root-package");
Expand All @@ -136,20 +166,11 @@ export async function applyRootPackage(rootDir: string, options: ApplyRootPackag
const pkg = await createPkg(rootPath);
if (!pkg) throw new Error("Could not find package.json");

const { template, projectName, visibility } = options;
const { template, visibility } = options;

try {
if (template === "library") {
const scope = LIBRARY_SCOPES[visibility ?? "public"];
pkg.packageJson.name = `${scope}/${projectName}`;
if (visibility === "private") {
pkg.packageJson.private = true;
}
} else {
// backend + monorepo: bare project name, private root.
pkg.packageJson.name = projectName;
pkg.packageJson.private = true;
}
pkg.packageJson.name = rootPackageName(options);
if (template !== "library" || visibility === "private") setPrivate(pkg.packageJson);
await pkg.write(pkg.packageJson);
} catch (error) {
debug("skipped %s", error);
Expand Down
10 changes: 10 additions & 0 deletions vland/cli/src/actions/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export const LIBRARY_SCOPES: Record<Visibility, string> = {
public: "@variablelab",
};

/**
* The GitHub owner of a project's repository, indexed by visibility: public
* projects live in the open-source `withvariablelab` org, private ones in
* `variableland`.
*/
export const GITHUB_OWNERS: Record<Visibility, string> = {
private: "variableland",
public: "withvariablelab",
};

const GITHUB_SOURCE = "github:withvariablelab/dx";
const GITHUB_REF = "main";

Expand Down
32 changes: 30 additions & 2 deletions vland/cli/test/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

Expand All @@ -13,6 +13,7 @@ type CliMode = "dev" | "prod";

type CliOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
};

export function createTestCli(mode: CliMode = "prod") {
Expand All @@ -22,13 +23,14 @@ export function createTestCli(mode: CliMode = "prod") {
cwd: opts.cwd,
env:
mode === "dev"
? { ...process.env, VLAND_TEMPLATES_DIR: TEMPLATES_DIR }
? { ...process.env, VLAND_TEMPLATES_DIR: TEMPLATES_DIR, ...opts.env }
: {
...process.env,
NODE_ENV: "production",
TEST: undefined,
NO_COLOR: "1",
VLAND_TEMPLATES_DIR: TEMPLATES_DIR,
...opts.env,
},
});
};
Expand All @@ -47,3 +49,29 @@ export function makeTmpDir(name: string): { dir: string; cleanup: () => void } {
export function gitOutput(cwd: string, args: string[]): string {
return spawnSync("git", args, { cwd, encoding: "utf8" }).stdout.trim();
}

const WORKSPACE_BIN = path.join(REPO_ROOT, "node_modules", ".bin");

/** Runs the workspace's `oxfmt --check` over the project in `cwd`. */
export function oxfmtCheck(cwd: string) {
return spawnSync(path.join(WORKSPACE_BIN, "oxfmt"), ["--check"], { cwd, encoding: "utf8" });
}

/**
* Runs the workspace's `biome check` over the project in `cwd`, set up the way
* `rr plugins add biome` sets it up: a biome.json that extends
* `@rrlab/biome-config`, resolved through a symlink to the workspace's
* node_modules.
*/
export function biomeCheck(cwd: string) {
writeFileSync(
path.join(cwd, "biome.json"),
`{\n "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",\n "extends": ["@rrlab/biome-config"]\n}\n`,
);
symlinkSync(path.join(REPO_ROOT, "node_modules"), path.join(cwd, "node_modules"), "dir");
return spawnSync(path.join(WORKSPACE_BIN, "biome"), ["check"], {
cwd,
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
});
}
Loading
Loading