cuttlely 1.0.1 → 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 +30 -1
- package/bin/cuttlely.js +4 -3
- package/lib/check.js +340 -0
- package/lib/cli.js +168 -18
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
6
|
+
process.exitCode = await main(argv)
|
|
6
7
|
} catch (error) {
|
|
7
8
|
console.error(error instanceof Error ? error.message : String(error))
|
|
8
|
-
process.exitCode =
|
|
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')
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
106
|
-
else
|
|
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
|
|
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
|
|
592
|
+
return error.exitCode
|
|
443
593
|
}
|
|
444
594
|
throw error
|
|
445
595
|
}
|