@tapi-dev/sdk 0.1.4 → 0.1.5

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
@@ -14,25 +14,34 @@ This package is ESM-first and works in runtimes with `fetch`, including modern N
14
14
 
15
15
  Tapi Studio is the desktop app used to author and test local Tapi integrations. Install the SDK first, then use the bundled CLI:
16
16
 
17
- ```bash
18
- npm install @tapi-dev/sdk
19
- npx tapi studio install --channel pilot
20
- npx tapi studio open
21
- ```
22
-
23
- You can also run the CLI directly from npm without adding the package first:
24
-
25
- ```bash
26
- npx @tapi-dev/sdk studio install --channel pilot
27
- ```
28
-
29
- The CLI reads the release manifest from:
30
-
31
- ```text
32
- https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
33
- ```
34
-
35
- It downloads the Windows installer, verifies the manifest SHA256, caches the installer locally, and runs it. The installer cache defaults to:
17
+ ```bash
18
+ npm install @tapi-dev/sdk
19
+ npx tapi studio install --channel pilot
20
+ npx tapi studio open
21
+ ```
22
+
23
+ `studio install` opens a browser sign-in before any Studio EXE download. The
24
+ CLI exchanges the browser credential for Firebase auth, requests a short-lived
25
+ Studio install token from the Tapi API, then fetches a protected manifest and
26
+ installer download URL. If the signed-in user is not yet approved, the server
27
+ submits an approval request and sends the existing email approval links to the
28
+ admin. After approval, rerun the same command.
29
+
30
+ You can also run the CLI directly from npm without adding the package first:
31
+
32
+ ```bash
33
+ npx @tapi-dev/sdk studio install --channel pilot
34
+ ```
35
+
36
+ The install flow talks to the Tapi API host first:
37
+
38
+ ```text
39
+ POST /api/sdk/v1/studio/install-token
40
+ GET /api/sdk/v1/studio/releases/<channel>
41
+ ```
42
+
43
+ It then downloads the Windows installer, verifies the manifest SHA256, caches
44
+ the installer locally, and runs it. The installer cache defaults to:
36
45
 
