@tapi-dev/sdk 0.1.3 → 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,50 +14,84 @@ 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
- %LOCALAPPDATA%\Tapi\Studio\downloads
39
- ```
40
-
41
- Useful commands:
42
-
43
- ```bash
44
- npx tapi studio install --channel pilot
45
- npx tapi studio install --channel pilot --download-only
46
- npx tapi studio install --manifest https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
47
- npx tapi studio open
48
- npx tapi studio doctor
49
- ```
50
-
51
- Environment overrides:
52
-
53
- ```env
54
- TAPI_STUDIO_CHANNEL=pilot
55
- TAPI_STUDIO_MANIFEST_URL=https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
56
- TAPI_DOWNLOADS_BASE_URL=https://d4xaf52nfwiok.cloudfront.net
57
- TAPI_STUDIO_EXE=C:\Users\you\AppData\Local\Tapi Studio\Tapi Studio.exe
58
- ```
59
-
60
- Tapi Studio desktop releases are currently published for Windows x64.
47
+ %LOCALAPPDATA%\Tapi\Studio\downloads
48
+ ```
49
+
50
+ Tapi Studio includes the matching Tapi Service build. When the installer runs,
51
+ it installs or replaces the local `tapi-service` Windows service with the
52
+ version declared in the Studio manifest. Developers do not pick a service
53
+ version separately; updating Studio updates the service version used by that
54
+ project and by apps generated from that project.
55
+
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.
61
95
 
62
96
  ## Quick Start
63
97
 
package/dist/cli.d.ts CHANGED
@@ -15,18 +15,40 @@ export interface StudioReleaseManifest {
15
15
  commitShort?: string;
16
16
  ref?: string;
17
17
  installerKind?: string;
18
+ bundledService?: {
19
+ product?: string;
20
+ version?: string;
21
+ source?: string;
22
+ manifestUrl?: string;
23
+ };
18
24
  }
19
25
  interface StudioCliOptions {
20
26
  channel: StudioChannel;
27
+ apiBaseUrl: string;
21
28
  manifestUrl: string;
29
+ manifestUrlOverride?: string;
22
30
  cacheDir: string;
23
31
  downloadOnly: boolean;
24
32
  silent: boolean;
25
33
  exePath?: string;
34
+ installToken?: string;
26
35
  }
27
36
  export declare function runCli(argv?: string[]): Promise<number>;
28
37
  export declare function parseStudioOptions(args: string[]): StudioCliOptions;
29
38
  export declare function getDefaultStudioCacheDir(): string;
39
+ export declare class CliWideEvent {
40
+ private readonly filePath;
41
+ private readonly startedAt;
42
+ private readonly data;
43
+ private readonly phases;
44
+ private constructor();
45
+ static create(eventName: string, initialFields?: Record<string, unknown>): Promise<CliWideEvent>;
46
+ phase(phase: string, fields?: Record<string, unknown>): Promise<void>;
47
+ finish(success: boolean, fields?: Record<string, unknown>): Promise<void>;
48
+ private write;
49
+ }
50
+ export declare function createCliWideEvent(eventName: string, initialFields?: Record<string, unknown>): Promise<CliWideEvent>;
51
+ export declare function getCliWideEventRoot(): string;
30
52
  export declare function getStudioExecutableCandidates(): string[];
31
53
  export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
32
54
  export declare function compareVersions(left: string, right: string): number;
package/dist/cli.js CHANGED
@@ -1,16 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { createHash, randomUUID } from "node:crypto";
4
5
  import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
5
- import { mkdir, rename, unlink } from "node:fs/promises";
6
+ import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
6
7
  import { homedir } from "node:os";
7
8
  import { basename, join, resolve } from "node:path";
9
+ import { performance } from "node:perf_hooks";
8
10
  import { Readable } from "node:stream";
9
11
  import { pipeline } from "node:stream/promises";
10
12
  import { fileURLToPath } from "node:url";
11
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";
12
17
  const DEFAULT_CHANNEL = "pilot";
13
18
  const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
19
+ const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
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
+ }
14
31
  const sdkVersion = readSdkVersion();
