@devosurf/tesser 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +41 -0
- package/bin/tesser.mjs +2 -0
- package/dist/index.js +2361 -0
- package/dist/index.js.map +7 -0
- package/package.json +34 -0
- package/src/client.ts +63 -0
- package/src/commands/auth.test.ts +19 -0
- package/src/commands/auth.ts +154 -0
- package/src/commands/deploy.ts +80 -0
- package/src/commands/dev.ts +119 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/replay.ts +81 -0
- package/src/commands/test.ts +149 -0
- package/src/config.ts +87 -0
- package/src/exit-codes.ts +24 -0
- package/src/index.ts +508 -0
- package/src/output.ts +47 -0
- package/src/project.ts +51 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// tesser — the machine-first CLI (ADR-0007). Every command: --json (stdout = one JSON
|
|
2
|
+
// document, stderr = logs), deterministic exit codes (src/exit-codes.ts), thin shell
|
|
3
|
+
// over the control-plane API.
|
|
4
|
+
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { extractAutomationManifest } from "@devosurf/tesser-sdk/internal";
|
|
10
|
+
import { ApiClient } from "./client.js";
|
|
11
|
+
import { readConfig, readLinkManifest, resolveTarget, writeConfig } from "./config.js";
|
|
12
|
+
import { EXIT } from "./exit-codes.js";
|
|
13
|
+
import { CliError, Output, toExit } from "./output.js";
|
|
14
|
+
import { discoverLocalAutomations, loadAutomationDef } from "./project.js";
|
|
15
|
+
import { authClaudeCode, authPi } from "./commands/auth.js";
|
|
16
|
+
import { deploy } from "./commands/deploy.js";
|
|
17
|
+
import { dev } from "./commands/dev.js";
|
|
18
|
+
import { init } from "./commands/init.js";
|
|
19
|
+
import { replay } from "./commands/replay.js";
|
|
20
|
+
import { runTests } from "./commands/test.js";
|
|
21
|
+
|
|
22
|
+
const exec = promisify(execFile);
|
|
23
|
+
const program = new Command();
|
|
24
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version as string;
|
|
25
|
+
|
|
26
|
+
interface GlobalOpts {
|
|
27
|
+
json?: boolean;
|
|
28
|
+
url?: string;
|
|
29
|
+
token?: string;
|
|
30
|
+
profile?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setup(): { out: Output; opts: GlobalOpts } {
|
|
34
|
+
const opts = program.opts<GlobalOpts>();
|
|
35
|
+
return { out: new Output(opts.json ?? false), opts };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function target(opts: GlobalOpts): ReturnType<typeof resolveTarget> {
|
|
39
|
+
return resolveTarget({
|
|
40
|
+
...(opts.url !== undefined ? { url: opts.url } : {}),
|
|
41
|
+
...(opts.token !== undefined ? { token: opts.token } : {}),
|
|
42
|
+
...(opts.profile !== undefined ? { profile: opts.profile } : {}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function api(opts: GlobalOpts): ApiClient {
|
|
47
|
+
const t = target(opts);
|
|
48
|
+
return new ApiClient(t.url, t.token);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requireProject(opts: GlobalOpts): { name: string; root: string } {
|
|
52
|
+
const t = target(opts);
|
|
53
|
+
if (!t.projectRoot || !t.project) {
|
|
54
|
+
throw new CliError(EXIT.USAGE, "not inside a Tesser project (no tesser.json) — run `tesser init <name>` or `tesser link`");
|
|
55
|
+
}
|
|
56
|
+
return { name: t.project, root: t.projectRoot };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
program
|
|
60
|
+
.name("tesser")
|
|
61
|
+
.version(VERSION)
|
|
62
|
+
.description("Code-first, agent-native automation. stdout = data, stderr = logs; --json everywhere.")
|
|
63
|
+
.option("--json", "machine output: one JSON document on stdout")
|
|
64
|
+
.option("--url <url>", "instance URL (overrides tesser.json / profile)")
|
|
65
|
+
.option("--token <token>", "API token (overrides TESSER_TOKEN / profile)")
|
|
66
|
+
.option("--profile <name>", "config profile");
|
|
67
|
+
|
|
68
|
+
program
|
|
69
|
+
.command("init")
|
|
70
|
+
.argument("<name>", "project name (kebab-case)")
|
|
71
|
+
.option("--dir <dir>", "parent directory")
|
|
72
|
+
.option("--instance <url>", "instance URL to write into tesser.json")
|
|
73
|
+
.description("scaffold a new Project (one repo of automations)")
|
|
74
|
+
.action((name: string, cmdOpts: { dir?: string; instance?: string }) => {
|
|
75
|
+
const { out } = setup();
|
|
76
|
+
try {
|
|
77
|
+
init(out, name, cmdOpts);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
toExit(err, out);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
program
|
|
84
|
+
.command("login")
|
|
85
|
+
.option("--token <token>", "API token from the instance (printed at first boot)")
|
|
86
|
+
.option("--instance <url>", "instance URL", "http://localhost:8377")
|
|
87
|
+
.option("--save-profile <name>", "profile name", "default")
|
|
88
|
+
.description("store instance credentials in ~/.config/tesser (or use env TESSER_TOKEN)")
|
|
89
|
+
.action(async (cmdOpts: { token?: string; instance: string; saveProfile: string }) => {
|
|
90
|
+
const { out, opts } = setup();
|
|
91
|
+
try {
|
|
92
|
+
const token = cmdOpts.token ?? opts.token;
|
|
93
|
+
if (!token) throw new CliError(EXIT.USAGE, "missing required option '--token <token>'");
|
|
94
|
+
const client = new ApiClient(cmdOpts.instance, token);
|
|
95
|
+
await client.get("/health"); // verify before storing
|
|
96
|
+
const config = readConfig();
|
|
97
|
+
config.profiles = { ...config.profiles, [cmdOpts.saveProfile]: { url: cmdOpts.instance, token } };
|
|
98
|
+
config.current = cmdOpts.saveProfile;
|
|
99
|
+
writeConfig(config);
|
|
100
|
+
out.data({ profile: cmdOpts.saveProfile, url: cmdOpts.instance }, () => `logged in to ${cmdOpts.instance} (profile "${cmdOpts.saveProfile}")`);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
toExit(err, out);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
program
|
|
107
|
+
.command("link")
|
|
108
|
+
.option("--repo <url>", "git remote the instance should pull (defaults to origin)")
|
|
109
|
+
.description("register this Project on the instance and wire git-sync")
|
|
110
|
+
.action(async (cmdOpts: { repo?: string }) => {
|
|
111
|
+
const { out, opts } = setup();
|
|
112
|
+
try {
|
|
113
|
+
const { name } = requireProject(opts);
|
|
114
|
+
let repoUrl = cmdOpts.repo;
|
|
115
|
+
if (repoUrl === undefined) {
|
|
116
|
+
repoUrl = await exec("git", ["remote", "get-url", "origin"]).then(
|
|
117
|
+
(r) => r.stdout.trim(),
|
|
118
|
+
() => undefined,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const result = await api(opts).post<{
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
deployKeyPublic: string;
|
|
125
|
+
webhookSetupUrl: string;
|
|
126
|
+
}>("/projects", {
|
|
127
|
+
name,
|
|
128
|
+
...(repoUrl !== undefined ? { repoUrl } : {}),
|
|
129
|
+
});
|
|
130
|
+
out.data(
|
|
131
|
+
{ ...result, repoUrl: repoUrl ?? null },
|
|
132
|
+
() =>
|
|
133
|
+
`linked project "${name}"${repoUrl ? ` ← ${repoUrl}` : " (no git remote yet — deploy with --local or set --repo)"}\n` +
|
|
134
|
+
`deploy key: ${result.deployKeyPublic}\n` +
|
|
135
|
+
`webhook setup: ${result.webhookSetupUrl}`,
|
|
136
|
+
);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
toExit(err, out);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
program
|
|
143
|
+
.command("test")
|
|
144
|
+
.option("--smoke-only", "run only the auto-generated smoke tests")
|
|
145
|
+
.option("--automation <id>", "limit to one automation")
|
|
146
|
+
.description("fast in-process validation: colocated tests + auto smoke (ADR-0008)")
|
|
147
|
+
.action(async (cmdOpts: { smokeOnly?: boolean; automation?: string }) => {
|
|
148
|
+
const { out, opts } = setup();
|
|
149
|
+
try {
|
|
150
|
+
const t = target(opts);
|
|
151
|
+
const root = t.projectRoot ?? process.cwd();
|
|
152
|
+
await runTests(out, root, { ...(cmdOpts.smokeOnly !== undefined ? { smokeOnly: cmdOpts.smokeOnly } : {}), filter: cmdOpts.automation });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
toExit(err, out);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
program
|
|
159
|
+
.command("build")
|
|
160
|
+
.description("build + statically extract every automation's manifest locally (CI check)")
|
|
161
|
+
.action(async () => {
|
|
162
|
+
const { out, opts } = setup();
|
|
163
|
+
try {
|
|
164
|
+
const t = target(opts);
|
|
165
|
+
const root = t.projectRoot ?? process.cwd();
|
|
166
|
+
const autos = discoverLocalAutomations(root);
|
|
167
|
+
if (autos.length === 0) throw new CliError(EXIT.USAGE, "no automations found");
|
|
168
|
+
const manifests: Array<ReturnType<typeof extractAutomationManifest>> = [];
|
|
169
|
+
for (const a of autos) {
|
|
170
|
+
const def = await loadAutomationDef(a.entry);
|
|
171
|
+
manifests.push(extractAutomationManifest(def));
|
|
172
|
+
}
|
|
173
|
+
out.data({ automations: manifests }, () =>
|
|
174
|
+
manifests.map((m) => `${m.id}: trigger=${m.trigger.kind} connections=${Object.keys(m.connections).join(",") || "-"} secrets=${Object.keys(m.secrets).join(",") || "-"}`).join("\n"),
|
|
175
|
+
);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
toExit(err, out);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
program
|
|
182
|
+
.command("deploy")
|
|
183
|
+
.option("--ref <ref>", "git ref to deploy (default: the project's production branch)")
|
|
184
|
+
.option("--local", "deploy the local working tree (dev/dogfood lane, skips git)")
|
|
185
|
+
.option("--no-wait", "queue the sync and return immediately")
|
|
186
|
+
.description("git → instance: build changed, gate on tests, promote on green (ADR-0006)")
|
|
187
|
+
.action(async (cmdOpts: { ref?: string; local?: boolean; wait?: boolean }) => {
|
|
188
|
+
const { out, opts } = setup();
|
|
189
|
+
try {
|
|
190
|
+
const { name, root } = requireProject(opts);
|
|
191
|
+
await deploy(out, api(opts), name, {
|
|
192
|
+
ref: cmdOpts.ref,
|
|
193
|
+
local: cmdOpts.local ? root : undefined,
|
|
194
|
+
...(cmdOpts.wait === false ? { wait: false } : {}),
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
toExit(err, out);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
program
|
|
202
|
+
.command("dev")
|
|
203
|
+
.option("--port <port>", "port for the local instance", "8377")
|
|
204
|
+
.option("--no-watch", "deploy once and keep serving without watching")
|
|
205
|
+
.description("zero-setup local instance (embedded postgres) + deploy-on-change")
|
|
206
|
+
.action(async (cmdOpts: { port: string; watch?: boolean }) => {
|
|
207
|
+
const { out, opts } = setup();
|
|
208
|
+
try {
|
|
209
|
+
const { name, root } = requireProject(opts);
|
|
210
|
+
await dev(out, root, name, { port: Number(cmdOpts.port), ...(cmdOpts.watch === false ? { watch: false } : {}) });
|
|
211
|
+
} catch (err) {
|
|
212
|
+
toExit(err, out);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const auth = program.command("auth").description("agent-assisted Harness credential setup; values post directly to connect links");
|
|
217
|
+
auth
|
|
218
|
+
.command("claude-code")
|
|
219
|
+
.requiredOption("--connect <urlOrToken>", "Tesser /connect/<token> URL or token")
|
|
220
|
+
.option("--mode <mode>", "subscription or apiKey", "subscription")
|
|
221
|
+
.option("--token-stdin", "read token from stdin instead of running claude setup-token")
|
|
222
|
+
.option("--from-env <name>", "read token from an environment variable")
|
|
223
|
+
.option("--scope <scope>", "workspace or per_user", "workspace")
|
|
224
|
+
.option("--end-user-id <id>", "end-user id for per_user connections")
|
|
225
|
+
.option("--bin <path>", "claude binary", "claude")
|
|
226
|
+
.description("connect Claude Code as a brokered Harness; subscription mode runs claude setup-token")
|
|
227
|
+
.action(async (cmdOpts: { connect: string; mode?: string; tokenStdin?: boolean; fromEnv?: string; scope?: string; endUserId?: string; bin?: string }) => {
|
|
228
|
+
const { out, opts } = setup();
|
|
229
|
+
try {
|
|
230
|
+
await authClaudeCode(out, target(opts).url, cmdOpts);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
toExit(err, out);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
auth
|
|
236
|
+
.command("pi")
|
|
237
|
+
.requiredOption("--connect <urlOrToken>", "Tesser /connect/<token> URL or token")
|
|
238
|
+
.option("--mode <mode>", "anthropicOAuth or anthropicApiKey", "anthropicOAuth")
|
|
239
|
+
.option("--token-stdin", "read token from stdin")
|
|
240
|
+
.option("--from-env <name>", "read token from an environment variable")
|
|
241
|
+
.option("--scope <scope>", "workspace or per_user", "workspace")
|
|
242
|
+
.option("--end-user-id <id>", "end-user id for per_user connections")
|
|
243
|
+
.description("connect Pi as a brokered Harness")
|
|
244
|
+
.action(async (cmdOpts: { connect: string; mode?: string; tokenStdin?: boolean; fromEnv?: string; scope?: string; endUserId?: string }) => {
|
|
245
|
+
const { out, opts } = setup();
|
|
246
|
+
try {
|
|
247
|
+
await authPi(out, target(opts).url, cmdOpts);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
toExit(err, out);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
program
|
|
254
|
+
.command("connect")
|
|
255
|
+
.option("--wait", "poll until the human completes the link")
|
|
256
|
+
.option("--status <token>", "check an existing connect link")
|
|
257
|
+
.description("mint a connect link for missing credentials; the human completes it in a browser (ADR-0005)")
|
|
258
|
+
.action(async (cmdOpts: { wait?: boolean; status?: string }) => {
|
|
259
|
+
const { out, opts } = setup();
|
|
260
|
+
try {
|
|
261
|
+
const client = api(opts);
|
|
262
|
+
if (cmdOpts.status) {
|
|
263
|
+
out.data(await client.get(`/connect-links/${cmdOpts.status}/status`));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const { name } = requireProject(opts);
|
|
267
|
+
const minted = await client.post<{ url: string | null; token?: string; requirements: unknown[] }>(
|
|
268
|
+
"/connect-links",
|
|
269
|
+
{ project: name },
|
|
270
|
+
);
|
|
271
|
+
if (!minted.url) {
|
|
272
|
+
out.data({ url: null, requirements: [] }, () => "nothing missing — all requirements are satisfied");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
out.data(minted, () => `open in a browser to connect:\n ${minted.url}`);
|
|
276
|
+
if (cmdOpts.wait) {
|
|
277
|
+
for (;;) {
|
|
278
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
279
|
+
const status = await client.get<{ status: string }>(`/connect-links/${minted.token}/status`);
|
|
280
|
+
if (status.status === "completed") {
|
|
281
|
+
out.log("connect link completed ✓");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (status.status === "expired") throw new CliError(EXIT.HALTED_CREDENTIALS, "connect link expired");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
process.exit(EXIT.HALTED_CREDENTIALS);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
toExit(err, out);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const secrets = program.command("secrets").description("workspace secrets — names only; values never echo");
|
|
294
|
+
secrets
|
|
295
|
+
.command("list")
|
|
296
|
+
.action(async () => {
|
|
297
|
+
const { out, opts } = setup();
|
|
298
|
+
try {
|
|
299
|
+
out.data(await api(opts).get("/secrets"));
|
|
300
|
+
} catch (err) {
|
|
301
|
+
toExit(err, out);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
secrets
|
|
305
|
+
.command("set")
|
|
306
|
+
.argument("<name>")
|
|
307
|
+
.option("--value-stdin", "read the value from stdin (pipe it; value never appears in argv)")
|
|
308
|
+
.description("set a secret value (human/CI lane: pipe via --value-stdin; agents mint connect links instead)")
|
|
309
|
+
.action(async (name: string, cmdOpts: { valueStdin?: boolean }) => {
|
|
310
|
+
const { out, opts } = setup();
|
|
311
|
+
try {
|
|
312
|
+
if (!cmdOpts.valueStdin) {
|
|
313
|
+
throw new CliError(
|
|
314
|
+
EXIT.USAGE,
|
|
315
|
+
"refusing a value on argv — pipe it: `printenv MY_SECRET | tesser secrets set " + name + " --value-stdin` (or use a connect link)",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const chunks: Buffer[] = [];
|
|
319
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
320
|
+
const value = Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
|
|
321
|
+
if (value.length === 0) throw new CliError(EXIT.USAGE, "empty value on stdin");
|
|
322
|
+
await api(opts).put(`/secrets/${encodeURIComponent(name)}`, { value });
|
|
323
|
+
out.data({ set: name }, () => `secret "${name}" set ✓`);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
toExit(err, out);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
secrets
|
|
329
|
+
.command("rm")
|
|
330
|
+
.argument("<name>")
|
|
331
|
+
.action(async (name: string) => {
|
|
332
|
+
const { out, opts } = setup();
|
|
333
|
+
try {
|
|
334
|
+
out.data(await api(opts).delete(`/secrets/${encodeURIComponent(name)}`));
|
|
335
|
+
} catch (err) {
|
|
336
|
+
toExit(err, out);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const runs = program.command("runs").description("inspect and drive runs");
|
|
341
|
+
runs
|
|
342
|
+
.command("list")
|
|
343
|
+
.option("--automation <id>")
|
|
344
|
+
.option("--status <status>")
|
|
345
|
+
.option("--limit <n>", "max rows", "25")
|
|
346
|
+
.action(async (cmdOpts: { automation?: string; status?: string; limit: string }) => {
|
|
347
|
+
const { out, opts } = setup();
|
|
348
|
+
try {
|
|
349
|
+
const { name } = requireProject(opts);
|
|
350
|
+
const params = new URLSearchParams({ project: name, limit: cmdOpts.limit });
|
|
351
|
+
if (cmdOpts.automation) params.set("automation", cmdOpts.automation);
|
|
352
|
+
if (cmdOpts.status) params.set("status", cmdOpts.status);
|
|
353
|
+
const data = await api(opts).get<{ runs: Array<Record<string, unknown>> }>(`/runs?${params}`);
|
|
354
|
+
out.data(data, (d: { runs: Array<{ id: string; automation_id: string; status: string; trigger_kind: string; created_at: string }> }) =>
|
|
355
|
+
d.runs.map((r) => `${r.id} ${r.automation_id} ${r.status} (${r.trigger_kind}) ${r.created_at}`).join("\n") || "(no runs)",
|
|
356
|
+
);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
toExit(err, out);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
runs
|
|
362
|
+
.command("show")
|
|
363
|
+
.argument("<runId>")
|
|
364
|
+
.action(async (runId: string) => {
|
|
365
|
+
const { out, opts } = setup();
|
|
366
|
+
try {
|
|
367
|
+
out.data(await api(opts).get(`/runs/${runId}`));
|
|
368
|
+
} catch (err) {
|
|
369
|
+
toExit(err, out);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
runs
|
|
373
|
+
.command("trigger")
|
|
374
|
+
.argument("<automation>")
|
|
375
|
+
.option("--input <json>", "input payload as JSON")
|
|
376
|
+
.option("--env <env>", "environment", "production")
|
|
377
|
+
.action(async (automation: string, cmdOpts: { input?: string; env: string }) => {
|
|
378
|
+
const { out, opts } = setup();
|
|
379
|
+
try {
|
|
380
|
+
const { name } = requireProject(opts);
|
|
381
|
+
const body: Record<string, unknown> = { project: name, automation, env: cmdOpts.env };
|
|
382
|
+
if (cmdOpts.input !== undefined) body["input"] = JSON.parse(cmdOpts.input);
|
|
383
|
+
out.data(await api(opts).post("/runs", body));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
toExit(err, out);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
runs
|
|
389
|
+
.command("signal")
|
|
390
|
+
.argument("<runId>")
|
|
391
|
+
.argument("<name>")
|
|
392
|
+
.option("--payload <json>", "signal payload as JSON")
|
|
393
|
+
.action(async (runId: string, name: string, cmdOpts: { payload?: string }) => {
|
|
394
|
+
const { out, opts } = setup();
|
|
395
|
+
try {
|
|
396
|
+
out.data(
|
|
397
|
+
await api(opts).post(`/runs/${runId}/signals/${encodeURIComponent(name)}`, {
|
|
398
|
+
...(cmdOpts.payload !== undefined ? { payload: JSON.parse(cmdOpts.payload) } : {}),
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
toExit(err, out);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
runs
|
|
406
|
+
.command("cancel")
|
|
407
|
+
.argument("<runId>")
|
|
408
|
+
.action(async (runId: string) => {
|
|
409
|
+
const { out, opts } = setup();
|
|
410
|
+
try {
|
|
411
|
+
out.data(await api(opts).post(`/runs/${runId}/cancel`));
|
|
412
|
+
} catch (err) {
|
|
413
|
+
toExit(err, out);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
program
|
|
418
|
+
.command("logs")
|
|
419
|
+
.argument("<runId>")
|
|
420
|
+
.option("--follow", "poll until the run settles")
|
|
421
|
+
.description("step logs for one run")
|
|
422
|
+
.action(async (runId: string, cmdOpts: { follow?: boolean }) => {
|
|
423
|
+
const { out, opts } = setup();
|
|
424
|
+
try {
|
|
425
|
+
const client = api(opts);
|
|
426
|
+
let printed = 0;
|
|
427
|
+
for (;;) {
|
|
428
|
+
const detail = await client.get<{
|
|
429
|
+
run: { status: string };
|
|
430
|
+
logs: Array<{ step: string | null; level: string; msg: string; created_at: string }>;
|
|
431
|
+
}>(`/runs/${runId}`);
|
|
432
|
+
const fresh = detail.logs.slice(printed);
|
|
433
|
+
printed = detail.logs.length;
|
|
434
|
+
if (out.json && !cmdOpts.follow) {
|
|
435
|
+
out.data(detail);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
for (const l of fresh) out.log(`[${l.level}]${l.step ? ` (${l.step})` : ""} ${l.msg}`);
|
|
439
|
+
if (!cmdOpts.follow || ["completed", "failed", "cancelled"].includes(detail.run.status)) {
|
|
440
|
+
if (!out.json) out.log(`run ${detail.run.status}`);
|
|
441
|
+
else out.data(detail);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
toExit(err, out);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
program
|
|
452
|
+
.command("replay")
|
|
453
|
+
.argument("<runId>")
|
|
454
|
+
.description("freeze a real run as a committed regression fixture + test (ADR-0008)")
|
|
455
|
+
.action(async (runId: string) => {
|
|
456
|
+
const { out, opts } = setup();
|
|
457
|
+
try {
|
|
458
|
+
const { root } = requireProject(opts);
|
|
459
|
+
await replay(out, api(opts), root, runId);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
toExit(err, out);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
program
|
|
466
|
+
.command("rollback")
|
|
467
|
+
.argument("<automation>")
|
|
468
|
+
.requiredOption("--to <version>", "version number to re-point the alias at")
|
|
469
|
+
.option("--env <env>", "environment", "production")
|
|
470
|
+
.description("instant alias re-point to a prior immutable version (no rebuild)")
|
|
471
|
+
.action(async (automation: string, cmdOpts: { to: string; env: string }) => {
|
|
472
|
+
const { out, opts } = setup();
|
|
473
|
+
try {
|
|
474
|
+
const { name } = requireProject(opts);
|
|
475
|
+
out.data(
|
|
476
|
+
await api(opts).post(`/projects/${name}/rollback`, {
|
|
477
|
+
automation,
|
|
478
|
+
toVersion: Number(cmdOpts.to),
|
|
479
|
+
env: cmdOpts.env,
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
toExit(err, out);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
program
|
|
488
|
+
.command("status")
|
|
489
|
+
.description("instance + project deploy status")
|
|
490
|
+
.action(async () => {
|
|
491
|
+
const { out, opts } = setup();
|
|
492
|
+
try {
|
|
493
|
+
const t = target(opts);
|
|
494
|
+
const client = api(opts);
|
|
495
|
+
const health = await client.get("/health");
|
|
496
|
+
const status = await client.get("/status");
|
|
497
|
+
const manifest = t.projectRoot ? readLinkManifest(t.projectRoot) : null;
|
|
498
|
+
const projectStatus = manifest ? await client.get(`/projects/${manifest.project}/deploys/latest`).catch(() => null) : null;
|
|
499
|
+
out.data({ instance: t.url, health, status, project: manifest?.project ?? null, deploy: projectStatus });
|
|
500
|
+
} catch (err) {
|
|
501
|
+
toExit(err, out);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
program.parseAsync().catch((err) => {
|
|
506
|
+
const out = new Output(process.argv.includes("--json"));
|
|
507
|
+
toExit(err, out);
|
|
508
|
+
});
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Machine-first I/O contract (ADR-0007): stdout = data (one JSON document with --json),
|
|
2
|
+
// stderr = logs/progress, deterministic exit codes.
|
|
3
|
+
|
|
4
|
+
import { EXIT, type ExitCode } from "./exit-codes.js";
|
|
5
|
+
|
|
6
|
+
export class Output {
|
|
7
|
+
constructor(readonly json: boolean) {}
|
|
8
|
+
|
|
9
|
+
/** Emit the command's data result. `human` renders the no-JSON form. */
|
|
10
|
+
data(value: unknown, human?: (v: never) => string): void {
|
|
11
|
+
if (this.json) {
|
|
12
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
13
|
+
} else {
|
|
14
|
+
process.stdout.write((human ? human(value as never) : JSON.stringify(value, null, 2)) + "\n");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Progress/notes → stderr, never stdout. */
|
|
19
|
+
log(msg: string): void {
|
|
20
|
+
process.stderr.write(msg + "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fail(code: ExitCode, message: string, extra?: Record<string, unknown>): never {
|
|
24
|
+
if (this.json) {
|
|
25
|
+
process.stdout.write(JSON.stringify({ error: { code, message, ...extra } }, null, 2) + "\n");
|
|
26
|
+
} else {
|
|
27
|
+
process.stderr.write(`error: ${message}\n`);
|
|
28
|
+
if (extra) process.stderr.write(JSON.stringify(extra, null, 2) + "\n");
|
|
29
|
+
}
|
|
30
|
+
process.exit(code);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class CliError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly code: ExitCode,
|
|
37
|
+
message: string,
|
|
38
|
+
readonly extra?: Record<string, unknown>,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toExit(err: unknown, out: Output): never {
|
|
45
|
+
if (err instanceof CliError) out.fail(err.code, err.message, err.extra);
|
|
46
|
+
out.fail(EXIT.ERROR, err instanceof Error ? err.message : String(err));
|
|
47
|
+
}
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Local project introspection: discover automations, bundle one (esbuild), load its def
|
|
2
|
+
// for smoke/build checks. (Apache-side implementation — the CLI never links the server.)
|
|
3
|
+
|
|
4
|
+
import { mkdtempSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { build } from "esbuild";
|
|
9
|
+
import type { AutomationDef } from "@devosurf/tesser-sdk";
|
|
10
|
+
|
|
11
|
+
export interface LocalAutomation {
|
|
12
|
+
automationId: string;
|
|
13
|
+
dir: string;
|
|
14
|
+
entry: string;
|
|
15
|
+
hasTests: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function discoverLocalAutomations(projectRoot: string): LocalAutomation[] {
|
|
19
|
+
const root = join(projectRoot, "automations");
|
|
20
|
+
if (!existsSync(root)) return [];
|
|
21
|
+
const out: LocalAutomation[] = [];
|
|
22
|
+
for (const name of readdirSync(root).sort()) {
|
|
23
|
+
const dir = join(root, name);
|
|
24
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
25
|
+
const entry = join(dir, "index.ts");
|
|
26
|
+
if (!existsSync(entry)) continue;
|
|
27
|
+
const hasTests = readdirSync(dir).some((f) => /\.test\.(ts|js|mts|mjs)$/.test(f));
|
|
28
|
+
out.push({ automationId: name, dir, entry, hasTests });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadAutomationDef(entry: string): Promise<AutomationDef<any, any, any, any, any, any, any>> {
|
|
34
|
+
const outDir = mkdtempSync(join(tmpdir(), "tesser-cli-"));
|
|
35
|
+
const outFile = join(outDir, "bundle.mjs");
|
|
36
|
+
await build({
|
|
37
|
+
entryPoints: [entry],
|
|
38
|
+
outfile: outFile,
|
|
39
|
+
bundle: true,
|
|
40
|
+
platform: "node",
|
|
41
|
+
format: "esm",
|
|
42
|
+
target: "node20",
|
|
43
|
+
packages: "bundle",
|
|
44
|
+
logLevel: "silent",
|
|
45
|
+
});
|
|
46
|
+
const mod = (await import(pathToFileURL(outFile).href)) as { default?: AutomationDef<any, any, any, any, any, any, any> };
|
|
47
|
+
if (!mod.default || typeof mod.default.run !== "function") {
|
|
48
|
+
throw new Error(`${entry}: no default export from defineAutomation`);
|
|
49
|
+
}
|
|
50
|
+
return mod.default;
|
|
51
|
+
}
|