diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e52711..c2e2d12c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 6047403a..c689a2f3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "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", @@ -61,7 +62,7 @@ "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" diff --git a/packages/claude-code-plugin/package.json b/packages/claude-code-plugin/package.json index 252f2277..045cd53d 100644 --- a/packages/claude-code-plugin/package.json +++ b/packages/claude-code-plugin/package.json @@ -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" } } diff --git a/packages/claude-code-plugin/setup/check-credentials.js b/packages/claude-code-plugin/setup/check-credentials.js index 6b9ca624..06a4e9ad 100644 --- a/packages/claude-code-plugin/setup/check-credentials.js +++ b/packages/claude-code-plugin/setup/check-credentials.js @@ -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) }; } diff --git a/packages/claude-code-plugin/tests/setup.test.js b/packages/claude-code-plugin/tests/setup.test.js index 2831e488..9a882e47 100644 --- a/packages/claude-code-plugin/tests/setup.test.js +++ b/packages/claude-code-plugin/tests/setup.test.js @@ -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'); @@ -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); diff --git a/packages/codex-plugin/setup/check-credentials.js b/packages/codex-plugin/setup/check-credentials.js index 802b4f85..18487e7e 100644 --- a/packages/codex-plugin/setup/check-credentials.js +++ b/packages/codex-plugin/setup/check-credentials.js @@ -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) }; } @@ -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); diff --git a/packages/codex-plugin/tests/setup.test.js b/packages/codex-plugin/tests/setup.test.js index 2831e488..9a882e47 100644 --- a/packages/codex-plugin/tests/setup.test.js +++ b/packages/codex-plugin/tests/setup.test.js @@ -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'); @@ -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); diff --git a/scripts/smoke-claude-code-pack-install.mjs b/scripts/smoke-claude-code-pack-install.mjs new file mode 100644 index 00000000..d2717ae3 --- /dev/null +++ b/scripts/smoke-claude-code-pack-install.mjs @@ -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 ?? ''}`); + } + + 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 ?? ''}`); + } + 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 ?? ''}`); + } + + 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 ?? ''}`); + } + 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 }); +}