@switchbot/openapi-cli 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { Command, CommanderError } from 'commander';
2
+ import { Command, CommanderError, InvalidArgumentError } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
5
+ import { parseDurationToMs } from './utils/flags.js';
4
6
  import { registerConfigCommand } from './commands/config.js';
5
7
  import { registerDevicesCommand } from './commands/devices.js';
6
8
  import { registerScenesCommand } from './commands/scenes.js';
@@ -19,25 +21,44 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
19
21
  const require = createRequire(import.meta.url);
20
22
  const { version: pkgVersion } = require('../package.json');
21
23
  const program = new Command();
24
+ // Top-level subcommand names. Used by stringArg to produce clearer errors when
25
+ // a value is omitted and the next argv token turns out to be a subcommand name.
26
+ const TOP_LEVEL_COMMANDS = [
27
+ 'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
28
+ 'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
29
+ 'history', 'plan', 'capabilities',
30
+ ];
31
+ const cacheModeArg = (value) => {
32
+ if (value.startsWith('-')) {
33
+ throw new InvalidArgumentError(`--cache requires a mode value, got "${value}". ` +
34
+ `Valid: "off", "auto", or a duration like "5m", "1h". Use --cache=<mode> if needed.`);
35
+ }
36
+ if (value === 'off' || value === 'auto')
37
+ return value;
38
+ if (parseDurationToMs(value) !== null)
39
+ return value;
40
+ throw new InvalidArgumentError(`--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`);
41
+ };
22
42
  program
23
43
  .name('switchbot')
24
44
  .description('Command-line tool for SwitchBot API v1.1')
25
45
  .version(pkgVersion)
26
46
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
27
- .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id')
28
- .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)')
47
+ .option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
48
+ .option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
29
49
  .option('-v, --verbose', 'Log HTTP request/response details to stderr')
30
50
  .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
31
- .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)')
32
- .option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)')
33
- .option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)')
51
+ .option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
52
+ .option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)', intArg('--retry-on-429', { min: 0 }))
53
+ .option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)', enumArg('--backoff', ['linear', 'exponential']))
34
54
  .option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)')
35
55
  .option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run')
36
- .option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)')
56
+ .option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)', cacheModeArg)
37
57
  .option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
38
- .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
39
- .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
40
- .option('--audit-log [path]', 'Append every mutating command to JSONL audit log (default ~/.switchbot/audit.log)')
58
+ .option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)', stringArg('--config', { disallow: TOP_LEVEL_COMMANDS }))
59
+ .option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json', stringArg('--profile', { disallow: TOP_LEVEL_COMMANDS }))
60
+ .option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
61
+ .option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log', stringArg('--audit-log-path', { disallow: TOP_LEVEL_COMMANDS }))
41
62
  .showHelpAfterError('(run with --help to see usage)')
42
63
  .showSuggestionAfterError();
43
64
  registerConfigCommand(program);
@@ -94,24 +115,33 @@ Discovery:
94
115
 
95
116
  Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
96
117
  `);
97
- // Map commander usage errors (unknown option, missing argument, etc.) to exit code 2.
98
- program.exitOverride((err) => {
99
- // --help and --version print to stdout and exit 0
118
+ // Map commander usage errors (unknown option, missing argument, argParser
119
+ // InvalidArgumentError, etc.) to exit code 2. Commander's exitOverride is
120
+ // per-command: subcommand errors won't bubble to the root override, so walk
121
+ // every registered command and apply the same handler.
122
+ const usageExitHandler = (err) => {
100
123
  if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
101
124
  process.exit(0);
102
125
  }
103
- // Everything else from commander (unknown option, missing argument,
104
- // invalid choice, conflicting options, unknown command) is a usage error.
105
126
  process.exit(2);
106
- });
127
+ };
128
+ function applyExitOverride(cmd) {
129
+ cmd.exitOverride(usageExitHandler);
130
+ cmd.commands.forEach(applyExitOverride);
131
+ }
132
+ applyExitOverride(program);
107
133
  try {
108
134
  await program.parseAsync();
109
135
  }
110
136
  catch (err) {
111
- // exitOverride already handled CommanderErrors; anything that escapes is a
112
- // runtime error (should be rare since actions use handleError).
137
+ // Subcommand-level CommanderErrors (e.g. InvalidArgumentError from an
138
+ // argParser on a subcommand option) don't always hit the root exitOverride.
139
+ // Mirror the root mapping so all usage errors surface as exit 2.
113
140
  if (err instanceof CommanderError) {
114
- process.exit(err.exitCode ?? 2);
141
+ if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
142
+ process.exit(0);
143
+ }
144
+ process.exit(2);
115
145
  }
116
146
  throw err;
117
147
  }
@@ -147,9 +147,13 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
147
147
  const spec = builtinCommands.find((c) => c.command === cmd);
148
148
  if (!spec) {
149
149
  const unique = [...new Set(builtinCommands.map((c) => c.command))];
150
+ const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
151
+ const hint = caseMatch
152
+ ? `Did you mean "${caseMatch}"? Supported commands: ${unique.join(', ')}`
153
+ : `Supported commands: ${unique.join(', ')}`;
150
154
  return {
151
155
  ok: false,
152
- error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', `Supported commands: ${unique.join(', ')}`),
156
+ error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
153
157
  };
154
158
  }
155
159
  const noParamExpected = spec.parameter === '—';
@@ -160,6 +164,17 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
160
164
  error: new CommandValidationError(`"${cmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${cmd}`),
161
165
  };
162
166
  }
167
+ // Warn when a parameter is required but the user omitted it
168
+ const paramRequired = !noParamExpected && spec.parameter !== 'default';
169
+ if (paramRequired && !userProvidedParam) {
170
+ const example = spec.exampleParams?.[0];
171
+ return {
172
+ ok: false,
173
+ error: new CommandValidationError(`"${cmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
174
+ ? `Example: switchbot devices command <deviceId> ${cmd} "${example}"`
175
+ : `See: switchbot devices commands ${cached.type}`),
176
+ };
177
+ }
163
178
  return { ok: true };
164
179
  }
165
180
  /**
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ const MAX_HISTORY = 100;
5
+ function historyDir() {
6
+ return path.join(os.homedir(), '.switchbot', 'device-history');
7
+ }
8
+ export class DeviceHistoryStore {
9
+ dir;
10
+ constructor() {
11
+ this.dir = historyDir();
12
+ }
13
+ record(deviceId, topic, deviceType, payload, t) {
14
+ try {
15
+ if (!fs.existsSync(this.dir))
16
+ fs.mkdirSync(this.dir, { recursive: true });
17
+ const file = path.join(this.dir, `${deviceId}.json`);
18
+ const existing = fs.existsSync(file)
19
+ ? JSON.parse(fs.readFileSync(file, 'utf-8'))
20
+ : { latest: null, history: [] };
21
+ const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
22
+ existing.latest = entry;
23
+ existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
24
+ fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
25
+ }
26
+ catch {
27
+ // best-effort — history loss is non-fatal
28
+ }
29
+ }
30
+ getLatest(deviceId) {
31
+ try {
32
+ const file = path.join(this.dir, `${deviceId}.json`);
33
+ if (!fs.existsSync(file))
34
+ return null;
35
+ return JSON.parse(fs.readFileSync(file, 'utf-8')).latest;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ getHistory(deviceId, limit = 20) {
42
+ try {
43
+ const file = path.join(this.dir, `${deviceId}.json`);
44
+ if (!fs.existsSync(file))
45
+ return [];
46
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
47
+ return data.history.slice(0, Math.min(limit, MAX_HISTORY));
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ listDevices() {
54
+ try {
55
+ if (!fs.existsSync(this.dir))
56
+ return [];
57
+ return fs.readdirSync(this.dir)
58
+ .filter((f) => f.endsWith('.json'))
59
+ .map((f) => f.slice(0, -5));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ }
66
+ export const deviceHistoryStore = new DeviceHistoryStore();
@@ -5,6 +5,7 @@ import { fetchDeviceList } from '../lib/devices.js';
5
5
  import { getCachedDevice } from '../devices/cache.js';
6
6
  import { createClient } from '../api/client.js';
7
7
  import { log } from '../logger.js';
8
+ import { deviceHistoryStore } from './device-history.js';
8
9
  export class EventSubscriptionManager {
9
10
  mqttClient = null;
10
11
  subscribers = new Map();
@@ -35,12 +36,18 @@ export class EventSubscriptionManager {
35
36
  client.onMessage((topic, payload) => {
36
37
  try {
37
38
  const data = JSON.parse(payload.toString());
38
- const deviceId = this.extractDeviceId(topic);
39
- if (deviceId && data.state) {
39
+ // Support SwitchBot direct format: { eventType, context: { deviceMac, deviceType, ... } }
40
+ // and AWS IoT shadow format: $aws/things/<id>/shadow/... with data.state
41
+ const context = data.context;
42
+ const deviceId = context?.deviceMac ?? this.extractDeviceId(topic);
43
+ const payloadData = context ?? data.state;
44
+ const deviceType = String(context?.deviceType ?? 'Unknown');
45
+ if (deviceId && payloadData) {
46
+ deviceHistoryStore.record(deviceId, topic, deviceType, payloadData);
40
47
  this.addEvent({
41
48
  kind: 'shadow.updated',
42
49
  deviceId,
43
- payload: data.state,
50
+ payload: payloadData,
44
51
  timestamp: Date.now(),
45
52
  });
46
53
  }
@@ -17,6 +17,14 @@ export class SwitchBotMqttClient {
17
17
  async connect() {
18
18
  if (this.client && this.state === 'connected')
19
19
  return;
20
+ // Remove stale listeners before replacing the client instance, otherwise
21
+ // the old client's close event fires after the new connection is established
22
+ // (AWS IoT drops the old session), triggering a spurious reconnect loop.
23
+ if (this.client) {
24
+ this.client.removeAllListeners();
25
+ this.client.end(true);
26
+ this.client = null;
27
+ }
20
28
  this.setState('connecting');
21
29
  this.credentialExpired = false;
22
30
  this.reconnectAttempts = 0;
@@ -2,8 +2,9 @@ import crypto from 'node:crypto';
2
2
  import { buildAuthHeaders } from '../auth.js';
3
3
  const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
4
4
  export async function fetchMqttCredential(token, secret) {
5
- // Derive a stable instance ID per token so the server can track this client.
6
- const instanceId = crypto.createHash('sha256').update(token).digest('hex').slice(0, 16);
5
+ // Use a random instanceId so each CLI session gets its own clientId, avoiding
6
+ // conflicts with the SwitchBot cloud service that shares the same account credentials.
7
+ const instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
7
8
  const headers = buildAuthHeaders(token, secret);
8
9
  const res = await fetch(CREDENTIAL_ENDPOINT, {
9
10
  method: 'POST',
@@ -0,0 +1,12 @@
1
+ export class SinkDispatcher {
2
+ sinks;
3
+ constructor(sinks) {
4
+ this.sinks = sinks;
5
+ }
6
+ async dispatch(event) {
7
+ await Promise.allSettled(this.sinks.map((s) => s.write(event)));
8
+ }
9
+ async close() {
10
+ await Promise.allSettled(this.sinks.map((s) => s.close?.()));
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export class FileSink {
4
+ filePath;
5
+ constructor(filePath) {
6
+ this.filePath = path.resolve(filePath);
7
+ const dir = path.dirname(this.filePath);
8
+ if (!fs.existsSync(dir))
9
+ fs.mkdirSync(dir, { recursive: true });
10
+ }
11
+ async write(event) {
12
+ try {
13
+ fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n', { encoding: 'utf-8' });
14
+ }
15
+ catch {
16
+ // best-effort
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,56 @@
1
+ const ICONS = {
2
+ 'Bot': '🤖',
3
+ 'Curtain': '🪟',
4
+ 'Hub': '📡',
5
+ 'Hub 2': '📡',
6
+ 'Hub 3': '📡',
7
+ 'Hub Mini': '📡',
8
+ 'Smart Lock': '🔒',
9
+ 'Smart Lock Pro': '🔒',
10
+ 'Plug': '🔌',
11
+ 'Plug Mini (US)': '🔌',
12
+ 'Plug Mini (JP)': '🔌',
13
+ 'Color Bulb': '💡',
14
+ 'Strip Light': '💡',
15
+ 'Contact Sensor': '🚪',
16
+ 'Motion Sensor': '👁',
17
+ 'Meter': '🌡',
18
+ 'MeterPro': '🌡',
19
+ 'Climate Panel': '🌡',
20
+ 'WoMeter': '🌡',
21
+ 'WoIOSensor': '🌡',
22
+ };
23
+ function icon(deviceType) {
24
+ return ICONS[deviceType] ?? '📱';
25
+ }
26
+ export function formatEventText(context) {
27
+ const type = context.deviceType ?? 'Unknown';
28
+ const pfx = `${icon(type)} ${type}`;
29
+ const parts = [];
30
+ if (context.temperature !== undefined)
31
+ parts.push(`${context.temperature}°C`);
32
+ if (context.humidity !== undefined)
33
+ parts.push(`${context.humidity}%`);
34
+ if (parts.length)
35
+ return `${pfx}: ${parts.join(' / ')}`;
36
+ if (context.power !== undefined)
37
+ return `${pfx}: ${context.power}`;
38
+ if (context.lockState !== undefined)
39
+ return `${pfx}: ${context.lockState}`;
40
+ if (context.openState !== undefined)
41
+ return `${pfx}: ${context.openState}`;
42
+ if (context.detectionState !== undefined)
43
+ return `${pfx}: ${context.detectionState}`;
44
+ if (context.brightness !== undefined)
45
+ return `${pfx}: ${context.brightness}`;
46
+ return `${pfx}: state change`;
47
+ }
48
+ export function parseSinkEvent(payload) {
49
+ const p = payload;
50
+ const context = (p?.context ?? {});
51
+ return {
52
+ deviceId: String(context.deviceMac ?? 'unknown'),
53
+ deviceType: String(context.deviceType ?? 'Unknown'),
54
+ text: formatEventText(context),
55
+ };
56
+ }
@@ -0,0 +1,44 @@
1
+ export class HomeAssistantSink {
2
+ url;
3
+ token;
4
+ webhookId;
5
+ eventType;
6
+ constructor(opts) {
7
+ this.url = opts.url.replace(/\/$/, '');
8
+ this.token = opts.token;
9
+ this.webhookId = opts.webhookId;
10
+ this.eventType = opts.eventType ?? 'switchbot_event';
11
+ }
12
+ async write(event) {
13
+ try {
14
+ let endpoint;
15
+ const headers = { 'content-type': 'application/json' };
16
+ if (this.webhookId) {
17
+ // Webhook mode: no auth needed, HA triggers automations directly
18
+ endpoint = `${this.url}/api/webhook/${this.webhookId}`;
19
+ }
20
+ else if (this.token) {
21
+ // REST event API: fires a custom event on the HA event bus
22
+ endpoint = `${this.url}/api/events/${this.eventType}`;
23
+ headers['authorization'] = `Bearer ${this.token}`;
24
+ }
25
+ else {
26
+ console.error('[homeassistant] requires --ha-webhook-id or --ha-token');
27
+ return;
28
+ }
29
+ const res = await fetch(endpoint, {
30
+ method: 'POST',
31
+ headers,
32
+ body: JSON.stringify(event),
33
+ signal: AbortSignal.timeout(10000),
34
+ });
35
+ if (!res.ok) {
36
+ const body = await res.text().catch(() => '');
37
+ console.error(`[homeassistant] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`);
38
+ }
39
+ }
40
+ catch (err) {
41
+ console.error(`[homeassistant] error: ${err instanceof Error ? err.message : String(err)}`);
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,33 @@
1
+ export class OpenClawSink {
2
+ url;
3
+ token;
4
+ model;
5
+ constructor(opts) {
6
+ this.url = (opts.url ?? 'http://localhost:18789').replace(/\/$/, '');
7
+ this.token = opts.token;
8
+ this.model = opts.model;
9
+ }
10
+ async write(event) {
11
+ try {
12
+ const res = await fetch(`${this.url}/v1/chat/completions`, {
13
+ method: 'POST',
14
+ headers: {
15
+ 'content-type': 'application/json',
16
+ 'authorization': `Bearer ${this.token}`,
17
+ },
18
+ body: JSON.stringify({
19
+ model: this.model,
20
+ messages: [{ role: 'user', content: event.text }],
21
+ }),
22
+ signal: AbortSignal.timeout(10000),
23
+ });
24
+ if (!res.ok) {
25
+ const body = await res.text().catch(() => '');
26
+ console.error(`[openclaw] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`);
27
+ }
28
+ }
29
+ catch (err) {
30
+ console.error(`[openclaw] error: ${err instanceof Error ? err.message : String(err)}`);
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,5 @@
1
+ export class StdoutSink {
2
+ async write(event) {
3
+ console.log(JSON.stringify(event));
4
+ }
5
+ }
@@ -0,0 +1,28 @@
1
+ export class TelegramSink {
2
+ token;
3
+ chatId;
4
+ constructor(opts) {
5
+ this.token = opts.token;
6
+ this.chatId = opts.chatId;
7
+ }
8
+ async write(event) {
9
+ try {
10
+ const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
11
+ method: 'POST',
12
+ headers: { 'content-type': 'application/json' },
13
+ body: JSON.stringify({
14
+ chat_id: this.chatId,
15
+ text: event.text,
16
+ }),
17
+ signal: AbortSignal.timeout(10000),
18
+ });
19
+ if (!res.ok) {
20
+ const body = await res.text().catch(() => '');
21
+ console.error(`[telegram] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`);
22
+ }
23
+ }
24
+ catch (err) {
25
+ console.error(`[telegram] error: ${err instanceof Error ? err.message : String(err)}`);
26
+ }
27
+ }
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ export class WebhookSink {
2
+ url;
3
+ constructor(url) {
4
+ this.url = url;
5
+ }
6
+ async write(event) {
7
+ try {
8
+ const res = await fetch(this.url, {
9
+ method: 'POST',
10
+ headers: { 'content-type': 'application/json' },
11
+ body: JSON.stringify(event),
12
+ signal: AbortSignal.timeout(10000),
13
+ });
14
+ if (!res.ok) {
15
+ console.error(`[webhook] POST failed: HTTP ${res.status}`);
16
+ }
17
+ }
18
+ catch (err) {
19
+ console.error(`[webhook] error: ${err instanceof Error ? err.message : String(err)}`);
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,62 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+ import { parseDurationToMs } from './flags.js';
3
+ /**
4
+ * Commander argParser callbacks that fail fast when a required-value flag
5
+ * swallows the next token (another flag, a subcommand name, etc.) — the
6
+ * default Commander behavior is to take the next argv token verbatim.
7
+ *
8
+ * Use `--flag=<val>` form to pass values that legitimately start with `--`.
9
+ */
10
+ export function intArg(flagName, opts) {
11
+ return (value) => {
12
+ if (value.startsWith('-')) {
13
+ throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
14
+ `Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
15
+ }
16
+ const n = Number(value);
17
+ if (!Number.isInteger(n)) {
18
+ throw new InvalidArgumentError(`${flagName} must be an integer (got "${value}")`);
19
+ }
20
+ if (opts?.min !== undefined && n < opts.min) {
21
+ throw new InvalidArgumentError(`${flagName} must be >= ${opts.min} (got "${value}")`);
22
+ }
23
+ if (opts?.max !== undefined && n > opts.max) {
24
+ throw new InvalidArgumentError(`${flagName} must be <= ${opts.max} (got "${value}")`);
25
+ }
26
+ return String(n);
27
+ };
28
+ }
29
+ export function durationArg(flagName) {
30
+ return (value) => {
31
+ if (value.startsWith('-')) {
32
+ throw new InvalidArgumentError(`${flagName} requires a duration value, got "${value}". ` +
33
+ `Use ${flagName}=<dur> if the value really starts with "-".`);
34
+ }
35
+ const ms = parseDurationToMs(value);
36
+ if (ms === null) {
37
+ throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h" (got "${value}")`);
38
+ }
39
+ return value;
40
+ };
41
+ }
42
+ export function stringArg(flagName, opts) {
43
+ return (value) => {
44
+ if (value.startsWith('--')) {
45
+ throw new InvalidArgumentError(`${flagName} requires a value. "${value}" looks like another option — ` +
46
+ `did you forget the value? Use ${flagName}=<val> if your value really starts with "--".`);
47
+ }
48
+ if (opts?.disallow?.includes(value)) {
49
+ throw new InvalidArgumentError(`${flagName} requires a value but got "${value}", which is a subcommand name. ` +
50
+ `Did you forget the value? Use ${flagName}=<val> or put ${flagName} after the subcommand.`);
51
+ }
52
+ return value;
53
+ };
54
+ }
55
+ export function enumArg(flagName, allowed) {
56
+ return (value) => {
57
+ if (!allowed.includes(value)) {
58
+ throw new InvalidArgumentError(`${flagName} must be one of: ${allowed.join(', ')} (got "${value}")`);
59
+ }
60
+ return value;
61
+ };
62
+ }
@@ -17,7 +17,7 @@ export function isVerbose() {
17
17
  export function isDryRun() {
18
18
  return process.argv.includes('--dry-run');
19
19
  }
20
- /** HTTP request timeout in milliseconds. Default 30s. */
20
+ /** HTTP request timeout in milliseconds. Default 30s. Minimum 100ms (values below 100ms are ignored). */
21
21
  export function getTimeout() {
22
22
  const v = getFlagValue('--timeout');
23
23
  if (!v)
@@ -25,6 +25,10 @@ export function getTimeout() {
25
25
  const n = Number(v);
26
26
  if (!Number.isFinite(n) || n <= 0)
27
27
  return 30_000;
28
+ if (n < 100) {
29
+ process.stderr.write(`Warning: --timeout ${n}ms is too low to complete any request; using 100ms minimum.\n`);
30
+ return 100;
31
+ }
28
32
  return n;
29
33
  }
30
34
  /** Override for the credentials file path. */
@@ -36,20 +40,17 @@ export function getProfile() {
36
40
  return getFlagValue('--profile');
37
41
  }
38
42
  /**
39
- * Audit log path. `--audit-log <path>` enables JSONL append on every mutating
40
- * command; default path is ~/.switchbot/audit.log when `--audit-log` is given
41
- * without a value. Returns null when the flag is absent.
43
+ * Audit log path. `--audit-log` enables JSONL append on every mutating command.
44
+ * Use `--audit-log-path <path>` to specify a custom file; otherwise defaults to
45
+ * ~/.switchbot/audit.log. Returns null when --audit-log is absent.
42
46
  */
43
47
  export function getAuditLog() {
44
- const idx = process.argv.indexOf('--audit-log');
45
- if (idx === -1)
48
+ if (!process.argv.includes('--audit-log'))
46
49
  return null;
47
- const next = process.argv[idx + 1];
48
- if (!next || next.startsWith('-')) {
49
- // bare --audit-log → default location
50
- return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`;
51
- }
52
- return next;
50
+ const customPath = getFlagValue('--audit-log-path');
51
+ if (customPath)
52
+ return customPath;
53
+ return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`;
53
54
  }
54
55
  /**
55
56
  * Max 429 retries before surfacing the error. Default 3. `--no-retry`
@@ -32,15 +32,16 @@ export function resolveFormat() {
32
32
  export function resolveFields() {
33
33
  return getFields();
34
34
  }
35
- export function filterFields(headers, rows, fields) {
35
+ export function filterFields(headers, rows, fields, aliases) {
36
36
  if (!fields || fields.length === 0)
37
37
  return { headers, rows };
38
- const unknown = fields.filter((f) => !headers.includes(f));
38
+ const resolved = aliases ? fields.map((f) => aliases[f] ?? f) : fields;
39
+ const unknown = fields.filter((_, i) => !headers.includes(resolved[i]));
39
40
  if (unknown.length > 0) {
40
41
  throw new UsageError(`Unknown field(s): ${unknown.map((f) => `"${f}"`).join(', ')}. ` +
41
42
  `Allowed: ${headers.map((f) => `"${f}"`).join(', ')}.`);
42
43
  }
43
- const indices = fields.map((f) => headers.indexOf(f));
44
+ const indices = resolved.map((f) => headers.indexOf(f));
44
45
  return {
45
46
  headers: indices.map((i) => headers[i]),
46
47
  rows: rows.map((row) => indices.map((i) => row[i])),
@@ -62,8 +63,8 @@ function rowToObject(headers, row) {
62
63
  }
63
64
  return obj;
64
65
  }
65
- export function renderRows(headers, rows, format, fields) {
66
- const filtered = filterFields(headers, rows, fields);
66
+ export function renderRows(headers, rows, format, fields, aliases) {
67
+ const filtered = filterFields(headers, rows, fields, aliases);
67
68
  const h = filtered.headers;
68
69
  const r = filtered.rows;
69
70
  switch (format) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",