@tapi-dev/sdk 0.1.13 → 0.1.17

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
@@ -30,6 +30,23 @@ Studio executable is installed yet, `tapi studio` runs the installer first, then
30
30
  opens Studio. The normal developer path is the portable server launched by the
31
31
  CLI.
32
32
 
33
+ By default, Studio downloads and extracted portable server releases live under
34
+ the user's local app data directory. On Windows that is usually
35
+ `%LOCALAPPDATA%\Tapi\Studio`. To keep the whole Studio install on another drive,
36
+ set `TAPI_STUDIO_HOME` before launching:
37
+
38
+ ```powershell
39
+ $env:TAPI_STUDIO_HOME="F:\Tapi\Studio"
40
+ npx tapi studio
41
+ ```
42
+
43
+ For separate paths, use `--cache-dir` for downloaded zip artifacts and
44
+ `--install-dir` for extracted portable Studio server releases.
45
+
46
+ After a successful install, the CLI prunes older Tapi-managed download artifacts
47
+ from the cache and older SDK-marked release folders from the install directory.
48
+ The current artifact and current release are kept.
49
+
33
50
  Useful commands:
34
51
 
35
52
  ```bash
package/dist/cli.d.ts CHANGED
@@ -54,6 +54,7 @@ interface StudioCliOptions {
54
54
  manifestUrl: string;
55
55
  manifestUrlOverride?: string;
56
56
  cacheDir: string;
57
+ installDir: string;
57
58
  downloadOnly: boolean;
58
59
  silent: boolean;
59
60
  exePath?: string;
@@ -102,6 +103,13 @@ export declare function getStudioExecutableCandidates(): string[];
102
103
  export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
103
104
  export declare function validateServiceReleaseManifest(input: unknown): ServiceReleaseManifest;
104
105
  export declare function compareVersions(left: string, right: string): number;
106
+ export declare function memoizeStudioInstallToken(options: StudioCliOptions, installToken?: string): string;
107
+ export declare function findPortablePlaywrightBrowsersDir(exePath: string): string | undefined;
105
108
  export declare function waitForHttpOk(url: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: FetchLike): Promise<void>;
106
109
  export declare function findPortableStudioServerExe(releaseDir: string, manifest: StudioReleaseManifest): string | undefined;
110
+ export declare function buildExpandArchiveCommand(zipPath: string, destination: string): string;
111
+ export declare function formatZipExtractionFailure(zipPath: string, destination: string, error: unknown): string;
112
+ export declare function pruneManagedFiles(directory: string, keepPaths: string[], isManagedFileName: (name: string) => boolean, maxNoncurrentEntries?: number, label?: string): Promise<number>;
113
+ export declare function isTapiStudioDownloadArtifact(fileName: string): boolean;
114
+ export declare function isTapiServiceDownloadArtifact(fileName: string): boolean;
107
115
  export {};
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
4
  import { createServer as createNetServer } from "node:net";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
- import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
6
+ import { createReadStream, createWriteStream, existsSync, readFileSync, readdirSync } from "node:fs";
7
7
  import { mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { homedir } from "node:os";
9
9
  import { basename, dirname, join, resolve } from "node:path";
@@ -23,6 +23,9 @@ const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
23
23
  const PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS = 30_000;
24
24
  const PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS = 250;
25
25
  const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
26
+ const MAX_NONCURRENT_MANAGED_DOWNLOADS = 0;
27
+ const MAX_NONCURRENT_MANAGED_RELEASES = 0;
28
+ const MANAGED_RELEASE_MARKER = ".tapi-managed-release";
26
29
  class StudioInstallApprovalError extends Error {
27
30
  code;
28
31
  status;
@@ -34,6 +37,75 @@ class StudioInstallApprovalError extends Error {
34
37
  }
35
38
  }
36
39
  const sdkVersion = readSdkVersion();
40
+ const CLI_SPINNER_FRAMES = ["|", "/", "-", "\\"];
41
+ function cliSpinnerEnabled() {
42
+ const noSpinner = (process.env.TAPI_NO_SPINNER || "").trim().toLowerCase();
43
+ if (["1", "true", "yes", "on"].includes(noSpinner)) {
44
+ return false;
45
+ }
46
+ const spinner = (process.env.TAPI_CLI_SPINNER || "").trim().toLowerCase();
47
+ if (["0", "false", "no", "off"].includes(spinner)) {
48
+ return false;
49
+ }
50
+ return Boolean(process.stderr.isTTY);
51
+ }
52
+ class CliSpinner {
53
+ message;
54
+ frameIndex = 0;
55
+ lastLength = 0;
56
+ timer;
57
+ constructor(message) {
58
+ this.message = message;
59
+ }
60
+ start() {
61
+ if (this.timer) {
62
+ return;
63
+ }
64
+ this.render();
65
+ this.timer = setInterval(() => this.render(), 90);
66
+ }
67
+ succeed() {
68
+ this.stop("done");
69
+ }
70
+ fail() {
71
+ this.stop("failed");
72
+ }
73
+ render() {
74
+ const frame = CLI_SPINNER_FRAMES[this.frameIndex % CLI_SPINNER_FRAMES.length];
75
+ this.frameIndex += 1;
76
+ this.write(`${frame} ${this.message}`);
77
+ }
78
+ stop(status) {
79
+ if (this.timer) {
80
+ clearInterval(this.timer);
81
+ this.timer = undefined;
82
+ }
83
+ this.write(`${this.message} ${status}`);
84
+ process.stderr.write("\n");
85
+ this.lastLength = 0;
86
+ }
87
+ write(text) {
88
+ const padding = this.lastLength > text.length ? " ".repeat(this.lastLength - text.length) : "";
89
+ process.stderr.write(`\r${text}${padding}`);
90
+ this.lastLength = text.length;
91
+ }
92
+ }
93
+ async function withCliSpinner(message, work) {
94
+ if (!cliSpinnerEnabled()) {
95
+ return await work();
96
+ }
97
+ const spinner = new CliSpinner(message);
98
+ spinner.start();
99
+ try {
100
+ const result = await work();
101
+ spinner.succeed();
102
+ return result;
103
+ }
104
+ catch (error) {
105
+ spinner.fail();
106
+ throw error;
107
+ }
108
+ }
37
109
  export async function runCli(argv = process.argv.slice(2)) {
38
110
  const [command, subcommand, ...rest] = argv;
39
111
  if (!command || command === "help" || command === "--help" || command === "-h") {
@@ -213,6 +285,14 @@ export function parseStudioOptions(args) {
213
285
  raw.cacheDir = resolve(arg.slice("--cache-dir=".length));
214
286
  continue;
215
287
  }
288
+ if (arg === "--install-dir") {
289
+ raw.installDir = resolve(requireOptionValue(args, ++index, "--install-dir"));
290
+ continue;
291
+ }
292
+ if (arg.startsWith("--install-dir=")) {
293
+ raw.installDir = resolve(arg.slice("--install-dir=".length));
294
+ continue;
295
+ }
216
296
  if (arg === "--exe") {
217
297
  raw.exePath = resolve(requireOptionValue(args, ++index, "--exe"));
218
298
  continue;
@@ -255,6 +335,7 @@ export function parseStudioOptions(args) {
255
335
  manifestUrl,
256
336
  manifestUrlOverride: explicitManifestUrl ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL") : undefined,
257
337
  cacheDir: raw.cacheDir ?? getDefaultStudioCacheDir(),
338
+ installDir: raw.installDir ?? resolve(envString("TAPI_STUDIO_INSTALL_DIR") ?? getDefaultPortableStudioServerReleasesDir()),
258
339
  downloadOnly: raw.downloadOnly ?? false,
259
340
  silent: raw.silent ?? false,
260
341
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
@@ -339,7 +420,7 @@ async function describeApiOperation(args) {
339
420
  apiKey: options.apiKey,
340
421
  projectId: options.projectId,
341
422
  });
342
- const description = await client.websiteApis.describe(operation);
423
+ const description = await withCliSpinner(`Fetching Tapi API description for ${operation}`, () => client.websiteApis.describe(operation));
343
424
  console.log(JSON.stringify(description, null, 2));
344
425
  return 0;
345
426
  }
@@ -423,7 +504,7 @@ function parseApiWorkspaceOptions(args) {
423
504
  async function syncApiCatalog(args) {
424
505
  try {
425
506
  const options = parseApiWorkspaceOptions(args);
426
- const catalog = await fetchApiCatalog(options);
507
+ const catalog = await withCliSpinner("Fetching Tapi API catalog", () => fetchApiCatalog(options));
427
508
  await writeJsonFile(options.catalogPath, catalog);
428
509
  console.log(`Synced Tapi API catalog: ${options.catalogPath}`);
429
510
  return 0;
@@ -436,7 +517,7 @@ async function syncApiCatalog(args) {
436
517
  async function generateApiClient(args) {
437
518
  try {
438
519
  const options = parseApiWorkspaceOptions(args);
439
- const catalog = await fetchApiCatalog(options);
520
+ const catalog = await withCliSpinner("Fetching Tapi API catalog", () => fetchApiCatalog(options));
440
521
  await writeJsonFile(options.catalogPath, catalog);
441
522
  await writeTextFile(options.typescriptPath, renderGeneratedApiClient(catalog));
442
523
  console.log(`Generated Tapi API client: ${options.typescriptPath}`);
@@ -521,9 +602,11 @@ async function syncApiTriggers(args) {
521
602
  apiKey: options.apiKey,
522
603
  projectId: options.projectId,
523
604
  });
524
- for (const trigger of triggers) {
525
- await client.triggers.create(trigger);
526
- }
605
+ await withCliSpinner(`Syncing ${triggers.length} Tapi API trigger(s)`, async () => {
606
+ for (const trigger of triggers) {
607
+ await client.triggers.create(trigger);
608
+ }
609
+ });
527
610
  console.log(`Synced ${triggers.length} Tapi API trigger(s) from ${options.configPath}.`);
528
611
  return 0;
529
612
  }
@@ -906,7 +989,7 @@ async function runServiceCommand(action, args = []) {
906
989
  return repairService(parseStudioOptions([]));
907
990
  }
908
991
  const command = servicePowerShell(normalized);
909
- const output = await runProcessCapture("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]);
992
+ const output = await withCliSpinner(`Running tapi-service ${normalized}`, () => runProcessCapture("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]));
910
993
  if (output.trim()) {
911
994
  console.log(output.trim());
912
995
  }
@@ -918,7 +1001,7 @@ async function runServiceCommand(action, args = []) {
918
1001
  }
919
1002
  }
920
1003
  async function repairService(options) {
921
- const status = await getServiceStatus();
1004
+ const status = await withCliSpinner("Checking Tapi Service status", () => getServiceStatus());
922
1005
  if (!status.installed) {
923
1006
  return installService(options);
924
1007
  }
@@ -964,7 +1047,7 @@ async function getServiceStatus() {
964
1047
  }
965
1048
  }
966
1049
  async function ensureServiceReadyForStudio(options) {
967
- const status = await getServiceStatus();
1050
+ const status = await withCliSpinner("Checking Tapi Service status", () => getServiceStatus());
968
1051
  if (!status.installed) {
969
1052
  console.log("Tapi Service is required and is not installed. Installing it now...");
970
1053
  const code = await installService(options);
@@ -985,13 +1068,13 @@ async function ensureServiceReadyForStudio(options) {
985
1068
  }
986
1069
  if (status.status !== "Running") {
987
1070
  console.log("Starting Tapi Service...");
988
- const output = await runProcessCapture("powershell.exe", [
1071
+ const output = await withCliSpinner("Starting Tapi Service", () => runProcessCapture("powershell.exe", [
989
1072
  "-NoProfile",
990
1073
  "-ExecutionPolicy",
991
1074
  "Bypass",
992
1075
  "-Command",
993
1076
  servicePowerShell("start"),
994
- ]);
1077
+ ]));
995
1078
  if (output.trim()) {
996
1079
  console.log(output.trim());
997
1080
  }
@@ -1003,23 +1086,12 @@ async function fetchServiceManifestForEnsure(options) {
1003
1086
  options: installEventOptions(options),
1004
1087
  });
1005
1088
  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
- }
1089
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Tapi Service install authorization");
1018
1090
  await event.phase("service_manifest.fetch.start", {
1019
1091
  apiBaseUrl: options.apiBaseUrl,
1020
1092
  channel: options.channel,
1021
1093
  });
1022
- const manifest = await fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken);
1094
+ const manifest = await withCliSpinner(`Fetching Tapi Service ${options.channel} manifest`, () => fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken));
1023
1095
  ensureCompatibleServiceManifest(manifest);
1024
1096
  await event.finish(true, {
1025
1097
  manifest: serviceManifestEventSummary(manifest),
@@ -1077,25 +1149,12 @@ async function installService(options) {
1077
1149
  arch: process.arch,
1078
1150
  });
1079
1151
  ensureWindowsHost();
1080
- let installToken = options.installToken?.trim();
1081
- if (!installToken) {
1082
- console.log("Checking Tapi Service install approval...");
1083
- await event.phase("auth.install_token.request.start", {
1084
- apiBaseUrl: options.apiBaseUrl,
1085
- channel: options.channel,
1086
- });
1087
- installToken = await obtainStudioInstallToken(options, event);
1088
- await event.phase("auth.install_token.request.success", {
1089
- apiBaseUrl: options.apiBaseUrl,
1090
- channel: options.channel,
1091
- });
1092
- }
1093
- console.log(`Fetching Tapi Service ${options.channel} manifest...`);
1152
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Tapi Service install authorization");
1094
1153
  await event.phase("service_manifest.fetch.start", {
1095
1154
  apiBaseUrl: options.apiBaseUrl,
1096
1155
  channel: options.channel,
1097
1156
  });
1098
- const manifest = await fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken);
1157
+ const manifest = await withCliSpinner(`Fetching Tapi Service ${options.channel} manifest`, () => fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken));
1099
1158
  await event.phase("service_manifest.fetch.success", {
1100
1159
  manifest: serviceManifestEventSummary(manifest),
1101
1160
  });
@@ -1112,13 +1171,12 @@ async function installService(options) {
1112
1171
  await event.phase("cache.hit", { zipPath });
1113
1172
  }
1114
1173
  else {
1115
- console.log(`Downloading Tapi Service ${manifest.version}...`);
1116
1174
  await event.phase("download.start", {
1117
1175
  url: manifest.url,
1118
1176
  destination: zipPath,
1119
1177
  expectedSha256: manifest.sha256,
1120
1178
  });
1121
- await downloadAndVerify(manifest.url, zipPath, manifest.sha256, "Tapi Service artifact");
1179
+ await withCliSpinner(`Downloading Tapi Service ${manifest.version}`, () => downloadAndVerify(manifest.url, zipPath, manifest.sha256, "Tapi Service artifact"));
1122
1180
  await event.phase("download.verified", {
1123
1181
  zipPath,
1124
1182
  expectedSha256: manifest.sha256,
@@ -1133,7 +1191,8 @@ async function installService(options) {
1133
1191
  return 0;
1134
1192
  }
1135
1193
  const releaseDir = join(releasesDir, safePathSegment(manifest.version));
1136
- await extractZip(zipPath, releaseDir);
1194
+ await withCliSpinner(`Extracting Tapi Service ${manifest.version}`, () => extractZip(zipPath, releaseDir));
1195
+ await markManagedReleaseDir(releaseDir, manifest.version);
1137
1196
  const installer = join(releaseDir, manifest.installer || "install_tapi_service.ps1");
1138
1197
  const hostExe = join(releaseDir, manifest.serviceHostExecutable || "tapi-service-host.exe");
1139
1198
  if (!existsSync(installer)) {
@@ -1144,7 +1203,6 @@ async function installService(options) {
1144
1203
  }
1145
1204
  const installLog = serviceInstallLogPaths(manifest.version);
1146
1205
  await mkdir(dirname(installLog.logPath), { recursive: true });
1147
- console.log(`Installing Tapi Service ${manifest.version}...`);
1148
1206
  await event.phase("service_installer.start", {
1149
1207
  installer,
1150
1208
  hostExe,
@@ -1153,7 +1211,7 @@ async function installService(options) {
1153
1211
  resultPath: installLog.resultPath,
1154
1212
  });
1155
1213
  try {
1156
- await runProcess("powershell.exe", [
1214
+ await withCliSpinner(`Installing Tapi Service ${manifest.version}`, () => runProcess("powershell.exe", [
1157
1215
  "-NoProfile",
1158
1216
  "-ExecutionPolicy",
1159
1217
  "Bypass",
@@ -1167,7 +1225,7 @@ async function installService(options) {
1167
1225
  installLog.logPath,
1168
1226
  "-ResultPath",
1169
1227
  installLog.resultPath,
1170
- ]);
1228
+ ]));
1171
1229
  }
1172
1230
  catch (error) {
1173
1231
  throw new Error(await formatServiceInstallFailure(error, installLog), { cause: error });
@@ -1176,6 +1234,8 @@ async function installService(options) {
1176
1234
  releaseDir,
1177
1235
  version: manifest.version,
1178
1236
  });
1237
+ await pruneManagedFiles(downloadsDir, [zipPath], isTapiServiceDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Service download");
1238
+ await pruneManagedDirs(releasesDir, [releaseDir], isSafeManagedReleaseDirName, MAX_NONCURRENT_MANAGED_RELEASES, "old Tapi Service release");
1179
1239
  console.log("Tapi Service is installed and running.");
1180
1240
  return 0;
1181
1241
  }
@@ -1196,42 +1256,45 @@ async function publishLocalApis(args) {
1196
1256
  if (apis.length === 0) {
1197
1257
  throw new Error("No local generated APIs found under .tapi/apis.");
1198
1258
  }
1199
- let sitemapCount = 0;
1200
- for (const sitemap of sitemaps) {
1201
- const site = String(sitemap.site || "").trim();
1202
- if (!site || !isRecord(sitemap.siteMap)) {
1203
- continue;
1204
- }
1205
- await putJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/sitemaps/${encodeURIComponent(site)}?project=${encodeURIComponent(options.projectId)}`, { site_map: sitemap.siteMap }, options.apiKey, options.projectId);
1206
- sitemapCount += 1;
1207
- }
1208
- let contracts = 0;
1209
- let requests = 0;
1210
- for (const api of apis) {
1211
- const apiId = String(api.id || api.name || "").trim();
1212
- if (!apiId) {
1213
- continue;
1214
- }
1215
- const site = String(api.site || "").trim();
1216
- await putJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/contracts/${encodeURIComponent(apiId)}?project=${encodeURIComponent(options.projectId)}`, { generated_api: api }, options.apiKey, options.projectId);
1217
- contracts += 1;
1218
- const apiRequests = api.requests && typeof api.requests === "object" ? Object.values(api.requests) : [];
1219
- for (const request of apiRequests) {
1220
- if (!isRecord(request)) {
1259
+ const { sitemapCount, contracts, requests, releaseVersion } = await withCliSpinner("Publishing local Tapi APIs", async () => {
1260
+ let sitemapCount = 0;
1261
+ for (const sitemap of sitemaps) {
1262
+ const site = String(sitemap.site || "").trim();
1263
+ if (!site || !isRecord(sitemap.siteMap)) {
1221
1264
  continue;
1222
1265
  }
1223
- const requestId = String(request.id || request.key || "").trim();
1224
- const status = String(request.status || "").trim();
1225
- if (!requestId || !["ready", "published"].includes(status)) {
1266
+ await putJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/sitemaps/${encodeURIComponent(site)}?project=${encodeURIComponent(options.projectId)}`, { site_map: sitemap.siteMap }, options.apiKey, options.projectId);
1267
+ sitemapCount += 1;
1268
+ }
1269
+ let contracts = 0;
1270
+ let requests = 0;
1271
+ for (const api of apis) {
1272
+ const apiId = String(api.id || api.name || "").trim();
1273
+ if (!apiId) {
1226
1274
  continue;
1227
1275
  }
1228
- await postJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/contracts/${encodeURIComponent(apiId)}/requests/${encodeURIComponent(requestId)}/publish?project=${encodeURIComponent(options.projectId)}`, { site }, options.apiKey, options.projectId);
1229
- requests += 1;
1276
+ const site = String(api.site || "").trim();
1277
+ await putJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/contracts/${encodeURIComponent(apiId)}?project=${encodeURIComponent(options.projectId)}`, { generated_api: api }, options.apiKey, options.projectId);
1278
+ contracts += 1;
1279
+ const apiRequests = api.requests && typeof api.requests === "object" ? Object.values(api.requests) : [];
1280
+ for (const request of apiRequests) {
1281
+ if (!isRecord(request)) {
1282
+ continue;
1283
+ }
1284
+ const requestId = String(request.id || request.key || "").trim();
1285
+ const status = String(request.status || "").trim();
1286
+ if (!requestId || !["ready", "published"].includes(status)) {
1287
+ continue;
1288
+ }
1289
+ await postJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/contracts/${encodeURIComponent(apiId)}/requests/${encodeURIComponent(requestId)}/publish?project=${encodeURIComponent(options.projectId)}`, { site }, options.apiKey, options.projectId);
1290
+ requests += 1;
1291
+ }
1230
1292
  }
1231
- }
1232
- const releaseResponse = await postJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/releases?project=${encodeURIComponent(options.projectId)}`, { environment: "production" }, options.apiKey, options.projectId);
1233
- const release = isRecord(releaseResponse) && isRecord(releaseResponse.release) ? releaseResponse.release : {};
1234
- const releaseVersion = typeof release.version === "string" ? release.version : "unknown";
1293
+ const releaseResponse = await postJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/releases?project=${encodeURIComponent(options.projectId)}`, { environment: "production" }, options.apiKey, options.projectId);
1294
+ const release = isRecord(releaseResponse) && isRecord(releaseResponse.release) ? releaseResponse.release : {};
1295
+ const releaseVersion = typeof release.version === "string" ? release.version : "unknown";
1296
+ return { sitemapCount, contracts, requests, releaseVersion };
1297
+ });
1235
1298
  console.log(`Published ${requests} API request(s) from ${contracts} contract(s), synced ${sitemapCount} sitemap(s), activated release ${releaseVersion}.`);
1236
1299
  return 0;
1237
1300
  }
@@ -1325,6 +1388,10 @@ async function requestJson(method, url, body, apiKey, projectId) {
1325
1388
  }
1326
1389
  }
1327
1390
  export function getDefaultStudioCacheDir() {
1391
+ const homeOverride = envString("TAPI_STUDIO_HOME");
1392
+ if (homeOverride) {
1393
+ return join(resolve(homeOverride), "downloads");
1394
+ }
1328
1395
  if (process.platform === "win32") {
1329
1396
  const localAppData = process.env.LOCALAPPDATA ??
1330
1397
  (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
@@ -1646,31 +1713,12 @@ async function installStudio(options) {
1646
1713
  throw new Error("Direct Studio manifest overrides are no longer supported for install. "
1647
1714
  + "Use --channel with the protected install flow instead.");
1648
1715
  }
1649
- let installToken = options.installToken?.trim();
1650
- if (installToken) {
1651
- await event.phase("auth.install_token.provided", {
1652
- apiBaseUrl: options.apiBaseUrl,
1653
- channel: options.channel,
1654
- });
1655
- }
1656
- else {
1657
- console.log("Checking Studio install approval...");
1658
- await event.phase("auth.install_token.request.start", {
1659
- apiBaseUrl: options.apiBaseUrl,
1660
- channel: options.channel,
1661
- });
1662
- installToken = await obtainStudioInstallToken(options, event);
1663
- await event.phase("auth.install_token.request.success", {
1664
- apiBaseUrl: options.apiBaseUrl,
1665
- channel: options.channel,
1666
- });
1667
- }
1668
- console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
1716
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1669
1717
  await event.phase("manifest.fetch.start", {
1670
1718
  apiBaseUrl: options.apiBaseUrl,
1671
1719
  channel: options.channel,
1672
1720
  });
1673
- const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
1721
+ const manifest = await withCliSpinner(`Fetching Tapi Studio ${options.channel} manifest`, () => fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken));
1674
1722
  await event.phase("manifest.fetch.success", {
1675
1723
  manifest: manifestEventSummary(manifest),
1676
1724
  });
@@ -1702,13 +1750,12 @@ async function installStudio(options) {
1702
1750
  }
1703
1751
  else {
1704
1752
  await event.phase("cache.miss", { installerPath });
1705
- console.log(`Downloading Tapi Studio ${manifest.version}...`);
1706
1753
  await event.phase("download.start", {
1707
1754
  url: manifest.url,
1708
1755
  destination: installerPath,
1709
1756
  expectedSha256: manifest.sha256,
1710
1757
  });
1711
- await downloadAndVerify(manifest.url, installerPath, manifest.sha256);
1758
+ await withCliSpinner(`Downloading Tapi Studio ${manifest.version}`, () => downloadAndVerify(manifest.url, installerPath, manifest.sha256));
1712
1759
  await event.phase("download.verified", {
1713
1760
  installerPath,
1714
1761
  expectedSha256: manifest.sha256,
@@ -1723,12 +1770,11 @@ async function installStudio(options) {
1723
1770
  return 0;
1724
1771
  }
1725
1772
  const installerArgs = options.silent ? ["/S"] : [];
1726
- console.log(`Starting Tapi Studio installer: ${installerPath}`);
1727
1773
  await event.phase("installer.start", {
1728
1774
  installerPath,
1729
1775
  args: installerArgs,
1730
1776
  });
1731
- await runProcess(installerPath, installerArgs);
1777
+ await withCliSpinner("Starting Tapi Studio installer", () => runProcess(installerPath, installerArgs));
1732
1778
  console.log("Tapi Studio installer finished.");
1733
1779
  await event.finish(true, {
1734
1780
  installerPath,
@@ -1786,6 +1832,39 @@ async function obtainStudioInstallToken(options, event) {
1786
1832
  const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, firebaseCreds.idToken);
1787
1833
  return issued.installToken;
1788
1834
  }
1835
+ export function memoizeStudioInstallToken(options, installToken) {
1836
+ const existing = options.installToken?.trim();
1837
+ if (existing) {
1838
+ options.installToken = existing;
1839
+ return existing;
1840
+ }
1841
+ const normalized = installToken?.trim() ?? "";
1842
+ if (normalized) {
1843
+ options.installToken = normalized;
1844
+ }
1845
+ return normalized;
1846
+ }
1847
+ async function ensureStudioInstallTokenForRun(options, event, message) {
1848
+ const existing = memoizeStudioInstallToken(options);
1849
+ if (existing) {
1850
+ await event.phase("auth.install_token.reused", {
1851
+ apiBaseUrl: options.apiBaseUrl,
1852
+ channel: options.channel,
1853
+ });
1854
+ return existing;
1855
+ }
1856
+ await event.phase("auth.install_token.request.start", {
1857
+ apiBaseUrl: options.apiBaseUrl,
1858
+ channel: options.channel,
1859
+ });
1860
+ const installToken = memoizeStudioInstallToken(options, await withCliSpinner(message, () => obtainStudioInstallToken(options, event)));
1861
+ await event.phase("auth.install_token.request.success", {
1862
+ apiBaseUrl: options.apiBaseUrl,
1863
+ channel: options.channel,
1864
+ cachedForRun: true,
1865
+ });
1866
+ return installToken;
1867
+ }
1789
1868
  async function openStudio(options) {
1790
1869
  ensureWindowsHost();
1791
1870
  if (options.downloadOnly) {
@@ -1795,7 +1874,7 @@ async function openStudio(options) {
1795
1874
  if (!options.exePath) {
1796
1875
  const portable = await ensurePortableStudioServer(options);
1797
1876
  if (portable) {
1798
- await launchPortableStudioServer(portable.exePath, options);
1877
+ await launchPortableStudioServer(portable.exePath, options, portable.manifest);
1799
1878
  return 0;
1800
1879
  }
1801
1880
  }
@@ -1835,23 +1914,12 @@ async function ensurePortableStudioServer(options) {
1835
1914
  options: installEventOptions(options),
1836
1915
  });
1837
1916
  try {
1838
- let installToken = options.installToken?.trim();
1839
- if (!installToken) {
1840
- await event.phase("auth.install_token.request.start", {
1841
- apiBaseUrl: options.apiBaseUrl,
1842
- channel: options.channel,
1843
- });
1844
- installToken = await obtainStudioInstallToken(options, event);
1845
- await event.phase("auth.install_token.request.success", {
1846
- apiBaseUrl: options.apiBaseUrl,
1847
- channel: options.channel,
1848
- });
1849
- }
1917
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1850
1918
  await event.phase("manifest.fetch.start", {
1851
1919
  apiBaseUrl: options.apiBaseUrl,
1852
1920
  channel: options.channel,
1853
1921
  });
1854
- const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
1922
+ const manifest = await withCliSpinner(`Fetching Tapi Studio ${options.channel} manifest`, () => fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken));
1855
1923
  ensureCompatibleManifest(manifest);
1856
1924
  await event.phase("manifest.fetch.success", {
1857
1925
  manifest: manifestEventSummary(manifest),
@@ -1862,7 +1930,7 @@ async function ensurePortableStudioServer(options) {
1862
1930
  });
1863
1931
  return null;
1864
1932
  }
1865
- const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest), manifest);
1933
+ const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest, options.installDir), manifest);
1866
1934
  if (existing) {
1867
1935
  await event.finish(true, {
1868
1936
  serverExe: existing,
@@ -1893,13 +1961,12 @@ async function installPortableStudioServer(options, manifest, event) {
1893
1961
  await event.phase("cache.hit", { artifactPath });
1894
1962
  }
1895
1963
  else {
1896
- console.log(`Downloading Tapi Studio server ${manifest.version}...`);
1897
1964
  await event.phase("download.start", {
1898
1965
  url: manifest.url,
1899
1966
  destination: artifactPath,
1900
1967
  expectedSha256: manifest.sha256,
1901
1968
  });
1902
- await downloadAndVerify(manifest.url, artifactPath, manifest.sha256, "Tapi Studio server artifact");
1969
+ await withCliSpinner(`Downloading Tapi Studio server ${manifest.version}`, () => downloadAndVerify(manifest.url, artifactPath, manifest.sha256, "Tapi Studio server artifact"));
1903
1970
  await event.phase("download.verified", {
1904
1971
  artifactPath,
1905
1972
  expectedSha256: manifest.sha256,
@@ -1907,18 +1974,17 @@ async function installPortableStudioServer(options, manifest, event) {
1907
1974
  }
1908
1975
  if (options.downloadOnly) {
1909
1976
  console.log(`Downloaded Tapi Studio server artifact: ${artifactPath}`);
1977
+ await pruneManagedFiles(options.cacheDir, [artifactPath], isTapiStudioDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Studio download");
1910
1978
  return artifactPath;
1911
1979
  }
1912
- const releaseDir = portableStudioServerReleaseDir(manifest);
1913
- await extractZip(artifactPath, releaseDir);
1914
- const exePath = findPortableStudioServerExe(releaseDir, manifest);
1915
- if (!exePath) {
1916
- throw new Error(`Tapi Studio server executable was not found after extraction: ${releaseDir}`);
1917
- }
1980
+ const releaseDir = portableStudioServerReleaseDir(manifest, options.installDir);
1981
+ const exePath = await withCliSpinner(`Extracting Tapi Studio server ${manifest.version}`, () => extractPortableStudioServerZip(artifactPath, releaseDir, manifest));
1982
+ await pruneManagedFiles(options.cacheDir, [artifactPath], isTapiStudioDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Studio download");
1983
+ await pruneManagedDirs(dirname(releaseDir), [releaseDir], isSafeManagedReleaseDirName, MAX_NONCURRENT_MANAGED_RELEASES, "old Tapi Studio server release");
1918
1984
  console.log(`Installed Tapi Studio server ${manifest.version}: ${releaseDir}`);
1919
1985
  return exePath;
1920
1986
  }
1921
- async function launchPortableStudioServer(exePath, options) {
1987
+ async function launchPortableStudioServer(exePath, options, manifest) {
1922
1988
  const host = "127.0.0.1";
1923
1989
  const port = await chooseStudioPort();
1924
1990
  const env = buildStudioLaunchEnv(options);
@@ -1930,17 +1996,49 @@ async function launchPortableStudioServer(exePath, options) {
1930
1996
  env.TAPI_STUDIO_HOST = host;
1931
1997
  env.TAPI_STUDIO_PORT = String(port);
1932
1998
  env.TAPI_STUDIO_SERVER_PORT = String(port);
1933
- const child = spawn(exePath, [], {
1934
- detached: true,
1935
- env,
1936
- stdio: "ignore",
1937
- windowsHide: true,
1938
- });
1939
- child.unref();
1999
+ env.TAPI_STUDIO_SERVER_VERSION = manifest.version;
2000
+ env.TAPI_STUDIO_SERVER_COMMIT = manifest.commit || "";
2001
+ env.TAPI_STUDIO_SERVER_COMMIT_SHORT = manifest.commitShort || manifest.version;
2002
+ const playwrightBrowsersPath = findPortablePlaywrightBrowsersDir(exePath);
2003
+ if (playwrightBrowsersPath) {
2004
+ env.PLAYWRIGHT_BROWSERS_PATH = playwrightBrowsersPath;
2005
+ }
1940
2006
  const url = `http://${host}:${port}`;