15
32
  export async function runCli(argv = process.argv.slice(2)) {
16
33
  const [command, subcommand, ...rest] = argv;
@@ -72,11 +89,31 @@ export function parseStudioOptions(args) {
72
89
  continue;
73
90
  }
74
91
  if (arg === "--manifest") {
75
- raw.manifestUrl = requireOptionValue(args, ++index, "--manifest");
92
+ raw.manifestUrlOverride = requireOptionValue(args, ++index, "--manifest");
76
93
  continue;
77
94
  }
78
95
  if (arg.startsWith("--manifest=")) {
79
- 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);
80
117
  continue;
81
118
  }
82
119
  if (arg === "--cache-dir") {
@@ -109,18 +146,26 @@ export function parseStudioOptions(args) {
109
146
  throw new Error(`Unknown option: ${arg}`);
110
147
  }
111
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");
112
154
  const downloadsBaseUrl = normalizeHttpUrl(envString("TAPI_DOWNLOADS_BASE_URL") ?? DEFAULT_DOWNLOADS_BASE_URL, "TAPI_DOWNLOADS_BASE_URL");
113
- const explicitManifestUrl = raw.manifestUrl ?? envString("TAPI_STUDIO_MANIFEST_URL");
155
+ const explicitManifestUrl = raw.manifestUrlOverride ?? envString("TAPI_STUDIO_MANIFEST_URL");
114
156
  const manifestUrl = explicitManifestUrl
115
157
  ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL")
116
158
  : `${downloadsBaseUrl.replace(/\/+$/, "")}/studio/channels/${channel}/latest.json`;
117
159
  return {
118
160
  channel,
161
+ apiBaseUrl,
119
162
  manifestUrl,
163
+ manifestUrlOverride: explicitManifestUrl ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL") : undefined,
120
164
  cacheDir: raw.cacheDir ?? getDefaultStudioCacheDir(),
121
165
  downloadOnly: raw.downloadOnly ?? false,
122
166
  silent: raw.silent ?? false,
123
167
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
168
+ installToken: raw.installToken ?? envString("TAPI_STUDIO_INSTALL_TOKEN"),
124
169
  };
125
170
  }
126
171
  export function getDefaultStudioCacheDir() {
@@ -131,6 +176,141 @@ export function getDefaultStudioCacheDir() {
131
176
  }
132
177
  return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "studio");
133
178
  }
