cuttlely 1.0.2 → 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.
package/README.md CHANGED
@@ -41,10 +41,39 @@ cuttlely --help
41
41
  cuttlely --version
42
42
  ```
43
43
 
44
+ ## Check a draft
45
+
46
+ `check` runs the Publish check on a flow's draft and waits for it. It reads the API key from `CUTTLELY_API_KEY` and calls the server you name. It does not start Docker.
47
+
48
+ ```bash
49
+ export CUTTLELY_API_KEY=<key>
50
+ npx cuttlely check --flow <flow id> --server http://127.0.0.1:43117 --timeout 300 --json check.json --fail-on-changed
51
+ ```
52
+
53
+ | Flag | Meaning |
54
+ | ------------------- | --------------------------------------------------------------------------------------------------------- |
55
+ | `--flow` | Flow id from the canvas address bar. Required. |
56
+ | `--server` | Server origin. Default `http://127.0.0.1:43117`. |
57
+ | `--timeout` | Seconds to wait. Default 300. |
58
+ | `--json` | Write the full check JSON to this path. |
59
+ | `--fail-on-changed` | Exit 1 when any answer is Changed. Without it, a finished check exits 0 and the summary still lists them. |
60
+
61
+ `pnpm dev` serves the API on port 3000. `scripts/start-cuttlely.sh` and `npx cuttlely start` listen on port 43117.
62
+
63
+ | Code | When |
64
+ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | 0 | The check finished. No answer was Changed, or `--fail-on-changed` was off. |
66
+ | 1 | `--fail-on-changed` was set and at least one answer was Changed. |
67
+ | 2 | The check timed out, the server returned an error, `CUTTLELY_API_KEY` was missing, the flow has no draft, or the command was used wrong. |
68
+
69
+ A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. Cancelling the command stops that server-side check too. That stop is best effort: if it fails, the exit code is still 2.
70
+
71
+ The key needs both permissions for the flow: `chatflows:view` and `chatflows:update`, or `agentflows:view` and `agentflows:update`. Update starts the check. View is what reading the result uses. The guide is [Fail a build when answers change](https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/ci-gate.md).
72
+
44
73
  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
74
 
46
75
  Compose uses the volume name `cuttlely_cuttlely-data`. This launcher uses `cuttlely-data`.
47
76
 
48
77
  The first account is created from the one-time setup link in `cuttlely logs`.
49
78
 
50
- This package makes no network call of its own. Docker pulls the image, and the command waits on the local health check.
79
+ The start, stop, status, logs and upgrade commands make no network call of their own. Docker pulls the image, and start waits on the local health check. `check` calls the Cuttlely server named with `--server`.
package/bin/cuttlely.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../lib/cli.js'
2
+ import { commandFailureCode, main } from '../lib/cli.js'
3
3
 
4
+ const argv = process.argv.slice(2)
4
5
  try {
5
- process.exitCode = await main(process.argv.slice(2))
6
+ process.exitCode = await main(argv)
6
7
  } catch (error) {
7
8
  console.error(error instanceof Error ? error.message : String(error))
8
- process.exitCode = 1
9
+ process.exitCode = commandFailureCode(argv)
9
10
  }
package/lib/check.js ADDED
@@ -0,0 +1,340 @@
1
+ // Runs the Publish check (POST /api/v1/flow-drafts/:id/check) and waits for it.
2
+ // Talks only to the server named with --server. The API key stays in the environment.
3
+ import { writeFileSync } from 'node:fs'
4
+
5
+ export const API_KEY_ENV = 'CUTTLELY_API_KEY'
6
+ export const DEFAULT_SERVER = 'http://127.0.0.1:43117'
7
+ export const DEFAULT_TIMEOUT_SECONDS = 300
8
+ export const DEFAULT_QUESTION_COUNT = 4
9
+ export const POLL_MS = 1000
10
+
11
+ export const EXIT_PASS = 0
12
+ export const EXIT_CHANGED = 1
13
+ export const EXIT_ERROR = 2
14
+
15
+ const ANSWER_CLIP = 240
16
+
17
+ function redact(text, secret) {
18
+ if (!secret || typeof text !== 'string' || !text.includes(secret)) return text
19
+ return text.split(secret).join('[redacted]')
20
+ }
21
+
22
+ function redactValue(value, secret) {
23
+ if (typeof value === 'string') return redact(value, secret)
24
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, secret))
25
+ if (value && typeof value === 'object') {
26
+ const copy = {}
27
+ for (const [key, item] of Object.entries(value)) copy[key] = redactValue(item, secret)
28
+ return copy
29
+ }
30
+ return value
31
+ }
32
+
33
+ function oneLine(text) {
34
+ const flat = String(text ?? '')
35
+ .replace(/\s+/g, ' ')
36
+ .trim()
37
+ if (!flat) return '(empty)'
38
+ if (flat.length <= ANSWER_CLIP) return flat
39
+ return `${flat.slice(0, ANSWER_CLIP - 3)}...`
40
+ }
41
+
42
+ function formatMs(value) {
43
+ return value == null ? 'n/a' : `${value} ms`
44
+ }
45
+
46
+ function formatUsd(value) {
47
+ const n = Number(value)
48
+ if (!Number.isFinite(n)) return ''
49
+ if (n === 0) return '$0'
50
+ if (Math.abs(n) >= 0.01) return `$${n.toFixed(2)}`
51
+ return `$${n.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
52
+ }
53
+
54
+ function estimateLine(label, usd, price) {
55
+ if (price === 'no price') return `${label}: estimated cost no price`
56
+ if (price === 'partial') {
57
+ const money = formatUsd(usd)
58
+ return money ? `${label}: estimated ${money} (some models have no price)` : `${label}: estimated cost no price`
59
+ }
60
+ if (price === 'known') {
61
+ const money = formatUsd(usd)
62
+ return money ? `${label}: estimated ${money}` : ''
63
+ }
64
+ return ''
65
+ }
66
+
67
+ export function changedCases(check) {
68
+ return (check?.cases || []).filter((row) => row && row.same === false && row.live && row.draft && !row.live.error && !row.draft.error)
69
+ }
70
+
71
+ function errorCases(check) {
72
+ return (check?.cases || []).filter((row) => row && (row.live?.error || row.draft?.error))
73
+ }
74
+
75
+ export function summaryText(check) {
76
+ const summary = check.summary || {}
77
+ const lines = [
78
+ `Check ${check.id} ${check.status}`,
79
+ `Questions: ${summary.ran ?? 0} ran, ${summary.same ?? 0} same, ${summary.changed ?? 0} changed, ${summary.errors ?? 0} errors`,
80
+ `Live: ${summary.liveTokens ?? 0} tokens, median ${formatMs(summary.liveMedianMs)}`,
81
+ `Draft: ${summary.draftTokens ?? 0} tokens, median ${formatMs(summary.draftMedianMs)}`
82
+ ]
83
+ // The check total, with each model priced once. It is not the sum of the per-answer figures.
84
+ const liveEstimate = estimateLine('Live', summary.liveEstimatedCostUsd, summary.livePrice)
85
+ const draftEstimate = estimateLine('Draft', summary.draftEstimatedCostUsd, summary.draftPrice)
86
+ if (liveEstimate) lines.push(liveEstimate)
87
+ if (draftEstimate) lines.push(draftEstimate)
88
+ if (check.liveEmpty) lines.push('Live has no cards, so only the draft was asked.')
89
+ if (check.stopReason) lines.push(`Stopped: ${check.stopReason}`)
90
+ if (check.error) lines.push(`Error: ${check.error}`)
91
+ const changed = changedCases(check)
92
+ if (changed.length) {
93
+ lines.push('', 'Changed')
94
+ for (const row of changed) {
95
+ lines.push(` ${oneLine(row.question)}`)
96
+ lines.push(` Live: ${oneLine(row.live.text)}`)
97
+ lines.push(` Draft: ${oneLine(row.draft.text)}`)
98
+ }
99
+ }
100
+ const errors = errorCases(check)
101
+ if (errors.length) {
102
+ lines.push('', 'Errors')
103
+ for (const row of errors) {
104
+ const why = [row.live?.error, row.draft?.error].filter(Boolean).join('; ')
105
+ lines.push(` ${oneLine(row.question)}`)
106
+ lines.push(` ${oneLine(why)}`)
107
+ }
108
+ }
109
+ return lines.join('\n')
110
+ }
111
+
112
+ // Replay is the only verdict this command runs. A later scored mode (pass or fail
113
+ // from a test set) should return the same { code, label } and be selected in selectVerdict.
114
+ export function replayVerdict(check, { failOnChanged }) {
115
+ if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
116
+ if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
117
+ if (check.status === 'stopped') return { code: EXIT_ERROR, label: 'stopped' }
118
+ if (check.status !== 'done') return { code: EXIT_ERROR, label: 'incomplete' }
119
+ if (check.draftChanged) return { code: EXIT_ERROR, label: 'draft-changed' }
120
+ const changed = Number(check.summary?.changed ?? 0)
121
+ if (!Number.isFinite(changed)) return { code: EXIT_ERROR, label: 'error' }
122
+ if (failOnChanged && changed > 0) return { code: EXIT_CHANGED, label: 'changed' }
123
+ return { code: EXIT_PASS, label: 'pass' }
124
+ }
125
+
126
+ export function selectVerdict(check, options) {
127
+ return replayVerdict(check, options)
128
+ }
129
+
130
+ function deadlineSignal(deadline, now) {
131
+ const remaining = Math.max(1, deadline - now())
132
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(remaining)
133
+ return undefined
134
+ }
135
+
136
+ async function callApi(fetchImpl, url, { method, headers, body, signal }) {
137
+ let response
138
+ try {
139
+ response = await fetchImpl(url, { method, headers, body, redirect: 'manual', signal })
140
+ } catch (error) {
141
+ const name = error && error.name
142
+ if (name === 'AbortError' || name === 'TimeoutError') return { timeout: true }
143
+ return { network: true, message: error instanceof Error ? error.message : String(error) }
144
+ }
145
+ const status = Number(response.status)
146
+ if (status >= 300 && status < 400) {
147
+ return { ok: false, status, message: 'The server sent a redirect, which this command will not follow.' }
148
+ }
149
+ let text = ''
150
+ try {
151
+ text = typeof response.text === 'function' ? await response.text() : ''
152
+ } catch (error) {
153
+ return { network: true, message: error instanceof Error ? error.message : String(error) }
154
+ }
155
+ let json = null
156
+ if (text) {
157
+ try {
158
+ json = JSON.parse(text)
159
+ } catch {
160
+ json = null
161
+ }
162
+ }
163
+ const raw = json && (json.message || json.error)
164
+ const message = typeof raw === 'string' && raw ? raw : text ? text.slice(0, 300) : `The server returned HTTP ${status}.`
165
+ return { ok: status >= 200 && status < 300, status, message, json }
166
+ }
167
+
168
+ function isNoDraft(result) {
169
+ return result.status === 409 && typeof result.message === 'string' && /no unpublished changes/i.test(result.message)
170
+ }
171
+
172
+ // Starting a check needs update. Reading it needs view. A key with only one of those gets 403,
173
+ // and the server message does not say which permission is missing.
174
+ function permissionDeniedMessage() {
175
+ return 'The API key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.'
176
+ }
177
+
178
+ function writeResult(writeFile, jsonPath, check, secret) {
179
+ if (!jsonPath || !check) return
180
+ const safe = redactValue(check, secret)
181
+ writeFile(jsonPath, `${JSON.stringify(safe, null, 4)}\n`)
182
+ }
183
+
184
+ async function stopCheck(fetchImpl, url, headers) {
185
+ try {
186
+ const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined
187
+ await fetchImpl(url, { method: 'DELETE', headers, redirect: 'manual', signal })
188
+ } catch {
189
+ // The timeout result stands either way. A stuck stop must not hide it.
190
+ }
191
+ }
192
+
193
+ export async function runCheck(options, io = {}) {
194
+ const out = io.out || ((line) => console.info(line))
195
+ const err = io.err || ((line) => console.error(line))
196
+ const env = io.env || process.env
197
+ const fetchImpl = io.fetch || globalThis.fetch.bind(globalThis)
198
+ const sleep = io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
199
+ const now = io.now || Date.now
200
+ const writeFile = io.writeFile || ((file, text) => writeFileSync(file, text))
201
+ const secret = typeof env[API_KEY_ENV] === 'string' ? env[API_KEY_ENV].trim() : ''
202
+ if (!secret) {
203
+ err(`${API_KEY_ENV} is not set.`)
204
+ return EXIT_ERROR
205
+ }
206
+
207
+ const server = options.server || DEFAULT_SERVER
208
+ const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS
209
+ const flow = options.flow
210
+ const failOnChanged = Boolean(options.failOnChanged)
211
+ const base = `${server}/api/v1/flow-drafts/${encodeURIComponent(flow)}/check`
212
+ const headers = { Authorization: `Bearer ${secret}`, Accept: 'application/json' }
213
+ const deadline = now() + timeoutSeconds * 1000
214
+
215
+ const say = (text) => redact(text, secret)
216
+
217
+ const fail = (text) => {
218
+ err(say(text))
219
+ return EXIT_ERROR
220
+ }
221
+
222
+ // Set once the server has accepted the check. Every later failure tries to stop it.
223
+ let check = null
224
+ const stopIfStarted = async () => {
225
+ if (!check?.id) return
226
+ await stopCheck(fetchImpl, `${base}/${encodeURIComponent(check.id)}`, headers)
227
+ }
228
+ // The stop is best effort. A failed DELETE does not replace the exit code.
229
+ const failRunning = async (text) => {
230
+ await stopIfStarted()
231
+ return fail(text)
232
+ }
233
+ // Signals are armed only after the id is known, and removed on a normal return so a
234
+ // finished check is left alone. process.exit skips the finally, so the handler disarms first.
235
+ const signalTarget = io.signals || process
236
+ let signalExit = false
237
+ const onSignal = () => {
238
+ if (signalExit) return
239
+ signalExit = true
240
+ disarmSignals()
241
+ void (async () => {
242
+ await stopIfStarted()
243
+ err('The check was cancelled.')
244
+ const exit = io.exit || ((code) => process.exit(code))
245
+ exit(EXIT_ERROR)
246
+ })()
247
+ }
248
+ const disarmSignals = () => {
249
+ signalTarget.removeListener('SIGTERM', onSignal)
250
+ signalTarget.removeListener('SIGINT', onSignal)
251
+ }
252
+ const armSignals = () => {
253
+ signalTarget.on('SIGTERM', onSignal)
254
+ signalTarget.on('SIGINT', onSignal)
255
+ }
256
+
257
+ try {
258
+ const started = await callApi(fetchImpl, base, {
259
+ method: 'POST',
260
+ headers: { ...headers, 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({ count: DEFAULT_QUESTION_COUNT }),
262
+ signal: deadlineSignal(deadline, now)
263
+ })
264
+ if (started.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
265
+ if (started.network) return fail(`Could not reach ${server}. ${started.message}`)
266
+ if (!started.ok) {
267
+ if (started.status === 403) return fail(permissionDeniedMessage())
268
+ if (isNoDraft(started)) return fail(`No draft to check. ${started.message}`)
269
+ return fail(`Check failed (${started.status}): ${started.message}`)
270
+ }
271
+ check = started.json
272
+ if (!check || typeof check !== 'object' || !check.id) return fail('The server did not return a check.')
273
+ armSignals()
274
+ out(`Started check ${check.id} for flow ${flow} (${check.total ?? 0} questions).`)
275
+
276
+ const giveUp = async () => {
277
+ await stopIfStarted()
278
+ try {
279
+ writeResult(writeFile, options.jsonPath, check, secret)
280
+ } catch (error) {
281
+ err(say(`Could not write ${options.jsonPath}. ${error instanceof Error ? error.message : String(error)}`))
282
+ }
283
+ return fail(`Timed out after ${timeoutSeconds}s waiting for check ${check.id}.`)
284
+ }
285
+
286
+ while (check.status === 'running') {
287
+ // Read before sleeping, and once more at the deadline, so a check that
288
+ // finishes in the last poll interval is not reported from a stale body.
289
+ const polled = await callApi(fetchImpl, `${base}/${encodeURIComponent(check.id)}`, {
290
+ method: 'GET',
291
+ headers,
292
+ signal: deadlineSignal(deadline + POLL_MS, now)
293
+ })
294
+ if (polled.timeout) return giveUp()
295
+ if (polled.network) return failRunning(`Could not reach ${server}. ${polled.message}`)
296
+ if (polled.status === 403) return failRunning(permissionDeniedMessage())
297
+ if (!polled.ok) return failRunning(`Check failed (${polled.status}): ${polled.message}`)
298
+ if (!polled.json || typeof polled.json !== 'object') return failRunning('The server did not return a check.')
299
+ const previous = check.completed
300
+ check = polled.json
301
+ if (check.status === 'running' && check.completed !== previous && check.completed != null) {
302
+ out(`Asked ${check.completed} of ${check.total}.`)
303
+ }
304
+ if (check.status !== 'running') break
305
+ const remaining = deadline - now()
306
+ if (remaining <= 0) return giveUp()
307
+ await sleep(Math.min(POLL_MS, remaining))
308
+ }
309
+
310
+ try {
311
+ writeResult(writeFile, options.jsonPath, check, secret)
312
+ } catch (error) {
313
+ err(say(`Could not write ${options.jsonPath}. ${error instanceof Error ? error.message : String(error)}`))
314
+ out(say(summaryText(check)))
315
+ await stopIfStarted()
316
+ return EXIT_ERROR
317
+ }
318
+ out(say(summaryText(check)))
319
+ const verdict = selectVerdict(check, { failOnChanged })
320
+ if (verdict.label === 'draft-changed') {
321
+ err('The draft changed while the check was running, so the result is out of date.')
322
+ } else if (verdict.label === 'failed') {
323
+ err(say(check.error ? `The check failed. ${check.error}` : 'The check failed.'))
324
+ } else if (verdict.label === 'stopped') {
325
+ err(`The check stopped${check.stopReason ? ` (${check.stopReason})` : ''} before it finished.`)
326
+ } else if (verdict.label === 'changed') {
327
+ const count = Number(check.summary?.changed ?? 0)
328
+ err(count === 1 ? '1 answer changed.' : `${count} answers changed.`)
329
+ } else if (verdict.code === EXIT_ERROR) {
330
+ err('The check did not finish.')
331
+ }
332
+ if (verdict.code === EXIT_ERROR) await stopIfStarted()
333
+ return verdict.code
334
+ } catch (error) {
335
+ await stopIfStarted()
336
+ return fail(error instanceof Error ? error.message : String(error))
337
+ } finally {
338
+ if (!signalExit) disarmSignals()
339
+ }
340
+ }
package/lib/cli.js CHANGED
@@ -4,6 +4,7 @@ import { readFileSync, statSync } from 'node:fs'
4
4
  import { spawn } from 'node:child_process'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { runCheck } from './check.js'
7
8
  import { spawnDocker } from './docker.js'
8
9
 
9
10
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
@@ -20,7 +21,12 @@ export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
20
21
  const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
21
22
  const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
22
23
 
23
- export class UsageError extends Error {}
24
+ export class UsageError extends Error {
25
+ constructor(message, exitCode = 1) {
26
+ super(message)
27
+ this.exitCode = exitCode
28
+ }
29
+ }
24
30
 
25
31
  export function helpText() {
26
32
  return [
@@ -32,6 +38,7 @@ export function helpText() {
32
38
  ' cuttlely status',
33
39
  ' cuttlely logs [--follow]',
34
40
  ' cuttlely upgrade [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
41
+ ' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] [--fail-on-changed]',
35
42
  ' cuttlely --help',
36
43
  ' cuttlely --version',
37
44
  '',
@@ -45,30 +52,96 @@ export function helpText() {
45
52
  ' status Show whether Cuttlely is running.',
46
53
  ' logs Show container logs. --follow keeps streaming.',
47
54
  ' upgrade Pull the image and start it again.',
55
+ ' check Run the Publish check on a flow draft. Reads CUTTLELY_API_KEY.',
56
+ ' The key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.',
57
+ '',
58
+ 'Exit codes for check:',
59
+ ' 0 The check finished. No answer was Changed, or --fail-on-changed was off.',
60
+ ' 1 --fail-on-changed was set and at least one answer was Changed.',
61
+ ' 2 The check timed out, the server returned an error, CUTTLELY_API_KEY was missing, the flow has no draft, or the command was used wrong.',
62
+ 'A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. That stop is best effort: if it fails, the exit code is still 2.',
63
+ 'Cancelling the command stops the server-side check.',
48
64
  '',
49
65
  `Install guide: ${INSTALL_DOCS}`
50
66
  ].join('\n')
51
67
  }
52
68
 
53
- function parsePort(value) {
69
+ function parsePort(value, exitCode = 1) {
54
70
  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)'}.`)
