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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,21 @@ jobs:
cache: npm
- run: npm ci
- run: npm run verify:release-gate

claude-code-release-gates:
name: Claude Code release gates (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: test
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: npm
- run: npm ci
- run: npm run build
- run: npm run smoke:claude-code-pack-install
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@
"smoke:codex-pack-install": "node scripts/smoke-codex-pack-install.mjs",
"smoke:codex-git-sparse": "node scripts/smoke-codex-git-sparse.mjs",
"smoke:codex-temp-prefix-route-a": "node scripts/smoke-codex-temp-prefix-route-a.mjs",
"smoke:claude-code-pack-install": "node scripts/smoke-claude-code-pack-install.mjs",
"test": "vitest run",
"test:workspaces": "npm test --workspaces --if-present",
"test:all": "npm test && npm run test:workspaces",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:release-smoke:manual": "npm test -- tests/commands/policy.test.ts tests/commands/devices.test.ts tests/commands/explain.test.ts tests/commands/doctor.test.ts tests/commands/mcp.test.ts tests/commands/health-check.test.ts tests/commands/quota.test.ts tests/commands/status-sync.test.ts tests/status-sync/smoke.test.ts tests/commands/watch.test.ts tests/commands/events.test.ts tests/devices/catalog-fidelity.test.ts tests/commands/schema.test.ts tests/commands/auth.test.ts tests/commands/config.test.ts tests/commands/scenes.test.ts tests/commands/batch.test.ts tests/commands/history.test.ts tests/commands/expand.test.ts tests/commands/webhook.test.ts tests/commands/daemon.test.ts tests/commands/upgrade-check.test.ts tests/commands/install.test.ts tests/commands/uninstall.test.ts tests/commands/rules.test.ts tests/commands/plan.test.ts",
"verify:pre-commit": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces",
"verify:release-gate": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces && npm run smoke:pack-install && npm run smoke:codex-pack-install && npm run smoke:codex-git-sparse && npm run smoke:codex-temp-prefix-route-a",
"verify:release-gate": "npm run build && npm test -- tests/version.test.ts tests/install/codex-checks.test.ts tests/commands/codex.test.ts && npm run test:workspaces && npm run smoke:pack-install && npm run smoke:codex-pack-install && npm run smoke:codex-git-sparse && npm run smoke:codex-temp-prefix-route-a && npm run smoke:claude-code-pack-install",
"verify:pre-push": "npm run verify:release-gate",
"verify:release": "node scripts/verify-release.mjs",
"prepublishOnly": "npm run verify:release-gate"
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-code-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@
},
"scripts": {
"test": "node --test",
"typecheck": "node --check bin/auth.js"
"typecheck": "node --check bin/auth.js && node --check setup/check-cli.js && node --check setup/check-credentials.js"
}
}
3 changes: 3 additions & 0 deletions packages/claude-code-plugin/setup/check-credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export function makeCheckCredentials(exec) {

if (doctorResult?.reason === 'doctor-failed') {
const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials);
if (errorKey === 'doctor-check-failed' && hasKeychainCredentials) {
return { ok: true, source: 'keychain' };
}
return { ok: false, errorKey, message: formatError(errorKey) };
}

Expand Down
32 changes: 31 additions & 1 deletion packages/claude-code-plugin/tests/setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('checkCredentials', () => {
assert.match(result.message, /switchbot auth logout/);
});

