tmux-ide 1.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { validateConfig } from "./validate.ts";
|
|
3
|
+
|
|
4
|
+
describe("validateConfig", () => {
|
|
5
|
+
it("accepts a valid minimal config", () => {
|
|
6
|
+
const errors = validateConfig({ rows: [{ panes: [{}] }] });
|
|
7
|
+
expect(errors).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("accepts a valid full config", () => {
|
|
11
|
+
const errors = validateConfig({
|
|
12
|
+
name: "my-project",
|
|
13
|
+
before: "pnpm install",
|
|
14
|
+
rows: [
|
|
15
|
+
{
|
|
16
|
+
size: "70%",
|
|
17
|
+
panes: [
|
|
18
|
+
{
|
|
19
|
+
title: "Editor",
|
|
20
|
+
command: "vim",
|
|
21
|
+
dir: "src",
|
|
22
|
+
size: "60%",
|
|
23
|
+
focus: true,
|
|
24
|
+
env: { PORT: 3000, HOST: "localhost" },
|
|
25
|
+
},
|
|
26
|
+
{ title: "Shell" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
panes: [{ title: "Dev", command: "pnpm dev" }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
theme: { accent: "colour75", border: "colour238", bg: "colour235", fg: "colour248" },
|
|
34
|
+
});
|
|
35
|
+
expect(errors).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects null config", () => {
|
|
39
|
+
const errors = validateConfig(null);
|
|
40
|
+
expect(errors).toEqual(["config must be an object"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects string config", () => {
|
|
44
|
+
const errors = validateConfig("hello");
|
|
45
|
+
expect(errors).toEqual(["config must be an object"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects array config", () => {
|
|
49
|
+
const errors = validateConfig([]);
|
|
50
|
+
expect(errors).toEqual(["config must be an object"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("requires rows to be an array", () => {
|
|
54
|
+
const errors = validateConfig({ rows: "nope" });
|
|
55
|
+
expect(errors.includes("'rows' must be an array")).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("requires rows to be non-empty", () => {
|
|
59
|
+
const errors = validateConfig({ rows: [] });
|
|
60
|
+
expect(errors.includes("'rows' must not be empty")).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("requires rows to have a panes array", () => {
|
|
64
|
+
const errors = validateConfig({ rows: [{}] });
|
|
65
|
+
expect(errors.includes("rows[0].panes must be an array")).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("requires panes to be non-empty", () => {
|
|
69
|
+
const errors = validateConfig({ rows: [{ panes: [] }] });
|
|
70
|
+
expect(errors.includes("rows[0].panes must not be empty")).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects non-string name", () => {
|
|
74
|
+
const errors = validateConfig({ name: 123, rows: [{ panes: [{}] }] });
|
|
75
|
+
expect(errors.includes("'name' must be a string")).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects non-string before", () => {
|
|
79
|
+
const errors = validateConfig({ before: true, rows: [{ panes: [{}] }] });
|
|
80
|
+
expect(errors.includes("'before' must be a string")).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects non-string pane.title", () => {
|
|
84
|
+
const errors = validateConfig({ rows: [{ panes: [{ title: 42 }] }] });
|
|
85
|
+
expect(errors.includes("rows[0].panes[0].title must be a string")).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects non-string pane.command", () => {
|
|
89
|
+
const errors = validateConfig({ rows: [{ panes: [{ command: false }] }] });
|
|
90
|
+
expect(errors.includes("rows[0].panes[0].command must be a string")).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects non-string pane.dir", () => {
|
|
94
|
+
const errors = validateConfig({ rows: [{ panes: [{ dir: [] }] }] });
|
|
95
|
+
expect(errors.includes("rows[0].panes[0].dir must be a string")).toBeTruthy();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("rejects non-boolean pane.focus", () => {
|
|
99
|
+
const errors = validateConfig({ rows: [{ panes: [{ focus: "yes" }] }] });
|
|
100
|
+
expect(errors.includes("rows[0].panes[0].focus must be a boolean")).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rejects non-object pane.env", () => {
|
|
104
|
+
const errors = validateConfig({ rows: [{ panes: [{ env: "PORT=3000" }] }] });
|
|
105
|
+
expect(errors.includes("rows[0].panes[0].env must be an object")).toBeTruthy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects invalid env values", () => {
|
|
109
|
+
const errors = validateConfig({ rows: [{ panes: [{ env: { PORT: true } }] }] });
|
|
110
|
+
expect(errors.includes("rows[0].panes[0].env.PORT must be a string or number")).toBeTruthy();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("rejects size without % suffix", () => {
|
|
114
|
+
const errors = validateConfig({ rows: [{ size: "70", panes: [{}] }] });
|
|
115
|
+
expect(errors.some((e) => e.includes("must be a percentage"))).toBeTruthy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects 0% size", () => {
|
|
119
|
+
const errors = validateConfig({ rows: [{ size: "0%", panes: [{}] }] });
|
|
120
|
+
expect(errors.some((e) => e.includes("must be a percentage"))).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects >100% size", () => {
|
|
124
|
+
const errors = validateConfig({ rows: [{ panes: [{ size: "150%" }] }] });
|
|
125
|
+
expect(errors.some((e) => e.includes("must not exceed 100%"))).toBeTruthy();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("validates theme fields as strings", () => {
|
|
129
|
+
const errors = validateConfig({ rows: [{ panes: [{}] }], theme: { accent: 123 } });
|
|
130
|
+
expect(errors.includes("theme.accent must be a string")).toBeTruthy();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("accepts team metadata without requiring a lead pane", () => {
|
|
134
|
+
const errors = validateConfig({
|
|
135
|
+
team: { name: "my-team" },
|
|
136
|
+
rows: [
|
|
137
|
+
{
|
|
138
|
+
panes: [{ title: "Claude", command: "claude" }, { title: "Shell" }],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
expect(errors).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("validates team pane role and task field types when provided", () => {
|
|
146
|
+
const errors = validateConfig({
|
|
147
|
+
team: { name: "my-team" },
|
|
148
|
+
rows: [
|
|
149
|
+
{
|
|
150
|
+
panes: [
|
|
151
|
+
{ command: "claude", role: "manager", task: false },
|
|
152
|
+
{ command: "claude", role: "teammate", task: "Review changes" },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
expect(
|
|
158
|
+
errors.includes('rows[0].panes[0].role must be "lead", "teammate", or "planner"'),
|
|
159
|
+
).toBeTruthy();
|
|
160
|
+
expect(errors.includes("rows[0].panes[0].task must be a string")).toBeTruthy();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("rejects non-object theme", () => {
|
|
164
|
+
const errors = validateConfig({ rows: [{ panes: [{}] }], theme: "blue" });
|
|
165
|
+
expect(errors.includes("'theme' must be an object")).toBeTruthy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects leading zeros in size (00%, 007%)", () => {
|
|
169
|
+
const errors = validateConfig({ rows: [{ size: "007%", panes: [{ size: "00%" }] }] });
|
|
170
|
+
expect(
|
|
171
|
+
errors.some((e) => e.includes('rows[0].size "007%"') && e.includes("percentage")),
|
|
172
|
+
).toBeTruthy();
|
|
173
|
+
expect(
|
|
174
|
+
errors.some((e) => e.includes('rows[0].panes[0].size "00%"') && e.includes("percentage")),
|
|
175
|
+
).toBeTruthy();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("rejects row sizes summing over 100%", () => {
|
|
179
|
+
const errors = validateConfig({
|
|
180
|
+
rows: [
|
|
181
|
+
{ size: "70%", panes: [{}] },
|
|
182
|
+
{ size: "40%", panes: [{}] },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
expect(errors.some((e) => e.includes("Row sizes sum to 110%"))).toBeTruthy();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("accepts row sizes summing to exactly 100%", () => {
|
|
189
|
+
const errors = validateConfig({
|
|
190
|
+
rows: [
|
|
191
|
+
{ size: "70%", panes: [{}] },
|
|
192
|
+
{ size: "30%", panes: [{}] },
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
expect(errors).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("rejects multiple focus: true in one row", () => {
|
|
199
|
+
const errors = validateConfig({
|
|
200
|
+
rows: [{ panes: [{ focus: true }, { focus: true }] }],
|
|
201
|
+
});
|
|
202
|
+
expect(errors.some((e) => e.includes("Row 0 has 2 panes with focus: true"))).toBeTruthy();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("accepts single focus: true per row", () => {
|
|
206
|
+
const errors = validateConfig({
|
|
207
|
+
rows: [{ panes: [{ focus: true }, {}] }, { panes: [{}, { focus: true }] }],
|
|
208
|
+
});
|
|
209
|
+
expect(errors).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("rejects pane sizes summing over 100% in a row", () => {
|
|
213
|
+
const errors = validateConfig({
|
|
214
|
+
rows: [{ panes: [{ size: "60%" }, { size: "50%" }] }],
|
|
215
|
+
});
|
|
216
|
+
expect(errors.some((e) => e.includes("Row 0 pane sizes sum to 110%"))).toBeTruthy();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("accepts pane sizes summing to exactly 100%", () => {
|
|
220
|
+
const errors = validateConfig({
|
|
221
|
+
rows: [{ panes: [{ size: "60%" }, { size: "40%" }] }],
|
|
222
|
+
});
|
|
223
|
+
expect(errors).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("accepts type: explorer pane", () => {
|
|
227
|
+
const errors = validateConfig({
|
|
228
|
+
rows: [{ panes: [{ type: "explorer", title: "Files" }] }],
|
|
229
|
+
});
|
|
230
|
+
expect(errors).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("accepts type: changes pane", () => {
|
|
234
|
+
const errors = validateConfig({
|
|
235
|
+
rows: [{ panes: [{ type: "changes", title: "Changes" }] }],
|
|
236
|
+
});
|
|
237
|
+
expect(errors).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("accepts type: preview pane", () => {
|
|
241
|
+
const errors = validateConfig({
|
|
242
|
+
rows: [{ panes: [{ type: "preview", title: "Preview" }] }],
|
|
243
|
+
});
|
|
244
|
+
expect(errors).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("accepts type: tasks pane", () => {
|
|
248
|
+
const errors = validateConfig({
|
|
249
|
+
rows: [{ panes: [{ type: "tasks", title: "Tasks" }] }],
|
|
250
|
+
});
|
|
251
|
+
expect(errors).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("accepts type: mission-control pane", () => {
|
|
255
|
+
const errors = validateConfig({
|
|
256
|
+
rows: [{ panes: [{ type: "mission-control", title: "Mission Control" }] }],
|
|
257
|
+
});
|
|
258
|
+
expect(errors).toEqual([]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("accepts type: costs pane", () => {
|
|
262
|
+
const errors = validateConfig({
|
|
263
|
+
rows: [{ panes: [{ type: "costs", title: "Costs" }] }],
|
|
264
|
+
});
|
|
265
|
+
expect(errors).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("rejects invalid type value", () => {
|
|
269
|
+
const errors = validateConfig({
|
|
270
|
+
rows: [{ panes: [{ type: "invalid" }] }],
|
|
271
|
+
});
|
|
272
|
+
expect(errors.some((e) => e.includes("rows[0].panes[0].type must be one of:"))).toBeTruthy();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("rejects type and command together", () => {
|
|
276
|
+
const errors = validateConfig({
|
|
277
|
+
rows: [{ panes: [{ type: "explorer", command: "vim" }] }],
|
|
278
|
+
});
|
|
279
|
+
expect(errors.includes("rows[0].panes[0] cannot have both 'type' and 'command'")).toBeTruthy();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("rejects non-string target", () => {
|
|
283
|
+
const errors = validateConfig({
|
|
284
|
+
rows: [{ panes: [{ target: 42 }] }],
|
|
285
|
+
});
|
|
286
|
+
expect(errors.includes("rows[0].panes[0].target must be a string")).toBeTruthy();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("accepts string target", () => {
|
|
290
|
+
const errors = validateConfig({
|
|
291
|
+
rows: [{ panes: [{ type: "explorer", target: "Claude" }] }],
|
|
292
|
+
});
|
|
293
|
+
expect(errors).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("accepts valid orchestrator config", () => {
|
|
297
|
+
const errors = validateConfig({
|
|
298
|
+
rows: [{ panes: [{}] }],
|
|
299
|
+
orchestrator: {
|
|
300
|
+
enabled: true,
|
|
301
|
+
auto_dispatch: true,
|
|
302
|
+
stall_timeout: 300000,
|
|
303
|
+
poll_interval: 5000,
|
|
304
|
+
master_pane: "Master",
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
expect(errors).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("accepts config without orchestrator", () => {
|
|
311
|
+
const errors = validateConfig({ rows: [{ panes: [{}] }] });
|
|
312
|
+
expect(errors).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("rejects non-object orchestrator", () => {
|
|
316
|
+
const errors = validateConfig({
|
|
317
|
+
rows: [{ panes: [{}] }],
|
|
318
|
+
orchestrator: "enabled",
|
|
319
|
+
});
|
|
320
|
+
expect(errors.includes("'orchestrator' must be an object")).toBeTruthy();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("rejects orchestrator with wrong field types", () => {
|
|
324
|
+
const errors = validateConfig({
|
|
325
|
+
rows: [{ panes: [{}] }],
|
|
326
|
+
orchestrator: {
|
|
327
|
+
enabled: "yes",
|
|
328
|
+
auto_dispatch: 1,
|
|
329
|
+
stall_timeout: "5000",
|
|
330
|
+
poll_interval: true,
|
|
331
|
+
master_pane: false,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
expect(errors.includes("orchestrator.enabled must be a boolean")).toBeTruthy();
|
|
335
|
+
expect(errors.includes("orchestrator.auto_dispatch must be a boolean")).toBeTruthy();
|
|
336
|
+
expect(errors.includes("orchestrator.stall_timeout must be a number (ms)")).toBeTruthy();
|
|
337
|
+
expect(errors.includes("orchestrator.poll_interval must be a number (ms)")).toBeTruthy();
|
|
338
|
+
expect(errors.includes("orchestrator.master_pane must be a string")).toBeTruthy();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("accepts orchestrator with only some fields", () => {
|
|
342
|
+
const errors = validateConfig({
|
|
343
|
+
rows: [{ panes: [{}] }],
|
|
344
|
+
orchestrator: { enabled: true },
|
|
345
|
+
});
|
|
346
|
+
expect(errors).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("accepts valid orchestrator hook and concurrency fields", () => {
|
|
350
|
+
const errors = validateConfig({
|
|
351
|
+
rows: [{ panes: [{}] }],
|
|
352
|
+
orchestrator: {
|
|
353
|
+
before_run: "pnpm install",
|
|
354
|
+
after_run: "pnpm test",
|
|
355
|
+
max_concurrent_agents: 3,
|
|
356
|
+
dispatch_mode: "goals",
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
expect(errors).toEqual([]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("rejects orchestrator hook and concurrency fields with wrong types", () => {
|
|
363
|
+
const errors = validateConfig({
|
|
364
|
+
rows: [{ panes: [{}] }],
|
|
365
|
+
orchestrator: {
|
|
366
|
+
before_run: 123,
|
|
367
|
+
after_run: false,
|
|
368
|
+
max_concurrent_agents: "ten",
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
expect(errors.includes("orchestrator.before_run must be a string")).toBeTruthy();
|
|
372
|
+
expect(errors.includes("orchestrator.after_run must be a string")).toBeTruthy();
|
|
373
|
+
expect(errors.includes("orchestrator.max_concurrent_agents must be a number")).toBeTruthy();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("collects multiple errors at once", () => {
|
|
377
|
+
const errors = validateConfig({
|
|
378
|
+
name: 123,
|
|
379
|
+
before: [],
|
|
380
|
+
rows: [{ panes: [{ title: 42, focus: "yes", size: "0%" }] }],
|
|
381
|
+
theme: "nope",
|
|
382
|
+
});
|
|
383
|
+
expect(errors.length >= 4).toBeTruthy();
|
|
384
|
+
});
|
|
385
|
+
});
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { readConfig } from "./lib/yaml-io.ts";
|
|
3
|
+
import { outputError } from "./lib/output.ts";
|
|
4
|
+
import { IdeConfigSchema } from "./schemas/ide-config.ts";
|
|
5
|
+
|
|
6
|
+
/** Minimal shape of Zod parse issues (v3/v4 compatible for our mappers). */
|
|
7
|
+
interface ZodLikeIssue {
|
|
8
|
+
path?: (string | number)[];
|
|
9
|
+
code?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
expected?: string;
|
|
12
|
+
received?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateConfig(config: unknown): string[] {
|
|
16
|
+
if (config == null || typeof config !== "object" || Array.isArray(config)) {
|
|
17
|
+
return ["config must be an object"];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = IdeConfigSchema.safeParse(config);
|
|
21
|
+
|
|
22
|
+
if (!result.success) {
|
|
23
|
+
return result.error.issues.map((issue) => mapZodIssue(issue as ZodLikeIssue, config));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Phase 2: semantic checks (only when schema parse succeeds)
|
|
27
|
+
const errors: string[] = [];
|
|
28
|
+
const cfg = result.data;
|
|
29
|
+
|
|
30
|
+
// 1. Row sizes sum ≤ 100%
|
|
31
|
+
const rowSizes = cfg.rows.filter((r) => r.size !== undefined).map((r) => parseInt(r.size!, 10));
|
|
32
|
+
const rowSum = rowSizes.reduce((a, b) => a + b, 0);
|
|
33
|
+
if (rowSum > 100) {
|
|
34
|
+
errors.push(`Row sizes sum to ${rowSum}%, which exceeds 100%`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < cfg.rows.length; i++) {
|
|
38
|
+
const row = cfg.rows[i]!;
|
|
39
|
+
|
|
40
|
+
// 2. Per-row pane sizes sum ≤ 100%
|
|
41
|
+
const paneSizes = row.panes
|
|
42
|
+
.filter((p) => p.size !== undefined)
|
|
43
|
+
.map((p) => parseInt(p.size!, 10));
|
|
44
|
+
const paneSum = paneSizes.reduce((a, b) => a + b, 0);
|
|
45
|
+
if (paneSum > 100) {
|
|
46
|
+
errors.push(`Row ${i} pane sizes sum to ${paneSum}%, which exceeds 100%`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 3. Max 1 focus:true per row
|
|
50
|
+
const focusCount = row.panes.filter((p) => p.focus === true).length;
|
|
51
|
+
if (focusCount > 1) {
|
|
52
|
+
errors.push(`Row ${i} has ${focusCount} panes with focus: true (max 1)`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 4. type and command cannot coexist
|
|
56
|
+
for (let j = 0; j < row.panes.length; j++) {
|
|
57
|
+
const pane = row.panes[j]!;
|
|
58
|
+
if (pane.type !== undefined && pane.command !== undefined) {
|
|
59
|
+
errors.push(`rows[${i}].panes[${j}] cannot have both 'type' and 'command'`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return errors;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Zod issue → legacy error string mapping
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function formatPath(path: (string | number)[]): string {
|
|
72
|
+
let result = "";
|
|
73
|
+
for (let i = 0; i < path.length; i++) {
|
|
74
|
+
const seg = path[i]!;
|
|
75
|
+
if (typeof seg === "number") {
|
|
76
|
+
result += `[${seg}]`;
|
|
77
|
+
} else if (i === 0) {
|
|
78
|
+
result += seg;
|
|
79
|
+
} else {
|
|
80
|
+
result += `.${seg}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function shouldQuote(path: (string | number)[]): boolean {
|
|
87
|
+
if (path.length === 1 && typeof path[0] === "string") return true;
|
|
88
|
+
if (path[0] === "team") return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isSizePath(path: (string | number)[]): boolean {
|
|
93
|
+
return path[path.length - 1] === "size";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isEnvValuePath(path: (string | number)[]): boolean {
|
|
97
|
+
const envIdx = path.indexOf("env");
|
|
98
|
+
return envIdx >= 0 && envIdx < path.length - 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getValueAtPath(obj: unknown, path: (string | number)[]): unknown {
|
|
102
|
+
let current: unknown = obj;
|
|
103
|
+
for (const seg of path) {
|
|
104
|
+
if (current == null || typeof current !== "object") return undefined;
|
|
105
|
+
current = (current as Record<string, unknown>)[String(seg)];
|
|
106
|
+
}
|
|
107
|
+
return current;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const MS_FIELDS = new Set(["stall_timeout", "poll_interval"]);
|
|
111
|
+
|
|
112
|
+
function typeDesc(path: (string | number)[], expected: string): string {
|
|
113
|
+
const base = expected.replace(/\s*\|\s*undefined/g, "").trim();
|
|
114
|
+
let desc: string;
|
|
115
|
+
if (base === "string") desc = "a string";
|
|
116
|
+
else if (base === "boolean") desc = "a boolean";
|
|
117
|
+
else if (base === "number") desc = "a number";
|
|
118
|
+
else if (base === "array") desc = "an array";
|
|
119
|
+
else if (base === "object" || base === "record") desc = "an object";
|
|
120
|
+
else desc = base;
|
|
121
|
+
|
|
122
|
+
const field = path[path.length - 1];
|
|
123
|
+
if (path[0] === "orchestrator" && typeof field === "string" && MS_FIELDS.has(field)) {
|
|
124
|
+
return `${desc} (ms)`;
|
|
125
|
+
}
|
|
126
|
+
return desc;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mapZodIssue(issue: ZodLikeIssue, config: unknown): string {
|
|
130
|
+
const path: (string | number)[] = issue.path ?? [];
|
|
131
|
+
const code: string = issue.code ?? "";
|
|
132
|
+
const rawPath = formatPath(path);
|
|
133
|
+
const display = shouldQuote(path) ? `'${rawPath}'` : rawPath;
|
|
134
|
+
const lastSeg = path[path.length - 1];
|
|
135
|
+
|
|
136
|
+
// Env value: rows[i].panes[j].env.KEY
|
|
137
|
+
if (isEnvValuePath(path)) {
|
|
138
|
+
return `${formatPath(path)} must be a string or number`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Size field: regex or refine failure (but not a type error like size: 42)
|
|
142
|
+
if (isSizePath(path) && code !== "invalid_type") {
|
|
143
|
+
if (code === "custom") {
|
|
144
|
+
return `${rawPath} must not exceed 100%`;
|
|
145
|
+
}
|
|
146
|
+
const val = getValueAtPath(config, path);
|
|
147
|
+
return `${rawPath} "${val}" must be a percentage (e.g. "50%")`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Empty array (too_small from .min(1))
|
|
151
|
+
if (code === "too_small") {
|
|
152
|
+
return `${display} must not be empty`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Enum: pane type
|
|
156
|
+
if (code === "invalid_value" && lastSeg === "type" && path.includes("panes")) {
|
|
157
|
+
return `${rawPath} must be one of: explorer, changes, preview, tasks, costs, config, mission-control`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Enum: pane role
|
|
161
|
+
if (code === "invalid_value" && lastSeg === "role") {
|
|
162
|
+
return `${rawPath} must be "lead", "teammate", or "planner"`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Enum: dispatch_mode
|
|
166
|
+
if (code === "invalid_value" && lastSeg === "dispatch_mode") {
|
|
167
|
+
return `${rawPath} must be "tasks" or "goals"`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Team.name missing vs wrong type
|
|
171
|
+
if (path.length === 2 && path[0] === "team" && path[1] === "name") {
|
|
172
|
+
if (issue.received === "undefined") {
|
|
173
|
+
return "'team.name' is required when team is specified";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Type mismatch
|
|
178
|
+
if (code === "invalid_type") {
|
|
179
|
+
return `${display} must be ${typeDesc(path, issue.expected ?? "")}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fallback
|
|
183
|
+
return `${display}: ${issue.message ?? "invalid value"}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function validate(
|
|
187
|
+
targetDir: string | undefined,
|
|
188
|
+
{ json }: { json?: boolean } = {},
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const dir = resolve(targetDir ?? ".");
|
|
191
|
+
let config;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
({ config } = readConfig(dir));
|
|
195
|
+
} catch (e) {
|
|
196
|
+
outputError(`Cannot read ide.yml: ${(e as Error).message}`, "READ_ERROR");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const errors = validateConfig(config);
|
|
201
|
+
const valid = errors.length === 0;
|
|
202
|
+
|
|
203
|
+
if (json) {
|
|
204
|
+
console.log(JSON.stringify({ valid, errors }, null, 2));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (valid) {
|
|
209
|
+
console.log("✓ ide.yml is valid");
|
|
210
|
+
} else {
|
|
211
|
+
console.log("✗ ide.yml has errors:");
|
|
212
|
+
for (const e of errors) {
|
|
213
|
+
console.log(` - ${e}`);
|
|
214
|
+
}
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
}
|