@swarmclawai/swarmclaw 1.0.0 → 1.0.3

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 (34) hide show
  1. package/README.md +15 -2
  2. package/bin/doctor-cmd.js +155 -0
  3. package/bin/doctor-cmd.test.js +50 -0
  4. package/bin/install-root.js +39 -0
  5. package/bin/install-root.test.js +60 -0
  6. package/bin/server-cmd.js +160 -38
  7. package/bin/swarmclaw.js +83 -3
  8. package/bin/update-cmd.js +1 -6
  9. package/bin/update-cmd.test.js +1 -36
  10. package/bin/worker-cmd.js +13 -6
  11. package/package.json +16 -15
  12. package/scripts/postinstall.mjs +17 -13
  13. package/src/app/api/gateways/[id]/health/route.ts +2 -32
  14. package/src/app/api/gateways/health-route.test.ts +1 -1
  15. package/src/app/api/setup/check-provider/helpers.ts +28 -0
  16. package/src/app/api/setup/check-provider/route.test.ts +1 -1
  17. package/src/app/api/setup/check-provider/route.ts +5 -32
  18. package/src/app/api/tasks/import/github/helpers.ts +100 -0
  19. package/src/app/api/tasks/import/github/route.test.ts +1 -1
  20. package/src/app/api/tasks/import/github/route.ts +2 -92
  21. package/src/app/api/webhooks/[id]/helpers.ts +253 -0
  22. package/src/app/api/webhooks/[id]/route.ts +2 -243
  23. package/src/app/api/webhooks/route.test.ts +4 -2
  24. package/src/app/usage/page.tsx +22 -12
  25. package/src/cli/binary.test.js +57 -0
  26. package/src/cli/index.js +13 -1
  27. package/src/cli/server-cmd.test.js +77 -0
  28. package/src/lib/server/data-dir.test.ts +38 -3
  29. package/src/lib/server/data-dir.ts +11 -0
  30. package/src/lib/server/openclaw/health.ts +30 -1
  31. package/src/lib/server/session-tools/file-send.test.ts +18 -2
  32. package/src/lib/server/session-tools/file.ts +11 -7
  33. package/src/lib/server/skills/skill-discovery.test.ts +34 -1
  34. package/src/lib/server/skills/skill-discovery.ts +9 -4
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import { fileURLToPath } from 'node:url'
5
- import { writeFileSync } from 'node:fs'
6
6
  import { spawnSync } from 'node:child_process'
7
7
  const INSTALL_METADATA_FILE = '.swarmclaw-install.json'
8
8
  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
@@ -48,7 +48,7 @@ function formatFailure(result) {
48
48
  }
49
49
 
