@smartmemory/compose 0.2.46-beta → 0.2.47-beta

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 (71) hide show
  1. package/README.md +17 -0
  2. package/bin/compose.js +35 -1
  3. package/dist/assets/{App-CZNprfTP.js → App-D7E7S49Q.js} +163 -153
  4. package/dist/assets/{arc-D7Ql7gBh.js → arc-LOW60tiI.js} +1 -1
  5. package/dist/assets/{architectureDiagram-3BPJPVTR-CMzqpLWw.js → architectureDiagram-3BPJPVTR-BDY00hRy.js} +1 -1
  6. package/dist/assets/{blockDiagram-GPEHLZMM-CbW2AYzk.js → blockDiagram-GPEHLZMM-DiC_6ktq.js} +1 -1
  7. package/dist/assets/browser-DW2JlJCC.js +8 -0
  8. package/dist/assets/{c4Diagram-AAUBKEIU-C89TIEqM.js → c4Diagram-AAUBKEIU-CXZC8woU.js} +1 -1
  9. package/dist/assets/channel-Dh96pu1k.js +1 -0
  10. package/dist/assets/{chunk-2J33WTMH-jLQK9et3.js → chunk-2J33WTMH-b8Rdk4S-.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-bH25OCUA.js → chunk-4BX2VUAB-BSxn03f-.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-Dhb0bOrW.js → chunk-55IACEB6-uUk1WzZ1.js} +1 -1
  13. package/dist/assets/{chunk-727SXJPM-CBfeQuSd.js → chunk-727SXJPM-zvXhT6iZ.js} +1 -1
  14. package/dist/assets/{chunk-AQP2D5EJ-CIxa5qRv.js → chunk-AQP2D5EJ-B6GCyrIp.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-CmXvjRAO.js → chunk-FMBD7UC4-DlhrqkBp.js} +1 -1
  16. package/dist/assets/{chunk-ND2GUHAM-DRs6NTTH.js → chunk-ND2GUHAM-lo_8kNzw.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-BmkcOtBV.js → chunk-QZHKN3VN-on-yD-C0.js} +1 -1
  18. package/dist/assets/classDiagram-4FO5ZUOK-BCqpDcaS.js +1 -0
  19. package/dist/assets/classDiagram-v2-Q7XG4LA2-BCqpDcaS.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-BZwhzUqa.js → cose-bilkent-S5V4N54A-DxV7Y2bU.js} +1 -1
  21. package/dist/assets/{dagre-BM42HDAG-CEHe9vzv.js → dagre-BM42HDAG-BHf9sURy.js} +1 -1
  22. package/dist/assets/{diagram-2AECGRRQ-Cq8RCIdY.js → diagram-2AECGRRQ-R9Puxzlh.js} +1 -1
  23. package/dist/assets/{diagram-5GNKFQAL-DCaYgQ-u.js → diagram-5GNKFQAL-BbjlClKx.js} +1 -1
  24. package/dist/assets/{diagram-KO2AKTUF-DehQMQAE.js → diagram-KO2AKTUF-r89Mx4m1.js} +1 -1
  25. package/dist/assets/{diagram-LMA3HP47-BUJ8pJCq.js → diagram-LMA3HP47-CstVDJZ3.js} +1 -1
  26. package/dist/assets/{diagram-OG6HWLK6-BrqALaH3.js → diagram-OG6HWLK6-xmX57FKh.js} +1 -1
  27. package/dist/assets/{erDiagram-TEJ5UH35-Cz2hnPqN.js → erDiagram-TEJ5UH35-DhLPxvrl.js} +1 -1
  28. package/dist/assets/{flowDiagram-I6XJVG4X-BpPrbE4K.js → flowDiagram-I6XJVG4X-BExoUaNm.js} +1 -1
  29. package/dist/assets/{ganttDiagram-6RSMTGT7-Dne2jtVu.js → ganttDiagram-6RSMTGT7-BEXa5a22.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-PVQCEYII-W8OpyxIG.js → gitGraphDiagram-PVQCEYII-Cbes9api.js} +1 -1
  31. package/dist/assets/{graph-viND0NdB.js → graph-BBXaecIU.js} +1 -1
  32. package/dist/assets/index-BwLfbbOu.css +1 -0
  33. package/dist/assets/{index-BVnbNZXC.js → index-DNuHtZwR.js} +22 -22
  34. package/dist/assets/{infoDiagram-5YYISTIA-4OeddgjS.js → infoDiagram-5YYISTIA-UBm4ssfm.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-YF4QCWOH-D0XwMcCq.js → ishikawaDiagram-YF4QCWOH-CksBYHCF.js} +1 -1
  36. package/dist/assets/{journeyDiagram-JHISSGLW-CO0wIeDT.js → journeyDiagram-JHISSGLW-CKHkIHky.js} +1 -1
  37. package/dist/assets/{kanban-definition-UN3LZRKU-CLU0ciYv.js → kanban-definition-UN3LZRKU-CEahMFzE.js} +1 -1
  38. package/dist/assets/{linear-BOFLe2h1.js → linear-CqLVtYRk.js} +1 -1
  39. package/dist/assets/{mindmap-definition-RKZ34NQL-ByuwlkdD.js → mindmap-definition-RKZ34NQL-C61zWt4M.js} +1 -1
  40. package/dist/assets/mobile-DwmxS_O4.js +17 -0
  41. package/dist/assets/{pieDiagram-4H26LBE5-CPm2BXke.js → pieDiagram-4H26LBE5-BIpZHyJW.js} +1 -1
  42. package/dist/assets/{quadrantDiagram-W4KKPZXB-CR3NLvwU.js → quadrantDiagram-W4KKPZXB-Db7nRbZm.js} +1 -1
  43. package/dist/assets/{requirementDiagram-4Y6WPE33-D4vpyryW.js → requirementDiagram-4Y6WPE33-BQqFCAr0.js} +1 -1
  44. package/dist/assets/{sankeyDiagram-5OEKKPKP--Clp0eo-.js → sankeyDiagram-5OEKKPKP-D-oAAMci.js} +1 -1
  45. package/dist/assets/{sequenceDiagram-3UESZ5HK-B1xrJkMo.js → sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js} +1 -1
  46. package/dist/assets/{stateDiagram-AJRCARHV-D5w5yQql.js → stateDiagram-AJRCARHV-B5DkQ8Pr.js} +1 -1
  47. package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +1 -0
  48. package/dist/assets/{timeline-definition-PNZ67QCA-DxOINQqf.js → timeline-definition-PNZ67QCA-DU9Fl-FW.js} +1 -1
  49. package/dist/assets/{vennDiagram-CIIHVFJN-B3Wss4do.js → vennDiagram-CIIHVFJN-Bthz7siw.js} +1 -1
  50. package/dist/assets/{wardley-L42UT6IY-nYDkwvlu.js → wardley-L42UT6IY-nqdlFSIA.js} +1 -1
  51. package/dist/assets/{wardleyDiagram-YWT4CUSO-BMM3myEL.js → wardleyDiagram-YWT4CUSO-BJ1BC5zc.js} +1 -1
  52. package/dist/assets/{xychartDiagram-2RQKCTM6-B5ElCNMx.js → xychartDiagram-2RQKCTM6-Bib5JrbR.js} +1 -1
  53. package/dist/index.html +3 -3
  54. package/lib/cli-remote.js +533 -0
  55. package/package.json +3 -1
  56. package/server/auth-middleware.js +309 -0
  57. package/server/auth-routes.js +195 -0
  58. package/server/auth-store.js +539 -0
  59. package/server/build-routes.js +1 -1
  60. package/server/index.js +134 -7
  61. package/server/remote-utils.js +134 -0
  62. package/server/security.js +46 -1
  63. package/server/supervisor.js +7 -1
  64. package/server/vision-routes.js +1 -1
  65. package/server/vision-server.js +1 -1
  66. package/dist/assets/channel-sjm4Ncyv.js +0 -1
  67. package/dist/assets/classDiagram-4FO5ZUOK-LkIQdBsc.js +0 -1
  68. package/dist/assets/classDiagram-v2-Q7XG4LA2-LkIQdBsc.js +0 -1
  69. package/dist/assets/index-D9M5IzwO.css +0 -1
  70. package/dist/assets/mobile-B-d4crm_.js +0 -17
  71. package/dist/assets/stateDiagram-v2-BHNVJYJU-TPU-mOqi.js +0 -1
