Skip to content
Closed
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: 9 additions & 4 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ on:
branches: [ main ]
paths:
- 'cli/**'
- 'packages/seats/**' # bundled into the cli tarball (bundleDependencies)
- 'commonly-mcp/**'
- 'docs/agents/skills/commonly/SKILL.md' # cli prepublishOnly copies it in
workflow_dispatch:
Expand Down Expand Up @@ -112,6 +113,14 @@ jobs:
with:
node-version: '22'

# Before the compare, not just before publish: the cli bundles
# packages/seats, and `npm pack` on a tree with no node_modules exits 0
# with the bundle silently missing. The compare would then see every
# tree as different from what npm serves. cli's prepack now refuses
# that case, so a missing install fails here, by name.
- name: Install
run: npm ci --ignore-scripts

- name: Decide whether this tree holds anything unpublished
id: cmp
shell: bash
Expand Down Expand Up @@ -237,10 +246,6 @@ jobs:
done
true

- name: Install
if: steps.cmp.outputs.action == 'publish'
run: npm ci --ignore-scripts

- name: Publish
if: steps.cmp.outputs.action == 'publish'
# No NODE_AUTH_TOKEN: npm exchanges this job's OIDC id-token with the
Expand Down
80 changes: 80 additions & 0 deletions cli/__tests__/seats-package.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* packages/seats is the runtime the CLI bundles (D62, extract-in-place). These
* tests hold the two properties the move depends on: the package imports
* nothing from Commonly, and the CLI tarball carries it.
*/
import { execFileSync } from 'child_process';
import { readdirSync, readFileSync, statSync } from 'fs';
import { builtinModules } from 'module';
import { dirname, join, relative, resolve, sep } from 'path';
import { fileURLToPath } from 'url';
import { createHermesAdapter, createRegistry, stub } from '@commonlyai/seats';
import cliHermes from '../src/lib/adapters/hermes.js';
import { getAdapter } from '../src/lib/adapters/index.js';

const CLI = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SEATS = resolve(CLI, '..', 'packages', 'seats');

const sourceFiles = (dir) => readdirSync(dir).flatMap((name) => {
const full = join(dir, name);
return statSync(full).isDirectory() ? sourceFiles(full) : (full.endsWith('.js') ? [full] : []);
});

const specifiers = (code) => [
...code.matchAll(/(?:^|\n)\s*(?:import|export)\b[^'"`;]*?from\s*['"]([^'"]+)['"]/g),
...code.matchAll(/(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g),
...code.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g),
].map((match) => match[1]);

describe('packages/seats', () => {
test('imports only node built-ins and its own modules', () => {
const builtins = new Set(builtinModules);
const files = sourceFiles(join(SEATS, 'src'));
expect(files.length).toBeGreaterThan(5);
const outside = [];
for (const file of files) {
for (const spec of specifiers(readFileSync(file, 'utf8'))) {
if (spec.startsWith('node:') || builtins.has(spec)) continue;
const target = spec.startsWith('.') ? resolve(dirname(file), spec) : null;
if (target && (target + sep).startsWith(join(SEATS, 'src') + sep)) continue;
outside.push(`${relative(SEATS, file)} → ${spec}`);
}
}
expect(outside).toEqual([]);
});

test('the boundary scan sees a bad import', () => {
expect(specifiers("import x from '../../cli/src/lib/environment.js';\nexport { y } from 'commander';"))
.toEqual(['../../cli/src/lib/environment.js', 'commander']);
});

test('the CLI registry serves the bound adapters', () => {
expect(getAdapter('stub')).toBe(stub);
expect(getAdapter('hermes')).toBe(cliHermes);
expect(Object.keys(cliHermes).sort()).toEqual(
Object.keys(createHermesAdapter()).sort(),
);
});

test('a host-less Hermes adapter takes the neutral defaults', async () => {
const hermes = createHermesAdapter();
expect(hermes.requiresModel).toBe(false);
await expect(hermes.validateEnvironment({ sandbox: { trust: 'public' } }))
.rejects.toThrow('no enforced confinement adapter exists');
});

test('a registry refuses duplicate names', () => {
expect(() => createRegistry([stub, stub])).toThrow('duplicate adapter name: stub');
expect(createRegistry([stub]).get('stub')).toBe(stub);
});

test('the CLI tarball bundles every package source file', () => {
const [pack] = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
cwd: CLI, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
}));
const packed = new Set(pack.files.map((file) => file.path));
const expected = sourceFiles(join(SEATS, 'src'))
.map((file) => `node_modules/@commonlyai/seats/${relative(SEATS, file).split(sep).join('/')}`);
expect(expected.filter((path) => !packed.has(path))).toEqual([]);
}, 60000);
});
16 changes: 16 additions & 0 deletions cli/package-lock.json

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

7 changes: 6 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@commonlyai/cli",
"version": "0.1.155",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
"type": "module",
"main": "./src/index.js",
"bin": {
Expand Down Expand Up @@ -32,11 +32,16 @@
"lint:fix": "eslint src --ext .js --fix",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:temp-cleanup": "node scripts/verify-temp-cleanup.mjs",
"prepack": "node scripts/require-bundled-seats.mjs",
"prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
},
"dependencies": {
"@commonlyai/seats": "file:../packages/seats",
"commander": "^12.0.0"
},
"bundleDependencies": [
"@commonlyai/seats"
],
"devDependencies": {
"eslint": "^8.56.0",
"jest": "^29.7.0"
Expand Down
11 changes: 11 additions & 0 deletions cli/scripts/require-bundled-seats.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// prepack: refuse to pack the CLI without its bundled runtime package.
// `npm pack` on a tree where npm ci has not run exits 0 and silently leaves
// node_modules/@commonlyai/seats out of the tarball, which would publish a CLI
// whose adapters cannot load.
import { existsSync } from 'fs';

const manifest = new URL('../node_modules/@commonlyai/seats/package.json', import.meta.url);
if (!existsSync(manifest)) {
console.error('@commonlyai/seats is not installed in cli/node_modules; run `npm ci` in cli/ before packing or publishing');
process.exit(1);
}
Loading
Loading