Skip to content

Commit 2cf2e20

Browse files
chenliuyunchenliuyunclaude
authored
feat(codex): codex setup auto-upgrade — version check, network detection, offline guidance (#55)
* feat(codex): auto-upgrade outdated CLI by checking npm registry in codex setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document codex setup auto-upgrade via npm registry check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(codex): surface registry-unavailability in setup output, report resolved version on fresh install Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(codex-plugin): add network-access reference + SKILL.md hook for Codex sandbox guidance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(codex): add check-network step with Codex config.toml hint when offline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(codex): strip pre-release in compareVersions, relax version regex to accept semver pre-release - export compareVersions and strip pre-release/build metadata before numeric comparison so '3.7.3' vs '3.8.0-rc.1' correctly returns -1 - remove '$' anchor from fetchLatestPublishedVersion regex so pre-release versions from the npm registry are accepted instead of silently falling back to VERSION - add 6 unit tests covering the fixed cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(codex): auto-skip install when offline, repair warn renderer, hasWarnings JSON field, remove dead null guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(codex): re-verify installed version after npm install-g to report accurate version in success message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: check actual keychain credentials via get --json, mark reset as DESTRUCTIVE_LOCAL - check-credentials.js: replace tryKeychainDescribe (auth keychain describe --json) with tryKeychainGet (auth keychain get --json, inspects present===true) so a fresh-install machine with no stored token is no longer falsely reported as authenticated - capabilities.ts: reclassify 'reset' from ACTION_LOCAL to DESTRUCTIVE_LOCAL since it permanently removes credentials, config, audit logs, quota data, history, and caches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(codex-plugin): update keychain mocks from describe to get --json with present field --------- Co-authored-by: chenliuyun <chenliuyun@onero.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9079ae2 commit 2cf2e20

8 files changed

Lines changed: 470 additions & 32 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ npx @switchbot/openapi-cli codex setup
7777
Then restart Codex and confirm it's working.
7878
```
7979

80+
`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.
81+
8082
**Or run directly (if CLI is already installed):**
8183

8284
```bash

packages/codex-plugin/setup/check-credentials.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,12 @@ async function tryDoctor(exec) {
8585
}
8686
}
8787

88-
async function tryKeychainDescribe(exec) {
88+
async function tryKeychainGet(exec) {
8989
try {
90-
await exec('switchbot', ['auth', 'keychain', 'describe', '--json'], { timeout: 8000 });
91-
return true;
90+
const { stdout } = await exec('switchbot', ['auth', 'keychain', 'get', '--json'], { timeout: 8000 });
91+
const parsed = JSON.parse(stdout);
92+
const data = parsed?.data ?? parsed;
93+
return data?.present === true;
9294
} catch {
9395
return false;
9496
}
@@ -104,7 +106,7 @@ export function makeCheckCredentials(exec) {
104106
// CLI missing — fall through to keychain
105107
}
106108

107-
const hasKeychainCredentials = await tryKeychainDescribe(exec);
109+
const hasKeychainCredentials = await tryKeychainGet(exec);
108110

109111
if (doctorResult?.reason === 'doctor-failed') {
110112
const errorKey = classifyDoctorFailure(doctorResult.detail ?? '', hasKeychainCredentials);

packages/codex-plugin/skills/switchbot/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ CLI doesn't know about it, refuse and explain — don't paper over it.
5050

5151
---
5252

53+
## Network requirements
54+
55+
`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:
56+
57+
→ Read `references/codex-network.md` for the exact `~/.codex/config.toml` fix.
58+
59+
---
60+
5361
## Required bootstrap (run this first, every session)
5462

5563
Before you take any action, establish context:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Codex network access for SwitchBot setup
2+
3+
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.
4+
5+
## Why network access is required
6+
7+
`switchbot codex setup` performs three network operations:
8+
9+
1. **npm registry probe** — checks for the latest `@switchbot/openapi-cli` version
10+
2. **npm install -g** — installs or upgrades the CLI if outdated
11+
3. **codex plugin marketplace add** — clones the plugin from GitHub
12+
13+
All three require outbound internet access. Codex workspaces are offline by default.
14+
15+
## How to enable network access in Codex
16+
17+
Add the following to `~/.codex/config.toml` (create the file if it does not exist):
18+
19+
```toml
20+
[sandbox_workspace_write]
21+
network_access = true
22+
```
23+
24+
Then **restart Codex** and re-run setup:
25+
26+
```
27+
switchbot codex setup
28+
```
29+
30+
## Notes
31+
32+
- `network_access = true` enables outbound internet for `workspace-write` sandbox mode only.
33+
- It does **not** reduce approval prompts on its own. Set `approval_policy = "on-request"` separately if you want fewer prompts.
34+
- If setup still fails after enabling network, run `switchbot codex doctor` to see which checks are failing.

packages/codex-plugin/tests/setup.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('checkCredentials', () => {
7575
err.stderr = 'HTTP 401 unauthorized';
7676
throw err;
7777
}
78-
if (args.includes('describe')) return { stdout: '{}' };
78+
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
7979
throw new Error('unexpected');
8080
};
8181
const check = makeCheckCredentials(fakeExec);
@@ -93,7 +93,7 @@ describe('checkCredentials', () => {
9393
err.stderr = 'connect ETIMEDOUT api.switch-bot.com';
9494
throw err;
9595
}
96-
if (args.includes('describe')) return { stdout: '{}' };
96+
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
9797
throw new Error('unexpected');
9898
};
9999
const check = makeCheckCredentials(fakeExec);
@@ -135,7 +135,7 @@ describe('checkCredentials', () => {
135135
if (args.includes('doctor')) {
136136
return { stdout: JSON.stringify({ data: { credentials: { configured: false } } }) };
137137
}
138-
if (args.includes('describe')) return { stdout: '{}' };
138+
if (args.includes('get')) return { stdout: JSON.stringify({ data: { present: true } }) };
139139
throw new Error('unexpected');
140140
};
141141
const check = makeCheckCredentials(fakeExec);

src/commands/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export const COMMAND_META: Record<string, CommandMeta> = {
214214
'status-sync start': ACTION_LOCAL,
215215
'status-sync stop': ACTION_LOCAL,
216216
'status-sync status': READ_LOCAL,
217-
'reset': ACTION_LOCAL,
217+
'reset': DESTRUCTIVE_LOCAL,
218218
'codex doctor': READ_LOCAL,
219219
'codex repair': ACTION_LOCAL,
220220
'codex setup': ACTION_LOCAL,

src/commands/codex.ts

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,47 @@ import {
1515
import { isJsonMode, printJson } from '../utils/output.js';
1616
import { getActiveProfile } from '../lib/request-context.js';
1717
import { getConfigPath } from '../utils/flags.js';
18+
import { VERSION } from '../version.js';
19+
20+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
21+
// Strip pre-release/build metadata (e.g. '3.8.0-rc.1+build' → '3.8.0')
22+
const core = (v: string) => (v.split(/[-+]/)[0] ?? v).split('.').map(Number);
23+
const pa = core(a);
24+
const pb = core(b);
25+
const len = Math.max(pa.length, pb.length);
26+
for (let i = 0; i < len; i++) {
27+
const na = pa[i] ?? 0;
28+
const nb = pb[i] ?? 0;
29+
if (na < nb) return -1;
30+
if (na > nb) return 1;
31+
}
32+
return 0;
33+
}
34+
35+
function fetchLatestPublishedVersion(packageName: string): { version: string; fromRegistry: boolean } {
36+
const r = spawnSync(
37+
'npm', ['view', packageName, 'version'],
38+
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 8000 },
39+
);
40+
if ((r.status ?? 1) === 0) {
41+
const v = (r.stdout ?? '').trim();
42+
if (/^\d+\.\d+\.\d+/.test(v)) return { version: v, fromRegistry: true };
43+
}
44+
// Offline or registry error: fall back to the running binary's own version.
45+
// When invoked via npx, VERSION == latest, so the comparison still works.
46+
return { version: VERSION, fromRegistry: false };
47+
}
1848

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

2252
async function runAllCodexDoctorChecks(): Promise<Check[]> {
23-
const base = await runDoctorChecks(CODEX_BASE_SECTIONS);
53+
const base = (await runDoctorChecks(CODEX_BASE_SECTIONS)) ?? [];
2454
const codexChecks: Check[] = [
2555
checkCodexCli(),
2656
checkCodexPluginNpm(),
2757
checkCodexPluginRegistered(),
28-
];
58+
].filter(Boolean) as Check[];
2959
return [...base, ...codexChecks];
3060
}
3161

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

82112
interface StepOutcome {
83113
step: string;
84-
status: 'ok' | 'skipped' | 'failed';
114+
status: 'ok' | 'skipped' | 'failed' | 'warn';
85115
message?: string;
86116
}
87117

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

306336
if (isJsonMode()) {
307-
printJson({ ok: !anyFailed, preflightFailed, outcomes });
337+
const anyWarn = outcomes.some((o) => o.status === 'warn');
338+
printJson({ ok: !anyFailed, hasWarnings: anyWarn, preflightFailed, outcomes });
308339
} else {
309340
for (const o of outcomes) {
310341
const icon =
311342
o.status === 'ok' ? chalk.green('✓') :
312343
o.status === 'skipped' ? chalk.dim('·') :
344+
o.status === 'warn' ? chalk.yellow('⚠') :
313345
chalk.red('✗');
314346
console.log(`${icon} ${o.step.padEnd(18)} ${o.message ?? ''}`);
315347
}
@@ -342,12 +374,34 @@ type SetupOutcome = StepOutcome;
342374

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

384+
function setupStepCheckNetwork(): SetupOutcome {
385+
const r = spawnSync(
386+
'npm', ['ping'],
387+
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 5000 },
388+
);
389+
if ((r.status ?? 1) === 0) {
390+
return { step: 'check-network', status: 'ok', message: 'npm registry reachable' };
391+
}
392+
return {
393+
step: 'check-network',
394+
status: 'warn',
395+
message: [
396+
'npm registry unreachable — install and plugin registration require network access.',
397+
'To enable network in Codex, add to ~/.codex/config.toml:',
398+
' [sandbox_workspace_write]',
399+
' network_access = true',
400+
'Then restart Codex and re-run: switchbot codex setup',
401+
].join('\n'),
402+
};
403+
}
404+
351405
function setupStepCheckCodexCli(): SetupOutcome {
352406
const c = checkCodexCli();
353407
if (c.status === 'fail') {
@@ -370,19 +424,56 @@ function setupStepInstallSwitchbotCli(): SetupOutcome {
370424
);
371425
}
372426

427+
function resolveInstalledVersion(packageName: string): string | null {
428+
const r = spawnSync(
429+
'npm', ['list', '-g', '--json', '--depth=0', packageName],
430+
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 },
431+
);
432+
try {
433+
const parsed = JSON.parse(r.stdout ?? '{}') as {
434+
dependencies?: Record<string, { version?: string }>;
435+
};
436+
return parsed?.dependencies?.[packageName]?.version ?? null;
437+
} catch {
438+
return null;
439+
}
440+
}
441+
373442
function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome {
443+
const { version: latestVersion, fromRegistry } = fetchLatestPublishedVersion(packageName);
444+
const registryNote = fromRegistry ? '' : ' (registry unreachable, used running version as reference)';
445+
374446
const list = spawnSync(
375447
'npm', ['list', '-g', '--json', '--depth=0', packageName],
376448
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 },
377449
);
378-
let installed = false;
450+
let installedVersion: string | null = null;
379451
try {
380-
const parsed = JSON.parse(list.stdout ?? '{}') as { dependencies?: Record<string, unknown> };
381-
installed = Boolean(parsed?.dependencies?.[packageName]);
452+
const parsed = JSON.parse(list.stdout ?? '{}') as {
453+
dependencies?: Record<string, { version?: string }>;
454+
};
455+
installedVersion = parsed?.dependencies?.[packageName]?.version ?? null;
382456
} catch { /* treat as not installed */ }
383-
if (installed) {
384-
return { step, status: 'ok', message: 'already installed' };
457+
458+
if (installedVersion !== null) {
459+
if (compareVersions(installedVersion, latestVersion) >= 0) {
460+
return { step, status: 'ok', message: `already installed (${installedVersion})${registryNote}` };
461+
}
462+
const upg = spawnSync(
463+
'npm', ['install', '-g', `${packageName}@latest`],
464+
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 },
465+
);
466+
if ((upg.status ?? 1) !== 0) {
467+
return {
468+
step,
469+
status: 'failed',
470+
message: `npm install -g failed upgrading from ${installedVersion} (exit ${upg.status ?? 1}): ${upg.stderr ?? ''}`,
471+
};
472+
}
473+
const newVersion = resolveInstalledVersion(packageName) ?? latestVersion;
474+
return { step, status: 'ok', message: `upgraded ${installedVersion}${newVersion}` };
385475
}
476+
386477
const inst = spawnSync(
387478
'npm', ['install', '-g', `${packageName}@latest`],
388479
{ encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 },
@@ -394,7 +485,8 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup
394485
message: `npm install -g failed (exit ${inst.status ?? 1}): ${inst.stderr ?? ''}`,
395486
};
396487
}
397-
return { step, status: 'ok', message: `installed ${packageName}@latest` };
488+
const installedNow = resolveInstalledVersion(packageName) ?? latestVersion;
489+
return { step, status: 'ok', message: `installed ${packageName}@${installedNow}` };
398490
}
399491

