diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8ef45f..faf4eb1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,19 @@ on:
branches: ["main", "development"]
jobs:
+ package:
+ name: Build and Install Package
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ cache: "npm"
+ - run: npm ci
+ - run: npm run build
+ - run: npm run test:package
+
lint-specs:
name: Lint Specs
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 3128cf4..18e4182 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -14,6 +14,11 @@ jobs:
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Verify release commit belongs to main
+ run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Use Node.js 22.x
uses: actions/setup-node@v4
@@ -22,14 +27,22 @@ jobs:
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
scope: '@letra-ai'
+
+ - name: Verify tag matches package version
+ run: node -e "const { version } = require('./packages/cli/package.json'); if (process.env.GITHUB_REF_NAME !== 'v' + version) throw new Error('Tag must match CLI version ' + version)"
- name: Install dependencies
run: npm ci
- name: Build package
run: npm run build
+
+ - name: Test consumer installation
+ id: package
+ run: npm run test:package -- --pack-destination "$RUNNER_TEMP/letra-release"
- name: Publish to npm
- run: npm publish --workspace=packages/cli --access public
+ run: npm publish "$TARBALL" --access public
env:
+ TARBALL: ${{ steps.package.outputs.tarball }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/package-lock.json b/package-lock.json
index a7add42..c9cb7d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13185,9 +13185,8 @@
},
"packages/cli": {
"name": "@letra-ai/cli",
- "version": "0.5.3",
+ "version": "0.6.1",
"dependencies": {
- "@letra/types": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "^5.4.1",
"commander": "^12.1.0",
@@ -13201,9 +13200,13 @@
"letra": "dist/index.js"
},
"devDependencies": {
+ "@letra/types": "*",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
+ },
+ "engines": {
+ "node": ">=22"
}
},
"packages/client": {
diff --git a/package.json b/package.json
index 8dc13a5..3aad93b 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"typecheck": "npm -w packages/cli run typecheck && npm -w packages/ui run typecheck",
"test": "npm -w packages/cli run test",
"test:client": "npm -w packages/client run test",
+ "test:package": "node scripts/test-package.mjs",
"ds:playground": "npm -w packages/design-toolkit run playground",
"ds:check": "npm -w packages/design-toolkit run check",
"ds:validate": "npm -w packages/design-toolkit run validate"
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 11ef811..ed704fe 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,12 +1,16 @@
{
"name": "@letra-ai/cli",
- "version": "0.6.0",
+ "version": "0.6.1",
"type": "module",
"bin": {
"letra": "dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+ "files": ["dist"],
+ "engines": {
+ "node": ">=22"
+ },
"scripts": {
"dev": "tsx src/index.ts",
"build": "node ../../scripts/build-cli.mjs",
@@ -15,7 +19,6 @@
"test:watch": "vitest"
},
"dependencies": {
- "@letra/types": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "^5.4.1",
"commander": "^12.1.0",
@@ -26,6 +29,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
+ "@letra/types": "*",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
diff --git a/packages/client/src/components/Kanban/KanbanView.tsx b/packages/client/src/components/Kanban/KanbanView.tsx
index a2c4970..c03e827 100644
--- a/packages/client/src/components/Kanban/KanbanView.tsx
+++ b/packages/client/src/components/Kanban/KanbanView.tsx
@@ -284,7 +284,7 @@ export default function KanbanView({
const progressVal = acCount
? acCount.done
: hasTasks
- ? it.tasks?.filter((t) => t.done).length
+ ? (it.tasks?.filter((t) => t.done).length ?? 0)
: 0;
return (
probe.close((error) => error ? reject(error) : resolveClose()));
+ return port;
+}
+
+let server;
+let serverOutput = "";
+try {
+ console.log("[package] Packing the CLI...");
+ const [packed] = JSON.parse(run([
+ npmCli, "pack", "--workspace=packages/cli", "--json", "--pack-destination", packDir,
+ ], repoDir, process.env));
+ const tarball = join(packDir, packed.filename);
+ for (const path of ["dist/index.js", "dist/index.d.ts", "dist/client/index.html", "dist/harness/default/v0.2.0/roles/analyst.yaml"]) {
+ assert(packed.files.some((file) => file.path === path), `Tarball is missing ${path}`);
+ }
+ assert(packed.files.some((file) => /^dist\/chunk-.+\.js$/.test(file.path)), "Tarball is missing CLI chunks");
+ assert(packed.files.every((file) => file.path.startsWith("dist/") || /^(package\.json|readme(?:\..*)?|licen[cs]e(?:\..*)?)$/i.test(file.path)), "Unexpected development files in tarball");
+
+ console.log("[package] Installing into a clean consumer directory...");
+ writeFileSync(join(installDir, "package.json"), JSON.stringify({ name: "letra-package-smoke", version: "1.0.0", private: true }));
+ run([npmCli, "install", "--omit=dev", "--no-audit", "--no-fund", tarball]);
+ const installedDir = join(installDir, "node_modules/@letra-ai/cli");
+ const installed = JSON.parse(readFileSync(join(installedDir, "package.json"), "utf8"));
+ assert.equal(installed.version, expected.version);
+ assert(!installed.dependencies?.["@letra/types"], "Internal types must not be a runtime dependency");
+ const cli = join(installedDir, installed.bin.letra);
+ assert.equal(run([cli, "--version"]), expected.version);
+ assert.match(run([cli, "--help"]), /Usage: letra/);
+ assert.match(run([cli, "flow", "--help"]), /serve/);
+ assert.match(run([cli, "mcp", "--help"]), /mcp/);
+
+ console.log("[package] Initializing an isolated workspace with the shipped harness...");
+ run([cli, "init", "--workspace", "package-smoke", "--yes"], projectDir);
+ const workspaceDir = readFileSync(join(projectDir, ".letra-link"), "utf8").trim();
+ const relativeWorkspace = relative(homeDir, workspaceDir);
+ assert(!isAbsolute(relativeWorkspace) && !relativeWorkspace.startsWith(".."), "Workspace escaped the isolated home");
+ assert(existsSync(join(workspaceDir, "workflow.json")), "Workspace initialization failed");
+ assert(!existsSync(join(projectDir, ".letra")), "Initialization created a project-local harness");
+ const pulse = JSON.parse(run([cli, "pulse", "--json"], projectDir));
+ assert.equal(pulse.workspace, "package-smoke");
+ const port = await freePort();
+ const baseUrl = `http://127.0.0.1:${port}`;
+ server = spawn(process.execPath, [cli, "flow", "serve", "--port", String(port)], {
+ cwd: projectDir, env: consumerEnv, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
+ });
+ server.stdout.on("data", (chunk) => { serverOutput += chunk; });
+ server.stderr.on("data", (chunk) => { serverOutput += chunk; });
+ let response;
+ for (let attempt = 0; attempt < 80; attempt++) {
+ assert.equal(server.exitCode, null, `Installed server exited:\n${serverOutput}`);
+ try { response = await fetch(`${baseUrl}/api/workflow`, { signal: AbortSignal.timeout(1000) }); } catch {}
+ if (response?.ok) break;
+ await delay(250);
+ }
+ assert(response?.ok, `Installed server did not start:\n${serverOutput}`);
+ const workflowResponse = await response.json();
+ assert.equal(workflowResponse.name, "package-smoke");
+ const page = await fetch(baseUrl);
+ assert.equal(page.status, 200);
+ const html = await page.text();
+ const assets = [...html.matchAll(/(?:src|href)="(?:\.)?(\/assets\/[^"]+)"/g)].map((match) => match[1]);
+ assert(assets.some((asset) => asset.endsWith(".js")), "Installed UI is missing its JavaScript entry");
+ assert(assets.some((asset) => asset.endsWith(".css")), "Installed UI is missing its stylesheet");
+ for (const asset of assets) {
+ const assetResponse = await fetch(new URL(asset, baseUrl));
+ assert.equal(assetResponse.status, 200, `Missing installed asset: ${asset}`);
+ assert(!(assetResponse.headers.get("content-type") ?? "").includes("text/html"), `Asset fell back to HTML: ${asset}`);
+ }
+ console.log(`[package] PASS: ${installed.name}@${installed.version}; CLI, workspace, API and web assets verified.`);
+ if (values["pack-destination"]) {
+ console.log(`[package] Tested tarball: ${tarball}`);
+ if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `tarball=${tarball}\n`);
+ }
+} finally {
+ if (server && server.exitCode === null) {
+ const exited = once(server, "exit");
+ server.kill();
+ await exited;
+ }
+ // Delete only the exact temporary directory allocated by this invocation.
+ assert(dirname(scratchDir) === tmpdir() && scratchDir.startsWith(join(tmpdir(), "letra-package-")));
+ rmSync(scratchDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+}