sesame-kit 0.4.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 (170) hide show
  1. package/LICENSE +26 -0
  2. package/LICENSE.biz3 +21 -0
  3. package/README.ja.md +225 -0
  4. package/README.md +222 -0
  5. package/bin/sesame.js +8 -0
  6. package/clients/js/sesame-client.mjs +208 -0
  7. package/clients/python/pyproject.toml +5 -0
  8. package/clients/python/sesame_client.py +323 -0
  9. package/clients/python/setup.cfg +11 -0
  10. package/docs/architecture.ja.md +132 -0
  11. package/docs/architecture.md +105 -0
  12. package/docs/commands.ja.md +316 -0
  13. package/docs/commands.md +308 -0
  14. package/docs/library.ja.md +152 -0
  15. package/docs/library.md +152 -0
  16. package/docs/migration.ja.md +13 -0
  17. package/docs/migration.md +13 -0
  18. package/package.json +114 -0
  19. package/src/access.js +375 -0
  20. package/src/account.js +36 -0
  21. package/src/auth.js +248 -0
  22. package/src/ble/devicemodel.js +164 -0
  23. package/src/ble/index.js +185 -0
  24. package/src/ble/protocol.js +319 -0
  25. package/src/ble/session.js +235 -0
  26. package/src/ble/transport.js +279 -0
  27. package/src/cli/access.js +373 -0
  28. package/src/cli/company.js +104 -0
  29. package/src/cli/iot.js +400 -0
  30. package/src/cli/org.js +788 -0
  31. package/src/cli/presetir.js +188 -0
  32. package/src/cli/schedule.js +83 -0
  33. package/src/cli/serve.js +308 -0
  34. package/src/cli.js +1815 -0
  35. package/src/client.js +957 -0
  36. package/src/company.js +147 -0
  37. package/src/config.js +575 -0
  38. package/src/crypto.js +162 -0
  39. package/src/devices.js +228 -0
  40. package/src/index.js +55 -0
  41. package/src/iot.js +513 -0
  42. package/src/ir.js +341 -0
  43. package/src/itemcodes.js +29 -0
  44. package/src/lock.js +194 -0
  45. package/src/org.js +803 -0
  46. package/src/paths.js +30 -0
  47. package/src/presetir.js +525 -0
  48. package/src/prompts.js +74 -0
  49. package/src/schedule.js +108 -0
  50. package/src/serve/daemon.js +251 -0
  51. package/src/serve/framing/grpc.js +145 -0
  52. package/src/serve/framing/http.js +144 -0
  53. package/src/serve/framing/ndjson.js +75 -0
  54. package/src/serve/framing/socket.js +73 -0
  55. package/src/serve/framing/stdio.js +28 -0
  56. package/src/serve/framing/token.js +36 -0
  57. package/src/serve/framing/ws.js +56 -0
  58. package/src/serve/grpc-methods.generated.json +378 -0
  59. package/src/serve/jsonrpc.js +164 -0
  60. package/src/serve/registry.js +226 -0
  61. package/src/serve/rpc-params.generated.json +1746 -0
  62. package/src/serve/sesame.proto +470 -0
  63. package/src/session-ui.js +181 -0
  64. package/src/sharekey.js +130 -0
  65. package/src/tokens.js +53 -0
  66. package/src/transport.js +634 -0
  67. package/src/util.js +26 -0
  68. package/types/access.d.ts +193 -0
  69. package/types/access.d.ts.map +1 -0
  70. package/types/account.d.ts +13 -0
  71. package/types/account.d.ts.map +1 -0
  72. package/types/auth.d.ts +80 -0
  73. package/types/auth.d.ts.map +1 -0
  74. package/types/ble/devicemodel.d.ts +212 -0
  75. package/types/ble/devicemodel.d.ts.map +1 -0
  76. package/types/ble/index.d.ts +160 -0
  77. package/types/ble/index.d.ts.map +1 -0
  78. package/types/ble/protocol.d.ts +201 -0
  79. package/types/ble/protocol.d.ts.map +1 -0
  80. package/types/ble/session.d.ts +129 -0
  81. package/types/ble/session.d.ts.map +1 -0
  82. package/types/ble/transport.d.ts +67 -0
  83. package/types/ble/transport.d.ts.map +1 -0
  84. package/types/cli/access.d.ts +6 -0
  85. package/types/cli/access.d.ts.map +1 -0
  86. package/types/cli/company.d.ts +6 -0
  87. package/types/cli/company.d.ts.map +1 -0
  88. package/types/cli/iot.d.ts +6 -0
  89. package/types/cli/iot.d.ts.map +1 -0
  90. package/types/cli/org.d.ts +6 -0
  91. package/types/cli/org.d.ts.map +1 -0
  92. package/types/cli/presetir.d.ts +6 -0
  93. package/types/cli/presetir.d.ts.map +1 -0
  94. package/types/cli/schedule.d.ts +6 -0
  95. package/types/cli/schedule.d.ts.map +1 -0
  96. package/types/cli/serve.d.ts +2 -0
  97. package/types/cli/serve.d.ts.map +1 -0
  98. package/types/cli.d.ts +2 -0
  99. package/types/cli.d.ts.map +1 -0
  100. package/types/client.d.ts +463 -0
  101. package/types/client.d.ts.map +1 -0
  102. package/types/company.d.ts +94 -0
  103. package/types/company.d.ts.map +1 -0
  104. package/types/config.d.ts +111 -0
  105. package/types/config.d.ts.map +1 -0
  106. package/types/crypto.d.ts +61 -0
  107. package/types/crypto.d.ts.map +1 -0
  108. package/types/devices.d.ts +116 -0
  109. package/types/devices.d.ts.map +1 -0
  110. package/types/index.d.ts +23 -0
  111. package/types/index.d.ts.map +1 -0
  112. package/types/iot.d.ts +312 -0
  113. package/types/iot.d.ts.map +1 -0
  114. package/types/ir.d.ts +147 -0
  115. package/types/ir.d.ts.map +1 -0
  116. package/types/itemcodes.d.ts +21 -0
  117. package/types/itemcodes.d.ts.map +1 -0
  118. package/types/lock.d.ts +89 -0
  119. package/types/lock.d.ts.map +1 -0
  120. package/types/org.d.ts +468 -0
  121. package/types/org.d.ts.map +1 -0
  122. package/types/paths.d.ts +10 -0
  123. package/types/paths.d.ts.map +1 -0
  124. package/types/presetir.d.ts +286 -0
  125. package/types/presetir.d.ts.map +1 -0
  126. package/types/prompts.d.ts +39 -0
  127. package/types/prompts.d.ts.map +1 -0
  128. package/types/schedule.d.ts +71 -0
  129. package/types/schedule.d.ts.map +1 -0
  130. package/types/serve/daemon.d.ts +133 -0
  131. package/types/serve/daemon.d.ts.map +1 -0
  132. package/types/serve/framing/grpc.d.ts +14 -0
  133. package/types/serve/framing/grpc.d.ts.map +1 -0
  134. package/types/serve/framing/http.d.ts +14 -0
  135. package/types/serve/framing/http.d.ts.map +1 -0
  136. package/types/serve/framing/ndjson.d.ts +19 -0
  137. package/types/serve/framing/ndjson.d.ts.map +1 -0
  138. package/types/serve/framing/socket.d.ts +14 -0
  139. package/types/serve/framing/socket.d.ts.map +1 -0
  140. package/types/serve/framing/stdio.d.ts +11 -0
  141. package/types/serve/framing/stdio.d.ts.map +1 -0
  142. package/types/serve/framing/token.d.ts +11 -0
  143. package/types/serve/framing/token.d.ts.map +1 -0
  144. package/types/serve/framing/ws.d.ts +13 -0
  145. package/types/serve/framing/ws.d.ts.map +1 -0
  146. package/types/serve/jsonrpc.d.ts +118 -0
  147. package/types/serve/jsonrpc.d.ts.map +1 -0
  148. package/types/serve/registry.d.ts +41 -0
  149. package/types/serve/registry.d.ts.map +1 -0
  150. package/types/session-ui.d.ts +36 -0
  151. package/types/session-ui.d.ts.map +1 -0
  152. package/types/sharekey.d.ts +35 -0
  153. package/types/sharekey.d.ts.map +1 -0
  154. package/types/tokens.d.ts +20 -0
  155. package/types/tokens.d.ts.map +1 -0
  156. package/types/transport.d.ts +138 -0
  157. package/types/transport.d.ts.map +1 -0
  158. package/types/util.d.ts +20 -0
  159. package/types/util.d.ts.map +1 -0
  160. package/vendor/biz3/README.md +37 -0
  161. package/vendor/biz3/constants/cmdCode.d.ts +48 -0
  162. package/vendor/biz3/constants/cmdCode.d.ts.map +1 -0
  163. package/vendor/biz3/constants/cmdCode.js +92 -0
  164. package/vendor/biz3/constants/messageConstants.d.ts +28 -0
  165. package/vendor/biz3/constants/messageConstants.d.ts.map +1 -0
  166. package/vendor/biz3/constants/messageConstants.js +30 -0
  167. package/vendor/biz3/constants/sesameDeviceModel.d.ts +75 -0
  168. package/vendor/biz3/constants/sesameDeviceModel.d.ts.map +1 -0
  169. package/vendor/biz3/constants/sesameDeviceModel.js +77 -0
  170. package/vendor/biz3/package.json +5 -0
