@tapi-dev/sdk 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,6 +101,48 @@ const completedRun = await tapi.runs.wait(run.id);
101
101
  console.log(completedRun.status, completedRun.result);
102
102
  ```
103
103
 
104
+ ## API-Call Triggers
105
+
106
+ Triggers are scheduled calls to published website APIs. Keep trigger definitions
107
+ in the app repo and sync them after publishing the API catalog:
108
+
109
+ ```ts
110
+ // tapi.config.ts
111
+ export default {
112
+ triggers: {
113
+ nightlyBalance: {
114
+ apiRequest: "schwab.get_balance",
115
+ schedule: { cron: "0 9 * * MON-FRI" },
116
+ inputs: { accountId: "main" },
117
+ runtime: { profileRef: "perm_default" },
118
+ },
119
+ },
120
+ };
121
+ ```
122
+
123
+ ```bash
124
+ TAPI_API_KEY=tapi_project_key npx tapi triggers sync
125
+ ```
126
+
127
+ `triggers sync` upserts by trigger name for the current `.tapi/project.json`
128
+ project. Schedules support standard five-field cron strings or intervals as
129
+ numbers of seconds / strings like `30s`, `15m`, `2h`, and `1d`.
130
+
131
+ You can also manage triggers directly:
132
+
133
+ ```ts
134
+ await tapi.triggers.create({
135
+ name: "nightlyBalance",
136
+ apiRequest: "schwab.get_balance",
137
+ schedule: { cron: "0 9 * * MON-FRI" },
138
+ inputs: { accountId: "main" },
139
+ runtime: { profileRef: "perm_default" },
140
+ });
141
+
142
+ await tapi.triggers.fire("act_123");
143
+ await tapi.triggers.disable("act_123");
144
+ ```
145
+
104
146
  You can inspect the same input/output contract from the CLI:
105
147
 
106
148
  ```bash
package/dist/cli.d.ts CHANGED
@@ -64,8 +64,18 @@ interface StudioCliOptions {
64
64
  projectSlug?: string;
65
65
  workspaceMode: boolean;
66
66
  }
67
+ export interface ServiceStatus {
68
+ installed: boolean;
69
+ name?: string;
70
+ status?: string;
71
+ pathName?: string;
72
+ installedVersion?: string;
73
+ }
74
+ type FetchLike = typeof fetch;
67
75
  export declare function runCli(argv?: string[]): Promise<number>;
68
76
  export declare function parseStudioOptions(args: string[]): StudioCliOptions;
77
+ export declare function installedServiceVersionFromPathName(pathName: string): string | undefined;
78
+ export declare function serviceNeedsInstall(status: ServiceStatus, manifest: ServiceReleaseManifest): boolean;
69
79
  export declare function getDefaultStudioCacheDir(): string;
70
80
  export declare function getDefaultServiceCacheDir(): string;
