@tapi-dev/sdk 0.1.29 → 0.1.31

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
@@ -60,6 +60,7 @@ export interface StudioCliOptions {
60
60
  silent: boolean;
61
61
  exePath?: string;
62
62
  installToken?: string;
63
+ installTokenSource?: "option" | "env" | "minted";
63
64
  workspace?: TapiWorkspace;
64
65
  workspaceRoot?: string;
65
66
  projectId?: string;
@@ -113,7 +114,7 @@ export declare function getStudioExecutableCandidates(): string[];
113
114
  export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
114
115
  export declare function validateServiceReleaseManifest(input: unknown): ServiceReleaseManifest;
115
116
  export declare function compareVersions(left: string, right: string): number;
116
- export declare function memoizeStudioInstallToken(options: StudioCliOptions, installToken?: string): string;
117
+ export declare function memoizeStudioInstallToken(options: StudioCliOptions, installToken?: string, source?: StudioCliOptions["installTokenSource"]): string;
117
118
  export declare function closeExistingPortableStudioServersForFreshLaunch(stopProcesses?: () => Promise<number>): Promise<{
118
119
  stopped: number;
119
120
  recordsRemoved: number;
@@ -125,7 +126,9 @@ export declare function chooseStudioPort(preferred?: number, options?: {
125
126
  portAvailable?: StudioPortAvailable;
126
127
  reapBlockedPorts?: StudioPortReaper;
127
128
  }): Promise<number>;
128
- export declare function downloadAndVerify(url: string, destination: string, expectedSha256: string, label?: string): Promise<void>;
129
+ export declare function downloadAndVerify(url: string, destination: string, expectedSha256: string, label?: string, options?: {
130
+ stallTimeoutMs?: number;
131
+ }): Promise<void>;
129
132
  export declare function studioInstanceRecordPathForOptions(options: StudioCliOptions): string;
130
133
  export declare function findPortableStudioServerExe(releaseDir: string, manifest: StudioReleaseManifest): string | undefined;
131
134
  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;
@@ -342,6 +344,8 @@ export function parseStudioOptions(args) {
342
344
  : `${downloadsBaseUrl.replace(/\/+$/, "")}/studio/channels/${channel}/latest.json`;
343
345
  const projectId = raw.projectId ?? envString("TAPI_PROJECT_ID") ?? workspace?.projectId;
344
346
  const projectSlug = raw.projectSlug ?? envString("TAPI_PROJECT_SLUG") ?? workspace?.projectSlug;
347
+ const envInstallToken = envString("TAPI_STUDIO_INSTALL_TOKEN");
348
+ const installToken = raw.installToken ?? envInstallToken;
345
349
  return {
346
350
  channel,
347
351
  apiBaseUrl,
@@ -352,7 +356,8 @@ export function parseStudioOptions(args) {
352
356
  downloadOnly: raw.downloadOnly ?? false,
353
357
  silent: raw.silent ?? false,
354
358
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
355
- installToken: raw.installToken ?? envString("TAPI_STUDIO_INSTALL_TOKEN"),
359
+ installToken,
360
+ installTokenSource: raw.installToken ? "option" : installToken ? "env" : undefined,
356
361
  workspace,
357
362
  workspaceRoot: workspace?.root,
358
363
  projectId,
@@ -1965,12 +1970,7 @@ async function installStudio(options) {
1965
1970
  throw new Error("Direct Studio manifest overrides are no longer supported for install. "
1966
1971
  + "Use --channel with the protected install flow instead.");
1967
1972
  }
1968
- const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
1969
- await event.phase("manifest.fetch.start", {
1970
- apiBaseUrl: options.apiBaseUrl,
1971
- channel: options.channel,
1972
- });
1973
- const manifest = await withCliSpinner(`Fetching Tapi Studio ${options.channel} manifest`, () => fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken));
1973
+ const manifest = await fetchStudioManifestForRun(options, event, "Checking Studio install authorization");
1974
1974
  await event.phase("manifest.fetch.success", {
1975
1975
  manifest: manifestEventSummary(manifest),
1976
1976
  });
@@ -2084,7 +2084,7 @@ async function obtainStudioInstallToken(options, event) {
2084
2084
  const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, firebaseCreds.idToken);
2085
2085
  return issued.installToken;
2086
2086
  }
2087
- export function memoizeStudioInstallToken(options, installToken) {
2087
+ export function memoizeStudioInstallToken(options, installToken, source = "minted") {
2088
2088
  const existing = options.installToken?.trim();
2089
2089
  if (existing) {
2090
2090
  options.installToken = existing;
@@ -2093,9 +2093,14 @@ export function memoizeStudioInstallToken(options, installToken) {
2093
2093
  const normalized = installToken?.trim() ?? "";
2094
2094
  if (normalized) {
2095
2095
  options.installToken = normalized;
2096
+ options.installTokenSource = source;
2096
2097
  }
2097
2098
  return normalized;
2098
2099
  }
2100
+ function clearStudioInstallTokenForRun(options) {
2101
+ options.installToken = undefined;
2102
+ options.installTokenSource = undefined;
2103
+ }
2099
2104
  async function ensureStudioInstallTokenForRun(options, event, message) {
2100
2105
  const existing = memoizeStudioInstallToken(options);
2101
2106
  if (existing) {
@@ -2109,7 +2114,7 @@ async function ensureStudioInstallTokenForRun(options, event, message) {
2109
2114
  apiBaseUrl: options.apiBaseUrl,
2110
2115
  channel: options.channel,
2111
2116
  });
2112
- const installToken = memoizeStudioInstallToken(options, await withCliSpinner(message, () => obtainStudioInstallToken(options, event)));
2117
+ const installToken = memoizeStudioInstallToken(options, await withCliSpinner(message, () => obtainStudioInstallToken(options, event)), "minted");
2113
2118
  await event.phase("auth.install_token.request.success", {
2114
2119
  apiBaseUrl: options.apiBaseUrl,
2115
2120
  channel: options.channel,
@@ -2117,6 +2122,39 @@ async function ensureStudioInstallTokenForRun(options, event, message) {
2117
2122
  });
2118
2123
  return installToken;
2119
2124
  }
2125
+ async function fetchStudioManifestForRun(options, event, authMessage) {
2126
+ const installToken = await ensureStudioInstallTokenForRun(options, event, authMessage);
2127
+ await event.phase("manifest.fetch.start", {
2128
+ apiBaseUrl: options.apiBaseUrl,
2129
+ channel: options.channel,
2130
+ });
2131
+ try {
2132
+ return await withCliSpinner(`Fetching Tapi Studio ${options.channel} manifest`, () => fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken));
2133
+ }
2134
+ catch (error) {
2135
+ if (!isExpiredStudioInstallTokenError(error) || options.installTokenSource === "option") {
2136
+ throw error;
2137
+ }
2138
+ await event.phase("auth.install_token.expired", {
2139
+ apiBaseUrl: options.apiBaseUrl,
2140
+ channel: options.channel,
2141
+ tokenSource: options.installTokenSource ?? "unknown",
2142
+ error: errorDetails(error),
2143
+ });
2144
+ clearStudioInstallTokenForRun(options);
2145
+ const refreshedToken = await ensureStudioInstallTokenForRun(options, event, "Refreshing Studio install authorization");
2146
+ await event.phase("manifest.fetch.retry.start", {
2147
+ apiBaseUrl: options.apiBaseUrl,
2148
+ channel: options.channel,
2149
+ });
2150
+ return await withCliSpinner(`Retrying Tapi Studio ${options.channel} manifest`, () => fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, refreshedToken));
2151
+ }
2152
+ }
2153
+ function isExpiredStudioInstallTokenError(error) {
2154
+ const message = formatError(error);
2155
+ return (message.includes("Failed to fetch protected Studio manifest: HTTP 401")
2156
+ && message.includes("Studio install token has expired"));
2157
+ }
2120
2158
  async function openStudio(options) {
2121
2159
  ensureWindowsHost();
2122
2160
  if (options.downloadOnly) {
@@ -2178,12 +2216,7 @@ async function ensurePortableStudioServer(options) {
2178
2216
  options: installEventOptions(options),
2179
2217
  });
2180
2218
  try {
2181
- const installToken = await ensureStudioInstallTokenForRun(options, event, "Checking Studio install authorization");
2182
- await event.phase("manifest.fetch.start", {
2183
- apiBaseUrl: options.apiBaseUrl,
2184
- channel: options.channel,
2185
- });
2186
- const manifest = await withCliSpinner(`Fetching Tapi Studio ${options.channel} manifest`, () => fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken));
2219
+ const manifest = await fetchStudioManifestForRun(options, event, "Checking Studio install authorization");
2187
2220
  ensureCompatibleManifest(manifest);
2188
2221
  await event.phase("manifest.fetch.success", {
2189
2222
  manifest: manifestEventSummary(manifest),
@@ -2617,11 +2650,11 @@ async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, f
2617
2650
  expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : undefined,
2618
2651
  };
2619
2652
  }
2620
- export async function downloadAndVerify(url, destination, expectedSha256, label = "Studio installer") {
2653
+ export async function downloadAndVerify(url, destination, expectedSha256, label = "Studio installer", options = {}) {
2621
2654
  const partialPath = `${destination}.partial`;
2622
2655
  await unlinkIfExists(partialPath);
2623
2656
  try {
2624
- await downloadFile(url, partialPath, label);
2657
+ await downloadFile(url, partialPath, label, options);
2625
2658
  const actualSha256 = await sha256File(partialPath);
2626
2659
  if (actualSha256 !== expectedSha256.toLowerCase()) {
2627
2660
  throw new Error(`${label} checksum mismatch. Expected ${expectedSha256}, got ${actualSha256}.`);
@@ -2634,15 +2667,97 @@ export async function downloadAndVerify(url, destination, expectedSha256, label
2634
2667
  throw error;
2635
2668
  }
2636
2669
  }
2637
- async function downloadFile(url, destination, label = "download") {
2638
- const response = await fetch(url);
2639
- if (!response.ok) {
2640
- throw new Error(`Failed to download ${label}: HTTP ${response.status}`);
2670
+ async function downloadFile(url, destination, label = "download", options = {}) {
2671
+ const stallTimeoutMs = normalizeDownloadTimeout(options.stallTimeoutMs);
2672
+ const controller = new AbortController();
2673
+ let stallTimer;
2674
+ let stallError;
2675
+ let source;
2676
+ let progressStream;
2677
+ let destinationStream;
2678
+ let receivedBytes = 0;
2679
+ let nextProgressLogBytes = DOWNLOAD_PROGRESS_LOG_BYTES;
2680
+ const failIfStalled = (phase) => {
2681
+ if (stallTimer) {
2682
+ clearTimeout(stallTimer);
2683
+ }
2684
+ stallTimer = setTimeout(() => {
2685
+ stallError = formatDownloadStallError(label, stallTimeoutMs, phase, receivedBytes);
2686
+ controller.abort(stallError);
2687
+ source?.destroy(stallError);
2688
+ progressStream?.destroy(stallError);
2689
+ destinationStream?.destroy(stallError);
2690
+ }, stallTimeoutMs);
2691
+ };
2692
+ try {
2693
+ console.log(`Downloading ${label}...`);
2694
+ failIfStalled("connecting");
2695
+ const response = await fetch(url, { signal: controller.signal });
2696
+ if (!response.ok) {
2697
+ throw new Error(`Failed to download ${label}: HTTP ${response.status}`);
2698
+ }
2699
+ if (!response.body) {
2700
+ throw new Error(`${label} response did not include a body.`);
2701
+ }
2702
+ failIfStalled("receiving data");
2703
+ source = Readable.fromWeb(response.body);
2704
+ progressStream = new Transform({
2705
+ transform(chunk, _encoding, callback) {
2706
+ receivedBytes += downloadChunkByteLength(chunk);
2707
+ failIfStalled("receiving data");
2708
+ if (receivedBytes >= nextProgressLogBytes) {
2709
+ console.log(`Downloaded ${label}: ${formatBytes(receivedBytes)}...`);
2710
+ while (receivedBytes >= nextProgressLogBytes) {
2711
+ nextProgressLogBytes += DOWNLOAD_PROGRESS_LOG_BYTES;
2712
+ }
2713
+ }
2714
+ callback(null, chunk);
2715
+ },
2716
+ });
2717
+ destinationStream = createWriteStream(destination);
2718
+ await pipeline(source, progressStream, destinationStream);
2719
+ console.log(`Downloaded ${label}: ${formatBytes(receivedBytes)}.`);
2720
+ }
2721
+ catch (error) {
2722
+ if (stallError) {
2723
+ throw stallError;
2724
+ }
2725
+ throw error;
2641
2726
  }
2642
- if (!response.body) {
2643
- throw new Error(`${label} response did not include a body.`);
2727
+ finally {
2728
+ if (stallTimer) {
2729
+ clearTimeout(stallTimer);
2730
+ }
2731
+ }
2732
+ }
2733
+ function downloadChunkByteLength(chunk) {
2734
+ if (typeof chunk === "string") {
2735
+ return Buffer.byteLength(chunk);
2736
+ }
2737
+ if (Buffer.isBuffer(chunk)) {
2738
+ return chunk.length;
2739
+ }
2740
+ if (chunk instanceof Uint8Array) {
2741
+ return chunk.byteLength;
2742
+ }
2743
+ return 0;
2744
+ }
2745
+ function normalizeDownloadTimeout(timeoutMs) {
2746
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
2747
+ return DOWNLOAD_STALL_TIMEOUT_MS;
2748
+ }
2749
+ return Math.max(1, Math.floor(timeoutMs));
2750
+ }
2751
+ function formatDownloadStallError(label, timeoutMs, phase, receivedBytes) {
2752
+ const progress = receivedBytes > 0 ? ` after ${formatBytes(receivedBytes)}` : "";
2753
+ return new Error(`${label} download stalled${progress}: no data received for ${formatDuration(timeoutMs)} while ${phase}. ` +
2754
+ "Check your network connection, VPN, or install authorization and retry.");
2755
+ }
2756
+ function formatDuration(ms) {
2757
+ if (ms < 1000) {
2758
+ return `${ms}ms`;
2644
2759
  }
2645
- await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
2760
+ return `${Math.ceil(ms / 1000)}s`;
2646
2761
  }
2647
2762
  async function browserGoogleSignIn(timeoutMs = DEFAULT_INSTALL_AUTH_TIMEOUT_MS) {
2648
2763
  ensureWindowsHost();
@@ -3332,6 +3447,7 @@ function installEventOptions(options) {
3332
3447
  silent: options.silent,
3333
3448
  exePath: options.exePath,
3334
3449
  installTokenProvided: Boolean(options.installToken),
3450
+ installTokenSource: options.installTokenSource,
3335
3451
  };
3336
3452
  }
3337
3453
  function serviceManifestEventSummary(manifest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",