50
50
  try {
51
- writeFileSync(
51
+ fs.writeFileSync(
52
52
  new URL(`../${INSTALL_METADATA_FILE}`, import.meta.url),
53
53
  JSON.stringify({
54
54
  packageManager: installedWith,
@@ -72,18 +72,22 @@ if (result.error || (result.status ?? 0) !== 0) {
72
72
  }
73
73
 
74
74
  if (!process.env.CI) {
75
- const sandboxImage = spawnSync(process.execPath, [ensureSandboxBrowserScript, '--quiet'], {
76
- cwd: packageRoot,
77
- encoding: 'utf8',
78
- stdio: 'pipe',
79
- })
80
- if (sandboxImage.error || (sandboxImage.status ?? 0) !== 0) {
81
- logWarn(`sandbox browser image setup failed: ${formatFailure(sandboxImage)}`)
82
- logWarn('Retry manually with: node ./scripts/ensure-sandbox-browser-image.mjs')
83
- }
75
+ if (!fs.existsSync(ensureSandboxBrowserScript)) {
76
+ logNote('Sandbox browser image helper is not present in this install context. Skipping setup.')
77
+ } else {
78
+ const sandboxImage = spawnSync(process.execPath, [ensureSandboxBrowserScript, '--quiet'], {
79
+ cwd: packageRoot,
80
+ encoding: 'utf8',
81
+ stdio: 'pipe',
82
+ })
83
+ if (sandboxImage.error || (sandboxImage.status ?? 0) !== 0) {
84
+ logWarn(`sandbox browser image setup failed: ${formatFailure(sandboxImage)}`)
85
+ logWarn('Retry manually with: node ./scripts/ensure-sandbox-browser-image.mjs')
86
+ }
84
87
 
85
- if (!commandExists('docker')) {
86
- logNote('Docker was not found. Container sandboxes will fall back to host execution until Docker is installed.')
88
+ if (!commandExists('docker')) {
89
+ logNote('Docker was not found. Container sandboxes will fall back to host execution until Docker is installed.')
90
+ }
87
91
  }
88
92
  }
89
93
 
@@ -1,39 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { probeOpenClawHealth } from '@/lib/server/openclaw/health'
3
- import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
2
+ import { probeOpenClawHealth, persistGatewayHealthResult } from '@/lib/server/openclaw/health'
3
+ import { loadGatewayProfiles } from '@/lib/server/storage'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
- import { notify } from '@/lib/server/ws-hub'
6
- import type { GatewayProfile } from '@/types'
7
- import type { OpenClawHealthResult } from '@/lib/server/openclaw/health'
8
5
  export const dynamic = 'force-dynamic'
9
6
 
10
- export function persistGatewayHealthResult(
11
- id: string,
12
- result: OpenClawHealthResult,
13
- now = Date.now(),
14
- ): GatewayProfile | null {
15
- const gateways = loadGatewayProfiles()
16
- const gateway = gateways[id]
17
- if (!gateway) return null
18
-
19
- gateway.status = result.ok ? 'healthy' : (result.authProvided ? 'degraded' : 'offline')
20
- gateway.lastCheckedAt = now
21
- gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
22
- gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
23
- gateway.deployment = {
24
- ...(gateway.deployment || {}),
25
- lastVerifiedAt: now,
26
- lastVerifiedOk: result.ok,
27
- lastVerifiedMessage: result.ok
28
- ? result.message
29
- : (result.error || result.hint || 'Gateway health check failed.'),
30
- }
31
- gateway.updatedAt = now
32
- saveGatewayProfiles(gateways)
33
- notify('gateways')
34
- return gateway
35
- }
36
-
37
7
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
38
8
  const { id } = await params
39
9
  const gateways = loadGatewayProfiles()
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { afterEach, test } from 'node:test'
3
3
 
4
- import { persistGatewayHealthResult } from './[id]/health/route'
4
+ import { persistGatewayHealthResult } from '@/lib/server/openclaw/health'
5
5
  import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
6
6
 
7
7
  const originalGateways = loadGatewayProfiles()
@@ -0,0 +1,28 @@
1
+ export function normalizeOllamaSetupEndpoint(endpoint: string, useCloud: boolean): string {
2
+ const normalized = endpoint.replace(/\/+$/, '')
3
+ if (useCloud) return normalized
4
+ return normalized.replace(/\/v1$/i, '')
5
+ }
6
+
7
+ export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
8
+ const text = await res.text().catch(() => '')
9
+ if (!text) return fallback
10
+ try {
11
+ const parsed = JSON.parse(text)
12
+ if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
13
+ if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
14
+ if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
15
+ if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
16
+ } catch {
17
+ // Non-JSON response body.
18
+ }
19
+ return text.slice(0, 300).trim() || fallback
20
+ }
21
+
22
+ export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
23
+ let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
24
+ if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
25
+ const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
26
+ const wsUrl = httpUrl.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
27
+ return { httpUrl, wsUrl }
28
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
 
4
- import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './route'
4
+ import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
5
5
 
6
6
  test('normalizeOllamaSetupEndpoint strips local /v1 suffixes but preserves cloud endpoints', () => {
7
7
  assert.equal(
@@ -3,6 +3,7 @@ import { loadCredentials, decryptKey } from '@/lib/server/storage'
3
3
  import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
4
4
  import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
5
5
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
6
+ import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
6
7
 
7
8
  type SetupProvider =
8
9
  | 'openai'
@@ -29,32 +30,11 @@ function clean(value: unknown): string {
29
30
  return typeof value === 'string' ? value.trim() : ''
30
31
  }
31
32
 
32
- export function normalizeOllamaSetupEndpoint(endpoint: string, useCloud: boolean): string {
33
- const normalized = endpoint.replace(/\/+$/, '')
34
- if (useCloud) return normalized
35
- return normalized.replace(/\/v1$/i, '')
36
- }
37
-
38
33
  function parseBody(input: unknown): SetupCheckBody {
39
34
  if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
40
35
  return input as SetupCheckBody
41
36
  }
42
37
 
43
- export async function parseErrorMessage(res: Response, fallback: string): Promise<string> {
44
- const text = await res.text().catch(() => '')
45
- if (!text) return fallback
46
- try {
47
- const parsed = JSON.parse(text)
48
- if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim()
49
- if (typeof parsed?.error === 'string' && parsed.error.trim()) return parsed.error.trim()
50
- if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim()
51
- if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim()
52
- } catch {
53
- // Non-JSON response body.
54
- }
55
- return text.slice(0, 300).trim() || fallback
56
- }
57
-
58
38
  async function checkOpenAiCompatible(
59
39
  providerName: string,
60
40
  apiKey: string,
@@ -234,14 +214,6 @@ async function checkOllama(params: {
234
214
  }
235
215
  }
236
216
 
237
- export function normalizeOpenClawUrl(raw: string): { httpUrl: string; wsUrl: string } {
238
- let url = (raw || 'http://localhost:18789').replace(/\/+$/, '')
239
- if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
240
- const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
241
- const wsUrl = httpUrl.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
242
- return { httpUrl, wsUrl }
243
- }
244
-
245
217
  async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; deviceId?: string; errorCode?: string; recommendedModel?: string }> {
246
218
  const { httpUrl: normalizedEndpoint, wsUrl } = normalizeOpenClawUrl(endpointRaw)
247
219
  const token = apiKey || undefined
@@ -258,9 +230,10 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
258
230
  let recommendedModel: string | undefined
259
231
  if (result.ws) {
260
232
  try {
261
- const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as any
262
- const models = Array.isArray(payload?.models) ? payload.models : Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : []
263
- const first = models[0]
233
+ const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as Record<string, unknown> | unknown[] | undefined
234
+ const p = payload as Record<string, unknown> | undefined
235
+ const models: unknown[] = Array.isArray(p?.models) ? p.models as unknown[] : Array.isArray(p?.data) ? p.data as unknown[] : Array.isArray(payload) ? payload : []
236
+ const first = models[0] as Record<string, unknown> | string | undefined
264
237
  if (typeof first === 'string') {
265
238
  recommendedModel = first
266
239
  } else if (typeof first?.id === 'string') {
@@ -0,0 +1,100 @@
1
+ import { dedup } from '@/lib/shared-utils'
2
+
3
+ type GitHubIssueLabel = string | { name?: string | null }
4
+
5
+ export interface GitHubIssueRecord {
6
+ id: number | string
7
+ number: number
8
+ title: string
9
+ body?: string | null
10
+ state?: string | null
11
+ html_url?: string | null
12
+ labels?: GitHubIssueLabel[]
13
+ assignee?: { login?: string | null } | null
14
+ user?: { login?: string | null } | null
15
+ pull_request?: unknown
16
+ }
17
+
18
+ export interface ParsedRepo {
19
+ owner: string
20
+ repo: string
21
+ fullName: string
22
+ }
23
+
24
+ const BODY_CHAR_LIMIT = 12_000
25
+
26
+ function normalizeLabelName(label: GitHubIssueLabel): string {
27
+ if (typeof label === 'string') return label.trim()
28
+ return String(label?.name || '').trim()
29
+ }
30
+
31
+ function normalizeTag(value: string): string {
32
+ return value.trim().replace(/\s+/g, ' ').slice(0, 60)
33
+ }
34
+
35
+ export function parseGitHubRepoInput(input: string): ParsedRepo | null {
36
+ const trimmed = input.trim().replace(/\.git$/i, '')
37
+ if (!trimmed) return null
38
+
39
+ if (/^https?:\/\//i.test(trimmed)) {
40
+ try {
41
+ const url = new URL(trimmed)
42
+ if (!/github\.com$/i.test(url.hostname)) return null
43
+ const parts = url.pathname.split('/').filter(Boolean)
44
+ if (parts.length < 2) return null
45
+ const owner = parts[0]
46
+ const repo = parts[1].replace(/\.git$/i, '')
47
+ if (!owner || !repo) return null
48
+ return { owner, repo, fullName: `${owner}/${repo}` }
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ const compact = trimmed.replace(/^github\.com\//i, '')
55
+ const parts = compact.split('/').filter(Boolean)
56
+ if (parts.length < 2) return null
57
+ const owner = parts[0]
58
+ const repo = parts[1].replace(/\.git$/i, '')
59
+ if (!owner || !repo) return null
60
+ return { owner, repo, fullName: `${owner}/${repo}` }
61
+ }
62
+
63
+ export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
64
+ const title = issue.title?.trim() || `Issue ${issue.number}`
65
+ return `[${repoFullName}#${issue.number}] ${title}`
66
+ }
67
+
68
+ export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
69
+ const labels = (issue.labels || [])
70
+ .map(normalizeLabelName)
71
+ .filter(Boolean)
72
+ const header = [
73
+ `Imported from GitHub issue ${repoFullName}#${issue.number}`,
74
+ issue.html_url ? `URL: ${issue.html_url}` : '',
75
+ issue.state ? `State: ${issue.state}` : '',
76
+ labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
77
+ issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
78
+ issue.user?.login ? `Opened by: ${issue.user.login}` : '',
79
+ ]
80
+ .filter(Boolean)
81
+ .join('\n')
82
+
83
+ const rawBody = String(issue.body || '').trim()
84
+ if (!rawBody) return header
85
+
86
+ const body = rawBody.length > BODY_CHAR_LIMIT
87
+ ? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
88
+ : rawBody
89
+
90
+ return `${header}\n\n${body}`
91
+ }
92
+
93
+ export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
94
+ const raw = [
95
+ 'github',
96
+ repoFullName,
97
+ ...(issue.labels || []).map(normalizeLabelName),
98
+ ]
99
+ return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
100
+ }
@@ -5,7 +5,7 @@ import {
5
5
  buildGitHubIssueTaskTags,
6
6
  buildGitHubIssueTaskTitle,
7
7
  parseGitHubRepoInput,
8
- } from './route'
8
+ } from './helpers'
9
9
 
10
10
  test('parseGitHubRepoInput accepts repo slugs and GitHub URLs', () => {
11
11
  assert.deepEqual(parseGitHubRepoInput('swarmclawai/swarmclaw'), {
@@ -6,10 +6,10 @@ import { formatZodError } from '@/lib/validation/schemas'
6
6
  import { loadSettings, loadTasks, logActivity, upsertStoredItems } from '@/lib/server/storage'
7
7
  import { notify } from '@/lib/server/ws-hub'
8
8
  import type { BoardTask } from '@/types'
9
- import { dedup } from '@/lib/shared-utils'
9
+ import { parseGitHubRepoInput, buildGitHubIssueTaskTitle, buildGitHubIssueTaskDescription, buildGitHubIssueTaskTags } from './helpers'
10
+ import type { GitHubIssueRecord } from './helpers'
10
11
 
11
12
  const MAX_IMPORT_LIMIT = 200
12
- const BODY_CHAR_LIMIT = 12_000
13
13
 
14
14
  const GitHubIssueImportSchema = z.object({
15
15
  repo: z.string().trim().min(1, 'Repository is required'),
@@ -23,25 +23,6 @@ const GitHubIssueImportSchema = z.object({
23
23
 
24
24
  type GitHubIssueLabel = string | { name?: string | null }
25
25
 
26
- interface GitHubIssueRecord {
27
- id: number | string
28
- number: number
29
- title: string
30
- body?: string | null
31
- state?: string | null
32
- html_url?: string | null
33
- labels?: GitHubIssueLabel[]
34
- assignee?: { login?: string | null } | null
35
- user?: { login?: string | null } | null
36
- pull_request?: unknown
37
- }
38
-
39
- interface ParsedRepo {
40
- owner: string
41
- repo: string
42
- fullName: string
43
- }
44
-
45
26
  function getGitHubToken(explicitToken: string): string {
46
27
  return explicitToken.trim()
47
28
  || process.env.GITHUB_TOKEN
@@ -55,10 +36,6 @@ function normalizeLabelName(label: GitHubIssueLabel): string {
55
36
  return String(label?.name || '').trim()
56
37
  }
57
38
 
58
- function normalizeTag(value: string): string {
59
- return value.trim().replace(/\s+/g, ' ').slice(0, 60)
60
- }
61
-
62
39
  function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
63
40
  return {
64
41
  taskId,
@@ -68,73 +45,6 @@ function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
68
45
  }
69
46
  }
70
47
 
71
- export function parseGitHubRepoInput(input: string): ParsedRepo | null {
72
- const trimmed = input.trim().replace(/\.git$/i, '')
73
- if (!trimmed) return null
74
-
75
- if (/^https?:\/\//i.test(trimmed)) {
76
- try {
77
- const url = new URL(trimmed)
78
- if (!/github\.com$/i.test(url.hostname)) return null
79
- const parts = url.pathname.split('/').filter(Boolean)
80
- if (parts.length < 2) return null
81
- const owner = parts[0]
82
- const repo = parts[1].replace(/\.git$/i, '')
83
- if (!owner || !repo) return null
84
- return { owner, repo, fullName: `${owner}/${repo}` }
85
- } catch {
86
- return null
87
- }
88
- }
89
-
90
- const compact = trimmed.replace(/^github\.com\//i, '')
91
- const parts = compact.split('/').filter(Boolean)
92
- if (parts.length < 2) return null
93
- const owner = parts[0]
94
- const repo = parts[1].replace(/\.git$/i, '')
95
- if (!owner || !repo) return null
96
- return { owner, repo, fullName: `${owner}/${repo}` }
97
- }
98
-
99
- export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
100
- const title = issue.title?.trim() || `Issue ${issue.number}`
101
- return `[${repoFullName}#${issue.number}] ${title}`
102
- }
103
-
104
- export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
105
- const labels = (issue.labels || [])
106
- .map(normalizeLabelName)
107
- .filter(Boolean)
108
- const header = [
109
- `Imported from GitHub issue ${repoFullName}#${issue.number}`,
110
- issue.html_url ? `URL: ${issue.html_url}` : '',
111
- issue.state ? `State: ${issue.state}` : '',
112
- labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
113
- issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
114
- issue.user?.login ? `Opened by: ${issue.user.login}` : '',
115
- ]
116
- .filter(Boolean)
117
- .join('\n')
118
-
119
- const rawBody = String(issue.body || '').trim()
120
- if (!rawBody) return header
121
-
122
- const body = rawBody.length > BODY_CHAR_LIMIT
123
- ? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
124
- : rawBody
125
-
126
- return `${header}\n\n${body}`
127
- }
128
-
129
- export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
130
- const raw = [
131
- 'github',
132
- repoFullName,
133
- ...(issue.labels || []).map(normalizeLabelName),
134
- ]
135
- return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
136
- }
137
-
138
48
  function findExistingImportedTask(
139
49
  tasks: Record<string, BoardTask>,
140
50
  repoFullName: string,