catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
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 +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import type { ServerWebSocket } from "bun";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
|
|
7
|
+
import { describeEvent } from "../../domain/run-events.ts";
|
|
8
|
+
import { ROLES, type Role } from "../../domain/roles.ts";
|
|
9
|
+
import { openUrl, writeRedirectPage } from "../../infra/open-url.ts";
|
|
10
|
+
import { VERSION } from "../../infra/version.ts";
|
|
11
|
+
import { setRoleRungs, setupView } from "../../services/panel-setup.ts";
|
|
12
|
+
import type { Deps } from "../../services/ports.ts";
|
|
13
|
+
import { answerUnit, cancelRun } from "../../services/run-client.ts";
|
|
14
|
+
import { listRunViews, parkedQuestions, runTimeline, runView, tailEvents } from "../../services/run-read.ts";
|
|
15
|
+
import { readEvents } from "../../services/run-events-store.ts";
|
|
16
|
+
import { findRun } from "../../services/run-store.ts";
|
|
17
|
+
import {
|
|
18
|
+
AnswerBodySchema,
|
|
19
|
+
ErrorBodySchema,
|
|
20
|
+
ListRunsQuerySchema,
|
|
21
|
+
LoginBodySchema,
|
|
22
|
+
OPERATIONS,
|
|
23
|
+
type Operation,
|
|
24
|
+
type OperationId,
|
|
25
|
+
QuestionsQuerySchema,
|
|
26
|
+
RungsBodySchema,
|
|
27
|
+
} from "./openapi.ts";
|
|
28
|
+
|
|
29
|
+
export const PANEL_COOKIE = "catherd_panel";
|
|
30
|
+
|
|
31
|
+
export interface PanelOptions {
|
|
32
|
+
deps: Deps;
|
|
33
|
+
repo: string | null;
|
|
34
|
+
token: string;
|
|
35
|
+
port: number;
|
|
36
|
+
distDir: string;
|
|
37
|
+
open: boolean;
|
|
38
|
+
/** What opens the sign-in page: a test replaces it. */
|
|
39
|
+
opener?: (target: string) => void;
|
|
40
|
+
pollMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface Panel {
|
|
43
|
+
url: string;
|
|
44
|
+
port: number;
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 32 random bytes, base64url: the token a `catherd panel` run accepts on its login page. */
|
|
49
|
+
export const newToken = (): string => randomBytes(32).toString("base64url");
|
|
50
|
+
|
|
51
|
+
const SECURITY_HEADERS = {
|
|
52
|
+
"Content-Security-Policy":
|
|
53
|
+
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
|
|
54
|
+
"X-Frame-Options": "DENY",
|
|
55
|
+
"Referrer-Policy": "no-referrer",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const STATUS: Record<string, number> = {
|
|
59
|
+
E_INPUT_INVALID: 400,
|
|
60
|
+
unauthorized: 401,
|
|
61
|
+
forbidden: 403,
|
|
62
|
+
E_RUN_NOT_FOUND: 404,
|
|
63
|
+
not_found: 404,
|
|
64
|
+
E_RUN_NOT_LIVE: 409,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const errorResponse = (code: string, message: string, fix?: string): Response =>
|
|
68
|
+
Response.json(
|
|
69
|
+
ErrorBodySchema.parse({
|
|
70
|
+
error: {
|
|
71
|
+
message,
|
|
72
|
+
type: code,
|
|
73
|
+
code,
|
|
74
|
+
...(fix === undefined ? {} : { fix }),
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
{ status: STATUS[code] ?? 500 },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const digest = (s: string): Buffer => createHash("sha256").update(s).digest();
|
|
81
|
+
const sameToken = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
|
|
82
|
+
|
|
83
|
+
const cookieOf = (req: Request): string | undefined =>
|
|
84
|
+
req.headers
|
|
85
|
+
.get("cookie")
|
|
86
|
+
?.split(";")
|
|
87
|
+
.map((c) => c.trim().split("="))
|
|
88
|
+
.find(([k]) => k === PANEL_COOKIE)?.[1];
|
|
89
|
+
|
|
90
|
+
/** The first problem of a request, as the 400's message: the field's path, then what is wrong with it. */
|
|
91
|
+
const issueText = (e: z.ZodError): string => {
|
|
92
|
+
const i = e.issues[0];
|
|
93
|
+
return i ? `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}` : "invalid request";
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
interface Call {
|
|
97
|
+
deps: Deps;
|
|
98
|
+
repo: string | null;
|
|
99
|
+
params: Record<string, string>;
|
|
100
|
+
query: unknown;
|
|
101
|
+
body: unknown;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const page = <T>(all: T[], q: { limit: number; offset: number }) => ({
|
|
105
|
+
items: all.slice(q.offset, q.offset + q.limit),
|
|
106
|
+
total: all.length,
|
|
107
|
+
});
|
|
108
|
+
const has = (hay: string[], needle: string | undefined): boolean =>
|
|
109
|
+
needle === undefined || hay.some((h) => h.toLowerCase().includes(needle.toLowerCase()));
|
|
110
|
+
|
|
111
|
+
const HANDLERS: Record<OperationId, (c: Call) => unknown> = {
|
|
112
|
+
login: () => null,
|
|
113
|
+
logout: () => null,
|
|
114
|
+
getSession: () => ({ authenticated: true }),
|
|
115
|
+
getVersion: () => ({ catherd: VERSION }),
|
|
116
|
+
getSetup: (c) => setupView(c.deps, c.repo),
|
|
117
|
+
setRoleRungs: (c) => {
|
|
118
|
+
const role = c.params.role as Role;
|
|
119
|
+
if (!ROLES.includes(role))
|
|
120
|
+
throw new CatherdError("E_INPUT_INVALID", `no role named "${role}"`, {
|
|
121
|
+
fix: `one of ${ROLES.join(", ")}`,
|
|
122
|
+
});
|
|
123
|
+
return setRoleRungs(c.deps, c.repo, role, (c.body as z.infer<typeof RungsBodySchema>).rungs);
|
|
124
|
+
},
|
|
125
|
+
// shortcut: reads every run's view per list call, upgrade when a home holds thousands of runs
|
|
126
|
+
listRuns: (c) => {
|
|
127
|
+
const q = c.query as z.infer<typeof ListRunsQuerySchema>;
|
|
128
|
+
const all = listRunViews(c.deps, { limit: 100_000 }).filter(
|
|
129
|
+
(r) => (q.phase === undefined || r.phase === q.phase) && has([r.title, r.id, r.repo], q.q),
|
|
130
|
+
);
|
|
131
|
+
return page(all, q);
|
|
132
|
+
},
|
|
133
|
+
getRun: (c) => runView(c.deps, c.params.id as string),
|
|
134
|
+
// shortcut: reads every event per page, upgrade past ~50k events
|
|
135
|
+
getRunTimeline: (c) => {
|
|
136
|
+
const id = c.params.id as string;
|
|
137
|
+
if (runView(c.deps, id).engine === "1.5") return { items: [], total: 0 };
|
|
138
|
+
return page(
|
|
139
|
+
runTimeline(id, 1, Number.MAX_SAFE_INTEGER).toReversed(),
|
|
140
|
+
c.query as { limit: number; offset: number },
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
cancelRun: (c) => cancelRun(c.deps, { run: c.params.id as string }),
|
|
144
|
+
answerUnit: (c) =>
|
|
145
|
+
answerUnit(c.deps, {
|
|
146
|
+
run: c.params.id as string,
|
|
147
|
+
...(c.body as z.infer<typeof AnswerBodySchema>),
|
|
148
|
+
}),
|
|
149
|
+
listQuestions: (c) => {
|
|
150
|
+
const q = c.query as z.infer<typeof QuestionsQuerySchema>;
|
|
151
|
+
return page(
|
|
152
|
+
parkedQuestions({ run: q.run }).filter((p) => has([p.question, p.unit, p.runTitle], q.q)),
|
|
153
|
+
q,
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const compile = (op: Operation): RegExp => new RegExp(`^${op.path.replace(/\{(\w+)\}/g, "(?<$1>[^/]+)")}$`);
|
|
159
|
+
const ROUTES = OPERATIONS.map((op) => ({ op, re: compile(op) }));
|
|
160
|
+
const LIVE = /^\/api\/runs\/([^/]+)\/live$/;
|
|
161
|
+
|
|
162
|
+
/** The Host this panel answers to, or null. A browser omits the default port, so port 80 also takes the bare name. */
|
|
163
|
+
export const panelHost = (header: string | null, port: number): string | null => {
|
|
164
|
+
const names = ["127.0.0.1", "localhost"];
|
|
165
|
+
const ok = names.flatMap((n) => (port === 80 ? [n, `${n}:80`] : [`${n}:${port}`]));
|
|
166
|
+
return header !== null && ok.includes(header) ? header : null;
|
|
167
|
+
};
|
|
168
|
+
/** A request without an Origin passes; one with it must come from the page this Host serves. */
|
|
169
|
+
export const panelOriginOk = (origin: string | null, host: string): boolean =>
|
|
170
|
+
origin === null || origin === `http://${host}`;
|
|
171
|
+
|
|
172
|
+
interface SocketData {
|
|
173
|
+
run: string;
|
|
174
|
+
from: number;
|
|
175
|
+
session: string;
|
|
176
|
+
stop?: () => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One Bun process on 127.0.0.1: the built files, the JSON API and a socket per run tailing its events. */
|
|
180
|
+
export function servePanel(o: PanelOptions): Panel {
|
|
181
|
+
const sessions = new Set<string>();
|
|
182
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
183
|
+
/** Ends a session and the run sockets it opened: a socket outlives its cookie otherwise. */
|
|
184
|
+
const revoke = (session: string): void => {
|
|
185
|
+
sessions.delete(session);
|
|
186
|
+
for (const ws of sockets) if (ws.data.session === session) ws.close(4401, "signed out");
|
|
187
|
+
};
|
|
188
|
+
const dist = resolve(o.distDir);
|
|
189
|
+
let origin = "";
|
|
190
|
+
let signInPage: ReturnType<typeof writeRedirectPage> | undefined;
|
|
191
|
+
const dropSignInPage = (): void => {
|
|
192
|
+
signInPage?.remove();
|
|
193
|
+
signInPage = undefined;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const hostOk = (req: Request): string | null => panelHost(req.headers.get("host"), server.port as number);
|
|
197
|
+
const originOk = (req: Request, host: string): boolean => panelOriginOk(req.headers.get("origin"), host);
|
|
198
|
+
const signedIn = (req: Request): boolean => {
|
|
199
|
+
const c = cookieOf(req);
|
|
200
|
+
return c !== undefined && sessions.has(c);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const staticFile = (req: Request, rawPath: string): Response => {
|
|
204
|
+
let path: string;
|
|
205
|
+
try {
|
|
206
|
+
path = decodeURIComponent(rawPath);
|
|
207
|
+
} catch {
|
|
208
|
+
return errorResponse("not_found", "not found");
|
|
209
|
+
}
|
|
210
|
+
const file = resolve(dist, `.${path}`);
|
|
211
|
+
if (file !== dist && !file.startsWith(dist + sep)) return errorResponse("not_found", "not found");
|
|
212
|
+
const served = existsSync(file) && statSync(file).isFile() ? file : join(dist, "index.html");
|
|
213
|
+
const accepts = req.headers.get("accept-encoding") ?? "";
|
|
214
|
+
for (const [enc, ext] of [
|
|
215
|
+
["br", ".br"],
|
|
216
|
+
["gzip", ".gz"],
|
|
217
|
+
] as const)
|
|
218
|
+
if (new RegExp(`\\b${enc}\\b`).test(accepts) && existsSync(served + ext))
|
|
219
|
+
return new Response(Bun.file(served + ext), {
|
|
220
|
+
headers: {
|
|
221
|
+
...cache(served),
|
|
222
|
+
"Content-Type": Bun.file(served).type,
|
|
223
|
+
"Content-Encoding": enc,
|
|
224
|
+
Vary: "Accept-Encoding",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
return new Response(Bun.file(served), {
|
|
228
|
+
headers: { ...cache(served), Vary: "Accept-Encoding" },
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
const cache = (file: string): Record<string, string> => ({
|
|
232
|
+
"Cache-Control": file.startsWith(join(dist, "assets") + sep)
|
|
233
|
+
? "public, max-age=31536000, immutable"
|
|
234
|
+
: "no-cache",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const api = async (req: Request, url: URL, host: string): Promise<Response> => {
|
|
238
|
+
const hit = ROUTES.map((r) => ({ ...r, m: r.re.exec(url.pathname) })).find(
|
|
239
|
+
(r) => r.m && r.op.method === req.method,
|
|
240
|
+
);
|
|
241
|
+
if (!hit?.m) return errorResponse("not_found", "not found");
|
|
242
|
+
const { op } = hit;
|
|
243
|
+
if (!op.public && !signedIn(req)) return errorResponse("unauthorized", "sign in first");
|
|
244
|
+
if (op.method !== "GET" && !originOk(req, host))
|
|
245
|
+
return errorResponse("forbidden", "cross-origin request refused");
|
|
246
|
+
let params: Record<string, string>;
|
|
247
|
+
try {
|
|
248
|
+
params = Object.fromEntries(
|
|
249
|
+
Object.entries(hit.m.groups ?? {}).map(([k, v]) => [k, decodeURIComponent(v)]),
|
|
250
|
+
);
|
|
251
|
+
} catch {
|
|
252
|
+
return errorResponse("E_INPUT_INVALID", "the request path has a malformed escape");
|
|
253
|
+
}
|
|
254
|
+
let query: unknown;
|
|
255
|
+
let body: unknown;
|
|
256
|
+
if (op.query) {
|
|
257
|
+
const r = op.query.safeParse(Object.fromEntries(url.searchParams));
|
|
258
|
+
if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
|
|
259
|
+
query = r.data;
|
|
260
|
+
}
|
|
261
|
+
if (op.body) {
|
|
262
|
+
let raw: unknown;
|
|
263
|
+
try {
|
|
264
|
+
raw = await req.json();
|
|
265
|
+
} catch {
|
|
266
|
+
return errorResponse("E_INPUT_INVALID", "the request body is not valid JSON");
|
|
267
|
+
}
|
|
268
|
+
const r = op.body.safeParse(raw);
|
|
269
|
+
if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
|
|
270
|
+
body = r.data;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
if (op.id === "login") {
|
|
274
|
+
if (!sameToken((body as z.infer<typeof LoginBodySchema>).token, o.token))
|
|
275
|
+
return errorResponse("unauthorized", "wrong token");
|
|
276
|
+
const session = randomBytes(32).toString("base64url");
|
|
277
|
+
sessions.add(session);
|
|
278
|
+
dropSignInPage();
|
|
279
|
+
return new Response(null, {
|
|
280
|
+
status: 204,
|
|
281
|
+
headers: {
|
|
282
|
+
"Set-Cookie": `${PANEL_COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (op.id === "logout") {
|
|
287
|
+
const c = cookieOf(req);
|
|
288
|
+
if (c) revoke(c);
|
|
289
|
+
return new Response(null, {
|
|
290
|
+
status: 204,
|
|
291
|
+
headers: {
|
|
292
|
+
"Set-Cookie": `${PANEL_COOKIE}=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/`,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const out = await HANDLERS[op.id]({
|
|
297
|
+
deps: o.deps,
|
|
298
|
+
repo: o.repo,
|
|
299
|
+
params,
|
|
300
|
+
query,
|
|
301
|
+
body,
|
|
302
|
+
});
|
|
303
|
+
return op.response === null
|
|
304
|
+
? new Response(null, { status: 204 })
|
|
305
|
+
: Response.json(op.response.parse(out));
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (isCatherdError(e)) return errorResponse(e.code, e.message, e.fix);
|
|
308
|
+
return errorResponse("internal", errorMessage(e));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const fetch = async (req: Request, srv: Bun.Server<SocketData>): Promise<Response | undefined> => {
|
|
313
|
+
const host = hostOk(req);
|
|
314
|
+
if (host === null) return withHeaders(errorResponse("forbidden", "this panel answers on 127.0.0.1 only"));
|
|
315
|
+
// the raw target, not URL's normalised one: a `%2e%2e` segment must reach the path check
|
|
316
|
+
const target = req.url.replace(/^[a-z]+:\/\/[^/]+/i, "");
|
|
317
|
+
const [rawPath = "/"] = target.split("?");
|
|
318
|
+
const url = new URL(req.url);
|
|
319
|
+
const live = LIVE.exec(url.pathname);
|
|
320
|
+
if (live && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
321
|
+
const session = cookieOf(req);
|
|
322
|
+
if (session === undefined || !sessions.has(session))
|
|
323
|
+
return withHeaders(errorResponse("unauthorized", "sign in first"));
|
|
324
|
+
if (!originOk(req, host)) return withHeaders(errorResponse("forbidden", "cross-origin socket refused"));
|
|
325
|
+
let id: string;
|
|
326
|
+
try {
|
|
327
|
+
id = decodeURIComponent(live[1] as string);
|
|
328
|
+
} catch {
|
|
329
|
+
return withHeaders(errorResponse("E_INPUT_INVALID", "the request path has a malformed escape"));
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
findRun(id);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
return withHeaders(
|
|
335
|
+
isCatherdError(e)
|
|
336
|
+
? errorResponse(e.code, e.message, e.fix)
|
|
337
|
+
: errorResponse("internal", errorMessage(e)),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
// no `from`: only what is appended from now on (the page loads the history over HTTP)
|
|
341
|
+
const asked = url.searchParams.get("from");
|
|
342
|
+
const from =
|
|
343
|
+
asked === null
|
|
344
|
+
? (readEvents(findRun(id)).at(-1)?.seq ?? 0) + 1
|
|
345
|
+
: Math.max(1, Number.parseInt(asked, 10) || 1);
|
|
346
|
+
if (srv.upgrade(req, { data: { run: id, from, session } })) return undefined;
|
|
347
|
+
return withHeaders(errorResponse("E_INPUT_INVALID", "not a websocket upgrade"));
|
|
348
|
+
}
|
|
349
|
+
if (url.pathname.startsWith("/api/")) return withHeaders(await api(req, url, host));
|
|
350
|
+
return withHeaders(staticFile(req, rawPath));
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const withHeaders = (r: Response): Response => {
|
|
354
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) r.headers.set(k, v);
|
|
355
|
+
return r;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
let server: Bun.Server<SocketData>;
|
|
359
|
+
try {
|
|
360
|
+
server = Bun.serve<SocketData>({
|
|
361
|
+
hostname: "127.0.0.1",
|
|
362
|
+
port: o.port,
|
|
363
|
+
fetch,
|
|
364
|
+
websocket: {
|
|
365
|
+
open(ws: ServerWebSocket<SocketData>) {
|
|
366
|
+
sockets.add(ws);
|
|
367
|
+
ws.data.stop = tailEvents(
|
|
368
|
+
findRun(ws.data.run),
|
|
369
|
+
ws.data.from,
|
|
370
|
+
(e) =>
|
|
371
|
+
ws.send(
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
seq: e.seq,
|
|
374
|
+
at: e.at,
|
|
375
|
+
type: e.type,
|
|
376
|
+
text: describeEvent(e),
|
|
377
|
+
}),
|
|
378
|
+
),
|
|
379
|
+
{ pollMs: o.pollMs },
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
close(ws: ServerWebSocket<SocketData>) {
|
|
383
|
+
sockets.delete(ws);
|
|
384
|
+
ws.data.stop?.();
|
|
385
|
+
},
|
|
386
|
+
// a `ping` frame keeps the tab's socket warm and means nothing
|
|
387
|
+
message() {},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
} catch (e) {
|
|
391
|
+
if (/in use|EADDRINUSE/i.test(errorMessage(e)))
|
|
392
|
+
throw new CatherdError("E_INPUT_INVALID", `port ${o.port} is in use`, {
|
|
393
|
+
fix: "catherd panel --port 0 (any free port)",
|
|
394
|
+
});
|
|
395
|
+
throw e;
|
|
396
|
+
}
|
|
397
|
+
origin = `http://127.0.0.1:${server.port}`;
|
|
398
|
+
if (o.open) {
|
|
399
|
+
signInPage = writeRedirectPage(`${origin}/login#token=${o.token}`);
|
|
400
|
+
(o.opener ?? openUrl)(signInPage.path);
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
url: origin,
|
|
404
|
+
port: server.port as number,
|
|
405
|
+
stop: async () => {
|
|
406
|
+
dropSignInPage();
|
|
407
|
+
await server.stop(true);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
6
|
+
import { defaultDeps } from "./deps.ts";
|
|
7
|
+
import { refuseInRole } from "./cli-kit.ts";
|
|
8
|
+
import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
9
|
+
import { type Panel, newToken, servePanel } from "./panel/server.ts";
|
|
10
|
+
|
|
11
|
+
/** The seams a test replaces: the server, and what keeps `catherd panel` alive until Ctrl-C. */
|
|
12
|
+
export const panelServer = {
|
|
13
|
+
serve: servePanel as (o: Parameters<typeof servePanel>[0]) => Panel,
|
|
14
|
+
untilStopped: (): Promise<void> =>
|
|
15
|
+
new Promise((done) => {
|
|
16
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) process.once(sig, () => done());
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The built panel: `CATHERD_PANEL_DIST`, else the `web/dist` the package ships. */
|
|
21
|
+
export const panelDist = (): string =>
|
|
22
|
+
process.env.CATHERD_PANEL_DIST ?? join(import.meta.dir, "../../web/dist");
|
|
23
|
+
|
|
24
|
+
/** `catherd panel`'s run, and a bare `catherd`'s: serves the panel until Ctrl-C. */
|
|
25
|
+
export async function runPanel(o: { port: number; open: boolean; host?: string }): Promise<void> {
|
|
26
|
+
refuseInRole(process.env, "catherd panel");
|
|
27
|
+
const distDir = panelDist();
|
|
28
|
+
if (!existsSync(join(distDir, "index.html")))
|
|
29
|
+
throw new CatherdError("E_CONFIG_INVALID", "the panel is not built", { fix: "bun run panel:build" });
|
|
30
|
+
const token = newToken();
|
|
31
|
+
const panel = panelServer.serve({
|
|
32
|
+
deps: defaultDeps(terminalHost(o.host)),
|
|
33
|
+
repo: await gitToplevel(process.cwd()),
|
|
34
|
+
token,
|
|
35
|
+
port: o.port,
|
|
36
|
+
distDir,
|
|
37
|
+
open: o.open,
|
|
38
|
+
});
|
|
39
|
+
console.log(`catherd panel: ${panel.url}/login#token=${token}`);
|
|
40
|
+
console.log("Ctrl-C stops it.");
|
|
41
|
+
await panelServer.untilStopped();
|
|
42
|
+
await panel.stop();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `catherd panel [--port <n>] [--no-open]`: Setup, Runs and Questions in your browser, on 127.0.0.1 only. */
|
|
46
|
+
export const panelCommand = defineCommand({
|
|
47
|
+
meta: {
|
|
48
|
+
name: "panel",
|
|
49
|
+
description: "Serve the panel on 127.0.0.1 and open it in your browser (a bare catherd does the same)",
|
|
50
|
+
},
|
|
51
|
+
args: {
|
|
52
|
+
port: { type: "string", default: "4317", description: "the port (0 picks a free one)" },
|
|
53
|
+
open: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
default: true,
|
|
56
|
+
description: "open the browser (--no-open prints the link only)",
|
|
57
|
+
},
|
|
58
|
+
...HOST_ARG,
|
|
59
|
+
},
|
|
60
|
+
async run({ args }) {
|
|
61
|
+
const port = Number(args.port);
|
|
62
|
+
if (!/^\d+$/.test(args.port) || port > 65535)
|
|
63
|
+
throw new CatherdError("E_INPUT_INVALID", `--port takes a number from 0 to 65535, not "${args.port}"`, {
|
|
64
|
+
fix: "catherd panel --port 4317",
|
|
65
|
+
});
|
|
66
|
+
await runPanel({ port, open: args.open, host: args.host });
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import {
|
|
4
|
+
pauseLine,
|
|
5
|
+
pauseMachine,
|
|
6
|
+
pauseWorkspace,
|
|
7
|
+
resumeMachine,
|
|
8
|
+
resumeWorkspace,
|
|
9
|
+
} from "../services/pause.ts";
|
|
10
|
+
import { JSON_ARG, printJson, refuseInRole } from "./cli-kit.ts";
|
|
11
|
+
|
|
12
|
+
/** Exactly one of --machine and --workspace: what a pause or resume covers. */
|
|
13
|
+
function scope(args: { machine?: boolean; workspace?: string }, usage: string): "machine" | string {
|
|
14
|
+
if (args.machine === true && args.workspace === undefined) return "machine";
|
|
15
|
+
if (args.machine !== true && args.workspace) return args.workspace;
|
|
16
|
+
throw new CatherdError(
|
|
17
|
+
"E_INPUT_INVALID",
|
|
18
|
+
"name what to pause: --machine or --workspace <id>, one of them",
|
|
19
|
+
{
|
|
20
|
+
fix: usage,
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SCOPE_ARGS = {
|
|
26
|
+
machine: { type: "boolean", description: "every run on this machine" },
|
|
27
|
+
workspace: { type: "string", description: "every child of this workspace" },
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const PAUSE_USAGE = 'catherd pause --machine "the VPN takes the default route"';
|
|
31
|
+
const RESUME_USAGE = "catherd resume --machine";
|
|
32
|
+
|
|
33
|
+
/** Spec 1.5 "Group pause": one blocker, one pause, instead of a park per run and milestone. */
|
|
34
|
+
export const pauseCommand = defineCommand({
|
|
35
|
+
meta: {
|
|
36
|
+
name: "pause",
|
|
37
|
+
description:
|
|
38
|
+
"Pause every run on this machine (--machine) or in a workspace: dispatches are refused until resume",
|
|
39
|
+
},
|
|
40
|
+
args: {
|
|
41
|
+
reason: { type: "positional", required: true, description: "why: what blocks the runs" },
|
|
42
|
+
...SCOPE_ARGS,
|
|
43
|
+
...JSON_ARG,
|
|
44
|
+
},
|
|
45
|
+
run({ args }) {
|
|
46
|
+
refuseInRole(process.env, "catherd pause");
|
|
47
|
+
const at = scope(args, PAUSE_USAGE);
|
|
48
|
+
const r =
|
|
49
|
+
at === "machine"
|
|
50
|
+
? pauseMachine(Date.now(), args.reason)
|
|
51
|
+
: pauseWorkspace(Date.now(), { workspace: at, reason: args.reason });
|
|
52
|
+
if (args.json) return printJson(r);
|
|
53
|
+
console.log(pauseLine(r.pause));
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const resumeCommand = defineCommand({
|
|
58
|
+
meta: { name: "resume", description: "Lift a pause on this machine (--machine) or on a workspace" },
|
|
59
|
+
args: { ...SCOPE_ARGS, ...JSON_ARG },
|
|
60
|
+
run({ args }) {
|
|
61
|
+
refuseInRole(process.env, "catherd resume");
|
|
62
|
+
const at = scope(args, RESUME_USAGE);
|
|
63
|
+
const r = at === "machine" ? resumeMachine(Date.now()) : resumeWorkspace(Date.now(), { workspace: at });
|
|
64
|
+
if (args.json) return printJson(r);
|
|
65
|
+
console.log(r.resumed ? `resumed: ${pauseLine(r.resumed)}` : "nothing was paused");
|
|
66
|
+
},
|
|
67
|
+
});
|