71
+ throw new UsageError(`Port must be a number from 1 to 65535, got ${value || '(missing)'}.`, exitCode)
56
72
  }
57
73
  return Number(value)
58
74
  }
59
75
 
60
- function parseTag(value) {
76
+ function parseTag(value, exitCode = 1) {
61
77
  if (!value || !TAG_RE.test(value)) {
62
- throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest.`)
78
+ throw new UsageError(`Not a valid image tag: ${value || '(missing)'}. Example: 1.0.0 or latest.`, exitCode)
79
+ }
80
+ return value
81
+ }
82
+
83
+ // These options take the next argv token as their value. A following `check` is that value, not the command.
84
+ // Any other previous token, including an unknown flag, leaves `check` as the command.
85
+ const VALUE_OPTIONS = new Set(['--flow', '--server', '--timeout', '--json', '--port', '-p', '--tag', '--env-file'])
86
+
87
+ export function isCheckCommand(argv) {
88
+ return argv.some((item, index) => {
89
+ if (item !== 'check') return false
90
+ if (index === 0) return true
91
+ return !VALUE_OPTIONS.has(argv[index - 1])
92
+ })
93
+ }
94
+
95
+ // An unexpected failure while running check is not a Changed answer.
96
+ export function commandFailureCode(argv) {
97
+ return isCheckCommand(argv) ? 2 : 1
98
+ }
99
+
100
+ const FLOW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
101
+
102
+ function parseFlow(value) {
103
+ if (!value || !FLOW_ID_RE.test(value)) {
104
+ throw new UsageError('--flow needs the flow id from the canvas address bar.', 2)
63
105
  }
64
106
  return value
65
107
  }
66
108
 
109
+ function parseServer(value) {
110
+ let url
111
+ try {
112
+ url = new URL(value)
113
+ } catch {
114
+ throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', 2)
115
+ }
116
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
117
+ throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', 2)
118
+ }
119
+ if (url.username || url.password) {
120
+ throw new UsageError(`Put the API key in CUTTLELY_API_KEY, not in the server URL.`, 2)
121
+ }
122
+ if (url.pathname !== '/' && url.pathname !== '') {
123
+ throw new UsageError('--server is the server origin, for example http://127.0.0.1:43117.', 2)
124
+ }
125
+ return url.origin
126
+ }
127
+
128
+ function parseTimeout(value) {
129
+ if (!value || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 3600) {
130
+ throw new UsageError(`Timeout must be a whole number of seconds from 1 to 3600, got ${value || '(missing)'}.`, 2)
131
+ }
132
+ return Number(value)
133
+ }
134
+
135
+ function parseJsonPath(value) {
136
+ if (!value || value.includes('\0')) throw new UsageError('--json needs a file path.', 2)
137
+ return value
138
+ }
139
+
67
140
  function takeValue(argv, index, flag) {
68
141
  const inline = argv[index].indexOf('=')
69
142
  if (inline !== -1) return { value: argv[index].slice(inline + 1), next: index }
70
143
  const value = argv[index + 1]
71
- if (!value || value.startsWith('-')) throw new UsageError(`${flag} needs a value. Run cuttlely --help.`)
144
+ if (!value || value.startsWith('-')) throw new UsageError(`${flag} needs a value. Run cuttlely --help.`, commandFailureCode(argv))
72
145
  return { value, next: index + 1 }
73
146
  }
74
147
 
@@ -81,37 +154,99 @@ export function parseArgs(argv) {
81
154
  tag: VERSION,
82
155
  envFile: null,
83
156
  open: true,
84
- follow: false
157
+ follow: false,
158
+ flow: null,
159
+ server: null,
160
+ timeoutSeconds: null,
161
+ jsonPath: null,
162
+ failOnChanged: false
85
163
  }
86
164
  const words = []
165
+ const dockerFlags = []
166
+ const checkFlags = []
87
167
  for (let i = 0; i < argv.length; i++) {
88
168
  const arg = argv[i]
89
169
  if (arg === '--help' || arg === '-h') opts.help = true
90
170
  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=')) {
171
+ else if (arg === '--no-open') {
172
+ opts.open = false
173
+ dockerFlags.push('--no-open')
174
+ } else if (arg === '--follow' || arg === '-f') {
175
+ opts.follow = true
176
+ dockerFlags.push(arg === '-f' ? '-f' : '--follow')
177
+ } else if (arg === '--port' || arg === '-p' || arg.startsWith('--port=')) {
94
178
  const taken = takeValue(argv, i, '--port')
95
- opts.port = parsePort(taken.value)
179
+ opts.port = parsePort(taken.value, commandFailureCode(argv))
96
180
  i = taken.next
181
+ dockerFlags.push('--port')
97
182
  } else if (arg === '--tag' || arg.startsWith('--tag=')) {
98
183
  const taken = takeValue(argv, i, '--tag')
99
- opts.tag = parseTag(taken.value)
184
+ opts.tag = parseTag(taken.value, commandFailureCode(argv))
100
185
  i = taken.next
186
+ dockerFlags.push('--tag')
101
187
  } else if (arg === '--env-file' || arg.startsWith('--env-file=')) {
102
188
  const taken = takeValue(argv, i, '--env-file')
103
189
  opts.envFile = taken.value
104
190
  i = taken.next
105
- } else if (arg.startsWith('-')) throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`)
106
- else words.push(arg)
191
+ dockerFlags.push('--env-file')
192
+ } else if (arg === '--flow' || arg.startsWith('--flow=')) {
193
+ const taken = takeValue(argv, i, '--flow')
194
+ opts.flow = taken.value
195
+ i = taken.next
196
+ checkFlags.push('--flow')
197
+ } else if (arg === '--server' || arg.startsWith('--server=')) {
198
+ const taken = takeValue(argv, i, '--server')
199
+ opts.server = taken.value
200
+ i = taken.next
201
+ checkFlags.push('--server')
202
+ } else if (arg === '--timeout' || arg.startsWith('--timeout=')) {
203
+ const taken = takeValue(argv, i, '--timeout')
204
+ opts.timeoutSeconds = taken.value
205
+ i = taken.next
206
+ checkFlags.push('--timeout')
207
+ } else if (arg === '--json' || arg.startsWith('--json=')) {
208
+ const taken = takeValue(argv, i, '--json')
209
+ opts.jsonPath = taken.value
210
+ i = taken.next
211
+ checkFlags.push('--json')
212
+ } else if (arg === '--fail-on-changed') {
213
+ opts.failOnChanged = true
214
+ checkFlags.push('--fail-on-changed')
215
+ } else if (arg.startsWith('-')) {
216
+ throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`, commandFailureCode(argv))
217
+ } else words.push(arg)
107
218
  }
108
- if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help.`)
219
+ if (words.length > 1) throw new UsageError(`Unknown argument ${words[1]}. Run cuttlely --help.`, words[0] === 'check' ? 2 : 1)
109
220
  if (words.length === 1) {
110
- if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help'].includes(words[0])) {
221
+ if (!['start', 'stop', 'status', 'logs', 'upgrade', 'help', 'check'].includes(words[0])) {
111
222
  throw new UsageError(`Unknown command ${words[0]}. Run cuttlely --help.`)
112
223
  }
113
224
  opts.command = words[0]
114
225
  }
226
+ if (opts.command === 'check') {
227
+ const checkExit = 2
228
+ if (dockerFlags.length) {
229
+ throw new UsageError(`${dockerFlags[0]} is not a check option. Run cuttlely check --help.`, checkExit)
230
+ }
231
+ if (!opts.help && !opts.version) {
232
+ if (!opts.flow) throw new UsageError('check needs --flow <id>. Run cuttlely check --help.', checkExit)
233
+ opts.flow = parseFlow(opts.flow)
234
+ if (checkFlags.includes('--server')) {
235
+ if (!opts.server)
236
+ throw new UsageError('--server needs an http or https URL, for example http://127.0.0.1:43117.', checkExit)
237
+ opts.server = parseServer(opts.server)
238
+ }
239
+ if (checkFlags.includes('--timeout')) {
240
+ if (!opts.timeoutSeconds)
241
+ throw new UsageError('Timeout must be a whole number of seconds from 1 to 3600, got (missing).', checkExit)
242
+ opts.timeoutSeconds = parseTimeout(opts.timeoutSeconds)
243
+ }
244
+ // An empty --json= is still the flag. Skipping it would run the check and write nothing.
245
+ if (checkFlags.includes('--json')) opts.jsonPath = parseJsonPath(opts.jsonPath)
246
+ }
247
+ } else if (checkFlags.length) {
248
+ throw new UsageError(`Unknown option ${checkFlags[0]}. Run cuttlely --help.`)
249
+ }
115
250
  return opts
116
251
  }
