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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ npx @switchbot/openapi-cli codex setup
Then restart Codex and confirm it's working.
```

`codex setup` checks the npm registry for the latest CLI version and upgrades automatically if your global install is outdated — no manual `npm install -g` step needed.

**Or run directly (if CLI is already installed):**

```bash
Expand Down
10 changes: 6 additions & 4 deletions packages/codex-plugin/setup/check-credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,12 @@ async function tryDoctor(exec) {
}
}

async function tryKeychainDescribe(exec) {
async function tryKeychainGet(exec) {
try {
await exec('switchbot', ['auth', 'keychain', 'describe', '--json'], { timeout: 8000 });
return true;
const { stdout } = await exec('switchbot', ['auth', 'keychain', 'get', '--json'], { timeout: 8000 });
const parsed = JSON.parse(stdout);
const data = parsed?.data ?? parsed;
return data?.present === true;
} catch {
return false;
}
Expand All @@ -104,7 +106,7 @@ export function makeCheckCredentials(exec) {
// CLI missing — fall through to keychain
}

const hasKeychainCredentials = await tryKeychainDescribe(exec);
const hasKeychainCredentials = await tryKeychainGet(exec);

if (doctorResult?.reason === 'doctor-failed') {
const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials);
Expand Down
8 changes: 8 additions & 0 deletions packages/codex-plugin/skills/switchbot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ CLI doesn't know about it, refuse and explain — don't paper over it.

---

## Network requirements

`codex setup` requires outbound internet access (npm registry + GitHub). Codex workspaces are offline by default. If setup reports a network error or the `check-network` step warns:

→ Read `references/codex-network.md` for the exact `~/.codex/config.toml` fix.

---

## Required bootstrap (run this first, every session)

Before you take any action, establish context:
Expand Down
34 changes: 34 additions & 0 deletions packages/codex-plugin/skills/switchbot/references/codex-network.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Codex network access for SwitchBot setup

Read this file when `switchbot codex setup` fails with a network error, or when the user asks why setup is failing or how to enable network access in Codex.

## Why network access is required

`switchbot codex setup` performs three network operations:

1. **npm registry probe** — checks for the latest `@switchbot/openapi-cli` version
2. **npm install -g** — installs or upgrades the CLI if outdated
3. **codex plugin marketplace add** — clones the plugin from GitHub

All three require outbound internet access. Codex workspaces are offline by default.

## How to enable network access in Codex

Add the following to `~/.codex/config.toml` (create the file if it does not exist):

```toml
[sandbox_workspace_write]
network_access = true
```

Then **restart Codex** and re-run setup:

```
switchbot codex setup
```

## Notes

- `network_access = true` enables outbound internet for `workspace-write` sandbox mode only.
- It does **not** reduce approval prompts on its own. Set `approval_policy = "on-request"` separately if you want fewer prompts.
- If setup still fails after enabling network, run `switchbot codex doctor` to see which checks are failing.
6 changes: 3 additions & 3 deletions packages/codex-plugin/tests/setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('checkCredentials', () => {
err.stderr = 'HTTP 401 unauthorized';
throw err;
}
if (args.includes('describe')) return { stdout: '{}' };
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
Expand All @@ -93,7 +93,7 @@ describe('checkCredentials', () => {
err.stderr = 'connect ETIMEDOUT api.switch-bot.com';
throw err;
}
if (args.includes('describe')) return { stdout: '{}' };
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
Expand Down Expand Up @@ -135,7 +135,7 @@ describe('checkCredentials', () => {
if (args.includes('doctor')) {
return { stdout: JSON.stringify({ data: { credentials: { configured: false } } }) };
}
if (args.includes('describe')) return { stdout: '{}' };
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
throw new Error('unexpected');
};
const check = makeCheckCredentials(fakeExec);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export const COMMAND_META: Record<string, CommandMeta> = {
'status-sync start': ACTION_LOCAL,
'status-sync stop': ACTION_LOCAL,
'status-sync status': READ_LOCAL,
'reset': ACTION_LOCAL,
'reset': DESTRUCTIVE_LOCAL,
'codex doctor': READ_LOCAL,
'codex repair': ACTION_LOCAL,
'codex setup': ACTION_LOCAL,
Expand Down
133 changes: 121 additions & 12 deletions src/commands/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,47 @@ import {
import { isJsonMode, printJson } from '../utils/output.js';
import { getActiveProfile } from '../lib/request-context.js';
import { getConfigPath } from '../utils/flags.js';
import { VERSION } from '../version.js';

export function compareVersions(a: string, b: string): -1 | 0 | 1 {
// Strip pre-release/build metadata (e.g. '3.8.0-rc.1+build' → '3.8.0')
const core = (v: string) => (v.split(/[-+]/)[0] ?? v).split('.').map(Number);
const pa = core(a);
const pb = core(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na < nb) return -1;
if (na > nb) return 1;
}
return 0;
}

function fetchLatestPublishedVersion(packageName: string): { version: string; fromRegistry: boolean } {
const r = spawnSync(
'npm', ['view', packageName, 'version'],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 8000 },
);
if ((r.status ?? 1) === 0) {
const v = (r.stdout ?? '').trim();
if (/^\d+\.\d+\.\d+/.test(v)) return { version: v, fromRegistry: true };
}
// Offline or registry error: fall back to the running binary's own version.
// When invoked via npx, VERSION == latest, so the comparison still works.
return { version: VERSION, fromRegistry: false };
}

const CODEX_BASE_SECTIONS = ['node', 'path', 'credentials', 'mcp'] as const;
const SWITCHBOT_CLI_PACKAGE = '@switchbot/openapi-cli';

async function runAllCodexDoctorChecks(): Promise<Check[]> {
const base = await runDoctorChecks(CODEX_BASE_SECTIONS);
const base = (await runDoctorChecks(CODEX_BASE_SECTIONS)) ?? [];
const codexChecks: Check[] = [
checkCodexCli(),
checkCodexPluginNpm(),
checkCodexPluginRegistered(),
];
].filter(Boolean) as Check[];
return [...base, ...codexChecks];
}

Expand Down Expand Up @@ -81,7 +111,7 @@ function buildAuthLoginArgv(profile: string, configPath?: string): string[] {

interface StepOutcome {
step: string;
status: 'ok' | 'skipped' | 'failed';
status: 'ok' | 'skipped' | 'failed' | 'warn';
message?: string;
}

Expand Down Expand Up @@ -304,12 +334,14 @@ function registerCodexRepairSubcommand(codex: Command): void {
const { outcomes, anyFailed, preflightFailed } = await runRepair(skip, ctx);

if (isJsonMode()) {
printJson({ ok: !anyFailed, preflightFailed, outcomes });
const anyWarn = outcomes.some((o) => o.status === 'warn');
printJson({ ok: !anyFailed, hasWarnings: anyWarn, preflightFailed, outcomes });
} else {
for (const o of outcomes) {
const icon =
o.status === 'ok' ? chalk.green('✓') :
o.status === 'skipped' ? chalk.dim('·') :
o.status === 'warn' ? chalk.yellow('⚠') :
chalk.red('✗');
console.log(`${icon} ${o.step.padEnd(18)} ${o.message ?? ''}`);
}
Expand Down Expand Up @@ -342,12 +374,34 @@ type SetupOutcome = StepOutcome;

const SETUP_STEPS: readonly StepDef[] = [
{ name: 'check-codex-cli', description: 'Verify codex CLI on PATH', skippable: false },
{ name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing', skippable: true },
{ name: 'check-network', description: 'Probe npm registry; print Codex config hint if offline', skippable: true },
{ name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing or outdated', skippable: true },
{ name: 'register-plugin', description: 'Register plugin (Route B git; npm install + Route A on fallback)', skippable: false },
{ name: 'auth', description: 'Verify credentials; spawn auth login if missing', skippable: true },
{ name: 'doctor-verify', description: 'Run 4 base + 3 Codex checks and report health', skippable: false },
];

function setupStepCheckNetwork(): SetupOutcome {
const r = spawnSync(
'npm', ['ping'],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 5000 },
);
if ((r.status ?? 1) === 0) {
return { step: 'check-network', status: 'ok', message: 'npm registry reachable' };
}
return {
step: 'check-network',
status: 'warn',
message: [
'npm registry unreachable — install and plugin registration require network access.',
'To enable network in Codex, add to ~/.codex/config.toml:',
' [sandbox_workspace_write]',
' network_access = true',
'Then restart Codex and re-run: switchbot codex setup',
].join('\n'),
};
}

function setupStepCheckCodexCli(): SetupOutcome {
const c = checkCodexCli();
if (c.status === 'fail') {
Expand All @@ -370,19 +424,56 @@ function setupStepInstallSwitchbotCli(): SetupOutcome {
);
}

function resolveInstalledVersion(packageName: string): string | null {
const r = spawnSync(
'npm', ['list', '-g', '--json', '--depth=0', packageName],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 },
);
try {
const parsed = JSON.parse(r.stdout ?? '{}') as {
dependencies?: Record<string, { version?: string }>;
};
return parsed?.dependencies?.[packageName]?.version ?? null;
} catch {
return null;
}
}

function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome {
const { version: latestVersion, fromRegistry } = fetchLatestPublishedVersion(packageName);
const registryNote = fromRegistry ? '' : ' (registry unreachable, used running version as reference)';

const list = spawnSync(
'npm', ['list', '-g', '--json', '--depth=0', packageName],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 },
);
let installed = false;
let installedVersion: string | null = null;
try {
const parsed = JSON.parse(list.stdout ?? '{}') as { dependencies?: Record<string, unknown> };
installed = Boolean(parsed?.dependencies?.[packageName]);
const parsed = JSON.parse(list.stdout ?? '{}') as {
dependencies?: Record<string, { version?: string }>;
};
installedVersion = parsed?.dependencies?.[packageName]?.version ?? null;
} catch { /* treat as not installed */ }
if (installed) {
return { step, status: 'ok', message: 'already installed' };

if (installedVersion !== null) {
if (compareVersions(installedVersion, latestVersion) >= 0) {
return { step, status: 'ok', message: `already installed (${installedVersion})${registryNote}` };
}
const upg = spawnSync(
'npm', ['install', '-g', `${packageName}@latest`],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 },
);
if ((upg.status ?? 1) !== 0) {
return {
step,
status: 'failed',
message: `npm install -g failed upgrading from ${installedVersion} (exit ${upg.status ?? 1}): ${upg.stderr ?? ''}`,
};
}
const newVersion = resolveInstalledVersion(packageName) ?? latestVersion;
return { step, status: 'ok', message: `upgraded ${installedVersion} → ${newVersion}` };
}

const inst = spawnSync(
'npm', ['install', '-g', `${packageName}@latest`],
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 },
Expand All @@ -394,7 +485,8 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup
message: `npm install -g failed (exit ${inst.status ?? 1}): ${inst.stderr ?? ''}`,
};
}
return { step, status: 'ok', message: `installed ${packageName}@latest` };
const installedNow = resolveInstalledVersion(packageName) ?? latestVersion;
return { step, status: 'ok', message: `installed ${packageName}@${installedNow}` };
}

function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome {
Expand Down Expand Up @@ -447,14 +539,26 @@ async function runSetup(
): Promise<{ outcomes: SetupOutcome[]; anyFailed: boolean; preflightFailed: boolean }> {
const outcomes: SetupOutcome[] = [];
let preflightFailed = false;
let networkOffline = false;

for (const step of SETUP_STEPS) {
// Auto-skip network-dependent steps when check-network warned
if (step.name === 'install-switchbot-cli' && networkOffline && !skip.has(step.name)) {
outcomes.push({
step: step.name,
status: 'skipped',
message: 'skipped: npm registry unreachable (see check-network warning above)',
});
continue;
}

if (skip.has(step.name)) {
outcomes.push({ step: step.name, status: 'skipped' });
continue;
}
let outcome: SetupOutcome;
if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli();
else if (step.name === 'check-network') outcome = setupStepCheckNetwork();
else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli();
else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx);
else if (step.name === 'auth') outcome = await setupStepAuth(ctx);
Expand All @@ -464,6 +568,9 @@ async function runSetup(
preflightFailed = true;
break;
}
if (step.name === 'check-network' && outcome.status === 'warn') {
networkOffline = true;
}
}
const anyFailed = outcomes.some((o) => o.status === 'failed');
return { outcomes, anyFailed, preflightFailed };
Expand Down Expand Up @@ -533,12 +640,14 @@ Environment variables:
const { outcomes, anyFailed, preflightFailed } = await runSetup(skip, ctx);

if (isJsonMode()) {
printJson({ ok: !anyFailed, preflightFailed, outcomes });
const anyWarn = outcomes.some((o) => o.status === 'warn');
printJson({ ok: !anyFailed, hasWarnings: anyWarn, preflightFailed, outcomes });
} else {
for (const o of outcomes) {
const icon =
o.status === 'ok' ? chalk.green('✓') :
o.status === 'skipped' ? chalk.dim('·') :
o.status === 'warn' ? chalk.yellow('⚠') :
chalk.red('✗');
console.log(`${icon} ${o.step.padEnd(22)} ${o.message ?? ''}`);
}
Expand Down
Loading
Loading