37
46
  ```text
38
47
  %LOCALAPPDATA%\Tapi\Studio\downloads
@@ -44,26 +53,45 @@ version declared in the Studio manifest. Developers do not pick a service
44
53
  version separately; updating Studio updates the service version used by that
45
54
  project and by apps generated from that project.
46
55
 
47
- Useful commands:
48
-
49
- ```bash
50
- npx tapi studio install --channel pilot
51
- npx tapi studio install --channel pilot --download-only
52
- npx tapi studio install --manifest https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
53
- npx tapi studio open
54
- npx tapi studio doctor
55
- ```
56
-
57
- Environment overrides:
58
-
59
- ```env
60
- TAPI_STUDIO_CHANNEL=pilot
61
- TAPI_STUDIO_MANIFEST_URL=https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
62
- TAPI_DOWNLOADS_BASE_URL=https://d4xaf52nfwiok.cloudfront.net
63
- TAPI_STUDIO_EXE=C:\Users\you\AppData\Local\Tapi Studio\Tapi Studio.exe
64
- ```
65
-
66
- Tapi Studio desktop releases are currently published for Windows x64.
56
+ Useful commands:
57
+
58
+ ```bash
59
+ npx tapi studio install --channel pilot
60
+ npx tapi studio install --channel pilot --download-only
61
+ npx tapi studio install --api-base-url https://api.example.com
62
+ npx tapi studio install --install-token tsi_your_preissued_token
63
+ npx tapi studio open
64
+ npx tapi studio doctor
65
+ ```
66
+
67
+ Environment overrides:
68
+
69
+ ```env
70
+ TAPI_BASE_URL=https://your-tapi-api-host
71
+ TAPI_STUDIO_API_BASE_URL=https://your-tapi-api-host
72
+ TAPI_STUDIO_CHANNEL=pilot
73
+ TAPI_STUDIO_MANIFEST_URL=https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
74
+ TAPI_STUDIO_INSTALL_TOKEN=tsi_your_preissued_token
75
+ TAPI_STUDIO_AUTH_HTML_URL=https://rsarlong-1f92fd.gitlab.io/auth.html
76
+ TAPI_FIREBASE_API_KEY=AIzaSyCDZR8lWyVQcWYfFdNZa4vuL4IWEC0h6gE
77
+ TAPI_STUDIO_EXE=C:\Users\you\AppData\Local\Tapi Studio\Tapi Studio.exe
78
+ ```
79
+
80
+ `TAPI_STUDIO_MANIFEST_URL` remains useful for `studio doctor`. Direct manifest
81
+ overrides are not used by the protected installer path.
82
+
83
+ Server-side protected install delivery also requires:
84
+
85
+ ```env
86
+ STUDIO_INSTALL_TOKEN_SECRET=replace-me
87
+ S3_BUCKET=your-private-release-bucket
88
+ AWS_REGION=us-east-1
89
+ ```
90
+
91
+ If the Studio installer objects remain publicly downloadable, users can bypass
92
+ the approval gate by skipping the CLI entirely.
93
+
94
+ Tapi Studio desktop releases are currently published for Windows x64.
67
95
 
68
96
  ## Quick Start
69
97
 
package/dist/cli.d.ts CHANGED
@@ -24,11 +24,14 @@ export interface StudioReleaseManifest {
24
24
  }
25
25
  interface StudioCliOptions {
26
26
  channel: StudioChannel;
27
+ apiBaseUrl: string;
27
28
  manifestUrl: string;
29
+ manifestUrlOverride?: string;
28
30
  cacheDir: string;
29
31
  downloadOnly: boolean;
30
32
  silent: boolean;
31
33
  exePath?: string;
34
+ installToken?: string;
32
35
  }
33
36
  export declare function runCli(argv?: string[]): Promise<number>;
34
37
  export declare function parseStudioOptions(args: string[]): StudioCliOptions;
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
5
6
  import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
@@ -10,9 +11,23 @@ import { Readable } from "node:stream";
10
11
  import { pipeline } from "node:stream/promises";
11
12
  import { fileURLToPath } from "node:url";
12
13
  const DEFAULT_DOWNLOADS_BASE_URL = "https://d4xaf52nfwiok.cloudfront.net";
14
+ const DEFAULT_STUDIO_API_BASE_URL = "https://determined-motivation-production.up.railway.app";
15
+ const DEFAULT_STUDIO_AUTH_HTML_URL = "https://rsarlong-1f92fd.gitlab.io/auth.html";
16
+ const DEFAULT_FIREBASE_API_KEY = "AIzaSyCDZR8lWyVQcWYfFdNZa4vuL4IWEC0h6gE";
13
17
  const DEFAULT_CHANNEL = "pilot";
14
18
  const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
19
+ const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
15
20
  const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
21
+ class StudioInstallApprovalError extends Error {
22
+ code;
23
+ status;
24
+ constructor(message, code, status) {
25
+ super(message);
26
+ this.code = code;
27
+ this.status = status;
28
+ this.name = "StudioInstallApprovalError";
29
+ }
30
+ }
16
31
  const sdkVersion = readSdkVersion();
17
32
  export async function runCli(argv = process.argv.slice(2)) {
18
33
  const [command, subcommand, ...rest] = argv;
@@ -74,11 +89,31 @@ export function parseStudioOptions(args) {
74
89
  continue;
75
90
  }
76
91
  if (arg === "--manifest") {
77
- raw.manifestUrl = requireOptionValue(args, ++index, "--manifest");
92
+ raw.manifestUrlOverride = requireOptionValue(args, ++index, "--manifest");
78
93
  continue;
79
94
  }
80
95
  if (arg.startsWith("--manifest=")) {
81
- raw.manifestUrl = arg.slice("--manifest=".length);
96
+ raw.manifestUrlOverride = arg.slice("--manifest=".length);
97
+ continue;
98
+ }
99
+ if (arg === "--api-base-url" || arg === "--server") {
100
+ raw.apiBaseUrl = requireOptionValue(args, ++index, arg);
101
+ continue;
102
+ }
103
+ if (arg.startsWith("--api-base-url=")) {
104
+ raw.apiBaseUrl = arg.slice("--api-base-url=".length);
105
+ continue;
106
+ }
107
+ if (arg.startsWith("--server=")) {
108
+ raw.apiBaseUrl = arg.slice("--server=".length);
109
+ continue;
110
+ }
111
+ if (arg === "--install-token") {
112
+ raw.installToken = requireOptionValue(args, ++index, "--install-token");
113
+ continue;
114
+ }
115
+ if (arg.startsWith("--install-token=")) {
116
+ raw.installToken = arg.slice("--install-token=".length);
82
117
  continue;
83
118
  }
84
119
  if (arg === "--cache-dir") {
@@ -111,18 +146,26 @@ export function parseStudioOptions(args) {
111
146
  throw new Error(`Unknown option: ${arg}`);
112
147
  }
113
148
  const channel = raw.channel ?? parseChannel(envString("TAPI_STUDIO_CHANNEL") ?? DEFAULT_CHANNEL);
149
+ const apiBaseUrl = normalizeHttpUrl(raw.apiBaseUrl
150
+ ?? envString("TAPI_STUDIO_API_BASE_URL")
151
+ ?? envString("TAPI_BASE_URL")
152
+ ?? envString("TAPI_STUDIO_SERVER_URL")
153
+ ?? DEFAULT_STUDIO_API_BASE_URL, "Studio API base URL");
114
154
  const downloadsBaseUrl = normalizeHttpUrl(envString("TAPI_DOWNLOADS_BASE_URL") ?? DEFAULT_DOWNLOADS_BASE_URL, "TAPI_DOWNLOADS_BASE_URL");
115
- const explicitManifestUrl = raw.manifestUrl ?? envString("TAPI_STUDIO_MANIFEST_URL");
155
+ const explicitManifestUrl = raw.manifestUrlOverride ?? envString("TAPI_STUDIO_MANIFEST_URL");
116
156
  const manifestUrl = explicitManifestUrl
117
157
  ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL")
118
158
  : `${downloadsBaseUrl.replace(/\/+$/, "")}/studio/channels/${channel}/latest.json`;
119
159
  return {
120
160
  channel,
161
+ apiBaseUrl,
121
162
  manifestUrl,
163
+ manifestUrlOverride: explicitManifestUrl ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL") : undefined,
122
164
  cacheDir: raw.cacheDir ?? getDefaultStudioCacheDir(),
123
165
  downloadOnly: raw.downloadOnly ?? false,
124
166
  silent: raw.silent ?? false,
125
167
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
168
+ installToken: raw.installToken ?? envString("TAPI_STUDIO_INSTALL_TOKEN"),
126
169
  };
127
170
  }
128
171
  export function getDefaultStudioCacheDir() {
@@ -332,12 +375,35 @@ async function installStudio(options) {
332
375
  arch: process.arch,
333
376
  });
334
377
  ensureWindowsHost();
378
+ if (options.manifestUrlOverride) {
379
+ throw new Error("Direct Studio manifest overrides are no longer supported for install. "
380
+ + "Use --channel with the protected install flow instead.");
381
+ }
382
+ let installToken = options.installToken?.trim();
383
+ if (installToken) {
384
+ await event.phase("auth.install_token.provided", {
385
+ apiBaseUrl: options.apiBaseUrl,
386
+ channel: options.channel,
387
+ });
388
+ }
389
+ else {
390
+ console.log("Checking Studio install approval...");
391
+ await event.phase("auth.install_token.request.start", {
392
+ apiBaseUrl: options.apiBaseUrl,
393
+ channel: options.channel,
394
+ });
395
+ installToken = await obtainStudioInstallToken(options, event);
396
+ await event.phase("auth.install_token.request.success", {
397
+ apiBaseUrl: options.apiBaseUrl,
398
+ channel: options.channel,
399
+ });
400
+ }
335
401
  console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
336
402
  await event.phase("manifest.fetch.start", {
337
- manifestUrl: options.manifestUrl,
403
+ apiBaseUrl: options.apiBaseUrl,
338
404
  channel: options.channel,
339
405
  });
340
- const manifest = await fetchStudioManifest(options.manifestUrl);
406
+ const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
341
407
  await event.phase("manifest.fetch.success", {
342
408
  manifest: manifestEventSummary(manifest),
343
409
  });
@@ -401,6 +467,49 @@ async function installStudio(options) {
401
467
  throw error;
402
468
  }
403
469
  }
470
+ async function obtainStudioInstallToken(options, event) {
471
+ const cachedCreds = await refreshCachedFirebaseCredentials();
472
+ if (cachedCreds) {
473
+ await event.phase("auth.cache.refresh.success", {
474
+ uid: cachedCreds.uid,
475
+ authCachePath: getStudioAuthCachePath(),
476
+ });
477
+ await writeStudioAuthCache(cachedCreds);
478
+ try {
479
+ const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, cachedCreds.idToken);
480
+ return issued.installToken;
481
+ }
482
+ catch (error) {
483
+ if (isApprovalTerminalError(error)) {
484
+ throw error;
485
+ }
486
+ await event.phase("auth.cache.install_token.retry_interactive", {
487
+ error: errorDetails(error),
488
+ });
489
+ }
490
+ }
491
+ else {
492
+ await event.phase("auth.cache.refresh.miss", {
493
+ authCachePath: getStudioAuthCachePath(),
494
+ });
495
+ }
496
+ const browserAuth = await browserGoogleSignIn();
497
+ await event.phase("auth.browser.callback.received", {
498
+ googleIdTokenPresent: Boolean(browserAuth.idToken),
499
+ googleAccessTokenPresent: Boolean(browserAuth.accessToken),
500
+ });
501
+ if (!browserAuth.idToken) {
502
+ throw new Error(browserAuth.error || browserAuth.detail || "Browser sign-in did not return a Google ID token.");
503
+ }
504
+ const firebaseCreds = await exchangeGoogleToFirebase(browserAuth.idToken, browserAuth.accessToken);
505
+ await writeStudioAuthCache(firebaseCreds);
506
+ await event.phase("auth.firebase.exchange.success", {
507
+ uid: firebaseCreds.uid,
508
+ authCachePath: getStudioAuthCachePath(),
509
+ });
510
+ const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, firebaseCreds.idToken);
511
+ return issued.installToken;
512
+ }
404
513
  async function openStudio(options) {
405
514
  ensureWindowsHost();
406
515
  const exePath = options.exePath ?? getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
@@ -422,6 +531,7 @@ async function runDoctor(options) {
422
531
  console.log(`Tapi SDK: ${sdkVersion}`);
423
532
  console.log(`Node: ${process.version}`);
424
533
  console.log(`Platform: ${process.platform}/${process.arch}`);
534
+ console.log(`Studio API: ${options.apiBaseUrl}`);
425
535
  console.log(`Studio channel: ${options.channel}`);
426
536
  console.log(`Studio manifest: ${options.manifestUrl}`);
427
537
  console.log(`Studio cache: ${options.cacheDir}`);
@@ -438,6 +548,18 @@ async function runDoctor(options) {
438
548
  }
439
549
  return 0;
440
550
  }
551
+ async function fetchProtectedStudioManifest(apiBaseUrl, channel, installToken, fetchImpl = fetch) {
552
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/studio/releases/${channel}`, {
553
+ headers: {
554
+ Accept: "application/json",
555
+ Authorization: `Bearer ${installToken}`,
556
+ },
557
+ });
558
+ if (!response.ok) {
559
+ throw new Error(`Failed to fetch protected Studio manifest: HTTP ${response.status}`);
560
+ }
561
+ return validateStudioManifest(await response.json());
562
+ }
441
563
  async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
