Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
release_bump_only: ${{ steps.classify.outputs.release_bump_only }}
run_heavy_ci: ${{ steps.classify.outputs.run_heavy_ci }}
channels_changed: ${{ steps.classify.outputs.channels_changed }}
packaging_changed: ${{ steps.classify.outputs.packaging_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -69,13 +70,32 @@ jobs:
esac
done

# The packed-artifact checks are expensive (Docker image build + a full
# registry install), and they can only regress when dependencies, the
# packed file list, the build, or the checks themselves change.
PACKAGING_CHANGED=false
for file in $CHANGED_FILES; do
case "$file" in
package.json|bun.lock|build.js|scripts/postinstall-patches.js|scripts/check-minimal-linux-npm-artifact.js|scripts/check-windows-node-pty-artifact.js|.github/workflows/ci.yml)
PACKAGING_CHANGED=true
;;
esac
done

RELEASE_BUMP_ONLY=false
if [ "$TITLE_MATCH" = true ] && [ "$FILES_MATCH" = true ]; then
RELEASE_BUMP_ONLY=true
fi

# A release bump touches package.json only; the artifact was already
# verified on the commit it bumps.
if [ "$RELEASE_BUMP_ONLY" = true ]; then
PACKAGING_CHANGED=false
fi

echo "release_bump_only=$RELEASE_BUMP_ONLY" >> "$GITHUB_OUTPUT"
echo "channels_changed=$CHANNELS_CHANGED" >> "$GITHUB_OUTPUT"
echo "packaging_changed=$PACKAGING_CHANGED" >> "$GITHUB_OUTPUT"

if [ "$RELEASE_BUMP_ONLY" = true ]; then
echo "run_heavy_ci=false" >> "$GITHUB_OUTPUT"
Expand All @@ -85,6 +105,7 @@ jobs:

echo "title=$TITLE"
echo "channels_changed=$CHANNELS_CHANGED"
echo "packaging_changed=$PACKAGING_CHANGED"
printf 'changed_files=%s\n' "$CHANGED_FILES"

check:
Expand Down Expand Up @@ -198,6 +219,16 @@ jobs:
- name: Build bundle
run: bun run build

# Covers both Linux legs: the x64 and arm64 prebuilds are published
# independently, so a missing arm64 prebuild breaks npx the same way.
# Gated on packaging changes / pushes to main because the Docker build and
# registry install cost several minutes per leg.
- name: Minimal Linux npm artifact check
if: runner.os == 'Linux' && (github.event_name == 'push' || needs.classify.outputs.packaging_changed == 'true')
env:
LETTA_CODE_MINIMAL_LINUX_ARTIFACT_SKIP_BUILD: "1"
run: bun run check:minimal-linux-npm-artifact

- name: CLI help smoke test
run: ./letta.js --help

Expand Down Expand Up @@ -230,6 +261,13 @@ jobs:
npm install -g (Get-Item letta-ai-letta-code-*.tgz).FullName
letta --help

- name: Windows node-pty artifact smoke test
if: runner.os == 'Windows'
shell: pwsh
run: |
$env:LETTA_CODE_GLOBAL_NODE_MODULES = npm root -g
bun run check:windows-node-pty-artifact

- name: Test npm install flow (Unix)
if: runner.os != 'Windows'
shell: sh
Expand Down
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,23 @@ also rejects staged parent-relative imports (`../`); use the `@/` alias.
11. **biome** — format + lint across source files
12. **typescript** — full `tsc --noEmit`

### Packaged Artifact Checks (not part of `bun run check`)

These validate the **published npm tarball** rather than the source tree, so
they need Docker or a global install and are wired into CI separately. `bun run
check` does *not* cover packaging regressions.

| Command | What it proves | Requirements |
|---------|----------------|--------------|
| `check:minimal-linux-npm-artifact` | `npm install` of the packed tarball succeeds in Ubuntu 24.04 with **no** `make`/`python3`, `letta --help` produces output, and node-pty loads a prebuilt binary and allocates a real TTY | Docker; runs on both Linux CI legs |
| `check:windows-node-pty-artifact` | The globally-installed package ships a Windows node-pty prebuild and its ConPTY actually executes written input | Windows; `npm install -g <tarball>` must have run first |

The Linux check derives its Docker platform from `process.arch`; override with
`--platform=linux/arm64` or `LETTA_CODE_MINIMAL_LINUX_ARTIFACT_PLATFORM`, and
pass `--skip-build` to reuse existing build output. In CI it is gated on the
`packaging_changed` output of the `classify` job — add a path there if you
introduce a file that can change the packed artifact.

### Environment Variables

| Variable | Effect |
Expand All @@ -231,3 +248,5 @@ also rejects staged parent-relative imports (`../`); use the `@/` alias.
- **`new URL("./path.ts", import.meta.url)` in tests** is not a static import and is not caught by the `@/` import codemod. Scan for `new URL(` manually when moving source files.
- **grep exits 1 on no matches** — pre-commit hooks use `|| true` on grep pipes to prevent false failures on clean commits.
- **macOS case-insensitive FS** — `existsSync("bash.ts")` returns `true` when `Bash.ts` exists. Rename scripts that use `existsSync` to check kebab-case targets will silently skip single-word PascalCase files. Use `git mv` for renames.
- **`node-pty` is pinned to an exact prerelease, on purpose.** `1.1.0` (still npm `latest`) ships no `prebuilds/linux-x64`, so `npm install` falls through to `node-gyp rebuild` and fails on minimal Linux images without `make` — this broke public `npx` installs (letta-ai/letta-acp#50). `1.2.0-beta.14` is the first release with Linux prebuilds. Do not "tidy" it back to a caret range or bump it casually; revisit only when upstream `1.2.0` ships stable, and re-run `check:minimal-linux-npm-artifact` when you do. Note `1.2.0` also drops winpty, so Windows now requires build ≥ 18309.
- **A node-pty prebuild can install cleanly and still be unusable.** node-pty's install script only checks that `prebuilds/<platform>-<arch>/` exists — it never validates the binary, and its presence suppresses the source-build fallback. The linux-x64 prebuild is glibc-linked (GLIBC_2.28), which fails two different ways: on older glibc `require` throws a dynamic-link error, and **on musl (Alpine) `require` succeeds and the first `spawn()` segfaults the process** (verified on `node:22-alpine`). Never `require("node-pty")` directly — go through `requireNodePty()` in `src/utils/node-pty-loader.ts`, which refuses a glibc prebuild on a musl runtime and turns both failures into a tagged error callers degrade on (`exec_command` falls back to a pipe).
4 changes: 2 additions & 2 deletions bun.lock

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
"cron-parser": "^5.6.1",
"glob": "^13.0.0",
"ink-link": "^5.0.0",
"node-pty": "^1.1.0",
"node-pty": "1.2.0-beta.14",
"open": "^10.2.0",
"react": "18.2.0",
"sharp": "^0.34.5",
Expand Down Expand Up @@ -169,6 +169,8 @@
"check:test-coverage": "node scripts/check-test-coverage.cjs",
"check:skill-frontmatter": "node scripts/check-skill-frontmatter.js",
"check:bundled-skill-scripts": "node scripts/check-bundled-skill-scripts.js",
"check:minimal-linux-npm-artifact": "node scripts/check-minimal-linux-npm-artifact.js",
"check:windows-node-pty-artifact": "node scripts/check-windows-node-pty-artifact.js",
"check": "bun run scripts/check.js",
"dev": "node scripts/dev.cjs",
"build": "node scripts/postinstall-patches.js && bun run build.js",
Expand Down
252 changes: 252 additions & 0 deletions scripts/check-minimal-linux-npm-artifact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/usr/bin/env node

/**
* Installs the packed npm artifact into a minimal Ubuntu image that has no C++
* toolchain (no make, no python3) and proves that:
* 1. `npm install` succeeds — i.e. node-pty resolved a prebuilt binary
* instead of falling back to `node-gyp rebuild`.
* 2. The installed CLI runs (`letta --help` produces real output).
* 3. node-pty loads and allocates an actual TTY.
*
* Regression guard for letta-ai/letta-acp#50, where `npx @letta-ai/letta-code`
* failed on minimal Ubuntu images with `Error: not found: make`.
*
* Usage:
* node scripts/check-minimal-linux-npm-artifact.js [--skip-build] [--platform=linux/arm64]
*
* Env:
* LETTA_CODE_MINIMAL_LINUX_ARTIFACT_SKIP_BUILD=1 reuse existing build output
* LETTA_CODE_MINIMAL_LINUX_ARTIFACT_PLATFORM docker platform override
*/

import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const tempDir = mkdtempSync(join(tmpdir(), "letta-code-minimal-linux-npm-"));
const imageTag = `letta-code-minimal-linux-npm:${process.pid}`;
const nodeVersion = "22.19.0";

const NODE_ARCH_BY_PLATFORM = {
"linux/amd64": "x64",
"linux/arm64": "arm64",
};

function resolvePlatform() {
const flag = process.argv.find((arg) => arg.startsWith("--platform="));
const requested =
flag?.slice("--platform=".length) ||
process.env.LETTA_CODE_MINIMAL_LINUX_ARTIFACT_PLATFORM ||
(process.arch === "arm64" ? "linux/arm64" : "linux/amd64");
if (!NODE_ARCH_BY_PLATFORM[requested]) {
throw new Error(
`Unsupported platform "${requested}". Expected one of: ${Object.keys(NODE_ARCH_BY_PLATFORM).join(", ")}`,
);
}
return requested;
}

const platform = resolvePlatform();
const nodeArch = NODE_ARCH_BY_PLATFORM[platform];
const skipBuild =
process.argv.includes("--skip-build") ||
process.env.LETTA_CODE_MINIMAL_LINUX_ARTIFACT_SKIP_BUILD === "1";

function run(command, args, options = {}) {
console.log(`$ ${[command, ...args].join(" ")}`);
execFileSync(command, args, {
cwd: repoRoot,
stdio: "inherit",
timeout: 10 * 60 * 1000,
...options,
});
}

function capture(command, args, options = {}) {
console.log(`$ ${[command, ...args].join(" ")}`);
return execFileSync(command, args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
timeout: 10 * 60 * 1000,
...options,
});
}

function assertDockerAvailable() {
try {
execFileSync("docker", ["version"], { stdio: "ignore", timeout: 60_000 });
} catch (error) {
throw new Error(
"Docker is required for check:minimal-linux-npm-artifact but `docker version` failed. " +
"Install Docker and make sure the daemon is running, then re-run. " +
`Underlying error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

// The image deliberately omits make/python3 so a node-gyp fallback fails loudly.
const dockerfile = `
FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update \\
&& apt-get install -y --no-install-recommends ca-certificates curl xz-utils \\
&& rm -rf /var/lib/apt/lists/*

RUN set -eux; \\
curl -fsSL "https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-${nodeArch}.tar.xz" -o /tmp/node.tar.xz; \\
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1; \\
rm /tmp/node.tar.xz; \\
node --version; \\
npm --version; \\
if command -v make >/dev/null 2>&1; then \\
echo "make unexpectedly present in minimal image" >&2; \\
exit 1; \\
fi

WORKDIR /workspace
`;

const ptySmoke = `
set -eux
if command -v make >/dev/null 2>&1; then
echo "make unexpectedly present before npm install" >&2
exit 1
fi
mkdir -p /workspace/app
cd /workspace/app
npm init -y >/dev/null
npm install --omit=dev --foreground-scripts /tmp/letta-code.tgz
./node_modules/.bin/letta --help >/tmp/letta-help.txt
grep -q letta /tmp/letta-help.txt || {
echo "letta --help produced no recognizable output" >&2
cat /tmp/letta-help.txt >&2
exit 1
}
node <<'NODE'
const { existsSync } = require("node:fs");
const { dirname, join } = require("node:path");
const { createRequire } = require("node:module");
const requireFromLetta = createRequire(require.resolve("@letta-ai/letta-code"));
const ptyRoot = dirname(requireFromLetta.resolve("node-pty/package.json"));

// A prebuild directory for this platform is what keeps npm from shelling out to
// node-gyp; \`build/\` only exists when a source rebuild ran (it is not shipped
// in the tarball). Assert both so a future node-pty bump that drops prebuilds
// fails here rather than in a user's npx install.
const prebuildDir = join(
ptyRoot,
"prebuilds",
process.platform + "-" + process.arch,
);
if (!existsSync(prebuildDir)) {
console.error("node-pty is missing a prebuild at " + prebuildDir);
process.exit(1);
}
if (existsSync(join(ptyRoot, "build"))) {
console.error(
"node-pty was rebuilt from source (" +
join(ptyRoot, "build") +
" exists); the prebuild was not used",
);
process.exit(1);
}
console.log("node-pty prebuild in use: " + prebuildDir);

const pty = requireFromLetta("node-pty");
const term = pty.spawn("/bin/bash", ["-lc", "test -t 0 && printf tty-ok"], {
name: "xterm-256color",
cols: 80,
rows: 24,
cwd: process.cwd(),
env: { ...process.env, TERM: "xterm-256color" },
});
let output = "";
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
try { term.kill(); } catch {}
console.error("PTY smoke timed out; output=" + JSON.stringify(output));
process.exit(1);
}, 20000);
term.onData((data) => {
output += data;
});
term.onExit(({ exitCode }) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (exitCode !== 0) {
console.error(
"PTY smoke exited with " + exitCode + "; output=" + JSON.stringify(output),
);
process.exit(1);
}
if (!output.includes("tty-ok")) {
console.error(
"PTY smoke did not allocate a tty; output=" + JSON.stringify(output),
);
process.exit(1);
}
console.log("PTY smoke passed");
});
NODE
`;

try {
assertDockerAvailable();
console.log(`Checking packed artifact on ${platform} (node ${nodeVersion})`);
if (skipBuild) {
console.log("$ bun run build (skipped; using existing build output)");
} else {
run("bun", ["run", "build"]);
}

const packOutput = capture("npm", [
"pack",
"--pack-destination",
tempDir,
"--json",
]);
const packed = JSON.parse(packOutput);
const filename = packed?.[0]?.filename;
if (!filename) {
throw new Error(`npm pack did not report a filename: ${packOutput}`);
}
const tarball = join(tempDir, filename);

writeFileSync(join(tempDir, "Dockerfile"), dockerfile);
run("docker", [
"build",
"--pull",
"--platform",
platform,
"-t",
imageTag,
tempDir,
]);
run("docker", [
"run",
"--rm",
"--platform",
platform,
"-v",
`${tarball}:/tmp/letta-code.tgz:ro`,
imageTag,
"sh",
"-c",
ptySmoke,
]);
console.log(`Minimal Linux packed artifact check passed (${platform})`);
} finally {
try {
execFileSync("docker", ["rmi", "-f", imageTag], { stdio: "ignore" });
} catch {}
rmSync(tempDir, { recursive: true, force: true });
}
Loading
Loading