@thyme-sh/cli 0.2.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/dist/index.js ADDED
@@ -0,0 +1,1127 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { existsSync } from "fs";
8
+ import { mkdir, writeFile } from "fs/promises";
9
+ import { join } from "path";
10
+
11
+ // src/utils/ui.ts
12
+ import * as clack from "@clack/prompts";
13
+ import pc from "picocolors";
14
+ function intro2(title) {
15
+ clack.intro(pc.bgCyan(pc.black(` ${title} `)));
16
+ }
17
+ function outro2(message) {
18
+ clack.outro(message);
19
+ }
20
+ function error(message) {
21
+ clack.log.error(pc.red(message));
22
+ }
23
+ function info(message) {
24
+ clack.log.info(pc.cyan(message));
25
+ }
26
+ function warn(message) {
27
+ clack.log.warn(pc.yellow(message));
28
+ }
29
+ function step(message) {
30
+ clack.log.step(message);
31
+ }
32
+ function log2(message) {
33
+ console.log(message);
34
+ }
35
+
36
+ // src/commands/init.ts
37
+ async function initCommand(projectName) {
38
+ intro2("Thyme CLI - Initialize Project");
39
+ let finalProjectName = projectName;
40
+ if (!finalProjectName) {
41
+ const name = await clack.text({
42
+ message: "What is your project name?",
43
+ placeholder: "my-thyme-project",
44
+ validate: (value) => {
45
+ if (!value) return "Project name is required";
46
+ if (!/^[a-z0-9-]+$/.test(value))
47
+ return "Project name must be lowercase alphanumeric with hyphens";
48
+ }
49
+ });
50
+ if (clack.isCancel(name)) {
51
+ clack.cancel("Operation cancelled");
52
+ process.exit(0);
53
+ }
54
+ finalProjectName = name;
55
+ }
56
+ const projectPath = join(process.cwd(), finalProjectName);
57
+ if (existsSync(projectPath)) {
58
+ error(`Directory "${finalProjectName}" already exists`);
59
+ process.exit(1);
60
+ }
61
+ const spinner = clack.spinner();
62
+ spinner.start("Creating project structure...");
63
+ try {
64
+ await mkdir(projectPath, { recursive: true });
65
+ await mkdir(join(projectPath, "functions"), { recursive: true });
66
+ const packageJson = {
67
+ name: finalProjectName,
68
+ version: "0.1.0",
69
+ type: "module",
70
+ private: true,
71
+ scripts: {
72
+ dev: "thyme run"
73
+ },
74
+ dependencies: {
75
+ "@thyme-sh/sdk": "^0.1.0",
76
+ viem: "^2.21.54",
77
+ zod: "^3.24.1"
78
+ },
79
+ devDependencies: {
80
+ "@thyme-sh/cli": "^0.1.0",
81
+ typescript: "^5.7.2"
82
+ }
83
+ };
84
+ await writeFile(
85
+ join(projectPath, "package.json"),
86
+ JSON.stringify(packageJson, null, 2)
87
+ );
88
+ const tsconfig = {
89
+ compilerOptions: {
90
+ target: "ES2022",
91
+ module: "ESNext",
92
+ moduleResolution: "bundler",
93
+ lib: ["ES2022", "DOM"],
94
+ strict: true,
95
+ esModuleInterop: true,
96
+ skipLibCheck: true,
97
+ forceConsistentCasingInFileNames: true,
98
+ resolveJsonModule: true
99
+ },
100
+ include: ["functions/**/*"]
101
+ };
102
+ await writeFile(
103
+ join(projectPath, "tsconfig.json"),
104
+ JSON.stringify(tsconfig, null, 2)
105
+ );
106
+ const envExample = `# Simulation settings (for --simulate flag)
107
+ RPC_URL=https://eth-sepolia.g.alchemy.com/v2/your-key
108
+ SIMULATE_ACCOUNT=0x742d35Cc6634C0532925a3b844Bc454e4438f44e
109
+
110
+ # Cloud authentication (set by \`thyme login\`)
111
+ THYME_AUTH_TOKEN=
112
+
113
+ # Cloud API URL (required - your Convex deployment URL)
114
+ # Example: https://your-deployment.convex.cloud
115
+ THYME_API_URL=
116
+ `;
117
+ await writeFile(join(projectPath, ".env.example"), envExample);
118
+ const gitignore = `node_modules/
119
+ dist/
120
+ .env
121
+ .env.local
122
+ *.log
123
+ `;
124
+ await writeFile(join(projectPath, ".gitignore"), gitignore);
125
+ const readme = `# ${finalProjectName}
126
+
127
+ A Thyme project for Web3 automation tasks.
128
+
129
+ ## Getting Started
130
+
131
+ \`\`\`bash
132
+ # Install dependencies
133
+ npm install
134
+
135
+ # Create a new task
136
+ thyme new my-task
137
+
138
+ # Run a task locally
139
+ thyme run my-task
140
+
141
+ # Simulate on-chain
142
+ thyme run my-task --simulate
143
+
144
+ # Deploy to cloud
145
+ thyme login
146
+ thyme upload my-task
147
+ \`\`\`
148
+
149
+ ## Project Structure
150
+
151
+ \`\`\`
152
+ functions/
153
+ my-task/
154
+ index.ts # Task definition
155
+ args.json # Test arguments
156
+ \`\`\`
157
+ `;
158
+ await writeFile(join(projectPath, "README.md"), readme);
159
+ spinner.stop("Project created successfully!");
160
+ outro2(
161
+ `${pc.green("\u2713")} Project initialized!
162
+
163
+ Next steps:
164
+ ${pc.cyan("cd")} ${finalProjectName}
165
+ ${pc.cyan("npm install")}
166
+ ${pc.cyan("thyme new")} my-task
167
+ ${pc.cyan("thyme run")} my-task`
168
+ );
169
+ } catch (err) {
170
+ spinner.stop("Failed to create project");
171
+ error(err instanceof Error ? err.message : String(err));
172
+ process.exit(1);
173
+ }
174
+ }
175
+
176
+ // src/utils/tasks.ts
177
+ import { existsSync as existsSync2 } from "fs";
178
+ import { readdir } from "fs/promises";
179
+ import { join as join2 } from "path";
180
+ async function discoverTasks(projectRoot) {
181
+ const functionsDir = join2(projectRoot, "functions");
182
+ if (!existsSync2(functionsDir)) {
183
+ return [];
184
+ }
185
+ try {
186
+ const entries = await readdir(functionsDir, { withFileTypes: true });
187
+ return entries.filter((e) => e.isDirectory()).filter((e) => existsSync2(join2(functionsDir, e.name, "index.ts"))).map((e) => e.name);
188
+ } catch {
189
+ return [];
190
+ }
191
+ }
192
+ function getTaskPath(projectRoot, taskName) {
193
+ return join2(projectRoot, "functions", taskName, "index.ts");
194
+ }
195
+ function getTaskArgsPath(projectRoot, taskName) {
196
+ return join2(projectRoot, "functions", taskName, "args.json");
197
+ }
198
+ function isThymeProject(projectRoot) {
199
+ const functionsDir = join2(projectRoot, "functions");
200
+ return existsSync2(functionsDir);
201
+ }
202
+
203
+ // src/commands/list.ts
204
+ async function listCommand() {
205
+ intro2("Thyme CLI - List Tasks");
206
+ const projectRoot = process.cwd();
207
+ if (!isThymeProject(projectRoot)) {
208
+ outro2(pc.red("Not in a Thyme project"));
209
+ process.exit(1);
210
+ }
211
+ const tasks = await discoverTasks(projectRoot);
212
+ if (tasks.length === 0) {
213
+ outro2(pc.yellow("No tasks found. Create one with `thyme new`"));
214
+ return;
215
+ }
216
+ step(`Found ${tasks.length} task(s):`);
217
+ for (const task of tasks) {
218
+ console.log(` ${pc.cyan("\u25CF")} ${task}`);
219
+ }
220
+ outro2("");
221
+ }
222
+
223
+ // src/commands/login.ts
224
+ import { existsSync as existsSync4 } from "fs";
225
+ import { appendFile, readFile, writeFile as writeFile2 } from "fs/promises";
226
+ import { join as join4 } from "path";
227
+
228
+ // src/utils/env.ts
229
+ import { existsSync as existsSync3 } from "fs";
230
+ import { join as join3 } from "path";
231
+ import { config } from "dotenv";
232
+ function loadEnv(projectRoot) {
233
+ const envPath = join3(projectRoot, ".env");
234
+ if (existsSync3(envPath)) {
235
+ config({ path: envPath });
236
+ }
237
+ }
238
+ function getEnv(key, fallback) {
239
+ return process.env[key] ?? fallback;
240
+ }
241
+
242
+ // src/commands/login.ts
243
+ async function loginCommand() {
244
+ intro2("Thyme CLI - Login");
245
+ const projectRoot = process.cwd();
246
+ const envPath = join4(projectRoot, ".env");
247
+ loadEnv(projectRoot);
248
+ info("To authenticate with Thyme Cloud:");
249
+ clack.log.message(
250
+ ` 1. Visit ${pc.cyan("https://thyme.sh/settings/api-keys")}`
251
+ );
252
+ clack.log.message(" 2. Generate a new API token");
253
+ clack.log.message(" 3. Copy the token and paste it below");
254
+ clack.log.message("");
255
+ const token = await clack.password({
256
+ message: "Paste your API token:",
257
+ validate: (value) => {
258
+ if (!value) return "Token is required";
259
+ if (value.length < 10) return "Token seems too short";
260
+ }
261
+ });
262
+ if (clack.isCancel(token)) {
263
+ clack.cancel("Operation cancelled");
264
+ process.exit(0);
265
+ }
266
+ const spinner = clack.spinner();
267
+ spinner.start("Verifying token...");
268
+ try {
269
+ const apiUrl = getEnv("THYME_API_URL");
270
+ if (!apiUrl) {
271
+ spinner.stop("Configuration error");
272
+ error(
273
+ "THYME_API_URL is not set. Please set it to your Convex deployment URL (e.g., https://your-deployment.convex.cloud)"
274
+ );
275
+ process.exit(1);
276
+ }
277
+ const verifyResponse = await fetch(`${apiUrl}/api/auth/verify`, {
278
+ method: "GET",
279
+ headers: {
280
+ Authorization: `Bearer ${token}`
281
+ }
282
+ });
283
+ if (!verifyResponse.ok) {
284
+ spinner.stop("Token verification failed");
285
+ const errorText = await verifyResponse.text();
286
+ error(`Invalid token: ${errorText}`);
287
+ process.exit(1);
288
+ }
289
+ const verifyData = await verifyResponse.json();
290
+ spinner.stop("Token verified!");
291
+ const saveSpinner = clack.spinner();
292
+ saveSpinner.start("Saving token...");
293
+ let envContent = "";
294
+ if (existsSync4(envPath)) {
295
+ envContent = await readFile(envPath, "utf-8");
296
+ }
297
+ const tokenRegex = /^THYME_AUTH_TOKEN=.*$/m;
298
+ if (tokenRegex.test(envContent)) {
299
+ envContent = envContent.replace(tokenRegex, `THYME_AUTH_TOKEN=${token}`);
300
+ await writeFile2(envPath, envContent);
301
+ } else {
302
+ const newLine = envContent && !envContent.endsWith("\n") ? "\n" : "";
303
+ await appendFile(envPath, `${newLine}THYME_AUTH_TOKEN=${token}
304
+ `);
305
+ }
306
+ saveSpinner.stop("Token saved successfully!");
307
+ clack.log.message("");
308
+ clack.log.success("Authenticated as:");
309
+ clack.log.message(
310
+ ` ${pc.cyan("User:")} ${verifyData.user.name || verifyData.user.email}`
311
+ );
312
+ clack.log.message(` ${pc.cyan("Email:")} ${verifyData.user.email}`);
313
+ if (verifyData.organizations && verifyData.organizations.length > 0) {
314
+ clack.log.message("");
315
+ clack.log.message(`${pc.cyan("Organizations:")}`);
316
+ for (const org of verifyData.organizations) {
317
+ clack.log.message(` \u2022 ${org.name} ${pc.dim(`(${org.role})`)}`);
318
+ }
319
+ }
320
+ outro2(`
321
+ You can now upload tasks with ${pc.cyan("thyme upload")}`);
322
+ } catch (err) {
323
+ spinner.stop("Failed to verify token");
324
+ error(err instanceof Error ? err.message : String(err));
325
+ process.exit(1);
326
+ }
327
+ }
328
+
329
+ // src/commands/new.ts
330
+ import { existsSync as existsSync5 } from "fs";
331
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
332
+ import { join as join5 } from "path";
333
+ async function newCommand(taskName) {
334
+ intro2("Thyme CLI - Create New Task");
335
+ const projectRoot = process.cwd();
336
+ if (!isThymeProject(projectRoot)) {
337
+ error("Not in a Thyme project. Run `thyme init` first.");
338
+ process.exit(1);
339
+ }
340
+ let finalTaskName = taskName;
341
+ if (!finalTaskName) {
342
+ const name = await clack.text({
343
+ message: "What is your task name?",
344
+ placeholder: "my-task",
345
+ validate: (value) => {
346
+ if (!value) return "Task name is required";
347
+ if (!/^[a-z0-9-]+$/.test(value))
348
+ return "Task name must be lowercase alphanumeric with hyphens";
349
+ }
350
+ });
351
+ if (clack.isCancel(name)) {
352
+ clack.cancel("Operation cancelled");
353
+ process.exit(0);
354
+ }
355
+ finalTaskName = name;
356
+ }
357
+ const taskPath = join5(projectRoot, "functions", finalTaskName);
358
+ if (existsSync5(taskPath)) {
359
+ error(`Task "${finalTaskName}" already exists`);
360
+ process.exit(1);
361
+ }
362
+ const spinner = clack.spinner();
363
+ spinner.start("Creating task...");
364
+ try {
365
+ await mkdir2(taskPath, { recursive: true });
366
+ const indexTs = `import { defineTask, z } from '@thyme-sh/sdk'
367
+ import { encodeFunctionData } from 'viem'
368
+
369
+ export default defineTask({
370
+ schema: z.object({
371
+ targetAddress: z.address(),
372
+ }),
373
+
374
+ async run(ctx) {
375
+ const { targetAddress } = ctx.args
376
+
377
+ // Your task logic here
378
+ console.log('Running task with address:', targetAddress)
379
+
380
+ // Example: Read from blockchain using the public client
381
+ // const balance = await ctx.client.getBalance({ address: targetAddress })
382
+ // const blockNumber = await ctx.client.getBlockNumber()
383
+ // const value = await ctx.client.readContract({
384
+ // address: targetAddress,
385
+ // abi: [...],
386
+ // functionName: 'balanceOf',
387
+ // args: [someAddress],
388
+ // })
389
+
390
+ // Example: Return calls to execute
391
+ return {
392
+ canExec: true,
393
+ calls: [
394
+ {
395
+ to: targetAddress,
396
+ data: '0x' as const,
397
+ },
398
+ ],
399
+ }
400
+
401
+ // Example with encodeFunctionData:
402
+ // const abi = [...] as const
403
+ // return {
404
+ // canExec: true,
405
+ // calls: [
406
+ // {
407
+ // to: targetAddress,
408
+ // data: encodeFunctionData({
409
+ // abi,
410
+ // functionName: 'transfer',
411
+ // args: [recipientAddress, 1000n],
412
+ // }),
413
+ // },
414
+ // ],
415
+ // }
416
+
417
+ // Or return false if conditions not met
418
+ // return {
419
+ // canExec: false,
420
+ // message: 'Conditions not met'
421
+ // }
422
+ },
423
+ })
424
+ `;
425
+ await writeFile3(join5(taskPath, "index.ts"), indexTs);
426
+ const args = {
427
+ targetAddress: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
428
+ };
429
+ await writeFile3(join5(taskPath, "args.json"), JSON.stringify(args, null, 2));
430
+ spinner.stop("Task created successfully!");
431
+ outro2(
432
+ `${pc.green("\u2713")} Task "${finalTaskName}" created!
433
+
434
+ Next steps:
435
+ ${pc.cyan("Edit")} functions/${finalTaskName}/index.ts
436
+ ${pc.cyan("Update")} functions/${finalTaskName}/args.json
437
+ ${pc.cyan("thyme run")} ${finalTaskName}`
438
+ );
439
+ } catch (err) {
440
+ spinner.stop("Failed to create task");
441
+ error(err instanceof Error ? err.message : String(err));
442
+ process.exit(1);
443
+ }
444
+ }
445
+
446
+ // src/commands/run.ts
447
+ import { existsSync as existsSync6 } from "fs";
448
+ import { readFile as readFile2 } from "fs/promises";
449
+ import { http, createPublicClient, formatEther } from "viem";
450
+
451
+ // src/deno/runner.ts
452
+ import { spawn } from "child_process";
453
+ import { dirname, resolve } from "path";
454
+ async function runInDeno(taskPath, args, config2) {
455
+ const taskDir = dirname(resolve(taskPath));
456
+ const absoluteTaskPath = resolve(taskPath);
457
+ const denoFlags = ["run", "--no-prompt"];
458
+ denoFlags.push(`--allow-read=${taskDir}`);
459
+ if (config2.memory) {
460
+ denoFlags.push(`--v8-flags=--max-old-space-size=${config2.memory}`);
461
+ }
462
+ if (config2.network) {
463
+ denoFlags.push("--allow-net");
464
+ }
465
+ denoFlags.push("-");
466
+ const execScript = `
467
+ import task from '${absoluteTaskPath}';
468
+ import { createPublicClient, http } from 'npm:viem@2.21.54';
469
+
470
+ // Create RPC request counter
471
+ let rpcRequestCount = 0;
472
+
473
+ // Wrap the http transport to count requests
474
+ const countingHttp = (url) => {
475
+ const baseTransport = http(url);
476
+ return (config) => {
477
+ const transport = baseTransport(config);
478
+ return {
479
+ ...transport,
480
+ request: async (params) => {
481
+ rpcRequestCount++;
482
+ return transport.request(params);
483
+ },
484
+ };
485
+ };
486
+ };
487
+
488
+ // Create public client for blockchain reads
489
+ const client = createPublicClient({
490
+ transport: countingHttp(${config2.rpcUrl ? `'${config2.rpcUrl}'` : "undefined"}),
491
+ });
492
+
493
+ const context = {
494
+ args: ${JSON.stringify(args)},
495
+ client,
496
+ };
497
+
498
+ try {
499
+ // Track execution time and memory
500
+ const startTime = performance.now();
501
+ const startMemory = Deno.memoryUsage().heapUsed;
502
+
503
+ const result = await task.run(context);
504
+
505
+ const endTime = performance.now();
506
+ const endMemory = Deno.memoryUsage().heapUsed;
507
+
508
+ const executionTime = endTime - startTime;
509
+ const memoryUsed = endMemory - startMemory;
510
+
511
+ console.log('__THYME_RESULT__' + JSON.stringify(result));
512
+ console.log('__THYME_STATS__' + JSON.stringify({ executionTime, memoryUsed, rpcRequestCount }));
513
+ } catch (error) {
514
+ console.error('Task execution error:', error instanceof Error ? error.message : String(error));
515
+ Deno.exit(1);
516
+ }
517
+ `;
518
+ return new Promise((resolve2) => {
519
+ const proc = spawn("deno", denoFlags, {
520
+ stdio: ["pipe", "pipe", "pipe"],
521
+ timeout: config2.timeout * 1e3,
522
+ cwd: taskDir
523
+ });
524
+ let stdout = "";
525
+ let stderr = "";
526
+ const logs = [];
527
+ proc.stdin?.write(execScript);
528
+ proc.stdin?.end();
529
+ proc.stdout?.on("data", (data) => {
530
+ stdout += data.toString();
531
+ });
532
+ proc.stderr?.on("data", (data) => {
533
+ stderr += data.toString();
534
+ });
535
+ proc.on("close", (code) => {
536
+ if (code !== 0) {
537
+ resolve2({
538
+ success: false,
539
+ logs,
540
+ error: stderr || `Process exited with code ${code}`
541
+ });
542
+ return;
543
+ }
544
+ try {
545
+ const lines = stdout.trim().split("\n");
546
+ let resultLine;
547
+ let statsLine;
548
+ for (const line of lines) {
549
+ if (line.startsWith("__THYME_RESULT__")) {
550
+ resultLine = line.substring("__THYME_RESULT__".length);
551
+ } else if (line.startsWith("__THYME_STATS__")) {
552
+ statsLine = line.substring("__THYME_STATS__".length);
553
+ } else if (line.trim()) {
554
+ logs.push(line.trim());
555
+ }
556
+ }
557
+ if (!resultLine) {
558
+ throw new Error("No result found in output");
559
+ }
560
+ const result = JSON.parse(resultLine);
561
+ const stats = statsLine ? JSON.parse(statsLine) : {
562
+ executionTime: void 0,
563
+ memoryUsed: void 0,
564
+ rpcRequestCount: void 0
565
+ };
566
+ resolve2({
567
+ success: true,
568
+ result,
569
+ logs,
570
+ executionTime: stats.executionTime,
571
+ memoryUsed: stats.memoryUsed,
572
+ rpcRequestCount: stats.rpcRequestCount
573
+ });
574
+ } catch (error2) {
575
+ resolve2({
576
+ success: false,
577
+ logs,
578
+ error: `Failed to parse result: ${error2 instanceof Error ? error2.message : String(error2)}`
579
+ });
580
+ }
581
+ });
582
+ proc.on("error", (error2) => {
583
+ resolve2({
584
+ success: false,
585
+ logs,
586
+ error: `Failed to spawn Deno: ${error2.message}`
587
+ });
588
+ });
589
+ });
590
+ }
591
+ async function checkDeno() {
592
+ return new Promise((resolve2) => {
593
+ const proc = spawn("deno", ["--version"], { stdio: "ignore" });
594
+ proc.on("close", (code) => resolve2(code === 0));
595
+ proc.on("error", () => resolve2(false));
596
+ });
597
+ }
598
+
599
+ // src/commands/run.ts
600
+ async function runCommand(taskName, options = {}) {
601
+ intro2("Thyme CLI - Run Task");
602
+ const projectRoot = process.cwd();
603
+ loadEnv(projectRoot);
604
+ if (!isThymeProject(projectRoot)) {
605
+ error("Not in a Thyme project");
606
+ process.exit(1);
607
+ }
608
+ const hasDeno = await checkDeno();
609
+ if (!hasDeno) {
610
+ error("Deno is not installed. Please install Deno: https://deno.land/");
611
+ process.exit(1);
612
+ }
613
+ let finalTaskName = taskName;
614
+ if (!finalTaskName) {
615
+ const tasks = await discoverTasks(projectRoot);
616
+ if (tasks.length === 0) {
617
+ error("No tasks found. Create one with `thyme new`");
618
+ process.exit(1);
619
+ }
620
+ const selected = await clack.select({
621
+ message: "Select a task to run:",
622
+ options: tasks.map((task) => ({ value: task, label: task }))
623
+ });
624
+ if (clack.isCancel(selected)) {
625
+ clack.cancel("Operation cancelled");
626
+ process.exit(0);
627
+ }
628
+ finalTaskName = selected;
629
+ }
630
+ const taskPath = getTaskPath(projectRoot, finalTaskName);
631
+ const argsPath = getTaskArgsPath(projectRoot, finalTaskName);
632
+ if (!existsSync6(taskPath)) {
633
+ error(`Task "${finalTaskName}" not found`);
634
+ process.exit(1);
635
+ }
636
+ const config2 = {
637
+ memory: 128,
638
+ timeout: 30,
639
+ network: true,
640
+ rpcUrl: getEnv("RPC_URL")
641
+ };
642
+ let args = {};
643
+ if (existsSync6(argsPath)) {
644
+ try {
645
+ const argsData = await readFile2(argsPath, "utf-8");
646
+ args = JSON.parse(argsData);
647
+ } catch (err) {
648
+ warn(
649
+ `Failed to load args.json: ${err instanceof Error ? err.message : String(err)}`
650
+ );
651
+ }
652
+ }
653
+ const spinner = clack.spinner();
654
+ spinner.start("Executing task in Deno sandbox...");
655
+ const result = await runInDeno(taskPath, args, config2);
656
+ if (!result.success) {
657
+ spinner.stop("Task execution failed");
658
+ error(result.error ?? "Unknown error");
659
+ if (result.logs.length > 0) {
660
+ step("Task output:");
661
+ for (const taskLog of result.logs) {
662
+ log2(` ${taskLog}`);
663
+ }
664
+ }
665
+ process.exit(1);
666
+ }
667
+ spinner.stop("Task executed successfully");
668
+ if (result.logs.length > 0) {
669
+ log2("");
670
+ step("Task output:");
671
+ for (const taskLog of result.logs) {
672
+ log2(` ${taskLog}`);
673
+ }
674
+ }
675
+ if (!result.result) {
676
+ error("No result returned from task");
677
+ process.exit(1);
678
+ }
679
+ log2("");
680
+ if (result.result.canExec) {
681
+ info(
682
+ `${pc.green("\u2713")} Result: canExec = true (${result.result.calls.length} call(s))`
683
+ );
684
+ log2("");
685
+ step("Calls to execute:");
686
+ for (const call of result.result.calls) {
687
+ log2(` ${pc.cyan("\u2192")} to: ${call.to}`);
688
+ log2(` data: ${call.data.slice(0, 20)}...`);
689
+ }
690
+ if (options.simulate) {
691
+ log2("");
692
+ await simulateCalls(result.result.calls);
693
+ }
694
+ } else {
695
+ warn("Result: canExec = false");
696
+ info(`Message: ${result.result.message}`);
697
+ }
698
+ log2("");
699
+ if (result.executionTime !== void 0 || result.memoryUsed !== void 0 || result.rpcRequestCount !== void 0) {
700
+ step("Execution stats:");
701
+ if (result.executionTime !== void 0) {
702
+ log2(` Duration: ${result.executionTime.toFixed(2)}ms`);
703
+ }
704
+ if (result.memoryUsed !== void 0) {
705
+ const memoryMB = (result.memoryUsed / 1024 / 1024).toFixed(2);
706
+ log2(` Memory: ${memoryMB}MB`);
707
+ }
708
+ if (result.rpcRequestCount !== void 0) {
709
+ log2(` RPC Requests: ${result.rpcRequestCount}`);
710
+ }
711
+ }
712
+ if (result.result?.canExec && !options.simulate) {
713
+ log2("");
714
+ info(
715
+ `${pc.dim("\u{1F4A1} Tip: Test calls on-chain with:")} ${pc.cyan(`thyme run ${finalTaskName} --simulate`)}`
716
+ );
717
+ outro2("");
718
+ } else {
719
+ outro2("");
720
+ }
721
+ }
722
+ async function simulateCalls(calls) {
723
+ const rpcUrl = getEnv("RPC_URL");
724
+ const account = getEnv("SIMULATE_ACCOUNT");
725
+ if (!rpcUrl || !account) {
726
+ warn("Simulation requires RPC_URL and SIMULATE_ACCOUNT in .env file");
727
+ return;
728
+ }
729
+ const spinner = clack.spinner();
730
+ spinner.start("Simulating on-chain...");
731
+ try {
732
+ const client = createPublicClient({
733
+ transport: http(rpcUrl)
734
+ });
735
+ const chainId = await client.getChainId();
736
+ const blockNumber = await client.getBlockNumber();
737
+ spinner.stop("Simulating on-chain...");
738
+ log2("");
739
+ info(`Chain ID: ${chainId}`);
740
+ info(`Block: ${blockNumber}`);
741
+ info(`Account: ${account}`);
742
+ const simulationSpinner = clack.spinner();
743
+ simulationSpinner.start("Running simulation...");
744
+ for (const call of calls) {
745
+ try {
746
+ await client.call({
747
+ account,
748
+ to: call.to,
749
+ data: call.data
750
+ });
751
+ } catch (err) {
752
+ simulationSpinner.stop("Simulation failed");
753
+ log2("");
754
+ error(
755
+ `Call to ${call.to} would revert: ${err instanceof Error ? err.message : String(err)}`
756
+ );
757
+ return;
758
+ }
759
+ }
760
+ const gasPrice = await client.getGasPrice();
761
+ simulationSpinner.stop("Simulation successful!");
762
+ log2("");
763
+ step("Simulation results:");
764
+ log2(` ${pc.green("\u2713")} All calls would succeed`);
765
+ log2(` Gas price: ${formatEther(gasPrice)} ETH`);
766
+ } catch (err) {
767
+ spinner.stop("Simulation failed");
768
+ log2("");
769
+ error(err instanceof Error ? err.message : String(err));
770
+ }
771
+ }
772
+
773
+ // src/commands/upload.ts
774
+ import { existsSync as existsSync7 } from "fs";
775
+
776
+ // src/utils/bundler.ts
777
+ import { readFile as readFile3 } from "fs/promises";
778
+ import { build } from "esbuild";
779
+ async function bundleTask(taskPath) {
780
+ const source = await readFile3(taskPath, "utf-8");
781
+ const nodeBuiltins = [
782
+ "assert",
783
+ "buffer",
784
+ "child_process",
785
+ "cluster",
786
+ "crypto",
787
+ "dgram",
788
+ "dns",
789
+ "events",
790
+ "fs",
791
+ "http",
792
+ "http2",
793
+ "https",
794
+ "net",
795
+ "os",
796
+ "path",
797
+ "perf_hooks",
798
+ "process",
799
+ "querystring",
800
+ "readline",
801
+ "stream",
802
+ "string_decoder",
803
+ "timers",
804
+ "tls",
805
+ "tty",
806
+ "url",
807
+ "util",
808
+ "v8",
809
+ "vm",
810
+ "zlib",
811
+ // Node: prefix versions
812
+ "node:assert",
813
+ "node:buffer",
814
+ "node:child_process",
815
+ "node:cluster",
816
+ "node:crypto",
817
+ "node:dgram",
818
+ "node:dns",
819
+ "node:events",
820
+ "node:fs",
821
+ "node:http",
822
+ "node:http2",
823
+ "node:https",
824
+ "node:net",
825
+ "node:os",
826
+ "node:path",
827
+ "node:perf_hooks",
828
+ "node:process",
829
+ "node:querystring",
830
+ "node:readline",
831
+ "node:stream",
832
+ "node:string_decoder",
833
+ "node:timers",
834
+ "node:tls",
835
+ "node:tty",
836
+ "node:url",
837
+ "node:util",
838
+ "node:v8",
839
+ "node:vm",
840
+ "node:zlib"
841
+ ];
842
+ const result = await build({
843
+ entryPoints: [taskPath],
844
+ bundle: true,
845
+ format: "esm",
846
+ platform: "neutral",
847
+ target: "esnext",
848
+ write: false,
849
+ treeShaking: true,
850
+ minify: false,
851
+ // Keep readable for debugging
852
+ sourcemap: false,
853
+ external: nodeBuiltins,
854
+ // Don't bundle Node.js built-ins
855
+ logLevel: "silent"
856
+ });
857
+ if (result.outputFiles.length === 0) {
858
+ throw new Error("No output from bundler");
859
+ }
860
+ const bundle = result.outputFiles[0].text;
861
+ return {
862
+ source,
863
+ bundle
864
+ };
865
+ }
866
+
867
+ // src/utils/compress.ts
868
+ import { compressTask as sdkCompressTask } from "@thyme-sh/sdk";
869
+ function compressTask(source, bundle) {
870
+ const { zipBuffer, checksum } = sdkCompressTask(source, bundle);
871
+ return {
872
+ zipBuffer: Buffer.from(zipBuffer),
873
+ checksum
874
+ };
875
+ }
876
+
877
+ // src/utils/schema-extractor.ts
878
+ function extractSchemaFromTask(taskCode) {
879
+ try {
880
+ const schemaExtractor = `
881
+ const { z } = require('zod');
882
+ const { zodToJsonSchema } = require('zod-to-json-schema');
883
+
884
+ // Extended z with address validator
885
+ const zodExtended = {
886
+ ...z,
887
+ address: () => z.string().refine((val) => /^0x[a-fA-F0-9]{40}$/.test(val), {
888
+ message: 'Invalid Ethereum address',
889
+ }),
890
+ };
891
+
892
+ // Mock defineTask to capture schema
893
+ let capturedSchema = null;
894
+ const defineTask = (definition) => {
895
+ if (definition.schema) {
896
+ capturedSchema = definition.schema;
897
+ }
898
+ return definition;
899
+ };
900
+
901
+ // Mock viem exports
902
+ const encodeFunctionData = () => '0x';
903
+
904
+ // Evaluate task code
905
+ ${taskCode}
906
+
907
+ // Convert schema to JSON Schema
908
+ if (capturedSchema) {
909
+ const jsonSchema = zodToJsonSchema(capturedSchema, { target: 'openApi3' });
910
+ console.log(JSON.stringify(jsonSchema));
911
+ } else {
912
+ console.log('null');
913
+ }
914
+ `;
915
+ const schemaMatch = taskCode.match(/schema:\s*z\.object\(\{([^}]+)\}\)/);
916
+ if (!schemaMatch) {
917
+ return null;
918
+ }
919
+ const schemaContent = schemaMatch[1];
920
+ const fields = {};
921
+ const fieldMatches = schemaContent.matchAll(
922
+ /(\w+):\s*z\.(\w+)\(\)/g
923
+ );
924
+ for (const match of fieldMatches) {
925
+ const [, fieldName, fieldType] = match;
926
+ if (fieldName && fieldType) {
927
+ let jsonType = "string";
928
+ switch (fieldType) {
929
+ case "string":
930
+ jsonType = "string";
931
+ break;
932
+ case "number":
933
+ jsonType = "number";
934
+ break;
935
+ case "boolean":
936
+ jsonType = "boolean";
937
+ break;
938
+ case "address":
939
+ jsonType = "string";
940
+ break;
941
+ default:
942
+ jsonType = "string";
943
+ }
944
+ fields[fieldName] = {
945
+ type: jsonType,
946
+ ...fieldType === "address" && {
947
+ pattern: "^0x[a-fA-F0-9]{40}$",
948
+ description: "Ethereum address"
949
+ }
950
+ };
951
+ }
952
+ }
953
+ if (Object.keys(fields).length === 0) {
954
+ return null;
955
+ }
956
+ const jsonSchema = {
957
+ type: "object",
958
+ properties: fields,
959
+ required: Object.keys(fields)
960
+ };
961
+ return JSON.stringify(jsonSchema);
962
+ } catch (err) {
963
+ console.error("Error extracting schema:", err);
964
+ return null;
965
+ }
966
+ }
967
+
968
+ // src/commands/upload.ts
969
+ async function uploadCommand(taskName, organizationId) {
970
+ intro2("Thyme CLI - Upload Task");
971
+ const projectRoot = process.cwd();
972
+ loadEnv(projectRoot);
973
+ if (!isThymeProject(projectRoot)) {
974
+ error("Not in a Thyme project");
975
+ process.exit(1);
976
+ }
977
+ const authToken = getEnv("THYME_AUTH_TOKEN");
978
+ if (!authToken) {
979
+ error("Not authenticated. Run `thyme login` first.");
980
+ process.exit(1);
981
+ }
982
+ const apiUrl = getEnv("THYME_API_URL");
983
+ if (!apiUrl) {
984
+ error(
985
+ "THYME_API_URL is not set. Please set it to your Convex deployment URL in .env"
986
+ );
987
+ process.exit(1);
988
+ }
989
+ let finalTaskName = taskName;
990
+ if (!finalTaskName) {
991
+ const tasks = await discoverTasks(projectRoot);
992
+ if (tasks.length === 0) {
993
+ error("No tasks found. Create one with `thyme new`");
994
+ process.exit(1);
995
+ }
996
+ const selected = await clack.select({
997
+ message: "Select a task to upload:",
998
+ options: tasks.map((task) => ({ value: task, label: task }))
999
+ });
1000
+ if (clack.isCancel(selected)) {
1001
+ clack.cancel("Operation cancelled");
1002
+ process.exit(0);
1003
+ }
1004
+ finalTaskName = selected;
1005
+ }
1006
+ const orgSpinner = clack.spinner();
1007
+ orgSpinner.start("Fetching organizations...");
1008
+ let organizations = [];
1009
+ try {
1010
+ const verifyResponse = await fetch(`${apiUrl}/api/auth/verify`, {
1011
+ method: "GET",
1012
+ headers: {
1013
+ Authorization: `Bearer ${authToken}`
1014
+ }
1015
+ });
1016
+ if (!verifyResponse.ok) {
1017
+ orgSpinner.stop("Failed to fetch organizations");
1018
+ error("Failed to authenticate. Please run `thyme login` again.");
1019
+ process.exit(1);
1020
+ }
1021
+ const verifyData = await verifyResponse.json();
1022
+ organizations = verifyData.organizations || [];
1023
+ orgSpinner.stop("Organizations loaded");
1024
+ } catch (err) {
1025
+ orgSpinner.stop("Failed to fetch organizations");
1026
+ error(err instanceof Error ? err.message : String(err));
1027
+ process.exit(1);
1028
+ }
1029
+ if (organizations.length === 0) {
1030
+ error("You are not a member of any organizations. Please create or join an organization first.");
1031
+ process.exit(1);
1032
+ }
1033
+ let selectedOrgId = organizationId;
1034
+ if (selectedOrgId) {
1035
+ const orgExists = organizations.find((org) => org.id === selectedOrgId);
1036
+ if (!orgExists) {
1037
+ error(`Organization with ID "${selectedOrgId}" not found or you don't have access to it.`);
1038
+ process.exit(1);
1039
+ }
1040
+ } else {
1041
+ const selected = await clack.select({
1042
+ message: "Select an organization to upload to:",
1043
+ options: organizations.map((org) => ({
1044
+ value: org.id,
1045
+ label: `${org.name} ${pc.dim(`(${org.role})`)}`
1046
+ }))
1047
+ });
1048
+ if (clack.isCancel(selected)) {
1049
+ clack.cancel("Operation cancelled");
1050
+ process.exit(0);
1051
+ }
1052
+ selectedOrgId = selected;
1053
+ }
1054
+ const taskPath = getTaskPath(projectRoot, finalTaskName);
1055
+ if (!existsSync7(taskPath)) {
1056
+ error(`Task "${finalTaskName}" not found`);
1057
+ process.exit(1);
1058
+ }
1059
+ const spinner = clack.spinner();
1060
+ spinner.start("Bundling task...");
1061
+ try {
1062
+ const { source, bundle } = await bundleTask(taskPath);
1063
+ spinner.message("Extracting schema...");
1064
+ const schema = extractSchemaFromTask(source);
1065
+ spinner.message("Compressing files...");
1066
+ const { zipBuffer, checksum } = compressTask(source, bundle);
1067
+ spinner.message("Uploading to cloud...");
1068
+ const formData = new FormData();
1069
+ formData.append(
1070
+ "data",
1071
+ JSON.stringify({
1072
+ organizationId: selectedOrgId,
1073
+ checkSum: checksum,
1074
+ schema: schema || void 0
1075
+ })
1076
+ );
1077
+ formData.append("blob", new Blob([zipBuffer]), "task.zip");
1078
+ const response = await fetch(`${apiUrl}/api/task/upload`, {
1079
+ method: "POST",
1080
+ headers: {
1081
+ Authorization: `Bearer ${authToken}`
1082
+ },
1083
+ body: formData
1084
+ });
1085
+ if (!response.ok) {
1086
+ const errorText = await response.text();
1087
+ throw new Error(`Upload failed: ${errorText}`);
1088
+ }
1089
+ const result = await response.json();
1090
+ spinner.stop("Task uploaded successfully!");
1091
+ const selectedOrg = organizations.find((org) => org.id === selectedOrgId);
1092
+ clack.log.message("");
1093
+ clack.log.success("Upload details:");
1094
+ clack.log.message(` ${pc.dim("Task:")} ${pc.cyan(finalTaskName)}`);
1095
+ clack.log.message(
1096
+ ` ${pc.dim("Organization:")} ${pc.cyan(selectedOrg?.name || "Unknown")}`
1097
+ );
1098
+ clack.log.message(
1099
+ ` ${pc.dim("Size:")} ${(zipBuffer.length / 1024).toFixed(2)} KB`
1100
+ );
1101
+ clack.log.message(` ${pc.dim("Checksum:")} ${checksum.slice(0, 16)}...`);
1102
+ if (result.taskId) {
1103
+ clack.log.message(` ${pc.dim("Task ID:")} ${pc.green(result.taskId)}`);
1104
+ }
1105
+ outro2(
1106
+ `${pc.green("\u2713")} Task uploaded!
1107
+
1108
+ Configure triggers in the dashboard: ${pc.cyan("https://thyme.sh/dashboard")}`
1109
+ );
1110
+ } catch (err) {
1111
+ spinner.stop("Upload failed");
1112
+ error(err instanceof Error ? err.message : String(err));
1113
+ process.exit(1);
1114
+ }
1115
+ }
1116
+
1117
+ // src/index.ts
1118
+ var program = new Command();
1119
+ program.name("thyme").description("CLI for developing and deploying Thyme tasks").version("0.1.0");
1120
+ program.command("init").description("Initialize a new Thyme project").argument("[name]", "Project name").action(initCommand);
1121
+ program.command("new").description("Create a new task").argument("[name]", "Task name").action(newCommand);
1122
+ program.command("run").description("Run a task locally").argument("[task]", "Task name").option("--simulate", "Simulate on-chain execution").action((task, options) => runCommand(task, options));
1123
+ program.command("list").description("List all tasks").action(listCommand);
1124
+ program.command("login").description("Authenticate with Thyme Cloud").action(loginCommand);
1125
+ program.command("upload").description("Upload a task to Thyme Cloud").argument("[task]", "Task name").option("-o, --organization <id>", "Organization ID to upload to").action((task, options) => uploadCommand(task, options.organization));
1126
+ program.parse();
1127
+ //# sourceMappingURL=index.js.map