mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
ee69d0ac29
commit
817ad1fe9a
9 changed files with 492 additions and 490 deletions
|
|
@ -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
190
.github/workflows/nix-build.yml
vendored
Normal 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
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
};
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
@ -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
160
flake.nix
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
241
scripts/check-nix-workspace.mjs
Normal file
241
scripts/check-nix-workspace.mjs
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue