diff --git a/packages/utils/README.md b/packages/utils/README.md new file mode 100644 index 0000000..ed3c022 --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,13 @@ +# `@tempblot/utils` + +Shared utilities for Tempblot tooling. + +```ts +import { cloneTemplate } from "@tempblot/utils"; + +const { path } = await cloneTemplate({ url: GIT_URL }); +``` + +`cloneTemplate` requires Git 2.37 or newer. It clones the template into a +temporary sparse checkout and removes that directory when the Node process +exits. diff --git a/packages/utils/eslint.config.mjs b/packages/utils/eslint.config.mjs new file mode 100644 index 0000000..01d1bbe --- /dev/null +++ b/packages/utils/eslint.config.mjs @@ -0,0 +1,17 @@ +import tempblotPreset from "@tempblot/config/eslint-preset.js"; + +export default [ + ...tempblotPreset, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["eslint.config.mjs"], + }, +]; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..015e205 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,56 @@ +{ + "name": "@tempblot/utils", + "version": "0.2.0", + "type": "module", + "description": "Shared utilities for Tempblot tooling", + "author": "Corbin Crutchley ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/crutchcorn/tempblot.git", + "directory": "packages/utils" + }, + "homepage": "https://github.com/crutchcorn/tempblot", + "bugs": { + "url": "https://github.com/crutchcorn/tempblot/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + }, + "keywords": [ + "git", + "tempblot", + "template", + "utilities" + ], + "scripts": { + "build": "vite build", + "test:lib": "vitest", + "test:build": "publint && attw --profile esm-only --pack .", + "test:types": "tsc --noEmit", + "test:eslint": "eslint ." + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@tempblot/config": "workspace:*", + "@types/node": "^24.5.2", + "publint": "^0.3.21", + "unplugin-dts": "^1.0.0-beta.6", + "vite": "^8.0.12", + "vitest": "^4.1.6" + }, + "engines": { + "node": ">=22.18.0" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000..d4cb3c4 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,161 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as fsPromises from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +const minimumGitVersion = [2, 37, 0] as const; +const cleanupPaths = new Set(); +let cleanupRegistered = false; + +export interface CloneTemplateOptions { + url: string | URL; +} + +export interface CloneTemplateResult { + path: string; +} + +export async function cloneTemplate( + options: CloneTemplateOptions, +): Promise { + await ensureSupportedGitVersion(); + + const clonePath = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "tempblot-template-"), + ); + + try { + await runGit([ + "clone", + "--filter=blob:none", + "--sparse", + "--", + options.url.toString(), + clonePath, + ]); + + const directories = await getTopLevelDirectories(clonePath); + await runGit( + [ + "sparse-checkout", + "set", + "--", + ...(directories.length > 0 ? directories : ["."]), + ], + clonePath, + ); + } catch (error) { + await fsPromises.rm(clonePath, { recursive: true, force: true }); + throw error; + } + + registerCleanup(clonePath); + return { path: clonePath }; +} + +async function ensureSupportedGitVersion(): Promise { + const output = await runGit(["--version"]); + const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(output); + + if (match === null) { + throw new Error( + `Unable to determine the installed Git version from: ${output}`, + ); + } + + const version = [ + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + Number.parseInt(match[3] ?? "0", 10), + ] as const; + + if (compareVersions(version, minimumGitVersion) < 0) { + throw new Error( + `Git 2.37 or newer is required; found ${version.join(".")}.`, + ); + } +} + +function compareVersions( + left: readonly number[], + right: readonly number[], +): number { + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + + if (difference !== 0) { + return difference; + } + } + + return 0; +} + +async function getTopLevelDirectories( + repositoryPath: string, +): Promise { + const output = await runGit( + ["ls-tree", "-z", "--name-only", "-d", "HEAD"], + repositoryPath, + ); + + return output.split("\0").filter((entry) => entry.length > 0); +} + +function runGit(arguments_: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + const commandName = `git ${arguments_[0] ?? ""}`.trimEnd(); + const child = spawn("git", arguments_, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", (error) => { + reject(new Error(`Failed to run ${commandName}: ${error.message}`)); + }); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(stdout.trim()); + return; + } + + const reason = signal === null ? `exit code ${code}` : `signal ${signal}`; + const details = stderr.trim(); + reject( + new Error( + `${commandName} failed with ${reason}${details.length > 0 ? `: ${details}` : ""}`, + ), + ); + }); + }); +} + +function registerCleanup(clonePath: string): void { + cleanupPaths.add(clonePath); + + if (cleanupRegistered) { + return; + } + + cleanupRegistered = true; + process.once("exit", () => { + for (const cleanupPath of cleanupPaths) { + try { + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + // The process is exiting, so cleanup is best-effort. + } + } + }); +} diff --git a/packages/utils/tests/index.spec.ts b/packages/utils/tests/index.spec.ts new file mode 100644 index 0000000..16169df --- /dev/null +++ b/packages/utils/tests/index.spec.ts @@ -0,0 +1,177 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { afterEach, expect, test } from "vitest"; + +import { cloneTemplate } from "../src/index.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const sourceUrl = pathToFileURL(path.join(__dirname, "../src/index.ts")).href; +const testRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + testRoots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +test("clones all template files with sparse checkout", async () => { + const repositoryPath = await createRepository(); + + const result = await cloneTemplate({ url: repositoryPath }); + testRoots.push(result.path); + + await expect( + fs.readFile(path.join(result.path, "root.txt"), "utf8"), + ).resolves.toBe("root"); + await expect( + fs.readFile( + path.join(result.path, "nested", "deep", "template.txt"), + "utf8", + ), + ).resolves.toBe("nested"); + await expect( + fs.readFile( + path.join(result.path, ".git", "info", "sparse-checkout"), + "utf8", + ), + ).resolves.toContain("nested"); +}); + +test("rejects Git versions older than 2.37", async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "tempblot-utils-version-"), + ); + const binPath = path.join(root, "bin"); + const gitPath = path.join(binPath, "git"); + testRoots.push(root); + + await fs.mkdir(binPath); + await fs.writeFile(gitPath, "#!/bin/sh\necho 'git version 2.36.9'\n"); + await fs.chmod(gitPath, 0o755); + + const result = await runNode( + ` + import { cloneTemplate } from ${JSON.stringify(sourceUrl)}; + try { + await cloneTemplate({ url: "unused" }); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } + `, + { PATH: binPath }, + ); + + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "Git 2.37 or newer is required; found 2.36.9.", + ); +}); + +test("removes the clone when the process exits", async () => { + const repositoryPath = await createRepository(); + const result = await runNode( + ` + import { cloneTemplate } from ${JSON.stringify(sourceUrl)}; + const result = await cloneTemplate({ url: process.argv[1] }); + await import("node:fs/promises").then(({ access }) => access(result.path)); + console.log(result.path); + `, + undefined, + repositoryPath, + ); + const clonePath = result.stdout.trim(); + + expect(result.code).toBe(0); + expect(clonePath).not.toBe(""); + await expect(fs.access(clonePath)).rejects.toThrow(); +}); + +async function createRepository(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tempblot-utils-repo-")); + const repositoryPath = path.join(root, "repository"); + testRoots.push(root); + + await fs.mkdir(path.join(repositoryPath, "nested", "deep"), { + recursive: true, + }); + await fs.writeFile(path.join(repositoryPath, "root.txt"), "root"); + await fs.writeFile( + path.join(repositoryPath, "nested", "deep", "template.txt"), + "nested", + ); + await runGit(["init", "--initial-branch=main", repositoryPath]); + await runGit(["-C", repositoryPath, "add", "."]); + await runGit([ + "-C", + repositoryPath, + "-c", + "user.name=Tempblot Tests", + "-c", + "user.email=tests@tempblot.dev", + "commit", + "--message=Initial commit", + ]); + + return repositoryPath; +} + +async function runGit(arguments_: string[]): Promise { + const result = await runProcess("git", arguments_); + + if (result.code !== 0) { + throw new Error(result.stderr); + } +} + +function runNode( + source: string, + environment?: NodeJS.ProcessEnv, + ...arguments_: string[] +): Promise { + return runProcess( + process.execPath, + ["--input-type=module", "--eval", source, ...arguments_], + environment === undefined ? undefined : { ...process.env, ...environment }, + ); +} + +interface ProcessResult { + code: number | null; + stdout: string; + stderr: string; +} + +function runProcess( + command: string, + arguments_: string[], + environment?: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { + env: environment, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} diff --git a/packages/utils/tsconfig.app.json b/packages/utils/tsconfig.app.json new file mode 100644 index 0000000..f8e4107 --- /dev/null +++ b/packages/utils/tsconfig.app.json @@ -0,0 +1,4 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src"] +} diff --git a/packages/utils/tsconfig.config.json b/packages/utils/tsconfig.config.json new file mode 100644 index 0000000..0bfb873 --- /dev/null +++ b/packages/utils/tsconfig.config.json @@ -0,0 +1,8 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000..6e2441f --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.config.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/utils/tsconfig.spec.json b/packages/utils/tsconfig.spec.json new file mode 100644 index 0000000..c6b7a16 --- /dev/null +++ b/packages/utils/tsconfig.spec.json @@ -0,0 +1,4 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src", "tests"] +} diff --git a/packages/utils/vite.config.ts b/packages/utils/vite.config.ts new file mode 100644 index 0000000..e2043e5 --- /dev/null +++ b/packages/utils/vite.config.ts @@ -0,0 +1,32 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import dts from "unplugin-dts/vite"; +import packageJson from "./package.json" with { type: "json" }; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig(() => ({ + plugins: [ + dts({ + tsconfigPath: "tsconfig.app.json", + entryRoot: "src", + }), + ], + build: { + lib: { + name: "TempblotUtils", + fileName: "index", + entry: resolve(__dirname, "src/index.ts"), + formats: ["es"], + }, + rollupOptions: { + external: [/^node:/], + }, + }, + test: { + name: packageJson.name, + dir: "./tests", + watch: false, + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fffd6e9..6431539 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,30 @@ importers: specifier: ^4.1.6 version: 4.1.6(@types/node@24.12.4)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + packages/utils: + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.2 + version: 0.18.2 + '@tempblot/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^24.5.2 + version: 24.12.4 + publint: + specifier: ^0.3.21 + version: 0.3.21 + unplugin-dts: + specifier: ^1.0.0-beta.6 + version: 1.0.0(esbuild@0.28.0)(rolldown@1.0.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.12 + version: 8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@24.12.4)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + packages/language-server: dependencies: '@tempblot/language-service':