@tapi-dev/sdk 0.1.15 → 0.1.20

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/cli.js CHANGED
@@ -8,6 +8,7 @@ import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "n
8
8
  import { homedir } from "node:os";
9
9
  import { basename, dirname, join, resolve } from "node:path";
10
10
  import { performance } from "node:perf_hooks";
11
+ import { emitKeypressEvents } from "node:readline";
11
12
  import { Readable } from "node:stream";
12
13
  import { pipeline } from "node:stream/promises";
13
14
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,6 +24,9 @@ const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
23
24
  const PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS = 30_000;
24
25
  const PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS = 250;
25
26
  const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
27
+ const MAX_NONCURRENT_MANAGED_DOWNLOADS = 0;
28
+ const MAX_NONCURRENT_MANAGED_RELEASES = 0;
29
+ const MANAGED_RELEASE_MARKER = ".tapi-managed-release";
26
30
  class StudioInstallApprovalError extends Error {
27
31
  code;
28
32
  status;
@@ -171,6 +175,23 @@ export async function runCli(argv = process.argv.slice(2)) {
171
175
  printTriggersHelp();
172
176
  return 1;
173
177
  }
178
+ if (command === "sessions") {
179
+ const sessionsCommand = subcommand && !subcommand.startsWith("-") ? subcommand : "list";
180
+ const sessionsArgs = subcommand && !subcommand.startsWith("-") ? rest : [subcommand, ...rest].filter(Boolean);
181
+ if (sessionsCommand === "help" || sessionsCommand === "--help" || sessionsCommand === "-h" || hasHelpFlag(sessionsArgs)) {
182
+ printSessionsHelp();
183
+ return 0;
184
+ }
185
+ if (sessionsCommand === "list") {
186
+ return listDevSessions(sessionsArgs);
187
+ }
188
+ if (sessionsCommand === "open") {
189
+ return openDevSession(sessionsArgs);
190
+ }
191
+ console.error(`Unknown sessions command: ${sessionsCommand}`);
192
+ printSessionsHelp();
193
+ return 1;
194
+ }
174
195
  if (command !== "studio") {
175
196
  console.error(`Unknown command: ${command}`);
176
197
  printHelp();
@@ -282,6 +303,14 @@ export function parseStudioOptions(args) {
282
303
  raw.cacheDir = resolve(arg.slice("--cache-dir=".length));
283
304
  continue;
284
305
  }
306
+ if (arg === "--install-dir") {
307
+ raw.installDir = resolve(requireOptionValue(args, ++index, "--install-dir"));
308
+ continue;
309
+ }
310
+ if (arg.startsWith("--install-dir=")) {
311
+ raw.installDir = resolve(arg.slice("--install-dir=".length));
312
+ continue;
313
+ }
285
314
  if (arg === "--exe") {
286
315
  raw.exePath = resolve(requireOptionValue(args, ++index, "--exe"));
287
316
  continue;
@@ -324,6 +353,7 @@ export function parseStudioOptions(args) {
324
353
  manifestUrl,
325
354
  manifestUrlOverride: explicitManifestUrl ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL") : undefined,
326
355
  cacheDir: raw.cacheDir ?? getDefaultStudioCacheDir(),
356
+ installDir: raw.installDir ?? resolve(envString("TAPI_STUDIO_INSTALL_DIR") ?? getDefaultPortableStudioServerReleasesDir()),
327
357
  downloadOnly: raw.downloadOnly ?? false,
328
358
  silent: raw.silent ?? false,
329
359
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
@@ -603,6 +633,350 @@ async function syncApiTriggers(args) {
603
633
  return 1;
604
634
  }
605
635
  }
636
+ function parseSessionsOptions(args) {
637
+ const workspace = loadWorkspace();
638
+ let apiBaseUrl = envString("TAPI_BASE_URL") || workspace?.apiBaseUrl || DEFAULT_STUDIO_API_BASE_URL;
639
+ let apiKey = envString("TAPI_API_KEY") || "";
640
+ let projectId = envString("TAPI_PROJECT_ID") || workspace?.projectId || "";
641
+ let site = "";
642
+ let sessionId = "";
643
+ let json = false;
644
+ let noInteractive = false;
645
+ for (let index = 0; index < args.length; index += 1) {
646
+ const arg = args[index];
647
+ if (!arg)
648
+ continue;
649
+ if (arg === "--api-base-url" || arg === "--server") {
650
+ apiBaseUrl = requireOptionValue(args, ++index, arg);
651
+ continue;
652
+ }
653
+ if (arg.startsWith("--api-base-url=")) {
654
+ apiBaseUrl = arg.slice("--api-base-url=".length);
655
+ continue;
656
+ }
657
+ if (arg.startsWith("--server=")) {
658
+ apiBaseUrl = arg.slice("--server=".length);
659
+ continue;
660
+ }
661
+ if (arg === "--api-key") {
662
+ apiKey = requireOptionValue(args, ++index, "--api-key");
663
+ continue;
664
+ }
665
+ if (arg.startsWith("--api-key=")) {
666
+ apiKey = arg.slice("--api-key=".length);
667
+ continue;
668
+ }
669
+ if (arg === "--project") {
670
+ projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
671
+ continue;
672
+ }
673
+ if (arg.startsWith("--project=")) {
674
+ projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
675
+ continue;
676
+ }
677
+ if (arg === "--site") {
678
+ site = requireOptionValue(args, ++index, "--site").trim();
679
+ continue;
680
+ }
681
+ if (arg.startsWith("--site=")) {
682
+ site = arg.slice("--site=".length).trim();
683
+ continue;
684
+ }
685
+ if (arg === "--json") {
686
+ json = true;
687
+ noInteractive = true;
688
+ continue;
689
+ }
690
+ if (arg === "--no-interactive") {
691
+ noInteractive = true;
692
+ continue;
693
+ }
694
+ if (arg.startsWith("--")) {
695
+ throw new Error(`Unknown sessions option: ${arg}`);
696
+ }
697
+ if (!sessionId) {
698
+ sessionId = arg;
699
+ continue;
700
+ }
701
+ throw new Error(`Unexpected sessions argument: ${arg}`);
702
+ }
703
+ if (!apiKey) {
704
+ throw new Error("sessions command requires --api-key or TAPI_API_KEY.");
705
+ }
706
+ if (!projectId) {
707
+ throw new Error("sessions command requires --project, TAPI_PROJECT_ID, or .tapi/project.json.");
708
+ }
709
+ return {
710
+ sessionId,
711
+ options: {
712
+ apiBaseUrl: normalizeHttpUrl(apiBaseUrl, "Tapi API base URL"),
713
+ apiKey,
714
+ projectId,
715
+ site: site || undefined,
716
+ json,
717
+ noInteractive,
718
+ },
719
+ };
720
+ }
721
+ async function listDevSessions(args) {
722
+ try {
723
+ const { options } = parseSessionsOptions(args);
724
+ const sessions = await fetchDevSessions(options);
725
+ if (options.json) {
726
+ console.log(JSON.stringify(sessions, null, 2));
727
+ return 0;
728
+ }
729
+ const rows = flattenDevSessions(sessions);
730
+ if (rows.length === 0) {
731
+ console.log(`No Tapi dev sessions found for project ${sessions.projectId || options.projectId}.`);
732
+ return 0;
733
+ }
734
+ if (!options.noInteractive && process.stdin.isTTY && process.stdout.isTTY && rows.some((session) => session.openable)) {
735
+ return await runSessionsPicker(sessions, options);
736
+ }
737
+ console.log(renderDevSessions(sessions));
738
+ const openable = rows.find((session) => session.openable);
739
+ if (openable) {
740
+ console.log("\nOpen a takeover session with `tapi sessions open <session-id>`.");
741
+ }
742
+ return 0;
743
+ }
744
+ catch (error) {
745
+ console.error(formatError(error));
746
+ return 1;
747
+ }
748
+ }
749
+ async function openDevSession(args) {
750
+ try {
751
+ const { sessionId, options } = parseSessionsOptions(args);
752
+ if (!sessionId) {
753
+ throw new Error("sessions open requires a session id.");
754
+ }
755
+ const sessions = await fetchDevSessions(options);
756
+ const session = findDevSession(sessions, sessionId);
757
+ if (!session) {
758
+ throw new Error(`Tapi session not found: ${sessionId}`);
759
+ }
760
+ return await openDevSessionInStudio(session, options);
761
+ }
762
+ catch (error) {
763
+ console.error(formatError(error));
764
+ return 1;
765
+ }
766
+ }
767
+ async function fetchDevSessions(options) {
768
+ const client = new TapiClient({
769
+ baseUrl: options.apiBaseUrl,
770
+ apiKey: options.apiKey,
771
+ projectId: options.projectId,
772
+ });
773
+ return await withCliSpinner("Fetching Tapi dev sessions", () => client.sessions.list({ site: options.site }));
774
+ }
775
+ function flattenDevSessions(sessions) {
776
+ return (sessions.groups || []).flatMap((group) => group.sessions || []);
777
+ }
778
+ function findDevSession(sessions, sessionId) {
779
+ const normalized = sessionId.trim();
780
+ return flattenDevSessions(sessions).find((session) => session.id === normalized || session.id.startsWith(normalized));
781
+ }
782
+ async function runSessionsPicker(sessions, options) {
783
+ let currentSessions = sessions;
784
+ let rows = flattenDevSessions(currentSessions);
785
+ let openableIndexes = openableSessionIndexes(rows);
786
+ if (openableIndexes.length === 0) {
787
+ console.log(renderDevSessions(currentSessions));
788
+ return 0;
789
+ }
790
+ let selectedIndex = openableIndexes[0];
791
+ const input = process.stdin;
792
+ return await new Promise((resolvePromise) => {
793
+ let onKeypress;
794
+ const cleanup = () => {
795
+ input.off("keypress", onKeypress);
796
+ input.setRawMode?.(false);
797
+ input.pause();
798
+ };
799
+ const move = (direction) => {
800
+ if (openableIndexes.length === 0)
801
+ return;
802
+ const currentOpenableIndex = openableIndexes.indexOf(selectedIndex);
803
+ const nextOpenableIndex = (currentOpenableIndex + direction + openableIndexes.length) % openableIndexes.length;
804
+ selectedIndex = openableIndexes[nextOpenableIndex];
805
+ render();
806
+ };
807
+ const render = () => {
808
+ process.stdout.write("\x1Bc");
809
+ process.stdout.write(renderDevSessions(currentSessions, rows[selectedIndex]?.id));
810
+ process.stdout.write("\n\nUse Up/Down and Enter to open Studio. Press r to refresh, q to exit.\n");
811
+ };
812
+ const finish = (code) => {
813
+ cleanup();
814
+ resolvePromise(code);
815
+ };
816
+ onKeypress = (_input, key = {}) => {
817
+ if (key.ctrl && key.name === "c") {
818
+ finish(130);
819
+ return;
820
+ }
821
+ if (key.name === "q" || key.name === "escape") {
822
+ finish(0);
823
+ return;
824
+ }
825
+ if (key.name === "up") {
826
+ move(-1);
827
+ return;
828
+ }
829
+ if (key.name === "down") {
830
+ move(1);
831
+ return;
832
+ }
833
+ if (key.name === "r") {
834
+ fetchDevSessions(options)
835
+ .then((nextSessions) => {
836
+ currentSessions = nextSessions;
837
+ rows = flattenDevSessions(currentSessions);
838
+ openableIndexes = openableSessionIndexes(rows);
839
+ if (!rows[selectedIndex]?.openable) {
840
+ selectedIndex = openableIndexes[0] ?? 0;
841
+ }
842
+ render();
843
+ })
844
+ .catch((error) => {
845
+ process.stderr.write(`\n${formatError(error)}\n`);
846
+ render();
847
+ });
848
+ return;
849
+ }
850
+ if (key.name === "return" || key.name === "enter") {
851
+ const session = rows[selectedIndex];
852
+ if (!session?.openable) {
853
+ return;
854
+ }
855
+ cleanup();
856
+ openDevSessionInStudio(session, options)
857
+ .then(resolvePromise)
858
+ .catch((error) => {
859
+ console.error(formatError(error));
860
+ resolvePromise(1);
861
+ });
862
+ }
863
+ };
864
+ emitKeypressEvents(input);
865
+ input.setRawMode?.(true);
866
+ input.resume();
867
+ input.on("keypress", onKeypress);
868
+ render();
869
+ });
870
+ }
871
+ function openableSessionIndexes(rows) {
872
+ return rows
873
+ .map((session, index) => (session.openable ? index : -1))
874
+ .filter((index) => index >= 0);
875
+ }
876
+ async function openDevSessionInStudio(session, options) {
877
+ if (!session.openable) {
878
+ throw new Error(`Tapi session ${session.id} is not awaiting developer takeover.`);
879
+ }
880
+ const params = studioSessionLaunchParams(session);
881
+ const studioOptions = parseStudioOptions([
882
+ "--api-base-url",
883
+ options.apiBaseUrl,
884
+ "--project",
885
+ options.projectId,
886
+ ]);
887
+ studioOptions.launchUrlQuery = params.toString();
888
+ const selected = await trySelectDevSessionInOpenStudio(session, studioOptions);
889
+ if (selected.selected) {
890
+ console.log(`Selected Tapi Studio session in existing Studio: ${selected.baseUrl}`);
891
+ return 0;
892
+ }
893
+ return await openStudio(studioOptions);
894
+ }
895
+ function studioSessionLaunchParams(session) {
896
+ const params = new URLSearchParams();
897
+ params.set("runId", session.id);
898
+ params.set("apiRunId", session.id);
899
+ if (session.sitemap)
900
+ params.set("sitemap", session.sitemap);
901
+ if (session.runtimeSessionId)
902
+ params.set("runtimeSessionId", session.runtimeSessionId);
903
+ return params;
904
+ }
905
+ function studioSessionSelectIntent(session) {
906
+ return {
907
+ sessionId: session.id,
908
+ runId: session.id,
909
+ apiRunId: session.id,
910
+ ...(session.sitemap ? { sitemap: session.sitemap } : {}),
911
+ ...(session.runtimeSessionId ? { runtimeSessionId: session.runtimeSessionId } : {}),
912
+ source: "cli",
913
+ selectedAt: new Date().toISOString(),
914
+ };
915
+ }
916
+ export async function trySelectDevSessionInOpenStudio(session, options, fetchImpl = fetch) {
917
+ const record = await readStudioInstanceRecord(options);
918
+ if (!record) {
919
+ return { selected: false, reason: "instance_record_missing" };
920
+ }
921
+ try {
922
+ const identityResponse = await fetchWithTimeout(`${record.baseUrl.replace(/\/+$/, "")}/api/studio/control/identity`, { headers: { Accept: "application/json" } }, 1_000, fetchImpl);
923
+ if (!identityResponse.ok) {
924
+ await unlinkIfExists(studioInstanceRecordPathForOptions(options));
925
+ return { selected: false, reason: `identity_http_${identityResponse.status}` };
926
+ }
927
+ const identity = await readJsonBody(identityResponse);
928
+ if (!studioInstanceIdentityMatches(identity, options)) {
929
+ return { selected: false, reason: "identity_mismatch" };
930
+ }
931
+ const selectResponse = await fetchWithTimeout(`${record.baseUrl.replace(/\/+$/, "")}/api/studio/control/select-session`, {
932
+ method: "POST",
933
+ headers: {
934
+ Accept: "application/json",
935
+ "Content-Type": "application/json",
936
+ },
937
+ body: JSON.stringify(studioSessionSelectIntent(session)),
938
+ }, 1_500, fetchImpl);
939
+ if (!selectResponse.ok) {
940
+ return { selected: false, reason: `select_http_${selectResponse.status}` };
941
+ }
942
+ return { selected: true, baseUrl: record.baseUrl };
943
+ }
944
+ catch (error) {
945
+ return { selected: false, reason: formatError(error) };
946
+ }
947
+ }
948
+ function renderDevSessions(sessions, selectedId = "") {
949
+ const lines = [`Tapi sessions for project ${sessions.projectId || "unknown"}`];
950
+ for (const group of sessions.groups || []) {
951
+ lines.push("");
952
+ lines.push(`[${group.sitemap || "unknown"}]`);
953
+ if (!group.sessions || group.sessions.length === 0) {
954
+ lines.push(" No sessions.");
955
+ continue;
956
+ }
957
+ for (const session of group.sessions) {
958
+ const selected = selectedId && session.id === selectedId ? ">" : " ";
959
+ const openable = session.openable ? "*" : " ";
960
+ const id = truncateText(session.id || "", 18).padEnd(18, " ");
961
+ const status = truncateText(String(session.status || ""), 24).padEnd(24, " ");
962
+ const apiName = session.apiName && session.requestKey
963
+ ? `${session.apiName}.${session.requestKey}`
964
+ : session.apiName || session.requestKey || "unknown";
965
+ const state = session.stateLabel ? ` state=${session.stateLabel}` : "";
966
+ const url = session.currentUrl ? ` ${truncateText(session.currentUrl, 60)}` : "";
967
+ lines.push(`${selected}${openable} ${id} ${status} ${apiName}${state}${url}`);
968
+ }
969
+ }
970
+ lines.push("");
971
+ lines.push("* openable awaiting-takeover session");
972
+ return lines.join("\n");
973
+ }
974
+ function truncateText(value, maxLength) {
975
+ if (value.length <= maxLength) {
976
+ return value;
977
+ }
978
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
979
+ }
606
980
  function defaultTriggerConfigPath(root) {
607
981
  for (const name of ["tapi.config.ts", "tapi.config.mjs", "tapi.config.js", "tapi.config.cjs", "tapi.config.json"]) {
608
982
  const path = join(root, name);
@@ -844,23 +1218,23 @@ function renderGeneratedApiClient(catalog) {
844
1218
  const lines = operations.map(({ key, operationName }) => ` ${operationName}: (inputs: Record<string, unknown> = {}, options: GeneratedRunOptions = {}) => client.websiteApis.run(${JSON.stringify(key)}, { ...options, inputs }),`);
845
1219
  return ` ${namespace}: {\n${lines.join("\n")}\n },`;
846
1220
  });
847
- return `/* Generated by Tapi. Do not edit by hand. */
848
- import { TapiClient, type TapiClientOptions, type RuntimeRunOptions } from "@tapi-dev/sdk";
849
-
850
- export interface GeneratedRunOptions {
851
- runtime?: RuntimeRunOptions;
852
- priority?: number;
853
- runnerId?: string;
854
- idempotencyKey?: string;
855
- site?: string;
856
- }
857
-
858
- export function createTapiGeneratedClient(options: TapiClientOptions) {
859
- const client = new TapiClient(options);
860
- return {
861
- ${namespaceBlocks.join("\n")}
862
- };
863
- }
1221
+ return `/* Generated by Tapi. Do not edit by hand. */
1222
+ import { TapiClient, type TapiClientOptions, type RuntimeRunOptions } from "@tapi-dev/sdk";
1223
+
1224
+ export interface GeneratedRunOptions {
1225
+ runtime?: RuntimeRunOptions;
1226
+ priority?: number;
1227
+ runnerId?: string;
1228
+ idempotencyKey?: string;
1229
+ site?: string;
1230
+ }
1231
+
1232
+ export function createTapiGeneratedClient(options: TapiClientOptions) {
1233
+ const client = new TapiClient(options);
1234
+ return {
1235
+ ${namespaceBlocks.join("\n")}
1236
+ };
1237
+ }
864
1238
  `;
865
1239
  }
866
1240
  function safeIdentifier(value) {
@@ -1074,18 +1448,7 @@ async function fetchServiceManifestForEnsure(options) {
1074
1448
  options: installEventOptions(options),
1075
1449
  });
1076
1450
  try {
1077
- let installToken = options.installToken?.trim();
1078
- if (!installToken) {
1079
- await event.phase("auth.install_token.request.start", {
1080
- apiBaseUrl: options.apiBaseUrl,
1081
- channel: options.channel,
1082
- });
1083
- installToken = await withCliSpinner("Checking Tapi Service install approval", () => obtainStudioInstallToken(options, event));
1084
- await event.phase("auth.install_token.request.success", {
1085
- apiBaseUrl: options.apiBaseUrl,
1086
- channel: options.channel,
1087
- });
1088
- }
1451
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Tapi Service install authorization");
1089
1452
  await event.phase("service_manifest.fetch.start", {
1090
1453
  apiBaseUrl: options.apiBaseUrl,
1091
1454
  channel: options.channel,
@@ -1148,18 +1511,7 @@ async function installService(options) {
1148
1511
  arch: process.arch,
1149
1512
  });
1150
1513
  ensureWindowsHost();
1151
- let installToken = options.installToken?.trim();
1152
- if (!installToken) {
1153
- await event.phase("auth.install_token.request.start", {
1154
- apiBaseUrl: options.apiBaseUrl,
1155
- channel: options.channel,
1156
- });
1157
- installToken = await withCliSpinner("Checking Tapi Service install approval", () => obtainStudioInstallToken(options, event));
1158
- await event.phase("auth.install_token.request.success", {
1159
- apiBaseUrl: options.apiBaseUrl,
1160
- channel: options.channel,
1161
- });
1162
- }
1514
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Tapi Service install authorization");
1163
1515
  await event.phase("service_manifest.fetch.start", {
1164
1516
  apiBaseUrl: options.apiBaseUrl,
1165
1517
  channel: options.channel,
@@ -1202,6 +1554,7 @@ async function installService(options) {
1202
1554
  }
1203
1555
  const releaseDir = join(releasesDir, safePathSegment(manifest.version));
1204
1556
  await withCliSpinner(`Extracting Tapi Service ${manifest.version}`, () => extractZip(zipPath, releaseDir));
1557
+ await markManagedReleaseDir(releaseDir, manifest.version);
1205
1558
  const installer = join(releaseDir, manifest.installer || "install_tapi_service.ps1");
1206
1559
  const hostExe = join(releaseDir, manifest.serviceHostExecutable || "tapi-service-host.exe");
1207
1560
  if (!existsSync(installer)) {
@@ -1243,6 +1596,8 @@ async function installService(options) {
1243
1596
  releaseDir,
1244
1597
  version: manifest.version,
1245
1598
  });
1599
+ await pruneManagedFiles(downloadsDir, [zipPath], isTapiServiceDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Service download");
1600
+ await pruneManagedDirs(releasesDir, [releaseDir], isSafeManagedReleaseDirName, MAX_NONCURRENT_MANAGED_RELEASES, "old Tapi Service release");
1246
1601
  console.log("Tapi Service is installed and running.");
1247
1602
  return 0;
1248
1603
  }
@@ -1395,6 +1750,10 @@ async function requestJson(method, url, body, apiKey, projectId) {
1395
1750
  }
1396
1751
  }
1397
1752
  export function getDefaultStudioCacheDir() {
1753
+ const homeOverride = envString("TAPI_STUDIO_HOME");
1754
+ if (homeOverride) {
1755
+ return join(resolve(homeOverride), "downloads");
1756
+ }
1398
1757
  if (process.platform === "win32") {
1399
1758
  const localAppData = process.env.LOCALAPPDATA ??
1400
1759
  (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
@@ -1716,24 +2075,7 @@ async function installStudio(options) {
1716
2075
  throw new Error("Direct Studio manifest overrides are no longer supported for install. "
1717
2076
  + "Use --channel with the protected install flow instead.");
1718
2077
  }
1719
- let installToken = options.installToken?.trim();
1720
- if (installToken) {
1721
- await event.phase("auth.install_token.provided", {
1722
- apiBaseUrl: options.apiBaseUrl,
1723
- channel: options.channel,
1724
- });
1725
- }
1726
- else {
1727
- await event.phase("auth.install_token.request.start", {
1728
- apiBaseUrl: options.apiBaseUrl,
1729
- channel: options.channel,
1730
- });
1731
- installToken = await withCliSpinner("Checking Studio install approval", () => obtainStudioInstallToken(options, event));
1732
- await event.phase("auth.install_token.request.success", {
1733
- apiBaseUrl: options.apiBaseUrl,
1734
- channel: options.channel,
1735
- });
1736
- }
2078
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1737
2079
  await event.phase("manifest.fetch.start", {
1738
2080
  apiBaseUrl: options.apiBaseUrl,
1739
2081
  channel: options.channel,
@@ -1852,6 +2194,39 @@ async function obtainStudioInstallToken(options, event) {
1852
2194
  const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, firebaseCreds.idToken);
1853
2195
  return issued.installToken;
1854
2196
  }
2197
+ export function memoizeStudioInstallToken(options, installToken) {
2198
+ const existing = options.installToken?.trim();
2199
+ if (existing) {
2200
+ options.installToken = existing;
2201
+ return existing;
2202
+ }
2203
+ const normalized = installToken?.trim() ?? "";
2204
+ if (normalized) {
2205
+ options.installToken = normalized;
2206
+ }
2207
+ return normalized;
2208
+ }
2209
+ async function ensureStudioInstallTokenForRun(options, event, message) {
2210
+ const existing = memoizeStudioInstallToken(options);
2211
+ if (existing) {
2212
+ await event.phase("auth.install_token.reused", {
2213
+ apiBaseUrl: options.apiBaseUrl,
2214
+ channel: options.channel,
2215
+ });
2216
+ return existing;
2217
+ }
2218
+ await event.phase("auth.install_token.request.start", {
2219
+ apiBaseUrl: options.apiBaseUrl,
2220
+ channel: options.channel,
2221
+ });
2222
+ const installToken = memoizeStudioInstallToken(options, await withCliSpinner(message, () => obtainStudioInstallToken(options, event)));
2223
+ await event.phase("auth.install_token.request.success", {
2224
+ apiBaseUrl: options.apiBaseUrl,
2225
+ channel: options.channel,
2226
+ cachedForRun: true,
2227
+ });
2228
+ return installToken;
2229
+ }
1855
2230
  async function openStudio(options) {
1856
2231
  ensureWindowsHost();
1857
2232
  if (options.downloadOnly) {
@@ -1876,7 +2251,7 @@ async function openStudio(options) {
1876
2251
  }
1877
2252
  if (!exePath || !existsSync(exePath)) {
1878
2253
  console.error("Tapi Studio executable was not found.");
1879
- console.error("Run `npx tapi studio install --channel pilot`, or set TAPI_STUDIO_EXE to the installed executable path.");
2254
+ console.error("Run `tapi studio install --channel pilot`, or set TAPI_STUDIO_EXE to the installed executable path.");
1880
2255
  return 1;
1881
2256
  }
1882
2257
  const child = spawn(exePath, [], {
@@ -1901,18 +2276,7 @@ async function ensurePortableStudioServer(options) {
1901
2276
  options: installEventOptions(options),
1902
2277
  });
1903
2278
  try {
1904
- let installToken = options.installToken?.trim();
1905
- if (!installToken) {
1906
- await event.phase("auth.install_token.request.start", {
1907
- apiBaseUrl: options.apiBaseUrl,
1908
- channel: options.channel,
1909
- });
1910
- installToken = await withCliSpinner("Checking Studio install approval", () => obtainStudioInstallToken(options, event));
1911
- await event.phase("auth.install_token.request.success", {
1912
- apiBaseUrl: options.apiBaseUrl,
1913
- channel: options.channel,
1914
- });
1915
- }
2279
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1916
2280
  await event.phase("manifest.fetch.start", {
1917
2281
  apiBaseUrl: options.apiBaseUrl,
1918
2282
  channel: options.channel,
@@ -1928,7 +2292,7 @@ async function ensurePortableStudioServer(options) {
1928
2292
  });
1929
2293
  return null;
1930
2294
  }
1931
- const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest), manifest);
2295
+ const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest, options.installDir), manifest);
1932
2296
  if (existing) {
1933
2297
  await event.finish(true, {
1934
2298
  serverExe: existing,
@@ -1972,14 +2336,13 @@ async function installPortableStudioServer(options, manifest, event) {
1972
2336
  }
1973
2337
  if (options.downloadOnly) {
1974
2338
  console.log(`Downloaded Tapi Studio server artifact: ${artifactPath}`);
2339
+ await pruneManagedFiles(options.cacheDir, [artifactPath], isTapiStudioDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Studio download");
1975
2340
  return artifactPath;
1976
2341
  }
1977
- const releaseDir = portableStudioServerReleaseDir(manifest);
1978
- await withCliSpinner(`Extracting Tapi Studio server ${manifest.version}`, () => extractZip(artifactPath, releaseDir));
1979
- const exePath = findPortableStudioServerExe(releaseDir, manifest);
1980
- if (!exePath) {
1981
- throw new Error(`Tapi Studio server executable was not found after extraction: ${releaseDir}`);
1982
- }
2342
+ const releaseDir = portableStudioServerReleaseDir(manifest, options.installDir);
2343
+ const exePath = await withCliSpinner(`Extracting Tapi Studio server ${manifest.version}`, () => extractPortableStudioServerZip(artifactPath, releaseDir, manifest));
2344
+ await pruneManagedFiles(options.cacheDir, [artifactPath], isTapiStudioDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Studio download");
2345
+ await pruneManagedDirs(dirname(releaseDir), [releaseDir], isSafeManagedReleaseDirName, MAX_NONCURRENT_MANAGED_RELEASES, "old Tapi Studio server release");
1983
2346
  console.log(`Installed Tapi Studio server ${manifest.version}: ${releaseDir}`);
1984
2347
  return exePath;
1985
2348
  }
@@ -2003,6 +2366,9 @@ async function launchPortableStudioServer(exePath, options, manifest) {
2003
2366
  env.PLAYWRIGHT_BROWSERS_PATH = playwrightBrowsersPath;
2004
2367
  }
2005
2368
  const url = `http://${host}:${port}`;
2369
+ const launchQuery = options.launchUrlQuery ? options.launchUrlQuery.replace(/^\?+/, "") : "";
2370
+ const launchUrl = launchQuery ? `${url}/?${launchQuery}` : url;
2371
+ let childPid = 0;
2006
2372
  await withCliSpinner("Starting Tapi Studio", async () => {
2007
2373
  const child = spawn(exePath, [], {
2008
2374
  detached: true,
@@ -2010,11 +2376,25 @@ async function launchPortableStudioServer(exePath, options, manifest) {
2010
2376
  stdio: "ignore",
2011
2377
  windowsHide: true,
2012
2378
  });
2379
+ childPid = child.pid ?? 0;
2013
2380
  child.unref();
2014
2381
  await waitForHttpOk(`${url}/healthz`, PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS);
2015
2382
  });
2016
- openBrowser(url);
2017
- console.log(`Opened Tapi Studio ${manifest.version}: ${url}`);
2383
+ await writeStudioInstanceRecord(options, {
2384
+ version: 1,
2385
+ baseUrl: url,
2386
+ host,
2387
+ port,
2388
+ pid: childPid || undefined,
2389
+ apiBaseUrl: options.apiBaseUrl,
2390
+ projectId: options.projectId,
2391
+ workspaceRoot: options.workspaceRoot,
2392
+ workspaceConfig: options.workspace?.configPath,
2393
+ manifestVersion: manifest.version,
2394
+ updatedAt: new Date().toISOString(),
2395
+ });
2396
+ openBrowser(launchUrl);
2397
+ console.log(`Opened Tapi Studio ${manifest.version}: ${launchUrl}`);
2018
2398
  }
2019
2399
  export function findPortablePlaywrightBrowsersDir(exePath) {
2020
2400
  const exeDir = dirname(exePath);
@@ -2060,6 +2440,19 @@ export async function waitForHttpOk(url, timeoutMs = PORTABLE_STUDIO_SERVER_READ
2060
2440
  const suffix = lastError ? ` Last error: ${lastError}.` : "";
2061
2441
  throw new Error(`Tapi Studio server did not become ready at ${url} within ${Math.ceil(timeoutMs / 1000)}s.${suffix}`);
2062
2442
  }
2443
+ async function fetchWithTimeout(url, init, timeoutMs, fetchImpl = fetch) {
2444
+ const controller = new AbortController();
2445
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
2446
+ try {
2447
+ return await fetchImpl(url, {
2448
+ ...init,
2449
+ signal: controller.signal,
2450
+ });
2451
+ }
2452
+ finally {
2453
+ clearTimeout(timeout);
2454
+ }
2455
+ }
2063
2456
  function delay(ms) {
2064
2457
  return new Promise((resolvePromise) => {
2065
2458
  setTimeout(resolvePromise, Math.max(0, ms));
@@ -2183,7 +2576,7 @@ async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, f
2183
2576
  if (!response.ok) {
2184
2577
  const detail = typeof responseBody?.detail === "string" ? responseBody.detail : "";
2185
2578
  if (detail === "pending_approval" || detail === "access_pending") {
2186
- throw new StudioInstallApprovalError("Access request submitted. Check your email for approval, then rerun `npx tapi studio install`.", detail, response.status);
2579
+ throw new StudioInstallApprovalError("Access request submitted. Check your email for approval, then rerun `tapi studio install`.", detail, response.status);
2187
2580
  }
2188
2581
  if (detail === "access_rejected") {
2189
2582
  throw new StudioInstallApprovalError("Studio install access was rejected. Contact the Tapi admin if this is unexpected.", detail, response.status);
@@ -2370,6 +2763,81 @@ async function writeStudioAuthCache(credentials) {
2370
2763
  function getStudioAuthCachePath() {
2371
2764
  return join(getDefaultTapiDataDir(), "auth.json");
2372
2765
  }
2766
+ export function studioInstanceRecordPathForOptions(options) {
2767
+ const key = createHash("sha256")
2768
+ .update(JSON.stringify({
2769
+ apiBaseUrl: comparableHttpUrl(options.apiBaseUrl),
2770
+ projectId: options.projectId ?? "",
2771
+ workspaceRoot: comparableFsPath(options.workspaceRoot ?? ""),
2772
+ }))
2773
+ .digest("hex")
2774
+ .slice(0, 24);
2775
+ return join(getDefaultTapiDataDir(), "Studio", "instances", `${key}.json`);
2776
+ }
2777
+ async function writeStudioInstanceRecord(options, record) {
2778
+ await writeJsonFile(studioInstanceRecordPathForOptions(options), record);
2779
+ }
2780
+ async function readStudioInstanceRecord(options) {
2781
+ try {
2782
+ const parsed = JSON.parse(await readFile(studioInstanceRecordPathForOptions(options), "utf8"));
2783
+ if (!isRecord(parsed)) {
2784
+ return null;
2785
+ }
2786
+ const baseUrl = typeof parsed.baseUrl === "string" ? parsed.baseUrl.trim() : "";
2787
+ const host = typeof parsed.host === "string" ? parsed.host.trim() : "";
2788
+ const port = typeof parsed.port === "number" && Number.isFinite(parsed.port) ? parsed.port : 0;
2789
+ if (!baseUrl || !host || port <= 0) {
2790
+ return null;
2791
+ }
2792
+ return {
2793
+ version: 1,
2794
+ baseUrl,
2795
+ host,
2796
+ port,
2797
+ pid: typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : undefined,
2798
+ apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : "",
2799
+ projectId: typeof parsed.projectId === "string" ? parsed.projectId : undefined,
2800
+ workspaceRoot: typeof parsed.workspaceRoot === "string" ? parsed.workspaceRoot : undefined,
2801
+ workspaceConfig: typeof parsed.workspaceConfig === "string" ? parsed.workspaceConfig : undefined,
2802
+ manifestVersion: typeof parsed.manifestVersion === "string" ? parsed.manifestVersion : undefined,
2803
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
2804
+ };
2805
+ }
2806
+ catch (error) {
2807
+ if (error.code === "ENOENT") {
2808
+ return null;
2809
+ }
2810
+ return null;
2811
+ }
2812
+ }
2813
+ function studioInstanceIdentityMatches(identity, options) {
2814
+ if (!identity || identity.ok !== true) {
2815
+ return false;
2816
+ }
2817
+ const expectedProject = (options.projectId ?? "").trim();
2818
+ const actualProject = typeof identity.projectId === "string" ? identity.projectId.trim() : "";
2819
+ if (expectedProject && actualProject && actualProject !== expectedProject) {
2820
+ return false;
2821
+ }
2822
+ const expectedWorkspace = comparableFsPath(options.workspaceRoot ?? "");
2823
+ const actualWorkspace = comparableFsPath(typeof identity.workspaceRoot === "string" ? identity.workspaceRoot : "");
2824
+ if (expectedWorkspace && actualWorkspace && actualWorkspace !== expectedWorkspace) {
2825
+ return false;
2826
+ }
2827
+ const expectedApiBaseUrl = comparableHttpUrl(options.apiBaseUrl);
2828
+ const actualApiBaseUrl = comparableHttpUrl(typeof identity.apiBaseUrl === "string" ? identity.apiBaseUrl : "");
2829
+ if (expectedApiBaseUrl && actualApiBaseUrl && actualApiBaseUrl !== expectedApiBaseUrl) {
2830
+ return false;
2831
+ }
2832
+ return true;
2833
+ }
2834
+ function comparableHttpUrl(value) {
2835
+ return String(value || "").trim().replace(/\/+$/, "").toLowerCase();
2836
+ }
2837
+ function comparableFsPath(value) {
2838
+ const trimmed = String(value || "").trim();
2839
+ return trimmed ? resolve(trimmed).toLowerCase() : "";
2840
+ }
2373
2841
  function getDefaultTapiDataDir() {
2374
2842
  if (process.platform === "win32") {
2375
2843
  const localAppData = process.env.LOCALAPPDATA
@@ -2493,10 +2961,14 @@ function isPortableStudioServerManifest(manifest) {
2493
2961
  const kind = String(manifest.installerKind || "").toLowerCase();
2494
2962
  return kind === "portable-server" || manifest.artifactName.toLowerCase().endsWith(".zip");
2495
2963
  }
2496
- function portableStudioServerReleaseDir(manifest) {
2497
- return join(getDefaultPortableStudioServerReleasesDir(), safePathSegment(manifest.version));
2964
+ function portableStudioServerReleaseDir(manifest, releasesDir = getDefaultPortableStudioServerReleasesDir()) {
2965
+ return join(releasesDir, safePathSegment(manifest.version));
2498
2966
  }
2499
2967
  function getDefaultPortableStudioServerReleasesDir() {
2968
+ const homeOverride = envString("TAPI_STUDIO_HOME");
2969
+ if (homeOverride) {
2970
+ return join(resolve(homeOverride), "server", "releases");
2971
+ }
2500
2972
  if (process.platform === "win32") {
2501
2973
  const localAppData = process.env.LOCALAPPDATA ??
2502
2974
  (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
@@ -2519,6 +2991,34 @@ export function findPortableStudioServerExe(releaseDir, manifest) {
2519
2991
  ];
2520
2992
  return candidates.find((candidate) => existsSync(candidate));
2521
2993
  }
2994
+ async function extractPortableStudioServerZip(zipPath, releaseDir, manifest) {
2995
+ const stagingDir = `${releaseDir}.extracting-${process.pid}-${Date.now()}`;
2996
+ const existingExe = findPortableStudioServerExe(releaseDir, manifest);
2997
+ if (!existingExe) {
2998
+ await rm(releaseDir, { recursive: true, force: true });
2999
+ }
3000
+ await rm(stagingDir, { recursive: true, force: true });
3001
+ try {
3002
+ await extractZip(zipPath, stagingDir);
3003
+ const stagedExe = findPortableStudioServerExe(stagingDir, manifest);
3004
+ if (!stagedExe) {
3005
+ throw new Error(`Tapi Studio server executable was not found after extraction. Expected ${manifest.serverExecutable || "tapi-studio-server.exe"} under ${stagingDir}.`);
3006
+ }
3007
+ await rm(releaseDir, { recursive: true, force: true });
3008
+ await mkdir(dirname(releaseDir), { recursive: true });
3009
+ await rename(stagingDir, releaseDir);
3010
+ await markManagedReleaseDir(releaseDir, manifest.version);
3011
+ const finalExe = findPortableStudioServerExe(releaseDir, manifest);
3012
+ if (!finalExe) {
3013
+ throw new Error(`Tapi Studio server executable disappeared while finalizing extraction: ${releaseDir}`);
3014
+ }
3015
+ return finalExe;
3016
+ }
3017
+ catch (error) {
3018
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
3019
+ throw error;
3020
+ }
3021
+ }
2522
3022
  function cachedServiceArtifactName(manifest) {
2523
3023
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiService-${manifest.version}.zip`;
2524
3024
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -2526,13 +3026,141 @@ function cachedServiceArtifactName(manifest) {
2526
3026
  async function extractZip(zipPath, destination) {
2527
3027
  await rm(destination, { recursive: true, force: true });
2528
3028
  await mkdir(destination, { recursive: true });
2529
- await runProcessCapture("powershell.exe", [
2530
- "-NoProfile",
2531
- "-ExecutionPolicy",
2532
- "Bypass",
2533
- "-Command",
2534
- `Expand-Archive -LiteralPath ${powerShellSingleQuoted(zipPath)} -DestinationPath ${powerShellSingleQuoted(destination)} -Force`,
2535
- ]);
3029
+ try {
3030
+ await runProcessCapture("powershell.exe", [
3031
+ "-NoProfile",
3032
+ "-ExecutionPolicy",
3033
+ "Bypass",
3034
+ "-Command",
3035
+ buildExpandArchiveCommand(zipPath, destination),
3036
+ ]);
3037
+ }
3038
+ catch (error) {
3039
+ throw new Error(formatZipExtractionFailure(zipPath, destination, error), { cause: error });
3040
+ }
3041
+ }
3042
+ export function buildExpandArchiveCommand(zipPath, destination) {
3043
+ return `$ErrorActionPreference = 'Stop'; try { Expand-Archive -LiteralPath ${powerShellSingleQuoted(zipPath)} -DestinationPath ${powerShellSingleQuoted(destination)} -Force -ErrorAction Stop } catch { Write-Error $_; exit 1 }`;
3044
+ }
3045
+ export function formatZipExtractionFailure(zipPath, destination, error) {
3046
+ const detail = formatError(error);
3047
+ const diskHint = /not enough space|no space left|disk full/i.test(detail)
3048
+ ? " Free disk space on that drive, or set TAPI_STUDIO_HOME / --cache-dir / --install-dir to paths on a drive with more room."
3049
+ : "";
3050
+ return `Failed to extract archive ${zipPath} to ${destination}: ${detail}${diskHint}`;
3051
+ }
3052
+ export async function pruneManagedFiles(directory, keepPaths, isManagedFileName, maxNoncurrentEntries = 0, label = "old Tapi download") {
3053
+ const keep = normalizedKeepPathSet(keepPaths);
3054
+ let entries;
3055
+ try {
3056
+ entries = await readdir(directory, { withFileTypes: true });
3057
+ }
3058
+ catch {
3059
+ return 0;
3060
+ }
3061
+ const candidates = [];
3062
+ for (const entry of entries) {
3063
+ if (!entry.isFile() || !isManagedFileName(entry.name)) {
3064
+ continue;
3065
+ }
3066
+ const candidate = join(directory, entry.name);
3067
+ if (keep.has(normalizeFilesystemPath(candidate))) {
3068
+ continue;
3069
+ }
3070
+ const info = await stat(candidate).catch(() => undefined);
3071
+ if (!info?.isFile()) {
3072
+ continue;
3073
+ }
3074
+ candidates.push({ path: candidate, mtimeMs: info.mtimeMs });
3075
+ }
3076
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
3077
+ const removable = candidates.slice(Math.max(0, maxNoncurrentEntries));
3078
+ let removed = 0;
3079
+ for (const candidate of removable) {
3080
+ try {
3081
+ await rm(candidate.path, { force: true });
3082
+ removed += 1;
3083
+ }
3084
+ catch {
3085
+ // Cache pruning is best effort; install success should not depend on cleanup.
3086
+ }
3087
+ }
3088
+ if (removed > 0) {
3089
+ console.log(`Pruned ${removed} ${label}${removed === 1 ? "" : "s"}.`);
3090
+ }
3091
+ return removed;
3092
+ }
3093
+ async function pruneManagedDirs(directory, keepPaths, isManagedDirName, maxNoncurrentEntries = 0, label = "old Tapi release") {
3094
+ const keep = normalizedKeepPathSet(keepPaths);
3095
+ let entries;
3096
+ try {
3097
+ entries = await readdir(directory, { withFileTypes: true });
3098
+ }
3099
+ catch {
3100
+ return 0;
3101
+ }
3102
+ const candidates = [];
3103
+ for (const entry of entries) {
3104
+ if (!entry.isDirectory() || !isManagedDirName(entry.name)) {
3105
+ continue;
3106
+ }
3107
+ const candidate = join(directory, entry.name);
3108
+ if (keep.has(normalizeFilesystemPath(candidate))) {
3109
+ continue;
3110
+ }
3111
+ if (!existsSync(join(candidate, MANAGED_RELEASE_MARKER))) {
3112
+ continue;
3113
+ }
3114
+ const info = await stat(candidate).catch(() => undefined);
3115
+ if (!info?.isDirectory()) {
3116
+ continue;
3117
+ }
3118
+ candidates.push({ path: candidate, mtimeMs: info.mtimeMs });
3119
+ }
3120
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
3121
+ const removable = candidates.slice(Math.max(0, maxNoncurrentEntries));
3122
+ let removed = 0;
3123
+ for (const candidate of removable) {
3124
+ try {
3125
+ await rm(candidate.path, { recursive: true, force: true });
3126
+ removed += 1;
3127
+ }
3128
+ catch {
3129
+ // Cache pruning is best effort; install success should not depend on cleanup.
3130
+ }
3131
+ }
3132
+ if (removed > 0) {
3133
+ console.log(`Pruned ${removed} ${label}${removed === 1 ? "" : "s"}.`);
3134
+ }
3135
+ return removed;
3136
+ }
3137
+ export function isTapiStudioDownloadArtifact(fileName) {
3138
+ const normalized = fileName.toLowerCase();
3139
+ return (normalized.endsWith(".zip")
3140
+ && (normalized.startsWith("tapi-studio")
3141
+ || normalized.startsWith("tapistudio")
3142
+ || normalized.includes("tapi_studio")));
3143
+ }
3144
+ export function isTapiServiceDownloadArtifact(fileName) {
3145
+ const normalized = fileName.toLowerCase();
3146
+ return normalized.endsWith(".zip") && (normalized.startsWith("tapiservice-") || normalized.startsWith("tapi-service"));
3147
+ }
3148
+ function isSafeManagedReleaseDirName(name) {
3149
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
3150
+ }
3151
+ async function markManagedReleaseDir(directory, version) {
3152
+ const payload = JSON.stringify({
3153
+ product: "tapi",
3154
+ version,
3155
+ managedBy: "@tapi-dev/sdk",
3156
+ });
3157
+ await writeFile(join(directory, MANAGED_RELEASE_MARKER), `${payload}\n`).catch(() => undefined);
3158
+ }
3159
+ function normalizedKeepPathSet(paths) {
3160
+ return new Set(paths.map((path) => normalizeFilesystemPath(path)));
3161
+ }
3162
+ function normalizeFilesystemPath(path) {
3163
+ return resolve(path).replace(/\\/g, "/").toLowerCase();
2536
3164
  }
2537
3165
  function installEventOptions(options) {
2538
3166
  return {
@@ -2541,6 +3169,7 @@ function installEventOptions(options) {
2541
3169
  manifestUrl: options.manifestUrl,
2542
3170
  manifestUrlOverride: options.manifestUrlOverride,
2543
3171
  cacheDir: options.cacheDir,
3172
+ installDir: options.installDir,
2544
3173
  downloadOnly: options.downloadOnly,
2545
3174
  silent: options.silent,
2546
3175
  exePath: options.exePath,
@@ -2669,145 +3298,166 @@ function readSdkVersion() {
2669
3298
  function printHelp() {
2670
3299
  console.log(`Tapi CLI
2671
3300
 
2672
- Usage:
2673
- tapi init --project PROJECT
2674
- tapi link --project PROJECT
2675
- tapi studio install [--channel pilot] [--api-base-url URL]
2676
- tapi studio
2677
- tapi studio open
2678
- tapi studio doctor
2679
- tapi apis describe <namespace.operation>
2680
- tapi apis sync
2681
- tapi apis generate
2682
- tapi triggers sync
2683
- tapi publish
2684
- tapi service status
2685
- tapi doctor
2686
-
2687
- Commands:
2688
- init Create .tapi/project.json for this repo
2689
- link Rebind this repo to an existing Tapi project
2690
- studio install Download, verify, and run the Tapi Studio installer
2691
- studio Open Tapi Studio for this repo
2692
- studio open Open Tapi Studio for this repo
2693
- studio doctor Check local SDK and Studio release configuration
2694
- apis describe Print a generated website API input/output contract
2695
- apis sync Save the published API catalog to .tapi/generated
2696
- apis generate Generate a TypeScript runtime wrapper from the catalog
2697
- triggers sync Upsert API-call triggers from tapi.config
2698
- publish Upload local .tapi API drafts and publish ready requests
2699
- service Inspect or control the local Tapi Windows service
2700
- doctor Alias for studio doctor
3301
+ Usage:
3302
+ tapi init --project PROJECT
3303
+ tapi link --project PROJECT
3304
+ tapi studio install [--channel pilot] [--api-base-url URL]
3305
+ tapi studio
3306
+ tapi studio open
3307
+ tapi studio doctor
3308
+ tapi apis describe <namespace.operation>
3309
+ tapi apis sync
3310
+ tapi apis generate
3311
+ tapi triggers sync
3312
+ tapi sessions
3313
+ tapi publish
3314
+ tapi service status
3315
+ tapi doctor
3316
+
3317
+ Commands:
3318
+ init Create .tapi/project.json for this repo
3319
+ link Rebind this repo to an existing Tapi project
3320
+ studio install Download, verify, and run the Tapi Studio installer
3321
+ studio Open Tapi Studio for this repo
3322
+ studio open Open Tapi Studio for this repo
3323
+ studio doctor Check local SDK and Studio release configuration
3324
+ apis describe Print a generated website API input/output contract
3325
+ apis sync Save the published API catalog to .tapi/generated
3326
+ apis generate Generate a TypeScript runtime wrapper from the catalog
3327
+ triggers sync Upsert API-call triggers from tapi.config
3328
+ sessions List dev-mode API sessions and open takeover sessions
3329
+ publish Upload local .tapi API drafts and publish ready requests
3330
+ service Inspect or control the local Tapi Windows service
3331
+ doctor Alias for studio doctor
3332
+ `);
3333
+ }
3334
+ function printSessionsHelp() {
3335
+ console.log(`Tapi dev session commands
3336
+
3337
+ Usage:
3338
+ tapi sessions [--api-base-url URL] [--api-key KEY] [--project PROJECT]
3339
+ tapi sessions list [--json] [--site SITE]
3340
+ tapi sessions open <session-id> [--api-base-url URL] [--api-key KEY] [--project PROJECT]
3341
+
3342
+ Options:
3343
+ --api-base-url <url> Tapi API base URL
3344
+ --server <url> Alias for --api-base-url
3345
+ --api-key <key> Tapi API key; defaults to TAPI_API_KEY
3346
+ --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
3347
+ --site <name> Limit sessions to one sitemap/site
3348
+ --json Print raw grouped session JSON
3349
+ --no-interactive Print the grouped list without the arrow-key picker
2701
3350
  `);
2702
3351
  }
2703
3352
  function printApisHelp() {
2704
- console.log(`Tapi generated website API commands
2705
-
2706
- Usage:
2707
- tapi apis describe <namespace.operation> [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2708
- tapi apis sync [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2709
- tapi apis generate [--api-base-url URL] [--api-key KEY] [--project PROJECT] [--out FILE]
2710
-
2711
- Options:
2712
- --api-base-url <url> Tapi API base URL
2713
- --server <url> Alias for --api-base-url
2714
- --api-key <key> Tapi API key; defaults to TAPI_API_KEY
2715
- --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
2716
- --catalog <path> Catalog JSON output path
2717
- --out <path> Generated TypeScript output path
3353
+ console.log(`Tapi generated website API commands
3354
+
3355
+ Usage:
3356
+ tapi apis describe <namespace.operation> [--api-base-url URL] [--api-key KEY] [--project PROJECT]
3357
+ tapi apis sync [--api-base-url URL] [--api-key KEY] [--project PROJECT]
3358
+ tapi apis generate [--api-base-url URL] [--api-key KEY] [--project PROJECT] [--out FILE]
3359
+
3360
+ Options:
3361
+ --api-base-url <url> Tapi API base URL
3362
+ --server <url> Alias for --api-base-url
3363
+ --api-key <key> Tapi API key; defaults to TAPI_API_KEY
3364
+ --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
3365
+ --catalog <path> Catalog JSON output path
3366
+ --out <path> Generated TypeScript output path
2718
3367
  `);
2719
3368
  }
2720
3369
  function printTriggersHelp() {
2721
- console.log(`Tapi API trigger commands
2722
-
2723
- Usage:
2724
- tapi triggers sync [--config FILE] [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2725
-
2726
- Options:
2727
- --config <path> Trigger config path; defaults to tapi.config.ts/js/json in the workspace root
2728
- --api-base-url <url> Tapi API base URL
2729
- --server <url> Alias for --api-base-url
2730
- --api-key <key> Tapi API key; defaults to TAPI_API_KEY
2731
- --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
2732
-
2733
- Config:
2734
- export default {
2735
- triggers: {
2736
- nightlyBalance: {
2737
- apiRequest: "schwab.get_balance",
2738
- interval: "1h",
2739
- inputs: { accountId: "main" },
2740
- runtime: { profileRef: "perm_default" },
2741
- },
2742
- },
2743
- };
3370
+ console.log(`Tapi API trigger commands
3371
+
3372
+ Usage:
3373
+ tapi triggers sync [--config FILE] [--api-base-url URL] [--api-key KEY] [--project PROJECT]
3374
+
3375
+ Options:
3376
+ --config <path> Trigger config path; defaults to tapi.config.ts/js/json in the workspace root
3377
+ --api-base-url <url> Tapi API base URL
3378
+ --server <url> Alias for --api-base-url
3379
+ --api-key <key> Tapi API key; defaults to TAPI_API_KEY
3380
+ --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
3381
+
3382
+ Config:
3383
+ export default {
3384
+ triggers: {
3385
+ nightlyBalance: {
3386
+ apiRequest: "schwab.get_balance",
3387
+ interval: "1h",
3388
+ inputs: { accountId: "main" },
3389
+ runtime: { profileRef: "perm_default" },
3390
+ },
3391
+ },
3392
+ };
2744
3393
  `);
2745
3394
  }
2746
3395
  function printStudioHelp() {
2747
- console.log(`Tapi Studio commands
2748
-
2749
- Usage:
2750
- tapi studio [options]
2751
- tapi studio install [options]
2752
- tapi studio open [options]
2753
- tapi studio doctor [options]
2754
-
2755
- Options:
3396
+ console.log(`Tapi Studio commands
3397
+
3398
+ Usage:
3399
+ tapi studio [options]
3400
+ tapi studio install [options]
3401
+ tapi studio open [options]
3402
+ tapi studio doctor [options]
3403
+
3404
+ Options:
2756
3405
  --channel <name> Release channel: pilot, stable, or nightly
2757
- --api-base-url <url> Tapi API base URL for approval and protected downloads
2758
- --server <url> Alias for --api-base-url
2759
- --workspace <path> Search this directory for .tapi/project.json
2760
- --no-workspace Do not load .tapi/project.json
2761
- --project <id> Tapi project id for workspace-bound Studio
2762
- --project-slug <slug> Optional display slug for the bound project
2763
- --install-token <tok> Preissued Studio install token (skips browser sign-in)
2764
- --manifest <url> Exact release manifest URL for doctor only
2765
- --cache-dir <path> Installer download cache directory
2766
- --download-only Download and verify without running the installer
2767
- --silent Run the NSIS installer with /S
2768
- --exe <path> Tapi Studio executable path for open/doctor
3406
+ --api-base-url <url> Tapi API base URL for install authorization and protected downloads
3407
+ --server <url> Alias for --api-base-url
3408
+ --workspace <path> Search this directory for .tapi/project.json
3409
+ --no-workspace Do not load .tapi/project.json
3410
+ --project <id> Tapi project id for workspace-bound Studio
3411
+ --project-slug <slug> Optional display slug for the bound project
3412
+ --install-token <tok> Preissued Studio install token (skips browser sign-in)
3413
+ --manifest <url> Exact release manifest URL for doctor only
3414
+ --cache-dir <path> Installer download cache directory
3415
+ --install-dir <path> Portable Studio server release directory
3416
+ --download-only Download and verify without running the installer
3417
+ --silent Run the NSIS installer with /S
3418
+ --exe <path> Tapi Studio executable path for open/doctor
2769
3419
  `);
2770
3420
  }
2771
3421
  function printWorkspaceHelp(command) {
2772
- console.log(`Tapi workspace ${command}
2773
-
2774
- Usage:
2775
- tapi ${command} --project PROJECT [--api-base-url URL] [--root PATH] [--force]
2776
-
2777
- Options:
2778
- --project <id> Tapi project id/name to bind this repo to
2779
- --project-slug <slug> Optional display slug
2780
- --api-base-url <url> Tapi API base URL stored in .tapi/project.json
2781
- --server <url> Alias for --api-base-url
2782
- --root <path> Directory where .tapi/project.json should be written
2783
- --force Overwrite an existing workspace config
3422
+ console.log(`Tapi workspace ${command}
3423
+
3424
+ Usage:
3425
+ tapi ${command} --project PROJECT [--api-base-url URL] [--root PATH] [--force]
3426
+
3427
+ Options:
3428
+ --project <id> Tapi project id/name to bind this repo to
3429
+ --project-slug <slug> Optional display slug
3430
+ --api-base-url <url> Tapi API base URL stored in .tapi/project.json
3431
+ --server <url> Alias for --api-base-url
3432
+ --root <path> Directory where .tapi/project.json should be written
3433
+ --force Overwrite an existing workspace config
2784
3434
  `);
2785
3435
  }
2786
3436
  function printServiceHelp() {
2787
- console.log(`Tapi service commands
2788
-
2789
- Usage:
2790
- tapi service status
2791
- tapi service start
2792
- tapi service stop
2793
- tapi service restart
2794
- tapi service repair
2795
-
2796
- Commands:
2797
- status Print installed/running status for tapi-service
2798
- start Start tapi-service
2799
- stop Stop tapi-service
2800
- restart Restart tapi-service
2801
- repair Restart tapi-service using the current installed service
3437
+ console.log(`Tapi service commands
3438
+
3439
+ Usage:
3440
+ tapi service status
3441
+ tapi service start
3442
+ tapi service stop
3443
+ tapi service restart
3444
+ tapi service repair
3445
+
3446
+ Commands:
3447
+ status Print installed/running status for tapi-service
3448
+ start Start tapi-service
3449
+ stop Stop tapi-service
3450
+ restart Restart tapi-service
3451
+ repair Restart tapi-service using the current installed service
2802
3452
  `);
2803
3453
  }
2804
3454
  function printPublishHelp() {
2805
- console.log(`Tapi publish
2806
-
2807
- Usage:
2808
- tapi publish [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2809
-
2810
- Publishes ready generated API requests from local .tapi/apis files.
3455
+ console.log(`Tapi publish
3456
+
3457
+ Usage:
3458
+ tapi publish [--api-base-url URL] [--api-key KEY] [--project PROJECT]
3459
+
3460
+ Publishes ready generated API requests from local .tapi/apis files.
2811
3461
  `);
2812
3462
  }
2813
3463
  function formatError(error) {