@@ -0,0 +1,533 @@
1
+ /**
2
+ * cli-remote.js — COMP-MOBILE-REMOTE S03
3
+ *
4
+ * Logic for `compose remote` CLI verbs.
5
+ * Extracted to lib/ so tests can import without spawning the CLI binary.
6
+ *
7
+ * Exports:
8
+ * runRemoteCommand(subArgs, opts) — dispatches to the appropriate verb.
9
+ *
10
+ * opts shape:
11
+ * port {number} Server port (default: 4001)
12
+ * token {string} COMPOSE_API_TOKEN value (default: process.env.COMPOSE_API_TOKEN)
13
+ * cwd {string} Project root
14
+ * lines {object} Output collector with .push(line) (default: console.log)
15
+ * qr {function} QR renderer: (url: string) => void | Promise<void>
16
+ * poll {function} Delay function: (ms: number) => Promise<void>
17
+ * distDir {string} Path to dist/ (for status verb)
18
+ * headFn {function} HEAD check: (url: string) => Promise<{ok, status}>
19
+ *
20
+ * @module lib/cli-remote
21
+ */
22
+
23
+ import http from 'node:http';
24
+ import https from 'node:https';
25
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
26
+ import { join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { dirname } from 'node:path';
29
+
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+ const COMPOSE_HOME = resolve(__dirname, '..');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Internal HTTP helper
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Make an HTTP request to the local compose server.
39
+ * Returns { status, body }.
40
+ */
41
+ function serverRequest(port, urlPath, { method = 'GET', body, headers = {} } = {}) {
42
+ const data = body ? JSON.stringify(body) : null;
43
+ const opts = {
44
+ hostname: '127.0.0.1',
45
+ port,
46
+ path: urlPath,
47
+ method,
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
51
+ ...headers,
52
+ },
53
+ };
54
+ return new Promise((res, rej) => {
55
+ const req = http.request(opts, (response) => {
56
+ let buf = '';
57
+ response.on('data', (d) => { buf += d; });
58
+ response.on('end', () => {
59
+ let parsed = buf;
60
+ try { parsed = JSON.parse(buf); } catch {}
61
+ res({ status: response.statusCode, body: parsed });
62
+ });
63
+ });
64
+ req.on('error', rej);
65
+ if (data) req.write(data);
66
+ req.end();
67
+ });
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // HEAD check helper (for status verb)
72
+ // ---------------------------------------------------------------------------
73
+
74
+ async function defaultHeadCheck(url, timeoutMs = 5000) {
75
+ return new Promise((resolve) => {
76
+ let parsed;
77
+ try {
78
+ parsed = new URL(url);
79
+ } catch {
80
+ return resolve({ ok: false, status: 0 });
81
+ }
82
+ const options = {
83
+ hostname: parsed.hostname,
84
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
85
+ path: parsed.pathname + parsed.search,
86
+ method: 'HEAD',
87
+ };
88
+ // https tunnels are the normal case — pick the module by protocol or the
89
+ // probe can never complete a TLS handshake (status would false-fail).
90
+ const mod = parsed.protocol === 'https:' ? https : http;
91
+ const req = mod.request(options, (res) => {
92
+ res.resume();
93
+ resolve({ ok: res.statusCode < 400, status: res.statusCode });
94
+ });
95
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ ok: false, status: 0 }); });
96
+ req.on('error', () => resolve({ ok: false, status: 0 }));
97
+ req.end();
98
+ });
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // compose.json helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Read .compose/compose.json from a project root. Returns {} on error.
107
+ */
108
+ function readComposeJson(cwd) {
109
+ const cfgPath = join(cwd, '.compose', 'compose.json');
110
+ if (!existsSync(cfgPath)) return {};
111
+ try { return JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch { return {}; }
112
+ }
113
+
114
+ /**
115
+ * Write .compose/compose.json, preserving ALL unknown keys.
116
+ * Spreads existing top-level keys first, then merges patch.
117
+ * For object-valued patch keys, deep-merges with existing sub-object.
118
+ */
119
+ function writeComposeJson(cwd, patch) {
120
+ const cfgPath = join(cwd, '.compose', 'compose.json');
121
+ const existing = readComposeJson(cwd);
122
+ const merged = { ...existing };
123
+ for (const [k, v] of Object.entries(patch)) {
124
+ if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
125
+ merged[k] = { ...(existing[k] || {}), ...v };
126
+ } else {
127
+ merged[k] = v;
128
+ }
129
+ }
130
+ writeFileSync(cfgPath, JSON.stringify(merged, null, 2));
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Arg parsing helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Parse a --flag=value or --flag value style arg from an args array.
139
+ * Returns the value string or undefined.
140
+ */
141
+ function parseFlag(args, name) {
142
+ const prefix = `--${name}=`;
143
+ for (let i = 0; i < args.length; i++) {
144
+ if (args[i].startsWith(prefix)) return args[i].slice(prefix.length);
145
+ if (args[i] === `--${name}` && i + 1 < args.length && !args[i + 1].startsWith('--')) {
146
+ return args[i + 1];
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ function hasFlag(args, name) {
153
+ return args.includes(`--${name}`);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Verb: pair
158
+ // ---------------------------------------------------------------------------
159
+
160
+ async function verbPair(subArgs, { port, token, cwd, out, qrFn, pollFn }) {
161
+ if (!token) {
162
+ out.push('Error: COMPOSE_API_TOKEN is not set.');
163
+ out.push('The compose server must be running and COMPOSE_API_TOKEN must be exported.');
164
+ out.push('Tip: start the server with `compose start`, then run this command in the same shell session.');
165
+ throw new Error('COMPOSE_API_TOKEN not set');
166
+ }
167
+
168
+ // Resolve public_host: --public-host flag > compose.json remote.public_host
169
+ const flagHost = parseFlag(subArgs, 'public-host');
170
+ const cfg = readComposeJson(cwd);
171
+ const publicHost = flagHost || cfg.remote?.public_host || null;
172
+
173
+ // Persist public_host to compose.json if flag was given (preserving unknown keys)
174
+ if (flagHost) {
175
+ writeComposeJson(cwd, { remote: { public_host: flagHost } });
176
+ }
177
+
178
+ // Call pair/init
179
+ let initResult;
180
+ try {
181
+ initResult = await serverRequest(port, '/api/auth/pair/init', {
182
+ method: 'POST',
183
+ body: {},
184
+ headers: { 'x-compose-token': token },
185
+ });
186
+ } catch (err) {
187
+ out.push(`Error: Could not reach compose server on port ${port}.`);
188
+ out.push('Make sure the server is running: compose start');
189
+ throw err;
190
+ }
191
+
192
+ if (initResult.status !== 200) {
193
+ out.push(`Error from server (${initResult.status}): ${JSON.stringify(initResult.body)}`);
194
+ throw new Error(`pair/init failed: ${initResult.status}`);
195
+ }
196
+
197
+ const { code, expires_at } = initResult.body;
198
+
199
+ // Build pair URL
200
+ let pairUrl;
201
+ if (publicHost) {
202
+ pairUrl = `${publicHost.replace(/\/$/, '')}/m/pair?code=${code}`;
203
+ } else {
204
+ pairUrl = `http://127.0.0.1:${port}/m/pair?code=${code}`;
205
+ out.push('WARNING: No public_host configured. Generating a localhost URL (only usable on this machine).');
206
+ out.push('Run with: compose remote pair --public-host=<your-tunnel-URL>');
207
+ }
208
+
209
+ out.push('');
210
+ out.push(`Pair URL: ${pairUrl}`);
211
+ out.push(`Code expires at: ${expires_at}`);
212
+ out.push('');
213
+
214
+ // Render QR code
215
+ await qrFn(pairUrl);
216
+
217
+ out.push('');
218
+ out.push('Waiting for device to pair (Ctrl-C to cancel)...');
219
+
220
+ // Poll pair/status every 2s until consumed, expired, or interrupted
221
+ while (true) {
222
+ await pollFn(2000);
223
+
224
+ let statusResult;
225
+ try {
226
+ statusResult = await serverRequest(
227
+ port,
228
+ `/api/auth/pair/status?code=${encodeURIComponent(code)}`,
229
+ { method: 'GET', headers: { 'x-compose-token': token } },
230
+ );
231
+ } catch {
232
+ out.push('Warning: lost connection to server while polling.');
233
+ break;
234
+ }
235
+
236
+ if (statusResult.status !== 200) {
237
+ out.push(`Polling error (${statusResult.status}): ${JSON.stringify(statusResult.body)}`);
238
+ break;
239
+ }
240
+
241
+ const status = statusResult.body.status;
242
+ if (status === 'consumed') {
243
+ out.push('Paired! Device successfully authenticated.');
244
+ break;
245
+ } else if (status === 'expired') {
246
+ out.push('Pairing code expired. Run `compose remote pair` again to generate a new code.');
247
+ break;
248
+ }
249
+ // status === 'pending' — continue polling
250
+ }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Verb: list
255
+ // ---------------------------------------------------------------------------
256
+
257
+ async function verbList(subArgs, { port, token, out }) {
258
+ if (!token) {
259
+ out.push('Error: COMPOSE_API_TOKEN is not set.');
260
+ throw new Error('COMPOSE_API_TOKEN not set');
261
+ }
262
+
263
+ let result;
264
+ try {
265
+ result = await serverRequest(port, '/api/auth/devices', {
266
+ method: 'GET',
267
+ headers: { 'x-compose-token': token },
268
+ });
269
+ } catch {
270
+ out.push(`Error: Could not reach compose server on port ${port}.`);
271
+ out.push('Make sure the server is running: compose start');
272
+ return;
273
+ }
274
+
275
+ if (result.status !== 200) {
276
+ out.push(`Error from server (${result.status}): ${JSON.stringify(result.body)}`);
277
+ return;
278
+ }
279
+
280
+ const devices = result.body.devices || [];
281
+ if (devices.length === 0) {
282
+ out.push('No paired devices.');
283
+ return;
284
+ }
285
+
286
+ // Print table
287
+ const COL = { id: 24, name: 30, paired_at: 26, last_seen: 26, revoked: 8 };
288
+ const pad = (s, n) => String(s ?? '').slice(0, n).padEnd(n);
289
+ const sep = '-'.repeat(COL.id + COL.name + COL.paired_at + COL.last_seen + COL.revoked + 8);
290
+
291
+ out.push(
292
+ `${pad('ID', COL.id)} ${pad('Name', COL.name)} ${pad('Paired At', COL.paired_at)} ${pad('Last Seen', COL.last_seen)} ${pad('Revoked', COL.revoked)}`,
293
+ );
294
+ out.push(sep);
295
+
296
+ for (const d of devices) {
297
+ out.push(
298
+ `${pad(d.id, COL.id)} ${pad(d.name, COL.name)} ${pad(d.paired_at, COL.paired_at)} ${pad(d.last_seen, COL.last_seen)} ${pad(d.revoked, COL.revoked)}`,
299
+ );
300
+ }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Verb: revoke
305
+ // ---------------------------------------------------------------------------
306
+
307
+ async function verbRevoke(subArgs, { port, token, out }) {
308
+ const deviceId = subArgs.find((a) => !a.startsWith('--'));
309
+ if (!deviceId) {
310
+ out.push('Usage: compose remote revoke <device-id>');
311
+ out.push('Get device IDs with: compose remote list');
312
+ throw new Error('Missing device-id');
313
+ }
314
+ if (!token) {
315
+ out.push('Error: COMPOSE_API_TOKEN is not set.');
316
+ throw new Error('COMPOSE_API_TOKEN not set');
317
+ }
318
+
319
+ let result;
320
+ try {
321
+ result = await serverRequest(port, `/api/auth/devices/${encodeURIComponent(deviceId)}`, {
322
+ method: 'DELETE',
323
+ headers: { 'x-compose-token': token },
324
+ });
325
+ } catch {
326
+ out.push(`Error: Could not reach compose server on port ${port}.`);
327
+ return;
328
+ }
329
+
330
+ if (result.status === 404) {
331
+ out.push(`Device not found: ${deviceId}`);
332
+ return;
333
+ }
334
+ if (result.status !== 200) {
335
+ out.push(`Error from server (${result.status}): ${JSON.stringify(result.body)}`);
336
+ return;
337
+ }
338
+ out.push(`Revoked device: ${deviceId}`);
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Verb: rotate-secret
343
+ // ---------------------------------------------------------------------------
344
+
345
+ async function verbRotateSecret(subArgs, { port, token, out }) {
346
+ if (!hasFlag(subArgs, 'yes')) {
347
+ out.push('Error: rotate-secret requires --yes to confirm.');
348
+ out.push('This operation invalidates ALL paired device tokens immediately.');
349
+ out.push('Devices will need to re-pair after rotation.');
350
+ out.push('');
351
+ out.push('To proceed: compose remote rotate-secret --yes');
352
+ throw new Error('--yes required');
353
+ }
354
+ if (!token) {
355
+ out.push('Error: COMPOSE_API_TOKEN is not set.');
356
+ throw new Error('COMPOSE_API_TOKEN not set');
357
+ }
358
+
359
+ let result;
360
+ try {
361
+ result = await serverRequest(port, '/api/auth/rotate-secret', {
362
+ method: 'POST',
363
+ body: {},
364
+ headers: { 'x-compose-token': token },
365
+ });
366
+ } catch {
367
+ out.push(`Error: Could not reach compose server on port ${port}.`);
368
+ return;
369
+ }
370
+
371
+ if (result.status !== 200) {
372
+ out.push(`Error from server (${result.status}): ${JSON.stringify(result.body)}`);
373
+ return;
374
+ }
375
+ out.push('Secret rotated successfully.');
376
+ out.push('All paired device tokens are now invalid. Devices must re-pair.');
377
+ out.push('Run `compose remote pair` to generate new pairing codes.');
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Verb: status
382
+ // ---------------------------------------------------------------------------
383
+
384
+ async function verbStatus(subArgs, { port, token, cwd, out, distDir, headFn }) {
385
+ const cfg = readComposeJson(cwd);
386
+ const publicHost = cfg.remote?.public_host || null;
387
+ const remoteAuthFlag = process.env.COMPOSE_REMOTE_AUTH || 'disabled';
388
+
389
+ // Resolve dist dir
390
+ const resolvedDistDir = distDir || join(COMPOSE_HOME, 'dist');
391
+
392
+ // Bind host (from env > config > default)
393
+ const bindHost = process.env.COMPOSE_HOST || cfg.server?.host || '127.0.0.1';
394
+ out.push(`Bind host: ${bindHost}`);
395
+
396
+ // Remote auth flag
397
+ out.push(`Remote auth: ${remoteAuthFlag}`);
398
+
399
+ // Public host
400
+ out.push(`Public host: ${publicHost || 'not configured'}`);
401
+
402
+ // dist/ presence
403
+ const distExists = existsSync(resolvedDistDir);
404
+ if (distExists) {
405
+ out.push(`dist/ bundle: present`);
406
+ } else {
407
+ out.push(`dist/ bundle: MISSING — run npm run build before using remote access`);
408
+ }
409
+
410
+ // Paired device count (graceful on ECONNREFUSED)
411
+ if (token) {
412
+ try {
413
+ const devResult = await serverRequest(port, '/api/auth/devices', {
414
+ method: 'GET',
415
+ headers: { 'x-compose-token': token },
416
+ });
417
+ if (devResult.status === 200) {
418
+ const allDevices = devResult.body.devices || [];
419
+ const activeCount = allDevices.filter((d) => !d.revoked).length;
420
+ out.push(`Paired devices: ${activeCount} active (${allDevices.length} total)`);
421
+ } else {
422
+ out.push(`Paired devices: (could not read — server returned ${devResult.status})`);
423
+ }
424
+ } catch {
425
+ out.push(`Paired devices: (server not running on port ${port})`);
426
+ }
427
+ } else {
428
+ out.push('Paired devices: (COMPOSE_API_TOKEN not set — cannot query server)');
429
+ }
430
+
431
+ // Public host reachability check
432
+ if (publicHost) {
433
+ const headUrl = `${publicHost.replace(/\/$/, '')}/api/health`;
434
+ out.push(`Checking: HEAD ${headUrl}`);
435
+ const checkFn = headFn || defaultHeadCheck;
436
+ try {
437
+ const headResult = await checkFn(headUrl);
438
+ if (headResult.ok) {
439
+ out.push(`Public host: reachable (HTTP ${headResult.status})`);
440
+ } else {
441
+ out.push(`Public host: UNREACHABLE (HTTP ${headResult.status || 'no response'})`);
442
+ out.push('Check that your tunnel is running and forwarding to port 4001.');
443
+ }
444
+ } catch {
445
+ out.push('Public host: UNREACHABLE (error during HEAD check)');
446
+ }
447
+ } else {
448
+ out.push('');
449
+ out.push('To configure remote access:');
450
+ out.push(' 1. Start a tunnel to port 4001 (Tailscale Funnel, Cloudflare Tunnel, ngrok, etc.)');
451
+ out.push(' 2. Run: compose remote pair --public-host=<your-tunnel-URL>');
452
+ }
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Main dispatcher
457
+ // ---------------------------------------------------------------------------
458
+
459
+ /**
460
+ * Run a remote subcommand.
461
+ *
462
+ * @param {string[]} subArgs Arguments after "remote" (e.g. ['pair', '--public-host=...'])
463
+ * @param {object} opts
464
+ * @param {number} [opts.port=4001] Server port
465
+ * @param {string} [opts.token] COMPOSE_API_TOKEN (default: process.env.COMPOSE_API_TOKEN)
466
+ * @param {string} [opts.cwd] Project root (default: process.cwd())
467
+ * @param {object} [opts.lines] Output collector with .push(line) (default: prints to stdout)
468
+ * @param {function}[opts.qr] QR renderer: (url) => void | Promise<void>
469
+ * @param {function}[opts.poll] Delay: (ms) => Promise<void>
470
+ * @param {string} [opts.distDir] Path to dist/ (status verb)
471
+ * @param {function}[opts.headFn] HEAD check fn (status verb)
472
+ */
473
+ export async function runRemoteCommand(subArgs, opts = {}) {
474
+ const {
475
+ port = 4001,
476
+ token = process.env.COMPOSE_API_TOKEN,
477
+ cwd = process.cwd(),
478
+ lines = null,
479
+ qr = null,
480
+ poll = null,
481
+ distDir = undefined,
482
+ headFn = undefined,
483
+ } = opts;
484
+
485
+ // Output collector: use provided array/object or fall back to stdout
486
+ const out = lines || { push: (l) => console.log(l) };
487
+
488
+ // Default QR renderer (qrcode-terminal, dynamically imported to avoid test dep issues)
489
+ const qrFn = qr || (async (url) => {
490
+ const { default: qrcode } = await import('qrcode-terminal');
491
+ qrcode.generate(url, { small: true });
492
+ });
493
+
494
+ // Default poll (sleep)
495
+ const pollFn = poll || ((ms) => new Promise((r) => setTimeout(r, ms)));
496
+
497
+ const sub = subArgs[0];
498
+ const rest = subArgs.slice(1);
499
+
500
+ const ctx = { port, token, cwd, out, qrFn, pollFn, distDir, headFn };
501
+
502
+ switch (sub) {
503
+ case 'pair':
504
+ return verbPair(rest, ctx);
505
+
506
+ case 'list':
507
+ return verbList(rest, ctx);
508
+
509
+ case 'revoke':
510
+ return verbRevoke(rest, ctx);
511
+
512
+ case 'rotate-secret':
513
+ return verbRotateSecret(rest, ctx);
514
+
515
+ case 'status':
516
+ return verbStatus(rest, ctx);
517
+
518
+ default: {
519
+ out.push('Usage: compose remote <subcommand>');
520
+ out.push('');
521
+ out.push('Subcommands:');
522
+ out.push(' pair [--public-host=URL] [--name=NAME] Pair a new device via QR code');
523
+ out.push(' list List paired devices');
524
+ out.push(' revoke <device-id> Revoke a paired device');
525
+ out.push(' rotate-secret --yes Rotate JWT signing secret (invalidates all devices)');
526
+ out.push(' status Show remote config and server health');
527
+ if (sub && sub !== '--help' && sub !== '-h') {
528
+ out.push(`\nUnknown subcommand: ${sub}`);
529
+ }
530
+ break;
531
+ }
532
+ }
533
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.46-beta",
3
+ "version": "0.2.47-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -98,6 +98,8 @@
98
98
  "express": "^4.21.0",
99
99
  "lucide-react": "^0.563.0",
100
100
  "mermaid": "^11.13.0",
101
+ "qrcode": "^1.5.4",
102
+ "qrcode-terminal": "^0.12.0",
101
103
  "react": "^19.2.5",
102
104
  "react-dom": "^19.2.5",
103
105
  "react-markdown": "^10.1.0",