cuttlely 0.0.0-stage → 1.0.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.
- package/LICENSE +13 -0
- package/README.md +49 -2
- package/bin/cuttlely.js +9 -0
- package/lib/cli.js +448 -0
- package/lib/docker.js +37 -0
- package/package.json +36 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 The Cuttlely Authors
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cuttlely
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Start [Cuttlely](https://cuttlely.com) in Docker. Cuttlely is the open-source agent platform for applications.
|
|
4
|
+
|
|
5
|
+
The full server needs the Python agent runtime that ships in the Docker image, so this package does not install the server. `npx cuttlely` pulls `ghcr.io/cuttlely/cuttlely` and runs it.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 20 or newer, and Docker.
|
|
8
|
+
|
|
9
|
+
## Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx cuttlely start
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That pulls `ghcr.io/cuttlely/cuttlely:1.0.0` (the same version as this package), keeps data in the Docker volume `cuttlely-data`, publishes port **43117**, waits until `http://127.0.0.1:43117/api/v1/ping` answers `pong`, prints `http://localhost:43117`, and opens it.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx cuttlely start --port 8080 --tag 1.0.0 --env-file .env --no-open
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Flag | Meaning |
|
|
22
|
+
| ------------ | ------------------------------------------------------------------------ |
|
|
23
|
+
| `--port` | Host and container port. Default `43117`. |
|
|
24
|
+
| `--tag` | Image tag. Default is this package's version. `latest` is allowed. |
|
|
25
|
+
| `--env-file` | Passed through to `docker run`. The port and data paths above still win. |
|
|
26
|
+
| `--no-open` | Print the URL and skip the browser. |
|
|
27
|
+
|
|
28
|
+
A global install works the same way: `npm install -g cuttlely`, then `cuttlely start`.
|
|
29
|
+
|
|
30
|
+
If Docker is missing or not running, the command says so and links to the [install guide](https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/docker.md).
|
|
31
|
+
|
|
32
|
+
## Other commands
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cuttlely stop # stop the container; running answers get up to 35 seconds to finish
|
|
36
|
+
cuttlely status
|
|
37
|
+
cuttlely logs
|
|
38
|
+
cuttlely logs --follow
|
|
39
|
+
cuttlely upgrade # pull the image and start it again
|
|
40
|
+
cuttlely --help
|
|
41
|
+
cuttlely --version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The container name is `cuttlely`. `start` leaves it running when it is already on the same image and port. `upgrade` always pulls and starts it again. The volume is `cuttlely-data`, mounted at `/var/cuttlely`.
|
|
45
|
+
|
|
46
|
+
Compose uses the volume name `cuttlely_cuttlely-data`. This launcher uses `cuttlely-data`.
|
|
47
|
+
|
|
48
|
+
The first account is created from the one-time setup link in `cuttlely logs`.
|
|
49
|
+
|
|
50
|
+
This package makes no network call of its own. Docker pulls the image, and the command waits on the local health check.
|
package/bin/cuttlely.js
ADDED
package/lib/cli.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// The full server needs the Python agent runtime, which ships in the Docker image.
|
|
2
|
+
// This package only talks to the local Docker daemon.
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { spawnDocker } from './docker.js'
|
|
8
|
+
|
|
9
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
10
|
+
|
|
11
|
+
export const VERSION = JSON.parse(readFileSync(PKG, 'utf8')).version
|
|
12
|
+
export const IMAGE = 'ghcr.io/cuttlely/cuttlely'
|
|
13
|
+
export const DEFAULT_PORT = 43117
|
|
14
|
+
export const CONTAINER_NAME = 'cuttlely'
|
|
15
|
+
export const VOLUME_NAME = 'cuttlely-data'
|
|
16
|
+
export const DATA_DIR = '/var/cuttlely'
|
|
17
|
+
export const INSTALL_DOCS = 'https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/docker.md'
|
|
18
|
+
export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
|
|
21
|
+
const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
|
|
22
|
+
|
|
23
|
+
export class UsageError extends Error {}
|
|
24
|
+
|
|
25
|
+
export function helpText() {
|
|
26
|
+
return [
|
|
27
|
+
'cuttlely runs Cuttlely in Docker.',
|
|
28
|
+
'',
|
|
29
|
+
'Usage:',
|
|
30
|
+
' cuttlely start [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
|
|
31
|
+
' cuttlely stop',
|
|
32
|
+
' cuttlely status',
|
|
33
|
+
' cuttlely logs [--follow]',
|
|
34
|
+
' cuttlely upgrade [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
|
|
35
|
+
' cuttlely --help',
|
|
36
|
+
' cuttlely --version',
|
|
37
|
+
'',
|
|
38
|
+
`The image is ${IMAGE}. The tag defaults to this package's version (${VERSION}).`,
|
|
39
|
+
`Data is kept in the Docker volume ${VOLUME_NAME}.`,
|
|
40
|
+
`The app listens on http://localhost:${DEFAULT_PORT} unless you set --port.`,
|
|
41
|
+
'',
|
|
42
|
+
'Commands:',
|
|
43
|
+
' start Pull the image and start Cuttlely. Opens the browser when it is ready.',
|
|
44
|
+
' stop Stop the Cuttlely container. Running answers get up to 35 seconds to finish.',
|
|
45
|
+
' status Show whether Cuttlely is running.',
|
|
46
|
+
' logs Show container logs. --follow keeps streaming.',
|
|
47
|
+
' upgrade Pull the image and start it again.',
|
|
48
|
+
'',
|
|
49
|
+
`Install guide: ${INSTALL_DOCS}`
|
|
50
|
+
].join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parsePort(value) {
|
|
54
|
+
if (!value || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) {
|
|
55
|
+
throw new UsageError(`Port must be a number from 1 to 65535, got ${value || '(missing)'}.`)
|
|
56
|
+
}
|
|
57
|
+
return Number(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseTag(value) {
|
|
61
|
+
if (!value || !TAG_RE.test(value)) {
|
|
62
|
+
throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest.`)
|
|
63
|
+
}
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function takeValue(argv, index, flag) {
|
|
68
|
+
const inline = argv[index].indexOf('=')
|
|
69
|
+
if (inline !== -1) return { value: argv[index].slice(inline + 1), next: index }
|
|
70
|
+
const value = argv[index + 1]
|
|
71
|
+
if (!value || value.startsWith('-')) throw new UsageError(`${flag} needs a value. Run cuttlely --help.`)
|
|
72
|
+
return { value, next: index + 1 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseArgs(argv) {
|
|
76
|
+
const opts = {
|
|
77
|
+
command: null,
|
|
78
|
+
help: false,
|
|
79
|
+
version: false,
|
|
80
|
+
port: DEFAULT_PORT,
|
|
81
|
+
tag: VERSION,
|
|
82
|
+
envFile: null,
|
|
83
|
+
open: true,
|
|
84
|
+
follow: false
|
|
85
|
+
}
|
|
86
|
+
const words = []
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const arg = argv[i]
|
|
89
|
+
if (arg === '--help' || arg === '-h') opts.help = true
|
|
90
|
+
else if (arg === '--version' || arg === '-V') opts.version = true
|
|
91
|
+
else if (arg === '--no-open') opts.open = false
|
|
92
|
+
else if (arg === '--follow' || arg === '-f') opts.follow = true
|
|
93
|
+
else if (arg === '--port' || arg === '-p' || arg.startsWith('--port=')) {
|
|
94
|
+
const taken = takeValue(argv, i, '--port')
|
|
95
|
+
opts.port = parsePort(taken.value)
|
|
96
|
+
i = taken.next
|
|
97
|
+
} else if (arg === '--tag' || arg.startsWith('--tag=')) {
|
|
98
|
+
const taken = takeValue(argv, i, '--tag')
|
|
99
|
+
opts.tag = parseTag(taken.value)
|
|
100
|
+
i = taken.next
|
|
101
|
+
} else if (arg === '--env-file' || arg.startsWith('--env-file=')) {
|
|
102
|
+
const taken = takeValue(argv, i, '--env-file')
|
|
103
|
+
opts.envFile = taken.value
|
|
104
|
+
i = taken.next
|
|
105
|
+
} else if (arg.startsWith('-')) throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`)
|
|
106
|
+
else words.push(arg)
|
|
107
|
+
}
|
|
108
|
+
if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help.`)
|
|
109
|
+
if (words.length === 1) {
|
|
110
|
+
if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help'].includes(words[0])) {
|
|
111
|
+
throw new UsageError(`Unknown command ${words[0]}. Run cuttlely --help.`)
|
|
112
|
+
}
|
|
113
|
+
opts.command = words[0]
|
|
114
|
+
}
|
|
115
|
+
return opts
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function containerRunArgs({ image, port, envFile }) {
|
|
119
|
+
const args = [
|
|
120
|
+
'run',
|
|
121
|
+
'-d',
|
|
122
|
+
'--name',
|
|
123
|
+
CONTAINER_NAME,
|
|
124
|
+
'--restart',
|
|
125
|
+
'unless-stopped',
|
|
126
|
+
'--init',
|
|
127
|
+
'--stop-timeout',
|
|
128
|
+
'35',
|
|
129
|
+
'-p',
|
|
130
|
+
`${port}:${port}`,
|
|
131
|
+
'-v',
|
|
132
|
+
`${VOLUME_NAME}:${DATA_DIR}`,
|
|
133
|
+
'-e',
|
|
134
|
+
'HOST=0.0.0.0',
|
|
135
|
+
'-e',
|
|
136
|
+
`PORT=${port}`,
|
|
137
|
+
'-e',
|
|
138
|
+
'NODE_OPTIONS=--max-old-space-size=2048',
|
|
139
|
+
'-e',
|
|
140
|
+
`DATABASE_PATH=${DATA_DIR}/data`,
|
|
141
|
+
'-e',
|
|
142
|
+
`SECRETKEY_PATH=${DATA_DIR}/data`,
|
|
143
|
+
'-e',
|
|
144
|
+
`BLOB_STORAGE_PATH=${DATA_DIR}/storage`,
|
|
145
|
+
'-e',
|
|
146
|
+
`LOG_PATH=${DATA_DIR}/logs`,
|
|
147
|
+
'-e',
|
|
148
|
+
`CUTTLELY_DATA_DIR=${DATA_DIR}`,
|
|
149
|
+
'-e',
|
|
150
|
+
`CUTTLELY_HERMES_HOME=${DATA_DIR}/hermes`,
|
|
151
|
+
'-e',
|
|
152
|
+
`HERMES_HOME=${DATA_DIR}/hermes`,
|
|
153
|
+
'-e',
|
|
154
|
+
`CUTTLELY_ETL_DIR=${DATA_DIR}/etl`,
|
|
155
|
+
'-e',
|
|
156
|
+
'CUTTLELY_INSTALL=docker',
|
|
157
|
+
'--health-cmd',
|
|
158
|
+
`curl -fsS "http://127.0.0.1:${port}/api/v1/ping"`,
|
|
159
|
+
'--health-interval',
|
|
160
|
+
'15s',
|
|
161
|
+
'--health-timeout',
|
|
162
|
+
'5s',
|
|
163
|
+
'--health-retries',
|
|
164
|
+
'20',
|
|
165
|
+
'--health-start-period',
|
|
166
|
+
'300s'
|
|
167
|
+
]
|
|
168
|
+
if (envFile) args.push('--env-file', envFile)
|
|
169
|
+
args.push(image)
|
|
170
|
+
return args
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function publicUrl(port) {
|
|
174
|
+
return `http://localhost:${port}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function healthUrl(port) {
|
|
178
|
+
return `http://127.0.0.1:${port}/api/v1/ping`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function dockerMissingMessage() {
|
|
182
|
+
return [
|
|
183
|
+
'Docker is not installed, or it is not on your PATH.',
|
|
184
|
+
'',
|
|
185
|
+
'Cuttlely runs in Docker. Install Docker, start it, and run this command again.',
|
|
186
|
+
INSTALL_DOCS
|
|
187
|
+
].join('\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function dockerDownMessage(detail) {
|
|
191
|
+
const lines = ['Docker is installed, but it is not running.', '', 'Start Docker and run this command again.', INSTALL_DOCS]
|
|
192
|
+
if (detail && detail.trim()) lines.push('', detail.trim())
|
|
193
|
+
return lines.join('\n')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function assertEnvFile(file) {
|
|
197
|
+
if (!file) return
|
|
198
|
+
let info
|
|
199
|
+
try {
|
|
200
|
+
info = statSync(file)
|
|
201
|
+
} catch {
|
|
202
|
+
throw new UsageError(`Env file not found: ${file}`)
|
|
203
|
+
}
|
|
204
|
+
if (!info.isFile()) throw new UsageError(`Env file is not a file: ${file}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function ensureDocker(docker, err) {
|
|
208
|
+
const result = await docker(['info', '--format', '{{.ServerVersion}}'])
|
|
209
|
+
if (result.error && result.error.code === 'ENOENT') {
|
|
210
|
+
err(dockerMissingMessage())
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
if (result.code !== 0 || result.error) {
|
|
214
|
+
err(dockerDownMessage(result.stderr || result.error?.message || ''))
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function inspectContainer(docker) {
|
|
221
|
+
const result = await docker(['inspect', CONTAINER_NAME, '--format', INSPECT_FORMAT])
|
|
222
|
+
if (result.code !== 0 || result.error) {
|
|
223
|
+
const text = `${result.stderr || ''}\n${result.stdout || ''}`
|
|
224
|
+
if (/No such object|no such container/i.test(text)) return null
|
|
225
|
+
return { error: (result.stderr || result.error?.message || 'Could not inspect the Cuttlely container.').trim() }
|
|
226
|
+
}
|
|
227
|
+
const [status, image, imageId, ports = ''] = result.stdout.trim().split('|')
|
|
228
|
+
const portMatch = /(\d+)\//.exec(ports)
|
|
229
|
+
return { status, image, imageId, port: portMatch ? Number(portMatch[1]) : null }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readImageId(docker, image) {
|
|
233
|
+
const result = await docker(['image', 'inspect', image, '--format', '{{.Id}}'])
|
|
234
|
+
if (result.code !== 0 || result.error) return ''
|
|
235
|
+
return result.stdout.trim()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function waitForHealth({ url, fetchImpl, sleep, timeoutMs, now }) {
|
|
239
|
+
const deadline = now() + timeoutMs
|
|
240
|
+
for (;;) {
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetchImpl(url, { redirect: 'manual' })
|
|
243
|
+
const body = typeof res.text === 'function' ? await res.text() : String(res.body ?? '')
|
|
244
|
+
if (res.ok && body.includes('pong')) return true
|
|
245
|
+
} catch {
|
|
246
|
+
// The server is still booting, or the port is not open yet.
|
|
247
|
+
}
|
|
248
|
+
if (now() >= deadline) return false
|
|
249
|
+
await sleep(1000)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function reportReady(ctx) {
|
|
254
|
+
const url = publicUrl(ctx.opts.port)
|
|
255
|
+
const ping = healthUrl(ctx.opts.port)
|
|
256
|
+
ctx.out(`Waiting for Cuttlely at ${ping}`)
|
|
257
|
+
const ready = await waitForHealth({
|
|
258
|
+
url: ping,
|
|
259
|
+
fetchImpl: ctx.fetchImpl,
|
|
260
|
+
sleep: ctx.sleep,
|
|
261
|
+
timeoutMs: ctx.timeoutMs,
|
|
262
|
+
now: ctx.now
|
|
263
|
+
})
|
|
264
|
+
if (!ready) {
|
|
265
|
+
ctx.err(`Cuttlely is still starting. Open ${url} when it is ready.`)
|
|
266
|
+
ctx.err('Logs: cuttlely logs')
|
|
267
|
+
ctx.err(INSTALL_DOCS)
|
|
268
|
+
return 1
|
|
269
|
+
}
|
|
270
|
+
ctx.out(`Cuttlely is ready. Open ${url}`)
|
|
271
|
+
if (ctx.opts.open) ctx.open(url)
|
|
272
|
+
return 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function removeContainer(docker) {
|
|
276
|
+
await docker(['stop', '-t', '35', CONTAINER_NAME])
|
|
277
|
+
return docker(['rm', CONTAINER_NAME])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function launch(ctx, { restart }) {
|
|
281
|
+
if (!(await ensureDocker(ctx.docker, ctx.err))) return 1
|
|
282
|
+
assertEnvFile(ctx.opts.envFile)
|
|
283
|
+
const image = `${IMAGE}:${ctx.opts.tag}`
|
|
284
|
+
ctx.out(`Pulling ${image}`)
|
|
285
|
+
const pulled = await ctx.docker(['pull', image], { stdio: 'inherit' })
|
|
286
|
+
if (pulled.code !== 0 || pulled.error) {
|
|
287
|
+
const detail = (pulled.stderr || pulled.error?.message || '').trim()
|
|
288
|
+
ctx.err([`Could not pull ${image}.`, 'Check the tag, and see the install guide.', INSTALL_DOCS, detail].filter(Boolean).join('\n'))
|
|
289
|
+
return 1
|
|
290
|
+
}
|
|
291
|
+
const imageId = await readImageId(ctx.docker, image)
|
|
292
|
+
const current = await inspectContainer(ctx.docker)
|
|
293
|
+
if (current && current.error) {
|
|
294
|
+
ctx.err(current.error)
|
|
295
|
+
return 1
|
|
296
|
+
}
|
|
297
|
+
const same = current && current.image === image && current.imageId === imageId && current.port === ctx.opts.port && !ctx.opts.envFile
|
|
298
|
+
if (current && same && current.status === 'running' && !restart) {
|
|
299
|
+
ctx.out('Cuttlely is already running.')
|
|
300
|
+
return reportReady(ctx)
|
|
301
|
+
}
|
|
302
|
+
if (current && same && !restart) {
|
|
303
|
+
const started = await ctx.docker(['start', CONTAINER_NAME])
|
|
304
|
+
if (started.code !== 0 || started.error) {
|
|
305
|
+
ctx.err((started.stderr || started.error?.message || 'Could not start the Cuttlely container.').trim())
|
|
306
|
+
return 1
|
|
307
|
+
}
|
|
308
|
+
return reportReady(ctx)
|
|
309
|
+
}
|
|
310
|
+
if (current) {
|
|
311
|
+
const removed = await removeContainer(ctx.docker)
|
|
312
|
+
if (removed.code !== 0 || removed.error) {
|
|
313
|
+
ctx.err((removed.stderr || removed.error?.message || 'Could not replace the Cuttlely container.').trim())
|
|
314
|
+
return 1
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const run = await ctx.docker(containerRunArgs({ image, port: ctx.opts.port, envFile: ctx.opts.envFile }))
|
|
318
|
+
if (run.code !== 0 || run.error) {
|
|
319
|
+
const detail = (run.stderr || run.error?.message || 'Could not start Cuttlely.').trim()
|
|
320
|
+
const hint = /port is already allocated|address already in use/i.test(detail)
|
|
321
|
+
? `Port ${ctx.opts.port} is in use. Stop what is using it, or run cuttlely start --port <port>.`
|
|
322
|
+
: ''
|
|
323
|
+
ctx.err([detail, hint].filter(Boolean).join('\n'))
|
|
324
|
+
return 1
|
|
325
|
+
}
|
|
326
|
+
return reportReady(ctx)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function stop(ctx) {
|
|
330
|
+
if (!(await ensureDocker(ctx.docker, ctx.err))) return 1
|
|
331
|
+
const current = await inspectContainer(ctx.docker)
|
|
332
|
+
if (current && current.error) {
|
|
333
|
+
ctx.err(current.error)
|
|
334
|
+
return 1
|
|
335
|
+
}
|
|
336
|
+
if (!current || !['running', 'restarting', 'paused'].includes(current.status)) {
|
|
337
|
+
ctx.out('Cuttlely is not running.')
|
|
338
|
+
return 0
|
|
339
|
+
}
|
|
340
|
+
const result = await ctx.docker(['stop', '-t', '35', CONTAINER_NAME])
|
|
341
|
+
if (result.code !== 0 || result.error) {
|
|
342
|
+
ctx.err((result.stderr || result.error?.message || 'Could not stop Cuttlely.').trim())
|
|
343
|
+
return 1
|
|
344
|
+
}
|
|
345
|
+
ctx.out('Stopped Cuttlely.')
|
|
346
|
+
return 0
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function status(ctx) {
|
|
350
|
+
if (!(await ensureDocker(ctx.docker, ctx.err))) return 1
|
|
351
|
+
const current = await inspectContainer(ctx.docker)
|
|
352
|
+
if (current && current.error) {
|
|
353
|
+
ctx.err(current.error)
|
|
354
|
+
return 1
|
|
355
|
+
}
|
|
356
|
+
if (!current) {
|
|
357
|
+
ctx.out('Cuttlely is not running.')
|
|
358
|
+
ctx.out('Start it with: cuttlely start')
|
|
359
|
+
return 0
|
|
360
|
+
}
|
|
361
|
+
ctx.out(`name: ${CONTAINER_NAME}`)
|
|
362
|
+
ctx.out(`status: ${current.status}`)
|
|
363
|
+
ctx.out(`image: ${current.image}`)
|
|
364
|
+
ctx.out(`volume: ${VOLUME_NAME}`)
|
|
365
|
+
if (current.status === 'running' && current.port) ctx.out(`url: ${publicUrl(current.port)}`)
|
|
366
|
+
return 0
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function logs(ctx) {
|
|
370
|
+
if (!(await ensureDocker(ctx.docker, ctx.err))) return 1
|
|
371
|
+
const current = await inspectContainer(ctx.docker)
|
|
372
|
+
if (current && current.error) {
|
|
373
|
+
ctx.err(current.error)
|
|
374
|
+
return 1
|
|
375
|
+
}
|
|
376
|
+
if (!current) {
|
|
377
|
+
ctx.err('No Cuttlely container yet. Start it with: cuttlely start')
|
|
378
|
+
return 1
|
|
379
|
+
}
|
|
380
|
+
const args = ctx.opts.follow ? ['logs', '-f', CONTAINER_NAME] : ['logs', CONTAINER_NAME]
|
|
381
|
+
const result = await ctx.docker(args, ctx.opts.follow ? { stdio: 'inherit' } : {})
|
|
382
|
+
if (!ctx.opts.follow) {
|
|
383
|
+
if (result.stdout) ctx.out(result.stdout.replace(/\s*$/, ''))
|
|
384
|
+
if (result.stderr && result.stderr.trim()) ctx.err(result.stderr.replace(/\s*$/, ''))
|
|
385
|
+
}
|
|
386
|
+
return result.code === 0 && !result.error ? 0 : 1
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function openBrowser(url) {
|
|
390
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
|
|
391
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]
|
|
392
|
+
try {
|
|
393
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
|
|
394
|
+
child.on('error', () => {})
|
|
395
|
+
child.unref()
|
|
396
|
+
} catch {
|
|
397
|
+
// The URL is already printed.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function main(argv, io = {}) {
|
|
402
|
+
const out = io.out || ((line) => console.info(line))
|
|
403
|
+
const err = io.err || ((line) => console.error(line))
|
|
404
|
+
let opts
|
|
405
|
+
try {
|
|
406
|
+
opts = parseArgs(argv)
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error instanceof UsageError) {
|
|
409
|
+
err(error.message)
|
|
410
|
+
return 1
|
|
411
|
+
}
|
|
412
|
+
throw error
|
|
413
|
+
}
|
|
414
|
+
if (opts.version) {
|
|
415
|
+
out(VERSION)
|
|
416
|
+
return 0
|
|
417
|
+
}
|
|
418
|
+
if (opts.help || !opts.command || opts.command === 'help') {
|
|
419
|
+
out(helpText())
|
|
420
|
+
return 0
|
|
421
|
+
}
|
|
422
|
+
const ctx = {
|
|
423
|
+
opts,
|
|
424
|
+
out,
|
|
425
|
+
err,
|
|
426
|
+
docker: io.docker || spawnDocker,
|
|
427
|
+
fetchImpl: io.fetch || globalThis.fetch.bind(globalThis),
|
|
428
|
+
sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
429
|
+
open: io.open || openBrowser,
|
|
430
|
+
now: io.now || Date.now,
|
|
431
|
+
timeoutMs: io.healthTimeoutMs ?? HEALTH_TIMEOUT_MS
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
if (opts.command === 'start') return await launch(ctx, { restart: false })
|
|
435
|
+
if (opts.command === 'upgrade') return await launch(ctx, { restart: true })
|
|
436
|
+
if (opts.command === 'stop') return await stop(ctx)
|
|
437
|
+
if (opts.command === 'status') return await status(ctx)
|
|
438
|
+
if (opts.command === 'logs') return await logs(ctx)
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (error instanceof UsageError) {
|
|
441
|
+
err(error.message)
|
|
442
|
+
return 1
|
|
443
|
+
}
|
|
444
|
+
throw error
|
|
445
|
+
}
|
|
446
|
+
err(`Unknown command ${opts.command}. Run cuttlely --help.`)
|
|
447
|
+
return 1
|
|
448
|
+
}
|
package/lib/docker.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
// Talks only to the local Docker CLI. Callers pass an argv array, never a shell string.
|
|
4
|
+
export function spawnDocker(args, { stdio = 'pipe' } = {}) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
let settled = false
|
|
7
|
+
const finish = (result) => {
|
|
8
|
+
if (settled) return
|
|
9
|
+
settled = true
|
|
10
|
+
resolve(result)
|
|
11
|
+
}
|
|
12
|
+
let child
|
|
13
|
+
try {
|
|
14
|
+
child = spawn('docker', args, {
|
|
15
|
+
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
windowsHide: true
|
|
17
|
+
})
|
|
18
|
+
} catch (error) {
|
|
19
|
+
finish({ code: 127, stdout: '', stderr: '', error })
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const stdout = []
|
|
23
|
+
const stderr = []
|
|
24
|
+
if (stdio !== 'inherit') {
|
|
25
|
+
child.stdout.setEncoding('utf8')
|
|
26
|
+
child.stderr.setEncoding('utf8')
|
|
27
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk))
|
|
28
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk))
|
|
29
|
+
}
|
|
30
|
+
child.on('error', (error) => {
|
|
31
|
+
finish({ code: 127, stdout: stdout.join(''), stderr: stderr.join(''), error })
|
|
32
|
+
})
|
|
33
|
+
child.on('close', (code) => {
|
|
34
|
+
finish({ code: code ?? 1, stdout: stdout.join(''), stderr: stderr.join(''), error: null })
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "cuttlely",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Start Cuttlely in Docker. The open-source agent platform for applications.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cuttlely": "./bin/cuttlely.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/cuttlely/cuttlely.git",
|
|
19
|
+
"directory": "packages/cli"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://cuttlely.com",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"cuttlely",
|
|
24
|
+
"agents",
|
|
25
|
+
"cli",
|
|
26
|
+
"docker"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/cli.test.js"
|
|
36
|
+
}
|
|
37
|
+
}
|