179
+ export class CliWideEvent {
180
+ filePath;
181
+ startedAt = performance.now();
182
+ data;
183
+ phases = [];
184
+ constructor(filePath, eventName, initialFields) {
185
+ this.filePath = filePath;
186
+ this.data = {
187
+ event: eventName,
188
+ seq: 1,
189
+ timestamp: new Date().toISOString(),
190
+ trace_id: `cli_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
191
+ actor: {
192
+ mode: "tapi_cli",
193
+ pid: process.pid,
194
+ cwd: process.cwd(),
195
+ node: process.version,
196
+ platform: process.platform,
197
+ arch: process.arch,
198
+ },
199
+ phases: this.phases,
200
+ ...initialFields,
201
+ };
202
+ }
203
+ static async create(eventName, initialFields = {}) {
204
+ try {
205
+ const root = getCliWideEventRoot();
206
+ await mkdir(root, { recursive: true });
207
+ const sessionDir = join(root, sessionDirName("tapi_cli"));
208
+ await mkdir(sessionDir, { recursive: true });
209
+ await pruneCliWideEventProcessRoots(root);
210
+ const filePath = join(sessionDir, `${safeEventFilename(eventName)}_${timeFilename(new Date())}.json`);
211
+ const event = new CliWideEvent(filePath, eventName, initialFields);
212
+ await event.phase("start");
213
+ return event;
214
+ }
215
+ catch {
216
+ return new CliWideEvent(undefined, eventName, initialFields);
217
+ }
218
+ }
219
+ async phase(phase, fields = {}) {
220
+ this.phases.push({
221
+ phase,
222
+ timestamp: new Date().toISOString(),
223
+ fields: sanitizeEventFields(fields),
224
+ });
225
+ this.data.phase = phase;
226
+ await this.write();
227
+ }
228
+ async finish(success, fields = {}) {
229
+ const durationMs = Math.round((performance.now() - this.startedAt) * 10) / 10;
230
+ this.data.duration_ms = durationMs;
231
+ this.data.outcome = success ? "success" : "error";
232
+ await this.phase(success ? "finish.success" : "finish.error", fields);
233
+ }
234
+ async write() {
235
+ if (!this.filePath) {
236
+ return;
237
+ }
238
+ try {
239
+ await writeFile(this.filePath, JSON.stringify(this.data, null, 2), "utf8");
240
+ }
241
+ catch {
242
+ // Diagnostics must never make the installer path fail.
243
+ }
244
+ }
245
+ }
246
+ export async function createCliWideEvent(eventName, initialFields = {}) {
247
+ return CliWideEvent.create(eventName, initialFields);
248
+ }
249
+ export function getCliWideEventRoot() {
250
+ const override = envString("TAPI_EVENTS_ROOT");
251
+ if (override) {
252
+ return override;
253
+ }
254
+ if (process.platform === "win32") {
255
+ const localAppData = process.env.LOCALAPPDATA ??
256
+ (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
257
+ return join(localAppData, "Tapi", "logs", "events");
258
+ }
259
+ return join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "tapi", "logs", "events");
260
+ }
261
+ async function pruneCliWideEventProcessRoots(root) {
262
+ try {
263
+ const entries = await readdir(root, { withFileTypes: true });
264
+ const directories = await Promise.all(entries
265
+ .filter((entry) => entry.isDirectory())
266
+ .map(async (entry) => {
267
+ const path = join(root, entry.name);
268
+ try {
269
+ return { path, mtimeMs: (await stat(path)).mtimeMs };
270
+ }
271
+ catch {
272
+ return { path, mtimeMs: 0 };
273
+ }
274
+ }));
275
+ if (directories.length <= MAX_WIDE_EVENT_PROCESS_ROOTS) {
276
+ return;
277
+ }
278
+ directories.sort((left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path));
279
+ await Promise.all(directories
280
+ .slice(MAX_WIDE_EVENT_PROCESS_ROOTS)
281
+ .map((entry) => rm(entry.path, { recursive: true, force: true })));
282
+ }
283
+ catch {
284
+ // Best-effort retention only.
285
+ }
286
+ }
287
+ function sessionDirName(role) {
288
+ const now = new Date();
289
+ const stamp = now
290
+ .toISOString()
291
+ .replace("T", "_")
292
+ .replace(/\.\d+Z$/, "")
293
+ .replace(/:/g, "-");
294
+ return `${stamp}_${role}_pid${process.pid}`;
295
+ }
296
+ function timeFilename(date) {
297
+ return date
298
+ .toISOString()
299
+ .split("T")[1]
300
+ .replace("Z", "")
301
+ .replace(/:/g, "-");
302
+ }
303
+ function safeEventFilename(eventName) {
304
+ return `001_${eventName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\./g, "_")}`;
305
+ }
306
+ function sanitizeEventFields(fields) {
307
+ return JSON.parse(JSON.stringify(fields, (_key, value) => {
308
+ if (value instanceof Error) {
309
+ return errorDetails(value);
310
+ }
311
+ return value;
312
+ }));
313
+ }
134
314
  export function getStudioExecutableCandidates() {
135
315
  const candidates = [
136
316
  process.env.TAPI_STUDIO_EXE,
@@ -185,29 +365,150 @@ export function compareVersions(left, right) {
185
365
  return 0;
186
366
  }
187
367
  async function installStudio(options) {
188
- ensureWindowsHost();
189
- console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
190
- const manifest = await fetchStudioManifest(options.manifestUrl);
191
- ensureCompatibleManifest(manifest);
192
- await mkdir(options.cacheDir, { recursive: true });
193
- const installerPath = join(options.cacheDir, cachedInstallerName(manifest));
194
- const verified = await hasVerifiedCachedInstaller(installerPath, manifest.sha256);
195
- if (verified) {
196
- console.log(`Using cached installer: ${installerPath}`);
368
+ const event = await createCliWideEvent("tapi_cli.studio_install", {
369
+ sdk_version: sdkVersion,
370
+ options: installEventOptions(options),
371
+ });
372
+ try {
373
+ await event.phase("host.check", {
374
+ platform: process.platform,
375
+ arch: process.arch,
376
+ });
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
+ }
401
+ console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
402
+ await event.phase("manifest.fetch.start", {
403
+ apiBaseUrl: options.apiBaseUrl,
404
+ channel: options.channel,
405
+ });
406
+ const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
407
+ await event.phase("manifest.fetch.success", {
408
+ manifest: manifestEventSummary(manifest),
409
+ });
410
+ ensureCompatibleManifest(manifest);
411
+ await event.phase("manifest.compatible", {
412
+ sdkVersion,
413
+ minSdkVersion: manifest.minSdkVersion,
414
+ platform: manifest.platform,
415
+ });
416
+ await mkdir(options.cacheDir, { recursive: true });
417
+ const installerPath = join(options.cacheDir, cachedInstallerName(manifest));
418
+ await event.phase("cache.check.start", {
419
+ cacheDir: options.cacheDir,
420
+ installerPath,
421
+ });
422
+ const verified = await hasVerifiedCachedInstaller(installerPath, manifest.sha256);
423
+ if (verified) {
424
+ console.log(`Using cached installer: ${installerPath}`);
425
+ await event.phase("cache.hit", { installerPath });
426
+ }
427
+ else {
428
+ await event.phase("cache.miss", { installerPath });
429
+ console.log(`Downloading Tapi Studio ${manifest.version}...`);
430
+ await event.phase("download.start", {
431
+ url: manifest.url,
432
+ destination: installerPath,
433
+ expectedSha256: manifest.sha256,
434
+ });
435
+ await downloadAndVerify(manifest.url, installerPath, manifest.sha256);
436
+ await event.phase("download.verified", {
437
+ installerPath,
438
+ expectedSha256: manifest.sha256,
439
+ });
440
+ }
441
+ if (options.downloadOnly) {
442
+ console.log(`Downloaded installer: ${installerPath}`);
443
+ await event.finish(true, {
444
+ installerPath,
445
+ downloadOnly: true,
446
+ });
447
+ return 0;
448
+ }
449
+ const installerArgs = options.silent ? ["/S"] : [];
450
+ console.log(`Starting Tapi Studio installer: ${installerPath}`);
451
+ await event.phase("installer.start", {
452
+ installerPath,
453
+ args: installerArgs,
454
+ });
455
+ await runProcess(installerPath, installerArgs);
456
+ console.log("Tapi Studio installer finished.");
457
+ await event.finish(true, {
458
+ installerPath,
459
+ downloadOnly: false,
460
+ });
461
+ return 0;
462
+ }
463
+ catch (error) {
464
+ await event.finish(false, {
465
+ error: errorDetails(error),
466
+ });
467
+ throw error;
468
+ }
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
+ }
197
490
  }
198
491
  else {
199
- console.log(`Downloading Tapi Studio ${manifest.version}...`);
200
- await downloadAndVerify(manifest.url, installerPath, manifest.sha256);
492
+ await event.phase("auth.cache.refresh.miss", {
493
+ authCachePath: getStudioAuthCachePath(),
494
+ });
201
495
  }
202
- if (options.downloadOnly) {
203
- console.log(`Downloaded installer: ${installerPath}`);
204
- return 0;
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.");
205
503
  }
206
- const installerArgs = options.silent ? ["/S"] : [];
207
- console.log(`Starting Tapi Studio installer: ${installerPath}`);
208
- await runProcess(installerPath, installerArgs);
209
- console.log("Tapi Studio installer finished.");
210
- return 0;
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;
211
512
  }
212
513
  async function openStudio(options) {
213
514
  ensureWindowsHost();
@@ -230,6 +531,7 @@ async function runDoctor(options) {
230
531
  console.log(`Tapi SDK: ${sdkVersion}`);
231
532
  console.log(`Node: ${process.version}`);
232
533
  console.log(`Platform: ${process.platform}/${process.arch}`);
534
+ console.log(`Studio API: ${options.apiBaseUrl}`);
233
535
  console.log(`Studio channel: ${options.channel}`);
234
536
  console.log(`Studio manifest: ${options.manifestUrl}`);
235
537
  console.log(`Studio cache: ${options.cacheDir}`);
@@ -246,6 +548,18 @@ async function runDoctor(options) {
246
548
  }
247
549
  return 0;
248
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
+ }
249
563
  async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
250
564
  const response = await fetchImpl(manifestUrl, {
251
565
  headers: {
@@ -257,6 +571,37 @@ async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
257
571
  }
258
572
  return validateStudioManifest(await response.json());
259
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
+ }
260
605
  async function downloadAndVerify(url, destination, expectedSha256) {
261
606
  const partialPath = `${destination}.partial`;
262
607
  await downloadFile(url, partialPath);
@@ -278,6 +623,198 @@ async function downloadFile(url, destination) {
278
623
  }
279
624
  await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
280
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
+ }
281
818
  async function hasVerifiedCachedInstaller(path, expectedSha256) {
282
819
  if (!existsSync(path)) {
283
820
  return false;
@@ -323,6 +860,34 @@ function cachedInstallerName(manifest) {
323
860
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiStudioSetup-${manifest.version}.exe`;
324
861
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
325
862
  }
863
+ function installEventOptions(options) {
864
+ return {
865
+ channel: options.channel,
866
+ apiBaseUrl: options.apiBaseUrl,
867
+ manifestUrl: options.manifestUrl,
868
+ manifestUrlOverride: options.manifestUrlOverride,
869
+ cacheDir: options.cacheDir,
870
+ downloadOnly: options.downloadOnly,
871
+ silent: options.silent,
872
+ exePath: options.exePath,
873
+ installTokenProvided: Boolean(options.installToken),
874
+ };
875
+ }
876
+ function manifestEventSummary(manifest) {
877
+ return {
878
+ product: manifest.product,
879
+ version: manifest.version,
880
+ channel: manifest.channel,
881
+ platform: manifest.platform,
882
+ artifactName: manifest.artifactName,
883
+ url: manifest.url,
884
+ sha256: manifest.sha256,
885
+ sizeBytes: manifest.sizeBytes,
886
+ minSdkVersion: manifest.minSdkVersion,
887
+ commitShort: manifest.commitShort,
888
+ bundledService: manifest.bundledService,
889
+ };
890
+ }
326
891
  function parseChannel(value) {
327
892
  if (value === "pilot" || value === "stable" || value === "nightly") {
328
893
  return value;
@@ -409,7 +974,7 @@ function printHelp() {
409
974
  console.log(`Tapi CLI
410
975
 
411
976
  Usage:
412
- tapi studio install [--channel pilot] [--manifest URL]
977
+ tapi studio install [--channel pilot] [--api-base-url URL]
413
978
  tapi studio open
414
979
  tapi studio doctor
415
980
  tapi doctor
@@ -430,17 +995,63 @@ Usage:
430
995
  tapi studio doctor [options]
431
996
 
432
997
  Options:
433
- --channel <name> Release channel: pilot, stable, or nightly
434
- --manifest <url> Exact release manifest URL
435
- --cache-dir <path> Installer download cache directory
436
- --download-only Download and verify without running the installer
437
- --silent Run the NSIS installer with /S
438
- --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
439
1007
  `);
440
1008
  }
441
1009
  function formatError(error) {
442
1010
  return error instanceof Error ? error.message : String(error);
443
1011
  }
1012
+ function errorDetails(error) {
1013
+ if (error instanceof Error) {
1014
+ const details = {
1015
+ name: error.name,
1016
+ message: error.message,
1017
+ stack: error.stack,
1018
+ };
1019
+ const cause = error.cause;
1020
+ if (cause) {
1021
+ details.cause = serializeError(cause);
1022
+ }
1023
+ return details;
1024
+ }
1025
+ return {
1026
+ message: String(error),
1027
+ };
1028
+ }
1029
+ function serializeError(error) {
1030
+ if (error instanceof Error) {
1031
+ const record = error;
1032
+ const details = {
1033
+ name: error.name,
1034
+ message: error.message,
1035
+ };
1036
+ for (const key of ["code", "errno", "syscall", "address", "port"]) {
1037
+ const value = record[key];
1038
+ if (value !== undefined) {
1039
+ details[key] = value;
1040
+ }
1041
+ }
1042
+ const cause = error.cause;
1043
+ if (cause) {
1044
+ details.cause = serializeError(cause);
1045
+ }
1046
+ return details;
1047
+ }
1048
+ if (error && typeof error === "object") {
1049
+ return Object.fromEntries(Object.entries(error).filter(([, value]) => value !== undefined));
1050
+ }
1051
+ return {
1052
+ message: String(error),
1053
+ };
1054
+ }
444
1055
  function isCliEntrypoint() {
445
1056
  const invokedPath = process.argv[1];
446
1057
  return Boolean(invokedPath && resolve(invokedPath) === fileURLToPath(import.meta.url));
package/dist/types.d.ts CHANGED
@@ -31,7 +31,10 @@ export interface TapiRunner {
31
31
  }
32
32
  export interface RuntimeRequirements {
33
33
  daemonRequired: boolean;
34
+ serviceRequired?: boolean;
34
35
  localControlUrl?: string;
36
+ serviceName?: string;
37
+ serviceVersion?: string;
35
38
  windowsServiceName?: string;
36
39
  [key: string]: unknown;
37
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",