package/src/cli.js ADDED
@@ -0,0 +1,1815 @@
1
+ // commander ベースの CLI。bin/sesame.js から run() を呼ぶ。
2
+ //
3
+ // 設計メモ:
4
+ // - グローバルオプション --config-dir / --debug / --json は program.opts() で取得
5
+ // - 全コマンドは loadCtx() でファクトリ越しに ConfigStore / TokenStore を得る
6
+ // - 出力は --json 指定時に JSON.stringify、それ以外は人間可読
7
+ // - 位置引数が足りない & TTY & !--json なら対話 prompt (src/prompts.js)
8
+
9
+ import { createInterface } from "node:readline/promises";
10
+ import { spawn } from "node:child_process";
11
+ import {
12
+ copyFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { dirname, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { Command } from "commander";
21
+ import { SesameHub3 } from "./client.js";
22
+ import { ConfigStore } from "./config.js";
23
+ import { FileTokenStore } from "./tokens.js";
24
+ import { configPaths } from "./paths.js";
25
+ import {
26
+ bootstrap,
27
+ CONFIG_META,
28
+ getValidIdToken,
29
+ loginInitiate,
30
+ loginVerify,
31
+ } from "./auth.js";
32
+ import { isInteractive, selectFromList, promptText, confirm as confirmPrompt } from "./prompts.js";
33
+ import { parseIrType, DEFAULT_IR_TYPE } from "./crypto.js";
34
+ import { registerScheduleCommands } from "./cli/schedule.js";
35
+ import { registerCompanyCommands } from "./cli/company.js";
36
+ import { registerOrgCommands } from "./cli/org.js";
37
+ import { registerAccessCommands } from "./cli/access.js";
38
+ import { registerIotCommands } from "./cli/iot.js";
39
+ import { registerPresetIrCommands } from "./cli/presetir.js";
40
+ import { registerServeCommand } from "./cli/serve.js";
41
+ import { SesameBle, capabilitiesForModel, transportsForOp } from "./ble/index.js";
42
+ import { bleWasUsed } from "./ble/transport.js";
43
+ import { EventEmitter } from "node:events";
44
+ // session-ui (ink + react) は session でしか使わないので、起動コスト削減のため動的 import する。
45
+
46
+ const __dirname = dirname(fileURLToPath(import.meta.url));
47
+
48
+ // `--json` がグローバルに指定されているか。run() 冒頭で argv から確定し、
49
+ // die()/エラー経路など program.opts() を取れない場所でも JSON 契約を守れるようにする。
50
+ // (--json 時: 成功は stdout に純 JSON 1件、エラーは stderr に {error,code} JSON、で統一)
51
+ let CLI_JSON = false;
52
+
53
+ // ---------- 共通ユーティリティ ----------
54
+
55
+ function getPkgVersion() {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf8"));
58
+ return pkg.version || "0.0.0";
59
+ } catch {
60
+ return "0.0.0";
61
+ }
62
+ }
63
+
64
+ function mask(s) {
65
+ if (typeof s !== "string") return s ?? "(none)";
66
+ if (s.length <= 8) return s;
67
+ return `${s.slice(0, 4)}…${s.slice(-4)} (len=${s.length})`;
68
+ }
69
+
70
+ function out(json, humanFn, jsonObj) {
71
+ if (json) console.log(JSON.stringify(jsonObj, null, 2));
72
+ else humanFn();
73
+ }
74
+
75
+ function die(msg, code = 1) {
76
+ // エラーは常に stderr へ (stdout は成功 JSON 専用に保つ)。--json 時は構造化封筒で出す。
77
+ if (CLI_JSON) console.error(JSON.stringify({ error: msg, code }));
78
+ else console.error(`Error: ${msg}`);
79
+ process.exit(code);
80
+ }
81
+
82
+ /** program.opts() を吸い上げて ConfigStore / TokenStore / paths を返す。 */
83
+ function loadCtx(program) {
84
+ const opts = program.opts();
85
+ const paths = configPaths(opts.configDir);
86
+ const configStore = new ConfigStore(paths.config);
87
+ const tokenStore = new FileTokenStore({
88
+ tokensPath: paths.tokens,
89
+ loginStatePath: paths.loginState,
90
+ });
91
+ return { opts, paths, configStore, tokenStore };
92
+ }
93
+
94
+ /**
95
+ * cli/ サブモジュール (registerXxxCommands) に渡す共有コンテキスト。
96
+ * program を内部に束縛し、新コマンドが cli.js の private helper に直接依存せず
97
+ * ctx 越しに利用できるようにする (循環 import 回避 + cli.js 肥大化防止)。
98
+ *
99
+ * @param {import("commander").Command} program
100
+ */
101
+ function makeCtx(program) {
102
+ return {
103
+ /** out(json, humanFn, jsonObj): --json 指定時は jsonObj を、それ以外は humanFn() を出力 */
104
+ out,
105
+ /** die(msg, code=1): エラー表示して exit */
106
+ die,
107
+ /** canPrompt(): TTY かつ --json なし */
108
+ canPrompt: () => canPrompt(program),
109
+ /** loadCtx(): { opts, paths, configStore, tokenStore } */
110
+ loadCtx: () => loadCtx(program),
111
+ /** withHub(fn): connect → fn(hub, {opts, paths}) → close */
112
+ withHub: (fn) => withHub(program, fn),
113
+ /**
114
+ * withAccount(fn): withHub に加え、実行前に refreshAccount() で実 companyID /
115
+ * subUUID を保証する (org / company など companyID 必須の op 用)。
116
+ * refreshAccount() の戻り (customerInfo) を fn の第2引数 extra.customerInfo に渡すため、
117
+ * employeeEmail/subUUID が要るコマンドは getLoginUser() を再度呼ばず済む。
118
+ */
119
+ withAccount: (fn) =>
120
+ withHub(program, async (hub, extra) => {
121
+ const customerInfo = await hub.refreshAccount();
122
+ return fn(hub, { ...extra, customerInfo });
123
+ }),
124
+ /** 対話 prompt 群 */
125
+ prompts: { selectFromList, promptText, confirm: confirmPrompt, promptLine },
126
+ /**
127
+ * parseJson(raw, hint): --json 文字列を JSON.parse。失敗時は die(...,2) し undefined を返す。
128
+ * cli/ 各モジュールで重複していた parseJsonArg を 1 本化したもの。
129
+ */
130
+ parseJson(raw, hint) {
131
+ try {
132
+ return JSON.parse(raw);
133
+ } catch (e) {
134
+ die(`--json の値が不正な JSON です: ${e.message}${hint ? `\n 例: ${hint}` : ""}`, 2);
135
+ return undefined;
136
+ }
137
+ },
138
+ };
139
+ }
140
+
141
+ async function withHub(program, fn) {
142
+ const { opts, paths, configStore, tokenStore } = loadCtx(program);
143
+ if (!configStore.exists()) {
144
+ die(
145
+ `No config at ${paths.config}. \`sesame init\` または \`sesame migrate\` を実行してください。`,
146
+ 2,
147
+ );
148
+ }
149
+ const hub = new SesameHub3({
150
+ config: configStore.load(),
151
+ configStore,
152
+ tokenStore,
153
+ debug: !!opts.debug,
154
+ });
155
+ try {
156
+ await hub.connect();
157
+ return await fn(hub, { opts, paths });
158
+ } finally {
159
+ await hub.close();
160
+ }
161
+ }
162
+
163
+ async function promptLine(question) {
164
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
165
+ let closed = false;
166
+ rl.once("close", () => { closed = true; });
167
+ try {
168
+ const ans = await rl.question(question);
169
+ // 3rd-pass L-1: Ctrl-D (EOF) で空文字 resolve した場合は throw (無限ループ防止)
170
+ if (closed && !ans) throw new Error("prompt aborted (EOF / Ctrl-D)");
171
+ return ans.trim();
172
+ } finally {
173
+ rl.close();
174
+ }
175
+ }
176
+
177
+ /** prompts が許可される条件: TTY かつ --json 指定なし。 */
178
+ function canPrompt(program) {
179
+ return isInteractive() && !program.opts().json;
180
+ }
181
+
182
+ /** 名前未指定 & 対話可能なら、設定済みリストから選択させる。 */
183
+ async function pickRemoteName(program, configStore, current) {
184
+ if (current) return current;
185
+ const cfg = configStore.load();
186
+ const names = Object.keys(cfg.remotes || {});
187
+ if (names.length === 0) die("リモコンが未登録です。先に `sesame remote add` を実行してください。", 2);
188
+ if (names.length === 1) return names[0];
189
+ if (!canPrompt(program)) return null;
190
+ return selectFromList("どのリモコン?", names, (n) => {
191
+ const r = cfg.remotes[n];
192
+ const def = n === cfg.default?.remote ? " *" : "";
193
+ return `${n}${def}\thub3=${r.hub3}\tkeys=${Object.keys(r.keys || {}).length}${r.alias ? `\t(${r.alias})` : ""}`;
194
+ });
195
+ }
196
+
197
+ async function pickLockName(program, configStore, current) {
198
+ if (current) return current;
199
+ const cfg = configStore.load();
200
+ const names = Object.keys(cfg.locks || {});
201
+ if (names.length === 0) die("ロックが未登録です。`sesame lock add` か `sesame lock sync-from-devices` を実行してください。", 2);
202
+ if (names.length === 1) return names[0];
203
+ if (!canPrompt(program)) return null;
204
+ return selectFromList("どのロック?", names, (n) => {
205
+ const l = cfg.locks[n];
206
+ const def = n === cfg.default?.lock ? " *" : "";
207
+ return `${n}${def}\t${l.deviceUUID}\tmodel=${l.model || "?"}${l.alias ? `\t(${l.alias})` : ""}`;
208
+ });
209
+ }
210
+
211
+ async function pickHub3Name(program, configStore, current) {
212
+ if (current) return current;
213
+ const cfg = configStore.load();
214
+ const names = Object.keys(cfg.hub3s || {});
215
+ if (names.length === 0) die("Hub3 が未登録です。`sesame hub3 add` を実行してください。", 2);
216
+ if (names.length === 1) return names[0];
217
+ if (!canPrompt(program)) return null;
218
+ return selectFromList("どの Hub3?", names, (n) => `${n}\t${cfg.hub3s[n].deviceId}`);
219
+ }
220
+
221
+ async function pickRemoteKeyName(program, configStore, remoteName, current) {
222
+ if (current) return current;
223
+ const cfg = configStore.load();
224
+ const remote = cfg.remotes?.[remoteName];
225
+ if (!remote) die(`Unknown remote "${remoteName}"`, 2);
226
+ const keys = Object.keys(remote.keys || {});
227
+ if (keys.length === 0) die(`remote "${remoteName}" にキーがありません。`, 2);
228
+ if (!canPrompt(program)) return null;
229
+ return selectFromList(`remote "${remoteName}" のどのキー?`, keys, (k) => `${k}\t${remote.keys[k]}`);
230
+ }
231
+
232
+ /** Hub から デバイス一覧を取って UUID を選ばせる (model フィルタ任意)。 */
233
+ async function pickDeviceUUID(program, hub, current, { filter, message = "どのデバイス?" } = {}) {
234
+ if (current) return current;
235
+ let list;
236
+ try { list = await hub.listUserDevices(); } catch { list = []; }
237
+ if (!list.length) {
238
+ try { list = await hub.listDevices(); } catch { /* ignore */ }
239
+ }
240
+ const filtered = filter ? (list || []).filter(filter) : (list || []);
241
+ if (!filtered.length) die("デバイスが見つかりません。", 2);
242
+ // 1 個ならそれを auto-pick (Review L-4)
243
+ if (filtered.length === 1) return filtered[0].deviceUUID;
244
+ if (!canPrompt(program)) {
245
+ // 非対話で複数候補あり → 具体的な救済策をエラーに含める
246
+ // (3rd-pass L-2: 外側の `list` を shadow しないようリネーム)
247
+ const summary = filtered.map((d) => ` ${d.deviceUUID}\t${d.deviceModel || "?"}\t${d.deviceName || ""}`).join("\n");
248
+ die(`複数のデバイスがあるため UUID 指定が必要です:\n${summary}`, 2);
249
+ }
250
+ const chosen = await selectFromList(message, filtered, (d) =>
251
+ `${d.deviceName || "(no name)"}\t${d.deviceModel || "?"}\t${d.deviceUUID}`);
252
+ return chosen.deviceUUID;
253
+ }
254
+
255
+ // ---------- コマンド: 認証 ----------
256
+
257
+ async function cmdLogin(email, opts, program) {
258
+ if (!email) die("email required: sesame login <email>", 2);
259
+ const { tokenStore } = loadCtx(program);
260
+ await loginInitiate(tokenStore, email);
261
+ out(CLI_JSON, () => {
262
+ console.log(`OK: 確認コードを ${email} に送信しました。`);
263
+ console.log(`Step 2: sesame verify <code>`);
264
+ }, { ok: true, email, next: "sesame verify <code>" });
265
+ }
266
+
267
+ /**
268
+ * 認証後の自動セットアップ。接続して companyID 取得 → ロック / Hub3+リモコン を devices から取り込む。
269
+ * best-effort: 各ステップは個別に try/catch し、失敗しても他を続行 (ネットワーク不調で認証成功を潰さない)。
270
+ * @returns {Promise<object>} 取り込みサマリ
271
+ */
272
+ async function bootstrapAfterLogin(program, { quiet = false } = {}) {
273
+ const log = (...a) => { if (!quiet) console.error(...a); };
274
+ const summary = { companyID: null, locks: null, hub3s: null, remotes: null, errors: [] };
275
+ try {
276
+ await withHub(program, async (hub) => {
277
+ try {
278
+ const ci = await hub.refreshAccount();
279
+ summary.companyID = ci?.companyID || null;
280
+ log(` ✓ アカウント (companyID=${summary.companyID || "default"})`);
281
+ } catch (e) { summary.errors.push(`account: ${e.message}`); log(` ✗ アカウント取得失敗: ${e.message}`); }
282
+
283
+ try {
284
+ const r = await hub.syncLocksFromDevices({});
285
+ summary.locks = r;
286
+ log(` ✓ ロック: +${r.added.length} 更新${r.updated.length}${r.added.length ? ` (${r.added.join(", ")})` : ""}`);
287
+ } catch (e) { summary.errors.push(`locks: ${e.message}`); log(` ✗ ロック取り込み失敗: ${e.message}`); }
288
+
289
+ try {
290
+ const r = await hub.syncHub3sFromDevices();
291
+ summary.hub3s = r;
292
+ log(` ✓ Hub3: +${r.added?.length || 0}${r.added?.length ? ` (${r.added.join(", ")})` : ""}`);
293
+ } catch (e) { summary.errors.push(`hub3s: ${e.message}`); log(` ✗ Hub3 取り込み失敗: ${e.message}`); }
294
+
295
+ try {
296
+ const { remotes } = await hub.syncRemotesFromDevices();
297
+ for (const name of [...remotes.added, ...remotes.updated]) { try { await hub.syncRemoteKeys(name); } catch { /* best effort */ } }
298
+ summary.remotes = remotes;
299
+ log(` ✓ Hub3 IR リモコン: +${remotes.added.length}${remotes.added.length ? ` (${remotes.added.join(", ")})` : ""}`);
300
+ } catch (e) { summary.errors.push(`remotes: ${e.message}`); log(` ✗ リモコン取り込み失敗: ${e.message}`); }
301
+ });
302
+ } catch (e) {
303
+ summary.errors.push(`connect: ${e.message}`);
304
+ const authExpired = /refresh token|unauthor|not authenticated|token/i.test(e.message || "");
305
+ summary.authExpired = authExpired;
306
+ if (authExpired) log(` ✗ クラウド認証が切れています (${e.message})\n → \`sesame login <email>\` → \`sesame verify\` で再ログインしてください`);
307
+ else log(` ✗ 接続失敗: ${e.message}\n → 後で \`sesame setup\` で再実行できます`);
308
+ }
309
+ return summary;
310
+ }
311
+
312
+ async function cmdVerify(code, _opts, program) {
313
+ const { opts, tokenStore } = loadCtx(program);
314
+ if (!code && canPrompt(program)) code = await promptLine("Verification code: ");
315
+ if (!code) die("code required: sesame verify <code>", 2);
316
+ const t = await loginVerify(tokenStore, code);
317
+ if (!opts.json) console.error("OK: signed in — セットアップを自動実行します...");
318
+ // 認証後の取り込みを自動化 (companyID / ロック / Hub3 IR)。失敗しても認証成功は維持。
319
+ const bootstrap = await bootstrapAfterLogin(program, { quiet: !!opts.json });
320
+ out(opts.json, () => {
321
+ const lk = bootstrap.locks ? bootstrap.locks.added.length + bootstrap.locks.updated.length : 0;
322
+ console.log(`\n準備完了: ロック ${lk} 件 取り込み済み。`);
323
+ console.log(" 例: sesame unlock / sesame status / sesame session");
324
+ if (bootstrap.errors.length) console.log(" (一部自動取り込みに失敗。`sesame setup` で再実行できます)");
325
+ }, {
326
+ ok: true,
327
+ clientId: t.clientId,
328
+ username: t.username,
329
+ deviceKey: t.deviceKey ? "set" : null,
330
+ bootstrap,
331
+ });
332
+ }
333
+
334
+ /** 認証後セットアップの手動再実行 (デバイス追加後など)。 */
335
+ async function cmdSetup(_opts, program) {
336
+ const { opts, tokenStore } = loadCtx(program);
337
+ if (!tokenStore.load()) die("未ログインです。先に `sesame login <email>` → `sesame verify` を実行してください。", 2);
338
+ if (!opts.json) console.error("セットアップ (companyID / ロック / Hub3 / リモコン 取り込み)...");
339
+ const bootstrap = await bootstrapAfterLogin(program, { quiet: !!opts.json });
340
+ const failed = bootstrap.errors.length > 0;
341
+ out(opts.json, () => {
342
+ if (bootstrap.authExpired) {
343
+ console.error("✗ クラウド認証が切れています。`sesame login <email>` → `sesame verify` で再ログインしてください。");
344
+ } else if (failed) {
345
+ console.error(`一部失敗: ${bootstrap.errors.join("; ")}`);
346
+ } else {
347
+ console.log("完了。`sesame locks ls` / `sesame remote ls` で確認できます。");
348
+ }
349
+ }, { ok: !failed, bootstrap });
350
+ if (failed) process.exitCode = 1;
351
+ }
352
+
353
+ async function cmdRefresh(_opts, program) {
354
+ const { opts, tokenStore } = loadCtx(program);
355
+ const t = await getValidIdToken(tokenStore, { marginSec: 999999 });
356
+ out(opts.json, () => {
357
+ console.log(`OK: idToken refreshed (len=${t.length})`);
358
+ }, { ok: true, idTokenLength: t.length });
359
+ }
360
+
361
+ async function cmdWhoami(_opts, program) {
362
+ await withHub(program, async (hub, { opts }) => {
363
+ // biz3GetLoginUser で customerInfo/quotas を取得し、実 companyID を config に保存
364
+ const customerInfo = await hub.refreshAccount();
365
+ const quotas = (await hub.getLoginUser()).quotas;
366
+ out(opts.json, () => {
367
+ if (!customerInfo) { console.log("(customerInfo 取得できず)"); return; }
368
+ console.log(`companyID: ${customerInfo.companyID}`);
369
+ console.log(`subUUID: ${customerInfo.subUUID || "(none)"}`);
370
+ if (customerInfo.name) console.log(`name: ${customerInfo.name}`);
371
+ if (customerInfo.subscriptionId) console.log(`subscription: ${customerInfo.subscriptionId}`);
372
+ console.log(`\ncompanyID を config.json に保存しました (以降の IR/device API で使用)。`);
373
+ }, { ok: true, customerInfo, quotas });
374
+ });
375
+ }
376
+
377
+ // ---------- コマンド: 操作 ----------
378
+
379
+ async function cmdSend(key, options, program) {
380
+ const { configStore } = loadCtx(program);
381
+ if (configStore.exists()) {
382
+ const remoteName = await pickRemoteName(program, configStore, options.remote);
383
+ if (!remoteName && !options.remote) die("--remote が必要です (非対話モード)", 2);
384
+ options.remote = remoteName || options.remote;
385
+ if (!key) {
386
+ key = await pickRemoteKeyName(program, configStore, options.remote, key);
387
+ if (!key) die("key が必要です (非対話モード)", 2);
388
+ }
389
+ } else if (!key) {
390
+ die("key required: sesame send <key>", 2);
391
+ }
392
+ await withHub(program, async (hub, { opts }) => {
393
+ const resp = await hub.send(options.remote, key);
394
+ out(opts.json, () => {
395
+ console.log(`OK: send ${key}`);
396
+ if (resp?.data?.message) console.log(` ${resp.data.message}`);
397
+ }, { ok: true, key, response: resp });
398
+ });
399
+ }
400
+
401
+ async function cmdList(options, program) {
402
+ const { configStore } = loadCtx(program);
403
+ if (configStore.exists()) {
404
+ options.remote = await pickRemoteName(program, configStore, options.remote) || options.remote;
405
+ }
406
+ await withHub(program, async (hub, { opts }) => {
407
+ const codes = await hub.listKeys(options.remote);
408
+ out(opts.json, () => {
409
+ if (!Array.isArray(codes) || codes.length === 0) {
410
+ console.log("(no keys)");
411
+ return;
412
+ }
413
+ console.log(`Found ${codes.length} keys:`);
414
+ for (const c of codes) console.log(` ${c.name}\t${c.keyUUID}`);
415
+ }, { ok: true, count: codes.length, keys: codes });
416
+ });
417
+ }
418
+
419
+ async function cmdPing(_opts, program) {
420
+ await withHub(program, async (hub, { opts }) => {
421
+ await hub.ping();
422
+ out(opts.json, () => console.log("OK: connected & keepalive ack received"), { ok: true });
423
+ });
424
+ }
425
+
426
+ async function cmdDevices(_opts, program) {
427
+ await withHub(program, async (hub, { opts, paths }) => {
428
+ const list = await hub.listDevices();
429
+ mkdirSync(paths.dir, { recursive: true });
430
+ writeFileSync(paths.devices, JSON.stringify({ devices: list }, null, 2) + "\n");
431
+ out(opts.json, () => {
432
+ console.log(`Found ${list.length} devices:\n`);
433
+ for (const d of list) {
434
+ console.log(` ${d.deviceName}`);
435
+ console.log(` model: ${d.deviceModel}`);
436
+ console.log(` UUID: ${d.deviceUUID}`);
437
+ console.log(` keyLevel: ${d.keyLevel}`);
438
+ console.log(` publicKey: ${mask(d.sesame2PublicKey)}`);
439
+ console.log(` secretKey: ${mask(d.secretKey)}`);
440
+ console.log("");
441
+ }
442
+ console.log(`Saved: ${paths.devices}`);
443
+ }, { ok: true, count: list.length, devices: list, savedTo: paths.devices });
444
+ });
445
+ }
446
+
447
+ // ---------- コマンド: セットアップ / 設定 ----------
448
+
449
+ async function cmdInit(_opts, program) {
450
+ const { opts, paths, configStore } = loadCtx(program);
451
+ mkdirSync(paths.dir, { recursive: true });
452
+ const created = configStore.init();
453
+ out(opts.json, () => {
454
+ if (created) console.log(`OK: created ${paths.config}`);
455
+ else console.log(`Already exists: ${paths.config}`);
456
+ console.log(``);
457
+ console.log(`このツールは Node.js 18+ が必要です (現在: ${process.version})。`);
458
+ console.log(`companyID はデフォルト (ch_CandyhouseMobile) のままで一般ユーザーは変更不要です。`);
459
+ console.log(``);
460
+ console.log(`次のステップ (所要 約3分):`);
461
+ console.log(` 1. sesame login <email> # email に確認コードが届く → sesame verify <code>`);
462
+ console.log(` 2a. ロックを使うなら: sesame lock sync-from-devices # 自動取り込み`);
463
+ console.log(` 2b. Hub3 IR を使うなら: sesame remote sync-from-devices # Hub3+リモコン+キーを一括取得`);
464
+ console.log(``);
465
+ console.log(`概念: Hub3=IR を飛ばす中継器 / remote=リモコン定義 / lock=施錠デバイス。`);
466
+ console.log(` IR を使うには Hub3 と remote の両方を登録する必要があります。`);
467
+ console.log(` irType は整数コード (例 49152=エアコン / 8192=テレビ) だが、通常は自動取得され意識不要。`);
468
+ console.log(``);
469
+ console.log(`※ \`sesame\` コマンドが見つからない場合は \`npm link\` を実行してください`);
470
+ console.log(` (または \`node bin/sesame.js ...\` で直接起動)。`);
471
+ }, { ok: true, created, configPath: paths.config, nodeVersion: process.version });
472
+ }
473
+
474
+ async function cmdConfigPath(opts, program) {
475
+ const { paths } = loadCtx(program);
476
+ out(CLI_JSON, () => console.log(paths.dir), { dir: paths.dir });
477
+ }
478
+
479
+ async function cmdConfigShow(_opts, program) {
480
+ const { opts, paths, configStore, tokenStore } = loadCtx(program);
481
+ const cfg = configStore.exists() ? configStore.load() : null;
482
+ const tokens = tokenStore.load();
483
+ const tokensMasked = tokens
484
+ ? {
485
+ clientId: tokens.clientId,
486
+ username: tokens.username,
487
+ idToken: mask(tokens.idToken),
488
+ refreshToken: mask(tokens.refreshToken),
489
+ accessToken: mask(tokens.accessToken),
490
+ deviceKey: tokens.deviceKey ? "set" : null,
491
+ lastRefresh: tokens.lastRefresh,
492
+ }
493
+ : null;
494
+ out(opts.json, () => {
495
+ console.log(`config dir: ${paths.dir}`);
496
+ console.log(`---- config.json ----`);
497
+ console.log(cfg ? JSON.stringify(cfg, null, 2) : "(not initialized)");
498
+ console.log(`---- tokens.json (masked) ----`);
499
+ console.log(tokensMasked ? JSON.stringify(tokensMasked, null, 2) : "(not signed in)");
500
+ }, { configDir: paths.dir, config: cfg, tokens: tokensMasked });
501
+ }
502
+
503
+ async function cmdRemoteLs(_opts, program) {
504
+ const { opts, configStore } = loadCtx(program);
505
+ if (!configStore.exists()) die("config not initialized. sesame init", 2);
506
+ const cfg = configStore.load();
507
+ const remotes = cfg.remotes || {};
508
+ const def = cfg.default?.remote;
509
+ out(opts.json, () => {
510
+ const names = Object.keys(remotes);
511
+ if (!names.length) { console.log("(no remotes)"); return; }
512
+ for (const n of names) {
513
+ const r = remotes[n];
514
+ const mark = n === def ? "*" : " ";
515
+ const keyCount = Object.keys(r.keys || {}).length;
516
+ console.log(`${mark} ${n}\thub3=${r.hub3}\tIR=${r.irDeviceUUID}\tkeys=${keyCount}${r.alias ? `\talias=${r.alias}` : ""}`);
517
+ }
518
+ console.log("\n(* = default)");
519
+ }, { default: def, remotes });
520
+ }
521
+
522
+ async function cmdRemoteAdd(_opts, program) {
523
+ // devices の応答だけで完結: Hub3 配下リモコン (uuid+type) を一覧から選ぶ。
524
+ // irType も irDeviceUUID も手打ちさせない。
525
+ await withHub(program, async (hub, { opts }) => {
526
+ await hub.syncHub3sFromDevices(); // hub3 名を確保
527
+ const candidates = await hub.listRemotesFromDevices();
528
+ if (!candidates.length) {
529
+ console.error("登録済みリモコンが見つかりません。biz3 アプリ等で先にリモコンを学習・登録してください。");
530
+ return;
531
+ }
532
+ const chosen = candidates.length === 1
533
+ ? candidates[0]
534
+ : await selectFromList("どのリモコン?", candidates,
535
+ (r) => `${r.alias || "(no name)"}\thub3=${r.hub3Name}\ttype=${r.type}\t${r.uuid}`);
536
+
537
+ // chosen.hub3DeviceUUID に対応する config 上の hub3 名を解決
538
+ const cfg = hub.config;
539
+ const hub3Entry = Object.entries(cfg.hub3s).find(
540
+ ([, h]) => h.deviceId.replace(/-/g, "").toLowerCase() === chosen.hub3DeviceUUID.replace(/-/g, "").toLowerCase(),
541
+ );
542
+ const hub3Name = hub3Entry ? hub3Entry[0] : null;
543
+ if (!hub3Name) die("リモコンの親 Hub3 が config に見つかりません (hub3 sync-from-devices を試してください)", 2);
544
+
545
+ const defaultName = (chosen.alias || "remote").replace(/\s+/g, "_").toLowerCase();
546
+ const name = canPrompt(program)
547
+ ? await promptText("config 上の呼び名", { defaultValue: defaultName })
548
+ : defaultName;
549
+
550
+ hub.configStore.addRemote(name, {
551
+ hub3: hub3Name,
552
+ irDeviceUUID: chosen.uuid,
553
+ irType: chosen.type,
554
+ irOperation: "learnEmit",
555
+ alias: chosen.alias,
556
+ keys: {},
557
+ });
558
+ const { keyCount } = await hub.syncRemoteKeys(name); // 末尾で自動 sync-keys
559
+ out(opts.json, () => {
560
+ console.log(`OK: remote "${name}" added (hub3=${hub3Name}, irType=${chosen.type}, keys=${keyCount})`);
561
+ }, { ok: true, name, hub3: hub3Name, irType: chosen.type, keyCount });
562
+ });
563
+ }
564
+
565
+ async function cmdRemoteSetDefault(name, opts, program) {
566
+ const { configStore } = loadCtx(program);
567
+ configStore.setDefaultRemote(name);
568
+ out(CLI_JSON, () => console.log(`OK: default remote = ${name}`), { ok: true, defaultRemote: name });
569
+ }
570
+
571
+ async function cmdRemoteSyncKeys(name, opts, program) {
572
+ await withHub(program, async (hub) => {
573
+ const { name: resolvedName, keyCount } = await hub.syncRemoteKeys(name);
574
+ out(CLI_JSON, () => console.log(`OK: synced ${keyCount} keys → remote "${resolvedName}"`),
575
+ { ok: true, remote: resolvedName, keyCount });
576
+ });
577
+ }
578
+
579
+ async function cmdHub3Ls(_opts, program) {
580
+ const { opts, configStore } = loadCtx(program);
581
+ if (!configStore.exists()) die("config not initialized. sesame init", 2);
582
+ const cfg = configStore.load();
583
+ const hub3s = cfg.hub3s || {};
584
+ out(opts.json, () => {
585
+ const names = Object.keys(hub3s);
586
+ if (!names.length) { console.log("(no hub3)"); return; }
587
+ for (const n of names) {
588
+ const h = hub3s[n];
589
+ console.log(` ${n}\t${h.deviceId}${h.name && h.name !== n ? `\t(${h.name})` : ""}`);
590
+ }
591
+ }, { hub3s });
592
+ }
593
+
594
+ async function cmdHub3Add(_opts, program) {
595
+ // devices から Hub3 を引いて選択式に (UUID 手打ちを排除)。
596
+ await withHub(program, async (hub, { opts }) => {
597
+ const list = await hub.listDevices();
598
+ const hub3Devices = list.filter((d) => d.deviceModel === "hub_3" || d.deviceModel === "hub_3_lte");
599
+ if (!hub3Devices.length) {
600
+ console.error("Hub3 が devices に見つかりません。手動登録するなら configStore.addHub3 を直接利用してください。");
601
+ return;
602
+ }
603
+ const chosen = hub3Devices.length === 1
604
+ ? hub3Devices[0]
605
+ : await selectFromList("どの Hub3?", hub3Devices,
606
+ (d) => `${d.deviceName || "(no name)"}\t${d.deviceUUID}`);
607
+ const defaultName = (chosen.deviceName || chosen.deviceUUID).replace(/\s+/g, "_").toLowerCase();
608
+ const name = canPrompt(program)
609
+ ? await promptText("config 上の呼び名", { defaultValue: defaultName })
610
+ : defaultName;
611
+ hub.configStore.addHub3(name, { deviceId: chosen.deviceUUID, name: chosen.deviceName || name });
612
+ out(opts.json, () => console.log(`OK: hub3 "${name}" added (${chosen.deviceUUID})`),
613
+ { ok: true, name, deviceId: chosen.deviceUUID });
614
+ });
615
+ }
616
+
617
+ // ---------- コマンド: lock ----------
618
+
619
+ async function cmdLockLs(_opts, program) {
620
+ const { opts, configStore } = loadCtx(program);
621
+ if (!configStore.exists()) die("config not initialized. sesame init", 2);
622
+ const cfg = configStore.load();
623
+ const locks = cfg.locks || {};
624
+ const def = cfg.default?.lock;
625
+ out(opts.json, () => {
626
+ const names = Object.keys(locks);
627
+ if (!names.length) { console.log("(no locks)"); return; }
628
+ for (const n of names) {
629
+ const l = locks[n];
630
+ const mark = n === def ? "*" : " ";
631
+ console.log(`${mark} ${n}\t${l.deviceUUID}\tmodel=${l.model || "?"}${l.alias ? `\t(${l.alias})` : ""}`);
632
+ }
633
+ console.log("\n(* = default)");
634
+ }, { default: def, locks });
635
+ }
636
+
637
+ async function cmdLockAdd(opts, program) {
638
+ const { configStore } = loadCtx(program);
639
+ if (!configStore.exists()) die("config not initialized. sesame init", 2);
640
+
641
+ // フラグ指定があれば非対話で登録 (他言語からの呼び出し/--json 用)。
642
+ // 不足分は TTY なら prompt で補い、非対話なら die で明示拒否する (固まらせない)。
643
+ const ask = async (flag, label, required) => {
644
+ if (opts[flag] != null) return opts[flag];
645
+ if (canPrompt(program)) return await promptLine(label);
646
+ if (required) die(`${flag} required (非対話モードでは --${flag} を指定してください)`, 2);
647
+ return null;
648
+ };
649
+ const name = await ask("name", "lock name (例: front): ", true);
650
+ if (!name) die("name required", 2);
651
+ const deviceUUID = await ask("uuid", "deviceUUID: ", true);
652
+ if (!deviceUUID) die("deviceUUID required", 2);
653
+ const secretKey = await ask("secret", "secretKey (32hex, devices コマンドで取得): ", true);
654
+ if (!secretKey) die("secretKey required", 2);
655
+ const model = await ask("model", "model (例: sesame_5, sesame_5_pro, sesame_6, wm_2): ", false);
656
+ const alias = await ask("alias", "alias (任意, 例: 玄関): ", false);
657
+ configStore.addLock(name, {
658
+ deviceUUID,
659
+ secretKey,
660
+ model: model || null,
661
+ alias: alias || null,
662
+ });
663
+ out(CLI_JSON, () => console.log(`OK: lock "${name}" added.`),
664
+ { ok: true, lock: name, deviceUUID, model: model || null, alias: alias || null });
665
+ }
666
+
667
+ async function cmdLockSetDefault(name, opts, program) {
668
+ const { configStore } = loadCtx(program);
669
+ configStore.setDefaultLock(name);
670
+ out(CLI_JSON, () => console.log(`OK: default lock = ${name}`), { ok: true, defaultLock: name });
671
+ }
672
+
673
+ async function cmdLockRm(name, options, program) {
674
+ const { configStore } = loadCtx(program);
675
+ // Review M-4: 確認 prompt 追加 (secretKey が消えると復旧は devices 再取得が必要)
676
+ // 2nd-pass M-4: 非対話モードでは prompt 不能なので --yes が無いと拒否
677
+ if (canPrompt(program)) {
678
+ if (!(await confirmPrompt(
679
+ `lock "${name}" の定義を削除しますか? (secretKey も消えるので、復旧には sesame devices で再取得が必要)`,
680
+ { defaultYes: false },
681
+ ))) {
682
+ return console.error("キャンセル");
683
+ }
684
+ } else if (!options.yes) {
685
+ die(`非対話モードでは確認 prompt が出せません。意図して削除する場合は --yes を付けてください。`, 2);
686
+ }
687
+ configStore.removeLock(name);
688
+ out(CLI_JSON, () => console.log(`OK: lock "${name}" removed.`), { ok: true, removed: name });
689
+ }
690
+
691
+ async function cmdLockSyncFromDevices(options, program) {
692
+ await withHub(program, async (hub, { opts }) => {
693
+ const r = await hub.syncLocksFromDevices({ prune: !!options.prune });
694
+ printSyncResult(opts.json, "lock", r);
695
+ });
696
+ }
697
+
698
+ async function cmdHub3SyncFromDevices(options, program) {
699
+ await withHub(program, async (hub, { opts }) => {
700
+ const r = await hub.syncHub3sFromDevices({ prune: !!options.prune });
701
+ printSyncResult(opts.json, "hub3", r);
702
+ });
703
+ }
704
+
705
+ async function cmdRemoteSyncFromDevices(_options, program) {
706
+ // 引数不要。devices の各 Hub3 stateInfo.remoteList から irType 込みで全リモコン取り込み。
707
+ await withHub(program, async (hub, { opts }) => {
708
+ const { remotes } = await hub.syncRemotesFromDevices();
709
+ // 取り込んだ各 remote のキーも同期
710
+ for (const name of [...remotes.added, ...remotes.updated]) {
711
+ try { await hub.syncRemoteKeys(name); } catch { /* best effort */ }
712
+ }
713
+ printSyncResult(opts.json, "remote", remotes);
714
+ });
715
+ }
716
+
717
+ /** sync 系の結果 (added/updated/removed) を整形出力。 */
718
+ function printSyncResult(json, kind, r) {
719
+ out(json, () => {
720
+ const parts = [];
721
+ if (r.added?.length) parts.push(`+${r.added.length} (${r.added.join(", ")})`);
722
+ if (r.updated?.length) parts.push(`~${r.updated.length} (${r.updated.join(", ")})`);
723
+ if (r.removed?.length) parts.push(`-${r.removed.length} (${r.removed.join(", ")})`);
724
+ console.log(`OK: ${kind} sync — ${parts.join(" / ") || "変更なし"}`);
725
+ }, { ok: true, kind, ...r });
726
+ }
727
+
728
+ // ---------- コマンド: IR advanced (Phase C) ----------
729
+
730
+ async function cmdIRLearn(remoteName, keyName, _opts, program) {
731
+ const { configStore } = loadCtx(program);
732
+ if (configStore.exists()) {
733
+ remoteName = await pickRemoteName(program, configStore, remoteName) || remoteName;
734
+ }
735
+ if (!keyName && canPrompt(program)) {
736
+ keyName = await promptText("登録するキー名");
737
+ }
738
+ if (!keyName) die("keyname required: sesame ir learn <remote> <keyname>", 2);
739
+ await withHub(program, async (hub, { opts }) => {
740
+ console.error(`Hub3 を学習モードに切替中... (remote=${remoteName || "default"}, key="${keyName}")`);
741
+ const result = await hub.learnIR(remoteName, keyName, {
742
+ onPrompt: () => console.error("→ 物理リモコンを Hub3 に向けて、登録したいボタンを押してください..."),
743
+ });
744
+ out(opts.json, () => {
745
+ console.log(`OK: learned "${keyName}"`);
746
+ if (result.saved?.keyUUID) console.log(` keyUUID: ${result.saved.keyUUID}`);
747
+ if (result.captured?.irData) {
748
+ const head = result.captured.irData.slice(0, 32);
749
+ console.log(` irData: ${head}... (len=${result.captured.irData.length})`);
750
+ }
751
+ }, { ok: true, key: keyName, ...result });
752
+ });
753
+ }
754
+
755
+ async function cmdIRModeGet(hub3Name, _opts, program) {
756
+ await withHub(program, async (hub, { opts }) => {
757
+ const mode = await hub.getIRMode(hub3Name);
758
+ out(opts.json, () => console.log(`mode: ${JSON.stringify(mode)}`), { mode });
759
+ });
760
+ }
761
+
762
+ async function cmdIRModeSet(hub3Name, mode, _opts, program) {
763
+ const m = Number(mode);
764
+ if (![0, 1].includes(m)) die("mode must be 0 (CONTROL) or 1 (REGISTER)", 2);
765
+ await withHub(program, async (hub, { opts }) => {
766
+ await hub.setIRMode(hub3Name, m);
767
+ out(opts.json, () => console.log(`OK: mode=${m} (${m === 0 ? "CONTROL" : "REGISTER"})`), { ok: true, mode: m });
768
+ });
769
+ }
770
+
771
+ async function cmdIRKeyRm(remoteName, keyName, options, program) {
772
+ const { configStore } = loadCtx(program);
773
+ if (configStore.exists()) {
774
+ remoteName = await pickRemoteName(program, configStore, remoteName) || remoteName;
775
+ keyName = await pickRemoteKeyName(program, configStore, remoteName, keyName) || keyName;
776
+ }
777
+ if (!keyName) die("key required", 2);
778
+ if (canPrompt(program)) {
779
+ if (!(await confirmPrompt(`key "${keyName}" を削除しますか?`, { defaultYes: false }))) {
780
+ return console.error("キャンセル");
781
+ }
782
+ } else if (!options?.yes) {
783
+ die(`非対話モードでは確認 prompt が出せません。--yes で強制削除可能。`, 2);
784
+ }
785
+ await withHub(program, async (hub, { opts }) => {
786
+ await hub.deleteIRKey(remoteName, keyName);
787
+ out(opts.json, () => console.log(`OK: deleted key "${keyName}" from remote "${remoteName || "default"}"`),
788
+ { ok: true });
789
+ });
790
+ }
791
+
792
+ async function cmdIRKeyRename(remoteName, keyName, newName, _opts, program) {
793
+ const { configStore } = loadCtx(program);
794
+ if (configStore.exists()) {
795
+ remoteName = await pickRemoteName(program, configStore, remoteName) || remoteName;
796
+ keyName = await pickRemoteKeyName(program, configStore, remoteName, keyName) || keyName;
797
+ }
798
+ if (!keyName) die("key required", 2);
799
+ if (!newName && canPrompt(program)) newName = await promptText(`新しい名前 (現: ${keyName})`);
800
+ if (!newName) die("new name required: sesame ir key rename <remote> <key> <new>", 2);
801
+ await withHub(program, async (hub, { opts }) => {
802
+ await hub.renameIRKey(remoteName, keyName, newName);
803
+ out(opts.json, () => console.log(`OK: renamed "${keyName}" → "${newName}"`), { ok: true });
804
+ });
805
+ }
806
+
807
+ async function cmdIRRemoteListServer(type, _opts, program) {
808
+ let t;
809
+ try { t = parseIrType(type); } catch (e) { die(e.message, 2); }
810
+ await withHub(program, async (hub, { opts }) => {
811
+ const list = await hub.listIRRemotes(t);
812
+ out(opts.json, () => {
813
+ console.log(`Found ${list.length} remotes (type=${t}):`);
814
+ for (const r of list) {
815
+ console.log(` ${r.alias || r.name || "(no name)"}\t${r.irDeviceUUID || r.uuid || ""}`);
816
+ }
817
+ }, { count: list.length, remotes: list });
818
+ });
819
+ }
820
+
821
+ async function cmdIRRemoteSearch(type, term, _opts, program) {
822
+ let t;
823
+ try { t = parseIrType(type); } catch (e) { die(e.message, 2); }
824
+ if (!term) die("search term required", 2);
825
+ await withHub(program, async (hub, { opts }) => {
826
+ const list = await hub.searchPresetIRRemotes(t, term);
827
+ out(opts.json, () => {
828
+ console.log(`Found ${list.length} preset remotes:`);
829
+ for (const r of list) {
830
+ console.log(` ${r.brandName || r.name || "?"}\t${r.modelName || r.model || ""}\t${r.uuid || ""}`);
831
+ }
832
+ }, { count: list.length, results: list });
833
+ });
834
+ }
835
+
836
+ async function cmdIRRemoteMatch(type, irData, _opts, program) {
837
+ let t;
838
+ try { t = parseIrType(type); } catch (e) { die(e.message, 2); }
839
+ if (!irData) die("irData required (hex)", 2);
840
+ await withHub(program, async (hub, { opts }) => {
841
+ const matches = await hub.matchIRRemote({ irData, irType: t });
842
+ out(opts.json, () => {
843
+ console.log(`Found ${matches.length} matching remotes`);
844
+ for (const m of matches) console.log(` ${JSON.stringify(m)}`);
845
+ }, { count: matches.length, matches });
846
+ });
847
+ }
848
+
849
+ async function cmdIRRemoteRmServer(name, _opts, program) {
850
+ await withHub(program, async (hub, { opts }) => {
851
+ await hub.deleteIRRemoteServer(name);
852
+ out(opts.json, () => console.log(`OK: deleted server-side remote "${name || "default"}"`),
853
+ { ok: true });
854
+ });
855
+ }
856
+
857
+ async function cmdIRRemoteRenameServer(name, alias, _opts, program) {
858
+ if (!alias) die("alias required", 2);
859
+ await withHub(program, async (hub, { opts }) => {
860
+ await hub.renameIRRemote(name, alias);
861
+ out(opts.json, () => console.log(`OK: renamed remote "${name || "default"}" → "${alias}"`),
862
+ { ok: true });
863
+ });
864
+ }
865
+
866
+ // ---------- コマンド: device management (Phase D) ----------
867
+
868
+ async function cmdDeviceUserLs(_opts, program) {
869
+ await withHub(program, async (hub, { opts }) => {
870
+ const list = await hub.listUserDevices();
871
+ out(opts.json, () => {
872
+ console.log(`Found ${list.length} user devices:\n`);
873
+ for (const d of list) {
874
+ console.log(` ${d.deviceName || "(no name)"}\t${d.deviceModel || "?"}\t${d.deviceUUID || ""}`);
875
+ }
876
+ }, { count: list.length, devices: list });
877
+ });
878
+ }
879
+
880
+ async function cmdDeviceStatus(uuid, _opts, program) {
881
+ await withHub(program, async (hub, { opts }) => {
882
+ uuid = await pickDeviceUUID(program, hub, uuid, { message: "どのデバイスの状態を見ますか?" }) || uuid;
883
+ if (!uuid) die("deviceUUID required", 2);
884
+ const status = await hub.getDeviceStatus(uuid);
885
+ out(opts.json, () => console.log(JSON.stringify(status, null, 2)), { status });
886
+ });
887
+ }
888
+
889
+ async function cmdDeviceRename(uuid, newName, _opts, program) {
890
+ await withHub(program, async (hub, { opts }) => {
891
+ uuid = await pickDeviceUUID(program, hub, uuid, { message: "どのデバイスを rename しますか?" }) || uuid;
892
+ if (!uuid) die("deviceUUID required", 2);
893
+ if (!newName && canPrompt(program)) newName = await promptText("新しいデバイス名");
894
+ if (!newName) die("new name required: sesame device rename <uuid> <name>", 2);
895
+ await hub.renameDevice(uuid, newName);
896
+ out(opts.json, () => console.log(`OK: renamed ${uuid} → "${newName}"`), { ok: true });
897
+ });
898
+ }
899
+
900
+ async function cmdDeviceRm(uuid, options, program) {
901
+ await withHub(program, async (hub, { opts }) => {
902
+ uuid = await pickDeviceUUID(program, hub, uuid, { message: "どのデバイスを削除しますか?" }) || uuid;
903
+ if (!uuid) die("deviceUUID required", 2);
904
+ if (canPrompt(program)) {
905
+ if (!(await confirmPrompt(`デバイス ${uuid} を削除しますか?`, { defaultYes: false }))) {
906
+ return console.error("キャンセル");
907
+ }
908
+ } else if (!options.yes) {
909
+ die(`非対話モードでは確認 prompt が出せません。意図して削除する場合は --yes を付けてください。`, 2);
910
+ }
911
+ await hub.deleteDevice(uuid);
912
+ out(opts.json, () => console.log(`OK: deleted device ${uuid}`), { ok: true });
913
+ });
914
+ }
915
+
916
+ async function cmdHistory(deviceUUID, options, program) {
917
+ await withHub(program, async (hub, { opts }) => {
918
+ if (!deviceUUID && canPrompt(program)) {
919
+ deviceUUID = await pickDeviceUUID(program, hub, null, { message: "どのデバイスの履歴?" });
920
+ }
921
+ const pageSize = options.pageSize ? Number(options.pageSize) : null;
922
+ const list = deviceUUID ? [{ deviceUUID }] : [];
923
+ const data = await hub.getDeviceHistory(list, pageSize);
924
+ out(opts.json, () => console.log(JSON.stringify(data, null, 2)), { data });
925
+ });
926
+ }
927
+
928
+ async function cmdBattery(deviceUUID, options, program) {
929
+ await withHub(program, async (hub, { opts }) => {
930
+ deviceUUID = await pickDeviceUUID(program, hub, deviceUUID, {
931
+ message: "どのデバイスの電池履歴?",
932
+ filter: (d) => /^(sesame_|wm_|ssmbot_|bot_|bike_)/.test(d.deviceModel || ""),
933
+ }) || deviceUUID;
934
+ if (!deviceUUID) die("deviceUUID required", 2);
935
+ const pageSize = options.pageSize ? Number(options.pageSize) : 100;
936
+ const data = await hub.getDeviceBattery(deviceUUID, { pageSize });
937
+ out(opts.json, () => {
938
+ const recs = data.records || [];
939
+ console.log(`Battery records: ${recs.length}`);
940
+ for (const r of recs) {
941
+ const t = r.ts ? new Date(r.ts * 1000).toISOString() : "?";
942
+ console.log(` ${t}\tlight=${r.light}\theavy=${r.heavy}\tlight%=${r.lightPercentage}\theavy%=${r.heavyPercentage}`);
943
+ }
944
+ if (data.lastEvaluatedKey) console.log(`\n次ページ key: ${JSON.stringify(data.lastEvaluatedKey)}`);
945
+ }, data);
946
+ });
947
+ }
948
+
949
+ async function cmdFirmware(_opts, program) {
950
+ await withHub(program, async (hub, { opts }) => {
951
+ const list = await hub.listFirmware();
952
+ out(opts.json, () => console.log(JSON.stringify(list, null, 2)), { firmwares: list });
953
+ });
954
+ }
955
+
956
+ async function cmdWebapi(func, options, program) {
957
+ if (!func) die("func required: sesame webapi <func> [--query json] [--body json] [--api-key ID]", 2);
958
+ let query = {}, body = {};
959
+ try {
960
+ if (options.query) query = JSON.parse(options.query);
961
+ if (options.body) body = JSON.parse(options.body);
962
+ } catch (e) {
963
+ die(`invalid JSON in --query/--body: ${e.message}`, 2);
964
+ }
965
+ await withHub(program, async (hub, { opts }) => {
966
+ const data = await hub.invokeWebAPI({ func, query, body, apiKeyId: options.apiKey });
967
+ out(opts.json, () => console.log(JSON.stringify(data, null, 2)), { data });
968
+ });
969
+ }
970
+
971
+ // ---------- 既存の lock op ----------
972
+
973
+ // ---------- 統合ロック操作 (トップレベル動詞 unlock/lock/toggle/status/autolock/bot) ----------
974
+
975
+ /**
976
+ * config からロック entry を解決する。
977
+ * 優先: 位置引数/--name → default.lock → 単一なら自動 → 部分一致 → 対話選択。
978
+ * @returns {{name:string, deviceUUID:string, secretKey:string}|null} die 済みなら null
979
+ */
980
+ async function resolveLockEntry(program, name) {
981
+ const { configStore } = loadCtx(program);
982
+ if (!configStore.exists()) { die("config がありません。`sesame init` → `sesame locks sync-from-devices` を先に。", 2); return null; }
983
+ const cfg = configStore.load();
984
+ const locks = cfg.locks || {};
985
+ const names = Object.keys(locks);
986
+ if (names.length === 0) { die("ロックが未登録です。`sesame locks sync-from-devices` で取り込んでください。", 2); return null; }
987
+
988
+ let chosen = null;
989
+ if (name) {
990
+ if (locks[name]) chosen = name; // 完全一致
991
+ else {
992
+ // 部分一致 (大文字小文字無視)
993
+ const matches = names.filter((n) => n.toLowerCase().includes(String(name).toLowerCase()));
994
+ if (matches.length === 1) chosen = matches[0];
995
+ else if (matches.length > 1) { die(`"${name}" が複数に一致: ${matches.join(", ")}`, 2); return null; }
996
+ else { die(`ロック "${name}" が見つかりません。候補: ${names.join(", ")}`, 2); return null; }
997
+ }
998
+ } else {
999
+ chosen = cfg.default?.lock || (names.length === 1 ? names[0] : null);
1000
+ if (!chosen) {
1001
+ if (!canPrompt(program)) { die(`複数ロックがあります。名前を指定してください: ${names.join(", ")}`, 2); return null; }
1002
+ chosen = await selectFromList("どのロック?", names, (n) => `${n}\t${locks[n].deviceUUID}`);
1003
+ if (!chosen) { console.error("キャンセルしました。"); return null; }
1004
+ }
1005
+ }
1006
+ const lock = locks[chosen];
1007
+ if (!lock?.deviceUUID || !lock?.secretKey) { die(`lock "${chosen}" に deviceUUID/secretKey がありません (sesame locks sync-from-devices で取り込み直し)。`, 2); return null; }
1008
+ return { name: chosen, deviceUUID: lock.deviceUUID, secretKey: lock.secretKey, model: lock.model || null };
1009
+ }
1010
+
1011
+ /**
1012
+ * 単発コマンドの経路を決定する。
1013
+ * - 既定 (全部モード): 能力フル。経路はツールが自動選択する。BLE はスキャン/接続のオーバーヘッドが
1014
+ * あるため毎回は張らず、cloud で運べる op は cloud、cloud で運べない op (autolock など BLE 必須)
1015
+ * のみ BLE で一時接続する (cloud が速いという意味ではなく、BLE の接続コストを毎回払わないため)。
1016
+ * - `--ble-only` / `--cloud-only`: 経路を固定したいときの明示指定 (最優先)。
1017
+ * 「BLE 接続を保持する」モードは `sesame session`。運べる経路はデバイス型×op の能力から導出する。
1018
+ * @returns {"cloud"|"ble"}
1019
+ */
1020
+ function pickTransport(op, options, model) {
1021
+ if (options.cloudOnly && options.bleOnly) { die("--cloud-only と --ble-only は同時指定できません。", 2); }
1022
+ const allowed = transportsForOp(model, op);
1023
+ if (allowed.length === 0) { die(`${op} に利用できる経路がありません (この型では非対応)。`, 2); }
1024
+ if (options.bleOnly) {
1025
+ if (!allowed.includes("ble")) { die(`${op} は BLE では送れません。`, 2); }
1026
+ return "ble";
1027
+ }
1028
+ if (options.cloudOnly) {
1029
+ if (!allowed.includes("cloud")) { die(`${op} はクラウドでは実機に反映されません (BLE 必須)。--ble-only か無指定で。`, 2); }
1030
+ return "cloud";
1031
+ }
1032
+ // 全部モード: cloud で運べるなら cloud (BLE の接続コストを避けるため)。cloud 不可な op (autolock) のみ BLE。
1033
+ return allowed.includes("cloud") ? "cloud" : "ble";
1034
+ }
1035
+
1036
+ /** auto フォールバック先の cloud が使えるか (token があるか)。 */
1037
+ function hasCloudSession(program) {
1038
+ const { tokenStore } = loadCtx(program);
1039
+ const t = tokenStore.load();
1040
+ return !!(t && (t.refreshToken || t.idToken));
1041
+ }
1042
+
1043
+ /** mechStatus を 1 行に整形。 */
1044
+ function fmtMech(s) {
1045
+ if (!s) return "(status 未取得)";
1046
+ const warn = [s.isBatteryCritical && "⚠電池残少", s.isStop && "停止", s.isCritical && "異常"].filter(Boolean).join(" ");
1047
+ // position はロック (Sesame5/6) のみ。Bot/Bike は概念がないので state だけ表示する。
1048
+ const pos = s.position == null ? "" : ` pos=${s.position}`;
1049
+ return `state=${s.state}${pos}${warn ? " " + warn : ""}`;
1050
+ }
1051
+
1052
+ /** config の全ロック entry (deviceUUID/secretKey が揃っているもの) を返す。 */
1053
+ function allLockEntries(program) {
1054
+ const { configStore } = loadCtx(program);
1055
+ if (!configStore.exists()) { die("config がありません。`sesame init` → `sesame locks sync-from-devices` を先に。", 2); return []; }
1056
+ const locks = configStore.load().locks || {};
1057
+ return Object.entries(locks)
1058
+ .filter(([, l]) => l?.deviceUUID && l?.secretKey)
1059
+ .map(([name, l]) => ({ name, deviceUUID: l.deviceUUID, secretKey: l.secretKey, model: l.model || null }));
1060
+ }
1061
+
1062
+ /**
1063
+ * config の全 Hub3 entry を返す ({name, deviceId, model, secretKey})。
1064
+ * secretKey/model は devices レコード丸ごと保存により config に揃っているので、ここで返す
1065
+ * (relay/LED は secretKey 必須。旧実装の「session 開始時に listDevices で再取得」する band-aid は廃止)。
1066
+ */
1067
+ function allHub3Entries(program) {
1068
+ const { configStore } = loadCtx(program);
1069
+ if (!configStore.exists()) return [];
1070
+ const hub3s = configStore.load().hub3s || {};
1071
+ return Object.entries(hub3s)
1072
+ .filter(([, h]) => h?.deviceId)
1073
+ .map(([name, h]) => ({ name, deviceId: h.deviceId, model: h.model || "hub_3", secretKey: h.secretKey || null }));
1074
+ }
1075
+
1076
+ /** 指定 Hub3 名に属する remote の一覧 ({name, label}) を返す (IR 送信のリモコン選択用)。 */
1077
+ function remotesForHub3(program, hub3Name) {
1078
+ const { configStore } = loadCtx(program);
1079
+ if (!configStore.exists()) return [];
1080
+ const remotes = configStore.load().remotes || {};
1081
+ return Object.entries(remotes)
1082
+ .filter(([, r]) => r?.hub3 === hub3Name)
1083
+ .map(([name, r]) => ({ name, label: r.alias ? `${name} (${r.alias})` : name }));
1084
+ }
1085
+
1086
+ /** 名前 (部分一致・大文字小文字無視) で entry を1つ選ぶ。曖昧/不在は null + reason。 */
1087
+ function matchLockName(input, entries) {
1088
+ if (!input) return { entry: null, reason: "name required" };
1089
+ const exact = entries.find((e) => e.name === input);
1090
+ if (exact) return { entry: exact };
1091
+ const m = entries.filter((e) => e.name.toLowerCase().includes(String(input).toLowerCase()));
1092
+ if (m.length === 1) return { entry: m[0] };
1093
+ if (m.length > 1) return { entry: null, reason: `"${input}" が複数に一致: ${m.map((e) => e.name).join(", ")}` };
1094
+ return { entry: null, reason: `"${input}" に一致するロックなし` };
1095
+ }
1096
+
1097
+ /**
1098
+ * 統合ハンドラ。op: unlock|lock|toggle|status|autolock|bot。
1099
+ * @param {string} op
1100
+ * @param {string|undefined} name 位置引数のロック名
1101
+ * @param {string|null} seconds autolock 用
1102
+ * @param {object} options commander options (--ble-only/--cloud-only/--name)
1103
+ * @param {object} program
1104
+ */
1105
+ /** BLE 経由で 1 操作を実行。scanTimeoutMs を指定すると圏外時に早めに失敗 (auto の高速フォールバック用)。 */
1106
+ /** 接続済みの SesameBle に対して 1 操作を実行 (接続/切断は呼び出し側責務)。 */
1107
+ /**
1108
+ * 接続済み SesameBle に op を実行する**唯一のコア**。単発コマンド・セッションの両方がここを通る
1109
+ * (session は保持中の接続を、単発は都度張った接続を渡す。「保持接続があればそれで操作する」という
1110
+ * セッションモードの挙動が、両方の既定動作になる)。能力ゲートは SesameBle 側が担保。表示はしない。
1111
+ * @returns {{result:any, status:object|null}}
1112
+ */
1113
+ async function bleExec(op, ble, seconds) {
1114
+ let result = null;
1115
+ if (op === "autolock") result = await ble.autolock(Number(seconds));
1116
+ else if (op !== "status") result = await ble[op](); // lock/unlock/toggle/click (履歴タグ無し = SDK null-tag [00 0E])
1117
+ const status = await ble.status().catch(() => null);
1118
+ return { result, status };
1119
+ }
1120
+
1121
+ /** 接続済みの SesameBle に対して 1 操作を実行し、単発コマンド向けに表示する (接続/切断は呼び出し側責務)。 */
1122
+ async function runBleOnLock(op, lock, entry, seconds, gopts) {
1123
+ const { result, status } = await bleExec(op, lock, seconds);
1124
+ out(gopts.json, () => {
1125
+ if (op === "status") { console.log(`${entry.name}: ${fmtMech(status)}`); return; }
1126
+ console.log(`OK: ${op}${op === "autolock" ? ` ${Number(seconds) === 0 ? "無効化" : Number(seconds) + "秒"}` : ""} (${entry.name})`);
1127
+ if (status) console.log(` ${fmtMech(status)}`);
1128
+ }, { ok: true, op, name: entry.name, via: "ble", result, status });
1129
+ }
1130
+
1131
+ /** BLE で 1 操作 (connect→op→close)。--ble-only 明示 or BLE 必須 op (autolock) 用。 */
1132
+ async function runBleOp(op, entry, seconds, gopts, { scanTimeoutMs } = {}) {
1133
+ await SesameBle.use(
1134
+ { secretKey: entry.secretKey, deviceUUID: entry.deviceUUID, model: entry.model, debug: !!gopts.debug, scanTimeoutMs },
1135
+ (lock) => runBleOnLock(op, lock, entry, seconds, gopts),
1136
+ );
1137
+ }
1138
+
1139
+ /** クラウド経由で 1 操作を実行。 */
1140
+ async function runCloudOp(op, entry, program) {
1141
+ await withHub(program, async (hub, { opts }) => {
1142
+ if (op === "status") {
1143
+ const st = await hub.getDeviceStatus(entry.deviceUUID);
1144
+ out(opts.json, () => console.log(`${entry.name}: ${JSON.stringify(st)}`), { ok: true, op, name: entry.name, via: "cloud", status: st });
1145
+ return;
1146
+ }
1147
+ // click (Bot の BLE クリック) は cloud では botClick(cmd=89) に対応。
1148
+ const resp = (op === "bot" || op === "click") ? await hub.botClick(entry.name) : await hub[op](entry.name); // lock/unlock/toggle
1149
+ out(opts.json, () => {
1150
+ console.log(`OK: ${op} (${entry.name})`);
1151
+ if (resp?.data && Object.keys(resp.data).length) console.log(` ${JSON.stringify(resp.data)}`);
1152
+ }, { ok: true, op, name: entry.name, via: "cloud", response: resp });
1153
+ });
1154
+ }
1155
+
1156
+ // セッション UI で使う操作ラベル (ロック系 + Hub3 系)。
1157
+ const SESSION_LABEL = {
1158
+ unlock: "🔓 解錠", lock: "🔒 施錠", toggle: "↕ トグル", click: "👆 クリック", status: "ℹ 状態", autolock: "⏱ オートロック",
1159
+ ir: "📡 IR 送信", "relay-on": "🔌 リレー ON", "relay-off": "🔌 リレー OFF", led: "💡 LED 調光",
1160
+ };
1161
+
1162
+ /* exported for tests */
1163
+ /**
1164
+ * デバイス型 × 利用可能な経路の **和集合** で操作一覧を作る。
1165
+ * その op を運べる経路が今使えるときだけ出す: BLE 接続中なら ble 能力、ログイン済みなら cloud 能力。
1166
+ * (例: ロックは BLE 接続中のみ autolock を出す。OS2 ロックは cloud の lock/unlock/toggle のみ。)
1167
+ * @param {{kind:string, entry:object, ble:object|null}} d
1168
+ * @param {boolean} hasCloud クラウド経路が使えるか
1169
+ */
1170
+ function sessionActionsFor(d, hasCloud) {
1171
+ const caps = capabilitiesForModel(d.entry.model);
1172
+ // 今使える経路で運べる op の集合。
1173
+ const avail = new Set();
1174
+ if (d.ble) for (const o of caps.ble) avail.add(o);
1175
+ if (hasCloud) for (const o of caps.cloud) avail.add(o);
1176
+
1177
+ // 提示順: lock5 は現在状態から自然な順、それ以外は能力順。
1178
+ let ordered;
1179
+ if (caps.kind === "lock5") {
1180
+ const primary = d.ble?.lastStatus?.state === "locked" ? "unlock" : "lock";
1181
+ ordered = [primary, ...["unlock", "lock", "toggle", "autolock"].filter((o) => o !== primary)];
1182
+ } else {
1183
+ ordered = caps.ops; // bot2:[click] / bike2:[unlock] / hub3:[ir,relay,led] / os2lock:[lock,unlock,toggle] 等
1184
+ }
1185
+
1186
+ const acts = [];
1187
+ for (const o of ordered.filter((o) => avail.has(o))) {
1188
+ if (o === "relay") { // Hub3 のリレーは ON/OFF の 2 項目に展開。
1189
+ acts.push({ label: SESSION_LABEL["relay-on"], value: "relay-on" }, { label: SESSION_LABEL["relay-off"], value: "relay-off" });
1190
+ } else {
1191
+ acts.push({ label: SESSION_LABEL[o], value: o });
1192
+ }
1193
+ }
1194
+ if (caps.mechKind && d.ble) acts.push({ label: SESSION_LABEL.status, value: "status" }); // mech がある型は BLE 接続中のみ状態取得
1195
+ return acts;
1196
+ }
1197
+
1198
+ /** ヘッダの状態表示。BLE 接続済みは実 mechStatus、Hub3/未接続は注記 (クラウド状態は形が不定で正規化しない)。 */
1199
+ function sessionFmtState(d) {
1200
+ if (d.kind === "hub3") return "(Hub3: IR / リレー / LED)";
1201
+ return d.ble ? fmtMech(d.ble.lastStatus) : "(BLE未接続)";
1202
+ }
1203
+
1204
+ /**
1205
+ * 1 操作を実行し結果メッセージを返す。
1206
+ * ロック: BLE 接続済みなら BLE、無ければクラウド (autolock は BLE 必須)。
1207
+ * Hub3 : IR 送信 (extra={remote,key}) / リレー ON/OFF / LED (extra=duty)。いずれもクラウド。
1208
+ * @param {object|null} hub クラウドクライアント (未ログイン時 null)
1209
+ */
1210
+ function makeSessionExec(hub) {
1211
+ return async (op, d, extra) => {
1212
+ if (d.kind === "hub3") {
1213
+ if (!hub) return "Hub3 操作にはログインが必要です。";
1214
+ if (op === "ir") { await hub.send(extra.remote, extra.key); return `OK: IR 送信 ${extra.remote}/${extra.key} (${d.entry.name})`; }
1215
+ if (op === "relay-on" || op === "relay-off") {
1216
+ if (!d.entry.secretKey) return "Hub3 の secretKey が取得できていません (`sesame devices` で再取得)。";
1217
+ await hub.iot.hub3RelaySwitch({ deviceId: d.entry.deviceId, secretKey: d.entry.secretKey, op: op === "relay-on" ? 0x01 : 0x00 });
1218
+ return `OK: リレー ${op === "relay-on" ? "ON" : "OFF"} (${d.entry.name}) [応答なし]`;
1219
+ }
1220
+ if (op === "led") {
1221
+ if (!d.entry.secretKey) return "Hub3 の secretKey が取得できていません (`sesame devices` で再取得)。";
1222
+ const r = await hub.iot.setHub3LedDuty({ deviceId: d.entry.deviceId, secretKey: d.entry.secretKey, op: 0x01, duty: Number(extra) });
1223
+ return `OK: LED duty=${Number(extra)} (${d.entry.name})${r?.ledDuty != null ? ` → ${r.ledDuty}` : ""}`;
1224
+ }
1225
+ return `未対応の操作: ${op}`;
1226
+ }
1227
+ // ロック系
1228
+ if (d.ble) {
1229
+ const { status } = await bleExec(op, d.ble, extra);
1230
+ return op === "status" ? `${d.entry.name}: ${fmtMech(status)}` : `OK: ${SESSION_LABEL[op]} (${d.entry.name})`;
1231
+ }
1232
+ if (op === "autolock") return "autolock は BLE 必須です (デバイスに近づいて再試行してください)。";
1233
+ if (op === "status") return `${d.entry.name}: (クラウド接続中・状態詳細は BLE 接続時のみ)`;
1234
+ if (!hub) return "この操作には BLE 圏内かログインが必要です。";
1235
+ if (op === "click") await hub.botClick(d.entry.name);
1236
+ else await hub[op](d.entry.name); // lock/unlock/toggle
1237
+ return `OK: ${SESSION_LABEL[op]} (${d.entry.name}) [cloud]`;
1238
+ };
1239
+ }
1240
+
1241
+ /**
1242
+ * 対象ロックへ BLE 接続を張ったまま保持し、runSessionMenu でメニュー操作させる。
1243
+ * 接続を維持するので 1 操作ごとの再スキャン/再接続が起きない。
1244
+ *
1245
+ * @param {string[]} names 対象ロック名 (部分一致可)。空なら config の全ロック。
1246
+ */
1247
+ async function cmdSession(names, options, program) {
1248
+ const gopts = program.opts();
1249
+ if (gopts.json) { die("session は対話モード専用です (--json 不可)。", 2); return; }
1250
+ if (!isInteractive()) { die("session は TTY 専用です。単発操作は `sesame unlock <name>` (必要なら --ble-only) を使ってください。", 2); return; }
1251
+
1252
+ const loggedIn = hasCloudSession(program);
1253
+
1254
+ // 操作できるデバイス全部を対象にする: ロック/Bot/Bike (BLE+cloud) と、ログイン済みなら Hub3 (cloud)。
1255
+ // model/secretKey は config の devices レコードに揃っているので entry がそのまま能力解決に使える。
1256
+ const locks = allLockEntries(program).map((e) => ({ ...e, kind: "lock" }));
1257
+ const hub3s = loggedIn ? allHub3Entries(program).map((e) => ({ ...e, kind: "hub3" })) : [];
1258
+ const allDevs = [...locks, ...hub3s];
1259
+ if (allDevs.length === 0) { die("操作できるデバイスがありません。`sesame locks sync-from-devices` / `sesame hub3 sync-from-devices` で取り込んでください。", 2); return; }
1260
+
1261
+ // 対象を決定: 名前指定があれば部分一致で絞る、無ければ全デバイス。
1262
+ let targets;
1263
+ if (Array.isArray(names) && names.length > 0) {
1264
+ targets = [];
1265
+ for (const n of names) {
1266
+ const matches = allDevs.filter((e) => e.name.toLowerCase().includes(String(n).toLowerCase()));
1267
+ if (matches.length === 0) { die(`デバイス "${n}" が見つかりません。候補: ${allDevs.map((e) => e.name).join(", ")}`, 2); return; }
1268
+ for (const m of matches) if (!targets.some((t) => t.name === m.name)) targets.push(m);
1269
+ }
1270
+ } else {
1271
+ targets = allDevs;
1272
+ }
1273
+
1274
+ const lockTargets = targets.filter((t) => t.kind === "lock");
1275
+
1276
+ /** @type {Map<string, {kind:string, entry:object, ble:(import("./ble/index.js").SesameBle|null)}>} */
1277
+ const devices = new Map();
1278
+ for (const t of targets) devices.set(t.name, { kind: t.kind, entry: t, ble: null });
1279
+
1280
+ // UI のライブ再描画トリガ。BLE の mechStatus publish / 背景接続の完了で "update" を流す。
1281
+ const bus = new EventEmitter();
1282
+ bus.setMaxListeners(0);
1283
+
1284
+ // BLE を張って devices[].ble に反映する (ロックのみ・best-effort・非致命)。繋がった台数を返す。
1285
+ const connectBle = async () => {
1286
+ if (lockTargets.length === 0) return 0;
1287
+ try {
1288
+ const result = await SesameBle.connectMany(lockTargets, { debug: !!gopts.debug, scanTimeoutMs: 8_000 });
1289
+ for (const [name, ble] of result.connected) {
1290
+ const d = devices.get(name);
1291
+ if (d) { d.ble = ble; ble.onStatus(() => bus.emit("update")); } // 以降 BLE 優先・状態変化で再描画
1292
+ }
1293
+ bus.emit("update"); // 接続が増えたら ·BLE に昇格させるため再描画
1294
+ return result.connected.size;
1295
+ } catch (e) {
1296
+ if (gopts.debug) console.error(`[ble] 接続失敗: ${e?.message || e}`);
1297
+ return 0;
1298
+ }
1299
+ };
1300
+
1301
+ let blePromise = null;
1302
+ if (loggedIn) {
1303
+ // 全部モードのアプリ的挙動: クラウドでメニューを即表示し、BLE は **バックグラウンド** で接続する
1304
+ // (繋がったデバイスは次の描画で ·BLE に昇格し、以降 BLE 優先)。起動を BLE スキャンで待たせない。
1305
+ if (lockTargets.length) console.error("[ble] バックグラウンドで接続中... (クラウドで操作可能)");
1306
+ blePromise = connectBle();
1307
+ } else {
1308
+ // 未ログイン: クラウドの下支えが無いので BLE を待つしかない。0 なら die。
1309
+ console.error(`[ble] スキャン中... (${lockTargets.map((t) => t.name).join(", ")})`);
1310
+ if ((await connectBle()) === 0) {
1311
+ die("BLE 圏内のデバイスが無く、クラウドも未ログインです。デバイスに近づくか `sesame login <email>` → `sesame verify` してください。", 1);
1312
+ return;
1313
+ }
1314
+ }
1315
+
1316
+ const { runSessionUI } = await import("./session-ui.js"); // ink/react を遅延ロード
1317
+ const runner = async (hub) => {
1318
+ // Hub3 の relay/LED 用 secretKey は config の devices レコードに保存済み (sync 時に取り込み)。
1319
+ // 旧実装の「session 開始時に listDevices で再取得」する band-aid は不要 (entry.secretKey をそのまま使う。
1320
+ // 欠落していれば relay/LED の exec が `sesame devices で再取得` を案内する)。
1321
+ try {
1322
+ await runSessionUI({
1323
+ devices,
1324
+ hasCloud: !!hub,
1325
+ bus,
1326
+ exec: makeSessionExec(hub),
1327
+ actionsFor: (d) => sessionActionsFor(d, !!hub),
1328
+ fmtState: sessionFmtState,
1329
+ hub3RemotesFor: (d) => remotesForHub3(program, d.entry.name).map((r) => ({ label: r.label, value: r.name })),
1330
+ listKeysFor: async (remoteName) => (await hub.listKeys(remoteName)).map((k) => ({ label: k.name, value: k.name })),
1331
+ });
1332
+ } finally {
1333
+ if (blePromise) await blePromise.catch(() => {}); // 背景接続の完了を待ってから閉じる
1334
+ for (const d of devices.values()) if (d.ble) await d.ble.close().catch(() => {});
1335
+ console.error("切断しました。");
1336
+ }
1337
+ };
1338
+
1339
+ if (loggedIn) await withHub(program, (hub) => runner(hub));
1340
+ else await runner(null);
1341
+ }
1342
+
1343
+ /** デバイスに対して可能な操作 (動詞)。型ごとの可否は能力モデルが別途ゲートする。 */
1344
+ const DEVICE_ACTIONS = new Set(["unlock", "lock", "toggle", "click", "status", "autolock"]);
1345
+
1346
+ /**
1347
+ * デバイス主語の実行: `sesame <device> [action] [args]`。
1348
+ * - action 省略 + TTY → そのデバイス (複数可) の対話セッション。
1349
+ * - action 省略 + 非対話 → status を表示。
1350
+ * - action 指定 → 1 発実行 (cmdAct に委譲。経路は全部モードで自動)。
1351
+ */
1352
+ async function cmdDeviceOp(device, action, args, options, program) {
1353
+ if (!action) {
1354
+ if (isInteractive() && !program.opts().json) { await cmdSession(device ? [device] : [], options, program); return; }
1355
+ action = "status"; // 非対話の既定は状態表示
1356
+ }
1357
+ if (!DEVICE_ACTIONS.has(action)) {
1358
+ die(`不明な操作 "${action}"。使えるのは: ${[...DEVICE_ACTIONS].join(" / ")} (例: sesame ${device || "<device>"} unlock)`, 2);
1359
+ return;
1360
+ }
1361
+ const seconds = action === "autolock" ? (args && args[0]) : null;
1362
+ if (action === "autolock" && (seconds == null)) {
1363
+ die("autolock には秒数が必要です (例: sesame front autolock 30、0=無効)。", 2);
1364
+ return;
1365
+ }
1366
+ await cmdAct(action, device, seconds, options, program);
1367
+ }
1368
+
1369
+ async function cmdAct(op, name, seconds, options, program) {
1370
+ const entry = await resolveLockEntry(program, name || options.name);
1371
+ if (!entry) return; // die 済み
1372
+ const transport = pickTransport(op, options, entry.model);
1373
+ const gopts = program.opts();
1374
+ const extra = op === "autolock" ? ` ${seconds}s` : "";
1375
+
1376
+ // デバイス型ごとの能力ゲート (SDK 準拠)。model が判っていて非対応な操作は接続前に弾く。
1377
+ // 例: Bot に lock/unlock → "click を使え"、Lock に click → "toggle を使え"。
1378
+ const BLE_OPS = new Set(["lock", "unlock", "toggle", "autolock", "click"]);
1379
+ if (BLE_OPS.has(op) && entry.model) {
1380
+ const caps = capabilitiesForModel(entry.model);
1381
+ if (!caps.ops.includes(op)) {
1382
+ die(`${caps.label} (${entry.model}) は ${op} に対応していません。可能な操作: ${caps.ops.join("/") || "なし"}`, 2);
1383
+ return;
1384
+ }
1385
+ }
1386
+
1387
+ // autolock の引数検証は接続前に。
1388
+ if (op === "autolock") {
1389
+ const sec = Number(seconds);
1390
+ if (!Number.isInteger(sec) || sec < 0 || sec > 65535) { die("seconds は 0..65535 の整数 (0=無効)。", 2); return; }
1391
+ }
1392
+
1393
+ if (transport === "ble") {
1394
+ // BLE 一時接続 (--ble-only 明示、または autolock のような cloud 不可な op)。
1395
+ if (!gopts.json) console.error(`[ble] ${op}${extra} → ${entry.name}`);
1396
+ try {
1397
+ await runBleOp(op, entry, seconds, gopts);
1398
+ } catch (e) {
1399
+ if (maybeHandleBleError(e)) return; // 権限/電源/未導入は設定誘導
1400
+ throw e;
1401
+ }
1402
+ return;
1403
+ }
1404
+ // transport === "cloud"
1405
+ if (!hasCloudSession(program)) {
1406
+ die("クラウド未ログインです。`sesame login <email>` → `sesame verify` でログインするか、BLE で操作する場合は `--ble-only` か `sesame session` を使ってください。", 2);
1407
+ return;
1408
+ }
1409
+ if (!gopts.json) console.error(`[cloud] ${op}${extra} → ${entry.name}`);
1410
+ await runCloudOp(op, entry, program);
1411
+ }
1412
+
1413
+ // ---------- コマンド: migrate ----------
1414
+
1415
+ async function cmdMigrate(srcDir, _opts, program) {
1416
+ const { opts, paths, configStore, tokenStore } = loadCtx(program);
1417
+ const src = resolve(srcDir || process.cwd());
1418
+ mkdirSync(paths.dir, { recursive: true });
1419
+
1420
+ const summary = { configDir: paths.dir, imported: [] };
1421
+
1422
+ // 1. tokens
1423
+ const oldTokens = resolve(src, ".tokens.json");
1424
+ if (existsSync(oldTokens)) {
1425
+ copyFileSync(oldTokens, paths.tokens);
1426
+ summary.imported.push("tokens.json");
1427
+ }
1428
+ const oldPending = resolve(src, ".login_state.json");
1429
+ if (existsSync(oldPending)) {
1430
+ copyFileSync(oldPending, paths.loginState);
1431
+ summary.imported.push("login_state.json");
1432
+ }
1433
+
1434
+ // 2. config: .env + keys.json を統合
1435
+ const cfg = configStore.load(); // 既存 or 空
1436
+ const envPath = resolve(src, ".env");
1437
+ let envVars = {};
1438
+ if (existsSync(envPath)) {
1439
+ envVars = parseDotenv(readFileSync(envPath, "utf8"));
1440
+ summary.imported.push(".env");
1441
+ }
1442
+ const keysPath = resolve(src, "keys.json");
1443
+ let keysFile = null;
1444
+ if (existsSync(keysPath)) {
1445
+ keysFile = JSON.parse(readFileSync(keysPath, "utf8"));
1446
+ summary.imported.push("keys.json");
1447
+ }
1448
+
1449
+ if (envVars.COMPANY_ID) cfg.companyID = envVars.COMPANY_ID;
1450
+ if (envVars.WS_URL) cfg.wsUrl = envVars.WS_URL;
1451
+ if (envVars.LANG) cfg.lang = envVars.LANG;
1452
+
1453
+ // hub3/remote は派生 view (cfg.hub3s) を直接いじらず、devices/remotes へ書く store API 経由で登録する
1454
+ // (view は save() の _reproject で再生成されるため、直接代入しても保存されず消える)。
1455
+ if (envVars.HUB3_DEVICE_ID) {
1456
+ const hub3Name = "default";
1457
+ configStore.addHub3(hub3Name, { deviceId: envVars.HUB3_DEVICE_ID, name: hub3Name });
1458
+ summary.hub3Added = hub3Name;
1459
+ }
1460
+
1461
+ if (envVars.IR_DEVICE_UUID && Object.keys(cfg.hub3s).length) {
1462
+ const hub3Name = Object.keys(cfg.hub3s)[0];
1463
+ const remoteName = keysFile?.alias || "default";
1464
+ configStore.addRemote(remoteName, {
1465
+ hub3: hub3Name,
1466
+ irDeviceUUID: envVars.IR_DEVICE_UUID,
1467
+ irType: Number(envVars.IR_TYPE) || DEFAULT_IR_TYPE,
1468
+ irOperation: envVars.IR_OPERATION || "learnEmit",
1469
+ alias: keysFile?.alias || null,
1470
+ keys: keysFile?.keys || {},
1471
+ });
1472
+ summary.remoteAdded = remoteName;
1473
+ }
1474
+
1475
+ configStore.save(); // companyID/wsUrl/lang 等の直接設定分を確定 (hub3/remote は上で保存済み)
1476
+
1477
+ out(opts.json, () => {
1478
+ console.log(`OK: migrated to ${paths.dir}`);
1479
+ console.log(`Imported: ${summary.imported.join(", ") || "(none)"}`);
1480
+ if (summary.hub3Added) console.log(` hub3: ${summary.hub3Added}`);
1481
+ if (summary.remoteAdded) console.log(` remote: ${summary.remoteAdded} (default)`);
1482
+ console.log(`\n旧ファイル (.env / .tokens.json / keys.json / .login_state.json) は不要なら削除して構いません。`);
1483
+ }, summary);
1484
+ }
1485
+
1486
+ function parseDotenv(content) {
1487
+ const out = {};
1488
+ for (const line of content.split(/\r?\n/)) {
1489
+ const s = line.trim();
1490
+ if (!s || s.startsWith("#")) continue;
1491
+ const m = s.match(/^([A-Z0-9_]+)\s*=\s*(.*)$/i);
1492
+ if (!m) continue;
1493
+ let val = m[2].trim();
1494
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
1495
+ val = val.slice(1, -1);
1496
+ }
1497
+ out[m[1]] = val;
1498
+ }
1499
+ return out;
1500
+ }
1501
+
1502
+ // ---------- run ----------
1503
+
1504
+ export async function run(argv = process.argv) {
1505
+ CLI_JSON = argv.includes("--json"); // die()/エラー経路用にグローバル --json を先に確定
1506
+ const program = new Command();
1507
+ program
1508
+ .name("sesame")
1509
+ .description("SESAME cloud CLI: lock control + Hub3 IR + device management (port of biz3 React with Consumer Cognito client)")
1510
+ .version(getPkgVersion(), "-V, --version")
1511
+ .option("--config-dir <path>", "設定ディレクトリ上書き (default: ~/.config/sesame-hub3)")
1512
+ .option("--debug", "詳細ログ")
1513
+ .option("--json", "JSON 出力");
1514
+
1515
+ program.addHelpText("before", `
1516
+ デバイス主語で操作します (device.action() と同じ並び):
1517
+ sesame <device> <action> 1 発実行 例: sesame front unlock / sesame kitchen click / sesame front autolock 30
1518
+ sesame <device> そのデバイスの対話メニュー
1519
+ sesame 全デバイスの対話メニュー (session)
1520
+ action: unlock / lock / toggle / click / status / autolock <秒> (使える操作は型で変わる)
1521
+ 経路は既定で自動。固定は --ble-only / --cloud-only。
1522
+ 下記は管理コマンド (login やデバイス管理・IR 等)。
1523
+ `);
1524
+
1525
+ program.command("login <email>").description("sign-in 開始 (email にコード送信)")
1526
+ .action((email, opts) => cmdLogin(email, opts, program));
1527
+ program.command("verify [code]").description("sign-in 完了 (省略時は対話入力)")
1528
+ .action((code, opts) => cmdVerify(code, opts, program));
1529
+ program.command("refresh").description("強制 token 更新")
1530
+ .action((opts) => cmdRefresh(opts, program));
1531
+ program.command("whoami").description("ログインユーザ情報 (biz3GetLoginUser) を取得し companyID を config に保存")
1532
+ .action((opts) => cmdWhoami(opts, program));
1533
+
1534
+ program.command("send [key]").description("IR 発射 (key は名前 or keyUUID, 省略時は対話選択)")
1535
+ .option("--remote <name>", "リモコン名 (省略時はデフォルト)")
1536
+ .action((key, opts) => cmdSend(key, opts, program));
1537
+ program.command("list").description("リモコン登録キー一覧 (getIRCodes)")
1538
+ .option("--remote <name>", "リモコン名 (省略時はデフォルト)")
1539
+ .action((opts) => cmdList(opts, program));
1540
+ program.command("ping").description("WS 接続確認")
1541
+ .action((opts) => cmdPing(opts, program));
1542
+ program.command("devices").description("全 SESAME デバイス情報 (secretKey 含む dump)")
1543
+ .action((opts) => cmdDevices(opts, program));
1544
+
1545
+ program.command("init").description("設定ディレクトリと config.json スケルトンを作成")
1546
+ .action((opts) => cmdInit(opts, program));
1547
+ program.command("setup").description("認証後の自動セットアップを再実行 (companyID / ロック / Hub3 IR をデバイスから取り込み)")
1548
+ .action((opts) => cmdSetup(opts, program));
1549
+ program.command("migrate [srcDir]").description("旧 .env / .tokens.json / keys.json を取り込み")
1550
+ .action((srcDir, opts) => cmdMigrate(srcDir, opts, program));
1551
+
1552
+ // サブコマンド省略時は show 相当を出す (引数なしで exit 1 にならないように)
1553
+ const config = program.command("config").description("設定の参照 (省略時は show)")
1554
+ .action((opts) => cmdConfigShow(opts, program));
1555
+ config.command("path").description("設定ディレクトリのパスを出力")
1556
+ .action((opts) => cmdConfigPath(opts, program));
1557
+ config.command("show").description("config.json / tokens.json (masked) を出力")
1558
+ .action((opts) => cmdConfigShow(opts, program));
1559
+
1560
+ const remote = program.command("remote").description("リモコン定義の編集");
1561
+ remote.command("ls").description("設定済みリモコン一覧")
1562
+ .action((opts) => cmdRemoteLs(opts, program));
1563
+ remote.command("add").description("リモコンを一覧から選んで 1 つ追加 (UUID/irType 手打ち不要)")
1564
+ .addHelpText("after", `
1565
+ devices だけで完結します (手入力は呼び名のみ):
1566
+ - 各 Hub3 配下の登録済みリモコンを一覧表示し選択 (irType も自動)
1567
+ - 追加後に自動で sync-keys (キー一覧を取り込み)
1568
+ 全リモコンを一括で取り込むなら \`sesame remote sync-from-devices\` の方が速い。`)
1569
+ .action((opts) => cmdRemoteAdd(opts, program));
1570
+ remote.command("set-default <name>").description("デフォルトリモコン設定")
1571
+ .action((name, opts) => cmdRemoteSetDefault(name, opts, program));
1572
+ remote.command("sync-keys [name]").description("getIRCodes で取得したキーを config.json に書き戻し")
1573
+ .action((name, opts) => cmdRemoteSyncKeys(name, opts, program));
1574
+ remote.command("sync-from-devices")
1575
+ .description("devices からリモコンを全件自動取り込み (Hub3 と irType を自動判定、引数不要)")
1576
+ .action((opts) => cmdRemoteSyncFromDevices(opts, program));
1577
+
1578
+ const hub3 = program.command("hub3").description("Hub3 定義の編集");
1579
+ hub3.command("ls").description("設定済み Hub3 一覧")
1580
+ .action((opts) => cmdHub3Ls(opts, program));
1581
+ hub3.command("add").description("devices から Hub3 を選んで追加 (UUID 手打ち不要)")
1582
+ .action((opts) => cmdHub3Add(opts, program));
1583
+ hub3.command("sync-from-devices").description("devices から Hub3 を全件自動取り込み")
1584
+ .option("--prune", "server に無い Hub3 を config から除去 (参照中の remote があるものは残す)")
1585
+ .action((opts) => cmdHub3SyncFromDevices(opts, program));
1586
+
1587
+ // ロック定義の管理 (グループ名は locks。操作は下のトップレベル動詞)
1588
+ const locks = program.command("locks").description("ロック定義の管理 (一覧/追加/削除/デフォルト/取込)");
1589
+ locks.command("ls").description("設定済みロック一覧")
1590
+ .action((opts) => cmdLockLs(opts, program));
1591
+ locks.command("add").description("ロック追加 (対話、またはフラグで非対話)")
1592
+ .option("--name <name>", "ロックの呼び名 (例: front)")
1593
+ .option("--uuid <uuid>", "ロックの deviceUUID (devices の出力にある)")
1594
+ .option("--secret <hex>", "32hex 共通鍵 (devices の出力にある)")
1595
+ .option("--model <model>", "例 sesame_5 / sesame_5_pro / sesame_6 / wm_2")
1596
+ .option("--alias <alias>", "表示名 (任意)")
1597
+ .addHelpText("after", `
1598
+ フラグ未指定なら対話で聞く。--json/非対話では --name/--uuid/--secret が必須。
1599
+ 通常は \`sesame locks sync-from-devices\` で自動取り込みが楽。
1600
+ 例: sesame locks add --name front --uuid <UUID> --secret <32hex> --model sesame_5_pro`)
1601
+ .action((opts) => cmdLockAdd(opts, program));
1602
+ locks.command("rm <name>").description("ロック定義削除 (--yes で非対話強制削除)")
1603
+ .option("--yes", "確認 prompt をスキップ (非対話モード必須)")
1604
+ .action((name, opts) => cmdLockRm(name, opts, program));
1605
+ locks.command("set-default <name>").description("デフォルトロック設定")
1606
+ .action((name, opts) => cmdLockSetDefault(name, opts, program));
1607
+ locks.command("sync-from-devices").description("devices からロックを config に取り込み (追加 + secretKey 更新)")
1608
+ .option("--prune", "server に無いロックを config から除去")
1609
+ .action((opts) => cmdLockSyncFromDevices(opts, program));
1610
+
1611
+ // ---------- デバイス主語の実行 (sesame <device> [action]) ----------
1612
+ // 主語はデバイス。`sesame front unlock` = front.unlock() 相当 (SDK の device.method() と同じ)。
1613
+ // action 省略は対話メニュー (= そのデバイスの session)。引数なし `sesame` は全デバイスの session。
1614
+ // 経路は既定「全部モード」(能力フル・自動。BLE 必須 op のみ BLE)。固定は --ble-only / --cloud-only。
1615
+ // 例: sesame front unlock / sesame kitchen click / sesame front autolock 30 / sesame front --ble-only
1616
+ //
1617
+ // 実体は隠し op コマンド。先頭トークンが既知コマンドでなければ run() がここへ振り分ける。
1618
+ program.command("op [device] [action] [args...]", { hidden: true })
1619
+ .option("--ble-only", "BLE 経路に固定 (近接 + Bluetooth 権限。接続に数秒)")
1620
+ .option("--cloud-only", "クラウド経路に固定 (要 login。一部操作は制限)")
1621
+ .action((device, action, args, opts) => cmdDeviceOp(device, action, args, opts, program));
1622
+
1623
+ program.command("session [names...]").alias("watch")
1624
+ .description("複数デバイスに BLE 接続を保持して対話操作 (sesame <device> の複数版)")
1625
+ .addHelpText("after", `
1626
+ 名前を省略すると全デバイスに、指定するとそれらに接続する。
1627
+ 接続後は矢印キーのメニューでデバイスと操作を選ぶ (操作は型で変わる)。
1628
+ 例: sesame session # 全デバイス
1629
+ sesame session front 裏口 # 指定デバイスだけ`)
1630
+ .action((names, opts) => cmdSession(names, opts, program));
1631
+
1632
+ // ---------- IR advanced (Phase C) ----------
1633
+ const irCmd = program.command("ir").description("Hub3 IR の高度な操作 (学習 / モード / 検索 / プリセット照合)");
1634
+ irCmd.command("learn [remote] [keyname]")
1635
+ .description("物理リモコンの 1 ボタンを学習して remote にキー登録 (引数省略で対話選択)")
1636
+ .action((remote, keyName, opts) => cmdIRLearn(remote, keyName, opts, program));
1637
+ const irMode = irCmd.command("mode").description("Hub3 の IR モード制御 (0=CONTROL / 1=REGISTER)");
1638
+ irMode.command("get [hub3]").description("現在のモード取得")
1639
+ .action((hub3, opts) => cmdIRModeGet(hub3, opts, program));
1640
+ irMode.command("set <mode> [hub3]").description("モード設定 (0 or 1)")
1641
+ .action((mode, hub3, opts) => cmdIRModeSet(hub3, mode, opts, program));
1642
+ const irKey = irCmd.command("key").description("キー (ボタン) CRUD");
1643
+ irKey.command("rm [remote] [key]").description("キー削除 (引数省略で対話選択、--yes で非対話強制)")
1644
+ .option("--yes", "確認 prompt をスキップ (非対話モード必須)")
1645
+ .action((remote, key, opts) => cmdIRKeyRm(remote, key, opts, program));
1646
+ irKey.command("rename [remote] [key] [new]").description("キー名変更 (引数省略で対話)")
1647
+ .action((remote, key, n, opts) => cmdIRKeyRename(remote, key, n, opts, program));
1648
+ irCmd.command("remote-list <irType>").description("server 側の登録リモコン一覧 (irType: 整数コード, 例 49152=エアコン)")
1649
+ .action((type, opts) => cmdIRRemoteListServer(type, opts, program));
1650
+ irCmd.command("search <irType> <term>").description("プリセットリモコン (メーカー DB) 検索 (irType: 例 49152=エアコン)")
1651
+ .action((type, term, opts) => cmdIRRemoteSearch(type, term, opts, program));
1652
+ irCmd.command("match <irType> <irData>").description("学習波形 (hex) からプリセット照合 (irType: 整数コード)")
1653
+ .action((type, irData, opts) => cmdIRRemoteMatch(type, irData, opts, program));
1654
+ irCmd.command("remote-rm [name]").description("server 側からリモコン削除 (config の remote は残る)")
1655
+ .action((name, opts) => cmdIRRemoteRmServer(name, opts, program));
1656
+ irCmd.command("remote-rename <alias> [name]").description("server 側でリモコンの alias 変更")
1657
+ .action((alias, name, opts) => cmdIRRemoteRenameServer(name, alias, opts, program));
1658
+
1659
+ // ---------- device management (Phase D) ----------
1660
+ const devCmd = program.command("device").description("デバイス管理 (個人/会社 + 名前変更/削除)");
1661
+ devCmd.command("user-ls").description("個人ユーザのデバイス一覧 (getUserDevice)")
1662
+ .action((opts) => cmdDeviceUserLs(opts, program));
1663
+ devCmd.command("status [uuid]").description("単機の現在状態 (lock state, battery 等)")
1664
+ .action((uuid, opts) => cmdDeviceStatus(uuid, opts, program));
1665
+ devCmd.command("rename [uuid] [name]").description("デバイス名変更 (引数省略で対話)")
1666
+ .action((uuid, name, opts) => cmdDeviceRename(uuid, name, opts, program));
1667
+ devCmd.command("rm [uuid]").description("company からデバイス削除 (確認 prompt あり、--yes で非対話強制)")
1668
+ .option("--yes", "確認 prompt をスキップ (非対話モード必須)")
1669
+ .action((uuid, opts) => cmdDeviceRm(uuid, opts, program));
1670
+
1671
+ program.command("history [deviceUUID]").description("デバイスの開閉履歴 (省略時は全デバイス or 対話)")
1672
+ .option("--page-size <n>", "ページサイズ")
1673
+ .action((uuid, opts) => cmdHistory(uuid, opts, program));
1674
+ program.command("battery [deviceUUID]").description("デバイスの電池履歴 (省略時は対話選択)")
1675
+ .option("--page-size <n>", "ページサイズ (default 100)")
1676
+ .action((uuid, opts) => cmdBattery(uuid, opts, program));
1677
+ program.command("firmware").description("配信中ファームウェア一覧")
1678
+ .action((opts) => cmdFirmware(opts, program));
1679
+ program.command("webapi <func>").description("biz3 WebAPI proxy 経由で REST API を叩く")
1680
+ .option("--query <json>", "query params (JSON)")
1681
+ .option("--body <json>", "request body (JSON)")
1682
+ .option("--api-key <id>", "apiKeyId (省略時は config.apiKeyId)")
1683
+ .action((func, opts) => cmdWebapi(func, opts, program));
1684
+
1685
+ // bootstrap (互換コマンド: 既存 token を JSON で流し込み)
1686
+ program.command("bootstrap").description("既存 idToken/refreshToken を JSON stdin から流し込み")
1687
+ .action(async (opts) => {
1688
+ // stdin がパイプ/リダイレクトでない (TTY) のに読みに行くと無限に待つので明示拒否する。
1689
+ if (process.stdin.isTTY) die("bootstrap は JSON を stdin から受け取ります: echo '{...}' | sesame bootstrap", 2);
1690
+ const { tokenStore } = loadCtx(program);
1691
+ const chunks = [];
1692
+ for await (const c of process.stdin) chunks.push(c);
1693
+ const values = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1694
+ const t = bootstrap(tokenStore, values);
1695
+ out(CLI_JSON, () => console.log(`OK: bootstrapped (clientId=${t.clientId})`),
1696
+ { ok: true, clientId: t.clientId });
1697
+ });
1698
+
1699
+ // meta コマンド
1700
+ program.command("meta").description("Cognito 構成 (region/userPoolId/clientId) を表示")
1701
+ .action(() => out(CLI_JSON, () => console.log(JSON.stringify(CONFIG_META, null, 2)), CONFIG_META));
1702
+
1703
+ // ---------- 拡張コマンド群 (Phase F–L) を cli/ サブモジュールから登録 ----------
1704
+ // 各 register は registerXxxCommands(program, ctx) で commander サブコマンドを生やす。
1705
+ // 本体ロジックは src/<module>.js、コマンド配線は src/cli/<module>.js に分離している。
1706
+ const ctx = makeCtx(program);
1707
+ registerScheduleCommands(program, ctx);
1708
+ registerCompanyCommands(program, ctx);
1709
+ registerOrgCommands(program, ctx);
1710
+ registerAccessCommands(program, ctx);
1711
+ registerIotCommands(program, ctx);
1712
+ registerPresetIrCommands(program, ctx);
1713
+ registerServeCommand(program); // 常駐 JSON-RPC バックエンド (serve は reserved に自動で入る)
1714
+
1715
+ // デバイス主語の振り分け。先頭トークンが「既知の管理コマンド」でなければデバイス名とみなし、
1716
+ // 隠し op コマンドへ回す (sesame <device> [action] = device.action())。
1717
+ const userArgs = argv.slice(2);
1718
+ const isHelp = userArgs.some((a) => a === "-h" || a === "--help");
1719
+ const isJson = userArgs.includes("--json");
1720
+ const firstTok = userArgs.find((a) => !a.startsWith("-"));
1721
+ const reserved = new Set();
1722
+ for (const c of program.commands) { reserved.add(c.name()); for (const a of c.aliases()) reserved.add(a); }
1723
+
1724
+ if (!isHelp) {
1725
+ if (!firstTok) {
1726
+ // 引数なし: 既定はデバイス主語の対話 (全デバイスの session)。非対話/JSON はそのまま help を出す。
1727
+ if (!isJson && isInteractive()) argv = [argv[0], argv[1], "session"];
1728
+ } else if (!reserved.has(firstTok)) {
1729
+ // 先頭が管理コマンドでない = デバイス名 → デバイス主語実行へ。
1730
+ argv = [argv[0], argv[1], "op", ...userArgs];
1731
+ }
1732
+ }
1733
+
1734
+ // commander 自身の usage エラー (引数不足/未知オプション等) も JSON 契約に乗せる。
1735
+ // 全コマンドに exitOverride を伝播させ process.exit でなく throw させて下の catch に集約。
1736
+ // --json 時は commander の素のエラー文 (writeErr) を抑止し、die() の JSON 封筒だけ出す。
1737
+ (function propagateExitOverride(cmd) {
1738
+ cmd.exitOverride();
1739
+ cmd.configureOutput({ writeErr: (str) => { if (!CLI_JSON) process.stderr.write(str); } });
1740
+ for (const c of cmd.commands) propagateExitOverride(c);
1741
+ })(program);
1742
+
1743
+ try {
1744
+ await program.parseAsync(argv);
1745
+ } catch (err) {
1746
+ // help/version 表示は正常終了 (commander が stdout に出力済み)。
1747
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.help" || err.code === "commander.version") {
1748
+ finishCli(); return;
1749
+ }
1750
+ if (program.opts().debug) console.error(err.stack);
1751
+ // BLE 権限/電源エラーは macOS なら該当設定ペインを自動で開いて誘導する。
1752
+ if (maybeHandleBleError(err)) { finishCli(); return; }
1753
+ const code = (typeof err.exitCode === "number" && err.exitCode !== 0) ? err.exitCode : 1;
1754
+ // commander の usage エラーは非 JSON 時すでに stderr へ整形済み (usage 付き) なので二重出力を避ける。
1755
+ if (typeof err.code === "string" && err.code.startsWith("commander.") && !CLI_JSON) {
1756
+ process.exitCode = code; finishCli(); return;
1757
+ }
1758
+ die(withStaleHint(err.message || String(err)), code);
1759
+ }
1760
+ finishCli();
1761
+ }
1762
+
1763
+ /**
1764
+ * 後始末してプロセスを終わらせる。noble (CoreBluetooth) を一度でも使うとネイティブ
1765
+ * ハンドルがイベントループに残り node が自然 exit しないため、その場合だけ明示終了する。
1766
+ * 出力の取りこぼしを防ぐため stdout を drain してから exit する。
1767
+ */
1768
+ function finishCli() {
1769
+ if (!bleWasUsed()) return; // クラウドのみのコマンドは自然 exit に任せる (出力 truncate 回避)
1770
+ const code = process.exitCode || 0;
1771
+ if (process.stdout.write("")) process.exit(code);
1772
+ else process.stdout.once("drain", () => process.exit(code));
1773
+ }
1774
+
1775
+ /**
1776
+ * BLE 権限/電源系エラーを検知し、macOS なら設定ペインを開いて案内する。
1777
+ * @returns {boolean} ハンドルした (= 呼び出し側は return) なら true
1778
+ */
1779
+ function maybeHandleBleError(err) {
1780
+ const code = err?.code;
1781
+ if (code !== "BLE_UNAUTHORIZED" && code !== "BLE_POWERED_OFF" && code !== "BLE_NO_ADAPTER") return false;
1782
+ if (CLI_JSON) console.error(JSON.stringify({ error: err.message, code: 2, bleCode: code }));
1783
+ else console.error(`Error: ${err.message}`);
1784
+ if (!CLI_JSON && process.platform === "darwin" && code === "BLE_UNAUTHORIZED") {
1785
+ // システム設定 → プライバシーとセキュリティ → Bluetooth を直接開く (人間向け誘導。--json では出さない)。
1786
+ try {
1787
+ spawn("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth"], {
1788
+ stdio: "ignore", detached: true,
1789
+ }).unref();
1790
+ console.error("→ Bluetooth のプライバシー設定を開きました。実行中のターミナル (Terminal/iTerm 等) を ON にして再実行してください。");
1791
+ } catch {
1792
+ console.error("→ システム設定 → プライバシーとセキュリティ → Bluetooth でターミナルを許可してください。");
1793
+ }
1794
+ }
1795
+ process.exitCode = 2;
1796
+ return true;
1797
+ }
1798
+
1799
+ /**
1800
+ * server が「未知のキー/デバイス」系エラーを返した場合、config が古い可能性を
1801
+ * 案内に添える (stale 検知の最小実装。完全な存在確認はせず誘導に留める)。
1802
+ */
1803
+ function withStaleHint(msg) {
1804
+ const m = String(msg);
1805
+ const looksStale =
1806
+ /Unknown key/i.test(m) ||
1807
+ /sendIR failed/i.test(m) ||
1808
+ /getIRCodes failed/i.test(m) ||
1809
+ /triggerLock failed/i.test(m) ||
1810
+ /not found/i.test(m) ||
1811
+ /invalid.*device/i.test(m);
1812
+ if (!looksStale) return m;
1813
+ return `${m}\nヒント: ローカル config が古い可能性があります。\n IR キー: sesame remote sync-keys [name]\n ロック/Hub3: sesame lock sync-from-devices / sesame hub3 sync-from-devices`;
1814
+ }
1815
+