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 }); +}