@commonlyai/cli 0.1.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.
@@ -0,0 +1,653 @@
1
+ /**
2
+ * commonly dev <subcommand>
3
+ *
4
+ * up — start local Commonly instance (wraps ./dev.sh up)
5
+ * clawdbot — bootstrap local OpenClaw runtime state for docker-compose
6
+ * down — stop local instance
7
+ * logs — tail local instance logs
8
+ * test — run backend tests
9
+ *
10
+ * Sets --instance http://localhost:5000 automatically after `dev up`.
11
+ */
12
+
13
+ import { randomBytes } from 'crypto';
14
+ import { spawnSync, spawn } from 'child_process';
15
+ import {
16
+ copyFileSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from 'fs';
22
+ import { dirname, isAbsolute, join } from 'path';
23
+ import { saveInstance, getToken, LOCAL_URL } from '../lib/config.js';
24
+ import { createClient, login as apiLogin } from '../lib/api.js';
25
+
26
+ const CLAWDBOT_AGENT_NAME = 'openclaw';
27
+ const CLAWDBOT_DEFAULT_INSTANCE_ID = 'local';
28
+ const CLAWDBOT_DEFAULT_DISPLAY_NAME = 'Local OpenClaw';
29
+ const CLAWDBOT_DEFAULT_POD_NAME = 'Local OpenClaw Sandbox';
30
+ const CLAWDBOT_CONFIG_RELATIVE_PATH = join('external', 'clawdbot-state', 'config', 'moltbot.json');
31
+ const LOCAL_ENV_FILE = '.env';
32
+ const LOCAL_ENV_EXAMPLE_FILE = '.env.example';
33
+ const LOCAL_LOGIN_DEFAULTS = {
34
+ email: 'dev@commonly.local',
35
+ password: 'password123',
36
+ username: 'localdev',
37
+ };
38
+ const CLAWDBOT_INSTALL_SCOPES = [
39
+ 'context:read',
40
+ 'messages:read',
41
+ 'messages:write',
42
+ 'memory:read',
43
+ 'memory:write',
44
+ ];
45
+ const CLAWDBOT_USER_TOKEN_SCOPES = [
46
+ 'agent:events:read',
47
+ 'agent:events:ack',
48
+ 'agent:context:read',
49
+ 'agent:messages:read',
50
+ 'agent:messages:write',
51
+ ];
52
+ const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
53
+
54
+ const findDevSh = (startDir = process.cwd()) => {
55
+ let dir = startDir;
56
+ for (let i = 0; i < 8; i++) {
57
+ const candidate = join(dir, 'dev.sh');
58
+ if (existsSync(candidate)) return candidate;
59
+ const parent = join(dir, '..');
60
+ if (parent === dir) break;
61
+ dir = parent;
62
+ }
63
+ return null;
64
+ };
65
+
66
+ const findRepoRoot = (startDir = process.cwd()) => {
67
+ const devSh = findDevSh(startDir);
68
+ return devSh ? dirname(devSh) : null;
69
+ };
70
+
71
+ const runDevSh = (args, opts = {}) => {
72
+ const devSh = findDevSh();
73
+ if (!devSh) {
74
+ console.error('dev.sh not found — run this command from within the commonly repo');
75
+ process.exit(1);
76
+ }
77
+
78
+ const spawnOpts = {
79
+ stdio: 'inherit',
80
+ env: { ...process.env, ...(opts.env || {}) },
81
+ };
82
+
83
+ if (opts.stream) {
84
+ return spawn('bash', [devSh, ...args], spawnOpts);
85
+ }
86
+
87
+ return spawnSync('bash', [devSh, ...args], spawnOpts);
88
+ };
89
+
90
+ export const isTruthyEnvValue = (value) => TRUTHY_ENV_VALUES.has(String(value || '').trim().toLowerCase());
91
+
92
+ const normalizeEnvValue = (rawValue) => {
93
+ const trimmed = String(rawValue || '').trim();
94
+ if (!trimmed) return '';
95
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
96
+ || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
97
+ return trimmed.slice(1, -1);
98
+ }
99
+ return trimmed.replace(/\s+#.*$/, '').trim();
100
+ };
101
+
102
+ const ENV_ASSIGNMENT_RE = /^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
103
+
104
+ const parseEnvEntries = (content = '') => {
105
+ const entries = new Map();
106
+ content.split(/\r?\n/).forEach((line, index) => {
107
+ const match = line.match(ENV_ASSIGNMENT_RE);
108
+ if (!match) return;
109
+ entries.set(match[1], {
110
+ index,
111
+ value: normalizeEnvValue(match[2]),
112
+ });
113
+ });
114
+ return entries;
115
+ };
116
+
117
+ export const upsertEnvFileValues = (content = '', updates = {}) => {
118
+ const lines = content ? content.split(/\r?\n/) : [];
119
+ const entries = parseEnvEntries(content);
120
+
121
+ Object.entries(updates).forEach(([key, value]) => {
122
+ const rendered = `${key}=${value}`;
123
+ if (entries.has(key)) {
124
+ lines[entries.get(key).index] = rendered;
125
+ } else {
126
+ lines.push(rendered);
127
+ }
128
+ });
129
+
130
+ const compacted = lines
131
+ .join('\n')
132
+ .replace(/\n{3,}/g, '\n\n')
133
+ .replace(/\n*$/, '\n');
134
+
135
+ return compacted || '\n';
136
+ };
137
+
138
+ const ensureRepoEnvFile = (repoRoot) => {
139
+ const envPath = join(repoRoot, LOCAL_ENV_FILE);
140
+ if (existsSync(envPath)) return envPath;
141
+
142
+ const examplePath = join(repoRoot, LOCAL_ENV_EXAMPLE_FILE);
143
+ if (existsSync(examplePath)) {
144
+ copyFileSync(examplePath, envPath);
145
+ return envPath;
146
+ }
147
+
148
+ writeFileSync(envPath, '', 'utf8');
149
+ return envPath;
150
+ };
151
+
152
+ const readEnvValue = (repoRoot, key) => {
153
+ const envPath = join(repoRoot, LOCAL_ENV_FILE);
154
+ if (!existsSync(envPath)) return '';
155
+ const entries = parseEnvEntries(readFileSync(envPath, 'utf8'));
156
+ return entries.get(key)?.value || '';
157
+ };
158
+
159
+ const writeEnvValues = (repoRoot, updates) => {
160
+ const envPath = ensureRepoEnvFile(repoRoot);
161
+ const current = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
162
+ const next = upsertEnvFileValues(current, updates);
163
+ writeFileSync(envPath, next, 'utf8');
164
+ return envPath;
165
+ };
166
+
167
+ const ensureLocalInstanceConfig = () => {
168
+ saveInstance({
169
+ key: 'local',
170
+ url: LOCAL_URL,
171
+ token: getToken('local') || null,
172
+ username: null,
173
+ userId: null,
174
+ });
175
+ };
176
+
177
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
178
+
179
+ const waitForLocalHealth = async ({ timeoutMs = 90000, intervalMs = 3000 } = {}) => {
180
+ const deadline = Date.now() + timeoutMs;
181
+ let lastError = null;
182
+
183
+ while (Date.now() < deadline) {
184
+ try {
185
+ const res = await fetch(`${LOCAL_URL}/api/health`);
186
+ if (res.ok) return;
187
+ lastError = new Error(`HTTP ${res.status}`);
188
+ } catch (error) {
189
+ lastError = error;
190
+ }
191
+ await sleep(intervalMs);
192
+ }
193
+
194
+ throw new Error(
195
+ `Local backend did not become healthy at ${LOCAL_URL} within ${Math.round(timeoutMs / 1000)}s`
196
+ + (lastError ? `: ${lastError.message}` : ''),
197
+ );
198
+ };
199
+
200
+ const resolveLocalLoginSettings = (repoRoot) => ({
201
+ email: process.env.LOCAL_DEV_LOGIN_EMAIL
202
+ || readEnvValue(repoRoot, 'LOCAL_DEV_LOGIN_EMAIL')
203
+ || LOCAL_LOGIN_DEFAULTS.email,
204
+ password: process.env.LOCAL_DEV_LOGIN_PASSWORD
205
+ || readEnvValue(repoRoot, 'LOCAL_DEV_LOGIN_PASSWORD')
206
+ || LOCAL_LOGIN_DEFAULTS.password,
207
+ username: process.env.LOCAL_DEV_LOGIN_USERNAME
208
+ || readEnvValue(repoRoot, 'LOCAL_DEV_LOGIN_USERNAME')
209
+ || LOCAL_LOGIN_DEFAULTS.username,
210
+ });
211
+
212
+ const persistLocalLogin = (loginResult) => {
213
+ const token = loginResult.token;
214
+ const userId = loginResult.user?._id || loginResult.user?.id || null;
215
+ const username = loginResult.user?.username || null;
216
+ saveInstance({
217
+ key: 'local',
218
+ url: LOCAL_URL,
219
+ token,
220
+ userId,
221
+ username,
222
+ });
223
+ return token;
224
+ };
225
+
226
+ const ensureLocalAuth = async (repoRoot) => {
227
+ ensureLocalInstanceConfig();
228
+
229
+ const existingToken = getToken('local');
230
+ if (existingToken) {
231
+ const probeClient = createClient({ instance: LOCAL_URL, token: existingToken });
232
+ try {
233
+ await probeClient.get('/api/pods');
234
+ return existingToken;
235
+ } catch (error) {
236
+ if (error.status && ![401, 403].includes(error.status)) {
237
+ throw error;
238
+ }
239
+ }
240
+ }
241
+
242
+ const credentials = resolveLocalLoginSettings(repoRoot);
243
+ const result = await apiLogin(LOCAL_URL, credentials.email, credentials.password);
244
+ return persistLocalLogin(result);
245
+ };
246
+
247
+ const listPods = async (client) => {
248
+ const data = await client.get('/api/pods');
249
+ return Array.isArray(data) ? data : data.pods || [];
250
+ };
251
+
252
+ const resolveOrCreatePod = async (client, { podId = null, podName = CLAWDBOT_DEFAULT_POD_NAME } = {}) => {
253
+ if (podId) return { podId, created: false, name: null };
254
+
255
+ const existingPods = await listPods(client);
256
+ const existing = existingPods.find((pod) => pod?.name === podName);
257
+ if (existing?._id) {
258
+ return { podId: existing._id, created: false, name: existing.name || podName };
259
+ }
260
+
261
+ const created = await client.post('/api/pods', {
262
+ name: podName,
263
+ type: 'chat',
264
+ });
265
+
266
+ return {
267
+ podId: created._id || created.id,
268
+ created: true,
269
+ name: created.name || podName,
270
+ };
271
+ };
272
+
273
+ const listInstalledAgents = async (client, podId) => {
274
+ const data = await client.get(`/api/registry/pods/${podId}/agents`);
275
+ return Array.isArray(data) ? data : data.agents || [];
276
+ };
277
+
278
+ const ensureOpenClawInstallation = async (client, { podId, instanceId, displayName }) => {
279
+ const agents = await listInstalledAgents(client, podId);
280
+ const existing = agents.find(
281
+ (agent) => agent?.agentName === CLAWDBOT_AGENT_NAME && (agent?.instanceId || 'default') === instanceId,
282
+ );
283
+
284
+ if (existing) {
285
+ return { installation: existing, created: false };
286
+ }
287
+
288
+ const result = await client.post('/api/registry/install', {
289
+ agentName: CLAWDBOT_AGENT_NAME,
290
+ podId,
291
+ instanceId,
292
+ displayName,
293
+ version: '1.0.0',
294
+ config: {
295
+ runtime: {
296
+ runtimeType: 'moltbot',
297
+ },
298
+ },
299
+ scopes: CLAWDBOT_INSTALL_SCOPES,
300
+ });
301
+
302
+ return {
303
+ installation: result.installation || result,
304
+ created: true,
305
+ };
306
+ };
307
+
308
+ const resolveGatewayToken = (repoRoot, explicitToken = null) => (
309
+ explicitToken
310
+ || readEnvValue(repoRoot, 'CLAWDBOT_GATEWAY_TOKEN')
311
+ || `local-clawdbot-${randomBytes(12).toString('hex')}`
312
+ );
313
+
314
+ const mapContainerPathToRepo = (repoRoot, configPath) => {
315
+ if (!configPath) return null;
316
+ if (!isAbsolute(configPath)) return join(repoRoot, configPath);
317
+ if (configPath.startsWith('/app/')) return join(repoRoot, configPath.slice('/app/'.length));
318
+ if (configPath.startsWith('/repo/')) return join(repoRoot, configPath.slice('/repo/'.length));
319
+ return configPath;
320
+ };
321
+
322
+ const resolveClawdbotConfigPath = (repoRoot, configPath = null) => (
323
+ mapContainerPathToRepo(repoRoot, configPath) || join(repoRoot, CLAWDBOT_CONFIG_RELATIVE_PATH)
324
+ );
325
+
326
+ export const patchClawdbotConfig = ({
327
+ config = {},
328
+ accountId,
329
+ podId,
330
+ displayName,
331
+ runtimeToken,
332
+ userToken,
333
+ gatewayToken,
334
+ }) => {
335
+ const next = JSON.parse(JSON.stringify(config || {}));
336
+
337
+ next.gateway = next.gateway || {};
338
+ next.gateway.mode = next.gateway.mode || 'local';
339
+ next.gateway.bind = next.gateway.bind || 'lan';
340
+ next.gateway.auth = next.gateway.auth || {};
341
+ if (gatewayToken) next.gateway.auth.token = gatewayToken;
342
+ next.gateway.controlUi = next.gateway.controlUi || {};
343
+ const allowedOrigins = Array.isArray(next.gateway.controlUi.allowedOrigins)
344
+ ? next.gateway.controlUi.allowedOrigins.filter(Boolean)
345
+ : [];
346
+ if (allowedOrigins.length === 0) {
347
+ next.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback = true;
348
+ }
349
+
350
+ next.channels = next.channels || {};
351
+ next.channels.commonly = next.channels.commonly || {};
352
+ next.channels.commonly.enabled = true;
353
+ next.channels.commonly.baseUrl = next.channels.commonly.baseUrl || 'http://backend:5000';
354
+ next.channels.commonly.accounts = next.channels.commonly.accounts || {};
355
+ const existingAccount = next.channels.commonly.accounts[accountId] || {};
356
+ const podIds = Array.isArray(existingAccount.podIds) ? existingAccount.podIds : [];
357
+ next.channels.commonly.accounts[accountId] = {
358
+ ...existingAccount,
359
+ runtimeToken,
360
+ userToken,
361
+ agentName: CLAWDBOT_AGENT_NAME,
362
+ instanceId: accountId,
363
+ podIds: Array.from(new Set([...podIds, podId].filter(Boolean))),
364
+ };
365
+
366
+ next.agents = next.agents || {};
367
+ next.agents.list = Array.isArray(next.agents.list) ? next.agents.list : [];
368
+ const agentEntry = next.agents.list.find((agent) => agent?.id === accountId);
369
+ if (agentEntry) {
370
+ if (!agentEntry.name) agentEntry.name = displayName || accountId;
371
+ if (!agentEntry.workspace) agentEntry.workspace = `/home/node/clawd/${accountId}`;
372
+ } else {
373
+ next.agents.list.push({
374
+ id: accountId,
375
+ name: displayName || accountId,
376
+ workspace: `/home/node/clawd/${accountId}`,
377
+ });
378
+ }
379
+
380
+ next.bindings = Array.isArray(next.bindings) ? next.bindings : [];
381
+ const hasBinding = next.bindings.some(
382
+ (binding) => binding?.match?.channel === 'commonly' && binding?.match?.accountId === accountId,
383
+ );
384
+ if (!hasBinding) {
385
+ next.bindings.push({
386
+ agentId: accountId,
387
+ match: {
388
+ channel: 'commonly',
389
+ accountId,
390
+ },
391
+ });
392
+ }
393
+
394
+ return next;
395
+ };
396
+
397
+ export const bootstrapClawdbotRuntime = async ({
398
+ client,
399
+ repoRoot,
400
+ podId = null,
401
+ podName = CLAWDBOT_DEFAULT_POD_NAME,
402
+ instanceId = CLAWDBOT_DEFAULT_INSTANCE_ID,
403
+ displayName = CLAWDBOT_DEFAULT_DISPLAY_NAME,
404
+ gatewayToken = null,
405
+ force = false,
406
+ }) => {
407
+ writeEnvValues(repoRoot, {
408
+ COMMONLY_LOCAL_CLAWDBOT: '1',
409
+ CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
410
+ });
411
+
412
+ const pod = await resolveOrCreatePod(client, { podId, podName });
413
+ const install = await ensureOpenClawInstallation(client, {
414
+ podId: pod.podId,
415
+ instanceId,
416
+ displayName,
417
+ });
418
+
419
+ let provisionResult = null;
420
+ try {
421
+ provisionResult = await client.post(
422
+ `/api/registry/pods/${pod.podId}/agents/${CLAWDBOT_AGENT_NAME}/provision`,
423
+ {
424
+ instanceId,
425
+ includeUserToken: true,
426
+ force,
427
+ scopes: CLAWDBOT_USER_TOKEN_SCOPES,
428
+ },
429
+ );
430
+ } catch (error) {
431
+ if (!(error.status === 429 && !force)) throw error;
432
+ }
433
+
434
+ const runtimeIssued = await client.post(
435
+ `/api/registry/pods/${pod.podId}/agents/${CLAWDBOT_AGENT_NAME}/runtime-tokens`,
436
+ { instanceId, force: true },
437
+ );
438
+ const runtimeToken = runtimeIssued.token;
439
+ if (!runtimeToken) {
440
+ throw new Error('Runtime token was not returned by the runtime-tokens route');
441
+ }
442
+
443
+ const userIssued = await client.post(
444
+ `/api/registry/pods/${pod.podId}/agents/${CLAWDBOT_AGENT_NAME}/user-token`,
445
+ {
446
+ instanceId,
447
+ displayName,
448
+ scopes: CLAWDBOT_USER_TOKEN_SCOPES,
449
+ },
450
+ );
451
+ const userToken = userIssued.token;
452
+ if (!userToken) {
453
+ throw new Error('User token was not returned by the user-token route');
454
+ }
455
+
456
+ const configPath = resolveClawdbotConfigPath(repoRoot, provisionResult?.configPath);
457
+ if (!existsSync(configPath) && !provisionResult) {
458
+ throw new Error(
459
+ `OpenClaw config not found at ${configPath}. Run with --force after the provision cooldown `
460
+ + 'or clear stale clawdbot state before bootstrapping again.',
461
+ );
462
+ }
463
+
464
+ const rawConfig = existsSync(configPath)
465
+ ? JSON.parse(readFileSync(configPath, 'utf8') || '{}')
466
+ : {};
467
+ const nextConfig = patchClawdbotConfig({
468
+ config: rawConfig,
469
+ accountId: instanceId,
470
+ podId: pod.podId,
471
+ displayName,
472
+ runtimeToken,
473
+ userToken,
474
+ gatewayToken,
475
+ });
476
+ mkdirSync(dirname(configPath), { recursive: true });
477
+ writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8');
478
+
479
+ writeEnvValues(repoRoot, {
480
+ COMMONLY_LOCAL_CLAWDBOT: '1',
481
+ CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
482
+ OPENCLAW_RUNTIME_TOKEN: runtimeToken,
483
+ OPENCLAW_USER_TOKEN: userToken,
484
+ });
485
+
486
+ return {
487
+ podId: pod.podId,
488
+ podCreated: pod.created,
489
+ installationCreated: install.created,
490
+ instanceId,
491
+ displayName,
492
+ gatewayToken,
493
+ runtimeToken,
494
+ userToken,
495
+ configPath,
496
+ provisioned: Boolean(provisionResult),
497
+ };
498
+ };
499
+
500
+ export const registerDev = (program) => {
501
+ const dev = program.command('dev').description('Local development environment');
502
+
503
+ dev.addHelpText('after', `
504
+ Examples:
505
+ $ commonly dev up # Start a local docker-compose stack
506
+ $ commonly dev clawdbot # Bootstrap local OpenClaw + start it
507
+ $ commonly dev status # Check health
508
+ $ commonly dev logs backend # Tail backend logs
509
+ $ commonly dev down # Stop everything
510
+
511
+ Login against the local instance with:
512
+ $ commonly login --instance http://localhost:5000
513
+ `);
514
+
515
+ dev
516
+ .command('up')
517
+ .description('Start local Commonly instance')
518
+ .option('--with-gateway', 'Enable the clawdbot profile for this start', false)
519
+ .action(async (opts) => {
520
+ runDevSh(['up'], {
521
+ env: opts.withGateway ? { COMMONLY_LOCAL_CLAWDBOT: '1' } : undefined,
522
+ });
523
+
524
+ ensureLocalInstanceConfig();
525
+
526
+ console.log('\nLocal instance ready:');
527
+ console.log(' Frontend: http://localhost:3000');
528
+ console.log(' Backend: http://localhost:5000');
529
+ console.log('\nLogin to local instance:');
530
+ console.log(' commonly login --instance http://localhost:5000 --key local');
531
+ });
532
+
533
+ dev
534
+ .command('clawdbot')
535
+ .description('Bootstrap local OpenClaw gateway config, tokens, and env state')
536
+ .option('--pod <podId>', 'Reuse an existing pod by id')
537
+ .option('--pod-name <name>', 'Create or reuse a local pod by name', CLAWDBOT_DEFAULT_POD_NAME)
538
+ .option('--instance-id <id>', 'OpenClaw instance/account id', CLAWDBOT_DEFAULT_INSTANCE_ID)
539
+ .option('--display <name>', 'Display name for the local OpenClaw agent', CLAWDBOT_DEFAULT_DISPLAY_NAME)
540
+ .option('--gateway-token <token>', 'Reuse an explicit gateway token instead of generating one')
541
+ .option('--force', 'Force reprovision and refresh local gateway state', false)
542
+ .option('--no-start', 'Write config and env only; do not start or restart docker services')
543
+ .action(async (opts) => {
544
+ const repoRoot = findRepoRoot();
545
+ if (!repoRoot) {
546
+ console.error('dev.sh not found — run this command from within the commonly repo');
547
+ process.exit(1);
548
+ }
549
+
550
+ const gatewayToken = resolveGatewayToken(repoRoot, opts.gatewayToken);
551
+ writeEnvValues(repoRoot, {
552
+ COMMONLY_LOCAL_CLAWDBOT: '1',
553
+ CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
554
+ });
555
+
556
+ if (opts.start) {
557
+ runDevSh(['up'], {
558
+ env: {
559
+ COMMONLY_LOCAL_CLAWDBOT: '1',
560
+ CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
561
+ },
562
+ });
563
+ await waitForLocalHealth();
564
+ }
565
+
566
+ ensureLocalInstanceConfig();
567
+ const token = await ensureLocalAuth(repoRoot);
568
+ const client = createClient({ instance: LOCAL_URL, token });
569
+
570
+ const result = await bootstrapClawdbotRuntime({
571
+ client,
572
+ repoRoot,
573
+ podId: opts.pod || null,
574
+ podName: opts.podName,
575
+ instanceId: opts.instanceId,
576
+ displayName: opts.display,
577
+ gatewayToken,
578
+ force: opts.force,
579
+ });
580
+
581
+ if (opts.start) {
582
+ runDevSh(['clawdbot', 'restart'], {
583
+ env: {
584
+ COMMONLY_LOCAL_CLAWDBOT: '1',
585
+ CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
586
+ },
587
+ });
588
+ }
589
+
590
+ console.log('\nLocal OpenClaw bootstrap complete:');
591
+ console.log(` Pod: ${result.podId}`);
592
+ console.log(` Agent: ${CLAWDBOT_AGENT_NAME}:${result.instanceId}`);
593
+ console.log(` Config: ${result.configPath}`);
594
+ console.log(` Gateway: ${gatewayToken}`);
595
+ console.log(` Started: ${opts.start ? 'yes' : 'no'}`);
596
+ console.log('\nNext:');
597
+ console.log(' ./dev.sh clawdbot logs gateway');
598
+ console.log(' commonly pod tail <podId> --instance local');
599
+ });
600
+
601
+ dev
602
+ .command('down')
603
+ .description('Stop local Commonly instance')
604
+ .action(() => {
605
+ runDevSh(['down']);
606
+ });
607
+
608
+ dev
609
+ .command('logs [service]')
610
+ .description('Tail logs (backend, frontend, mongo, postgres)')
611
+ .option('--follow', 'Stream logs continuously', true)
612
+ .action((service) => {
613
+ const args = service ? ['logs', service] : ['logs'];
614
+ runDevSh(args, { stream: true });
615
+ });
616
+
617
+ dev
618
+ .command('test')
619
+ .description('Run tests')
620
+ .option('--watch', 'Watch mode', false)
621
+ .option('--frontend', 'Frontend tests only', false)
622
+ .option('--backend', 'Backend tests only', false)
623
+ .action((opts) => {
624
+ const devSh = findDevSh();
625
+ if (!devSh) { console.error('dev.sh not found'); process.exit(1); }
626
+
627
+ if (!opts.frontend) runDevSh(['test']);
628
+ if (!opts.backend) {
629
+ const dir = join(findDevSh(), '../frontend');
630
+ if (existsSync(dir)) {
631
+ spawnSync('npm', ['test', ...(opts.watch ? [] : ['--', '--watchAll=false'])], {
632
+ cwd: dir,
633
+ stdio: 'inherit',
634
+ shell: true,
635
+ });
636
+ }
637
+ }
638
+ });
639
+
640
+ dev
641
+ .command('status')
642
+ .description('Check health of local instance')
643
+ .action(async () => {
644
+ const client = createClient({ instance: LOCAL_URL, token: null });
645
+ try {
646
+ const data = await client.get('/api/health');
647
+ console.log('Local instance: healthy');
648
+ console.log(JSON.stringify(data, null, 2));
649
+ } catch {
650
+ console.log('Local instance: not running (start with: commonly dev up)');
651
+ }
652
+ });
653
+ };