@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 +6 -5
- package/dist/cli.js +183 -60
- package/package.json +1 -1
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
2168
|
-
const
|
|
2169
|
-
|
|
2170
|
-
|
|
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
|
-
|
|
2212
|
-
console.
|
|
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
|
|
2632
|
-
|
|
2633
|
-
|
|
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
|
-
|
|
2636
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|