From 8b69fd5328176960a2842a35cb4dfc9d0aaab029 Mon Sep 17 00:00:00 2001 From: localhost41 Date: Thu, 9 Jul 2026 16:54:13 -0700 Subject: [PATCH] Add create-qvac-app release hardening --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 5 +++ LICENSE | 21 ++++++++++ README.md | 14 ++++--- package.json | 15 ++++++-- src/cli.ts | 12 +++--- test/index.test.ts | 49 ++++++++++++++++++++++++ test/pack.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 LICENSE create mode 100644 test/pack.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fbfecf..eb094dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,4 @@ jobs: else pnpm install fi - - run: pnpm lint - - run: pnpm test - - run: pnpm build + - run: pnpm verify diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a936c4..621fc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Remove `tsx` from generated apps so pnpm 11 installs do not require approving + ignored `esbuild` build scripts. +- Add generated-app install/build and packed-tarball installability checks. +- Add publish-ready package metadata and license file. + ## v0.1.0-alpha.1 - 2025-01-30 - Initial repository scaffold. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..feb2cef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LocalHost Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 209799e..9ce19dc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ pnpm dev The app sends one chat completion request to your local QVAC server and prints the response text. +The generated app uses `tsc` and `node` directly, so first-run installs work +with pnpm 11 without approving ignored dependency build scripts. + To point at a non-default local endpoint: ```bash @@ -115,15 +118,14 @@ a basic chat example that calls the QVAC local HTTP server at ### Testing packaging -To verify that `npm pack` works (a prerequisite for publishing), build the -project and run `npm pack`: +To verify the package before publishing, run: ```bash -pnpm build -npm pack +pnpm verify ``` -This will create a tarball (e.g., `localhostlabs-create-qvac-app-0.1.0-alpha.1.tgz`) -containing only the files listed in the `files` field of `package.json`. +This lints, runs the unit and generated-app first-run tests, creates a packed +tarball, installs that tarball into a clean temp project, and checks the +installed CLI can run. CI runs the same health checks on pull requests and pushes to `main`. diff --git a/package.json b/package.json index 80af1ce..da0a49c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "@localhostlabs/create-qvac-app", "version": "0.1.0-alpha.1", - "private": true, "description": "QVAC developer tooling package: create-qvac-app", "license": "MIT", "repository": { @@ -14,13 +13,23 @@ "bin": { "create-qvac-app": "dist/cli.js" }, - "files": ["dist", "README.md", "CHANGELOG.md"], + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsc -p tsconfig.json", "cli": "node dist/cli.js", "test": "pnpm build && vitest run", "lint": "tsc -p tsconfig.json --noEmit", - "prepack": "pnpm build" + "prepack": "pnpm build", + "verify": "pnpm lint && pnpm test && pnpm pack:check", + "pack:check": "pnpm build && vitest run test/pack.test.ts" }, "devDependencies": { "@types/node": "^26.0.0", diff --git a/src/cli.ts b/src/cli.ts index f0ae94d..bba71f7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import type { Readable, Writable } from "node:stream"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; export const HELP_TEXT = `create-qvac-app @@ -70,13 +70,12 @@ function createNodeChatApp(projectDirectory: string): void { private: true, type: "module", scripts: { - dev: "tsx src/index.ts", + dev: "pnpm build && pnpm start", build: "tsc -p tsconfig.json", start: "node dist/index.js", }, devDependencies: { "@types/node": "^26.0.0", - tsx: "^4.20.0", typescript: "^5.9.0", }, }, @@ -272,6 +271,9 @@ export async function runCli( return 0; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if ( + process.argv[1] && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) +) { process.exitCode = await runCli(); } diff --git a/test/index.test.ts b/test/index.test.ts index cb5dcc2..be00407 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -9,6 +9,23 @@ import { describe, expect, it } from "vitest"; import { name } from "../src/index.js"; const execFileAsync = promisify(execFile); +const TWO_MINUTES = 120_000; + +async function runCommand( + command: string, + args: string[], + cwd: string, +): Promise<{ stderr: string; stdout: string }> { + return execFileAsync(command, args, { + cwd, + env: { + ...process.env, + CI: "1", + }, + timeout: TWO_MINUTES, + maxBuffer: 1024 * 1024 * 10, + }); +} async function runCliWithInput( cliPath: string, @@ -82,8 +99,40 @@ describe("create-qvac-app", () => { expect(readFileSync(join(appDirectory, "src/index.ts"), "utf8")).toContain( "QVAC_BASE_URL", ); + + const packageJson = JSON.parse( + readFileSync(join(appDirectory, "package.json"), "utf8"), + ) as { + scripts: Record; + devDependencies: Record; + }; + expect(packageJson.scripts.dev).toBe("pnpm build && pnpm start"); + expect(packageJson.devDependencies).not.toHaveProperty("tsx"); }); + it( + "scaffolds an app that installs and builds from a clean directory", + async () => { + const cliPath = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + const tempDirectory = await mkdtemp(join(tmpdir(), "create-qvac-app-")); + const appDirectory = join(tempDirectory, "first-run"); + + await execFileAsync(process.execPath, [ + cliPath, + appDirectory, + "--template", + "node-chat", + ]); + + await runCommand("pnpm", ["install"], appDirectory); + const { stderr, stdout } = await runCommand("pnpm", ["build"], appDirectory); + + expect(`${stdout}${stderr}`).toContain("tsc -p tsconfig.json"); + expect(existsSync(join(appDirectory, "dist/index.js"))).toBe(true); + }, + TWO_MINUTES, + ); + it("lets users choose a template", async () => { const cliPath = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); const tempDirectory = await mkdtemp(join(tmpdir(), "create-qvac-app-")); diff --git a/test/pack.test.ts b/test/pack.test.ts new file mode 100644 index 0000000..0bbcfe4 --- /dev/null +++ b/test/pack.test.ts @@ -0,0 +1,82 @@ +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const TWO_MINUTES = 120_000; +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); + +async function runCommand( + command: string, + args: string[], + cwd: string, +): Promise<{ stderr: string; stdout: string }> { + return execFileAsync(command, args, { + cwd, + env: { + ...process.env, + CI: "1", + }, + timeout: TWO_MINUTES, + maxBuffer: 1024 * 1024 * 10, + }); +} + +interface PackResult { + filename: string; +} + +describe("package tarball", () => { + it( + "packs an installable CLI tarball", + async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), "create-qvac-app-pack-")); + const packDirectory = join(tempDirectory, "pack"); + const consumerDirectory = join(tempDirectory, "consumer"); + mkdirSync(packDirectory); + mkdirSync(consumerDirectory); + + const { stdout } = await runCommand( + "npm", + ["pack", "--json", "--pack-destination", packDirectory], + projectRoot, + ); + const [packResult] = JSON.parse(stdout) as PackResult[]; + const tarballPath = join(packDirectory, packResult.filename); + + expect(existsSync(tarballPath)).toBe(true); + + writeFileSync( + join(consumerDirectory, "package.json"), + `${JSON.stringify( + { + private: true, + type: "module", + dependencies: { + "@localhostlabs/create-qvac-app": tarballPath, + }, + }, + null, + 2, + )}\n`, + ); + + await runCommand("pnpm", ["install"], consumerDirectory); + const { stderr, stdout: helpText } = await runCommand( + "pnpm", + ["exec", "--", "create-qvac-app", "--help"], + consumerDirectory, + ); + + expect(stderr).toBe(""); + expect(helpText).toContain("create-qvac-app"); + expect(helpText).toContain("--template node-chat"); + }, + TWO_MINUTES, + ); +});