117
252
 
@@ -407,7 +542,7 @@ export async function main(argv, io = {}) {
407
542
  } catch (error) {
408
543
  if (error instanceof UsageError) {
409
544
  err(error.message)
410
- return 1
545
+ return error.exitCode
411
546
  }
412
547
  throw error
413
548
  }
@@ -424,6 +559,9 @@ export async function main(argv, io = {}) {
424
559
  out,
425
560
  err,
426
561
  docker: io.docker || spawnDocker,
562
+ env: io.env || process.env,
563
+ fetch: io.fetch || globalThis.fetch.bind(globalThis),
564
+ writeFile: io.writeFile,
427
565
  fetchImpl: io.fetch || globalThis.fetch.bind(globalThis),
428
566
  sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
429
567
  open: io.open || openBrowser,
@@ -431,6 +569,18 @@ export async function main(argv, io = {}) {
431
569
  timeoutMs: io.healthTimeoutMs ?? HEALTH_TIMEOUT_MS
432
570
  }
433
571
  try {
572
+ if (opts.command === 'check') {
573
+ return await runCheck(
574
+ {
575
+ flow: opts.flow,
576
+ server: opts.server,
577
+ timeoutSeconds: opts.timeoutSeconds,
578
+ jsonPath: opts.jsonPath,
579
+ failOnChanged: opts.failOnChanged
580
+ },
581
+ ctx
582
+ )
583
+ }
434
584
  if (opts.command === 'start') return await launch(ctx, { restart: false })
435
585
  if (opts.command === 'upgrade') return await launch(ctx, { restart: true })
436
586
  if (opts.command === 'stop') return await stop(ctx)
@@ -439,7 +589,7 @@ export async function main(argv, io = {}) {
439
589
  } catch (error) {
440
590
  if (error instanceof UsageError) {
441
591
  err(error.message)
442
- return 1
592
+ return error.exitCode
443
593
  }
444
594
  throw error
445
595
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuttlely",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Start Cuttlely in Docker. The open-source agent platform for applications.",
5
5
  "type": "module",
6
6
  "bin": {