@tapi-dev/sdk 0.1.15 → 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,7 +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;
105
107
  export declare function findPortablePlaywrightBrowsersDir(exePath: string): string | undefined;
106
108
  export declare function waitForHttpOk(url: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: FetchLike): Promise<void>;
107
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;
108
115
  export {};
package/dist/cli.js CHANGED
@@ -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;
@@ -282,6 +285,14 @@ export function parseStudioOptions(args) {
282
285
  raw.cacheDir = resolve(arg.slice("--cache-dir=".length));
283
286
  continue;
284
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
+ }
285
296
  if (arg === "--exe") {
286
297
  raw.exePath = resolve(requireOptionValue(args, ++index, "--exe"));
287
298
  continue;
@@ -324,6 +335,7 @@ export function parseStudioOptions(args) {
324
335
  manifestUrl,
325
336
  manifestUrlOverride: explicitManifestUrl ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL") : undefined,
326
337
  cacheDir: raw.cacheDir ?? getDefaultStudioCacheDir(),
338
+ installDir: raw.installDir ?? resolve(envString("TAPI_STUDIO_INSTALL_DIR") ?? getDefaultPortableStudioServerReleasesDir()),
327
339
  downloadOnly: raw.downloadOnly ?? false,
328
340
  silent: raw.silent ?? false,
329
341
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
@@ -1074,18 +1086,7 @@ async function fetchServiceManifestForEnsure(options) {
1074
1086
  options: installEventOptions(options),
1075
1087
  });
1076
1088
  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
- }
1089
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Tapi Service install authorization");
1089
1090
  await event.phase("service_manifest.fetch.start", {
1090
1091
  apiBaseUrl: options.apiBaseUrl,
1091
1092
  channel: options.channel,
@@ -1148,18 +1149,7 @@ async function installService(options) {
1148
1149
  arch: process.arch,
1149
1150
  });
1150
1151
  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
- }
1152
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Tapi Service install authorization");
1163
1153
  await event.phase("service_manifest.fetch.start", {
1164
1154
  apiBaseUrl: options.apiBaseUrl,
1165
1155
  channel: options.channel,
@@ -1202,6 +1192,7 @@ async function installService(options) {
1202
1192
  }
1203
1193
  const releaseDir = join(releasesDir, safePathSegment(manifest.version));
1204
1194
  await withCliSpinner(`Extracting Tapi Service ${manifest.version}`, () => extractZip(zipPath, releaseDir));
1195
+ await markManagedReleaseDir(releaseDir, manifest.version);
1205
1196
  const installer = join(releaseDir, manifest.installer || "install_tapi_service.ps1");
1206
1197
  const hostExe = join(releaseDir, manifest.serviceHostExecutable || "tapi-service-host.exe");
1207
1198
  if (!existsSync(installer)) {
@@ -1243,6 +1234,8 @@ async function installService(options) {
1243
1234
  releaseDir,
1244
1235
  version: manifest.version,
1245
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");
1246
1239
  console.log("Tapi Service is installed and running.");
1247
1240
  return 0;
1248
1241
  }
@@ -1395,6 +1388,10 @@ async function requestJson(method, url, body, apiKey, projectId) {
1395
1388
  }
1396
1389
  }
1397
1390
  export function getDefaultStudioCacheDir() {
1391
+ const homeOverride = envString("TAPI_STUDIO_HOME");
1392
+ if (homeOverride) {
1393
+ return join(resolve(homeOverride), "downloads");
1394
+ }
1398
1395
  if (process.platform === "win32") {
1399
1396
  const localAppData = process.env.LOCALAPPDATA ??
1400
1397
  (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
@@ -1716,24 +1713,7 @@ async function installStudio(options) {
1716
1713
  throw new Error("Direct Studio manifest overrides are no longer supported for install. "
1717
1714
  + "Use --channel with the protected install flow instead.");
1718
1715
  }
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
- }
1716
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1737
1717
  await event.phase("manifest.fetch.start", {
1738
1718
  apiBaseUrl: options.apiBaseUrl,
1739
1719
  channel: options.channel,
@@ -1852,6 +1832,39 @@ async function obtainStudioInstallToken(options, event) {
1852
1832
  const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, firebaseCreds.idToken);
1853
1833
  return issued.installToken;
1854
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
+ }
1855
1868
  async function openStudio(options) {
1856
1869
  ensureWindowsHost();
1857
1870
  if (options.downloadOnly) {
@@ -1901,18 +1914,7 @@ async function ensurePortableStudioServer(options) {
1901
1914
  options: installEventOptions(options),
1902
1915
  });
1903
1916
  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
- }
1917
+ const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1916
1918
  await event.phase("manifest.fetch.start", {
1917
1919
  apiBaseUrl: options.apiBaseUrl,
1918
1920
  channel: options.channel,
@@ -1928,7 +1930,7 @@ async function ensurePortableStudioServer(options) {
1928
1930
  });
1929
1931
  return null;
1930
1932
  }
1931
- const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest), manifest);
1933
+ const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest, options.installDir), manifest);
1932
1934
  if (existing) {
1933
1935
  await event.finish(true, {
1934
1936
  serverExe: existing,
@@ -1972,14 +1974,13 @@ async function installPortableStudioServer(options, manifest, event) {
1972
1974
  }
1973
1975
  if (options.downloadOnly) {
1974
1976
  console.log(`Downloaded Tapi Studio server artifact: ${artifactPath}`);
1977
+ await pruneManagedFiles(options.cacheDir, [artifactPath], isTapiStudioDownloadArtifact, MAX_NONCURRENT_MANAGED_DOWNLOADS, "old Tapi Studio download");
1975
1978
  return artifactPath;
1976
1979
  }
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
- }
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");
1983
1984
  console.log(`Installed Tapi Studio server ${manifest.version}: ${releaseDir}`);
1984
1985
  return exePath;
1985
1986
  }
@@ -2493,10 +2494,14 @@ function isPortableStudioServerManifest(manifest) {
2493
2494
  const kind = String(manifest.installerKind || "").toLowerCase();
2494
2495
  return kind === "portable-server" || manifest.artifactName.toLowerCase().endsWith(".zip");
2495
2496
  }
2496
- function portableStudioServerReleaseDir(manifest) {
2497
- return join(getDefaultPortableStudioServerReleasesDir(), safePathSegment(manifest.version));
2497
+ function portableStudioServerReleaseDir(manifest, releasesDir = getDefaultPortableStudioServerReleasesDir()) {
2498
+ return join(releasesDir, safePathSegment(manifest.version));
2498
2499
  }
2499
2500
  function getDefaultPortableStudioServerReleasesDir() {
2501
+ const homeOverride = envString("TAPI_STUDIO_HOME");
2502
+ if (homeOverride) {
2503
+ return join(resolve(homeOverride), "server", "releases");
2504
+ }
2500
2505
  if (process.platform === "win32") {
2501
2506
  const localAppData = process.env.LOCALAPPDATA ??
2502
2507
  (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
@@ -2519,6 +2524,34 @@ export function findPortableStudioServerExe(releaseDir, manifest) {
2519
2524
  ];
2520
2525
  return candidates.find((candidate) => existsSync(candidate));
2521
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
+ }
2522
2555
  function cachedServiceArtifactName(manifest) {
2523
2556
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiService-${manifest.version}.zip`;
2524
2557
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -2526,13 +2559,141 @@ function cachedServiceArtifactName(manifest) {
2526
2559
  async function extractZip(zipPath, destination) {
2527
2560
  await rm(destination, { recursive: true, force: true });
2528
2561
  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
- ]);
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();
2536
2697
  }
2537
2698
  function installEventOptions(options) {
2538
2699
  return {
@@ -2541,6 +2702,7 @@ function installEventOptions(options) {
2541
2702
  manifestUrl: options.manifestUrl,
2542
2703
  manifestUrlOverride: options.manifestUrlOverride,
2543
2704
  cacheDir: options.cacheDir,
2705
+ installDir: options.installDir,
2544
2706
  downloadOnly: options.downloadOnly,
2545
2707
  silent: options.silent,
2546
2708
  exePath: options.exePath,
@@ -2754,7 +2916,7 @@ Usage:
2754
2916
 
2755
2917
  Options:
2756
2918
  --channel <name> Release channel: pilot, stable, or nightly
2757
- --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
2758
2920
  --server <url> Alias for --api-base-url
2759
2921
  --workspace <path> Search this directory for .tapi/project.json
2760
2922
  --no-workspace Do not load .tapi/project.json
@@ -2763,6 +2925,7 @@ Options:
2763
2925
  --install-token <tok> Preissued Studio install token (skips browser sign-in)
2764
2926
  --manifest <url> Exact release manifest URL for doctor only
2765
2927
  --cache-dir <path> Installer download cache directory
2928
+ --install-dir <path> Portable Studio server release directory
2766
2929
  --download-only Download and verify without running the installer
2767
2930
  --silent Run the NSIS installer with /S
2768
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.15",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",