1941
- await waitForHttpOk(`${url}/healthz`, PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS);
2007
+ await withCliSpinner("Starting Tapi Studio", async () => {
2008
+ const child = spawn(exePath, [], {
2009
+ detached: true,
2010
+ env,
2011
+ stdio: "ignore",
2012
+ windowsHide: true,
2013
+ });
2014
+ child.unref();
2015
+ await waitForHttpOk(`${url}/healthz`, PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS);
2016
+ });
1942
2017
  openBrowser(url);
1943
- console.log(`Opened Tapi Studio: ${url}`);
2018
+ console.log(`Opened Tapi Studio ${manifest.version}: ${url}`);
2019
+ }
2020
+ export function findPortablePlaywrightBrowsersDir(exePath) {
2021
+ const exeDir = dirname(exePath);
2022
+ const releaseDir = basename(exeDir).toLowerCase() === "tapi-studio-server"
2023
+ ? dirname(exeDir)
2024
+ : exeDir;
2025
+ const candidates = [
2026
+ join(releaseDir, "pw"),
2027
+ join(releaseDir, "playwright-browsers"),
2028
+ join(releaseDir, "sidecars", "pw"),
2029
+ join(exeDir, "pw"),
2030
+ join(exeDir, "playwright-browsers"),
2031
+ ];
2032
+ return candidates.find(isPlaywrightBrowsersDir);
2033
+ }
2034
+ function isPlaywrightBrowsersDir(path) {
2035
+ try {
2036
+ return existsSync(path)
2037
+ && readdirSync(path, { withFileTypes: true }).some((entry) => entry.isDirectory() && entry.name.startsWith("chromium_headless_shell-"));
2038
+ }
2039
+ catch {
2040
+ return false;
2041
+ }
1944
2042
  }
