@swarmclawai/swarmclaw 0.7.4 → 0.7.6

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,626 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+ import { copyTextToClipboard } from '@/lib/clipboard'
6
+
7
+ type RemoteTemplate = 'docker' | 'render' | 'fly' | 'railway'
8
+ type RemoteProvider = 'hetzner' | 'digitalocean' | 'vultr' | 'linode' | 'lightsail' | 'gcp' | 'azure' | 'oci' | 'generic'
9
+
10
+ interface LocalDeployStatus {
11
+ running: boolean
12
+ processId: string | null
13
+ pid: number | null
14
+ port: number
15
+ endpoint: string
16
+ wsUrl: string
17
+ token: string | null
18
+ startedAt: number | null
19
+ tail: string
20
+ lastError: string | null
21
+ launchCommand: string
22
+ installCommand: string
23
+ }
24
+
25
+ interface DeployFile {
26
+ name: string
27
+ language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
28
+ content: string
29
+ }
30
+
31
+ interface DeployBundle {
32
+ template: RemoteTemplate
33
+ provider: RemoteProvider
34
+ providerLabel: string
35
+ title: string
36
+ summary: string
37
+ endpoint: string
38
+ wsUrl: string
39
+ token: string
40
+ runbook: string[]
41
+ files: DeployFile[]
42
+ }
43
+
44
+ interface DeployStatusResponse {
45
+ local: LocalDeployStatus
46
+ }
47
+
48
+ interface DeployActionResponse {
49
+ ok: boolean
50
+ local?: LocalDeployStatus
51
+ token?: string
52
+ bundle?: DeployBundle
53
+ error?: string
54
+ }
55
+
56
+ interface ApplyPatch {
57
+ endpoint?: string
58
+ token?: string
59
+ name?: string
60
+ notes?: string
61
+ }
62
+
63
+ interface OpenClawDeployPanelProps {
64
+ endpoint?: string | null
65
+ token?: string | null
66
+ suggestedName?: string | null
67
+ title?: string
68
+ description?: string
69
+ compact?: boolean
70
+ onApply?: (patch: ApplyPatch) => void | Promise<void>
71
+ }
72
+
73
+ const TEMPLATE_OPTIONS: Array<{
74
+ id: RemoteTemplate
75
+ label: string
76
+ detail: string
77
+ }> = [
78
+ {
79
+ id: 'docker',
80
+ label: 'VPS Smart Deploy',
81
+ detail: 'Official OpenClaw Docker image plus cloud-init for mainstream VPS hosts',
82
+ },
83
+ {
84
+ id: 'render',
85
+ label: 'Render',
86
+ detail: 'Managed HTTPS with a repo-backed Docker service',
87
+ },
88
+ {
89
+ id: 'fly',
90
+ label: 'Fly.io',
91
+ detail: 'Persistent remote gateway with Fly volumes and HTTPS',
92
+ },
93
+ {
94
+ id: 'railway',
95
+ label: 'Railway',
96
+ detail: 'Simple Docker deploy with volume-backed state',
97
+ },
98
+ ]
99
+
100
+ const PROVIDER_OPTIONS: Array<{
101
+ id: RemoteProvider
102
+ label: string
103
+ detail: string
104
+ }> = [
105
+ { id: 'hetzner', label: 'Hetzner', detail: 'Cheap always-on VPS' },
106
+ { id: 'digitalocean', label: 'DigitalOcean', detail: 'Droplet + user-data flow' },
107
+ { id: 'vultr', label: 'Vultr', detail: 'Cloud Compute startup script' },
108
+ { id: 'linode', label: 'Linode', detail: 'Simple Ubuntu VM path' },
109
+ { id: 'lightsail', label: 'Lightsail', detail: 'AWS-hosted simple VPS' },
110
+ { id: 'gcp', label: 'GCP', detail: 'Compute Engine VM' },
111
+ { id: 'azure', label: 'Azure', detail: 'Ubuntu VM custom data' },
112
+ { id: 'oci', label: 'OCI', detail: 'Oracle cloud-init bootstrap' },
113
+ { id: 'generic', label: 'Generic', detail: 'Any Ubuntu 24.04 host' },
114
+ ]
115
+
116
+ function buildLocalRunCommand(port: number, token?: string | null): string {
117
+ const parts = ['npx', 'openclaw', 'gateway', 'run', '--allow-unconfigured', '--force', '--bind', 'loopback', '--port', String(port)]
118
+ if (token) parts.push('--auth', 'token', '--token', token)
119
+ return parts.join(' ')
120
+ }
121
+
122
+ function buildLocalInstallCommand(port: number, token?: string | null): string {
123
+ const parts = ['npx', 'openclaw', 'gateway', 'install', '--port', String(port)]
124
+ if (token) parts.push('--token', token)
125
+ return `${parts.join(' ')} && npx openclaw gateway start`
126
+ }
127
+
128
+ function parseMaybeUrl(value: string | null | undefined): URL | null {
129
+ const trimmed = typeof value === 'string' ? value.trim() : ''
130
+ if (!trimmed) return null
131
+ try {
132
+ return new URL(trimmed)
133
+ } catch {
134
+ try {
135
+ return new URL(`http://${trimmed}`)
136
+ } catch {
137
+ return null
138
+ }
139
+ }
140
+ }
141
+
142
+ function isLocalEndpoint(value: string | null | undefined): boolean {
143
+ const parsed = parseMaybeUrl(value)
144
+ if (!parsed) return false
145
+ const host = parsed.hostname.toLowerCase()
146
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
147
+ }
148
+
149
+ function inferPort(value: string | null | undefined, fallback = 18789): number {
150
+ const parsed = parseMaybeUrl(value)
151
+ if (!parsed?.port) return fallback
152
+ const port = Number.parseInt(parsed.port, 10)
153
+ return Number.isFinite(port) ? port : fallback
154
+ }
155
+
156
+ function inferRemoteTarget(value: string | null | undefined): string {
157
+ const parsed = parseMaybeUrl(value)
158
+ if (!parsed || isLocalEndpoint(value)) return ''
159
+ const base = `${parsed.protocol}//${parsed.host}`
160
+ return base.replace(/\/+$/, '')
161
+ }
162
+
163
+ function badgeTone(active: boolean): string {
164
+ return active
165
+ ? 'border-accent-bright/30 bg-accent-bright/10 text-accent-bright'
166
+ : 'border-white/[0.08] bg-white/[0.02] text-text-2 hover:bg-white/[0.05]'
167
+ }
168
+
169
+ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
170
+ const {
171
+ endpoint,
172
+ token,
173
+ suggestedName,
174
+ title = 'Smart Deploy OpenClaw',
175
+ description = 'Launch a local gateway on this host or generate a remote bundle with opinionated defaults.',
176
+ compact = false,
177
+ onApply,
178
+ } = props
179
+
180
+ const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local')
181
+ const [localStatus, setLocalStatus] = useState<LocalDeployStatus | null>(null)
182
+ const [localPort, setLocalPort] = useState(() => inferPort(endpoint))
183
+ const [deployToken, setDeployToken] = useState(token || '')
184
+ const [remoteTarget, setRemoteTarget] = useState(() => inferRemoteTarget(endpoint))
185
+ const [remoteScheme, setRemoteScheme] = useState<'http' | 'https'>(() => (
186
+ typeof endpoint === 'string' && endpoint.trim().startsWith('http://') ? 'http' : 'https'
187
+ ))
188
+ const [remoteTemplate, setRemoteTemplate] = useState<RemoteTemplate>('docker')
189
+ const [remoteProvider, setRemoteProvider] = useState<RemoteProvider>('hetzner')
190
+ const [bundle, setBundle] = useState<DeployBundle | null>(null)
191
+ const [bundleFile, setBundleFile] = useState('')
192
+ const [loading, setLoading] = useState<'idle' | 'starting-local' | 'stopping-local' | 'generating-bundle'>('idle')
193
+ const [message, setMessage] = useState('')
194
+ const [error, setError] = useState('')
195
+ const [copiedKey, setCopiedKey] = useState('')
196
+
197
+ useEffect(() => {
198
+ if (token && !deployToken) setDeployToken(token)
199
+ }, [token, deployToken])
200
+
201
+ useEffect(() => {
202
+ if (endpoint && isLocalEndpoint(endpoint)) {
203
+ setLocalPort(inferPort(endpoint))
204
+ setActiveTab('local')
205
+ } else if (endpoint && inferRemoteTarget(endpoint)) {
206
+ setRemoteTarget(inferRemoteTarget(endpoint))
207
+ setActiveTab('remote')
208
+ }
209
+ }, [endpoint])
210
+
211
+ useEffect(() => {
212
+ let cancelled = false
213
+ api<DeployStatusResponse>('GET', '/openclaw/deploy')
214
+ .then((result) => {
215
+ if (!cancelled) {
216
+ setLocalStatus(result.local)
217
+ if (result.local.token) {
218
+ setDeployToken((current) => current || result.local.token || '')
219
+ }
220
+ }
221
+ })
222
+ .catch(() => {})
223
+ return () => {
224
+ cancelled = true
225
+ }
226
+ }, [])
227
+
228
+ const selectedFile = useMemo(() => {
229
+ if (!bundle) return null
230
+ return bundle.files.find((file) => file.name === bundleFile) || bundle.files[0] || null
231
+ }, [bundle, bundleFile])
232
+ const localLaunchCommand = useMemo(() => {
233
+ const typedToken = deployToken.trim()
234
+ if (typedToken) return buildLocalRunCommand(localPort, typedToken)
235
+ if (localStatus?.launchCommand) return localStatus.launchCommand
236
+ return buildLocalRunCommand(localPort)
237
+ }, [deployToken, localPort, localStatus?.launchCommand])
238
+ const localInstallCommand = useMemo(() => {
239
+ const typedToken = deployToken.trim()
240
+ if (typedToken) return buildLocalInstallCommand(localPort, typedToken)
241
+ if (localStatus?.installCommand) return localStatus.installCommand
242
+ return buildLocalInstallCommand(localPort)
243
+ }, [deployToken, localPort, localStatus?.installCommand])
244
+
245
+ const showMessage = (next: string) => {
246
+ setMessage(next)
247
+ if (!next) return
248
+ window.setTimeout(() => {
249
+ setMessage((current) => (current === next ? '' : current))
250
+ }, 2200)
251
+ }
252
+
253
+ const onCopied = async (key: string, value: string) => {
254
+ const ok = await copyTextToClipboard(value)
255
+ if (!ok) return
256
+ setCopiedKey(key)
257
+ window.setTimeout(() => {
258
+ setCopiedKey((current) => (current === key ? '' : current))
259
+ }, 1200)
260
+ }
261
+
262
+ const handleStartLocal = async () => {
263
+ setLoading('starting-local')
264
+ setError('')
265
+ try {
266
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
267
+ action: 'start-local',
268
+ port: localPort,
269
+ token: deployToken.trim() || undefined,
270
+ })
271
+ if (!result.ok || !result.local) throw new Error(result.error || 'Local OpenClaw deploy failed.')
272
+ setLocalStatus(result.local)
273
+ if (result.token) setDeployToken(result.token)
274
+ await Promise.resolve(onApply?.({
275
+ endpoint: result.local.endpoint,
276
+ token: result.token || deployToken,
277
+ name: suggestedName || `Local OpenClaw ${result.local.port}`,
278
+ notes: 'Managed by SwarmClaw local deploy.',
279
+ }))
280
+ showMessage('Local OpenClaw started and applied to this connection.')
281
+ } catch (err: unknown) {
282
+ setError(err instanceof Error ? err.message : 'Local OpenClaw deploy failed.')
283
+ } finally {
284
+ setLoading('idle')
285
+ }
286
+ }
287
+
288
+ const handleStopLocal = async () => {
289
+ setLoading('stopping-local')
290
+ setError('')
291
+ try {
292
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', { action: 'stop-local' })
293
+ if (!result.ok || !result.local) throw new Error(result.error || 'Failed to stop local OpenClaw.')
294
+ setLocalStatus(result.local)
295
+ showMessage('Stopped managed local OpenClaw runtime.')
296
+ } catch (err: unknown) {
297
+ setError(err instanceof Error ? err.message : 'Failed to stop local OpenClaw.')
298
+ } finally {
299
+ setLoading('idle')
300
+ }
301
+ }
302
+
303
+ const handleGenerateBundle = async () => {
304
+ setLoading('generating-bundle')
305
+ setError('')
306
+ try {
307
+ const result = await api<DeployActionResponse>('POST', '/openclaw/deploy', {
308
+ action: 'bundle',
309
+ template: remoteTemplate,
310
+ target: remoteTarget.trim(),
311
+ scheme: remoteScheme,
312
+ token: deployToken.trim() || undefined,
313
+ provider: remoteProvider,
314
+ })
315
+ if (!result.ok || !result.bundle) throw new Error(result.error || 'Failed to generate OpenClaw deploy bundle.')
316
+ setBundle(result.bundle)
317
+ setBundleFile(result.bundle.files[0]?.name || '')
318
+ setDeployToken(result.bundle.token)
319
+ await Promise.resolve(onApply?.({
320
+ endpoint: result.bundle.endpoint,
321
+ token: result.bundle.token,
322
+ name: suggestedName || result.bundle.title,
323
+ notes: `OpenClaw remote deploy template: ${result.bundle.title}`,
324
+ }))
325
+ showMessage('Remote bundle generated and applied to this connection.')
326
+ } catch (err: unknown) {
327
+ setError(err instanceof Error ? err.message : 'Failed to generate OpenClaw deploy bundle.')
328
+ } finally {
329
+ setLoading('idle')
330
+ }
331
+ }
332
+
333
+ return (
334
+ <div className={`rounded-[16px] border border-white/[0.08] bg-surface ${compact ? 'p-4' : 'p-5'} text-left`}>
335
+ <div className="flex flex-wrap items-start justify-between gap-3">
336
+ <div>
337
+ <div className="font-display text-[16px] font-700 text-text">{title}</div>
338
+ <p className="mt-1 text-[12px] text-text-3 leading-relaxed">{description}</p>
339
+ </div>
340
+ <div className="flex items-center gap-2">
341
+ <button
342
+ type="button"
343
+ onClick={() => setActiveTab('local')}
344
+ className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(activeTab === 'local')}`}
345
+ >
346
+ Local
347
+ </button>
348
+ <button
349
+ type="button"
350
+ onClick={() => setActiveTab('remote')}
351
+ className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(activeTab === 'remote')}`}
352
+ >
353
+ Remote
354
+ </button>
355
+ </div>
356
+ </div>
357
+
358
+ {activeTab === 'local' && (
359
+ <div className="mt-4 space-y-4">
360
+ <div className="grid gap-3 md:grid-cols-[120px_1fr]">
361
+ <div>
362
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Port</label>
363
+ <input
364
+ type="number"
365
+ value={localPort}
366
+ onChange={(e) => setLocalPort(Number.parseInt(e.target.value, 10) || 18789)}
367
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text outline-none focus:border-accent-bright/30"
368
+ />
369
+ </div>
370
+ <div>
371
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Gateway token</label>
372
+ <input
373
+ type="text"
374
+ value={deployToken}
375
+ onChange={(e) => setDeployToken(e.target.value)}
376
+ placeholder="Leave blank to generate a secure token"
377
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
378
+ />
379
+ </div>
380
+ </div>
381
+
382
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-3">
383
+ <div className="flex items-center justify-between gap-3">
384
+ <div>
385
+ <div className="text-[13px] font-600 text-text">Managed local runtime</div>
386
+ <div className="mt-1 text-[12px] text-text-3">
387
+ One-click bring-up on the same machine running SwarmClaw. Good for quickstarts and non-technical local installs.
388
+ </div>
389
+ </div>
390
+ <div className={`rounded-full px-2.5 py-1 text-[10px] font-700 uppercase tracking-[0.08em] ${
391
+ localStatus?.running
392
+ ? 'bg-emerald-500/10 text-emerald-300'
393
+ : 'bg-white/[0.05] text-text-3'
394
+ }`}>
395
+ {localStatus?.running ? 'running' : 'idle'}
396
+ </div>
397
+ </div>
398
+
399
+ <div className="mt-3 flex flex-wrap gap-2">
400
+ <button
401
+ type="button"
402
+ onClick={handleStartLocal}
403
+ disabled={loading !== 'idle'}
404
+ className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-700 text-white border-none cursor-pointer hover:brightness-110 transition-all disabled:opacity-40"
405
+ >
406
+ {loading === 'starting-local' ? 'Starting…' : 'Deploy on This Host'}
407
+ </button>
408
+ {localStatus?.running && (
409
+ <button
410
+ type="button"
411
+ onClick={handleStopLocal}
412
+ disabled={loading !== 'idle'}
413
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
414
+ >
415
+ {loading === 'stopping-local' ? 'Stopping…' : 'Stop'}
416
+ </button>
417
+ )}
418
+ <button
419
+ type="button"
420
+ onClick={() => onCopied('local-launch', localLaunchCommand)}
421
+ disabled={!localLaunchCommand}
422
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
423
+ >
424
+ {copiedKey === 'local-launch' ? 'Copied launch' : 'Copy launch cmd'}
425
+ </button>
426
+ <button
427
+ type="button"
428
+ onClick={() => onCopied('local-install', localInstallCommand)}
429
+ disabled={!localInstallCommand}
430
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
431
+ >
432
+ {copiedKey === 'local-install' ? 'Copied install' : 'Copy service cmd'}
433
+ </button>
434
+ <button
435
+ type="button"
436
+ onClick={() => onCopied('local-token', deployToken.trim() || localStatus?.token || '')}
437
+ disabled={!deployToken.trim() && !localStatus?.token}
438
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all disabled:opacity-40"
439
+ >
440
+ {copiedKey === 'local-token' ? 'Copied token' : 'Copy token'}
441
+ </button>
442
+ </div>
443
+
444
+ {localStatus && (
445
+ <div className="mt-3 grid gap-3 md:grid-cols-2">
446
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
447
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Endpoint</div>
448
+ <div className="mt-1 text-[12px] text-text-2 font-mono break-all">{localStatus.endpoint}</div>
449
+ </div>
450
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
451
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Persistent install</div>
452
+ <div className="mt-1 text-[12px] text-text-3 leading-relaxed">
453
+ For a durable OS service, use the generated install command after the quick deploy works.
454
+ </div>
455
+ </div>
456
+ </div>
457
+ )}
458
+
459
+ {!!localStatus?.tail && (
460
+ <pre className="mt-3 overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-2 text-[11px] text-text-2/80 whitespace-pre-wrap">
461
+ {localStatus.tail}
462
+ </pre>
463
+ )}
464
+ </div>
465
+ </div>
466
+ )}
467
+
468
+ {activeTab === 'remote' && (
469
+ <div className="mt-4 space-y-4">
470
+ <div className="grid gap-3 md:grid-cols-[1fr_120px]">
471
+ <div>
472
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Public host or URL</label>
473
+ <input
474
+ type="text"
475
+ value={remoteTarget}
476
+ onChange={(e) => setRemoteTarget(e.target.value)}
477
+ placeholder="openclaw.example.com or https://openclaw.example.com"
478
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text font-mono outline-none focus:border-accent-bright/30"
479
+ />
480
+ </div>
481
+ <div>
482
+ <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Scheme</label>
483
+ <select
484
+ value={remoteScheme}
485
+ onChange={(e) => setRemoteScheme(e.target.value === 'http' ? 'http' : 'https')}
486
+ className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text outline-none focus:border-accent-bright/30"
487
+ >
488
+ <option value="https">https</option>
489
+ <option value="http">http</option>
490
+ </select>
491
+ </div>
492
+ </div>
493
+
494
+ <div>
495
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Deploy target</div>
496
+ <div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
497
+ {TEMPLATE_OPTIONS.map((option) => (
498
+ <button
499
+ key={option.id}
500
+ type="button"
501
+ onClick={() => setRemoteTemplate(option.id)}
502
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(remoteTemplate === option.id)}`}
503
+ >
504
+ <div className="text-[13px] font-700">{option.label}</div>
505
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
506
+ </button>
507
+ ))}
508
+ </div>
509
+ </div>
510
+
511
+ {remoteTemplate === 'docker' && (
512
+ <div>
513
+ <div className="text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">VPS provider</div>
514
+ <div className="grid gap-2 md:grid-cols-3 xl:grid-cols-5">
515
+ {PROVIDER_OPTIONS.map((option) => (
516
+ <button
517
+ key={option.id}
518
+ type="button"
519
+ onClick={() => setRemoteProvider(option.id)}
520
+ className={`rounded-[12px] border px-3 py-3 text-left transition-all cursor-pointer ${badgeTone(remoteProvider === option.id)}`}
521
+ >
522
+ <div className="text-[13px] font-700">{option.label}</div>
523
+ <div className="mt-1 text-[11px] leading-relaxed text-text-3">{option.detail}</div>
524
+ </button>
525
+ ))}
526
+ </div>
527
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
528
+ SwarmClaw generates a provider-specific runbook plus a cloud-init quickstart, but the runtime itself still comes from the official OpenClaw Docker image.
529
+ </p>
530
+ </div>
531
+ )}
532
+
533
+ <div className="flex flex-wrap gap-2">
534
+ <button
535
+ type="button"
536
+ onClick={handleGenerateBundle}
537
+ disabled={loading !== 'idle'}
538
+ className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-700 text-white border-none cursor-pointer hover:brightness-110 transition-all disabled:opacity-40"
539
+ >
540
+ {loading === 'generating-bundle' ? 'Generating…' : 'Generate Bundle'}
541
+ </button>
542
+ {bundle && (
543
+ <button
544
+ type="button"
545
+ onClick={() => onCopied('remote-token', bundle.token)}
546
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3.5 py-2 text-[12px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all"
547
+ >
548
+ {copiedKey === 'remote-token' ? 'Copied token' : 'Copy token'}
549
+ </button>
550
+ )}
551
+ </div>
552
+
553
+ {bundle && (
554
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
555
+ <div className="flex flex-wrap items-start justify-between gap-3">
556
+ <div>
557
+ <div className="text-[14px] font-700 text-text">{bundle.title}</div>
558
+ <p className="mt-1 text-[12px] text-text-3 leading-relaxed">{bundle.summary}</p>
559
+ </div>
560
+ <div className="grid gap-2 md:grid-cols-2">
561
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
562
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Endpoint</div>
563
+ <div className="mt-1 text-[11px] font-mono text-text-2 break-all">{bundle.endpoint}</div>
564
+ </div>
565
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2">
566
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">Host path</div>
567
+ <div className="mt-1 text-[11px] text-text-2">{bundle.providerLabel}</div>
568
+ </div>
569
+ </div>
570
+ </div>
571
+
572
+ <div className="mt-3 grid gap-2">
573
+ {bundle.runbook.map((step, index) => (
574
+ <div key={`${bundle.template}:${index}`} className="text-[12px] text-text-2 leading-relaxed">
575
+ {index + 1}. {step}
576
+ </div>
577
+ ))}
578
+ </div>
579
+
580
+ <div className="mt-4 flex flex-wrap gap-2">
581
+ {bundle.files.map((file) => (
582
+ <button
583
+ key={file.name}
584
+ type="button"
585
+ onClick={() => setBundleFile(file.name)}
586
+ className={`rounded-[10px] border px-3 py-1.5 text-[12px] font-700 transition-all cursor-pointer ${badgeTone(selectedFile?.name === file.name)}`}
587
+ >
588
+ {file.name}
589
+ </button>
590
+ ))}
591
+ </div>
592
+
593
+ {selectedFile && (
594
+ <div className="mt-3">
595
+ <div className="mb-2 flex items-center justify-between gap-2">
596
+ <div className="text-[12px] font-600 text-text-2">{selectedFile.name}</div>
597
+ <button
598
+ type="button"
599
+ onClick={() => onCopied(`file:${selectedFile.name}`, selectedFile.content)}
600
+ className="rounded-[10px] border border-white/[0.08] bg-transparent px-3 py-1.5 text-[11px] font-700 text-text-2 cursor-pointer hover:bg-white/[0.04] transition-all"
601
+ >
602
+ {copiedKey === `file:${selectedFile.name}` ? 'Copied' : 'Copy file'}
603
+ </button>
604
+ </div>
605
+ <pre className="overflow-x-auto rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-3 text-[11px] text-text-2/80 whitespace-pre-wrap">
606
+ {selectedFile.content}
607
+ </pre>
608
+ </div>
609
+ )}
610
+ </div>
611
+ )}
612
+ </div>
613
+ )}
614
+
615
+ {(message || error || localStatus?.lastError) && (
616
+ <div className={`mt-4 rounded-[12px] border px-3 py-2 text-[12px] ${
617
+ error || localStatus?.lastError
618
+ ? 'border-red-400/20 bg-red-400/[0.06] text-red-200'
619
+ : 'border-emerald-500/20 bg-emerald-500/[0.06] text-emerald-200'
620
+ }`}>
621
+ {error || localStatus?.lastError || message}
622
+ </div>
623
+ )}
624
+ </div>
625
+ )
626
+ }