chore(flake): simplify nix build and add ci validation (#257)

* chore(flake): simplify nix build and add ci validation

- Replace dynamic pnpm-workspace.yaml parsing with hardcoded workspacePaths
  and workspaceNames to reduce format assumptions
- Remove update-pnpm-deps script and kimi-code-pnpm-deps package; use
  lib.fakeHash for standard hash mismatch workflow
- Remove nodejs_latest fallback in nodejsFor, hardcode to nodejs_24
- Add nix-build CI workflow that posts hash-mismatch details to PR comments
- Remove unused Nix installation step from release.yml
- Add workspace maintenance note to AGENTS.md
This commit is contained in:
Haozhe 2026-06-01 11:47:38 +08:00 committed by GitHub
parent ee69d0ac29
commit 817ad1fe9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 492 additions and 490 deletions

View file

@ -22,15 +22,11 @@ All other `@moonshot-ai/*` packages are treated as internal packages, including
## Workflow
1. If the change includes `pnpm-lock.yaml`, `pnpm-workspace.yaml`, root or workspace `package.json`, `.npmrc`, `flake.nix`, or `flake.lock`:
- First check `command -v nix >/dev/null 2>&1`.
- If `nix` exists, run `nix run .#update-pnpm-deps`.
- If `nix` is unavailable, skip this step and do not block the changeset; mention in the final response that the command was not run.
2. List the packages that were actually changed.
3. Choose a bump level for each package.
4. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`.
5. Create a short kebab-case file under `.changeset/`.
6. Split unrelated changes into separate changesets; keep one logical change in one file.
1. List the packages that were actually changed.
2. Choose a bump level for each package.
3. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`.
4. Create a short kebab-case file under `.changeset/`.
5. Split unrelated changes into separate changesets; keep one logical change in one file.
Format:

190
.github/workflows/nix-build.yml vendored Normal file
View file

@ -0,0 +1,190 @@
name: Nix Build
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read
pull-requests: write
jobs:
check-workspace-sync:
name: Check flake.nix workspace sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check workspace sync
id: check
run: |
set -euo pipefail
if node scripts/check-nix-workspace.mjs; then
echo "SYNC_OK=true" >> "$GITHUB_OUTPUT"
else
echo "SYNC_OK=false" >> "$GITHUB_OUTPUT"
fi
- name: Extract missing items
if: steps.check.outputs.SYNC_OK == 'false' && github.event_name == 'pull_request'
id: missing
run: |
{
echo "body<<EOF"
echo "<!-- nix-workspace-sync-bot -->"
echo "❌ flake.nix workspace lists out of sync"
echo ""
echo "The recursive workspace dependencies of \`apps/kimi-code\` do not match \`flake.nix\`. Please run \`node scripts/check-nix-workspace.mjs\` locally for details."
echo ""
echo "Typical fix: add the missing package name to \`workspaceNames\` and its path to \`workspacePaths\` in \`flake.nix\`."
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Post or update PR comment
if: steps.check.outputs.SYNC_OK == 'false' && github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
COMMENT_BODY: ${{ steps.missing.outputs.body }}
with:
script: |
const marker = '<!-- nix-workspace-sync-bot -->';
const body = process.env.COMMENT_BODY;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if workspace sync failed
if: steps.check.outputs.SYNC_OK == 'false'
run: exit 1
nix-build:
name: nix build .#kimi-code
needs: check-workspace-sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
nix-path: nixpkgs=channel:nixos-unstable
- name: Build
id: build
run: |
set -euo pipefail
LOG_FILE="$(mktemp)"
if nix build .#kimi-code --print-build-logs 2>&1 | tee "$LOG_FILE"; then
echo "BUILD_FAILED=false" >> "$GITHUB_OUTPUT"
else
echo "BUILD_FAILED=true" >> "$GITHUB_OUTPUT"
echo "LOG_FILE=$LOG_FILE" >> "$GITHUB_OUTPUT"
fi
- name: Extract error log
if: steps.build.outputs.BUILD_FAILED == 'true' && github.event_name == 'pull_request'
id: error
run: |
LOG_FILE="${{ steps.build.outputs.LOG_FILE }}"
# Extract hash mismatch info
SPECIFIED=$(grep -oP 'specified:\s+\K\S+' "$LOG_FILE" || true)
GOT=$(grep -oP 'got:\s+\K\S+' "$LOG_FILE" || true)
if [ -n "$SPECIFIED" ] && [ -n "$GOT" ]; then
{
echo "body<<EOF"
echo "❌ Nix build failed"
echo ""
echo "Hash mismatch in \`pnpmDeps\`:"
echo "| | Hash |"
echo "|---|---|"
echo "| **specified** | \`$SPECIFIED\` |"
echo "| **got** | \`$GOT\` |"
echo ""
echo "Please update \`flake.nix\` with the **got** hash."
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
TAIL=$(tail -n 80 "$LOG_FILE" | sed 's/^/ /')
{
echo "body<<EOF"
echo "❌ Nix build failed"
echo ""
echo "\`\`\`"
echo "$TAIL"
echo "\`\`\`"
echo "EOF"
} >> "$GITHUB_OUTPUT"
fi
- name: Post or update PR comment
if: steps.build.outputs.BUILD_FAILED == 'true' && github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
COMMENT_BODY: ${{ steps.error.outputs.body }}
with:
script: |
const marker = '<!-- nix-build-bot -->';
const body = marker + '\n' + process.env.COMMENT_BODY;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail the job if build failed
if: steps.build.outputs.BUILD_FAILED == 'true'
run: exit 1

View file

@ -18,7 +18,7 @@ jobs:
permissions:
contents: write
pull-requests: write
id-token: write # Required for NPM Trusted Publishing (OIDC)
id-token: write # Required for NPM Trusted Publishing (OIDC)
steps:
- name: Checkout
uses: actions/checkout@v6
@ -28,17 +28,12 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
# Nix must be installed before setup-node so that setup-node's PATH
# entry (Node >= 24) is prepended after Nix's, keeping Node 24 first.
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@latest
@ -62,8 +57,8 @@ jobs:
with:
publish: pnpm changeset publish
version: pnpm run version:release
commit: 'ci: release packages'
title: 'ci: release packages'
commit: "ci: release packages"
title: "ci: release packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# NPM_TOKEN is not needed with Trusted Publishing (OIDC)

View file

@ -29,6 +29,13 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
- **pnpm**: `10.33.0` (from the root `package.json` `packageManager`).
- `pnpm install` will fail when the Node version is not satisfied, because `.npmrc` sets `engine-strict=true`.
## Monorepo Workspace Maintenance
- `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists.
- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix`.**
- Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset.
- Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched.
## General Coding Rules
- For optional object properties, pass `undefined` directly instead of using conditional spread.

View file

@ -1,152 +0,0 @@
import { execFile } from 'node:child_process';
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
const execFileAsync = promisify(execFile);
const repoRoot = resolve(import.meta.dirname, '../../../../..');
const scriptPath = join(repoRoot, 'build/nix/update-pnpm-deps.sh');
const currentHash = 'sha256-LZ9Bkm3pG2ib7NcdqcP/kmoYWsNjXQ8PoEIlg/94oVo=';
const fakeHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
const newHash = 'sha256-NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN=';
const tempRoots: string[] = [];
describe('update-pnpm-deps.sh', () => {
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it('exits without patching fakeHash when the current pnpmDeps hash still builds', async () => {
const fixture = await createFixtureRepo(currentHash);
const result = await runUpdateScript(fixture, { currentValid: true });
await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.toContain(currentHash);
await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.not.toContain(fakeHash);
await expect(readFile(fixture.nixLogPath, 'utf-8')).resolves.toBe('current\n');
expect(result.stdout).toContain('pnpmDeps hash still valid');
const cache = JSON.parse(
await readFile(join(fixture.root, '.git/kimi-code/pnpm-deps-hashes-v1.json'), 'utf-8'),
) as Record<string, { hash: string }>;
expect(Object.values(cache)).toContainEqual(expect.objectContaining({ hash: currentHash }));
});
it('writes a local cache after discovery and verifies the cached hash on the next run', async () => {
const fixture = await createFixtureRepo(currentHash);
await runUpdateScript(fixture, { currentValid: false });
await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.toContain(newHash);
const cache = JSON.parse(
await readFile(join(fixture.root, '.git/kimi-code/pnpm-deps-hashes-v1.json'), 'utf-8'),
) as Record<string, { hash: string }>;
expect(Object.values(cache)).toContainEqual(expect.objectContaining({ hash: newHash }));
await writeFile(join(fixture.root, 'flake.nix'), flakeWithHash(currentHash));
await writeFile(fixture.nixLogPath, '');
const result = await runUpdateScript(fixture, { currentValid: false });
await expect(readFile(join(fixture.root, 'flake.nix'), 'utf-8')).resolves.toContain(newHash);
await expect(readFile(fixture.nixLogPath, 'utf-8')).resolves.toBe('current\nnew\n');
expect(result.stdout).toContain('cache hit');
});
});
async function createFixtureRepo(hash: string): Promise<{ binDir: string; nixLogPath: string; root: string }> {
const root = await mkdtemp(join(tmpdir(), 'kimi-update-pnpm-deps-'));
tempRoots.push(root);
await writeFile(join(root, 'flake.nix'), flakeWithHash(hash));
await writeFile(join(root, 'flake.lock'), '{}\n');
await writeFile(join(root, '.npmrc'), 'engine-strict=true\n');
await writeFile(join(root, 'package.json'), '{"name":"root"}\n');
await writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n');
await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n');
await mkdir(join(root, 'apps/example'), { recursive: true });
await writeFile(join(root, 'apps/example/package.json'), '{"name":"example"}\n');
await execFileAsync('git', ['init'], { cwd: root });
const binDir = join(root, 'bin');
const nixLogPath = join(root, 'nix.log');
await mkdir(binDir);
await writeFile(nixLogPath, '');
await writeFile(
join(binDir, 'nix'),
`#!/usr/bin/env bash
set -euo pipefail
state=unknown
if grep -Fq "$CURRENT_HASH" flake.nix; then
state=current
elif grep -Fq "$FAKE_HASH" flake.nix; then
state=fake
elif grep -Fq "$NEW_HASH" flake.nix; then
state=new
fi
printf '%s\\n' "$state" >> "$NIX_LOG"
case "$state" in
current)
if [ "\${CURRENT_VALID:-0}" = "1" ]; then
exit 0
fi
echo "current hash invalid" >&2
exit 1
;;
fake)
echo "error: hash mismatch" >&2
echo " got: $NEW_HASH" >&2
exit 1
;;
new)
exit 0
;;
*)
echo "unexpected hash state: $state" >&2
exit 1
;;
esac
`,
);
await chmod(join(binDir, 'nix'), 0o755);
return { binDir, nixLogPath, root };
}
async function runUpdateScript(
fixture: { binDir: string; nixLogPath: string; root: string },
options: { currentValid: boolean },
): Promise<{ stdout: string; stderr: string }> {
return execFileAsync('bash', [scriptPath], {
cwd: fixture.root,
env: {
...process.env,
CURRENT_HASH: currentHash,
CURRENT_VALID: options.currentValid ? '1' : '0',
FAKE_HASH: fakeHash,
NEW_HASH: newHash,
NIX_LOG: fixture.nixLogPath,
PATH: `${fixture.binDir}:${process.env['PATH'] ?? ''}`,
},
});
}
function flakeWithHash(hash: string): string {
return `{
outputs = { self, nixpkgs }: {
packages.x86_64-linux.kimi-code-pnpm-deps = {
hash = "${hash}";
};
};
}
`;
}

View file

@ -1,201 +0,0 @@
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
FLAKE="$ROOT/flake.nix"
FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
CACHE_VERSION="v1"
FETCHER_VERSION="3"
CACHE_FILE="$ROOT/.git/kimi-code/pnpm-deps-hashes-$CACHE_VERSION.json"
RESTORE_ORIG_HASH=0
ORIG_HASH="$(grep -E -o 'hash = "sha256-[A-Za-z0-9+/=]+"' "$FLAKE" \
| head -n 1 \
| sed -E 's/hash = "(.*)"/\1/')"
if [ -z "$ORIG_HASH" ]; then
echo "error: could not find pnpmDeps hash in flake.nix" >&2
exit 1
fi
set_hash() {
sed -i.bak -E "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$1\"|" "$FLAKE"
rm -f "$FLAKE.bak"
}
cleanup() {
if [ "$RESTORE_ORIG_HASH" = "1" ]; then
set_hash "$ORIG_HASH"
fi
}
trap cleanup EXIT
trap 'exit 130' INT TERM
hash_stream() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | sed -E 's/[[:space:]].*$//'
else
shasum -a 256 | sed -E 's/[[:space:]].*$//'
fi
}
hash_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | sed -E 's/[[:space:]].*$//'
else
shasum -a 256 "$1" | sed -E 's/[[:space:]].*$//'
fi
}
print_file_fingerprint() {
path="$1"
if [ -f "$path" ]; then
printf 'file:%s\n' "$path"
hash_file "$path"
printf '\n'
else
printf 'missing:%s\n' "$path"
fi
}
input_fingerprint() {
{
printf 'cacheVersion=%s\n' "$CACHE_VERSION"
printf 'fetcherVersion=%s\n' "$FETCHER_VERSION"
printf 'file:flake.nix(normalized-pnpmDeps-hash)\n'
sed -E 's|hash = "sha256-[A-Za-z0-9+/=]+"|hash = "sha256-<normalized>"|' "$FLAKE"
printf '\n'
for path in \
.npmrc \
flake.lock \
package.json \
pnpm-lock.yaml \
pnpm-workspace.yaml
do
print_file_fingerprint "$path"
done
git ls-files --cached --others --exclude-standard -- \
'*/package.json' \
'.pnpmfile.cjs' \
'patches/**' \
| sort -u \
| while IFS= read -r path; do
[ -n "$path" ] || continue
[ "$path" = "package.json" ] && continue
print_file_fingerprint "$path"
done
} | hash_stream
}
read_cached_hash() {
[ -f "$CACHE_FILE" ] || return 0
# shellcheck disable=SC2016
node -e '
const fs = require("node:fs");
const [file, key] = process.argv.slice(1);
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
const entry = parsed[key];
if (entry && typeof entry.hash === "string") {
console.log(entry.hash);
}
' "$CACHE_FILE" "$INPUT_KEY"
}
write_cached_hash() {
hash="$1"
mkdir -p "$(dirname "$CACHE_FILE")"
# shellcheck disable=SC2016
node -e '
const fs = require("node:fs");
const [file, key, hash, createdAt] = process.argv.slice(1);
let parsed = {};
try {
if (fs.existsSync(file)) {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
}
} catch {
parsed = {};
}
parsed[key] = { hash, createdAt };
fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(parsed, null, 2)}\n`);
fs.renameSync(`${file}.tmp`, file);
' "$CACHE_FILE" "$INPUT_KEY" "$hash" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
}
echo "==> current pnpmDeps hash: $ORIG_HASH"
INPUT_KEY="$(input_fingerprint)"
if nix build --no-link '.#kimi-code-pnpm-deps' >/dev/null 2>&1; then
write_cached_hash "$ORIG_HASH"
echo "==> pnpmDeps hash still valid; cached input fingerprint $INPUT_KEY"
exit 0
fi
echo "==> current hash did not build; checking local pnpmDeps hash cache"
CACHED_HASH=""
if ! CACHED_HASH="$(read_cached_hash)"; then
echo "warning: ignoring unreadable pnpmDeps hash cache at $CACHE_FILE" >&2
CACHED_HASH=""
fi
if [ -n "$CACHED_HASH" ] && [ "$CACHED_HASH" != "$ORIG_HASH" ]; then
echo "==> cache hit for pnpmDeps input: $CACHED_HASH"
RESTORE_ORIG_HASH=1
set_hash "$CACHED_HASH"
if nix build --no-link '.#kimi-code-pnpm-deps' >/dev/null 2>&1; then
RESTORE_ORIG_HASH=0
write_cached_hash "$CACHED_HASH"
echo "==> done. pnpmDeps hash: $ORIG_HASH -> $CACHED_HASH"
exit 0
fi
echo "==> cached hash failed verification; falling back to hash discovery"
set_hash "$ORIG_HASH"
RESTORE_ORIG_HASH=0
fi
echo "==> patching flake.nix with fakeHash to provoke a mismatch"
RESTORE_ORIG_HASH=1
set_hash "$FAKE_HASH"
echo "==> running nix build to discover the real hash"
BUILD_OUT="$(nix build --no-link --print-build-logs '.#kimi-code-pnpm-deps' 2>&1 || true)"
NEW_HASH="$(printf '%s\n' "$BUILD_OUT" \
| grep -E -o 'got:[[:space:]]+sha256-[A-Za-z0-9+/=]+' \
| head -n 1 \
| sed -E 's/^got:[[:space:]]+//')"
if [ -z "$NEW_HASH" ]; then
echo "error: could not extract a new hash from nix build output." >&2
echo "----- nix build output -----" >&2
printf '%s\n' "$BUILD_OUT" >&2
echo "----- end output -----" >&2
set_hash "$ORIG_HASH"
RESTORE_ORIG_HASH=0
exit 1
fi
set_hash "$NEW_HASH"
RESTORE_ORIG_HASH=0
echo "==> verifying build with new hash"
if ! nix build --no-link '.#kimi-code-pnpm-deps'; then
echo "error: verification build failed after hash update." >&2
echo " flake.nix was left pointing at $NEW_HASH for inspection." >&2
exit 1
fi
write_cached_hash "$NEW_HASH"
if [ "$NEW_HASH" = "$ORIG_HASH" ]; then
echo "==> hash unchanged ($ORIG_HASH); flake.nix already up to date"
exit 0
fi
echo "==> done. pnpmDeps hash: $ORIG_HASH -> $NEW_HASH"

160
flake.nix
View file

@ -30,28 +30,20 @@
);
minNodeVersion = "24.15.0";
requiredNodeMajor = "24";
# nodejsFor picks pkgs.nodejs_24 when it satisfies the minimum, otherwise
# falls back to pkgs.nodejs_latest. The build pipeline (tsdown target
# `node24`, SEA flags) assumes a 24.x runtime, so we hard-fail if neither
# candidate is in the 24.x line.
# Hardcode to Node.js 24.x; fail the evaluation if the pinned nixpkgs
# does not offer a new enough 24.x.
nodejsFor =
pkgs:
let
candidate =
if lib.versionAtLeast pkgs.nodejs_24.version minNodeVersion then
pkgs.nodejs_24
else
pkgs.nodejs_latest;
major = lib.versions.major candidate.version;
node = pkgs.nodejs_24;
in
if major == requiredNodeMajor then
candidate
if lib.versionAtLeast node.version minNodeVersion then
node
else
throw ''
Kimi Code requires Node.js ${requiredNodeMajor}.x (>= ${minNodeVersion}),
but nixpkgs only offers ${candidate.version}.
Kimi Code requires Node.js >= ${minNodeVersion},
but nixpkgs only offers ${node.version}.
Pin a newer nixpkgs revision or update minNodeVersion in flake.nix.
'';
@ -61,87 +53,43 @@
nodejs = nodejsFor pkgs;
};
# ---------------------------------------------------------------------
# Derive workspace members from pnpm-workspace.yaml + each package.json.
# -------------------------------------------------------------------
# Workspace members (kept in sync with pnpm-workspace.yaml).
#
# Source of truth is pnpm-workspace.yaml's `packages:` section. We expand
# each glob (or literal path) against the repo root, keep only entries
# that contain a package.json, and read their `name` field. Both the
# `src` fileset and the `pnpmWorkspaces` filter for pnpmConfigHook are
# derived from this list, so adding/removing a workspace only requires
# updating pnpm-workspace.yaml (followed by `nix run .#update-pnpm-deps`).
# ---------------------------------------------------------------------
# HARD REQUIREMENT: whenever you add or remove a workspace package,
# you MUST update both lists below. Missing a path will break the Nix
# build (src fileset silently drops files); missing a name will break
# pnpmConfigHook (dependencies for that workspace won't be fetched).
# -------------------------------------------------------------------
workspacePaths = [
./packages/agent-core
./packages/kaos
./packages/kosong
./packages/migration-legacy
./packages/node-sdk
./packages/oauth
./packages/telemetry
./apps/kimi-code
./apps/vis
./apps/vis/server
./apps/vis/web
./docs
];
# Minimal parser for the `packages:` list in pnpm-workspace.yaml. Only
# handles top-level `- <entry>` items under the `packages:` key; other
# sections (catalog, overrides) are ignored. Sufficient for the format
# pnpm produces and we maintain.
parsePnpmWorkspaceGlobs =
file:
let
lines = lib.splitString "\n" (builtins.readFile file);
isPackagesHeader = l: builtins.match "^packages:[[:space:]]*$" l != null;
isTopLevelKey = l: builtins.match "^[^[:space:]#].*:.*$" l != null;
extractItem =
l:
let
m = builtins.match "^[[:space:]]+-[[:space:]]+['\"]?([^'\"#[:space:]]+)['\"]?[[:space:]]*$" l;
in
if m == null then null else builtins.head m;
step =
state: line:
if state.done then
state
else if !state.inSection then
if isPackagesHeader line then state // { inSection = true; } else state
else
let
item = extractItem line;
in
if item != null then
state // { items = state.items ++ [ item ]; }
else if isTopLevelKey line then
state // { inSection = false; done = true; }
else
state;
result = lib.foldl' step {
inSection = false;
items = [ ];
done = false;
} lines;
in
result.items;
expandWorkspaceGlob =
root: glob:
if lib.hasSuffix "/*" glob then
let
dir = lib.removeSuffix "/*" glob;
absDir = root + "/${dir}";
in
if builtins.pathExists absDir then
map (n: "${dir}/${n}") (
builtins.attrNames (lib.filterAttrs (_: t: t == "directory") (builtins.readDir absDir))
)
else
[ ]
else if builtins.pathExists (root + "/${glob}") then
[ glob ]
else
[ ];
workspaceMembers =
let
globs = parsePnpmWorkspaceGlobs ./pnpm-workspace.yaml;
paths = lib.unique (lib.concatMap (expandWorkspaceGlob ./.) globs);
in
builtins.filter (p: builtins.pathExists (./. + "/${p}/package.json")) paths;
workspacePaths = map (p: ./. + "/${p}") workspaceMembers;
workspaceNames = map (
p: (builtins.fromJSON (builtins.readFile (./. + "/${p}/package.json"))).name
) workspaceMembers;
workspaceNames = [
"@moonshot-ai/agent-core"
"@moonshot-ai/kaos"
"@moonshot-ai/kosong"
"@moonshot-ai/migration-legacy"
"@moonshot-ai/kimi-code-sdk"
"@moonshot-ai/kimi-code-oauth"
"@moonshot-ai/kimi-telemetry"
"@moonshot-ai/kimi-code"
"@moonshot-ai/vis"
"@moonshot-ai/vis-server"
"@moonshot-ai/vis-web"
"kimi-code-docs"
];
in
{
packages = forAllSystems (
@ -248,27 +196,9 @@
platforms = systems;
};
});
# Expose pnpmDeps as a top-level package so `nix build .#kimi-code-pnpm-deps`
# (used by the update-pnpm-deps app) is a stable selector that doesn't
# depend on attribute drilling into a derivation.
kimi-code-pnpm-deps = kimi-code.pnpmDeps;
update-pnpm-deps = pkgs.writeShellApplication {
name = "update-pnpm-deps";
runtimeInputs = [
pkgs.nix
pkgs.git
nodejs
pkgs.gnused
pkgs.gnugrep
pkgs.coreutils
];
text = builtins.readFile ./build/nix/update-pnpm-deps.sh;
};
in
{
inherit kimi-code kimi-code-pnpm-deps update-pnpm-deps;
inherit kimi-code;
default = kimi-code;
}
);
@ -278,10 +208,6 @@
type = "app";
program = "${self.packages.${pkgs.system}.kimi-code}/bin/kimi";
};
update-pnpm-deps = {
type = "app";
program = "${self.packages.${pkgs.system}.update-pnpm-deps}/bin/update-pnpm-deps";
};
default = self.apps.${pkgs.system}.kimi-code;
});