1945
2043
  export async function waitForHttpOk(url, timeoutMs = PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS, intervalMs = PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS, fetchImpl = fetch) {
1946
2044
  const startedAt = performance.now();
@@ -1998,7 +2096,7 @@ async function runDoctor(options) {
1998
2096
  const exePath = options.exePath ?? getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
1999
2097
  console.log(`Studio executable: ${exePath && existsSync(exePath) ? exePath : "not found"}`);
2000
2098
  try {
2001
- const manifest = await fetchStudioManifest(options.manifestUrl);
2099
+ const manifest = await withCliSpinner("Fetching latest Tapi Studio manifest", () => fetchStudioManifest(options.manifestUrl));
2002
2100
  ensureCompatibleManifest(manifest);
2003
2101
  console.log(`Latest Studio: ${manifest.version} (${manifest.platform})`);
2004
2102
  }
@@ -2396,10 +2494,14 @@ function isPortableStudioServerManifest(manifest) {
2396
2494
  const kind = String(manifest.installerKind || "").toLowerCase();
2397
2495
  return kind === "portable-server" || manifest.artifactName.toLowerCase().endsWith(".zip");
2398
2496
  }
2399
- function portableStudioServerReleaseDir(manifest) {
2400
- return join(getDefaultPortableStudioServerReleasesDir(), safePathSegment(manifest.version));
2497
+ function portableStudioServerReleaseDir(manifest, releasesDir = getDefaultPortableStudioServerReleasesDir()) {
2498
+ return join(releasesDir, safePathSegment(manifest.version));
2401
2499
  }
2402
2500
  function getDefaultPortableStudioServerReleasesDir() {
2501
+ const homeOverride = envString("TAPI_STUDIO_HOME");
2502
+ if (homeOverride) {
2503
+ return join(resolve(homeOverride), "server", "releases");
2504
+ }
2403
2505
  if (process.platform === "win32") {
2404
2506
  const localAppData = process.env.LOCALAPPDATA ??
2405
2507
  (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
@@ -2422,6 +2524,34 @@ export function findPortableStudioServerExe(releaseDir, manifest) {
2422
2524
  ];
2423
2525
  return candidates.find((candidate) => existsSync(candidate));
2424
2526
  }
2527
+ async function extractPortableStudioServerZip(zipPath, releaseDir, manifest) {
2528
+ const stagingDir = `${releaseDir}.extracting-${process.pid}-${Date.now()}`;
2529
+ const existingExe = findPortableStudioServerExe(releaseDir, manifest);
2530
+ if (!existingExe) {
2531
+ await rm(releaseDir, { recursive: true, force: true });
2532
+ }
2533
+ await rm(stagingDir, { recursive: true, force: true });
2534
+ try {
2535
+ await extractZip(zipPath, stagingDir);
2536
+ const stagedExe = findPortableStudioServerExe(stagingDir, manifest);
2537
+ if (!stagedExe) {
2538
+ throw new Error(`Tapi Studio server executable was not found after extraction. Expected ${manifest.serverExecutable || "tapi-studio-server.exe"} under ${stagingDir}.`);
2539
+ }
2540
+ await rm(releaseDir, { recursive: true, force: true });
2541
+ await mkdir(dirname(releaseDir), { recursive: true });
2542
+ await rename(stagingDir, releaseDir);
2543
+ await markManagedReleaseDir(releaseDir, manifest.version);
2544
+ const finalExe = findPortableStudioServerExe(releaseDir, manifest);
2545
+ if (!finalExe) {
2546
+ throw new Error(`Tapi Studio server executable disappeared while finalizing extraction: ${releaseDir}`);
2547
+ }
2548
+ return finalExe;
2549
+ }
2550
+ catch (error) {
2551
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
2552
+ throw error;
2553
+ }
2554
+ }
2425
2555
  function cachedServiceArtifactName(manifest) {
2426
2556
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiService-${manifest.version}.zip`;
2427
2557
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -2429,13 +2559,141 @@ function cachedServiceArtifactName(manifest) {
2429
2559
  async function extractZip(zipPath, destination) {
2430
2560
  await rm(destination, { recursive: true, force: true });
2431
2561
  await mkdir(destination, { recursive: true });
2432
- await runProcessCapture("powershell.exe", [
2433
- "-NoProfile",
2434
- "-ExecutionPolicy",
2435
- "Bypass",
2436
- "-Command",
2437
- `Expand-Archive -LiteralPath ${powerShellSingleQuoted(zipPath)} -DestinationPath ${powerShellSingleQuoted(destination)} -Force`,
2438
- ]);
2562
+ try {
2563
+ await runProcessCapture("powershell.exe", [
2564
+ "-NoProfile",
2565
+ "-ExecutionPolicy",
2566
+ "Bypass",
2567
+ "-Command",
2568
+ buildExpandArchiveCommand(zipPath, destination),
2569
+ ]);
2570
+ }
2571
+ catch (error) {
2572
+ throw new Error(formatZipExtractionFailure(zipPath, destination, error), { cause: error });
2573
+ }
2574
+ }
2575
+ export function buildExpandArchiveCommand(zipPath, destination) {
2576
+ return `$ErrorActionPreference = 'Stop'; try { Expand-Archive -LiteralPath ${powerShellSingleQuoted(zipPath)} -DestinationPath ${powerShellSingleQuoted(destination)} -Force -ErrorAction Stop } catch { Write-Error $_; exit 1 }`;
2577
+ }
2578
+ export function formatZipExtractionFailure(zipPath, destination, error) {
2579
+ const detail = formatError(error);
2580
+ const diskHint = /not enough space|no space left|disk full/i.test(detail)
2581
+ ? " Free disk space on that drive, or set TAPI_STUDIO_HOME / --cache-dir / --install-dir to paths on a drive with more room."
2582
+ : "";
2583
+ return `Failed to extract archive ${zipPath} to ${destination}: ${detail}${diskHint}`;
2584
+ }
2585
+ export async function pruneManagedFiles(directory, keepPaths, isManagedFileName, maxNoncurrentEntries = 0, label = "old Tapi download") {
2586
+ const keep = normalizedKeepPathSet(keepPaths);
2587
+ let entries;
2588
+ try {
2589
+ entries = await readdir(directory, { withFileTypes: true });
2590
+ }
2591
+ catch {
2592
+ return 0;
2593
+ }
2594
+ const candidates = [];
2595
+ for (const entry of entries) {
2596
+ if (!entry.isFile() || !isManagedFileName(entry.name)) {
2597
+ continue;
2598
+ }
2599
+ const candidate = join(directory, entry.name);
2600
+ if (keep.has(normalizeFilesystemPath(candidate))) {
2601
+ continue;
2602
+ }
2603
+ const info = await stat(candidate).catch(() => undefined);
2604
+ if (!info?.isFile()) {
2605
+ continue;
2606
+ }
2607
+ candidates.push({ path: candidate, mtimeMs: info.mtimeMs });
2608
+ }
2609
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
2610
+ const removable = candidates.slice(Math.max(0, maxNoncurrentEntries));
2611
+ let removed = 0;
2612
+ for (const candidate of removable) {
2613
+ try {
2614
+ await rm(candidate.path, { force: true });
2615
+ removed += 1;
2616
+ }
2617
+ catch {
2618
+ // Cache pruning is best effort; install success should not depend on cleanup.
2619
+ }
2620
+ }
2621
+ if (removed > 0) {
2622
+ console.log(`Pruned ${removed} ${label}${removed === 1 ? "" : "s"}.`);
2623
+ }
2624
+ return removed;
2625
+ }
2626
+ async function pruneManagedDirs(directory, keepPaths, isManagedDirName, maxNoncurrentEntries = 0, label = "old Tapi release") {
2627
+ const keep = normalizedKeepPathSet(keepPaths);
2628
+ let entries;
2629
+ try {
2630
+ entries = await readdir(directory, { withFileTypes: true });
2631
+ }
2632
+ catch {
2633
+ return 0;
2634
+ }
2635
+ const candidates = [];
2636
+ for (const entry of entries) {
2637
+ if (!entry.isDirectory() || !isManagedDirName(entry.name)) {
2638
+ continue;
2639
+ }
2640
+ const candidate = join(directory, entry.name);
2641
+ if (keep.has(normalizeFilesystemPath(candidate))) {
2642
+ continue;
2643
+ }
2644
+ if (!existsSync(join(candidate, MANAGED_RELEASE_MARKER))) {
2645
+ continue;
2646
+ }
2647
+ const info = await stat(candidate).catch(() => undefined);
2648
+ if (!info?.isDirectory()) {
2649
+ continue;
2650
+ }
2651
+ candidates.push({ path: candidate, mtimeMs: info.mtimeMs });
2652
+ }
2653
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
2654
+ const removable = candidates.slice(Math.max(0, maxNoncurrentEntries));
2655
+ let removed = 0;
2656
+ for (const candidate of removable) {
2657
+ try {
2658
+ await rm(candidate.path, { recursive: true, force: true });
2659
+ removed += 1;
2660
+ }
2661
+ catch {
2662
+ // Cache pruning is best effort; install success should not depend on cleanup.
2663
+ }
2664
+ }
2665
+ if (removed > 0) {
2666
+ console.log(`Pruned ${removed} ${label}${removed === 1 ? "" : "s"}.`);
2667
+ }
2668
+ return removed;
2669
+ }
2670
+ export function isTapiStudioDownloadArtifact(fileName) {
2671
+ const normalized = fileName.toLowerCase();
2672
+ return (normalized.endsWith(".zip")
2673
+ && (normalized.startsWith("tapi-studio")
2674
+ || normalized.startsWith("tapistudio")
2675
+ || normalized.includes("tapi_studio")));
2676
+ }
2677
+ export function isTapiServiceDownloadArtifact(fileName) {
2678
+ const normalized = fileName.toLowerCase();
2679
+ return normalized.endsWith(".zip") && (normalized.startsWith("tapiservice-") || normalized.startsWith("tapi-service"));
2680
+ }
2681
+ function isSafeManagedReleaseDirName(name) {
2682
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
2683
+ }
2684
+ async function markManagedReleaseDir(directory, version) {
2685
+ const payload = JSON.stringify({
2686
+ product: "tapi",
2687
+ version,
2688
+ managedBy: "@tapi-dev/sdk",
2689
+ });
2690
+ await writeFile(join(directory, MANAGED_RELEASE_MARKER), `${payload}\n`).catch(() => undefined);
2691
+ }
2692
+ function normalizedKeepPathSet(paths) {
2693
+ return new Set(paths.map((path) => normalizeFilesystemPath(path)));
2694
+ }
2695
+ function normalizeFilesystemPath(path) {
2696
+ return resolve(path).replace(/\\/g, "/").toLowerCase();
2439
2697
  }
2440
2698
  function installEventOptions(options) {
2441
2699
  return {
@@ -2444,6 +2702,7 @@ function installEventOptions(options) {
2444
2702
  manifestUrl: options.manifestUrl,
2445
2703
  manifestUrlOverride: options.manifestUrlOverride,
2446
2704
  cacheDir: options.cacheDir,
2705
+ installDir: options.installDir,
2447
2706
  downloadOnly: options.downloadOnly,
2448
2707
  silent: options.silent,
2449
2708
  exePath: options.exePath,
@@ -2657,7 +2916,7 @@ Usage:
2657
2916
 
2658
2917
  Options:
2659
2918
  --channel <name> Release channel: pilot, stable, or nightly
2660
- --api-base-url <url> Tapi API base URL for approval and protected downloads
2919
+ --api-base-url <url> Tapi API base URL for install authorization and protected downloads
2661
2920
  --server <url> Alias for --api-base-url
2662
2921
  --workspace <path> Search this directory for .tapi/project.json
2663
2922
  --no-workspace Do not load .tapi/project.json
@@ -2666,6 +2925,7 @@ Options:
2666
2925
  --install-token <tok> Preissued Studio install token (skips browser sign-in)
2667
2926
  --manifest <url> Exact release manifest URL for doctor only
2668
2927
  --cache-dir <path> Installer download cache directory
2928
+ --install-dir <path> Portable Studio server release directory
2669
2929
  --download-only Download and verify without running the installer
2670
2930
  --silent Run the NSIS installer with /S
2671
2931
  --exe <path> Tapi Studio executable path for open/doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.13",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",