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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -15,7 +19,6 @@
"test:watch": "vitest"
},
"dependencies": {
"@letra/types": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "^5.4.1",
"commander": "^12.1.0",
Expand All @@ -26,6 +29,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@letra/types": "*",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/components/Kanban/KanbanView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand Down
124 changes: 124 additions & 0 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Exercise the tarball as a consumer, without the monorepo's dependencies or user data.
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

const repoDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const npmCli = process.env.npm_execpath;
assert(npmCli, "Run this check with npm run test:package");
const { values } = parseArgs({ options: { "pack-destination": { type: "string" } } });
const scratchDir = mkdtempSync(join(tmpdir(), "letra-package-"));
const installDir = join(scratchDir, "consumer");
const homeDir = join(scratchDir, "home");
const projectDir = join(scratchDir, "project");
const packDir = values["pack-destination"] ? resolve(values["pack-destination"]) : join(scratchDir, "packed");
for (const dir of [installDir, homeDir, projectDir, packDir]) mkdirSync(dir, { recursive: true });
const expected = JSON.parse(readFileSync(join(repoDir, "packages/cli/package.json"), "utf8"));
const consumerEnv = { ...process.env, HOME: homeDir, USERPROFILE: homeDir };
// No inherited workspace or Node resolution override may hide an installation failure.
for (const key of ["LETRA_WORKSPACE", "NODE_PATH", "NODE_OPTIONS"]) delete consumerEnv[key];

function run(args, cwd = installDir, env = consumerEnv) {
const result = spawnSync(process.execPath, args, {
cwd, env, encoding: "utf8", timeout: 180_000, windowsHide: true,
});
assert.ifError(result.error);
assert.equal(result.status, 0, `${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`);
return result.stdout.trim();
}

async function freePort() {
const probe = createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const port = probe.address().port;
await new Promise((resolveClose, reject) => 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 });
}
Loading