@tapi-dev/sdk 0.1.28 → 0.1.30

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.d.ts CHANGED
@@ -114,10 +114,9 @@ export declare function validateStudioManifest(input: unknown): StudioReleaseMan
114
114
  export declare function validateServiceReleaseManifest(input: unknown): ServiceReleaseManifest;
115
115
  export declare function compareVersions(left: string, right: string): number;
116
116
  export declare function memoizeStudioInstallToken(options: StudioCliOptions, installToken?: string): string;
117
- export declare function tryOpenExistingPortableStudioServer(options: StudioCliOptions, manifest: StudioReleaseManifest, fetchImpl?: FetchLike, openBrowserImpl?: (url: string) => void): Promise<{
118
- opened: boolean;
119
- baseUrl?: string;
120
- reason?: string;
117
+ export declare function closeExistingPortableStudioServersForFreshLaunch(stopProcesses?: () => Promise<number>): Promise<{
118
+ stopped: number;
119
+ recordsRemoved: number;
121
120
  }>;
122
121
  export declare function findPortablePlaywrightBrowsersDir(exePath: string): string | undefined;
123
122
  export declare function waitForHttpOk(url: string, timeoutMs?: number, intervalMs?: number, fetchImpl?: FetchLike): Promise<void>;
@@ -126,7 +125,9 @@ export declare function chooseStudioPort(preferred?: number, options?: {
126
125
  portAvailable?: StudioPortAvailable;
127
126
  reapBlockedPorts?: StudioPortReaper;
128
127
  }): Promise<number>;
129
- export declare function downloadAndVerify(url: string, destination: string, expectedSha256: string, label?: string): Promise<void>;
128
+ export declare function downloadAndVerify(url: string, destination: string, expectedSha256: string, label?: string, options?: {
129
+ stallTimeoutMs?: number;
130
+ }): Promise<void>;
130
131
  export declare function studioInstanceRecordPathForOptions(options: StudioCliOptions): string;
131
132
  export declare function findPortableStudioServerExe(releaseDir: string, manifest: StudioReleaseManifest): string | undefined;
132
133
  export declare function estimateRequiredExtractionBytes(zipBytes: number): number;
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { homedir } from "node:os";
9
9
  import { basename, dirname, join, resolve } from "node:path";
10
10
  import { performance } from "node:perf_hooks";
11
11
  import { emitKeypressEvents } from "node:readline";
12
- import { Readable } from "node:stream";
12
+ import { Readable, Transform } from "node:stream";
13
13
  import { pipeline } from "node:stream/promises";
14
14
  import { fileURLToPath, pathToFileURL } from "node:url";
15
15
  import { TapiClient } from "./index.js";
@@ -23,6 +23,8 @@ const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
23
23
  const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
24
24
  const PORTABLE_STUDIO_SERVER_READY_TIMEOUT_MS = 30_000;
25
25
  const PORTABLE_STUDIO_SERVER_READY_INTERVAL_MS = 250;
26
+ const DOWNLOAD_STALL_TIMEOUT_MS = 30_000;
27
+ const DOWNLOAD_PROGRESS_LOG_BYTES = 50 * 1024 * 1024;
26
28
  const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
27
29
  const MAX_NONCURRENT_MANAGED_DOWNLOADS = 0;
28
30
  const MAX_NONCURRENT_MANAGED_RELEASES = 0;
@@ -2123,13 +2125,10 @@ async function openStudio(options) {
2123
2125
  throw new Error("--download-only is only supported with `tapi studio install`.");
2124
2126
  }
2125
2127
  await ensureServiceReadyForStudio(options);
2128
+ await closeExistingPortableStudioServersForFreshLaunch();
2126
2129
  if (!options.exePath) {
2127
2130
  const portable = await ensurePortableStudioServer(options);
2128
2131
  if (portable) {
2129
- const existing = await tryOpenExistingPortableStudioServer(options, portable.manifest);
2130
- if (existing.opened) {
2131
- return 0;
2132
- }
2133
2132
  await launchPortableStudioServer(portable.exePath, options, portable.manifest);
2134
2133
  return 0;
2135
2134
  }
@@ -2164,53 +2163,16 @@ function findStudioExecutable(options) {
2164
2163
  }
2165
2164
  return getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
2166
2165
  }
2167
- export async function tryOpenExistingPortableStudioServer(options, manifest, fetchImpl = fetch, openBrowserImpl = openBrowser) {
2168
- const record = await readStudioInstanceRecord(options);
2169
- if (!record) {
2170
- return { opened: false, reason: "instance_record_missing" };
2171
- }
2172
- if ((record.manifestVersion || "") !== manifest.version) {
2173
- return { opened: false, reason: "manifest_version_mismatch" };
2174
- }
2175
- try {
2176
- const identityResponse = await fetchWithTimeout(`${record.baseUrl.replace(/\/+$/, "")}/api/studio/control/identity`, { headers: { Accept: "application/json" } }, 1_000, fetchImpl);
2177
- if (!identityResponse.ok) {
2178
- await unlinkIfExists(studioInstanceRecordPathForOptions(options));
2179
- return { opened: false, reason: `identity_http_${identityResponse.status}` };
2180
- }
2181
- const identity = await readJsonBody(identityResponse);
2182
- if (!studioInstanceIdentityMatches(identity, options)) {
2183
- return { opened: false, reason: "identity_mismatch" };
2184
- }
2185
- const launchUrl = studioLaunchUrl(record.baseUrl, options);
2186
- await requestExistingStudioRuntimeCleanup(record.baseUrl, fetchImpl);
2187
- openBrowserImpl(launchUrl);
2188
- console.log(`Opened existing Tapi Studio ${record.manifestVersion || manifest.version}: ${launchUrl}`);
2189
- return { opened: true, baseUrl: record.baseUrl };
2190
- }
2191
- catch (error) {
2192
- await unlinkIfExists(studioInstanceRecordPathForOptions(options));
2193
- return { opened: false, reason: formatError(error) };
2194
- }
2195
- }
2196
- async function requestExistingStudioRuntimeCleanup(baseUrl, fetchImpl = fetch) {
2197
- const url = `${baseUrl.replace(/\/+$/, "")}/api/studio/control/cleanup-runtime-session`;
2198
- try {
2199
- const response = await fetchWithTimeout(url, {
2200
- method: "POST",
2201
- headers: {
2202
- Accept: "application/json",
2203
- "Content-Type": "application/json",
2204
- },
2205
- body: JSON.stringify({ reason: "tapi_studio_reopen" }),
2206
- }, 5_000, fetchImpl);
2207
- if (!response.ok && response.status !== 404) {
2208
- console.warn(`Tapi Studio runtime cleanup returned HTTP ${response.status}; opening Studio anyway.`);
2209
- }
2166
+ export async function closeExistingPortableStudioServersForFreshLaunch(stopProcesses = stopPortableStudioServerProcesses) {
2167
+ const stopped = await stopProcesses();
2168
+ const recordsRemoved = await clearStudioInstanceRecords();
2169
+ if (stopped > 0) {
2170
+ await delay(500);
2210
2171
  }
2211
- catch (error) {
2212
- console.warn(`Tapi Studio runtime cleanup failed: ${formatError(error)}. Opening Studio anyway.`);
2172
+ if (stopped > 0 || recordsRemoved > 0) {
2173
+ console.log(`Closed existing Tapi Studio servers before fresh launch: stopped=${stopped}, records=${recordsRemoved}`);
2213
2174
  }
2175
+ return { stopped, recordsRemoved };
2214
2176
  }
2215
2177
  async function ensurePortableStudioServer(options) {
2216
2178
  const event = await createCliWideEvent("tapi_cli.studio_server_ensure", {
@@ -2490,6 +2452,53 @@ foreach ($pidValue in $owners.Keys) {
2490
2452
  return 0;
2491
2453
  }
2492
2454
  }
2455
+ async function stopPortableStudioServerProcesses() {
2456
+ if (process.platform !== "win32") {
2457
+ return 0;
2458
+ }
2459
+ const script = `
2460
+ $ErrorActionPreference = 'SilentlyContinue'
2461
+ $currentPid = $PID
2462
+ $processes = @(Get-CimInstance Win32_Process | Where-Object {
2463
+ $pidValue = [int]$_.ProcessId
2464
+ if ($pidValue -le 0 -or $pidValue -eq $currentPid) {
2465
+ $false
2466
+ } else {
2467
+ $path = [string]$_.ExecutablePath
2468
+ $cmd = [string]$_.CommandLine
2469
+ $name = [string]$_.Name
2470
+ (
2471
+ $name -ieq 'tapi-studio-server.exe' -or
2472
+ $path -match '\\\\Tapi\\\\Studio\\\\server\\\\releases\\\\' -or
2473
+ $cmd -match 'tapi-studio-server' -or
2474
+ $cmd -match 'scripts[\\\\/]studio\\.py'
2475
+ )
2476
+ }
2477
+ })
2478
+ $stopped = 0
2479
+ foreach ($process in $processes) {
2480
+ try {
2481
+ Stop-Process -Id ([int]$process.ProcessId) -Force -ErrorAction Stop
2482
+ $stopped += 1
2483
+ } catch {}
2484
+ }
2485
+ [pscustomobject]@{ stopped = $stopped } | ConvertTo-Json -Compress
2486
+ `;
2487
+ try {
2488
+ const output = await runProcessCapture("powershell.exe", [
2489
+ "-NoProfile",
2490
+ "-ExecutionPolicy",
2491
+ "Bypass",
2492
+ "-Command",
2493
+ script,
2494
+ ]);
2495
+ const payload = JSON.parse(output.trim() || "{}");
2496
+ return typeof payload.stopped === "number" ? payload.stopped : 0;
2497
+ }
2498
+ catch {
2499
+ return 0;
2500
+ }
2501
+ }
2493
2502
  async function runDoctor(options) {
2494
2503
  console.log(`Tapi SDK: ${sdkVersion}`);
2495
2504
  console.log(`Node: ${process.version}`);
@@ -2610,11 +2619,11 @@ async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, f
2610
2619
  expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : undefined,
2611
2620
  };
2612
2621
  }
2613
- export async function downloadAndVerify(url, destination, expectedSha256, label = "Studio installer") {
2622
+ export async function downloadAndVerify(url, destination, expectedSha256, label = "Studio installer", options = {}) {
2614
2623
  const partialPath = `${destination}.partial`;
2615
2624
  await unlinkIfExists(partialPath);
2616
2625
  try {
2617
- await downloadFile(url, partialPath, label);
2626
+ await downloadFile(url, partialPath, label, options);
2618
2627
  const actualSha256 = await sha256File(partialPath);
2619
2628
  if (actualSha256 !== expectedSha256.toLowerCase()) {
2620
2629
  throw new Error(`${label} checksum mismatch. Expected ${expectedSha256}, got ${actualSha256}.`);
@@ -2627,15 +2636,97 @@ export async function downloadAndVerify(url, destination, expectedSha256, label
2627
2636
  throw error;
2628
2637
  }
2629
2638
  }
2630
- async function downloadFile(url, destination, label = "download") {
2631
- const response = await fetch(url);
2632
- if (!response.ok) {
2633
- throw new Error(`Failed to download ${label}: HTTP ${response.status}`);
2639
+ async function downloadFile(url, destination, label = "download", options = {}) {
2640
+ const stallTimeoutMs = normalizeDownloadTimeout(options.stallTimeoutMs);
2641
+ const controller = new AbortController();
2642
+ let stallTimer;
2643
+ let stallError;
2644
+ let source;
2645
+ let progressStream;
2646
+ let destinationStream;
2647
+ let receivedBytes = 0;
2648
+ let nextProgressLogBytes = DOWNLOAD_PROGRESS_LOG_BYTES;
2649
+ const failIfStalled = (phase) => {
2650
+ if (stallTimer) {
2651
+ clearTimeout(stallTimer);
2652
+ }
2653
+ stallTimer = setTimeout(() => {
2654
+ stallError = formatDownloadStallError(label, stallTimeoutMs, phase, receivedBytes);
2655
+ controller.abort(stallError);
2656
+ source?.destroy(stallError);
2657
+ progressStream?.destroy(stallError);
2658
+ destinationStream?.destroy(stallError);
2659
+ }, stallTimeoutMs);
2660
+ };
2661
+ try {
2662
+ console.log(`Downloading ${label}...`);
2663
+ failIfStalled("connecting");
2664
+ const response = await fetch(url, { signal: controller.signal });
2665
+ if (!response.ok) {
2666
+ throw new Error(`Failed to download ${label}: HTTP ${response.status}`);
2667
+ }
2668
+ if (!response.body) {
2669
+ throw new Error(`${label} response did not include a body.`);
2670
+ }
2671
+ failIfStalled("receiving data");
2672
+ source = Readable.fromWeb(response.body);
2673
+ progressStream = new Transform({
2674
+ transform(chunk, _encoding, callback) {
2675
+ receivedBytes += downloadChunkByteLength(chunk);
2676
+ failIfStalled("receiving data");
2677
+ if (receivedBytes >= nextProgressLogBytes) {
2678
+ console.log(`Downloaded ${label}: ${formatBytes(receivedBytes)}...`);
2679
+ while (receivedBytes >= nextProgressLogBytes) {
2680
+ nextProgressLogBytes += DOWNLOAD_PROGRESS_LOG_BYTES;
2681
+ }
2682
+ }
2683
+ callback(null, chunk);
2684
+ },
2685
+ });
2686
+ destinationStream = createWriteStream(destination);
2687
+ await pipeline(source, progressStream, destinationStream);
2688
+ console.log(`Downloaded ${label}: ${formatBytes(receivedBytes)}.`);
2634
2689
  }
2635
- if (!response.body) {
2636
- throw new Error(`${label} response did not include a body.`);
2690
+ catch (error) {
2691
+ if (stallError) {
2692
+ throw stallError;
2693
+ }
2694
+ throw error;
2695
+ }
2696
+ finally {
2697
+ if (stallTimer) {
2698
+ clearTimeout(stallTimer);
2699
+ }
2637
2700
  }
2638
- await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
2701
+ }
2702
+ function downloadChunkByteLength(chunk) {
2703
+ if (typeof chunk === "string") {
2704
+ return Buffer.byteLength(chunk);
2705
+ }
2706
+ if (Buffer.isBuffer(chunk)) {
2707
+ return chunk.length;
2708
+ }
2709
+ if (chunk instanceof Uint8Array) {
2710
+ return chunk.byteLength;
2711
+ }
2712
+ return 0;
2713
+ }
2714
+ function normalizeDownloadTimeout(timeoutMs) {
2715
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
2716
+ return DOWNLOAD_STALL_TIMEOUT_MS;
2717
+ }
2718
+ return Math.max(1, Math.floor(timeoutMs));
2719
+ }
2720
+ function formatDownloadStallError(label, timeoutMs, phase, receivedBytes) {
2721
+ const progress = receivedBytes > 0 ? ` after ${formatBytes(receivedBytes)}` : "";
2722
+ return new Error(`${label} download stalled${progress}: no data received for ${formatDuration(timeoutMs)} while ${phase}. ` +
2723
+ "Check your network connection, VPN, or install authorization and retry.");
2724
+ }
2725
+ function formatDuration(ms) {
2726
+ if (ms < 1000) {
2727
+ return `${ms}ms`;
2728
+ }
2729
+ return `${Math.ceil(ms / 1000)}s`;
2639
2730
  }
2640
2731
  async function browserGoogleSignIn(timeoutMs = DEFAULT_INSTALL_AUTH_TIMEOUT_MS) {
2641
2732
  ensureWindowsHost();
@@ -2795,7 +2886,39 @@ export function studioInstanceRecordPathForOptions(options) {
2795
2886
  }))
2796
2887
  .digest("hex")
2797
2888
  .slice(0, 24);
2798
- return join(getDefaultTapiDataDir(), "Studio", "instances", `${key}.json`);
2889
+ return join(studioInstanceRecordsDir(), `${key}.json`);
2890
+ }
2891
+ function studioInstanceRecordsDir() {
2892
+ return join(getDefaultTapiDataDir(), "Studio", "instances");
2893
+ }
2894
+ async function clearStudioInstanceRecords() {
2895
+ let entries;
2896
+ try {
2897
+ entries = await readdir(studioInstanceRecordsDir(), { withFileTypes: true });
2898
+ }
2899
+ catch (error) {
2900
+ if (error.code === "ENOENT") {
2901
+ return 0;
2902
+ }
2903
+ throw error;
2904
+ }
2905
+ let removed = 0;
2906
+ for (const entry of entries) {
2907
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".json")) {
2908
+ continue;
2909
+ }
2910
+ const path = join(studioInstanceRecordsDir(), entry.name);
2911
+ try {
2912
+ await unlink(path);
2913
+ removed += 1;
2914
+ }
2915
+ catch (error) {
2916
+ if (error.code !== "ENOENT") {
2917
+ throw error;
2918
+ }
2919
+ }
2920
+ }
2921
+ return removed;
2799
2922
  }
2800
2923
  async function writeStudioInstanceRecord(options, record) {
2801
2924
  await writeJsonFile(studioInstanceRecordPathForOptions(options), record);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",