@synap-core/cli 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,12 +11,16 @@
11
11
  * synap openclaw restart — restart the container
12
12
  */
13
13
  import chalk from "chalk";
14
+ import ora from "ora";
14
15
  import { execSync } from "child_process";
15
16
  import prompts from "prompts";
17
+ import crypto from "node:crypto";
16
18
  import fs from "node:fs";
19
+ import path from "node:path";
17
20
  import { log, banner } from "../utils/logger.js";
18
21
  import { detectOpenClaw } from "../lib/openclaw.js";
19
- import { findSynapDeployDir } from "../lib/pod.js";
22
+ import { findSynapDeployDir, getLocalPodConfig } from "../lib/pod.js";
23
+ import { getStoredToken } from "../lib/auth.js";
20
24
  // ─── Overview ────────────────────────────────────────────────────────────────
21
25
  export async function openclawOverview() {
22
26
  banner();
@@ -46,10 +50,18 @@ export async function openclawOverview() {
46
50
  log.dim(`Version: ${oc.version}`);
47
51
  // ── AI provider ─────────────────────────────────────────────────────────
48
52
  log.heading("AI Provider");
49
- const apiKeyStatus = getAiKeyStatus();
50
- if (apiKeyStatus.configured) {
51
- log.success(`${apiKeyStatus.provider} key configured`);
52
- log.dim(`Model: ${apiKeyStatus.model ?? "default"}`);
53
+ const aiConfig = readOpenClawAiConfig(oc);
54
+ const hasAnyKey = !!(aiConfig.anthropicKey || aiConfig.openaiKey || aiConfig.geminiKey);
55
+ const apiKeyStatus = { configured: hasAnyKey };
56
+ if (hasAnyKey) {
57
+ if (aiConfig.anthropicKey)
58
+ log.success(`Anthropic: ${maskKey(aiConfig.anthropicKey)}`);
59
+ if (aiConfig.openaiKey)
60
+ log.success(`OpenAI: ${maskKey(aiConfig.openaiKey)}`);
61
+ if (aiConfig.geminiKey)
62
+ log.success(`Google: ${maskKey(aiConfig.geminiKey)}`);
63
+ if (aiConfig.primaryModel)
64
+ log.dim(`Model: ${aiConfig.primaryModel}`);
53
65
  }
54
66
  else {
55
67
  log.warn("No AI API key configured — OpenClaw cannot process requests");
@@ -78,16 +90,20 @@ export async function openclawOverview() {
78
90
  }
79
91
  // ── Dashboard ────────────────────────────────────────────────────────────
80
92
  log.heading("Dashboard");
81
- if (oc.runtime === "docker") {
82
- const containerName = oc.containerName ?? "openclaw";
83
- log.info(`Web UI: ${chalk.cyan(`http://localhost:${oc.gatewayPort ?? 18789}`)}`);
84
- log.dim("If on a remote server, tunnel first:");
85
- log.dim(` ssh -N -L 18789:localhost:18789 user@your-server`);
86
- log.dim(` Then open: http://localhost:18789`);
93
+ const publicUrl = getOpenClawPublicUrl();
94
+ if (publicUrl) {
95
+ log.success(`Public: ${chalk.cyan(publicUrl)}`);
96
+ log.dim(`Local: http://localhost:${oc.gatewayPort ?? 18789}`);
97
+ log.dim("Open: synap openclaw dashboard");
98
+ }
99
+ else if (oc.runtime === "docker") {
100
+ log.info(`Local: ${chalk.cyan(`http://localhost:${oc.gatewayPort ?? 18789}`)}`);
101
+ log.dim("Expose via domain: synap openclaw setup-domain");
102
+ log.dim("SSH tunnel (remote): synap openclaw dashboard");
87
103
  }
88
104
  else {
89
105
  log.info(`Web UI: ${chalk.cyan(`http://localhost:${oc.gatewayPort ?? 18789}`)}`);
90
- log.dim("Open: openclaw dashboard — or just open the URL above");
106
+ log.dim("Open: openclaw dashboard — or: synap openclaw dashboard");
91
107
  }
92
108
  // ── How to connect ───────────────────────────────────────────────────────
93
109
  log.heading("AI Client (MCP)");
@@ -126,37 +142,49 @@ export async function openclawConnect(opts) {
126
142
  const gatewayPort = oc.gatewayPort ?? 18789;
127
143
  const isDocker = oc.runtime === "docker";
128
144
  // OpenClaw MCP is stdio-based — clients run `openclaw mcp serve` as a local process
129
- // which connects to the gateway over WebSocket.
130
- // For Docker/remote setups the gateway is at ws://localhost:<port> after SSH tunnel.
145
+ // which connects to the gateway over WebSocket. The gateway token authenticates
146
+ // the connection we fetch it from the container so the config is ready to paste.
147
+ const token = readGatewayToken(oc) ?? undefined;
148
+ if (!token) {
149
+ log.warn("Could not read gateway token from OpenClaw.");
150
+ log.dim("The MCP configs below will require you to add --token manually.");
151
+ log.dim("Run: synap openclaw token");
152
+ log.blank();
153
+ }
131
154
  const client = opts.client?.toLowerCase();
132
155
  if (!client || client === "claude") {
133
- printMcpConfig("Claude Desktop", gatewayPort, isDocker, "claude");
156
+ printMcpConfig("Claude Desktop", gatewayPort, isDocker, "claude", token);
134
157
  }
135
158
  if (!client || client === "cursor") {
136
- printMcpConfig("Cursor", gatewayPort, isDocker, "cursor");
159
+ printMcpConfig("Cursor", gatewayPort, isDocker, "cursor", token);
137
160
  }
138
161
  if (!client || client === "windsurf") {
139
- printMcpConfig("Windsurf", gatewayPort, isDocker, "windsurf");
162
+ printMcpConfig("Windsurf", gatewayPort, isDocker, "windsurf", token);
140
163
  }
141
164
  if (client && !["claude", "cursor", "windsurf"].includes(client)) {
142
165
  log.warn(`Unknown client "${client}". Showing generic config.`);
143
- printMcpConfig("MCP Client", gatewayPort, isDocker, "generic");
166
+ printMcpConfig("MCP Client", gatewayPort, isDocker, "generic", token);
144
167
  }
145
168
  if (isDocker) {
146
169
  log.blank();
147
170
  log.info("Remote server? Tunnel the gateway port first:");
148
- log.dim(" ssh -N -L 18789:localhost:18789 user@your-server");
149
- log.dim(" Then use the configs above (they point to localhost:18789)");
171
+ log.dim(` ssh -N -L ${gatewayPort}:localhost:${gatewayPort} user@your-server`);
172
+ log.dim(" Then use the configs above (they point to localhost)");
150
173
  log.blank();
151
- log.dim("openclaw must be installed locally: npm i -g openclaw");
174
+ log.dim("openclaw must be installed locally on the client machine:");
175
+ log.dim(" npm i -g openclaw");
152
176
  }
153
177
  }
154
- function printMcpConfig(label, gatewayPort, isRemote, client) {
178
+ function printMcpConfig(label, gatewayPort, isRemote, client, token) {
155
179
  log.heading(label);
156
180
  // MCP config: stdio command that connects to the local (or tunneled) gateway
157
- const args = isRemote
158
- ? ["mcp", "serve", "--url", `ws://localhost:${gatewayPort}`]
159
- : ["mcp", "serve"];
181
+ const args = ["mcp", "serve"];
182
+ if (isRemote) {
183
+ args.push("--url", `ws://localhost:${gatewayPort}`);
184
+ }
185
+ if (token) {
186
+ args.push("--token", token);
187
+ }
160
188
  const config = JSON.stringify({ mcpServers: { openclaw: { command: "openclaw", args } } }, null, 2);
161
189
  const paths = {
162
190
  claude: "macOS: ~/Library/Application Support/Claude/claude_desktop_config.json",
@@ -181,95 +209,269 @@ function printMcpConfig(label, gatewayPort, isRemote, client) {
181
209
  export function openclawDashboard() {
182
210
  const oc = detectOpenClaw();
183
211
  const port = oc.gatewayPort ?? 18789;
184
- const url = `http://localhost:${port}`;
212
+ const localUrl = `http://localhost:${port}`;
213
+ // Check if a public domain is configured via Caddy
214
+ const publicUrl = getOpenClawPublicUrl();
215
+ if (publicUrl) {
216
+ log.info(`Dashboard: ${chalk.cyan(publicUrl)}`);
217
+ log.blank();
218
+ try {
219
+ const open = process.platform === "darwin" ? "open" : "xdg-open";
220
+ execSync(`${open} ${publicUrl}`, { stdio: "ignore" });
221
+ }
222
+ catch {
223
+ log.dim("Open the URL above in your browser.");
224
+ }
225
+ return;
226
+ }
185
227
  if (oc.runtime === "docker") {
186
- // On a remote server the browser can't open — give SSH tunnel instructions
187
228
  const isRemoteServer = !process.env.DISPLAY && process.platform === "linux";
188
229
  if (isRemoteServer) {
189
- log.info(`OpenClaw dashboard: ${chalk.cyan(url)}`);
230
+ log.info(`Dashboard: ${chalk.cyan(localUrl)}`);
231
+ log.blank();
232
+ log.info("You're on a remote server — tunnel the port to your laptop:");
190
233
  log.blank();
191
- log.info("You're on a remote server. Tunnel the port to your laptop:");
234
+ console.log(chalk.cyan(` ssh -N -L ${port}:localhost:${port} user@$(hostname -I | awk '{print $1}')`));
192
235
  log.blank();
193
- console.log(chalk.cyan(` ssh -N -L ${port}:localhost:${port} user@$(hostname)`));
236
+ log.dim(`Then open ${chalk.cyan(localUrl)} in your browser.`);
194
237
  log.blank();
195
- log.dim(`Then open ${url} in your browser.`);
238
+ log.info("Or expose it permanently via your domain:");
239
+ log.dim(" synap openclaw setup-domain");
196
240
  }
197
241
  else {
198
- // Local Docker — just open it
199
- log.info(`Opening ${chalk.cyan(url)} ...`);
242
+ log.info(`Opening ${chalk.cyan(localUrl)} ...`);
200
243
  try {
201
244
  const open = process.platform === "darwin" ? "open" : "xdg-open";
202
- execSync(`${open} ${url}`, { stdio: "ignore" });
245
+ execSync(`${open} ${localUrl}`, { stdio: "ignore" });
203
246
  }
204
247
  catch {
205
- log.dim(`Open manually: ${url}`);
248
+ log.dim(`Open manually: ${localUrl}`);
206
249
  }
207
250
  }
208
251
  }
209
252
  else {
210
- // Local install — use openclaw's own command
211
- log.info(`Opening dashboard...`);
253
+ log.info("Opening dashboard...");
212
254
  try {
213
255
  execSync("openclaw dashboard", { stdio: "inherit", timeout: 5000 });
214
256
  }
215
257
  catch {
216
- // Fallback: open URL directly
217
258
  try {
218
259
  const open = process.platform === "darwin" ? "open" : "xdg-open";
219
- execSync(`${open} ${url}`, { stdio: "ignore" });
260
+ execSync(`${open} ${localUrl}`, { stdio: "ignore" });
220
261
  }
221
262
  catch {
222
- log.info(`Dashboard: ${chalk.cyan(url)}`);
263
+ log.info(`Dashboard: ${chalk.cyan(localUrl)}`);
223
264
  }
224
265
  }
225
266
  }
226
267
  }
227
- // ─── Configure: set AI provider key ──────────────────────────────────────────
228
- export async function openclawConfigure() {
268
+ export async function openclawSetupDomain() {
229
269
  banner();
230
- log.heading("Configure AI Provider");
270
+ log.heading("Expose OpenClaw Dashboard");
271
+ log.blank();
231
272
  const deployDir = findSynapDeployDir();
232
- // Show current status
233
- const current = getAiKeyStatus();
234
- if (current.configured) {
235
- log.success(`Currently using: ${current.provider}`);
236
- log.dim(`Model: ${current.model ?? "default"}`);
273
+ if (!deployDir) {
274
+ log.error("Couldn't find the Synap deploy directory.");
275
+ log.dim("Run this command on the server where your pod is deployed.");
276
+ return;
277
+ }
278
+ const envDomain = readEnvVar(deployDir, "DOMAIN");
279
+ if (!envDomain) {
280
+ log.error("DOMAIN is not set in .env — can't determine pod domain.");
281
+ return;
282
+ }
283
+ // Detect pod type: managed (*.synap.live) vs self-hosted (custom domain)
284
+ const isManaged = envDomain.endsWith(".synap.live") || envDomain === "synap.live";
285
+ const localConfig = getLocalPodConfig();
286
+ const creds = getStoredToken();
287
+ log.info(`Pod domain: ${chalk.cyan(envDomain)}`);
288
+ log.dim(`Type: ${isManaged ? "managed (synap.live)" : "self-hosted"}`);
289
+ log.blank();
290
+ let publicDomain;
291
+ let authMode;
292
+ let basicAuthPassword;
293
+ if (isManaged) {
294
+ // ── Managed pod flow: CP creates DNS + CP OAuth ───────────────────────
295
+ if (!creds) {
296
+ log.error("Not logged in to Synap.");
297
+ log.dim("Run: synap login (or: synap login --token <token>)");
298
+ return;
299
+ }
300
+ if (!localConfig?.podId) {
301
+ log.error("Pod ID not found in local config.");
302
+ log.dim("Run: synap init (to set up the pod connection)");
303
+ return;
304
+ }
305
+ const { confirm } = await prompts({
306
+ type: "confirm",
307
+ name: "confirm",
308
+ message: `Create DNS record openclaw.${envDomain}? (CP will provision it)`,
309
+ initial: true,
310
+ });
311
+ if (!confirm)
312
+ return;
313
+ const spinner = ora("Asking Control Plane to create DNS record...").start();
314
+ try {
315
+ const result = await requestDashboardDomainFromCp(creds.token, localConfig.podId);
316
+ publicDomain = result.domain;
317
+ authMode = result.authMode;
318
+ spinner.succeed(`Domain created: ${chalk.cyan(publicDomain)}`);
319
+ }
320
+ catch (err) {
321
+ spinner.fail(err instanceof Error ? err.message : String(err));
322
+ log.dim("If this is a custom-domain or self-hosted pod, run again — it will fall back to manual mode.");
323
+ return;
324
+ }
325
+ }
326
+ else {
327
+ // ── Self-hosted flow: user sets DNS, we use basic auth ────────────────
328
+ log.info("Self-hosted pod — you'll need to add a DNS record yourself.");
237
329
  log.blank();
330
+ const { subdomain } = await prompts({
331
+ type: "text",
332
+ name: "subdomain",
333
+ message: "Public subdomain for OpenClaw:",
334
+ initial: `openclaw.${envDomain}`,
335
+ });
336
+ if (!subdomain)
337
+ return;
338
+ publicDomain = subdomain;
339
+ authMode = "basic";
340
+ log.blank();
341
+ log.info("Add this DNS A record:");
342
+ log.dim(` Type: A`);
343
+ log.dim(` Name: ${publicDomain}`);
344
+ log.dim(` Value: <this server's public IP>`);
345
+ log.blank();
346
+ const { dnsReady } = await prompts({
347
+ type: "confirm",
348
+ name: "dnsReady",
349
+ message: "DNS record added?",
350
+ initial: true,
351
+ });
352
+ if (!dnsReady) {
353
+ log.dim("Run this command again once DNS is set up.");
354
+ return;
355
+ }
356
+ // Generate a strong random password for basic auth
357
+ basicAuthPassword = generatePassword(32);
238
358
  }
239
- const { provider } = await prompts({
240
- type: "select",
241
- name: "provider",
242
- message: "Which AI provider?",
243
- choices: [
244
- {
245
- title: "Anthropic (Claude)",
246
- description: "claude-sonnet-4-6 best quality, recommended",
247
- value: "anthropic",
248
- },
249
- {
250
- title: "OpenAI (GPT-4o)",
251
- value: "openai",
252
- },
253
- {
254
- title: "Google (Gemini)",
255
- value: "google",
256
- },
257
- {
258
- title: "Synap IS (via pod)",
259
- description: "Uses your pod AI — no external key needed",
260
- value: "synap",
261
- },
262
- ],
263
- });
264
- if (!provider)
359
+ // ── Write the Caddy auth snippet ──────────────────────────────────────────
360
+ const snippetPath = path.join(deployDir, "openclaw_auth.snippet");
361
+ if (authMode === "cp-oauth") {
362
+ fs.writeFileSync(snippetPath, generateCpOAuthSnippet(), { mode: 0o644 });
363
+ log.success("Wrote CP OAuth auth snippet");
364
+ }
365
+ else {
366
+ const hash = await bcryptHashViaCaddy(basicAuthPassword);
367
+ fs.writeFileSync(snippetPath, generateBasicAuthSnippet("openclaw", hash), { mode: 0o644 });
368
+ log.success("Wrote basic auth snippet");
369
+ }
370
+ // ── Update .env ───────────────────────────────────────────────────────────
371
+ const envFile = path.join(deployDir, ".env");
372
+ writeEnvVar(envFile, "OPENCLAW_DOMAIN", publicDomain);
373
+ log.success(`Set OPENCLAW_DOMAIN=${publicDomain} in .env`);
374
+ // ── Restart Caddy ────────────────────────────────────────────────────────
375
+ log.blank();
376
+ log.info("Restarting Caddy to apply changes...");
377
+ try {
378
+ execSync("docker compose restart caddy", {
379
+ cwd: deployDir,
380
+ stdio: "pipe",
381
+ timeout: 30000,
382
+ });
383
+ log.success("Caddy restarted");
384
+ }
385
+ catch {
386
+ log.warn("Caddy restart failed — run manually:");
387
+ log.dim(` cd ${deployDir} && docker compose restart caddy`);
388
+ }
389
+ // ── Summary ──────────────────────────────────────────────────────────────
390
+ log.blank();
391
+ console.log(chalk.green("═══════════════════════════════════════════"));
392
+ console.log(chalk.green.bold(" OpenClaw Dashboard Ready"));
393
+ console.log(chalk.green("═══════════════════════════════════════════"));
394
+ log.blank();
395
+ log.info(`URL: ${chalk.cyan(`https://${publicDomain}`)}`);
396
+ log.blank();
397
+ if (authMode === "cp-oauth") {
398
+ log.info("Auth: Synap session (you're already logged in to synap.live)");
399
+ log.dim("Open the URL — if you're not signed in, you'll be redirected to login.");
400
+ }
401
+ else {
402
+ log.info("Auth: basic auth");
403
+ log.dim(` Username: openclaw`);
404
+ log.dim(` Password: ${chalk.cyan(basicAuthPassword)}`);
405
+ log.blank();
406
+ log.warn("Save this password — it won't be shown again.");
407
+ }
408
+ log.blank();
409
+ log.dim("TLS: Caddy will provision a Let's Encrypt certificate on first visit.");
410
+ log.dim("This takes ~30s the first time.");
411
+ log.blank();
412
+ }
413
+ export async function openclawConfigure(opts = {}) {
414
+ const oc = detectOpenClaw();
415
+ if (!oc.found) {
416
+ log.error("OpenClaw is not running.");
417
+ return;
418
+ }
419
+ // ── Show current config ─────────────────────────────────────────────────
420
+ if (opts.show) {
421
+ log.heading("OpenClaw AI Config");
422
+ const current = readOpenClawAiConfig(oc);
423
+ if (current.anthropicKey)
424
+ log.success(`Anthropic: ${maskKey(current.anthropicKey)}`);
425
+ if (current.openaiKey)
426
+ log.success(`OpenAI: ${maskKey(current.openaiKey)}`);
427
+ if (current.geminiKey)
428
+ log.success(`Google: ${maskKey(current.geminiKey)}`);
429
+ if (current.primaryModel)
430
+ log.info(`Model: ${current.primaryModel}`);
431
+ if (!current.anthropicKey && !current.openaiKey && !current.geminiKey) {
432
+ log.warn("No AI provider key configured");
433
+ }
265
434
  return;
266
- if (provider === "synap") {
435
+ }
436
+ // ── Interactive (delegate to OpenClaw's own wizard) ──────────────────────
437
+ if (opts.interactive && oc.runtime === "docker") {
438
+ const containerName = oc.containerName ?? "openclaw";
439
+ log.heading("Handing off to OpenClaw");
440
+ log.dim(`Running: docker exec -it ${containerName} openclaw configure`);
267
441
  log.blank();
268
- log.info("Synap IS uses your pod's AI subscription.");
269
- log.dim("Make sure IS is provisioned: synap finish");
270
- log.dim("Then OpenClaw will use your pod URL as the AI endpoint.");
442
+ try {
443
+ execSync(`docker exec -it ${containerName} openclaw configure`, { stdio: "inherit" });
444
+ }
445
+ catch (err) {
446
+ log.error(`openclaw configure failed: ${err instanceof Error ? err.message : String(err)}`);
447
+ }
271
448
  return;
272
449
  }
450
+ banner();
451
+ log.heading("Configure AI Provider");
452
+ // ── Scripted path (--provider + --key) ───────────────────────────────────
453
+ let provider = opts.provider;
454
+ let apiKey = opts.key;
455
+ let model = opts.model;
456
+ if (!provider) {
457
+ const pick = await prompts({
458
+ type: "select",
459
+ name: "provider",
460
+ message: "Which AI provider?",
461
+ choices: [
462
+ { title: "Anthropic (Claude)", description: "recommended", value: "anthropic" },
463
+ { title: "OpenAI (GPT-4o)", value: "openai" },
464
+ { title: "Google (Gemini)", value: "google" },
465
+ { title: "Run OpenClaw's own wizard", description: "interactive", value: "wizard" },
466
+ ],
467
+ });
468
+ if (!pick.provider)
469
+ return;
470
+ if (pick.provider === "wizard") {
471
+ return openclawConfigure({ interactive: true });
472
+ }
473
+ provider = pick.provider;
474
+ }
273
475
  const envKey = provider === "anthropic"
274
476
  ? "ANTHROPIC_API_KEY"
275
477
  : provider === "openai"
@@ -280,64 +482,47 @@ export async function openclawConfigure() {
280
482
  : provider === "openai"
281
483
  ? "openai/gpt-4o"
282
484
  : "google/gemini-2.0-flash";
283
- const { apiKey } = await prompts({
284
- type: "password",
285
- name: "apiKey",
286
- message: `${envKey}:`,
287
- });
288
- if (!apiKey)
289
- return;
290
- const { model } = await prompts({
291
- type: "text",
292
- name: "model",
293
- message: "Model (leave blank for default):",
294
- initial: modelDefault,
295
- });
296
- if (deployDir) {
297
- // Write to .env file
298
- const envFile = `${deployDir}/.env`;
299
- writeEnvVar(envFile, envKey, apiKey);
300
- if (model && model !== modelDefault) {
301
- writeEnvVar(envFile, "OPENCLAW_MODEL", model);
302
- }
303
- else {
304
- writeEnvVar(envFile, "OPENCLAW_MODEL", modelDefault);
305
- }
306
- log.blank();
307
- log.success(`${envKey} written to ${deployDir}/.env`);
308
- // Restart container to pick up new env vars
309
- const oc = detectOpenClaw();
310
- const containerName = oc.containerName ?? "openclaw";
311
- const { doRestart } = await prompts({
312
- type: "confirm",
313
- name: "doRestart",
314
- message: `Restart ${containerName} to apply?`,
315
- initial: true,
485
+ if (!apiKey) {
486
+ const res = await prompts({ type: "password", name: "apiKey", message: `${envKey}:` });
487
+ if (!res.apiKey)
488
+ return;
489
+ apiKey = res.apiKey;
490
+ }
491
+ if (!model) {
492
+ const res = await prompts({
493
+ type: "text",
494
+ name: "model",
495
+ message: "Model:",
496
+ initial: modelDefault,
316
497
  });
317
- if (doRestart) {
318
- try {
319
- log.info(`Restarting ${containerName}...`);
320
- execSync(`docker restart ${containerName}`, { stdio: "pipe", timeout: 30000 });
321
- log.success("Restarted. Give it 30s to come back up.");
322
- log.dim(`Check: synap openclaw`);
323
- }
324
- catch {
325
- log.warn("Restart failed — restart manually:");
326
- log.dim(` docker restart ${containerName}`);
327
- }
328
- }
498
+ if (!res.model)
499
+ return;
500
+ model = res.model;
329
501
  }
330
- else {
331
- // No deploy dir show env export instructions
332
- log.blank();
333
- log.warn("Could not find deploy directory. Set the key manually:");
334
- log.blank();
335
- console.log(chalk.cyan(` export ${envKey}="${apiKey}"`));
336
- if (model) {
337
- console.log(chalk.cyan(` export OPENCLAW_MODEL="${model}"`));
338
- }
339
- log.blank();
340
- log.dim("Or add to your pod .env file, then: docker restart openclaw");
502
+ // ── Write via OpenClaw's own config system ──────────────────────────────
503
+ const containerName = oc.containerName ?? "openclaw";
504
+ const spinner = ora("Writing OpenClaw config...").start();
505
+ try {
506
+ // Set the API key inside OpenClaw's env block
507
+ execSync(`docker exec ${containerName} openclaw config set env.${envKey} ${JSON.stringify(apiKey)}`, { stdio: "pipe", timeout: 15000 });
508
+ // Set the primary model
509
+ execSync(`docker exec ${containerName} openclaw config set agents.defaults.model.primary ${JSON.stringify(model)}`, { stdio: "pipe", timeout: 15000 });
510
+ spinner.succeed("Config written");
511
+ }
512
+ catch (err) {
513
+ spinner.fail(`openclaw config set failed: ${err instanceof Error ? err.message : String(err)}`);
514
+ log.dim("Make sure OpenClaw is running: synap openclaw");
515
+ return;
516
+ }
517
+ // ── Restart to apply ─────────────────────────────────────────────────────
518
+ log.info("Restarting OpenClaw to apply...");
519
+ try {
520
+ execSync(`docker restart ${containerName}`, { stdio: "pipe", timeout: 30000 });
521
+ log.success("Restarted — give it ~30s to come back up");
522
+ log.dim("Check: synap openclaw");
523
+ }
524
+ catch {
525
+ log.warn("Restart failed — run manually: docker restart openclaw");
341
526
  }
342
527
  log.blank();
343
528
  }
@@ -362,6 +547,138 @@ export function openclawLogs(opts) {
362
547
  log.dim(`Try: docker logs ${containerName} --tail 50`);
363
548
  }
364
549
  }
550
+ // ─── Token: print the gateway token ──────────────────────────────────────────
551
+ export function openclawToken(opts) {
552
+ const oc = detectOpenClaw();
553
+ if (!oc.found) {
554
+ log.error("OpenClaw is not running.");
555
+ return;
556
+ }
557
+ const token = readGatewayToken(oc);
558
+ if (!token) {
559
+ log.error("Could not read gateway token from OpenClaw.");
560
+ log.dim("Try: synap openclaw doctor");
561
+ return;
562
+ }
563
+ if (opts.for) {
564
+ // Print a pre-filled MCP client config with the token embedded
565
+ const client = opts.for.toLowerCase();
566
+ const gatewayPort = oc.gatewayPort ?? 18789;
567
+ const config = {
568
+ mcpServers: {
569
+ openclaw: {
570
+ command: "openclaw",
571
+ args: [
572
+ "mcp",
573
+ "serve",
574
+ "--url",
575
+ `ws://localhost:${gatewayPort}`,
576
+ "--token",
577
+ token,
578
+ ],
579
+ },
580
+ },
581
+ };
582
+ const paths = {
583
+ claude: "~/Library/Application Support/Claude/claude_desktop_config.json",
584
+ cursor: "~/.cursor/mcp.json",
585
+ windsurf: "~/.windsurf/mcp.json",
586
+ };
587
+ log.heading(client.charAt(0).toUpperCase() + client.slice(1));
588
+ if (paths[client])
589
+ log.dim(`Config file: ${paths[client]}`);
590
+ log.blank();
591
+ console.log(chalk.cyan(JSON.stringify(config, null, 2)));
592
+ log.blank();
593
+ return;
594
+ }
595
+ if (opts.copy) {
596
+ try {
597
+ const pbcopy = process.platform === "darwin"
598
+ ? "pbcopy"
599
+ : process.platform === "linux"
600
+ ? "xclip -selection clipboard"
601
+ : null;
602
+ if (pbcopy) {
603
+ execSync(`echo -n ${JSON.stringify(token)} | ${pbcopy}`, { stdio: "pipe" });
604
+ log.success("Token copied to clipboard");
605
+ return;
606
+ }
607
+ }
608
+ catch {
609
+ // fall through to print
610
+ }
611
+ }
612
+ // Plain print
613
+ console.log(token);
614
+ }
615
+ function readGatewayToken(oc) {
616
+ if (!oc.found)
617
+ return null;
618
+ if (oc.runtime === "docker") {
619
+ const containerName = oc.containerName ?? "openclaw";
620
+ // Try OpenClaw's own config first — works even if token file path changes
621
+ try {
622
+ const raw = execSync(`docker exec ${containerName} openclaw config get gateway.token 2>/dev/null`, { encoding: "utf-8", timeout: 5000 }).trim();
623
+ if (raw && raw !== "undefined" && raw !== "null") {
624
+ return raw.replace(/^["']|["']$/g, "");
625
+ }
626
+ }
627
+ catch {
628
+ // fall through
629
+ }
630
+ // Fallback: read the token file directly
631
+ try {
632
+ const raw = execSync(`docker exec ${containerName} cat /root/.openclaw/gateway.token 2>/dev/null`, { encoding: "utf-8", timeout: 5000 }).trim();
633
+ return raw || null;
634
+ }
635
+ catch {
636
+ return null;
637
+ }
638
+ }
639
+ // Local install — read from host filesystem
640
+ try {
641
+ const tokenPath = `${process.env.HOME}/.openclaw/gateway.token`;
642
+ if (fs.existsSync(tokenPath)) {
643
+ return fs.readFileSync(tokenPath, "utf-8").trim();
644
+ }
645
+ }
646
+ catch {
647
+ // ignore
648
+ }
649
+ return null;
650
+ }
651
+ // ─── Doctor: run OpenClaw's own diagnostic ───────────────────────────────────
652
+ export function openclawDoctor(opts) {
653
+ const oc = detectOpenClaw();
654
+ if (!oc.found) {
655
+ log.error("OpenClaw is not running.");
656
+ return;
657
+ }
658
+ const fixFlag = opts.fix ? " --fix" : "";
659
+ if (oc.runtime === "docker") {
660
+ const containerName = oc.containerName ?? "openclaw";
661
+ log.dim(`Running: docker exec ${containerName} openclaw doctor${fixFlag}`);
662
+ log.blank();
663
+ try {
664
+ execSync(`docker exec ${containerName} openclaw doctor${fixFlag}`, {
665
+ stdio: "inherit",
666
+ timeout: 60000,
667
+ });
668
+ }
669
+ catch {
670
+ log.warn("openclaw doctor reported issues or failed");
671
+ }
672
+ }
673
+ else {
674
+ try {
675
+ execSync(`openclaw doctor${fixFlag}`, { stdio: "inherit", timeout: 60000 });
676
+ }
677
+ catch {
678
+ log.warn("openclaw doctor reported issues or failed");
679
+ }
680
+ }
681
+ }
365
682
  // ─── Restart ─────────────────────────────────────────────────────────────────
366
683
  export async function openclawRestart() {
367
684
  const oc = detectOpenClaw();
@@ -388,53 +705,22 @@ export async function openclawRestart() {
388
705
  log.dim(`Try: docker restart ${containerName}`);
389
706
  }
390
707
  }
391
- function getAiKeyStatus() {
392
- // Check deploy dir .env first (most accurate for Docker deployments)
708
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
709
+ function getOpenClawPublicUrl() {
393
710
  const deployDir = findSynapDeployDir();
394
- if (deployDir) {
395
- const envFile = `${deployDir}/.env`;
396
- try {
397
- const envContent = fs.readFileSync(envFile, "utf-8");
398
- const vars = parseEnvFile(envContent);
399
- if (vars.ANTHROPIC_API_KEY) {
400
- return { configured: true, provider: "Anthropic", model: vars.OPENCLAW_MODEL };
401
- }
402
- if (vars.OPENAI_API_KEY) {
403
- return { configured: true, provider: "OpenAI", model: vars.OPENCLAW_MODEL };
404
- }
405
- if (vars.GEMINI_API_KEY) {
406
- return { configured: true, provider: "Google", model: vars.OPENCLAW_MODEL };
407
- }
408
- }
409
- catch {
410
- // unreadable
411
- }
711
+ if (!deployDir)
712
+ return null;
713
+ try {
714
+ const content = fs.readFileSync(`${deployDir}/.env`, "utf-8");
715
+ const match = content.match(/^OPENCLAW_DOMAIN=(.+)$/m);
716
+ const domain = match?.[1]?.trim();
717
+ if (!domain || domain === "disabled.invalid" || domain === "")
718
+ return null;
719
+ return `https://${domain}`;
412
720
  }
413
- // Fallback: check live container env (via docker inspect)
414
- const oc = detectOpenClaw();
415
- if (oc.runtime === "docker") {
416
- try {
417
- const containerName = oc.containerName ?? "openclaw";
418
- const raw = execSync(`docker inspect --format '{{range .Config.Env}}{{.}}\\n{{end}}' ${containerName} 2>/dev/null`, { encoding: "utf-8", timeout: 5000 });
419
- const envLines = raw.split("\\n").filter(Boolean);
420
- const env = {};
421
- for (const line of envLines) {
422
- const idx = line.indexOf("=");
423
- if (idx > 0)
424
- env[line.slice(0, idx)] = line.slice(idx + 1);
425
- }
426
- if (env.ANTHROPIC_API_KEY)
427
- return { configured: true, provider: "Anthropic", model: env.OPENCLAW_MODEL };
428
- if (env.OPENAI_API_KEY)
429
- return { configured: true, provider: "OpenAI", model: env.OPENCLAW_MODEL };
430
- if (env.GEMINI_API_KEY)
431
- return { configured: true, provider: "Google", model: env.OPENCLAW_MODEL };
432
- }
433
- catch {
434
- // docker not available
435
- }
721
+ catch {
722
+ return null;
436
723
  }
437
- return { configured: false };
438
724
  }
439
725
  function checkSkillInstalled(oc) {
440
726
  if (!oc.found)
@@ -469,19 +755,117 @@ function writeEnvVar(envFile, key, value) {
469
755
  : content + "\n" + line + "\n";
470
756
  fs.writeFileSync(envFile, content, { mode: 0o600 });
471
757
  }
472
- function parseEnvFile(content) {
473
- const result = {};
474
- for (const line of content.split("\n")) {
475
- const trimmed = line.trim();
476
- if (!trimmed || trimmed.startsWith("#"))
477
- continue;
478
- const idx = trimmed.indexOf("=");
479
- if (idx > 0) {
480
- const key = trimmed.slice(0, idx);
481
- const val = trimmed.slice(idx + 1).replace(/^["']|["']$/g, "");
482
- result[key] = val;
483
- }
484
- }
485
- return result;
758
+ // ─── Domain setup helpers ────────────────────────────────────────────────────
759
+ function readEnvVar(deployDir, key) {
760
+ try {
761
+ const content = fs.readFileSync(path.join(deployDir, ".env"), "utf-8");
762
+ const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
763
+ return match?.[1]?.trim() ?? null;
764
+ }
765
+ catch {
766
+ return null;
767
+ }
768
+ }
769
+ function generatePassword(length) {
770
+ // URL-safe random password
771
+ return crypto
772
+ .randomBytes(length)
773
+ .toString("base64url")
774
+ .slice(0, length);
775
+ }
776
+ async function bcryptHashViaCaddy(plaintext) {
777
+ // Caddy ships `caddy hash-password` which outputs a bcrypt hash.
778
+ // Run it via the running caddy container so we don't need a bcrypt dep in Node.
779
+ try {
780
+ const hash = execSync(`docker exec -i caddy caddy hash-password --plaintext ${JSON.stringify(plaintext)}`, { encoding: "utf-8", timeout: 10000 }).trim();
781
+ return hash;
782
+ }
783
+ catch (err) {
784
+ // Fallback: try without container (if caddy is in PATH)
785
+ try {
786
+ return execSync(`caddy hash-password --plaintext ${JSON.stringify(plaintext)}`, { encoding: "utf-8", timeout: 10000 }).trim();
787
+ }
788
+ catch {
789
+ throw new Error(`Could not hash password via caddy: ${err instanceof Error ? err.message : String(err)}`);
790
+ }
791
+ }
792
+ }
793
+ function generateCpOAuthSnippet() {
794
+ return `# OpenClaw dashboard auth — CP OAuth (generated by synap openclaw setup-domain)
795
+ #
796
+ # Validates the Better Auth session cookie against the Synap Control Plane.
797
+ # Because the CP cookie is set with Domain=.synap.live (crossSubDomainCookies),
798
+ # the browser sends it automatically to this subdomain.
799
+ #
800
+ # 200 → authenticated, pass through to openclaw
801
+ # 401 → redirect to synap.live login
802
+
803
+ forward_auth https://api.synap.live {
804
+ uri /api/auth/me
805
+ copy_headers X-Authenticated-User
806
+
807
+ @unauthorized status 401
808
+ handle_response @unauthorized {
809
+ redir https://synap.live/login?redirect=https://{host}{uri} temporary
810
+ }
811
+ }
812
+ `;
813
+ }
814
+ function generateBasicAuthSnippet(username, bcryptHash) {
815
+ return `# OpenClaw dashboard auth — basic auth (generated by synap openclaw setup-domain)
816
+ #
817
+ # Single credential protects the dashboard. The password hash below was
818
+ # generated with \`caddy hash-password\`. To rotate, run:
819
+ # synap openclaw setup-domain
820
+
821
+ basicauth {
822
+ ${username} ${bcryptHash}
823
+ }
824
+ `;
825
+ }
826
+ function readOpenClawAiConfig(oc) {
827
+ if (!oc.found || oc.runtime !== "docker")
828
+ return {};
829
+ const containerName = oc.containerName ?? "openclaw";
830
+ const read = (key) => {
831
+ try {
832
+ const out = execSync(`docker exec ${containerName} openclaw config get ${key} 2>/dev/null`, { encoding: "utf-8", timeout: 5000 }).trim();
833
+ if (!out || out === "undefined" || out === "null")
834
+ return undefined;
835
+ return out.replace(/^["']|["']$/g, "");
836
+ }
837
+ catch {
838
+ return undefined;
839
+ }
840
+ };
841
+ return {
842
+ anthropicKey: read("env.ANTHROPIC_API_KEY"),
843
+ openaiKey: read("env.OPENAI_API_KEY"),
844
+ geminiKey: read("env.GEMINI_API_KEY"),
845
+ primaryModel: read("agents.defaults.model.primary"),
846
+ };
847
+ }
848
+ function maskKey(key) {
849
+ if (key.length <= 8)
850
+ return "•".repeat(key.length);
851
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
852
+ }
853
+ async function requestDashboardDomainFromCp(cpToken, podId) {
854
+ const cpUrl = process.env.SYNAP_CP_URL ?? "https://api.synap.live";
855
+ const res = await fetch(`${cpUrl}/openclaw/expose-dashboard`, {
856
+ method: "POST",
857
+ headers: {
858
+ "Content-Type": "application/json",
859
+ Authorization: `Bearer ${cpToken}`,
860
+ },
861
+ body: JSON.stringify({ podId }),
862
+ signal: AbortSignal.timeout(20000),
863
+ });
864
+ if (!res.ok) {
865
+ const body = (await res.json().catch(() => null));
866
+ throw new Error(`CP request failed (HTTP ${res.status}): ${body?.error ?? "unknown error"}`);
867
+ }
868
+ const data = (await res.json());
869
+ return { domain: data.domain, authMode: data.authMode };
486
870
  }
487
871
  //# sourceMappingURL=openclaw.js.map