View file

@ -22,7 +22,7 @@
"clean": "pnpm -r run clean",
"changeset": "changeset",
"version": "changeset version",
"version:release": "changeset version && nix run .#update-pnpm-deps",
"version:release": "changeset version",
"publish": "pnpm run typecheck && pnpm run lint && pnpm run sherif && pnpm run test && pnpm run build && pnpm run lint:pkg && changeset publish",
"prepare": "simple-git-hooks"
},

View file

@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* Recursively resolve workspace dependencies starting from apps/kimi-code
* and verify they are all present in flake.nix workspaceNames/workspacePaths.
*
* Exit code 0 if everything is in sync, 1 otherwise.
*/
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { resolve, join } from "node:path";
const ROOT = resolve(import.meta.dirname, "..");
const FLAKE_NIX = join(ROOT, "flake.nix");
const START_PKG = "@moonshot-ai/kimi-code";
/**
* Parse pnpm-workspace.yaml to get workspace directory globs.
*/
function getWorkspaceGlobs() {
const yamlPath = join(ROOT, "pnpm-workspace.yaml");
const content = readFileSync(yamlPath, "utf8");
const lines = content.split("\n");
const globs = [];
let inPackages = false;
for (const line of lines) {
if (line.startsWith("packages:")) {
inPackages = true;
continue;
}
if (inPackages) {
const match = line.match(/^\s+-\s+(.+)$/);
if (match) {
globs.push(match[1]);
} else if (line.trim() !== "" && !line.startsWith(" ")) {
break;
}
}
}
return globs;
}
/**
* Expand globs like "packages/*" into actual directories.
*/
function expandGlobsSafe(globs) {
const dirs = [];
for (const g of globs) {
if (g.endsWith("/*")) {
const base = g.slice(0, -2);
const basePath = join(ROOT, base);
if (!existsSync(basePath)) continue;
for (const entry of readdirSync(basePath, { withFileTypes: true })) {
if (entry.isDirectory()) {
dirs.push(join(base, entry.name));
}
}
} else {
const p = join(ROOT, g);
if (existsSync(p)) {
dirs.push(g);
}
}
}
return dirs;
}
/**
* Build a map of package name -> relative directory for all workspace packages.
*/
function buildWorkspaceMap(dirs) {
const map = new Map();
for (const dir of dirs) {
const pkgPath = join(ROOT, dir, "package.json");
if (!existsSync(pkgPath)) continue;
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
if (pkg.name) {
map.set(pkg.name, dir);
}
}
return map;
}
/**
* Recursively collect all workspace dependencies (transitive closure).
*/
function resolveWorkspaceDeps(workspaceMap, startName) {
const visited = new Set();
const closure = new Set();
function visit(name) {
if (visited.has(name)) return;
visited.add(name);
const dir = workspaceMap.get(name);
if (!dir) return;
const pkgPath = join(ROOT, dir, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const depSections = [
pkg.dependencies,
pkg.devDependencies,
pkg.peerDependencies,
];
for (const section of depSections) {
if (!section) continue;
for (const [depName, specifier] of Object.entries(section)) {
if (
typeof specifier === "string" &&
(specifier.includes("workspace") || specifier.startsWith("link:"))
) {
closure.add(depName);
visit(depName);
}
}
}
}
visit(startName);
return closure;
}
/**
* Parse workspaceNames and workspacePaths from flake.nix.
*/
function parseFlakeNix() {
const content = readFileSync(FLAKE_NIX, "utf8");
function extractArray(label) {
const regex = new RegExp(
`${label}\\s*=\\s*\\[(.*?)\\]`,
"s"
);
const match = content.match(regex);
if (!match) {
throw new Error(`Could not find ${label} in flake.nix`);
}
const items = [];
// workspaceNames uses quoted strings, workspacePaths uses bare Nix paths
const itemRegex = label === "workspacePaths" ? /\.\/[^\s\]]+/g : /"([^"]+)"/g;
let m;
if (label === "workspacePaths") {
while ((m = itemRegex.exec(match[1])) !== null) {
items.push(m[0]);
}
} else {
while ((m = itemRegex.exec(match[1])) !== null) {
items.push(m[1]);
}
}
return items;
}
return {
names: extractArray("workspaceNames"),
paths: extractArray("workspacePaths"),
};
}
function main() {
const globs = getWorkspaceGlobs();
const dirs = expandGlobsSafe(globs);
const workspaceMap = buildWorkspaceMap(dirs);
if (!workspaceMap.has(START_PKG)) {
console.error(`Start package ${START_PKG} not found in workspace.`);
process.exit(1);
}
const closure = resolveWorkspaceDeps(workspaceMap, START_PKG);
/** @type {string[]} */
const closureNames = [...closure].sort((a, b) => a.localeCompare(b));
const flake = parseFlakeNix();
const flakeNameSet = new Set(flake.names);
const flakePathSet = new Set(flake.paths);
const missingNames = closureNames.filter((n) => !flakeNameSet.has(n));
/** @type {Array<{name: string, path: string}>} */
const missingPaths = [];
for (const name of closureNames) {
const dir = workspaceMap.get(name);
if (dir && !flakePathSet.has(`./${dir}`)) {
missingPaths.push({ name, path: `./${dir}` });
}
}
// Also check that the start package itself is in flake.nix
if (!flakeNameSet.has(START_PKG)) {
missingNames.unshift(START_PKG);
}
const startDir = workspaceMap.get(START_PKG);
if (startDir && !flakePathSet.has(`./${startDir}`)) {
missingPaths.unshift({ name: START_PKG, path: `./${startDir}` });
}
const ok = missingNames.length === 0 && missingPaths.length === 0;
if (!ok) {
console.error("❌ flake.nix workspace lists are out of sync.\n");
if (missingNames.length > 0) {
console.error(
"The following workspace packages are missing from flake.nix workspaceNames:"
);
for (const n of missingNames) {
console.error(` - ${n}`);
}
console.error("");
}
if (missingPaths.length > 0) {
console.error(
"The following workspace paths are missing from flake.nix workspacePaths:"
);
for (const { name, path } of missingPaths) {
console.error(` - ${path} (${name})`);
}
console.error("");
}
console.error(
"Please add the missing entries to both workspaceNames and workspacePaths in flake.nix."
);
console.error(
`\nExpected workspaceNames (${flake.names.length + missingNames.length} total):`
);
const expectedNames = new Set([...flake.names, ...missingNames.map((m) => m)]);
for (const n of [...expectedNames].sort((a, b) => a.localeCompare(b))) {
console.error(` ${n}`);
}
process.exit(1);
}
console.log(
`✅ All ${closureNames.length} recursive workspace dependencies are present in flake.nix.`
);
}
main();