it('returns doctor-check-failed when doctor errors look like network failures', async () => {
it('returns ok:true from keychain when doctor fails with a network error and keychain has credentials', async () => {
const fakeExec = async (cmd, args) => {
if (args.includes('doctor')) {
const err = new Error('ETIMEDOUT');
Expand All @@ -98,12 +98,42 @@ describe('checkCredentials', () => {
};
const check = makeCheckCredentials(fakeExec);
const result = await check();
assert.deepEqual(result, { ok: true, source: 'keychain' });
});

it('returns ok:false with doctor-check-failed when network error and no keychain', async () => {
const fakeExec = async (cmd, args) => {
if (args.includes('doctor')) {
const err = new Error('ETIMEDOUT');
err.stderr = 'connect ETIMEDOUT api.switch-bot.com';
throw err;
}
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: false } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
const result = await check();
assert.equal(result.ok, false);
assert.equal(result.errorKey, 'doctor-check-failed');
assert.match(result.message, /health check/i);
assert.match(result.message, /switchbot doctor/);
});

it('returns ok:true from keychain when doctor fails for a non-auth reason and keychain has credentials', async () => {
const fakeExec = async (cmd, args) => {
if (args.includes('doctor')) {
const err = new Error('doctor failed');
err.stdout = JSON.stringify({ data: { overall: 'fail', checks: [{ name: 'policy', status: 'fail' }] } });
throw err;
}
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
const result = await check();
assert.deepEqual(result, { ok: true, source: 'keychain' });
});

it('returns ok:false when both doctor and keychain describe fail', async () => {
const fakeExec = async () => { throw new Error('all fail'); };
const check = makeCheckCredentials(fakeExec);
Expand Down
7 changes: 6 additions & 1 deletion packages/codex-plugin/setup/check-credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ export function makeCheckCredentials(exec) {

if (doctorResult?.reason === 'doctor-failed') {
const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials);
if (errorKey === 'doctor-check-failed' && hasKeychainCredentials) {
return { ok: true, source: 'keychain' };
}
return { ok: false, errorKey, message: formatError(errorKey) };
}

Expand All @@ -123,5 +126,7 @@ export function makeCheckCredentials(exec) {
};
}

const defaultExec = promisify(execFile);
const _execFile = promisify(execFile);
const defaultExec = (cmd, args, opts) =>
_execFile(cmd, args, { ...opts, shell: process.platform === 'win32' });
export const checkCredentials = makeCheckCredentials(defaultExec);
32 changes: 31 additions & 1 deletion packages/codex-plugin/tests/setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('checkCredentials', () => {
assert.match(result.message, /switchbot auth logout/);
});

it('returns doctor-check-failed when doctor errors look like network failures', async () => {
it('returns ok:true from keychain when doctor fails with a network error and keychain has credentials', async () => {
const fakeExec = async (cmd, args) => {
if (args.includes('doctor')) {
const err = new Error('ETIMEDOUT');
Expand All @@ -98,12 +98,42 @@ describe('checkCredentials', () => {
};
const check = makeCheckCredentials(fakeExec);
const result = await check();
assert.deepEqual(result, { ok: true, source: 'keychain' });
});

it('returns ok:false with doctor-check-failed when network error and no keychain', async () => {
const fakeExec = async (cmd, args) => {
if (args.includes('doctor')) {
const err = new Error('ETIMEDOUT');
err.stderr = 'connect ETIMEDOUT api.switch-bot.com';
throw err;
}
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: false } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
const result = await check();
assert.equal(result.ok, false);
assert.equal(result.errorKey, 'doctor-check-failed');
assert.match(result.message, /health check/i);
assert.match(result.message, /switchbot doctor/);
});

it('returns ok:true from keychain when doctor fails for a non-auth reason and keychain has credentials', async () => {
const fakeExec = async (cmd, args) => {
if (args.includes('doctor')) {
const err = new Error('doctor failed');
err.stdout = JSON.stringify({ data: { overall: 'fail', checks: [{ name: 'policy', status: 'fail' }] } });
throw err;
}
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
const result = await check();
assert.deepEqual(result, { ok: true, source: 'keychain' });
});

it('returns ok:false when both doctor and keychain describe fail', async () => {
const fakeExec = async () => { throw new Error('all fail'); };
const check = makeCheckCredentials(fakeExec);
Expand Down
126 changes: 126 additions & 0 deletions scripts/smoke-claude-code-pack-install.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.dirname(scriptDir);
const workDir = mkdtempSync(path.join(os.tmpdir(), 'switchbot-claude-code-pack-smoke-'));
const packed = [];

function runNpm(args, options = {}) {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath) {
return execFileSync(process.execPath, [npmExecPath, ...args], options);
}
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
return execFileSync(npmCmd, args, options);
}

function pack(args) {
const out = runNpm(['pack', '--json', ...args], {
cwd: repoRoot,
encoding: 'utf-8',
});
const [result] = JSON.parse(out);
if (!result?.filename) {
throw new Error(`npm pack did not return a filename: ${out}`);
}
const tarball = path.join(repoRoot, result.filename);
packed.push(tarball);
return tarball;
}

function readJson(file) {
return JSON.parse(readFileSync(file, 'utf-8'));
}

try {
const cliTarball = pack([]);
const pluginTarball = pack(['--workspace', '@switchbot/claude-code-plugin']);

runNpm(['init', '-y'], {
cwd: workDir,
stdio: 'ignore',
});
runNpm(['install', cliTarball, pluginTarball], {
cwd: workDir,
stdio: 'inherit',
});

const cliPkg = readJson(path.join(repoRoot, 'package.json'));
const installedCliPkg = readJson(path.join(workDir, 'node_modules', '@switchbot', 'openapi-cli', 'package.json'));
if (installedCliPkg.version !== cliPkg.version) {
throw new Error(`installed CLI version mismatch: expected ${cliPkg.version}, got ${installedCliPkg.version}`);
}

const pluginRoot = path.join(workDir, 'node_modules', '@switchbot', 'claude-code-plugin');
const pluginPkg = readJson(path.join(pluginRoot, 'package.json'));
const peer = pluginPkg.peerDependencies?.['@switchbot/openapi-cli'];
if (!peer || peer.includes('workspace:')) {
throw new Error(`claude-code plugin peerDependency is not publishable: ${peer ?? '<missing>'}`);
}

for (const requiredPath of [
'.claude-plugin/hooks.json',
'.claude-plugin/marketplace.json',
'bin/auth.js',
'plugins/switchbot/.claude-plugin/hooks.json',
'plugins/switchbot/.claude-plugin/plugin.json',
'plugins/switchbot/.mcp.json',
]) {
const fullPath = path.join(pluginRoot, ...requiredPath.split('/'));
if (!existsSync(fullPath)) {
throw new Error(`claude-code plugin tarball missing ${requiredPath}`);
}
}

const marketplace = readJson(path.join(pluginRoot, '.claude-plugin', 'marketplace.json'));
if (marketplace?.name !== 'switchbot') {
throw new Error(`marketplace name must be switchbot, got ${marketplace?.name ?? '<missing>'}`);
}
const switchbotEntry = marketplace?.plugins?.find((p) => p?.name === 'switchbot');
if (switchbotEntry?.source !== './plugins/switchbot') {
throw new Error(`marketplace switchbot plugin source must be './plugins/switchbot', got ${switchbotEntry?.source ?? '<missing>'}`);
}

const hooks = readJson(path.join(pluginRoot, '.claude-plugin', 'hooks.json'));
if (hooks?.onInstall?.command !== 'node') {
throw new Error(`onInstall command must be 'node', got ${hooks?.onInstall?.command ?? '<missing>'}`);
}
const hookArgs = hooks?.onInstall?.args ?? [];
if (!Array.isArray(hookArgs) || hookArgs.length === 0) {
throw new Error(`onInstall args must be a non-empty array, got ${JSON.stringify(hookArgs)}`);
}
const hookScript = path.resolve(path.join(pluginRoot, '.claude-plugin'), hookArgs[0]);
if (!existsSync(hookScript)) {
throw new Error(`onInstall args[0] resolves to a non-existent file: ${hookScript}`);
}

// Verify auth.js syntax is valid
execFileSync(process.execPath, ['--check', path.join(pluginRoot, 'bin', 'auth.js')], {
encoding: 'utf-8',
});

// Verify auth.js exports makeRunOnInstall and behaves correctly with mock deps
const { makeRunOnInstall } = await import(
pathToFileURL(path.join(pluginRoot, 'bin', 'auth.js')).href
);
const runOnInstall = makeRunOnInstall({
checkCli: async () => ({ ok: false, message: 'CLI not found' }),
checkCredentials: async () => ({ ok: false, message: 'No credentials' }),
runInherit: async () => 0,
});
const exitCode = await runOnInstall();
if (exitCode !== 1) {
throw new Error(`makeRunOnInstall with missing CLI must return 1 (non-blocking), got ${exitCode}`);
}

console.log('claude-code pack-install smoke ok: tarballs install, required files present, marketplace and hooks valid, auth.js non-blocking');
} finally {
for (const tarball of packed) {
rmSync(tarball, { force: true });
}
rmSync(workDir, { recursive: true, force: true });
}
Loading