442
564
  const response = await fetchImpl(manifestUrl, {
443
565
  headers: {
@@ -449,6 +571,37 @@ async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
449
571
  }
450
572
  return validateStudioManifest(await response.json());
451
573
  }
574
+ async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, fetchImpl = fetch) {
575
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/studio/install-token`, {
576
+ method: "POST",
577
+ headers: {
578
+ Accept: "application/json",
579
+ Authorization: `Bearer ${firebaseIdToken}`,
580
+ "Content-Type": "application/json",
581
+ },
582
+ body: JSON.stringify({ channel }),
583
+ });
584
+ const responseBody = await readJsonBody(response);
585
+ if (!response.ok) {
586
+ const detail = typeof responseBody?.detail === "string" ? responseBody.detail : "";
587
+ if (detail === "pending_approval" || detail === "access_pending") {
588
+ throw new StudioInstallApprovalError("Access request submitted. Check your email for approval, then rerun `npx tapi studio install`.", detail, response.status);
589
+ }
590
+ if (detail === "access_rejected") {
591
+ throw new StudioInstallApprovalError("Studio install access was rejected. Contact the Tapi admin if this is unexpected.", detail, response.status);
592
+ }
593
+ throw new StudioInstallApprovalError(detail || `Failed to request Studio install token: HTTP ${response.status}`, detail || "install_token_request_failed", response.status);
594
+ }
595
+ const payload = responseBody;
596
+ const installToken = typeof payload?.installToken === "string" ? payload.installToken.trim() : "";
597
+ if (!installToken) {
598
+ throw new Error("Studio install token response did not include installToken.");
599
+ }
600
+ return {
601
+ installToken,
602
+ expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : undefined,
603
+ };
604
+ }
452
605
  async function downloadAndVerify(url, destination, expectedSha256) {
453
606
  const partialPath = `${destination}.partial`;
454
607
  await downloadFile(url, partialPath);
@@ -470,6 +623,198 @@ async function downloadFile(url, destination) {
470
623
  }
471
624
  await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
472
625
  }
626
+ async function browserGoogleSignIn(timeoutMs = DEFAULT_INSTALL_AUTH_TIMEOUT_MS) {
627
+ ensureWindowsHost();
628
+ return new Promise((resolvePromise, rejectPromise) => {
629
+ let timeoutHandle;
630
+ const cleanup = () => {
631
+ if (timeoutHandle) {
632
+ clearTimeout(timeoutHandle);
633
+ timeoutHandle = undefined;
634
+ }
635
+ try {
636
+ server.close();
637
+ }
638
+ catch {
639
+ // ignore close races
640
+ }
641
+ };
642
+ const server = createServer((req, res) => {
643
+ const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
644
+ const idToken = requestUrl.searchParams.get("idToken") || "";
645
+ const accessToken = requestUrl.searchParams.get("accessToken") || "";
646
+ const error = requestUrl.searchParams.get("error") || "";
647
+ const detail = requestUrl.searchParams.get("detail") || "";
648
+ const ok = Boolean(idToken);
649
+ res.statusCode = 200;
650
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
651
+ res.end(renderBrowserSignInResponseHtml(ok));
652
+ cleanup();
653
+ if (!ok) {
654
+ rejectPromise(new Error(error || detail || "Browser sign-in did not return Google credentials."));
655
+ return;
656
+ }
657
+ resolvePromise({
658
+ accessToken: accessToken || undefined,
659
+ detail: detail || undefined,
660
+ idToken,
661
+ });
662
+ });
663
+ timeoutHandle = setTimeout(() => {
664
+ cleanup();
665
+ rejectPromise(new Error("Browser sign-in timed out or was cancelled."));
666
+ }, timeoutMs);
667
+ server.once("error", (error) => {
668
+ cleanup();
669
+ rejectPromise(error);
670
+ });
671
+ server.listen(0, "127.0.0.1", () => {
672
+ const address = server.address();
673
+ const port = typeof address === "object" && address ? address.port : 0;
674
+ if (!port) {
675
+ cleanup();
676
+ rejectPromise(new Error("Could not start local callback server for Studio sign-in."));
677
+ return;
678
+ }
679
+ try {
680
+ openBrowser(`${getStudioAuthHtmlUrl()}?port=${port}`);
681
+ }
682
+ catch (error) {
683
+ cleanup();
684
+ rejectPromise(error);
685
+ }
686
+ });
687
+ });
688
+ }
689
+ async function exchangeGoogleToFirebase(googleIdToken, googleAccessToken, fetchImpl = fetch) {
690
+ const postBody = [
691
+ `id_token=${encodeURIComponent(googleIdToken)}`,
692
+ "providerId=google.com",
693
+ googleAccessToken ? `access_token=${encodeURIComponent(googleAccessToken)}` : "",
694
+ ].filter(Boolean).join("&");
695
+ const response = await fetchImpl(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${getFirebaseApiKey()}`, {
696
+ method: "POST",
697
+ headers: {
698
+ Accept: "application/json",
699
+ "Content-Type": "application/json",
700
+ },
701
+ body: JSON.stringify({
702
+ postBody,
703
+ requestUri: "http://127.0.0.1",
704
+ returnSecureToken: true,
705
+ returnIdpCredential: true,
706
+ }),
707
+ });
708
+ const responseBody = await readJsonBody(response);
709
+ if (!response.ok) {
710
+ throw new Error(`Firebase exchange failed: HTTP ${response.status}`);
711
+ }
712
+ const uid = typeof responseBody?.localId === "string" ? responseBody.localId.trim() : "";
713
+ const idToken = typeof responseBody?.idToken === "string" ? responseBody.idToken.trim() : "";
714
+ const refreshToken = typeof responseBody?.refreshToken === "string" ? responseBody.refreshToken.trim() : "";
715
+ if (!uid || !idToken || !refreshToken) {
716
+ throw new Error("Firebase exchange response is missing uid, idToken, or refreshToken.");
717
+ }
718
+ return { uid, idToken, refreshToken };
719
+ }
720
+ async function refreshCachedFirebaseCredentials(fetchImpl = fetch) {
721
+ const cache = readStudioAuthCache();
722
+ if (!cache?.refreshToken) {
723
+ return null;
724
+ }
725
+ const response = await fetchImpl(`https://securetoken.googleapis.com/v1/token?key=${getFirebaseApiKey()}`, {
726
+ method: "POST",
727
+ headers: {
728
+ Accept: "application/json",
729
+ "Content-Type": "application/x-www-form-urlencoded",
730
+ },
731
+ body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(cache.refreshToken)}`,
732
+ });
733
+ const responseBody = await readJsonBody(response);
734
+ if (!response.ok) {
735
+ return null;
736
+ }
737
+ const uid = typeof responseBody?.user_id === "string" ? responseBody.user_id.trim() : "";
738
+ const idToken = typeof responseBody?.id_token === "string" ? responseBody.id_token.trim() : "";
739
+ const refreshToken = typeof responseBody?.refresh_token === "string" ? responseBody.refresh_token.trim() : "";
740
+ if (!uid || !idToken || !refreshToken) {
741
+ return null;
742
+ }
743
+ return { uid, idToken, refreshToken };
744
+ }
745
+ function readStudioAuthCache() {
746
+ const cachePath = getStudioAuthCachePath();
747
+ if (!existsSync(cachePath)) {
748
+ return null;
749
+ }
750
+ try {
751
+ const payload = JSON.parse(readFileSync(cachePath, "utf8"));
752
+ const uid = typeof payload.uid === "string" ? payload.uid.trim() : "";
753
+ const idToken = typeof payload.id_token === "string" ? payload.id_token.trim() : "";
754
+ const refreshToken = typeof payload.refresh_token === "string" ? payload.refresh_token.trim() : "";
755
+ if (!uid || !refreshToken) {
756
+ return null;
757
+ }
758
+ return { uid, idToken, refreshToken };
759
+ }
760
+ catch {
761
+ return null;
762
+ }
763
+ }
764
+ async function writeStudioAuthCache(credentials) {
765
+ await mkdir(getDefaultTapiDataDir(), { recursive: true });
766
+ await writeFile(getStudioAuthCachePath(), `${JSON.stringify({
767
+ uid: credentials.uid,
768
+ refresh_token: credentials.refreshToken,
769
+ id_token: credentials.idToken,
770
+ }, null, 2)}\n`, "utf8");
771
+ }
772
+ function getStudioAuthCachePath() {
773
+ return join(getDefaultTapiDataDir(), "auth.json");
774
+ }
775
+ function getDefaultTapiDataDir() {
776
+ if (process.platform === "win32") {
777
+ const localAppData = process.env.LOCALAPPDATA
778
+ ?? (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
779
+ return join(localAppData, "Tapi");
780
+ }
781
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "tapi");
782
+ }
783
+ function getStudioAuthHtmlUrl() {
784
+ return normalizeHttpUrl(envString("TAPI_STUDIO_AUTH_HTML_URL") ?? DEFAULT_STUDIO_AUTH_HTML_URL, "TAPI_STUDIO_AUTH_HTML_URL");
785
+ }
786
+ function getFirebaseApiKey() {
787
+ return (envString("TAPI_FIREBASE_API_KEY") ?? DEFAULT_FIREBASE_API_KEY).trim();
788
+ }
789
+ function openBrowser(url) {
790
+ const child = spawn("cmd", ["/c", "start", "", url], {
791
+ detached: true,
792
+ stdio: "ignore",
793
+ windowsHide: true,
794
+ });
795
+ child.unref();
796
+ }
797
+ async function readJsonBody(response) {
798
+ const text = await response.text();
799
+ if (!text.trim()) {
800
+ return null;
801
+ }
802
+ try {
803
+ return JSON.parse(text);
804
+ }
805
+ catch {
806
+ return null;
807
+ }
808
+ }
809
+ function isApprovalTerminalError(error) {
810
+ return error instanceof StudioInstallApprovalError
811
+ && (error.code === "pending_approval" || error.code === "access_pending" || error.code === "access_rejected");
812
+ }
813
+ function renderBrowserSignInResponseHtml(success) {
814
+ return success
815
+ ? "<!DOCTYPE html><html><body style='font-family:system-ui;text-align:center;padding:80px'><h2 style='color:#22c55e'>&#10003; Signed in!</h2><p>Return to your terminal.</p><script>setTimeout(()=>window.close(),1600)</script></body></html>"
816
+ : "<!DOCTYPE html><html><body style='font-family:system-ui;text-align:center;padding:80px'><h2 style='color:#ef4444'>&#9888; Sign-in failed</h2><p>Return to your terminal for details.</p></body></html>";
817
+ }
473
818
  async function hasVerifiedCachedInstaller(path, expectedSha256) {
474
819
  if (!existsSync(path)) {
475
820
  return false;
@@ -518,11 +863,14 @@ function cachedInstallerName(manifest) {
518
863
  function installEventOptions(options) {
519
864
  return {
520
865
  channel: options.channel,
866
+ apiBaseUrl: options.apiBaseUrl,
521
867
  manifestUrl: options.manifestUrl,
868
+ manifestUrlOverride: options.manifestUrlOverride,
522
869
  cacheDir: options.cacheDir,
523
870
  downloadOnly: options.downloadOnly,
524
871
  silent: options.silent,
525
872
  exePath: options.exePath,
873
+ installTokenProvided: Boolean(options.installToken),
526
874
  };
527
875
  }
528
876
  function manifestEventSummary(manifest) {
@@ -626,7 +974,7 @@ function printHelp() {
626
974
  console.log(`Tapi CLI
627
975
 
628
976
  Usage:
629
- tapi studio install [--channel pilot] [--manifest URL]
977
+ tapi studio install [--channel pilot] [--api-base-url URL]
630
978
  tapi studio open
631
979
  tapi studio doctor
632
980
  tapi doctor
@@ -647,12 +995,15 @@ Usage:
647
995
  tapi studio doctor [options]
648
996
 
649
997
  Options:
650
- --channel <name> Release channel: pilot, stable, or nightly
651
- --manifest <url> Exact release manifest URL
652
- --cache-dir <path> Installer download cache directory
653
- --download-only Download and verify without running the installer
654
- --silent Run the NSIS installer with /S
655
- --exe <path> Tapi Studio executable path for open/doctor
998
+ --channel <name> Release channel: pilot, stable, or nightly
999
+ --api-base-url <url> Tapi API base URL for approval and protected downloads
1000
+ --server <url> Alias for --api-base-url
1001
+ --install-token <tok> Preissued Studio install token (skips browser sign-in)
1002
+ --manifest <url> Exact release manifest URL for doctor only
1003
+ --cache-dir <path> Installer download cache directory
1004
+ --download-only Download and verify without running the installer
1005
+ --silent Run the NSIS installer with /S
1006
+ --exe <path> Tapi Studio executable path for open/doctor
656
1007
  `);
657
1008
  }
658
1009
  function formatError(error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",