@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 +67 -39
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +363 -12
- package/package.json +1 -1
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 --
|
|
53
|
-
npx tapi studio
|
|
54
|
-
npx tapi studio
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
92
|
+
raw.manifestUrlOverride = requireOptionValue(args, ++index, "--manifest");
|
|
78
93
|
continue;
|
|
79
94
|
}
|
|
80
95
|
if (arg.startsWith("--manifest=")) {
|
|
81
|
-
raw.
|
|
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.
|
|
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
|
-
|
|
403
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
338
404
|
channel: options.channel,
|
|
339
405
|
});
|
|
340
|
-
const manifest = await
|
|
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'>✓ 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'>⚠ 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] [--
|
|
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>
|
|
651
|
-
--
|
|
652
|
-
--
|
|
653
|
-
--
|
|
654
|
-
--
|
|
655
|
-
--
|
|
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) {
|