@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/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@devosurf/tesser",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Tesser CLI — the machine-first interface for agents and humans.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "tesser": "./bin/tesser.mjs"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^15.0.0",
21
+ "esbuild": "^0.28.1",
22
+ "@devosurf/tesser-sdk": "0.1.0-alpha.0",
23
+ "@devosurf/tesser-testing": "0.1.0-alpha.0"
24
+ },
25
+ "main": "./dist/index.js",
26
+ "types": "./src/index.ts",
27
+ "files": [
28
+ "bin",
29
+ "dist",
30
+ "src",
31
+ "README.md",
32
+ "LICENSE"
33
+ ]
34
+ }
package/src/client.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Thin control-plane client. Every command is a shell over this (ADR-0007).
2
+
3
+ import { EXIT } from "./exit-codes.js";
4
+ import { CliError } from "./output.js";
5
+
6
+ export class ApiClient {
7
+ constructor(
8
+ readonly baseUrl: string,
9
+ readonly token: string | undefined,
10
+ ) {}
11
+
12
+ async request<T>(method: string, path: string, body?: unknown): Promise<T> {
13
+ if (!this.token) {
14
+ throw new CliError(
15
+ EXIT.AUTH,
16
+ "no API token — run `tesser login --url <instance> --token <tsk_…>` or set TESSER_TOKEN",
17
+ );
18
+ }
19
+ let res: Response;
20
+ try {
21
+ res = await fetch(`${this.baseUrl}/api${path}`, {
22
+ method,
23
+ headers: {
24
+ authorization: `Bearer ${this.token}`,
25
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
26
+ },
27
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
28
+ });
29
+ } catch (cause) {
30
+ throw new CliError(EXIT.AUTH, `cannot reach instance at ${this.baseUrl} (${String(cause)})`);
31
+ }
32
+ const text = await res.text();
33
+ let parsed: unknown;
34
+ try {
35
+ parsed = text.length > 0 ? JSON.parse(text) : null;
36
+ } catch {
37
+ parsed = { raw: text };
38
+ }
39
+ if (!res.ok) {
40
+ const errBody = parsed as { error?: { code?: string; message?: string } };
41
+ const message = errBody?.error?.message ?? `instance responded ${res.status}`;
42
+ if (res.status === 401) throw new CliError(EXIT.AUTH, message);
43
+ if (res.status === 404) throw new CliError(EXIT.NOT_FOUND, message);
44
+ if (res.status === 400) throw new CliError(EXIT.USAGE, message);
45
+ if (res.status === 409) throw new CliError(EXIT.CONFLICT, message);
46
+ throw new CliError(EXIT.ERROR, message);
47
+ }
48
+ return parsed as T;
49
+ }
50
+
51
+ get<T>(path: string): Promise<T> {
52
+ return this.request("GET", path);
53
+ }
54
+ post<T>(path: string, body?: unknown): Promise<T> {
55
+ return this.request("POST", path, body);
56
+ }
57
+ put<T>(path: string, body?: unknown): Promise<T> {
58
+ return this.request("PUT", path, body);
59
+ }
60
+ delete<T>(path: string): Promise<T> {
61
+ return this.request("DELETE", path);
62
+ }
63
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { _test } from "./auth.js";
3
+
4
+ describe("Harness auth helper internals", () => {
5
+ test("extracts Claude setup-token output without exposing it", () => {
6
+ const raw = "Open browser...\nCLAUDE_CODE_OAUTH_TOKEN=oauth_token_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJ\n";
7
+ expect(_test.extractClaudeToken(raw)).toBe("oauth_token_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJ");
8
+ expect(_test.sanitizeSetupOutput(raw)).not.toContain("abcdefghijklmnopqrstuvwxyz0123456789");
9
+ });
10
+
11
+ test("builds connect form endpoint from URL or token", () => {
12
+ expect(_test.connectPostUrl("https://inst.example", "https://inst.example/connect/cl_abc123")).toBe(
13
+ "https://inst.example/connect/cl_abc123/connection",
14
+ );
15
+ expect(_test.connectPostUrl("https://inst.example/", "cl_deadbeef")).toBe(
16
+ "https://inst.example/connect/cl_deadbeef/connection",
17
+ );
18
+ });
19
+ });
@@ -0,0 +1,154 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EXIT } from "../exit-codes.js";
3
+ import { CliError, type Output } from "../output.js";
4
+
5
+ export interface HarnessAuthOpts {
6
+ connect: string;
7
+ mode?: string;
8
+ scope?: string;
9
+ endUserId?: string;
10
+ tokenStdin?: boolean;
11
+ fromEnv?: string;
12
+ bin?: string;
13
+ }
14
+
15
+ export async function authClaudeCode(out: Output, instanceUrl: string, opts: HarnessAuthOpts): Promise<void> {
16
+ const mode = opts.mode ?? "subscription";
17
+ if (mode !== "subscription" && mode !== "apiKey") {
18
+ throw new CliError(EXIT.USAGE, "claude-code auth mode must be subscription or apiKey");
19
+ }
20
+ const token =
21
+ opts.tokenStdin === true || opts.fromEnv !== undefined
22
+ ? await tokenFromPipeOrEnv(opts)
23
+ : mode === "subscription"
24
+ ? await runClaudeSetupToken(out, opts.bin ?? "claude")
25
+ : await tokenFromPipeOrEnv({ ...opts, tokenStdin: true });
26
+ await postConnection({
27
+ instanceUrl,
28
+ connect: opts.connect,
29
+ connector: "claude-code",
30
+ mode,
31
+ scope: opts.scope ?? "workspace",
32
+ ...(opts.endUserId !== undefined ? { endUserId: opts.endUserId } : {}),
33
+ fields: mode === "subscription" ? { oauth_token: token } : { api_key: token },
34
+ });
35
+ out.data({ connector: "claude-code", mode, connected: true }, () => `claude-code ${mode} connected ✓`);
36
+ }
37
+
38
+ export async function authPi(out: Output, instanceUrl: string, opts: HarnessAuthOpts): Promise<void> {
39
+ const mode = opts.mode ?? "anthropicOAuth";
40
+ if (mode !== "anthropicOAuth" && mode !== "anthropicApiKey") {
41
+ throw new CliError(EXIT.USAGE, "pi auth mode must be anthropicOAuth or anthropicApiKey");
42
+ }
43
+ const token = await tokenFromPipeOrEnv(opts);
44
+ await postConnection({
45
+ instanceUrl,
46
+ connect: opts.connect,
47
+ connector: "pi",
48
+ mode,
49
+ scope: opts.scope ?? "workspace",
50
+ ...(opts.endUserId !== undefined ? { endUserId: opts.endUserId } : {}),
51
+ fields: mode === "anthropicOAuth" ? { oauth_token: token } : { api_key: token },
52
+ });
53
+ out.data({ connector: "pi", mode, connected: true }, () => `pi ${mode} connected ✓`);
54
+ }
55
+
56
+ async function tokenFromPipeOrEnv(opts: HarnessAuthOpts): Promise<string> {
57
+ if (opts.fromEnv !== undefined) {
58
+ const value = process.env[opts.fromEnv];
59
+ if (!value) throw new CliError(EXIT.USAGE, `env ${opts.fromEnv} is empty or missing`);
60
+ return value;
61
+ }
62
+ if (opts.tokenStdin !== true) {
63
+ throw new CliError(EXIT.USAGE, "pass --token-stdin or --from-env <NAME> so the token never appears in argv");
64
+ }
65
+ const chunks: Buffer[] = [];
66
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
67
+ const value = Buffer.concat(chunks).toString("utf8").trim();
68
+ if (value.length === 0) throw new CliError(EXIT.USAGE, "empty token on stdin");
69
+ return value;
70
+ }
71
+
72
+ async function runClaudeSetupToken(out: Output, bin: string): Promise<string> {
73
+ out.log("starting `claude setup-token`; complete the browser login if prompted...");
74
+ const raw = await runInteractiveCapture(bin, ["setup-token"]);
75
+ const token = extractClaudeToken(raw);
76
+ if (!token) {
77
+ throw new CliError(
78
+ EXIT.ERROR,
79
+ "could not find a Claude Code OAuth token in `claude setup-token` output; rerun with `claude setup-token | tesser auth claude-code --connect <url> --token-stdin`",
80
+ );
81
+ }
82
+ return token;
83
+ }
84
+
85
+ function runInteractiveCapture(command: string, args: string[]): Promise<string> {
86
+ return new Promise((resolve, reject) => {
87
+ const child = spawn(command, args, { stdio: ["inherit", "pipe", "pipe"] });
88
+ let raw = "";
89
+ const onData = (buf: Buffer) => {
90
+ const text = buf.toString("utf8");
91
+ raw += text;
92
+ process.stderr.write(sanitizeSetupOutput(text));
93
+ };
94
+ child.stdout.on("data", onData);
95
+ child.stderr.on("data", onData);
96
+ child.on("error", reject);
97
+ child.on("close", (code) => {
98
+ if (code === 0) resolve(raw);
99
+ else reject(new CliError(EXIT.ERROR, `${command} ${args.join(" ")} exited ${code ?? 1}`));
100
+ });
101
+ });
102
+ }
103
+
104
+ function extractClaudeToken(raw: string): string | null {
105
+ const envMatch = raw.match(/CLAUDE_CODE_OAUTH_TOKEN\s*=\s*([^\s]+)/);
106
+ if (envMatch?.[1]) return envMatch[1].trim().replace(/^['"]|['"]$/g, "");
107
+ const candidates = raw
108
+ .split(/\r?\n/)
109
+ .map((l) => l.trim())
110
+ .filter((l) => /^[A-Za-z0-9._-]{40,}$/.test(l) && !/^https?:/i.test(l));
111
+ return candidates.at(-1) ?? null;
112
+ }
113
+
114
+ function sanitizeSetupOutput(text: string): string {
115
+ return text.replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)[^\s]+/g, "$1[redacted]").replace(/\b[A-Za-z0-9._-]{64,}\b/g, "[redacted-token]");
116
+ }
117
+
118
+ async function postConnection(opts: {
119
+ instanceUrl: string;
120
+ connect: string;
121
+ connector: string;
122
+ mode: string;
123
+ scope: string;
124
+ endUserId?: string;
125
+ fields: Record<string, string>;
126
+ }): Promise<void> {
127
+ const url = connectPostUrl(opts.instanceUrl, opts.connect);
128
+ const body = new URLSearchParams({ connector: opts.connector, mode: opts.mode, scope: opts.scope });
129
+ if (opts.endUserId !== undefined) body.set("end_user_id", opts.endUserId);
130
+ for (const [key, value] of Object.entries(opts.fields)) body.set(`field_${key}`, value);
131
+ const res = await fetch(url, {
132
+ method: "POST",
133
+ headers: { "content-type": "application/x-www-form-urlencoded" },
134
+ body: body.toString(),
135
+ redirect: "manual",
136
+ });
137
+ if (res.status >= 300 && res.status < 400) return;
138
+ const text = await res.text().catch(() => "");
139
+ throw new CliError(EXIT.ERROR, `connect page rejected ${opts.connector} ${opts.mode}: ${res.status}${text ? ` ${text}` : ""}`);
140
+ }
141
+
142
+ function connectPostUrl(instanceUrl: string, connect: string): string {
143
+ if (/^https?:\/\//i.test(connect)) {
144
+ const u = new URL(connect);
145
+ const match = u.pathname.match(/\/connect\/([^/]+)/);
146
+ if (!match?.[1]) throw new CliError(EXIT.USAGE, "--connect must be a Tesser /connect/<token> URL or token");
147
+ return `${u.origin}/connect/${match[1]}/connection`;
148
+ }
149
+ const token = connect.replace(/^\/?connect\//, "");
150
+ if (!/^cl_[a-f0-9]+$/i.test(token)) throw new CliError(EXIT.USAGE, "--connect must be a Tesser /connect/<token> URL or token");
151
+ return `${instanceUrl.replace(/\/$/, "")}/connect/${token}/connection`;
152
+ }
153
+
154
+ export const _test = { extractClaudeToken, sanitizeSetupOutput, connectPostUrl };
@@ -0,0 +1,80 @@
1
+ // `tesser deploy`: trigger a sync (git ref, or --local working tree), poll to a settled
2
+ // state, surface the connect link on halt (exit 4), failures on red (exit 8).
3
+
4
+ import type { ApiClient } from "../client.js";
5
+ import { EXIT } from "../exit-codes.js";
6
+ import { CliError, Output } from "../output.js";
7
+
8
+ interface DeployState {
9
+ repo?: { status: string; report?: DeployReport | null; error?: string | null } | null;
10
+ live?: unknown[];
11
+ }
12
+ interface DeployReport {
13
+ sha?: string;
14
+ env?: string;
15
+ built?: string[];
16
+ unchanged?: string[];
17
+ failed?: Array<{ automation: string; stage: string; reason: string }>;
18
+ removed?: string[];
19
+ connectUrl?: string;
20
+ manual?: unknown[];
21
+ }
22
+
23
+ export async function deploy(
24
+ out: Output,
25
+ api: ApiClient,
26
+ project: string,
27
+ opts: { ref?: string | undefined; local?: string | undefined; timeoutMs?: number; wait?: boolean },
28
+ ): Promise<never> {
29
+ await api.post(`/projects/${project}/sync`, {
30
+ ...(opts.ref !== undefined ? { ref: opts.ref } : {}),
31
+ ...(opts.local !== undefined ? { localPath: opts.local } : {}),
32
+ });
33
+ out.log(`sync queued for ${project}${opts.local ? " (local tree)" : opts.ref ? ` @ ${opts.ref}` : ""}`);
34
+
35
+ if (opts.wait === false) {
36
+ out.data({ queued: true });
37
+ process.exit(EXIT.OK);
38
+ }
39
+
40
+ const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60_000);
41
+ let last: DeployState = {};
42
+ while (Date.now() < deadline) {
43
+ await new Promise((r) => setTimeout(r, 750));
44
+ last = await api.get<DeployState>(`/projects/${project}/deploys/latest`);
45
+ const status = last.repo?.status;
46
+ if (status === "syncing" || status === "idle" || status === undefined) continue;
47
+
48
+ const report = (last.repo?.report ?? {}) as DeployReport;
49
+ if (status === "halted-credentials") {
50
+ out.data(
51
+ { status, connectUrl: report.connectUrl, report },
52
+ () =>
53
+ `deploy HALTED — credentials needed.\nOpen this link in a browser to connect:\n ${report.connectUrl}\nThen rerun: tesser deploy${opts.local ? " --local" : ""}`,
54
+ );
55
+ process.exit(EXIT.HALTED_CREDENTIALS);
56
+ }
57
+ if (status === "failed") {
58
+ out.data({ status, report, error: last.repo?.error }, () =>
59
+ [
60
+ "deploy FAILED:",
61
+ ...(report.failed ?? []).map((f) => ` ${f.automation} [${f.stage}]: ${f.reason.split("\n")[0]}`),
62
+ ...(last.repo?.error ? [` ${last.repo.error}`] : []),
63
+ ].join("\n"),
64
+ );
65
+ process.exit(EXIT.DEPLOY_FAILED);
66
+ }
67
+ if (status === "synced") {
68
+ out.data({ status, report, live: last.live }, () =>
69
+ [
70
+ `deploy OK @ ${report.sha?.slice(0, 8) ?? "?"} → ${report.env}`,
71
+ ` built: ${report.built?.join(", ") || "(none)"}`,
72
+ ` unchanged: ${report.unchanged?.join(", ") || "(none)"}`,
73
+ ...(report.removed?.length ? [` removed: ${report.removed.join(", ")}`] : []),
74
+ ].join("\n"),
75
+ );
76
+ process.exit(EXIT.OK);
77
+ }
78
+ }
79
+ throw new CliError(EXIT.ERROR, "timed out waiting for the deploy to settle", { last: last as never });
80
+ }
@@ -0,0 +1,119 @@
1
+ // `tesser dev`: a real local instance with zero setup — spawns the tesser-server BINARY
2
+ // (process boundary; the Apache CLI never links AGPL code) on embedded PGlite, deploys
3
+ // the local working tree through the REAL reconciler, and redeploys on change.
4
+
5
+ import { randomBytes } from "node:crypto";
6
+ import { spawn } from "node:child_process";
7
+ import { existsSync, watch } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { ApiClient } from "../client.js";
10
+ import { EXIT } from "../exit-codes.js";
11
+ import { CliError, Output } from "../output.js";
12
+
13
+ /** Returns [command, ...args]. Prefers the package's real .mjs entry (run with node);
14
+ * falls back to the .bin shim executed directly (it is a shell script, not JS). */
15
+ function findServerBin(start: string): string[] | null {
16
+ const envBin = process.env["TESSER_SERVER_BIN"];
17
+ if (envBin && existsSync(envBin)) {
18
+ return envBin.endsWith(".mjs") || envBin.endsWith(".js") ? [process.execPath, envBin] : [envBin];
19
+ }
20
+ let dir = start;
21
+ for (;;) {
22
+ const entry = join(dir, "node_modules", "@devosurf", "tesser-server", "bin", "tesser-server.mjs");
23
+ if (existsSync(entry)) return [process.execPath, entry];
24
+ const shim = join(dir, "node_modules", ".bin", "tesser-server");
25
+ if (existsSync(shim)) return [shim];
26
+ const parent = dirname(dir);
27
+ if (parent === dir) return null;
28
+ dir = parent;
29
+ }
30
+ }
31
+
32
+ export async function dev(
33
+ out: Output,
34
+ projectRoot: string,
35
+ project: string,
36
+ opts: { port?: number; watch?: boolean },
37
+ ): Promise<void> {
38
+ const bin = findServerBin(projectRoot);
39
+ if (!bin) {
40
+ throw new CliError(
41
+ EXIT.USAGE,
42
+ "tesser-server binary not found — install @devosurf/tesser-server (pnpm add -D @devosurf/tesser-server) or set TESSER_SERVER_BIN",
43
+ );
44
+ }
45
+ const port = opts.port ?? 8377;
46
+ const token = `tsk_${randomBytes(24).toString("hex")}`;
47
+ const url = `http://localhost:${port}`;
48
+
49
+ out.log(`starting local instance on ${url} (embedded postgres at .tesser/pglite)`);
50
+ const child = spawn(bin[0]!, bin.slice(1), {
51
+ env: {
52
+ ...process.env,
53
+ PORT: String(port),
54
+ TESSER_DATA_DIR: join(projectRoot, ".tesser"),
55
+ TESSER_BOOTSTRAP_TOKEN: token,
56
+ TESSER_BASE_URL: url,
57
+ DATABASE_URL: "",
58
+ },
59
+ stdio: ["ignore", "inherit", "inherit"],
60
+ });
61
+ const stop = () => {
62
+ child.kill("SIGTERM");
63
+ };
64
+ process.on("SIGINT", () => {
65
+ stop();
66
+ process.exit(0);
67
+ });
68
+ process.on("SIGTERM", stop);
69
+
70
+ const api = new ApiClient(url, token);
71
+ const deadline = Date.now() + 30_000;
72
+ for (;;) {
73
+ try {
74
+ await api.get("/health");
75
+ break;
76
+ } catch {
77
+ if (Date.now() > deadline) throw new CliError(EXIT.ERROR, "local instance did not come up");
78
+ await new Promise((r) => setTimeout(r, 300));
79
+ }
80
+ }
81
+ await api.post("/projects", { name: project });
82
+
83
+ const syncOnce = async () => {
84
+ await api.post(`/projects/${project}/sync`, { localPath: projectRoot });
85
+ // poll to settled, print, but DON'T exit (deploy() exits — inline a light loop)
86
+ for (let i = 0; i < 600; i++) {
87
+ await new Promise((r) => setTimeout(r, 500));
88
+ const state = await api.get<{ repo?: { status: string; report?: { connectUrl?: string; failed?: unknown[] } } }>(
89
+ `/projects/${project}/deploys/latest`,
90
+ );
91
+ const status = state.repo?.status;
92
+ if (status === "syncing") continue;
93
+ if (status === "halted-credentials") {
94
+ out.log(`HALTED — connect credentials in your browser:\n ${state.repo?.report?.connectUrl}`);
95
+ } else if (status === "failed") {
96
+ out.log(`deploy failed: ${JSON.stringify(state.repo?.report?.failed ?? [])}`);
97
+ } else if (status === "synced") {
98
+ out.log(`deployed ✓ — webhooks at ${url}/hooks/${project}/<automation>`);
99
+ }
100
+ return;
101
+ }
102
+ };
103
+ await syncOnce();
104
+
105
+ if (opts.watch !== false) {
106
+ let timer: NodeJS.Timeout | null = null;
107
+ watch(join(projectRoot, "automations"), { recursive: true }, () => {
108
+ if (timer) clearTimeout(timer);
109
+ timer = setTimeout(() => {
110
+ out.log("change detected — redeploying…");
111
+ void syncOnce();
112
+ }, 400);
113
+ });
114
+ out.log("watching automations/ for changes (ctrl-c to stop)");
115
+ await new Promise(() => {}); // run until interrupted
116
+ } else {
117
+ stop();
118
+ }
119
+ }
@@ -0,0 +1,101 @@
1
+ // `tesser init <name>`: scaffold a Project — the linked repo unit (ADR-0006). One
2
+ // automation per directory; colocated tests; git is the source of truth.
3
+
4
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { EXIT } from "../exit-codes.js";
7
+ import { CliError, Output } from "../output.js";
8
+
9
+ const EXAMPLE_AUTOMATION = `import { defineAutomation, onWebhook } from "@devosurf/tesser-sdk";
10
+ import { z } from "zod";
11
+
12
+ export default defineAutomation({
13
+ id: "hello",
14
+ trigger: onWebhook({ input: z.object({ name: z.string().default("world") }) }),
15
+ output: z.object({ greeting: z.string() }),
16
+
17
+ run: async (input, ctx) => {
18
+ // Plain TypeScript. Durability ONLY from ctx.step() (every side effect goes inside one).
19
+ const greeting = await ctx.step("compose", async () => \`hello, \${input.name}!\`);
20
+ return { greeting };
21
+ },
22
+ });
23
+ `;
24
+
25
+ const EXAMPLE_TEST = `import { createTest } from "@devosurf/tesser-testing";
26
+ import automation from "./index";
27
+
28
+ test("greets by name", async () => {
29
+ const t = createTest({ automation });
30
+ const { result } = await t.run({ input: { name: "tesser" } });
31
+ expect(result).toEqual({ greeting: "hello, tesser!" });
32
+ });
33
+ `;
34
+
35
+ export function init(out: Output, name: string, opts: { dir?: string | undefined; instance?: string | undefined }): void {
36
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
37
+ throw new CliError(EXIT.USAGE, "project name must be kebab-case");
38
+ }
39
+ const root = join(opts.dir ?? process.cwd(), name);
40
+ if (existsSync(join(root, "tesser.json"))) {
41
+ throw new CliError(EXIT.CONFLICT, `${root} is already a Tesser project`);
42
+ }
43
+ mkdirSync(join(root, "automations", "hello"), { recursive: true });
44
+
45
+ writeFileSync(
46
+ join(root, "tesser.json"),
47
+ JSON.stringify({ project: name, ...(opts.instance !== undefined ? { instance: opts.instance } : {}) }, null, 2) + "\n",
48
+ );
49
+ writeFileSync(
50
+ join(root, "package.json"),
51
+ JSON.stringify(
52
+ {
53
+ name,
54
+ private: true,
55
+ type: "module",
56
+ packageManager: "pnpm@9.12.0",
57
+ scripts: { test: "tesser test", deploy: "tesser deploy", dev: "tesser dev" },
58
+ dependencies: { "@devosurf/tesser-sdk": "latest", "@devosurf/tesser-connectors": "latest", zod: "^4" },
59
+ devDependencies: {
60
+ "@devosurf/tesser": "latest",
61
+ "@devosurf/tesser-server": "latest",
62
+ "@devosurf/tesser-testing": "latest",
63
+ vitest: "^4",
64
+ },
65
+ },
66
+ null,
67
+ 2,
68
+ ) + "\n",
69
+ );
70
+ writeFileSync(
71
+ join(root, "tsconfig.json"),
72
+ JSON.stringify(
73
+ {
74
+ compilerOptions: {
75
+ target: "ES2022",
76
+ module: "ESNext",
77
+ moduleResolution: "Bundler",
78
+ strict: true,
79
+ skipLibCheck: true,
80
+ types: ["vitest/globals"],
81
+ },
82
+ include: ["automations"],
83
+ },
84
+ null,
85
+ 2,
86
+ ) + "\n",
87
+ );
88
+ writeFileSync(
89
+ join(root, "vitest.config.ts"),
90
+ `import { defineConfig } from "vitest/config";\nexport default defineConfig({ test: { globals: true, include: ["automations/**/*.test.ts"] } });\n`,
91
+ );
92
+ writeFileSync(join(root, ".gitignore"), "node_modules/\n.tesser/\n.env\n");
93
+ writeFileSync(join(root, "automations", "hello", "index.ts"), EXAMPLE_AUTOMATION);
94
+ writeFileSync(join(root, "automations", "hello", "index.test.ts"), EXAMPLE_TEST);
95
+
96
+ out.data(
97
+ { created: root, next: ["cd " + name, "git init && git add -A && git commit -m init", "npm install (or pnpm)", "tesser link", "tesser test"] },
98
+ () =>
99
+ `created ${root}\nnext:\n cd ${name}\n git init && git add -A && git commit -m init\n pnpm install\n tesser link # register on your instance\n tesser test # green in milliseconds`,
100
+ );
101
+ }
@@ -0,0 +1,81 @@
1
+ // `tesser replay <runId>` (ADR-0008): pull a real run's trigger + journal, freeze it as a
2
+ // committed fixture, and write a regression test that re-feeds the exact input with
3
+ // journal-derived mocks. The suite grows from real edge cases.
4
+
5
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import type { ApiClient } from "../client.js";
8
+ import { EXIT } from "../exit-codes.js";
9
+ import { CliError, Output } from "../output.js";
10
+
11
+ interface ReplayBundle {
12
+ replay: {
13
+ id: string;
14
+ automation_id: string;
15
+ status: string;
16
+ trigger: Record<string, unknown>;
17
+ input: unknown;
18
+ output: unknown;
19
+ error: unknown;
20
+ journal: Array<{ name: string; occurrence: number; status: string; result: unknown; error: unknown }>;
21
+ };
22
+ }
23
+
24
+ export async function replay(out: Output, api: ApiClient, projectRoot: string, runId: string): Promise<void> {
25
+ const { replay: run } = await api.get<ReplayBundle>(`/runs/${runId}/replay`);
26
+ const dir = join(projectRoot, "automations", run.automation_id);
27
+ if (!existsSync(dir)) {
28
+ throw new CliError(EXIT.NOT_FOUND, `automation directory not found locally: automations/${run.automation_id}`);
29
+ }
30
+ const shortId = run.id.slice(0, 8);
31
+ const fixtureDir = join(dir, "__replays__");
32
+ mkdirSync(fixtureDir, { recursive: true });
33
+ const fixturePath = join(fixtureDir, `${shortId}.replay.json`);
34
+ writeFileSync(
35
+ fixturePath,
36
+ JSON.stringify(
37
+ {
38
+ runId: run.id,
39
+ automation: run.automation_id,
40
+ recordedStatus: run.status,
41
+ trigger: run.trigger,
42
+ input: run.input,
43
+ output: run.output,
44
+ error: run.error,
45
+ steps: run.journal.filter((s) => !s.name.startsWith("$")),
46
+ },
47
+ null,
48
+ 2,
49
+ ) + "\n",
50
+ );
51
+
52
+ const testPath = join(dir, `replay-${shortId}.test.ts`);
53
+ writeFileSync(
54
+ testPath,
55
+ `// Regression frozen from run ${run.id} (recorded status: ${run.status}).
56
+ // Generated by \`tesser replay\` — adjust the final assertion once the bug is fixed.
57
+ import { createTest } from "@devosurf/tesser-testing";
58
+ import automation from "./index";
59
+ import replay from "./__replays__/${shortId}.replay.json";
60
+
61
+ test("replays run ${shortId} exactly", async () => {
62
+ const t = createTest({ automation });
63
+ // Recorded step results replay through the journal — completed steps return their
64
+ // captured values without executing, exactly like durable recovery (ADR-0002).
65
+ const journal = replay.steps
66
+ .filter((s) => s.status === "completed")
67
+ .map((s) => ({ name: s.name, occurrence: s.occurrence, result: s.result }));
68
+ const result = await t.run({ input: replay.input ?? undefined, journal });
69
+
70
+ // Recorded behaviour at capture time — keep as the regression contract:
71
+ expect(result.status).toBe(${JSON.stringify(run.status === "completed" ? "completed" : "failed")});
72
+ ${run.status === "completed" ? ` expect(result.result).toEqual(replay.output);` : ` // This run FAILED in production. After fixing, flip the expectation to "completed".`}
73
+ });
74
+ `,
75
+ );
76
+
77
+ out.data(
78
+ { fixture: fixturePath, test: testPath, recordedStatus: run.status },
79
+ () => `frozen run ${shortId} → ${testPath}\nfixture: ${fixturePath}\nrun \`tesser test\` to execute it`,
80
+ );
81
+ }