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
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,4 @@ jobs:
else
pnpm install
fi
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- run: pnpm verify
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
15 changes: 12 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand Down
12 changes: 7 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
},
},
Expand Down Expand Up @@ -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();
}
49 changes: 49 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>;
devDependencies: Record<string, string>;
};
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-"));
Expand Down
82 changes: 82 additions & 0 deletions test/pack.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading