@switchbot/openapi-cli 3.1.1 → 3.2.0

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.
Files changed (113) hide show
  1. package/README.md +3 -3
  2. package/dist/index.js +56945 -169
  3. package/dist/policy/schema/v0.2.json +1 -1
  4. package/package.json +3 -2
  5. package/dist/api/client.js +0 -235
  6. package/dist/auth.js +0 -20
  7. package/dist/commands/agent-bootstrap.js +0 -182
  8. package/dist/commands/auth.js +0 -354
  9. package/dist/commands/batch.js +0 -413
  10. package/dist/commands/cache.js +0 -126
  11. package/dist/commands/capabilities.js +0 -385
  12. package/dist/commands/catalog.js +0 -359
  13. package/dist/commands/completion.js +0 -385
  14. package/dist/commands/config.js +0 -376
  15. package/dist/commands/daemon.js +0 -410
  16. package/dist/commands/device-meta.js +0 -159
  17. package/dist/commands/devices.js +0 -948
  18. package/dist/commands/doctor.js +0 -1015
  19. package/dist/commands/events.js +0 -563
  20. package/dist/commands/expand.js +0 -130
  21. package/dist/commands/explain.js +0 -139
  22. package/dist/commands/health.js +0 -113
  23. package/dist/commands/history.js +0 -320
  24. package/dist/commands/identity.js +0 -59
  25. package/dist/commands/install.js +0 -246
  26. package/dist/commands/mcp.js +0 -2017
  27. package/dist/commands/plan.js +0 -653
  28. package/dist/commands/policy.js +0 -586
  29. package/dist/commands/quota.js +0 -78
  30. package/dist/commands/rules.js +0 -875
  31. package/dist/commands/scenes.js +0 -264
  32. package/dist/commands/schema.js +0 -177
  33. package/dist/commands/status-sync.js +0 -131
  34. package/dist/commands/uninstall.js +0 -237
  35. package/dist/commands/upgrade-check.js +0 -107
  36. package/dist/commands/watch.js +0 -194
  37. package/dist/commands/webhook.js +0 -182
  38. package/dist/config.js +0 -258
  39. package/dist/credentials/backends/file.js +0 -101
  40. package/dist/credentials/backends/linux.js +0 -129
  41. package/dist/credentials/backends/macos.js +0 -129
  42. package/dist/credentials/backends/windows.js +0 -215
  43. package/dist/credentials/keychain.js +0 -88
  44. package/dist/credentials/prime.js +0 -52
  45. package/dist/devices/cache.js +0 -293
  46. package/dist/devices/catalog.js +0 -767
  47. package/dist/devices/device-meta.js +0 -56
  48. package/dist/devices/history-agg.js +0 -138
  49. package/dist/devices/history-query.js +0 -181
  50. package/dist/devices/param-validator.js +0 -433
  51. package/dist/devices/resources.js +0 -270
  52. package/dist/install/default-steps.js +0 -257
  53. package/dist/install/preflight.js +0 -212
  54. package/dist/install/steps.js +0 -67
  55. package/dist/lib/command-keywords.js +0 -17
  56. package/dist/lib/daemon-state.js +0 -46
  57. package/dist/lib/destructive-mode.js +0 -12
  58. package/dist/lib/devices.js +0 -382
  59. package/dist/lib/idempotency.js +0 -106
  60. package/dist/lib/plan-store.js +0 -68
  61. package/dist/lib/request-context.js +0 -12
  62. package/dist/lib/scenes.js +0 -10
  63. package/dist/logger.js +0 -16
  64. package/dist/mcp/device-history.js +0 -145
  65. package/dist/mcp/events-subscription.js +0 -213
  66. package/dist/mqtt/client.js +0 -180
  67. package/dist/mqtt/credential.js +0 -30
  68. package/dist/policy/add-rule.js +0 -124
  69. package/dist/policy/diff.js +0 -91
  70. package/dist/policy/format.js +0 -57
  71. package/dist/policy/load.js +0 -61
  72. package/dist/policy/migrate.js +0 -67
  73. package/dist/policy/schema.js +0 -18
  74. package/dist/policy/validate.js +0 -262
  75. package/dist/rules/action.js +0 -216
  76. package/dist/rules/audit-query.js +0 -89
  77. package/dist/rules/conflict-analyzer.js +0 -214
  78. package/dist/rules/cron-scheduler.js +0 -186
  79. package/dist/rules/destructive.js +0 -52
  80. package/dist/rules/engine.js +0 -757
  81. package/dist/rules/matcher.js +0 -230
  82. package/dist/rules/pid-file.js +0 -95
  83. package/dist/rules/quiet-hours.js +0 -45
  84. package/dist/rules/suggest.js +0 -95
  85. package/dist/rules/throttle.js +0 -116
  86. package/dist/rules/types.js +0 -34
  87. package/dist/rules/webhook-listener.js +0 -223
  88. package/dist/rules/webhook-token.js +0 -90
  89. package/dist/schema/field-aliases.js +0 -131
  90. package/dist/sinks/dispatcher.js +0 -12
  91. package/dist/sinks/file.js +0 -19
  92. package/dist/sinks/format.js +0 -56
  93. package/dist/sinks/homeassistant.js +0 -44
  94. package/dist/sinks/openclaw.js +0 -33
  95. package/dist/sinks/stdout.js +0 -5
  96. package/dist/sinks/telegram.js +0 -28
  97. package/dist/sinks/types.js +0 -1
  98. package/dist/sinks/webhook.js +0 -22
  99. package/dist/status-sync/manager.js +0 -268
  100. package/dist/utils/arg-parsers.js +0 -66
  101. package/dist/utils/audit.js +0 -121
  102. package/dist/utils/filter.js +0 -189
  103. package/dist/utils/flags.js +0 -186
  104. package/dist/utils/format.js +0 -117
  105. package/dist/utils/health.js +0 -101
  106. package/dist/utils/help-json.js +0 -54
  107. package/dist/utils/name-resolver.js +0 -137
  108. package/dist/utils/output.js +0 -404
  109. package/dist/utils/quota.js +0 -227
  110. package/dist/utils/redact.js +0 -68
  111. package/dist/utils/retry.js +0 -140
  112. package/dist/utils/string.js +0 -22
  113. package/dist/version.js +0 -4
@@ -1,186 +0,0 @@
1
- /**
2
- * Read global flags directly from process.argv (same pattern as isJsonMode).
3
- * Kept simple so subcommand actions don't need to thread program.opts() down.
4
- */
5
- function getFlagValue(...flagNames) {
6
- for (const flag of flagNames) {
7
- const idx = process.argv.indexOf(flag);
8
- if (idx !== -1 && idx + 1 < process.argv.length) {
9
- return process.argv[idx + 1];
10
- }
11
- // Also accept the `--flag=value` token form. Commander.js recognizes it at
12
- // the option layer but global-flag scans like this one used to miss it,
13
- // so `--format=json` silently fell back to the default (table).
14
- const prefix = `${flag}=`;
15
- const combined = process.argv.find((arg) => arg.startsWith(prefix));
16
- if (combined !== undefined) {
17
- return combined.slice(prefix.length);
18
- }
19
- }
20
- return undefined;
21
- }
22
- export function isVerbose() {
23
- return process.argv.includes('--verbose') || process.argv.includes('-v');
24
- }
25
- /**
26
- * Opt-in: disable header redaction in verbose traces. Adds a big warning on
27
- * stderr. Use only when actively debugging an auth issue — never in logs or
28
- * CI output.
29
- */
30
- export function isTraceUnsafe() {
31
- return process.argv.includes('--trace-unsafe');
32
- }
33
- export function isDryRun() {
34
- return process.argv.includes('--dry-run');
35
- }
36
- /** HTTP request timeout in milliseconds. Default 30s. Minimum 100ms (values below 100ms are ignored). */
37
- export function getTimeout() {
38
- const v = getFlagValue('--timeout');
39
- if (!v)
40
- return 30_000;
41
- const n = Number(v);
42
- if (!Number.isFinite(n) || n <= 0)
43
- return 30_000;
44
- if (n < 100) {
45
- process.stderr.write(`Warning: --timeout ${n}ms is too low to complete any request; using 100ms minimum.\n`);
46
- return 100;
47
- }
48
- return n;
49
- }
50
- /** Override for the credentials file path. */
51
- export function getConfigPath() {
52
- return getFlagValue('--config');
53
- }
54
- /** Named profile → ~/.switchbot/profiles/<name>.json. */
55
- export function getProfile() {
56
- return getFlagValue('--profile');
57
- }
58
- /**
59
- * Audit log path. `--audit-log` enables JSONL append on every mutating command.
60
- * Use `--audit-log-path <path>` to specify a custom file; otherwise defaults to
61
- * ~/.switchbot/audit.log. Returns null when --audit-log is absent.
62
- */
63
- export function getAuditLog() {
64
- if (!process.argv.includes('--audit-log'))
65
- return null;
66
- const customPath = getFlagValue('--audit-log-path');
67
- if (customPath)
68
- return customPath;
69
- return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`;
70
- }
71
- /**
72
- * Max 429 retries before surfacing the error. Default 3. `--no-retry`
73
- * disables retries entirely; `--retry-on-429 <n>` overrides the count.
74
- */
75
- export function getRetryOn429() {
76
- if (process.argv.includes('--no-retry'))
77
- return 0;
78
- const v = getFlagValue('--retry-on-429');
79
- if (v === undefined)
80
- return 3;
81
- const n = Number(v);
82
- if (!Number.isFinite(n) || n < 0)
83
- return 3;
84
- return Math.floor(n);
85
- }
86
- /** Backoff strategy for 429 retries. Default 'exponential'. */
87
- export function getBackoffStrategy() {
88
- const v = getFlagValue('--backoff');
89
- if (v === 'linear')
90
- return 'linear';
91
- return 'exponential';
92
- }
93
- /**
94
- * Max retries on 5xx / gateway-timeout responses for idempotent (GET) reads.
95
- * Default 2. `--no-retry` disables retries entirely. POSTs are not retried
96
- * automatically — use --idempotency-key and let the server dedupe.
97
- */
98
- export function getRetryOn5xx() {
99
- if (process.argv.includes('--no-retry'))
100
- return 0;
101
- const v = getFlagValue('--retry-on-5xx');
102
- if (v === undefined)
103
- return 2;
104
- const n = Number(v);
105
- if (!Number.isFinite(n) || n < 0)
106
- return 2;
107
- return Math.floor(n);
108
- }
109
- /**
110
- * Whether local quota counting is disabled. Quota counting is best-effort
111
- * (see src/utils/quota.ts) — this lets scripts opt out entirely when even
112
- * best-effort file I/O is unwelcome.
113
- */
114
- export function isQuotaDisabled() {
115
- return process.argv.includes('--no-quota');
116
- }
117
- const DEFAULT_LIST_TTL_MS = 60 * 60 * 1000;
118
- function parseDurationToMs(v) {
119
- const m = /^(\d+)(ms|s|m|h|d|w)?$/.exec(v.trim().toLowerCase());
120
- if (!m)
121
- return null;
122
- const n = Number(m[1]);
123
- if (!Number.isFinite(n) || n < 0)
124
- return null;
125
- const unit = m[2] ?? 'ms';
126
- switch (unit) {
127
- case 'ms': return n;
128
- case 's': return n * 1000;
129
- case 'm': return n * 60 * 1000;
130
- case 'h': return n * 60 * 60 * 1000;
131
- case 'd': return n * 24 * 60 * 60 * 1000;
132
- case 'w': return n * 7 * 24 * 60 * 60 * 1000;
133
- default: return null;
134
- }
135
- }
136
- export { parseDurationToMs };
137
- /** The --format flag value, or undefined when absent. */
138
- export function getFormat() {
139
- return getFlagValue('--format');
140
- }
141
- /** Comma-separated --fields value, split into an array. */
142
- export function getFields() {
143
- const v = getFlagValue('--fields');
144
- if (!v)
145
- return undefined;
146
- return v.split(',').map((f) => f.trim()).filter(Boolean);
147
- }
148
- export function getTableStyle() {
149
- const v = getFlagValue('--table-style');
150
- if (v === 'unicode' || v === 'ascii' || v === 'simple' || v === 'markdown')
151
- return v;
152
- if (getFormat() === 'markdown')
153
- return 'markdown';
154
- // TTY → pretty unicode borders. Non-TTY (pipe/redirect) → ascii to avoid
155
- // mojibake in consumer logs.
156
- return process.stdout.isTTY ? 'unicode' : 'ascii';
157
- }
158
- export function getCacheMode() {
159
- if (process.argv.includes('--no-cache')) {
160
- return { listTtlMs: 0, statusTtlMs: 0 };
161
- }
162
- // Individual TTL overrides take precedence over the combined --cache flag.
163
- const listFlag = getFlagValue('--cache-list');
164
- const statusFlag = getFlagValue('--cache-status');
165
- if (listFlag !== undefined || statusFlag !== undefined) {
166
- const listTtlMs = listFlag !== undefined
167
- ? (parseDurationToMs(listFlag) ?? DEFAULT_LIST_TTL_MS)
168
- : DEFAULT_LIST_TTL_MS;
169
- const statusTtlMs = statusFlag !== undefined
170
- ? (parseDurationToMs(statusFlag) ?? 0)
171
- : 0;
172
- return { listTtlMs, statusTtlMs };
173
- }
174
- const v = getFlagValue('--cache');
175
- if (!v || v === 'auto') {
176
- return { listTtlMs: DEFAULT_LIST_TTL_MS, statusTtlMs: 0 };
177
- }
178
- if (v === 'off') {
179
- return { listTtlMs: 0, statusTtlMs: 0 };
180
- }
181
- const ms = parseDurationToMs(v);
182
- if (ms === null || ms === 0) {
183
- return { listTtlMs: DEFAULT_LIST_TTL_MS, statusTtlMs: 0 };
184
- }
185
- return { listTtlMs: ms, statusTtlMs: ms };
186
- }
@@ -1,117 +0,0 @@
1
- import { printTable, printJson, isJsonMode, UsageError, emitJsonError } from './output.js';
2
- import { getFormat, getFields } from './flags.js';
3
- import { dump as yamlDump } from 'js-yaml';
4
- export function parseFormat(flag) {
5
- if (!flag)
6
- return 'table';
7
- const lower = flag.toLowerCase();
8
- switch (lower) {
9
- case 'table': return 'table';
10
- case 'json': return 'json';
11
- case 'jsonl': return 'jsonl';
12
- case 'tsv': return 'tsv';
13
- case 'yaml': return 'yaml';
14
- case 'id': return 'id';
15
- case 'markdown': return 'markdown';
16
- default: {
17
- const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id, markdown.`;
18
- if (isJsonMode()) {
19
- emitJsonError({ code: 2, kind: 'usage', message: msg });
20
- }
21
- else {
22
- console.error(msg);
23
- }
24
- process.exit(2);
25
- }
26
- }
27
- }
28
- export function resolveFormat() {
29
- if (process.argv.includes('--json'))
30
- return 'json';
31
- return parseFormat(getFormat());
32
- }
33
- export function resolveFields() {
34
- return getFields();
35
- }
36
- export function filterFields(headers, rows, fields, aliases) {
37
- if (!fields || fields.length === 0)
38
- return { headers, rows };
39
- const resolved = aliases ? fields.map((f) => aliases[f] ?? f) : fields;
40
- const unknown = fields.filter((_, i) => !headers.includes(resolved[i]));
41
- if (unknown.length > 0) {
42
- throw new UsageError(`Unknown field(s): ${unknown.map((f) => `"${f}"`).join(', ')}. ` +
43
- `Allowed: ${headers.map((f) => `"${f}"`).join(', ')}.`);
44
- }
45
- const indices = resolved.map((f) => headers.indexOf(f));
46
- return {
47
- headers: indices.map((i) => headers[i]),
48
- rows: rows.map((row) => indices.map((i) => row[i])),
49
- };
50
- }
51
- function cellToString(cell) {
52
- if (cell === null || cell === undefined)
53
- return '';
54
- if (typeof cell === 'boolean')
55
- return cell ? 'true' : 'false';
56
- if (typeof cell === 'object')
57
- return JSON.stringify(cell);
58
- return String(cell);
59
- }
60
- function rowToObject(headers, row) {
61
- const obj = {};
62
- for (let i = 0; i < headers.length; i++) {
63
- obj[headers[i]] = row[i] ?? null;
64
- }
65
- return obj;
66
- }
67
- export function renderRows(headers, rows, format, fields, aliases) {
68
- const filtered = filterFields(headers, rows, fields, aliases);
69
- const h = filtered.headers;
70
- const r = filtered.rows;
71
- // Markdown format is rendered as table with markdown style forced regardless
72
- // of the user's --table-style, so `--format markdown` is a self-contained
73
- // contract (bug #8).
74
- if (format === 'markdown') {
75
- printTable(h, r, 'markdown');
76
- return;
77
- }
78
- switch (format) {
79
- case 'table':
80
- printTable(h, r);
81
- break;
82
- case 'json':
83
- printJson(r.map((row) => rowToObject(h, row)));
84
- break;
85
- case 'jsonl':
86
- for (const row of r) {
87
- console.log(JSON.stringify(rowToObject(h, row)));
88
- }
89
- break;
90
- case 'tsv':
91
- console.log(h.join('\t'));
92
- for (const row of r) {
93
- console.log(row.map(cellToString).join('\t'));
94
- }
95
- break;
96
- case 'yaml':
97
- for (const row of r) {
98
- const obj = rowToObject(h, row);
99
- console.log('---');
100
- console.log(yamlDump(obj, { lineWidth: -1 }).trimEnd());
101
- }
102
- break;
103
- case 'id': {
104
- const idIdx = h.indexOf('deviceId') !== -1 ? h.indexOf('deviceId')
105
- : h.indexOf('sceneId') !== -1 ? h.indexOf('sceneId')
106
- : -1;
107
- if (idIdx === -1) {
108
- throw new UsageError(`--format=id requires a "deviceId" or "sceneId" column. ` +
109
- `This command outputs: ${h.map((c) => `"${c}"`).join(', ')}.`);
110
- }
111
- for (const row of r) {
112
- console.log(cellToString(row[idIdx]));
113
- }
114
- break;
115
- }
116
- }
117
- }
@@ -1,101 +0,0 @@
1
- /**
2
- * Health report utilities — collects process, quota, audit, and circuit
3
- * breaker state into a single snapshot suitable for /health-style checks
4
- * and Prometheus-compatible metrics export.
5
- *
6
- * No side effects: reading is safe to call from any context.
7
- */
8
- import fs from 'node:fs';
9
- import os from 'node:os';
10
- import path from 'node:path';
11
- import { todayUsage, DAILY_QUOTA } from './quota.js';
12
- import { readAudit } from './audit.js';
13
- import { apiCircuitBreaker } from '../api/client.js';
14
- const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
15
- const AUDIT_ERROR_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h
16
- export function getHealthReport(auditPath = DEFAULT_AUDIT_PATH) {
17
- const now = new Date();
18
- // Process info
19
- const procHealth = {
20
- pid: process.pid,
21
- uptimeSeconds: Math.floor(process.uptime()),
22
- platform: process.platform,
23
- nodeVersion: process.version,
24
- memoryMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
25
- };
26
- // Quota
27
- const { total: used } = todayUsage(now);
28
- const pct = Math.round((used / DAILY_QUOTA) * 100);
29
- const quotaHealth = {
30
- used,
31
- limit: DAILY_QUOTA,
32
- percentUsed: pct,
33
- remaining: Math.max(0, DAILY_QUOTA - used),
34
- status: pct >= 90 ? 'critical' : pct >= 70 ? 'warn' : 'ok',
35
- };
36
- // Audit error rate (last 24h)
37
- let auditHealth;
38
- if (!fs.existsSync(auditPath)) {
39
- auditHealth = { present: false, recentErrors: 0, recentTotal: 0, errorRatePercent: 0, status: 'ok' };
40
- }
41
- else {
42
- const entries = readAudit(auditPath);
43
- const windowStart = now.getTime() - AUDIT_ERROR_WINDOW_MS;
44
- const recent = entries.filter((e) => new Date(e.t).getTime() >= windowStart);
45
- const errors = recent.filter((e) => e.result === 'error').length;
46
- const total = recent.length;
47
- const errorRate = total > 0 ? Math.round((errors / total) * 100) : 0;
48
- auditHealth = {
49
- present: true,
50
- recentErrors: errors,
51
- recentTotal: total,
52
- errorRatePercent: errorRate,
53
- status: errorRate >= 30 ? 'warn' : 'ok',
54
- };
55
- }
56
- // Circuit breaker
57
- const cbStats = apiCircuitBreaker.getStats();
58
- const circuitHealth = {
59
- name: apiCircuitBreaker.name,
60
- state: cbStats.state,
61
- failures: cbStats.failures,
62
- status: cbStats.state === 'open' ? 'open' : 'ok',
63
- };
64
- // Overall
65
- const degraded = quotaHealth.status !== 'ok' ||
66
- auditHealth.status !== 'ok' ||
67
- circuitHealth.status !== 'ok';
68
- const down = circuitHealth.status === 'open';
69
- const overall = down ? 'down' : degraded ? 'degraded' : 'ok';
70
- return {
71
- generatedAt: now.toISOString(),
72
- overall,
73
- process: procHealth,
74
- quota: quotaHealth,
75
- audit: auditHealth,
76
- circuit: circuitHealth,
77
- };
78
- }
79
- /**
80
- * Render a minimal Prometheus-compatible text metrics export.
81
- * Only includes the most actionable gauges.
82
- */
83
- export function toPrometheusText(report) {
84
- const lines = [];
85
- const push = (name, value, help) => {
86
- if (help)
87
- lines.push(`# HELP ${name} ${help}`);
88
- lines.push(`# TYPE ${name} gauge`);
89
- lines.push(`${name} ${value}`);
90
- };
91
- push('switchbot_quota_used_total', report.quota.used, 'SwitchBot API requests used today');
92
- push('switchbot_quota_remaining', report.quota.remaining, 'SwitchBot API quota remaining today');
93
- push('switchbot_quota_percent_used', report.quota.percentUsed, 'SwitchBot API quota percent used today');
94
- push('switchbot_audit_recent_errors', report.audit.recentErrors, 'Audit log errors in the last 24h');
95
- push('switchbot_audit_error_rate_percent', report.audit.errorRatePercent, 'Audit error rate percent (last 24h)');
96
- push('switchbot_circuit_open', report.circuit.state === 'open' ? 1 : 0, 'API circuit breaker open (1=open, 0=closed/half-open)');
97
- push('switchbot_circuit_failures', report.circuit.failures, 'Consecutive API failures recorded by circuit breaker');
98
- push('switchbot_process_uptime_seconds', report.process.uptimeSeconds, 'Process uptime in seconds');
99
- push('switchbot_process_memory_mb', report.process.memoryMb, 'Process RSS memory usage in MB');
100
- return lines.join('\n') + '\n';
101
- }
@@ -1,54 +0,0 @@
1
- import { IDENTITY } from '../commands/identity.js';
2
- export function commandToJson(cmd, opts = {}) {
3
- const args = cmd.registeredArguments.map((a) => ({
4
- name: a.name(),
5
- required: a.required,
6
- variadic: a.variadic,
7
- description: a.description ?? '',
8
- }));
9
- const options = cmd.options
10
- .filter((o) => o.long !== '--help' && o.long !== '--version')
11
- .map((o) => {
12
- const entry = { flags: o.flags, description: o.description ?? '' };
13
- if (o.defaultValue !== undefined)
14
- entry.defaultValue = o.defaultValue;
15
- if (o.argChoices && o.argChoices.length > 0)
16
- entry.choices = o.argChoices;
17
- return entry;
18
- });
19
- const subcommands = cmd.commands
20
- .filter((c) => !c.name().startsWith('_'))
21
- .map((c) => ({ name: c.name(), description: c.description() }));
22
- const out = {
23
- name: cmd.name(),
24
- description: cmd.description(),
25
- arguments: args,
26
- options,
27
- subcommands,
28
- };
29
- if (opts.includeIdentity) {
30
- out.product = IDENTITY.product;
31
- out.domain = IDENTITY.domain;
32
- out.vendor = IDENTITY.vendor;
33
- out.apiVersion = IDENTITY.apiVersion;
34
- out.apiDocs = IDENTITY.apiDocs;
35
- out.productCategories = IDENTITY.productCategories;
36
- }
37
- return out;
38
- }
39
- /** Walk argv tokens (skipping flags) to find the deepest matching subcommand. */
40
- export function resolveTargetCommand(root, argv) {
41
- let cmd = root;
42
- for (const token of argv) {
43
- if (token.startsWith('-'))
44
- continue;
45
- const sub = cmd.commands.find((c) => c.name() === token || c.aliases().includes(token));
46
- if (sub) {
47
- cmd = sub;
48
- }
49
- else {
50
- break;
51
- }
52
- }
53
- return cmd;
54
- }
@@ -1,137 +0,0 @@
1
- import { loadCache } from '../devices/cache.js';
2
- import { loadDeviceMeta } from '../devices/device-meta.js';
3
- import { levenshtein, normalizeDeviceName } from './string.js';
4
- import { UsageError, StructuredUsageError } from './output.js';
5
- export const ALL_STRATEGIES = [
6
- 'exact', 'prefix', 'substring', 'fuzzy', 'first', 'require-unique',
7
- ];
8
- export function isValidStrategy(s) {
9
- return ALL_STRATEGIES.includes(s);
10
- }
11
- function resolveDeviceByName(query, opts = {}) {
12
- const strategy = opts.strategy ?? 'fuzzy';
13
- const cache = loadCache();
14
- if (!cache || Object.keys(cache.devices).length === 0) {
15
- return { ok: false, ambiguous: false };
16
- }
17
- const meta = loadDeviceMeta();
18
- const q = normalizeDeviceName(query);
19
- const threshold = Math.min(3, Math.floor(q.length * 0.3));
20
- const typeFilter = opts.type ? opts.type.toLowerCase() : null;
21
- const roomFilter = opts.room ? opts.room.toLowerCase() : null;
22
- const candidates = [];
23
- for (const [deviceId, device] of Object.entries(cache.devices)) {
24
- // narrow filters first
25
- if (opts.category && device.category !== opts.category)
26
- continue;
27
- if (typeFilter && device.type.toLowerCase() !== typeFilter)
28
- continue;
29
- if (roomFilter) {
30
- const rn = (device.roomName ?? '').toLowerCase();
31
- if (rn !== roomFilter && !rn.includes(roomFilter))
32
- continue;
33
- }
34
- const alias = meta.devices[deviceId]?.alias;
35
- const rawName = normalizeDeviceName(device.name);
36
- const normAlias = alias ? normalizeDeviceName(alias) : null;
37
- // exact alias/name wins immediately for lenient strategies.
38
- // Under require-unique we must NOT short-circuit: there may be other devices
39
- // that also match (e.g. via substring), making the result ambiguous. Collect
40
- // the exact hit as a candidate and let the full ambiguity check decide below.
41
- if ((normAlias && normAlias === q) || rawName === q) {
42
- if (strategy !== 'require-unique') {
43
- return { ok: true, deviceId };
44
- }
45
- // require-unique: treat exact match as a high-priority candidate (score 0)
46
- candidates.push({ deviceId, name: device.name, score: 0 });
47
- continue;
48
- }
49
- if (strategy === 'exact')
50
- continue;
51
- if (strategy === 'prefix') {
52
- if ((normAlias && normAlias.startsWith(q)) || rawName.startsWith(q)) {
53
- candidates.push({ deviceId, name: device.name, score: 1 });
54
- }
55
- continue;
56
- }
57
- // substring + fuzzy + require-unique + first all share substring match
58
- if ((normAlias && normAlias.includes(q)) || rawName.includes(q)) {
59
- candidates.push({ deviceId, name: device.name, score: 1 });
60
- continue;
61
- }
62
- if (strategy === 'substring')
63
- continue;
64
- // fuzzy / require-unique / first → also levenshtein
65
- if (strategy === 'fuzzy' || strategy === 'require-unique' || strategy === 'first') {
66
- const distName = levenshtein(rawName, q);
67
- const distAlias = normAlias ? levenshtein(normAlias, q) : Number.POSITIVE_INFINITY;
68
- const dist = Math.min(distName, distAlias);
69
- if (dist <= threshold) {
70
- candidates.push({ deviceId, name: device.name, score: dist + 1 });
71
- }
72
- }
73
- }
74
- if (candidates.length === 0)
75
- return { ok: false, ambiguous: false };
76
- candidates.sort((a, b) => a.score - b.score);
77
- if (strategy === 'first') {
78
- return { ok: true, deviceId: candidates[0].deviceId };
79
- }
80
- if (strategy === 'require-unique') {
81
- if (candidates.length === 1)
82
- return { ok: true, deviceId: candidates[0].deviceId };
83
- return { ok: false, ambiguous: true, candidates: candidates.slice(0, 4) };
84
- }
85
- // fuzzy / substring / prefix: collapse cluster of near-ties
86
- const best = candidates[0].score;
87
- const top = candidates.filter((c) => c.score <= best + 1);
88
- if (top.length === 1)
89
- return { ok: true, deviceId: top[0].deviceId };
90
- return { ok: false, ambiguous: true, candidates: top.slice(0, 4) };
91
- }
92
- export function resolveDeviceId(deviceId, nameQuery, opts = {}) {
93
- if (deviceId && nameQuery) {
94
- throw new UsageError('Provide either a deviceId argument or --name, not both.');
95
- }
96
- if (deviceId)
97
- return deviceId;
98
- if (!nameQuery) {
99
- throw new UsageError('A deviceId argument or --name flag is required.');
100
- }
101
- if (opts.strategy && !isValidStrategy(opts.strategy)) {
102
- throw new UsageError(`--name-strategy must be one of: ${ALL_STRATEGIES.join(', ')} (got "${opts.strategy}")`);
103
- }
104
- const cache = loadCache();
105
- if (!cache) {
106
- throw new UsageError(`--name requires the device cache. Run 'switchbot devices list' first to populate it.`);
107
- }
108
- const result = resolveDeviceByName(nameQuery, opts);
109
- if (result.ok)
110
- return result.deviceId;
111
- if (result.ambiguous) {
112
- const candidates = result.candidates.map((c) => ({ deviceId: c.deviceId, name: c.name }));
113
- const narrow = [];
114
- if (!opts.type)
115
- narrow.push('--type');
116
- if (!opts.category)
117
- narrow.push('--category');
118
- if (!opts.room)
119
- narrow.push('--room');
120
- const strategyHint = opts.strategy === 'fuzzy'
121
- ? `pass --name-strategy=first to pick the best match`
122
- : `pass --name-strategy=fuzzy or --name-strategy=first to pick the best match`;
123
- const hint = narrow.length > 0
124
- ? `Narrow with ${narrow.join(' / ')}, refine the name, use the deviceId directly, or ${strategyHint}.`
125
- : `Refine the name, use the deviceId directly, or ${strategyHint}.`;
126
- throw new StructuredUsageError(`"${nameQuery}" is ambiguous — ${candidates.length} devices match.`, {
127
- error: 'ambiguous_name_match',
128
- query: nameQuery,
129
- candidates,
130
- hint,
131
- });
132
- }
133
- const noMatchNarrow = opts.type || opts.category || opts.room
134
- ? ' after applying --type/--category/--room filters'
135
- : '';
136
- throw new UsageError(`No device matches "${nameQuery}"${noMatchNarrow}. Run 'switchbot devices list' to see device names.`);
137
- }