400492
function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome {
@@ -447,14 +539,26 @@ async function runSetup(
447539
): Promise<{ outcomes: SetupOutcome[]; anyFailed: boolean; preflightFailed: boolean }> {
448540
const outcomes: SetupOutcome[] = [];
449541
let preflightFailed = false;
542+
let networkOffline = false;
450543

451544
for (const step of SETUP_STEPS) {
545+
// Auto-skip network-dependent steps when check-network warned
546+
if (step.name === 'install-switchbot-cli' && networkOffline && !skip.has(step.name)) {
547+
outcomes.push({
548+
step: step.name,
549+
status: 'skipped',
550+
message: 'skipped: npm registry unreachable (see check-network warning above)',
551+
});
552+
continue;
553+
}
554+
452555
if (skip.has(step.name)) {
453556
outcomes.push({ step: step.name, status: 'skipped' });
454557
continue;
455558
}
456559
let outcome: SetupOutcome;
457560
if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli();
561+
else if (step.name === 'check-network') outcome = setupStepCheckNetwork();
458562
else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli();
459563
else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx);
460564
else if (step.name === 'auth') outcome = await setupStepAuth(ctx);
@@ -464,6 +568,9 @@ async function runSetup(
464568
preflightFailed = true;
465569
break;
466570
}
571+
if (step.name === 'check-network' && outcome.status === 'warn') {
572+
networkOffline = true;
573+
}
467574
}
468575
const anyFailed = outcomes.some((o) => o.status === 'failed');
469576
return { outcomes, anyFailed, preflightFailed };
@@ -533,12 +640,14 @@ Environment variables:
533640
const { outcomes, anyFailed, preflightFailed } = await runSetup(skip, ctx);
534641

535642
if (isJsonMode()) {
536-
printJson({ ok: !anyFailed, preflightFailed, outcomes });
643+
const anyWarn = outcomes.some((o) => o.status === 'warn');
644+
printJson({ ok: !anyFailed, hasWarnings: anyWarn, preflightFailed, outcomes });
537645
} else {
538646
for (const o of outcomes) {
539647
const icon =
540648
o.status === 'ok' ? chalk.green('✓') :
541649
o.status === 'skipped' ? chalk.dim('·') :
650+
o.status === 'warn' ? chalk.yellow('⚠') :
542651
chalk.red('✗');
543652
console.log(`${icon} ${o.step.padEnd(22)} ${o.message ?? ''}`);
544653
}

0 commit comments

Comments
 (0)