71
81
  export declare class CliWideEvent {
@@ -85,5 +95,6 @@ export declare function getStudioExecutableCandidates(): string[];
85
95
  export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
86
96
  export declare function validateServiceReleaseManifest(input: unknown): ServiceReleaseManifest;
87
97
  export declare function compareVersions(left: string, right: string): number;
98
+ export declare function waitForHttpOk(url: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: FetchLike): Promise<void>;
88
99
  export declare function findPortableStudioServerExe(releaseDir: string, manifest: StudioReleaseManifest): string | undefined;
89
100
  export {};
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { basename, dirname, join, resolve } from "node:path";
10
10
  import { performance } from "node:perf_hooks";
11
11
  import { Readable } from "node:stream";
12
12
  import { pipeline } from "node:stream/promises";
13
- import { fileURLToPath } from "node:url";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  import { TapiClient } from "./index.js";
15
15
  import { loadWorkspace, normalizeProjectValue, writeWorkspaceConfig, } from "./workspace.js";
16
16
  const DEFAULT_DOWNLOADS_BASE_URL = "https://d4xaf52nfwiok.cloudfront.net";
@@ -20,6 +20,8 @@ const DEFAULT_FIREBASE_API_KEY = "AIzaSyCDZR8lWyVQcWYfFdNZa4vuL4IWEC0h6gE";
20
20
  const DEFAULT_CHANNEL = "pilot";
21
21
  const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
22
22
  const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
23
+ const PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS = 30_000;
24
+ const PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS = 250;
23
25
  const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
24
26
  class StudioInstallApprovalError extends Error {
25
27
  code;
@@ -88,6 +90,18 @@ export async function runCli(argv = process.argv.slice(2)) {
88
90
  printApisHelp();
89
91
  return 1;
90
92
  }
93
+ if (command === "triggers") {
94
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h" || hasHelpFlag(rest)) {
95
+ printTriggersHelp();
96
+ return 0;
97
+ }
98
+ if (subcommand === "sync") {
99
+ return syncApiTriggers(rest);
100
+ }
101
+ console.error(`Unknown triggers command: ${subcommand}`);
102
+ printTriggersHelp();
103
+ return 1;
104
+ }
91
105
  if (command !== "studio") {
92
106
  console.error(`Unknown command: ${command}`);
93
107
  printHelp();
@@ -433,6 +447,298 @@ async function generateApiClient(args) {
433
447
  return 1;
434
448
  }
435
449
  }
450
+ function parseTriggerSyncOptions(args) {
451
+ const workspace = loadWorkspace();
452
+ let apiBaseUrl = envString("TAPI_BASE_URL") || workspace?.apiBaseUrl || DEFAULT_STUDIO_API_BASE_URL;
453
+ let apiKey = envString("TAPI_API_KEY") || "";
454
+ let projectId = envString("TAPI_PROJECT_ID") || workspace?.projectId || "";
455
+ let configPath = "";
456
+ const root = workspace?.root || process.cwd();
457
+ for (let index = 0; index < args.length; index += 1) {
458
+ const arg = args[index];
459
+ if (!arg)
460
+ continue;
461
+ if (arg === "--api-base-url" || arg === "--server") {
462
+ apiBaseUrl = requireOptionValue(args, ++index, arg);
463
+ continue;
464
+ }
465
+ if (arg.startsWith("--api-base-url=")) {
466
+ apiBaseUrl = arg.slice("--api-base-url=".length);
467
+ continue;
468
+ }
469
+ if (arg.startsWith("--server=")) {
470
+ apiBaseUrl = arg.slice("--server=".length);
471
+ continue;
472
+ }
473
+ if (arg === "--api-key") {
474
+ apiKey = requireOptionValue(args, ++index, "--api-key");
475
+ continue;
476
+ }
477
+ if (arg.startsWith("--api-key=")) {
478
+ apiKey = arg.slice("--api-key=".length);
479
+ continue;
480
+ }
481
+ if (arg === "--project") {
482
+ projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
483
+ continue;
484
+ }
485
+ if (arg.startsWith("--project=")) {
486
+ projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
487
+ continue;
488
+ }
489
+ if (arg === "--config") {
490
+ configPath = requireOptionValue(args, ++index, "--config");
491
+ continue;
492
+ }
493
+ if (arg.startsWith("--config=")) {
494
+ configPath = arg.slice("--config=".length);
495
+ continue;
496
+ }
497
+ throw new Error(`Unknown triggers option: ${arg}`);
498
+ }
499
+ if (!apiKey) {
500
+ throw new Error("triggers sync requires --api-key or TAPI_API_KEY.");
501
+ }
502
+ if (!projectId) {
503
+ throw new Error("triggers sync requires --project, TAPI_PROJECT_ID, or .tapi/project.json.");
504
+ }
505
+ return {
506
+ apiBaseUrl: normalizeHttpUrl(apiBaseUrl, "Tapi API base URL"),
507
+ apiKey,
508
+ projectId,
509
+ configPath: configPath ? resolve(root, configPath) : defaultTriggerConfigPath(root),
510
+ };
511
+ }
512
+ async function syncApiTriggers(args) {
513
+ try {
514
+ const options = parseTriggerSyncOptions(args);
515
+ const triggers = await readApiTriggerConfig(options.configPath);
516
+ if (triggers.length === 0) {
517
+ throw new Error(`No Tapi API triggers found in ${options.configPath}.`);
518
+ }
519
+ const client = new TapiClient({
520
+ baseUrl: options.apiBaseUrl,
521
+ apiKey: options.apiKey,
522
+ projectId: options.projectId,
523
+ });
524
+ for (const trigger of triggers) {
525
+ await client.triggers.create(trigger);
526
+ }
527
+ console.log(`Synced ${triggers.length} Tapi API trigger(s) from ${options.configPath}.`);
528
+ return 0;
529
+ }
530
+ catch (error) {
531
+ console.error(formatError(error));
532
+ return 1;
533
+ }
534
+ }
535
+ function defaultTriggerConfigPath(root) {
536
+ for (const name of ["tapi.config.ts", "tapi.config.mjs", "tapi.config.js", "tapi.config.cjs", "tapi.config.json"]) {
537
+ const path = join(root, name);
538
+ if (existsSync(path)) {
539
+ return path;
540
+ }
541
+ }
542
+ return join(root, "tapi.config.json");
543
+ }
544
+ async function readApiTriggerConfig(configPath) {
545
+ if (!existsSync(configPath)) {
546
+ throw new Error(`Tapi trigger config not found at ${configPath}. Create tapi.config.json or pass --config.`);
547
+ }
548
+ const payload = await loadTriggerConfigPayload(configPath);
549
+ return normalizeTriggerConfig(payload, configPath);
550
+ }
551
+ async function loadTriggerConfigPayload(configPath) {
552
+ if (configPath.endsWith(".json")) {
553
+ return JSON.parse(readFileSync(configPath, "utf8"));
554
+ }
555
+ if (configPath.endsWith(".ts")) {
556
+ return evaluateTypeScriptTriggerConfig(configPath);
557
+ }
558
+ if (configPath.endsWith(".js") || configPath.endsWith(".mjs") || configPath.endsWith(".cjs")) {
559
+ const moduleUrl = pathToFileURL(configPath).href;
560
+ const module = await import(moduleUrl);
561
+ return module.default ?? module;
562
+ }
563
+ throw new Error("Tapi trigger config must be a .json, .js, .mjs, .cjs, or simple .ts file.");
564
+ }
565
+ function evaluateTypeScriptTriggerConfig(configPath) {
566
+ let source = readFileSync(configPath, "utf8");
567
+ source = source.replace(/^\s*import\s+type\s+[\s\S]*?;\s*$/gm, "");
568
+ source = source.replace(/^\s*import\s+[\s\S]*?;\s*$/gm, "");
569
+ const marker = "export default";
570
+ const markerIndex = source.indexOf(marker);
571
+ if (markerIndex < 0) {
572
+ throw new Error("tapi.config.ts must use `export default { ... }`.");
573
+ }
574
+ let expression = source.slice(markerIndex + marker.length).trim();
575
+ if (expression.endsWith(";")) {
576
+ expression = expression.slice(0, -1).trim();
577
+ }
578
+ expression = expression.replace(/\s+satisfies\s+[\s\S]+$/m, "").trim();
579
+ expression = expression.replace(/\s+as\s+const\s*$/m, "").trim();
580
+ try {
581
+ return Function("process", `"use strict"; return (${expression});`)(process);
582
+ }
583
+ catch (error) {
584
+ throw new Error(`Could not evaluate ${configPath}: ${formatError(error)}. Use a simple export default object or switch to tapi.config.js/json.`);
585
+ }
586
+ }
587
+ function normalizeTriggerConfig(payload, configPath) {
588
+ const rawTriggers = isRecord(payload) && "triggers" in payload ? payload.triggers : payload;
589
+ if (Array.isArray(rawTriggers)) {
590
+ return rawTriggers.map((entry, index) => normalizeTriggerEntry(entry, index, configPath));
591
+ }
592
+ if (isRecord(rawTriggers)) {
593
+ return Object.entries(rawTriggers).map(([name, entry], index) => {
594
+ if (!isRecord(entry)) {
595
+ throw new Error(`Trigger '${name}' in ${configPath} must be an object.`);
596
+ }
597
+ return normalizeTriggerEntry({ name, ...entry }, index, configPath);
598
+ });
599
+ }
600
+ throw new Error(`Tapi trigger config at ${configPath} must contain a triggers array or object.`);
601
+ }
602
+ function normalizeTriggerEntry(entry, index, configPath) {
603
+ if (!isRecord(entry)) {
604
+ throw new Error(`Trigger #${index + 1} in ${configPath} must be an object.`);
605
+ }
606
+ const name = stringField(entry.name, `Trigger #${index + 1} name`);
607
+ const apiRequest = optionalStringField(entry.apiRequest, "apiRequest") ??
608
+ optionalStringField(entry.api, "api") ??
609
+ apiRequestFromParts(entry.apiName, entry.requestKey);
610
+ if (!apiRequest) {
611
+ throw new Error(`Trigger '${name}' requires apiRequest, api, or apiName/requestKey.`);
612
+ }
613
+ if (!apiRequest.includes(".")) {
614
+ throw new Error(`Trigger '${name}' apiRequest must be formatted as '<apiName>.<requestKey>'.`);
615
+ }
616
+ const schedule = normalizeTriggerSchedule(entry.schedule, entry.interval, name);
617
+ const request = {
618
+ name,
619
+ apiRequest,
620
+ ...(entry.enabled === undefined ? {} : { enabled: booleanField(entry.enabled, `Trigger '${name}' enabled`) }),
621
+ ...(schedule ? { schedule } : {}),
622
+ ...(entry.inputs === undefined ? {} : { inputs: recordField(entry.inputs, `Trigger '${name}' inputs`) }),
623
+ ...(entry.runtime === undefined ? {} : { runtime: recordField(entry.runtime, `Trigger '${name}' runtime`) }),
624
+ ...(entry.runnerId === undefined ? {} : { runnerId: stringField(entry.runnerId, `Trigger '${name}' runnerId`) }),
625
+ ...(entry.priority === undefined ? {} : { priority: numberField(entry.priority, `Trigger '${name}' priority`) }),
626
+ ...(entry.site === undefined ? {} : { site: stringField(entry.site, `Trigger '${name}' site`) }),
627
+ };
628
+ return request;
629
+ }
630
+ function normalizeTriggerSchedule(scheduleValue, intervalValue, name) {
631
+ if (scheduleValue === undefined && intervalValue === undefined) {
632
+ return undefined;
633
+ }
634
+ if (typeof scheduleValue === "string" && looksLikeCron(scheduleValue)) {
635
+ return { cron: scheduleValue.trim() };
636
+ }
637
+ if (typeof scheduleValue === "number" || typeof scheduleValue === "string") {
638
+ return { intervalSeconds: parseDurationSeconds(scheduleValue, `Trigger '${name}' schedule`) };
639
+ }
640
+ if (scheduleValue !== undefined && !isRecord(scheduleValue)) {
641
+ throw new Error(`Trigger '${name}' schedule must be an object, number, or duration string.`);
642
+ }
643
+ const schedule = scheduleValue === undefined ? {} : { ...scheduleValue };
644
+ if (intervalValue !== undefined) {
645
+ if ("cron" in schedule) {
646
+ throw new Error(`Trigger '${name}' cannot define both cron and interval.`);
647
+ }
648
+ schedule.intervalSeconds = parseDurationSeconds(intervalValue, `Trigger '${name}' interval`);
649
+ }
650
+ else if ("interval" in schedule) {
651
+ if ("cron" in schedule) {
652
+ throw new Error(`Trigger '${name}' cannot define both cron and interval.`);
653
+ }
654
+ schedule.intervalSeconds = parseDurationSeconds(schedule.interval, `Trigger '${name}' schedule.interval`);
655
+ delete schedule.interval;
656
+ }
657
+ else if ("interval_seconds" in schedule) {
658
+ if ("cron" in schedule) {
659
+ throw new Error(`Trigger '${name}' cannot define both cron and interval_seconds.`);
660
+ }
661
+ schedule.intervalSeconds = parseDurationSeconds(schedule.interval_seconds, `Trigger '${name}' schedule.interval_seconds`);
662
+ delete schedule.interval_seconds;
663
+ }
664
+ else if ("intervalSeconds" in schedule) {
665
+ if ("cron" in schedule) {
666
+ throw new Error(`Trigger '${name}' cannot define both cron and intervalSeconds.`);
667
+ }
668
+ schedule.intervalSeconds = parseDurationSeconds(schedule.intervalSeconds, `Trigger '${name}' schedule.intervalSeconds`);
669
+ }
670
+ else if ("cron" in schedule) {
671
+ schedule.cron = stringField(schedule.cron, `Trigger '${name}' schedule.cron`);
672
+ }
673
+ if (!("intervalSeconds" in schedule) && !("cron" in schedule)) {
674
+ throw new Error(`Trigger '${name}' schedule requires intervalSeconds, interval, or cron.`);
675
+ }
676
+ return schedule;
677
+ }
678
+ function looksLikeCron(value) {
679
+ return value.trim().split(/\s+/).length === 5;
680
+ }
681
+ function parseDurationSeconds(value, fieldName) {
682
+ if (typeof value === "number") {
683
+ if (!Number.isFinite(value) || value <= 0) {
684
+ throw new Error(`${fieldName} must be a positive number of seconds.`);
685
+ }
686
+ return Math.round(value);
687
+ }
688
+ if (typeof value !== "string") {
689
+ throw new Error(`${fieldName} must be a number of seconds or a duration string like '15m'.`);
690
+ }
691
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)?$/i);
692
+ if (!match) {
693
+ throw new Error(`${fieldName} must be a duration like '30s', '15m', '2h', or '1d'.`);
694
+ }
695
+ const amount = Number(match[1]);
696
+ const unit = (match[2] || "s").toLowerCase();
697
+ const multiplier = unit.startsWith("m") ? 60 :
698
+ unit.startsWith("h") ? 3600 :
699
+ unit.startsWith("d") ? 86400 :
700
+ 1;
701
+ const seconds = Math.round(amount * multiplier);
702
+ if (!Number.isFinite(seconds) || seconds <= 0) {
703
+ throw new Error(`${fieldName} must be greater than zero seconds.`);
704
+ }
705
+ return seconds;
706
+ }
707
+ function apiRequestFromParts(apiName, requestKey) {
708
+ const api = optionalStringField(apiName, "apiName");
709
+ const request = optionalStringField(requestKey, "requestKey");
710
+ return api && request ? `${api}.${request}` : undefined;
711
+ }
712
+ function stringField(value, fieldName) {
713
+ if (typeof value !== "string" || !value.trim()) {
714
+ throw new Error(`${fieldName} must be a non-empty string.`);
715
+ }
716
+ return value.trim();
717
+ }
718
+ function optionalStringField(value, fieldName) {
719
+ if (value === undefined || value === null || value === "") {
720
+ return undefined;
721
+ }
722
+ return stringField(value, fieldName);
723
+ }
724
+ function booleanField(value, fieldName) {
725
+ if (typeof value !== "boolean") {
726
+ throw new Error(`${fieldName} must be true or false.`);
727
+ }
728
+ return value;
729
+ }
730
+ function numberField(value, fieldName) {
731
+ if (typeof value !== "number" || !Number.isFinite(value)) {
732
+ throw new Error(`${fieldName} must be a finite number.`);
733
+ }
734
+ return value;
735
+ }
736
+ function recordField(value, fieldName) {
737
+ if (!isRecord(value)) {
738
+ throw new Error(`${fieldName} must be an object.`);
739
+ }
740
+ return value;
741
+ }
436
742
  async function fetchApiCatalog(options) {
437
743
  const client = new TapiClient({
438
744
  baseUrl: options.apiBaseUrl,
@@ -620,7 +926,7 @@ async function repairService(options) {
620
926
  }
621
927
  function servicePowerShell(action) {
622
928
  const serviceName = "tapi-service";
623
- const status = `$svc = Get-Service -Name '${serviceName}' -ErrorAction SilentlyContinue; if ($null -eq $svc) { [pscustomobject]@{ installed = $false; name = '${serviceName}'; status = 'not_installed' } | ConvertTo-Json -Compress; exit 0 }; [pscustomobject]@{ installed = $true; name = $svc.Name; status = $svc.Status.ToString() } | ConvertTo-Json -Compress`;
929
+ const status = `$svc = Get-Service -Name '${serviceName}' -ErrorAction SilentlyContinue; if ($null -eq $svc) { [pscustomobject]@{ installed = $false; name = '${serviceName}'; status = 'not_installed'; pathName = '' } | ConvertTo-Json -Compress; exit 0 }; $cim = Get-CimInstance Win32_Service -Filter "Name='${serviceName}'" -ErrorAction SilentlyContinue; $pathName = if ($null -ne $cim) { [string]$cim.PathName } else { '' }; [pscustomobject]@{ installed = $true; name = $svc.Name; status = $svc.Status.ToString(); pathName = $pathName } | ConvertTo-Json -Compress`;
624
930
  if (action === "status") {
625
931
  return status;
626
932
  }
@@ -649,6 +955,8 @@ async function getServiceStatus() {
649
955
  installed: Boolean(payload.installed),
650
956
  name: typeof payload.name === "string" ? payload.name : "tapi-service",
651
957
  status: typeof payload.status === "string" ? payload.status : "unknown",
958
+ pathName: typeof payload.pathName === "string" ? payload.pathName : "",
959
+ installedVersion: installedServiceVersionFromPathName(typeof payload.pathName === "string" ? payload.pathName : ""),
652
960
  };
653
961
  }
654
962
  catch {
@@ -665,6 +973,16 @@ async function ensureServiceReadyForStudio(options) {
665
973
  }
666
974
  return;
667
975
  }
976
+ const manifest = await fetchServiceManifestForEnsure(options);
977
+ if (manifest && serviceNeedsInstall(status, manifest)) {
978
+ const current = status.installedVersion || "unknown";
979
+ console.log(`Updating Tapi Service from ${current} to ${manifest.version}...`);
980
+ const code = await installService(options);
981
+ if (code !== 0) {
982
+ throw new Error("Tapi Service update failed.");
983
+ }
984
+ return;
985
+ }
668
986
  if (status.status !== "Running") {
669
987
  console.log("Starting Tapi Service...");
670
988
  const output = await runProcessCapture("powershell.exe", [
@@ -679,6 +997,75 @@ async function ensureServiceReadyForStudio(options) {
679
997
  }
680
998
  }
681
999
  }
1000
+ async function fetchServiceManifestForEnsure(options) {
1001
+ const event = await createCliWideEvent("tapi_cli.service_ensure", {
1002
+ sdk_version: sdkVersion,
1003
+ options: installEventOptions(options),
1004
+ });
1005
+ try {
1006
+ let installToken = options.installToken?.trim();
1007
+ if (!installToken) {
1008
+ await event.phase("auth.install_token.request.start", {
1009
+ apiBaseUrl: options.apiBaseUrl,
1010
+ channel: options.channel,
1011
+ });
1012
+ installToken = await obtainStudioInstallToken(options, event);
1013
+ await event.phase("auth.install_token.request.success", {
1014
+ apiBaseUrl: options.apiBaseUrl,
1015
+ channel: options.channel,
1016
+ });
1017
+ }
1018
+ await event.phase("service_manifest.fetch.start", {
1019
+ apiBaseUrl: options.apiBaseUrl,
1020
+ channel: options.channel,
1021
+ });
1022
+ const manifest = await fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken);
1023
+ ensureCompatibleServiceManifest(manifest);
1024
+ await event.finish(true, {
1025
+ manifest: serviceManifestEventSummary(manifest),
1026
+ });
1027
+ return manifest;
1028
+ }
1029
+ catch (error) {
1030
+ await event.finish(false, {
1031
+ error: errorDetails(error),
1032
+ });
1033
+ if (isProtectedServiceManifestAuthError(error)) {
1034
+ throw error;
1035
+ }
1036
+ console.warn(`Could not check for Tapi Service updates: ${formatError(error)}`);
1037
+ return null;
1038
+ }
1039
+ }
1040
+ function isProtectedServiceManifestAuthError(error) {
1041
+ const message = formatError(error);
1042
+ return (message.includes("Failed to fetch protected Tapi Service manifest: HTTP 401")
1043
+ || message.includes("Failed to fetch protected Tapi Service manifest: HTTP 403")
1044
+ || message.includes("Worker bootstrap secret required"));
1045
+ }
1046
+ export function installedServiceVersionFromPathName(pathName) {
1047
+ const normalized = String(pathName || "").replace(/\\/g, "/");
1048
+ const parts = normalized
1049
+ .split("/")
1050
+ .map((part) => part.trim().replace(/^"+|"+$/g, ""))
1051
+ .filter(Boolean);
1052
+ const releasesIndex = parts.findIndex((part) => part.toLowerCase() === "releases");
1053
+ if (releasesIndex < 0 || releasesIndex + 1 >= parts.length) {
1054
+ return undefined;
1055
+ }
1056
+ const version = parts[releasesIndex + 1]?.replace(/^"+|"+$/g, "").trim();
1057
+ return version || undefined;
1058
+ }
1059
+ export function serviceNeedsInstall(status, manifest) {
1060
+ if (!status.installed) {
1061
+ return true;
1062
+ }
1063
+ const installedVersion = status.installedVersion || installedServiceVersionFromPathName(status.pathName || "");
1064
+ if (!installedVersion) {
1065
+ return true;
1066
+ }
1067
+ return installedVersion !== manifest.version;
1068
+ }
682
1069
  async function installService(options) {
683
1070
  const event = await createCliWideEvent("tapi_cli.service_install", {
684
1071
  sdk_version: sdkVersion,
@@ -1468,9 +1855,36 @@ async function launchPortableStudioServer(exePath, options) {
1468
1855
  });
1469
1856
  child.unref();
1470
1857
  const url = `http://${host}:${port}`;
1858
+ await waitForHttpOk(`${url}/healthz`, PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS);
1471
1859
  openBrowser(url);
1472
1860
  console.log(`Opened Tapi Studio: ${url}`);
1473
1861
  }
1862
+ export async function waitForHttpOk(url, timeoutMs = PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, intervalMs = PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS, fetchImpl = fetch) {
1863
+ const startedAt = performance.now();
1864
+ let lastError = "";
1865
+ while (performance.now() - startedAt < timeoutMs) {
1866
+ try {
1867
+ const response = await fetchImpl(url, {
1868
+ headers: { Accept: "application/json" },
1869
+ });
1870
+ if (response.ok) {
1871
+ return;
1872
+ }
1873
+ lastError = `HTTP ${response.status}`;
1874
+ }
1875
+ catch (error) {
1876
+ lastError = formatError(error);
1877
+ }
1878
+ await delay(intervalMs);
1879
+ }
1880
+ const suffix = lastError ? ` Last error: ${lastError}.` : "";
1881
+ throw new Error(`Tapi Studio server did not become ready at ${url} within ${Math.ceil(timeoutMs / 1000)}s.${suffix}`);
1882
+ }
1883
+ function delay(ms) {
1884
+ return new Promise((resolvePromise) => {
1885
+ setTimeout(resolvePromise, Math.max(0, ms));
1886
+ });
1887
+ }
1474
1888
  async function chooseStudioPort(preferred = 18766) {
1475
1889
  for (let port = preferred; port < preferred + 25; port += 1) {
1476
1890
  if (await isPortAvailable(port)) {
@@ -2085,6 +2499,7 @@ Usage:
2085
2499
  tapi apis describe <namespace.operation>
2086
2500
  tapi apis sync
2087
2501
  tapi apis generate
2502
+ tapi triggers sync
2088
2503
  tapi publish
2089
2504
  tapi service status
2090
2505
  tapi doctor
@@ -2099,6 +2514,7 @@ Commands:
2099
2514
  apis describe Print a generated website API input/output contract
2100
2515
  apis sync Save the published API catalog to .tapi/generated
2101
2516
  apis generate Generate a TypeScript runtime wrapper from the catalog
2517
+ triggers sync Upsert API-call triggers from tapi.config
2102
2518
  publish Upload local .tapi API drafts and publish ready requests
2103
2519
  service Inspect or control the local Tapi Windows service
2104
2520
  doctor Alias for studio doctor
@@ -2121,6 +2537,32 @@ Options:
2121
2537
  --out <path> Generated TypeScript output path
2122
2538
  `);
2123
2539
  }
2540
+ function printTriggersHelp() {
2541
+ console.log(`Tapi API trigger commands
2542
+
2543
+ Usage:
2544
+ tapi triggers sync [--config FILE] [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2545
+
2546
+ Options:
2547
+ --config <path> Trigger config path; defaults to tapi.config.ts/js/json in the workspace root
2548
+ --api-base-url <url> Tapi API base URL
2549
+ --server <url> Alias for --api-base-url
2550
+ --api-key <key> Tapi API key; defaults to TAPI_API_KEY
2551
+ --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
2552
+
2553
+ Config:
2554
+ export default {
2555
+ triggers: {
2556
+ nightlyBalance: {
2557
+ apiRequest: "schwab.get_balance",
2558
+ interval: "1h",
2559
+ inputs: { accountId: "main" },
2560
+ runtime: { profileRef: "perm_default" },
2561
+ },
2562
+ },
2563
+ };
2564
+ `);
2565
+ }
2124
2566
  function printStudioHelp() {
2125
2567
  console.log(`Tapi Studio commands
2126
2568
 
package/dist/client.d.ts CHANGED
@@ -7,5 +7,7 @@ export declare class HttpClient {
7
7
  constructor(options: TapiClientOptions);
8
8
  get<T>(path: string): Promise<T>;
9
9
  post<T>(path: string, body?: unknown): Promise<T>;
10
+ patch<T>(path: string, body?: unknown): Promise<T>;
11
+ delete<T>(path: string): Promise<T>;
10
12
  private request;
11
13
  }
package/dist/client.js CHANGED
@@ -16,6 +16,12 @@ export class HttpClient {
16
16
  async post(path, body) {
17
17
  return this.request("POST", path, body);
18
18
  }
19
+ async patch(path, body) {
20
+ return this.request("PATCH", path, body);
21
+ }
22
+ async delete(path) {
23
+ return this.request("DELETE", path);
24
+ }
19
25
  async request(method, path, body) {
20
26
  const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
21
27
  method,
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import { CatalogResource } from "./catalog.js";
3
3
  import { RunnersResource } from "./runners.js";
4
4
  import { RunsResource } from "./runs.js";
5
5
  import { RuntimeResource } from "./runtime.js";
6
+ import { TriggersResource } from "./triggers.js";
6
7
  import type { TapiClientOptions } from "./types.js";
7
8
  import { WebsiteApisResource } from "./website-apis.js";
8
9
  export declare class TapiClient {
@@ -11,6 +12,7 @@ export declare class TapiClient {
11
12
  readonly runners: RunnersResource;
12
13
  readonly runs: RunsResource;
13
14
  readonly runtime: RuntimeResource;
15
+ readonly triggers: TriggersResource;
14
16
  readonly websiteApis: WebsiteApisResource;
15
17
  constructor(options: TapiClientOptions);
16
18
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { CatalogResource } from "./catalog.js";
4
4
  import { RunnersResource } from "./runners.js";
5
5
  import { RunsResource } from "./runs.js";
6
6
  import { RuntimeResource } from "./runtime.js";
7
+ import { TriggersResource } from "./triggers.js";
7
8
  import { WebsiteApisResource } from "./website-apis.js";
8
9
  export class TapiClient {
9
10
  catalog;
@@ -11,6 +12,7 @@ export class TapiClient {
11
12
  runners;
12
13
  runs;
13
14
  runtime;
15
+ triggers;
14
16
  websiteApis;
15
17
  constructor(options) {
16
18
  const http = new HttpClient(options);
@@ -19,6 +21,7 @@ export class TapiClient {
19
21
  this.runners = new RunnersResource(http);
20
22
  this.runs = new RunsResource(http);
21
23
  this.runtime = new RuntimeResource(http, options);
24
+ this.triggers = new TriggersResource(http);
22
25
  this.websiteApis = new WebsiteApisResource(http);
23
26
  }
24
27
  }
@@ -0,0 +1,17 @@
1
+ import type { HttpClient } from "./client.js";
2
+ import type { WebsiteApiTrigger, WebsiteApiTriggerCreateRequest, WebsiteApiTriggerList, WebsiteApiTriggerRunResult, WebsiteApiTriggerUpdateRequest } from "./types.js";
3
+ export declare class TriggersResource {
4
+ private readonly http;
5
+ constructor(http: HttpClient);
6
+ list(): Promise<WebsiteApiTriggerList>;
7
+ create(request: WebsiteApiTriggerCreateRequest): Promise<WebsiteApiTrigger>;
8
+ get(triggerId: string): Promise<WebsiteApiTrigger>;
9
+ update(triggerId: string, request: WebsiteApiTriggerUpdateRequest): Promise<WebsiteApiTrigger>;
10
+ enable(triggerId: string): Promise<WebsiteApiTrigger>;
11
+ disable(triggerId: string): Promise<WebsiteApiTrigger>;
12
+ delete(triggerId: string): Promise<{
13
+ deleted: boolean;
14
+ id: string;
15
+ }>;
16
+ fire(triggerId: string): Promise<WebsiteApiTriggerRunResult>;
17
+ }
@@ -0,0 +1,30 @@
1
+ export class TriggersResource {
2
+ http;
3
+ constructor(http) {
4
+ this.http = http;
5
+ }
6
+ list() {
7
+ return this.http.get("/api/sdk/v1/website-api-triggers");
8
+ }
9
+ create(request) {
10
+ return this.http.post("/api/sdk/v1/website-api-triggers", request);
11
+ }
12
+ get(triggerId) {
13
+ return this.http.get(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}`);
14
+ }
15
+ update(triggerId, request) {
16
+ return this.http.patch(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}`, request);
17
+ }
18
+ enable(triggerId) {
19
+ return this.update(triggerId, { enabled: true });
20
+ }
21
+ disable(triggerId) {
22
+ return this.update(triggerId, { enabled: false });
23
+ }
24
+ delete(triggerId) {
25
+ return this.http.delete(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}`);
26
+ }
27
+ fire(triggerId) {
28
+ return this.http.post(`/api/sdk/v1/website-api-triggers/${encodeURIComponent(triggerId)}/run`);
29
+ }
30
+ }
package/dist/types.d.ts CHANGED
@@ -162,6 +162,54 @@ export interface WebsiteApiOperation {
162
162
  outputSchema?: Record<string, unknown>;
163
163
  [key: string]: unknown;
164
164
  }
165
+ export interface WebsiteApiTriggerSchedule {
166
+ intervalSeconds?: number;
167
+ cron?: string;
168
+ [key: string]: unknown;
169
+ }
170
+ export interface WebsiteApiTriggerCreateRequest {
171
+ name: string;
172
+ apiRequest: string;
173
+ enabled?: boolean;
174
+ schedule?: WebsiteApiTriggerSchedule;
175
+ inputs?: Record<string, unknown>;
176
+ runtime?: RuntimeRunOptions;
177
+ runnerId?: string;
178
+ priority?: number;
179
+ site?: string;
180
+ }
181
+ export interface WebsiteApiTriggerUpdateRequest {
182
+ enabled: boolean;
183
+ }
184
+ export interface WebsiteApiTrigger {
185
+ id: string;
186
+ name: string;
187
+ apiRequest: string;
188
+ apiName: string;
189
+ requestKey: string;
190
+ site?: string;
191
+ enabled: boolean;
192
+ schedule?: WebsiteApiTriggerSchedule | Record<string, unknown>;
193
+ inputs?: Record<string, unknown>;
194
+ runtime?: RuntimeRunOptions | Record<string, unknown>;
195
+ runnerId?: string;
196
+ priority?: number;
197
+ lastRun?: TapiRun | Record<string, unknown> | null;
198
+ lastError?: string;
199
+ lastFiredAt?: string | null;
200
+ nextFireAt?: string | null;
201
+ createdAt?: string;
202
+ updatedAt?: string;
203
+ [key: string]: unknown;
204
+ }
205
+ export interface WebsiteApiTriggerList {
206
+ triggers: WebsiteApiTrigger[];
207
+ }
208
+ export interface WebsiteApiTriggerRunResult {
209
+ success: boolean;
210
+ trigger: WebsiteApiTrigger;
211
+ run: TapiRun | Record<string, unknown>;
212
+ }
165
213
  export interface TapiRun {
166
214
  id: string;
167
215
  status: RunStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",