@tapi-dev/sdk 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
+ import { createServer as createNetServer } from "node:net";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
  import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
6
7
  import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
7
8
  import { homedir } from "node:os";
8
- import { basename, join, resolve } from "node:path";
9
+ import { basename, dirname, join, resolve } from "node:path";
9
10
  import { performance } from "node:perf_hooks";
10
11
  import { Readable } from "node:stream";
11
12
  import { pipeline } from "node:stream/promises";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import { TapiClient } from "./index.js";
15
+ import { loadWorkspace, normalizeProjectValue, writeWorkspaceConfig, } from "./workspace.js";
14
16
  const DEFAULT_DOWNLOADS_BASE_URL = "https://d4xaf52nfwiok.cloudfront.net";
15
17
  const DEFAULT_STUDIO_API_BASE_URL = "https://determined-motivation-production.up.railway.app";
16
18
  const DEFAULT_STUDIO_AUTH_HTML_URL = "https://rsarlong-1f92fd.gitlab.io/auth.html";
@@ -47,6 +49,27 @@ export async function runCli(argv = process.argv.slice(2)) {
47
49
  }
48
50
  return runDoctor(parseStudioOptions([subcommand, ...rest].filter(Boolean)));
49
51
  }
52
+ if (command === "init" || command === "link") {
53
+ if (hasHelpFlag([subcommand, ...rest])) {
54
+ printWorkspaceHelp(command);
55
+ return 0;
56
+ }
57
+ return writeWorkspace(command, [subcommand, ...rest].filter(Boolean));
58
+ }
59
+ if (command === "service") {
60
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h" || hasHelpFlag(rest)) {
61
+ printServiceHelp();
62
+ return 0;
63
+ }
64
+ return runServiceCommand(subcommand, rest);
65
+ }
66
+ if (command === "publish") {
67
+ if (hasHelpFlag([subcommand, ...rest])) {
68
+ printPublishHelp();
69
+ return 0;
70
+ }
71
+ return publishLocalApis([subcommand, ...rest].filter(Boolean));
72
+ }
50
73
  if (command === "apis") {
51
74
  if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h" || hasHelpFlag(rest)) {
52
75
  printApisHelp();
@@ -55,6 +78,12 @@ export async function runCli(argv = process.argv.slice(2)) {
55
78
  if (subcommand === "describe") {
56
79
  return describeApiOperation(rest);
57
80
  }
81
+ if (subcommand === "sync") {
82
+ return syncApiCatalog(rest);
83
+ }
84
+ if (subcommand === "generate") {
85
+ return generateApiClient(rest);
86
+ }
58
87
  console.error(`Unknown apis command: ${subcommand}`);
59
88
  printApisHelp();
60
89
  return 1;
@@ -64,7 +93,10 @@ export async function runCli(argv = process.argv.slice(2)) {
64
93
  printHelp();
65
94
  return 1;
66
95
  }
67
- if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
96
+ if (!subcommand) {
97
+ return openStudio(parseStudioOptions([]));
98
+ }
99
+ if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
68
100
  printStudioHelp();
69
101
  return 0;
70
102
  }
@@ -88,6 +120,8 @@ export async function runCli(argv = process.argv.slice(2)) {
88
120
  }
89
121
  export function parseStudioOptions(args) {
90
122
  const raw = {};
123
+ let workspaceSearchRoot;
124
+ let skipWorkspace = false;
91
125
  for (let index = 0; index < args.length; index += 1) {
92
126
  const arg = args[index];
93
127
  if (!arg) {
@@ -121,6 +155,34 @@ export function parseStudioOptions(args) {
121
155
  raw.apiBaseUrl = arg.slice("--server=".length);
122
156
  continue;
123
157
  }
158
+ if (arg === "--workspace") {
159
+ workspaceSearchRoot = requireOptionValue(args, ++index, "--workspace");
160
+ continue;
161
+ }
162
+ if (arg.startsWith("--workspace=")) {
163
+ workspaceSearchRoot = arg.slice("--workspace=".length);
164
+ continue;
165
+ }
166
+ if (arg === "--no-workspace") {
167
+ skipWorkspace = true;
168
+ continue;
169
+ }
170
+ if (arg === "--project") {
171
+ raw.projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
172
+ continue;
173
+ }
174
+ if (arg.startsWith("--project=")) {
175
+ raw.projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
176
+ continue;
177
+ }
178
+ if (arg === "--project-slug") {
179
+ raw.projectSlug = normalizeProjectValue(requireOptionValue(args, ++index, "--project-slug"), "projectSlug");
180
+ continue;
181
+ }
182
+ if (arg.startsWith("--project-slug=")) {
183
+ raw.projectSlug = normalizeProjectValue(arg.slice("--project-slug=".length), "projectSlug");
184
+ continue;
185
+ }
124
186
  if (arg === "--install-token") {
125
187
  raw.installToken = requireOptionValue(args, ++index, "--install-token");
126
188
  continue;
@@ -158,17 +220,21 @@ export function parseStudioOptions(args) {
158
220
  }
159
221
  throw new Error(`Unknown option: ${arg}`);
160
222
  }
223
+ const workspace = skipWorkspace ? undefined : loadWorkspace(workspaceSearchRoot ?? process.cwd());
161
224
  const channel = raw.channel ?? parseChannel(envString("TAPI_STUDIO_CHANNEL") ?? DEFAULT_CHANNEL);
162
225
  const apiBaseUrl = normalizeHttpUrl(raw.apiBaseUrl
163
226
  ?? envString("TAPI_STUDIO_API_BASE_URL")
164
227
  ?? envString("TAPI_BASE_URL")
165
228
  ?? envString("TAPI_STUDIO_SERVER_URL")
229
+ ?? workspace?.apiBaseUrl
166
230
  ?? DEFAULT_STUDIO_API_BASE_URL, "Studio API base URL");
167
231
  const downloadsBaseUrl = normalizeHttpUrl(envString("TAPI_DOWNLOADS_BASE_URL") ?? DEFAULT_DOWNLOADS_BASE_URL, "TAPI_DOWNLOADS_BASE_URL");
168
232
  const explicitManifestUrl = raw.manifestUrlOverride ?? envString("TAPI_STUDIO_MANIFEST_URL");
169
233
  const manifestUrl = explicitManifestUrl
170
234
  ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL")
171
235
  : `${downloadsBaseUrl.replace(/\/+$/, "")}/studio/channels/${channel}/latest.json`;
236
+ const projectId = raw.projectId ?? envString("TAPI_PROJECT_ID") ?? workspace?.projectId;
237
+ const projectSlug = raw.projectSlug ?? envString("TAPI_PROJECT_SLUG") ?? workspace?.projectSlug;
172
238
  return {
173
239
  channel,
174
240
  apiBaseUrl,
@@ -179,13 +245,19 @@ export function parseStudioOptions(args) {
179
245
  silent: raw.silent ?? false,
180
246
  exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
181
247
  installToken: raw.installToken ?? envString("TAPI_STUDIO_INSTALL_TOKEN"),
248
+ workspace,
249
+ workspaceRoot: workspace?.root,
250
+ projectId,
251
+ projectSlug,
252
+ workspaceMode: Boolean(projectId || workspace),
182
253
  };
183
254
  }
184
255
  function parseApiOptions(args) {
185
256
  let operation = "";
186
- let apiBaseUrl = envString("TAPI_BASE_URL") || DEFAULT_STUDIO_API_BASE_URL;
257
+ const workspace = loadWorkspace();
258
+ let apiBaseUrl = envString("TAPI_BASE_URL") || workspace?.apiBaseUrl || DEFAULT_STUDIO_API_BASE_URL;
187
259
  let apiKey = envString("TAPI_API_KEY") || "";
188
- let appId = envString("TAPI_APP_ID");
260
+ let projectId = envString("TAPI_PROJECT_ID") || workspace?.projectId || "";
189
261
  for (let index = 0; index < args.length; index += 1) {
190
262
  const arg = args[index];
191
263
  if (!arg)
@@ -210,12 +282,12 @@ function parseApiOptions(args) {
210
282
  apiKey = arg.slice("--api-key=".length);
211
283
  continue;
212
284
  }
213
- if (arg === "--app") {
214
- appId = requireOptionValue(args, ++index, "--app");
285
+ if (arg === "--project") {
286
+ projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
215
287
  continue;
216
288
  }
217
- if (arg.startsWith("--app=")) {
218
- appId = arg.slice("--app=".length);
289
+ if (arg.startsWith("--project=")) {
290
+ projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
219
291
  continue;
220
292
  }
221
293
  if (arg.startsWith("--")) {
@@ -233,12 +305,15 @@ function parseApiOptions(args) {
233
305
  if (!apiKey) {
234
306
  throw new Error("apis describe requires --api-key or TAPI_API_KEY.");
235
307
  }
308
+ if (!projectId) {
309
+ throw new Error("apis describe requires --project, TAPI_PROJECT_ID, or .tapi/project.json.");
310
+ }
236
311
  return {
237
312
  operation,
238
313
  options: {
239
314
  apiBaseUrl: normalizeHttpUrl(apiBaseUrl, "Tapi API base URL"),
240
315
  apiKey,
241
- appId,
316
+ projectId,
242
317
  },
243
318
  };
244
319
  }
@@ -248,7 +323,7 @@ async function describeApiOperation(args) {
248
323
  const client = new TapiClient({
249
324
  baseUrl: options.apiBaseUrl,
250
325
  apiKey: options.apiKey,
251
- appId: options.appId,
326
+ projectId: options.projectId,
252
327
  });
253
328
  const description = await client.websiteApis.describe(operation);
254
329
  console.log(JSON.stringify(description, null, 2));
@@ -259,6 +334,596 @@ async function describeApiOperation(args) {
259
334
  return 1;
260
335
  }
261
336
  }
337
+ function parseApiWorkspaceOptions(args) {
338
+ const workspace = loadWorkspace();
339
+ let apiBaseUrl = envString("TAPI_BASE_URL") || workspace?.apiBaseUrl || DEFAULT_STUDIO_API_BASE_URL;
340
+ let apiKey = envString("TAPI_API_KEY") || "";
341
+ let projectId = envString("TAPI_PROJECT_ID") || workspace?.projectId || "";
342
+ let catalogPath = workspace?.config.generated?.catalog || ".tapi/generated/catalog.json";
343
+ let typescriptPath = workspace?.config.generated?.typescript || "src/tapi.generated.ts";
344
+ for (let index = 0; index < args.length; index += 1) {
345
+ const arg = args[index];
346
+ if (!arg)
347
+ continue;
348
+ if (arg === "--api-base-url" || arg === "--server") {
349
+ apiBaseUrl = requireOptionValue(args, ++index, arg);
350
+ continue;
351
+ }
352
+ if (arg.startsWith("--api-base-url=")) {
353
+ apiBaseUrl = arg.slice("--api-base-url=".length);
354
+ continue;
355
+ }
356
+ if (arg.startsWith("--server=")) {
357
+ apiBaseUrl = arg.slice("--server=".length);
358
+ continue;
359
+ }
360
+ if (arg === "--api-key") {
361
+ apiKey = requireOptionValue(args, ++index, "--api-key");
362
+ continue;
363
+ }
364
+ if (arg.startsWith("--api-key=")) {
365
+ apiKey = arg.slice("--api-key=".length);
366
+ continue;
367
+ }
368
+ if (arg === "--project") {
369
+ projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
370
+ continue;
371
+ }
372
+ if (arg.startsWith("--project=")) {
373
+ projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
374
+ continue;
375
+ }
376
+ if (arg === "--catalog") {
377
+ catalogPath = requireOptionValue(args, ++index, "--catalog");
378
+ continue;
379
+ }
380
+ if (arg.startsWith("--catalog=")) {
381
+ catalogPath = arg.slice("--catalog=".length);
382
+ continue;
383
+ }
384
+ if (arg === "--out") {
385
+ typescriptPath = requireOptionValue(args, ++index, "--out");
386
+ continue;
387
+ }
388
+ if (arg.startsWith("--out=")) {
389
+ typescriptPath = arg.slice("--out=".length);
390
+ continue;
391
+ }
392
+ throw new Error(`Unknown apis option: ${arg}`);
393
+ }
394
+ if (!apiKey) {
395
+ throw new Error("apis command requires --api-key or TAPI_API_KEY.");
396
+ }
397
+ if (!projectId) {
398
+ throw new Error("apis command requires --project, TAPI_PROJECT_ID, or .tapi/project.json.");
399
+ }
400
+ const root = workspace?.root || process.cwd();
401
+ return {
402
+ apiBaseUrl: normalizeHttpUrl(apiBaseUrl, "Tapi API base URL"),
403
+ apiKey,
404
+ projectId,
405
+ catalogPath: resolve(root, catalogPath),
406
+ typescriptPath: resolve(root, typescriptPath),
407
+ };
408
+ }
409
+ async function syncApiCatalog(args) {
410
+ try {
411
+ const options = parseApiWorkspaceOptions(args);
412
+ const catalog = await fetchApiCatalog(options);
413
+ await writeJsonFile(options.catalogPath, catalog);
414
+ console.log(`Synced Tapi API catalog: ${options.catalogPath}`);
415
+ return 0;
416
+ }
417
+ catch (error) {
418
+ console.error(formatError(error));
419
+ return 1;
420
+ }
421
+ }
422
+ async function generateApiClient(args) {
423
+ try {
424
+ const options = parseApiWorkspaceOptions(args);
425
+ const catalog = await fetchApiCatalog(options);
426
+ await writeJsonFile(options.catalogPath, catalog);
427
+ await writeTextFile(options.typescriptPath, renderGeneratedApiClient(catalog));
428
+ console.log(`Generated Tapi API client: ${options.typescriptPath}`);
429
+ return 0;
430
+ }
431
+ catch (error) {
432
+ console.error(formatError(error));
433
+ return 1;
434
+ }
435
+ }
436
+ async function fetchApiCatalog(options) {
437
+ const client = new TapiClient({
438
+ baseUrl: options.apiBaseUrl,
439
+ apiKey: options.apiKey,
440
+ projectId: options.projectId,
441
+ });
442
+ return client.catalog.get();
443
+ }
444
+ async function writeJsonFile(path, payload) {
445
+ await writeTextFile(path, `${JSON.stringify(payload, null, 2)}\n`);
446
+ }
447
+ async function writeTextFile(path, content) {
448
+ await mkdir(dirname(path), { recursive: true });
449
+ await writeFile(path, content, "utf8");
450
+ }
451
+ function renderGeneratedApiClient(catalog) {
452
+ const namespaces = new Map();
453
+ for (const api of catalog.apis || []) {
454
+ const namespace = safeIdentifier(api.name || "api");
455
+ for (const request of api.requests || []) {
456
+ const key = String(request.operation || request.sdkName || request.key || "").trim();
457
+ if (!key)
458
+ continue;
459
+ const operationName = safeIdentifier(key);
460
+ const operation = `${api.name}.${key}`;
461
+ const items = namespaces.get(namespace) ?? [];
462
+ items.push({ key: operation, operationName });
463
+ namespaces.set(namespace, items);
464
+ }
465
+ }
466
+ const namespaceBlocks = [...namespaces.entries()].map(([namespace, operations]) => {
467
+ const lines = operations.map(({ key, operationName }) => ` ${operationName}: (inputs: Record<string, unknown> = {}, options: GeneratedRunOptions = {}) => client.websiteApis.run(${JSON.stringify(key)}, { ...options, inputs }),`);
468
+ return ` ${namespace}: {\n${lines.join("\n")}\n },`;
469
+ });
470
+ return `/* Generated by Tapi. Do not edit by hand. */
471
+ import { TapiClient, type TapiClientOptions, type RuntimeRunOptions } from "@tapi-dev/sdk";
472
+
473
+ export interface GeneratedRunOptions {
474
+ runtime?: RuntimeRunOptions;
475
+ priority?: number;
476
+ runnerId?: string;
477
+ idempotencyKey?: string;
478
+ site?: string;
479
+ }
480
+
481
+ export function createTapiGeneratedClient(options: TapiClientOptions) {
482
+ const client = new TapiClient(options);
483
+ return {
484
+ ${namespaceBlocks.join("\n")}
485
+ };
486
+ }
487
+ `;
488
+ }
489
+ function safeIdentifier(value) {
490
+ const cleaned = String(value || "api")
491
+ .replace(/[^a-zA-Z0-9_$]+/g, "_")
492
+ .replace(/^([^a-zA-Z_$])/, "_$1");
493
+ return cleaned || "api";
494
+ }
495
+ function isRecord(value) {
496
+ return typeof value === "object" && value !== null && !Array.isArray(value);
497
+ }
498
+ function parseWorkspaceOptions(command, args) {
499
+ const raw = {
500
+ force: command === "link",
501
+ };
502
+ for (let index = 0; index < args.length; index += 1) {
503
+ const arg = args[index];
504
+ if (!arg) {
505
+ continue;
506
+ }
507
+ if (arg === "--project") {
508
+ raw.projectId = normalizeProjectValue(requireOptionValue(args, ++index, "--project"), "project");
509
+ continue;
510
+ }
511
+ if (arg.startsWith("--project=")) {
512
+ raw.projectId = normalizeProjectValue(arg.slice("--project=".length), "project");
513
+ continue;
514
+ }
515
+ if (arg === "--project-slug") {
516
+ raw.projectSlug = normalizeProjectValue(requireOptionValue(args, ++index, "--project-slug"), "projectSlug");
517
+ continue;
518
+ }
519
+ if (arg.startsWith("--project-slug=")) {
520
+ raw.projectSlug = normalizeProjectValue(arg.slice("--project-slug=".length), "projectSlug");
521
+ continue;
522
+ }
523
+ if (arg === "--api-base-url" || arg === "--server") {
524
+ raw.apiBaseUrl = requireOptionValue(args, ++index, arg);
525
+ continue;
526
+ }
527
+ if (arg.startsWith("--api-base-url=")) {
528
+ raw.apiBaseUrl = arg.slice("--api-base-url=".length);
529
+ continue;
530
+ }
531
+ if (arg.startsWith("--server=")) {
532
+ raw.apiBaseUrl = arg.slice("--server=".length);
533
+ continue;
534
+ }
535
+ if (arg === "--root") {
536
+ raw.root = resolve(requireOptionValue(args, ++index, "--root"));
537
+ continue;
538
+ }
539
+ if (arg.startsWith("--root=")) {
540
+ raw.root = resolve(arg.slice("--root=".length));
541
+ continue;
542
+ }
543
+ if (arg === "--force") {
544
+ raw.force = true;
545
+ continue;
546
+ }
547
+ if (arg.startsWith("--")) {
548
+ throw new Error(`Unknown ${command} option: ${arg}`);
549
+ }
550
+ if (!raw.projectId) {
551
+ raw.projectId = normalizeProjectValue(arg, "project");
552
+ continue;
553
+ }
554
+ throw new Error(`Unexpected ${command} argument: ${arg}`);
555
+ }
556
+ if (!raw.projectId) {
557
+ throw new Error(`tapi ${command} requires --project <id>.`);
558
+ }
559
+ return {
560
+ force: raw.force ?? false,
561
+ projectId: raw.projectId,
562
+ projectSlug: raw.projectSlug,
563
+ apiBaseUrl: raw.apiBaseUrl ? normalizeHttpUrl(raw.apiBaseUrl, "Tapi API base URL") : undefined,
564
+ root: raw.root,
565
+ };
566
+ }
567
+ async function writeWorkspace(command, args) {
568
+ try {
569
+ const options = parseWorkspaceOptions(command, args);
570
+ const workspace = await writeWorkspaceConfig(options);
571
+ console.log(`Tapi workspace ${command === "init" ? "initialized" : "linked"}: ${workspace.configPath}`);
572
+ console.log(`Project: ${workspace.projectId}`);
573
+ if (workspace.apiBaseUrl) {
574
+ console.log(`API: ${workspace.apiBaseUrl}`);
575
+ }
576
+ return 0;
577
+ }
578
+ catch (error) {
579
+ console.error(formatError(error));
580
+ return 1;
581
+ }
582
+ }
583
+ async function runServiceCommand(action, args = []) {
584
+ try {
585
+ ensureWindowsHost();
586
+ const normalized = action.toLowerCase();
587
+ if (!["status", "install", "start", "stop", "restart", "repair"].includes(normalized)) {
588
+ throw new Error(`Unknown service command: ${action}`);
589
+ }
590
+ if (normalized === "install") {
591
+ return installService(parseStudioOptions(args));
592
+ }
593
+ if (normalized === "repair" && args.length > 0) {
594
+ return repairService(parseStudioOptions(args));
595
+ }
596
+ if (args.length > 0) {
597
+ throw new Error(`tapi service ${normalized} does not accept options.`);
598
+ }
599
+ if (normalized === "repair") {
600
+ return repairService(parseStudioOptions([]));
601
+ }
602
+ const command = servicePowerShell(normalized);
603
+ const output = await runProcessCapture("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]);
604
+ if (output.trim()) {
605
+ console.log(output.trim());
606
+ }
607
+ return 0;
608
+ }
609
+ catch (error) {
610
+ console.error(formatError(error));
611
+ return 1;
612
+ }
613
+ }
614
+ async function repairService(options) {
615
+ const status = await getServiceStatus();
616
+ if (!status.installed) {
617
+ return installService(options);
618
+ }
619
+ return runServiceCommand("restart");
620
+ }
621
+ function servicePowerShell(action) {
622
+ const serviceName = "tapi-service";
623
+ const status = `$svc = Get-Service -Name '${serviceName}' -ErrorAction SilentlyContinue; if ($null -eq $svc) { [pscustomobject]@{ installed = $false; name = '${serviceName}'; status = 'not_installed' } | ConvertTo-Json -Compress; exit 0 }; [pscustomobject]@{ installed = $true; name = $svc.Name; status = $svc.Status.ToString() } | ConvertTo-Json -Compress`;
624
+ if (action === "status") {
625
+ return status;
626
+ }
627
+ if (action === "start") {
628
+ return `Start-Service -Name '${serviceName}' -ErrorAction Stop; ${status}`;
629
+ }
630
+ if (action === "stop") {
631
+ return `Stop-Service -Name '${serviceName}' -ErrorAction Stop; ${status}`;
632
+ }
633
+ if (action === "restart" || action === "repair") {
634
+ return `Restart-Service -Name '${serviceName}' -Force -ErrorAction Stop; ${status}`;
635
+ }
636
+ return status;
637
+ }
638
+ async function getServiceStatus() {
639
+ const output = await runProcessCapture("powershell.exe", [
640
+ "-NoProfile",
641
+ "-ExecutionPolicy",
642
+ "Bypass",
643
+ "-Command",
644
+ servicePowerShell("status"),
645
+ ]);
646
+ try {
647
+ const payload = JSON.parse(output.trim());
648
+ return {
649
+ installed: Boolean(payload.installed),
650
+ name: typeof payload.name === "string" ? payload.name : "tapi-service",
651
+ status: typeof payload.status === "string" ? payload.status : "unknown",
652
+ };
653
+ }
654
+ catch {
655
+ return { installed: false, name: "tapi-service", status: "unknown" };
656
+ }
657
+ }
658
+ async function ensureServiceReadyForStudio(options) {
659
+ const status = await getServiceStatus();
660
+ if (!status.installed) {
661
+ console.log("Tapi Service is required and is not installed. Installing it now...");
662
+ const code = await installService(options);
663
+ if (code !== 0) {
664
+ throw new Error("Tapi Service install failed.");
665
+ }
666
+ return;
667
+ }
668
+ if (status.status !== "Running") {
669
+ console.log("Starting Tapi Service...");
670
+ const output = await runProcessCapture("powershell.exe", [
671
+ "-NoProfile",
672
+ "-ExecutionPolicy",
673
+ "Bypass",
674
+ "-Command",
675
+ servicePowerShell("start"),
676
+ ]);
677
+ if (output.trim()) {
678
+ console.log(output.trim());
679
+ }
680
+ }
681
+ }
682
+ async function installService(options) {
683
+ const event = await createCliWideEvent("tapi_cli.service_install", {
684
+ sdk_version: sdkVersion,
685
+ options: installEventOptions(options),
686
+ });
687
+ try {
688
+ await event.phase("host.check", {
689
+ platform: process.platform,
690
+ arch: process.arch,
691
+ });
692
+ ensureWindowsHost();
693
+ let installToken = options.installToken?.trim();
694
+ if (!installToken) {
695
+ console.log("Checking Tapi Service install approval...");
696
+ await event.phase("auth.install_token.request.start", {
697
+ apiBaseUrl: options.apiBaseUrl,
698
+ channel: options.channel,
699
+ });
700
+ installToken = await obtainStudioInstallToken(options, event);
701
+ await event.phase("auth.install_token.request.success", {
702
+ apiBaseUrl: options.apiBaseUrl,
703
+ channel: options.channel,
704
+ });
705
+ }
706
+ console.log(`Fetching Tapi Service ${options.channel} manifest...`);
707
+ await event.phase("service_manifest.fetch.start", {
708
+ apiBaseUrl: options.apiBaseUrl,
709
+ channel: options.channel,
710
+ });
711
+ const manifest = await fetchProtectedServiceManifest(options.apiBaseUrl, options.channel, installToken);
712
+ await event.phase("service_manifest.fetch.success", {
713
+ manifest: serviceManifestEventSummary(manifest),
714
+ });
715
+ ensureCompatibleServiceManifest(manifest);
716
+ const serviceCacheDir = getDefaultServiceCacheDir();
717
+ const downloadsDir = join(serviceCacheDir, "downloads");
718
+ const releasesDir = join(serviceCacheDir, "releases");
719
+ await mkdir(downloadsDir, { recursive: true });
720
+ await mkdir(releasesDir, { recursive: true });
721
+ const zipPath = join(downloadsDir, cachedServiceArtifactName(manifest));
722
+ const verified = await hasVerifiedCachedInstaller(zipPath, manifest.sha256);
723
+ if (verified) {
724
+ console.log(`Using cached Tapi Service artifact: ${zipPath}`);
725
+ await event.phase("cache.hit", { zipPath });
726
+ }
727
+ else {
728
+ console.log(`Downloading Tapi Service ${manifest.version}...`);
729
+ await event.phase("download.start", {
730
+ url: manifest.url,
731
+ destination: zipPath,
732
+ expectedSha256: manifest.sha256,
733
+ });
734
+ await downloadAndVerify(manifest.url, zipPath, manifest.sha256, "Tapi Service artifact");
735
+ await event.phase("download.verified", {
736
+ zipPath,
737
+ expectedSha256: manifest.sha256,
738
+ });
739
+ }
740
+ if (options.downloadOnly) {
741
+ console.log(`Downloaded Tapi Service artifact: ${zipPath}`);
742
+ await event.finish(true, {
743
+ zipPath,
744
+ downloadOnly: true,
745
+ });
746
+ return 0;
747
+ }
748
+ const releaseDir = join(releasesDir, safePathSegment(manifest.version));
749
+ await extractZip(zipPath, releaseDir);
750
+ const installer = join(releaseDir, manifest.installer || "install_tapi_service.ps1");
751
+ const hostExe = join(releaseDir, manifest.serviceHostExecutable || "tapi-service-host.exe");
752
+ if (!existsSync(installer)) {
753
+ throw new Error(`Tapi Service installer was not found after extraction: ${installer}`);
754
+ }
755
+ if (!existsSync(hostExe)) {
756
+ throw new Error(`Tapi Service host executable was not found after extraction: ${hostExe}`);
757
+ }
758
+ console.log(`Installing Tapi Service ${manifest.version}...`);
759
+ await event.phase("service_installer.start", {
760
+ installer,
761
+ hostExe,
762
+ releaseDir,
763
+ });
764
+ await runProcess("powershell.exe", [
765
+ "-NoProfile",
766
+ "-ExecutionPolicy",
767
+ "Bypass",
768
+ "-File",
769
+ installer,
770
+ "-ExecutablePath",
771
+ hostExe,
772
+ "-WorkingDirectory",
773
+ releaseDir,
774
+ ]);
775
+ await event.finish(true, {
776
+ releaseDir,
777
+ version: manifest.version,
778
+ });
779
+ console.log("Tapi Service is installed and running.");
780
+ return 0;
781
+ }
782
+ catch (error) {
783
+ await event.finish(false, {
784
+ error: errorDetails(error),
785
+ });
786
+ throw error;
787
+ }
788
+ }
789
+ async function publishLocalApis(args) {
790
+ try {
791
+ const options = parseApiWorkspaceOptions(args);
792
+ const workspace = loadWorkspace();
793
+ const root = workspace?.root || process.cwd();
794
+ const sitemaps = await readLocalSitemaps(join(root, ".tapi", "sitemaps"));
795
+ const apis = await readLocalGeneratedApis(join(root, ".tapi", "apis"));
796
+ if (apis.length === 0) {
797
+ throw new Error("No local generated APIs found under .tapi/apis.");
798
+ }
799
+ let sitemapCount = 0;
800
+ for (const sitemap of sitemaps) {
801
+ const site = String(sitemap.site || "").trim();
802
+ if (!site || !isRecord(sitemap.siteMap)) {
803
+ continue;
804
+ }
805
+ await putJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/sitemaps/${encodeURIComponent(site)}?project=${encodeURIComponent(options.projectId)}`, { site_map: sitemap.siteMap }, options.apiKey, options.projectId);
806
+ sitemapCount += 1;
807
+ }
808
+ let contracts = 0;
809
+ let requests = 0;
810
+ for (const api of apis) {
811
+ const apiId = String(api.id || api.name || "").trim();
812
+ if (!apiId) {
813
+ continue;
814
+ }
815
+ const site = String(api.site || "").trim();
816
+ await putJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/contracts/${encodeURIComponent(apiId)}?project=${encodeURIComponent(options.projectId)}`, { generated_api: api }, options.apiKey, options.projectId);
817
+ contracts += 1;
818
+ const apiRequests = api.requests && typeof api.requests === "object" ? Object.values(api.requests) : [];
819
+ for (const request of apiRequests) {
820
+ if (!isRecord(request)) {
821
+ continue;
822
+ }
823
+ const requestId = String(request.id || request.key || "").trim();
824
+ const status = String(request.status || "").trim();
825
+ if (!requestId || !["ready", "published"].includes(status)) {
826
+ continue;
827
+ }
828
+ await postJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/contracts/${encodeURIComponent(apiId)}/requests/${encodeURIComponent(requestId)}/publish?project=${encodeURIComponent(options.projectId)}`, { site }, options.apiKey, options.projectId);
829
+ requests += 1;
830
+ }
831
+ }
832
+ const releaseResponse = await postJson(`${options.apiBaseUrl.replace(/\/+$/, "")}/api/v1/generated-apis/releases?project=${encodeURIComponent(options.projectId)}`, { environment: "production" }, options.apiKey, options.projectId);
833
+ const release = isRecord(releaseResponse) && isRecord(releaseResponse.release) ? releaseResponse.release : {};
834
+ const releaseVersion = typeof release.version === "string" ? release.version : "unknown";
835
+ console.log(`Published ${requests} API request(s) from ${contracts} contract(s), synced ${sitemapCount} sitemap(s), activated release ${releaseVersion}.`);
836
+ return 0;
837
+ }
838
+ catch (error) {
839
+ console.error(formatError(error));
840
+ return 1;
841
+ }
842
+ }
843
+ async function readLocalSitemaps(root) {
844
+ const sitemaps = [];
845
+ if (!existsSync(root)) {
846
+ return sitemaps;
847
+ }
848
+ const entries = await readdir(root, { withFileTypes: true });
849
+ for (const entry of entries) {
850
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
851
+ continue;
852
+ }
853
+ const path = join(root, entry.name);
854
+ try {
855
+ const payload = JSON.parse(readFileSync(path, "utf8"));
856
+ if (!isRecord(payload) || !isRecord(payload.siteMap)) {
857
+ continue;
858
+ }
859
+ const site = String(payload.site || payload.siteMap.site || "").trim();
860
+ if (site) {
861
+ sitemaps.push({ site, siteMap: payload.siteMap });
862
+ }
863
+ }
864
+ catch {
865
+ continue;
866
+ }
867
+ }
868
+ return sitemaps;
869
+ }
870
+ async function readLocalGeneratedApis(root) {
871
+ const apis = [];
872
+ if (!existsSync(root)) {
873
+ return apis;
874
+ }
875
+ const entries = await readdir(root, { withFileTypes: true });
876
+ for (const entry of entries) {
877
+ const path = join(root, entry.name);
878
+ if (entry.isDirectory()) {
879
+ apis.push(...await readLocalGeneratedApis(path));
880
+ continue;
881
+ }
882
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
883
+ continue;
884
+ }
885
+ try {
886
+ const payload = JSON.parse(readFileSync(path, "utf8"));
887
+ if (isRecord(payload) && isRecord(payload.api)) {
888
+ apis.push(payload.api);
889
+ }
890
+ }
891
+ catch {
892
+ continue;
893
+ }
894
+ }
895
+ return apis;
896
+ }
897
+ async function putJson(url, body, apiKey, projectId) {
898
+ return requestJson("PUT", url, body, apiKey, projectId);
899
+ }
900
+ async function postJson(url, body, apiKey, projectId) {
901
+ return requestJson("POST", url, body, apiKey, projectId);
902
+ }
903
+ async function requestJson(method, url, body, apiKey, projectId) {
904
+ const response = await fetch(url, {
905
+ method,
906
+ headers: {
907
+ Authorization: `Bearer ${apiKey}`,
908
+ "Content-Type": "application/json",
909
+ "X-Tapi-Project": projectId,
910
+ },
911
+ body: JSON.stringify(body),
912
+ });
913
+ const text = await response.text();
914
+ if (!response.ok) {
915
+ throw new Error(`Tapi publish request failed: HTTP ${response.status} ${text}`);
916
+ }
917
+ if (!text.trim()) {
918
+ return {};
919
+ }
920
+ try {
921
+ return JSON.parse(text);
922
+ }
923
+ catch {
924
+ return {};
925
+ }
926
+ }
262
927
  export function getDefaultStudioCacheDir() {
263
928
  if (process.platform === "win32") {
264
929
  const localAppData = process.env.LOCALAPPDATA ??
@@ -267,6 +932,14 @@ export function getDefaultStudioCacheDir() {
267
932
  }
268
933
  return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "studio");
269
934
  }
935
+ export function getDefaultServiceCacheDir() {
936
+ if (process.platform === "win32") {
937
+ const localAppData = process.env.LOCALAPPDATA ??
938
+ (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
939
+ return join(localAppData, "Tapi", "Service");
940
+ }
941
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "service");
942
+ }
270
943
  export class CliWideEvent {
271
944
  filePath;
272
945
  startedAt = performance.now();
@@ -432,12 +1105,46 @@ export function validateStudioManifest(input) {
432
1105
  commitShort: optionalString(record, "commitShort"),
433
1106
  ref: optionalString(record, "ref"),
434
1107
  installerKind: optionalString(record, "installerKind"),
1108
+ serverExecutable: optionalString(record, "serverExecutable"),
435
1109
  };
436
1110
  if (!/^[a-f0-9]{64}$/i.test(manifest.sha256)) {
437
1111
  throw new Error("Studio manifest sha256 must be a 64-character hex digest.");
438
1112
  }
439
1113
  return manifest;
440
1114
  }
1115
+ export function validateServiceReleaseManifest(input) {
1116
+ if (!input || typeof input !== "object") {
1117
+ throw new Error("Tapi Service manifest response was not a JSON object.");
1118
+ }
1119
+ const record = input;
1120
+ const contents = Array.isArray(record.contents)
1121
+ ? record.contents.filter((item) => typeof item === "string")
1122
+ : undefined;
1123
+ const manifest = {
1124
+ product: optionalString(record, "product"),
1125
+ version: requiredString(record, "version"),
1126
+ channel: requiredString(record, "channel"),
1127
+ platform: requiredString(record, "platform"),
1128
+ artifactName: requiredString(record, "artifactName"),
1129
+ url: normalizeHttpUrl(requiredString(record, "url"), "Tapi Service manifest url"),
1130
+ sha256: requiredString(record, "sha256").toLowerCase(),
1131
+ sizeBytes: optionalNumber(record, "sizeBytes"),
1132
+ serviceHostExecutable: optionalString(record, "serviceHostExecutable"),
1133
+ workerExecutable: optionalString(record, "workerExecutable"),
1134
+ installer: optionalString(record, "installer"),
1135
+ uninstaller: optionalString(record, "uninstaller"),
1136
+ builtAt: optionalString(record, "builtAt"),
1137
+ commit: optionalString(record, "commit"),
1138
+ commitShort: optionalString(record, "commitShort"),
1139
+ ref: optionalString(record, "ref"),
1140
+ mobileChrome: isRecord(record.mobileChrome) ? record.mobileChrome : undefined,
1141
+ contents,
1142
+ };
1143
+ if (!/^[a-f0-9]{64}$/i.test(manifest.sha256)) {
1144
+ throw new Error("Tapi Service manifest sha256 must be a 64-character hex digest.");
1145
+ }
1146
+ return manifest;
1147
+ }
441
1148
  export function compareVersions(left, right) {
442
1149
  const leftParts = versionCore(left);
443
1150
  const rightParts = versionCore(right);
@@ -503,6 +1210,15 @@ async function installStudio(options) {
503
1210
  minSdkVersion: manifest.minSdkVersion,
504
1211
  platform: manifest.platform,
505
1212
  });
1213
+ if (isPortableStudioServerManifest(manifest)) {
1214
+ const serverExe = await installPortableStudioServer(options, manifest, event);
1215
+ await event.finish(true, {
1216
+ serverExe,
1217
+ downloadOnly: options.downloadOnly,
1218
+ installerKind: manifest.installerKind,
1219
+ });
1220
+ return 0;
1221
+ }
506
1222
  await mkdir(options.cacheDir, { recursive: true });
507
1223
  const installerPath = join(options.cacheDir, cachedInstallerName(manifest));
508
1224
  await event.phase("cache.check.start", {
@@ -602,6 +1318,17 @@ async function obtainStudioInstallToken(options, event) {
602
1318
  }
603
1319
  async function openStudio(options) {
604
1320
  ensureWindowsHost();
1321
+ if (options.downloadOnly) {
1322
+ throw new Error("--download-only is only supported with `tapi studio install`.");
1323
+ }
1324
+ await ensureServiceReadyForStudio(options);
1325
+ if (!options.exePath) {
1326
+ const portable = await ensurePortableStudioServer(options);
1327
+ if (portable) {
1328
+ await launchPortableStudioServer(portable.exePath, options);
1329
+ return 0;
1330
+ }
1331
+ }
605
1332
  const exePath = options.exePath ?? getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
606
1333
  if (!exePath || !existsSync(exePath)) {
607
1334
  console.error("Tapi Studio executable was not found.");
@@ -610,6 +1337,7 @@ async function openStudio(options) {
610
1337
  }
611
1338
  const child = spawn(exePath, [], {
612
1339
  detached: true,
1340
+ env: buildStudioLaunchEnv(options),
613
1341
  stdio: "ignore",
614
1342
  windowsHide: false,
615
1343
  });
@@ -617,6 +1345,135 @@ async function openStudio(options) {
617
1345
  console.log(`Opened Tapi Studio: ${exePath}`);
618
1346
  return 0;
619
1347
  }
1348
+ async function ensurePortableStudioServer(options) {
1349
+ const event = await createCliWideEvent("tapi_cli.studio_server_ensure", {
1350
+ sdk_version: sdkVersion,
1351
+ options: installEventOptions(options),
1352
+ });
1353
+ try {
1354
+ let installToken = options.installToken?.trim();
1355
+ if (!installToken) {
1356
+ await event.phase("auth.install_token.request.start", {
1357
+ apiBaseUrl: options.apiBaseUrl,
1358
+ channel: options.channel,
1359
+ });
1360
+ installToken = await obtainStudioInstallToken(options, event);
1361
+ await event.phase("auth.install_token.request.success", {
1362
+ apiBaseUrl: options.apiBaseUrl,
1363
+ channel: options.channel,
1364
+ });
1365
+ }
1366
+ await event.phase("manifest.fetch.start", {
1367
+ apiBaseUrl: options.apiBaseUrl,
1368
+ channel: options.channel,
1369
+ });
1370
+ const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
1371
+ ensureCompatibleManifest(manifest);
1372
+ await event.phase("manifest.fetch.success", {
1373
+ manifest: manifestEventSummary(manifest),
1374
+ });
1375
+ if (!isPortableStudioServerManifest(manifest)) {
1376
+ await event.finish(true, {
1377
+ installerKind: manifest.installerKind || "legacy",
1378
+ });
1379
+ return null;
1380
+ }
1381
+ const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest), manifest);
1382
+ if (existing) {
1383
+ await event.finish(true, {
1384
+ serverExe: existing,
1385
+ cacheHit: true,
1386
+ });
1387
+ return { exePath: existing, manifest };
1388
+ }
1389
+ const exePath = await installPortableStudioServer(options, manifest, event);
1390
+ await event.finish(true, {
1391
+ serverExe: exePath,
1392
+ cacheHit: false,
1393
+ });
1394
+ return { exePath, manifest };
1395
+ }
1396
+ catch (error) {
1397
+ await event.finish(false, {
1398
+ error: errorDetails(error),
1399
+ });
1400
+ throw error;
1401
+ }
1402
+ }
1403
+ async function installPortableStudioServer(options, manifest, event) {
1404
+ await mkdir(options.cacheDir, { recursive: true });
1405
+ const artifactPath = join(options.cacheDir, cachedInstallerName(manifest));
1406
+ const verified = await hasVerifiedCachedInstaller(artifactPath, manifest.sha256);
1407
+ if (verified) {
1408
+ console.log(`Using cached Tapi Studio server artifact: ${artifactPath}`);
1409
+ await event.phase("cache.hit", { artifactPath });
1410
+ }
1411
+ else {
1412
+ console.log(`Downloading Tapi Studio server ${manifest.version}...`);
1413
+ await event.phase("download.start", {
1414
+ url: manifest.url,
1415
+ destination: artifactPath,
1416
+ expectedSha256: manifest.sha256,
1417
+ });
1418
+ await downloadAndVerify(manifest.url, artifactPath, manifest.sha256, "Tapi Studio server artifact");
1419
+ await event.phase("download.verified", {
1420
+ artifactPath,
1421
+ expectedSha256: manifest.sha256,
1422
+ });
1423
+ }
1424
+ if (options.downloadOnly) {
1425
+ console.log(`Downloaded Tapi Studio server artifact: ${artifactPath}`);
1426
+ return artifactPath;
1427
+ }
1428
+ const releaseDir = portableStudioServerReleaseDir(manifest);
1429
+ await extractZip(artifactPath, releaseDir);
1430
+ const exePath = findPortableStudioServerExe(releaseDir, manifest);
1431
+ if (!exePath) {
1432
+ throw new Error(`Tapi Studio server executable was not found after extraction: ${releaseDir}`);
1433
+ }
1434
+ console.log(`Installed Tapi Studio server ${manifest.version}: ${releaseDir}`);
1435
+ return exePath;
1436
+ }
1437
+ async function launchPortableStudioServer(exePath, options) {
1438
+ const host = "127.0.0.1";
1439
+ const port = await chooseStudioPort();
1440
+ const env = buildStudioLaunchEnv(options);
1441
+ env.TAPI_RUNTIME_MODE = "installed";
1442
+ env.TAPI_RUNTIME_NAMESPACE = "installed";
1443
+ env.TAPI_RUNTIME_CONTROL_PORT = env.TAPI_RUNTIME_CONTROL_PORT || "8765";
1444
+ env.TAPI_PROCESS_ROLE = "studio_server";
1445
+ env.TAPI_STUDIO_OPEN_BROWSER = "0";
1446
+ env.TAPI_STUDIO_HOST = host;
1447
+ env.TAPI_STUDIO_PORT = String(port);
1448
+ env.TAPI_STUDIO_SERVER_PORT = String(port);
1449
+ const child = spawn(exePath, [], {
1450
+ detached: true,
1451
+ env,
1452
+ stdio: "ignore",
1453
+ windowsHide: true,
1454
+ });
1455
+ child.unref();
1456
+ const url = `http://${host}:${port}`;
1457
+ openBrowser(url);
1458
+ console.log(`Opened Tapi Studio: ${url}`);
1459
+ }
1460
+ async function chooseStudioPort(preferred = 18766) {
1461
+ for (let port = preferred; port < preferred + 25; port += 1) {
1462
+ if (await isPortAvailable(port)) {
1463
+ return port;
1464
+ }
1465
+ }
1466
+ throw new Error(`No available local Studio port found starting at ${preferred}.`);
1467
+ }
1468
+ async function isPortAvailable(port) {
1469
+ return await new Promise((resolvePromise) => {
1470
+ const server = createNetServer();
1471
+ server.once("error", () => resolvePromise(false));
1472
+ server.listen(port, "127.0.0.1", () => {
1473
+ server.close(() => resolvePromise(true));
1474
+ });
1475
+ });
1476
+ }
620
1477
  async function runDoctor(options) {
621
1478
  console.log(`Tapi SDK: ${sdkVersion}`);
622
1479
  console.log(`Node: ${process.version}`);
@@ -625,6 +1482,8 @@ async function runDoctor(options) {
625
1482
  console.log(`Studio channel: ${options.channel}`);
626
1483
  console.log(`Studio manifest: ${options.manifestUrl}`);
627
1484
  console.log(`Studio cache: ${options.cacheDir}`);
1485
+ console.log(`Workspace: ${options.workspaceRoot ?? "not found"}`);
1486
+ console.log(`Project: ${options.projectId ?? "not set"}`);
628
1487
  const exePath = options.exePath ?? getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
629
1488
  console.log(`Studio executable: ${exePath && existsSync(exePath) ? exePath : "not found"}`);
630
1489
  try {
@@ -638,6 +1497,27 @@ async function runDoctor(options) {
638
1497
  }
639
1498
  return 0;
640
1499
  }
1500
+ function buildStudioLaunchEnv(options) {
1501
+ const env = { ...process.env };
1502
+ env.TAPI_STUDIO_API_BASE_URL = options.apiBaseUrl;
1503
+ env.TAPI_BASE_URL = env.TAPI_BASE_URL || options.apiBaseUrl;
1504
+ if (options.workspaceRoot) {
1505
+ env.TAPI_WORKSPACE_ROOT = options.workspaceRoot;
1506
+ }
1507
+ if (options.workspace?.configPath) {
1508
+ env.TAPI_WORKSPACE_CONFIG = options.workspace.configPath;
1509
+ }
1510
+ if (options.projectId) {
1511
+ env.TAPI_PROJECT_ID = options.projectId;
1512
+ }
1513
+ if (options.projectSlug) {
1514
+ env.TAPI_PROJECT_SLUG = options.projectSlug;
1515
+ }
1516
+ if (options.workspaceMode) {
1517
+ env.TAPI_STUDIO_WORKSPACE_MODE = "1";
1518
+ }
1519
+ return env;
1520
+ }
641
1521
  async function fetchProtectedStudioManifest(apiBaseUrl, channel, installToken, fetchImpl = fetch) {
642
1522
  const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/studio/releases/${channel}`, {
643
1523
  headers: {
@@ -654,6 +1534,22 @@ async function fetchProtectedStudioManifest(apiBaseUrl, channel, installToken, f
654
1534
  }
655
1535
  return validateStudioManifest(responseBody);
656
1536
  }
1537
+ async function fetchProtectedServiceManifest(apiBaseUrl, channel, installToken, fetchImpl = fetch) {
1538
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/service/releases/${channel}`, {
1539
+ headers: {
1540
+ Accept: "application/json",
1541
+ Authorization: `Bearer ${installToken}`,
1542
+ },
1543
+ });
1544
+ const responseBody = await readJsonBody(response);
1545
+ if (!response.ok) {
1546
+ const detail = typeof responseBody?.detail === "string" ? responseBody.detail : "";
1547
+ throw new Error(detail
1548
+ ? `Failed to fetch protected Tapi Service manifest: HTTP ${response.status} (${detail})`
1549
+ : `Failed to fetch protected Tapi Service manifest: HTTP ${response.status}`);
1550
+ }
1551
+ return validateServiceReleaseManifest(responseBody);
1552
+ }
657
1553
  async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
658
1554
  const response = await fetchImpl(manifestUrl, {
659
1555
  headers: {
@@ -696,24 +1592,24 @@ async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, f
696
1592
  expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : undefined,
697
1593
  };
698
1594
  }
699
- async function downloadAndVerify(url, destination, expectedSha256) {
1595
+ async function downloadAndVerify(url, destination, expectedSha256, label = "Studio installer") {
700
1596
  const partialPath = `${destination}.partial`;
701
- await downloadFile(url, partialPath);
1597
+ await downloadFile(url, partialPath, label);
702
1598
  const actualSha256 = await sha256File(partialPath);
703
1599
  if (actualSha256 !== expectedSha256.toLowerCase()) {
704
1600
  await unlinkIfExists(partialPath);
705
- throw new Error(`Studio installer checksum mismatch. Expected ${expectedSha256}, got ${actualSha256}.`);
1601
+ throw new Error(`${label} checksum mismatch. Expected ${expectedSha256}, got ${actualSha256}.`);
706
1602
  }
707
1603
  await rename(partialPath, destination);
708
1604
  console.log(`Verified SHA256: ${actualSha256}`);
709
1605
  }
710
- async function downloadFile(url, destination) {
1606
+ async function downloadFile(url, destination, label = "download") {
711
1607
  const response = await fetch(url);
712
1608
  if (!response.ok) {
713
- throw new Error(`Failed to download Studio installer: HTTP ${response.status}`);
1609
+ throw new Error(`Failed to download ${label}: HTTP ${response.status}`);
714
1610
  }
715
1611
  if (!response.body) {
716
- throw new Error("Studio installer response did not include a body.");
1612
+ throw new Error(`${label} response did not include a body.`);
717
1613
  }
718
1614
  await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
719
1615
  }
@@ -937,6 +1833,32 @@ async function runProcess(command, args) {
937
1833
  });
938
1834
  });
939
1835
  }
1836
+ async function runProcessCapture(command, args) {
1837
+ return await new Promise((resolvePromise, rejectPromise) => {
1838
+ const child = spawn(command, args, {
1839
+ stdio: ["ignore", "pipe", "pipe"],
1840
+ windowsHide: true,
1841
+ });
1842
+ let stdout = "";
1843
+ let stderr = "";
1844
+ child.stdout.on("data", (chunk) => {
1845
+ stdout += String(chunk);
1846
+ });
1847
+ child.stderr.on("data", (chunk) => {
1848
+ stderr += String(chunk);
1849
+ });
1850
+ child.once("error", rejectPromise);
1851
+ child.once("exit", (code) => {
1852
+ const err = stderr.trim();
1853
+ if (code === 0) {
1854
+ resolvePromise(stdout);
1855
+ }
1856
+ else {
1857
+ rejectPromise(new Error(err || `Process exited with code ${code ?? "unknown"}.`));
1858
+ }
1859
+ });
1860
+ });
1861
+ }
940
1862
  function ensureWindowsHost() {
941
1863
  if (process.platform !== "win32" || process.arch !== "x64") {
942
1864
  throw new Error("Tapi Studio desktop installer is currently published for Windows x64 only.");
@@ -950,10 +1872,58 @@ function ensureCompatibleManifest(manifest) {
950
1872
  throw new Error(`Tapi Studio ${manifest.version} requires @tapi-dev/sdk >= ${manifest.minSdkVersion}; installed SDK is ${sdkVersion}.`);
951
1873
  }
952
1874
  }
1875
+ function ensureCompatibleServiceManifest(manifest) {
1876
+ if (manifest.platform !== SUPPORTED_STUDIO_PLATFORM) {
1877
+ throw new Error(`This SDK expected ${SUPPORTED_STUDIO_PLATFORM}, but the Tapi Service manifest points to ${manifest.platform}.`);
1878
+ }
1879
+ }
953
1880
  function cachedInstallerName(manifest) {
954
1881
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiStudioSetup-${manifest.version}.exe`;
955
1882
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
956
1883
  }
1884
+ function isPortableStudioServerManifest(manifest) {
1885
+ const kind = String(manifest.installerKind || "").toLowerCase();
1886
+ return kind === "portable-server" || manifest.artifactName.toLowerCase().endsWith(".zip");
1887
+ }
1888
+ function portableStudioServerReleaseDir(manifest) {
1889
+ return join(getDefaultPortableStudioServerReleasesDir(), safePathSegment(manifest.version));
1890
+ }
1891
+ function getDefaultPortableStudioServerReleasesDir() {
1892
+ if (process.platform === "win32") {
1893
+ const localAppData = process.env.LOCALAPPDATA ??
1894
+ (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
1895
+ return join(localAppData, "Tapi", "Studio", "server", "releases");
1896
+ }
1897
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "studio", "server", "releases");
1898
+ }
1899
+ function findPortableStudioServerExe(releaseDir, manifest) {
1900
+ const executable = manifest.serverExecutable || "tapi-studio-server.exe";
1901
+ const baseName = executable.toLowerCase().endsWith(".exe")
1902
+ ? executable.slice(0, -4)
1903
+ : executable;
1904
+ const candidates = [
1905
+ join(releaseDir, executable),
1906
+ join(releaseDir, baseName, `${baseName}.exe`),
1907
+ join(releaseDir, "tapi-studio-server.exe"),
1908
+ join(releaseDir, "tapi-studio-server", "tapi-studio-server.exe"),
1909
+ ];
1910
+ return candidates.find((candidate) => existsSync(candidate));
1911
+ }
1912
+ function cachedServiceArtifactName(manifest) {
1913
+ const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiService-${manifest.version}.zip`;
1914
+ return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
1915
+ }
1916
+ async function extractZip(zipPath, destination) {
1917
+ await rm(destination, { recursive: true, force: true });
1918
+ await mkdir(destination, { recursive: true });
1919
+ await runProcessCapture("powershell.exe", [
1920
+ "-NoProfile",
1921
+ "-ExecutionPolicy",
1922
+ "Bypass",
1923
+ "-Command",
1924
+ `Expand-Archive -LiteralPath ${powerShellSingleQuoted(zipPath)} -DestinationPath ${powerShellSingleQuoted(destination)} -Force`,
1925
+ ]);
1926
+ }
957
1927
  function installEventOptions(options) {
958
1928
  return {
959
1929
  channel: options.channel,
@@ -967,6 +1937,22 @@ function installEventOptions(options) {
967
1937
  installTokenProvided: Boolean(options.installToken),
968
1938
  };
969
1939
  }
1940
+ function serviceManifestEventSummary(manifest) {
1941
+ return {
1942
+ product: manifest.product,
1943
+ version: manifest.version,
1944
+ channel: manifest.channel,
1945
+ platform: manifest.platform,
1946
+ artifactName: manifest.artifactName,
1947
+ url: manifest.url,
1948
+ sha256: manifest.sha256,
1949
+ sizeBytes: manifest.sizeBytes,
1950
+ serviceHostExecutable: manifest.serviceHostExecutable,
1951
+ workerExecutable: manifest.workerExecutable,
1952
+ installer: manifest.installer,
1953
+ commitShort: manifest.commitShort,
1954
+ };
1955
+ }
970
1956
  function manifestEventSummary(manifest) {
971
1957
  return {
972
1958
  product: manifest.product,
@@ -982,6 +1968,12 @@ function manifestEventSummary(manifest) {
982
1968
  bundledService: manifest.bundledService,
983
1969
  };
984
1970
  }
1971
+ function safePathSegment(value) {
1972
+ return String(value || "release").replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "release";
1973
+ }
1974
+ function powerShellSingleQuoted(value) {
1975
+ return `'${String(value).replace(/'/g, "''")}'`;
1976
+ }
985
1977
  function parseChannel(value) {
986
1978
  if (value === "pilot" || value === "stable" || value === "nightly") {
987
1979
  return value;
@@ -1067,52 +2059,117 @@ function readSdkVersion() {
1067
2059
  function printHelp() {
1068
2060
  console.log(`Tapi CLI
1069
2061
 
1070
- Usage:
1071
- tapi studio install [--channel pilot] [--api-base-url URL]
1072
- tapi studio open
1073
- tapi studio doctor
1074
- tapi apis describe <namespace.operation>
1075
- tapi doctor
1076
-
1077
- Commands:
1078
- studio install Download, verify, and run the Tapi Studio installer
1079
- studio open Open an installed Tapi Studio desktop app
1080
- studio doctor Check local SDK and Studio release configuration
1081
- apis describe Print a generated website API input/output contract
1082
- doctor Alias for studio doctor
2062
+ Usage:
2063
+ tapi init --project PROJECT
2064
+ tapi link --project PROJECT
2065
+ tapi studio install [--channel pilot] [--api-base-url URL]
2066
+ tapi studio
2067
+ tapi studio open
2068
+ tapi studio doctor
2069
+ tapi apis describe <namespace.operation>
2070
+ tapi apis sync
2071
+ tapi apis generate
2072
+ tapi publish
2073
+ tapi service status
2074
+ tapi doctor
2075
+
2076
+ Commands:
2077
+ init Create .tapi/project.json for this repo
2078
+ link Rebind this repo to an existing Tapi project
2079
+ studio install Download, verify, and run the Tapi Studio installer
2080
+ studio Open Tapi Studio for this repo
2081
+ studio open Open Tapi Studio for this repo
2082
+ studio doctor Check local SDK and Studio release configuration
2083
+ apis describe Print a generated website API input/output contract
2084
+ apis sync Save the published API catalog to .tapi/generated
2085
+ apis generate Generate a TypeScript runtime wrapper from the catalog
2086
+ publish Upload local .tapi API drafts and publish ready requests
2087
+ service Inspect or control the local Tapi Windows service
2088
+ doctor Alias for studio doctor
1083
2089
  `);
1084
2090
  }
1085
2091
  function printApisHelp() {
1086
- console.log(`Tapi generated website API commands
1087
-
1088
- Usage:
1089
- tapi apis describe <namespace.operation> [--api-base-url URL] [--api-key KEY] [--app APP]
1090
-
1091
- Options:
1092
- --api-base-url <url> Tapi API base URL
1093
- --server <url> Alias for --api-base-url
1094
- --api-key <key> Tapi API key; defaults to TAPI_API_KEY
1095
- --app <id> SDK app/project id; defaults to TAPI_APP_ID
2092
+ console.log(`Tapi generated website API commands
2093
+
2094
+ Usage:
2095
+ tapi apis describe <namespace.operation> [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2096
+ tapi apis sync [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2097
+ tapi apis generate [--api-base-url URL] [--api-key KEY] [--project PROJECT] [--out FILE]
2098
+
2099
+ Options:
2100
+ --api-base-url <url> Tapi API base URL
2101
+ --server <url> Alias for --api-base-url
2102
+ --api-key <key> Tapi API key; defaults to TAPI_API_KEY
2103
+ --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
2104
+ --catalog <path> Catalog JSON output path
2105
+ --out <path> Generated TypeScript output path
1096
2106
  `);
1097
2107
  }
1098
2108
  function printStudioHelp() {
1099
- console.log(`Tapi Studio commands
1100
-
1101
- Usage:
1102
- tapi studio install [options]
1103
- tapi studio open [options]
1104
- tapi studio doctor [options]
1105
-
1106
- Options:
2109
+ console.log(`Tapi Studio commands
2110
+
2111
+ Usage:
2112
+ tapi studio [options]
2113
+ tapi studio install [options]
2114
+ tapi studio open [options]
2115
+ tapi studio doctor [options]
2116
+
2117
+ Options:
1107
2118
  --channel <name> Release channel: pilot, stable, or nightly
1108
2119
  --api-base-url <url> Tapi API base URL for approval and protected downloads
1109
- --server <url> Alias for --api-base-url
1110
- --install-token <tok> Preissued Studio install token (skips browser sign-in)
1111
- --manifest <url> Exact release manifest URL for doctor only
1112
- --cache-dir <path> Installer download cache directory
1113
- --download-only Download and verify without running the installer
1114
- --silent Run the NSIS installer with /S
1115
- --exe <path> Tapi Studio executable path for open/doctor
2120
+ --server <url> Alias for --api-base-url
2121
+ --workspace <path> Search this directory for .tapi/project.json
2122
+ --no-workspace Do not load .tapi/project.json
2123
+ --project <id> Tapi project id for workspace-bound Studio
2124
+ --project-slug <slug> Optional display slug for the bound project
2125
+ --install-token <tok> Preissued Studio install token (skips browser sign-in)
2126
+ --manifest <url> Exact release manifest URL for doctor only
2127
+ --cache-dir <path> Installer download cache directory
2128
+ --download-only Download and verify without running the installer
2129
+ --silent Run the NSIS installer with /S
2130
+ --exe <path> Tapi Studio executable path for open/doctor
2131
+ `);
2132
+ }
2133
+ function printWorkspaceHelp(command) {
2134
+ console.log(`Tapi workspace ${command}
2135
+
2136
+ Usage:
2137
+ tapi ${command} --project PROJECT [--api-base-url URL] [--root PATH] [--force]
2138
+
2139
+ Options:
2140
+ --project <id> Tapi project id/name to bind this repo to
2141
+ --project-slug <slug> Optional display slug
2142
+ --api-base-url <url> Tapi API base URL stored in .tapi/project.json
2143
+ --server <url> Alias for --api-base-url
2144
+ --root <path> Directory where .tapi/project.json should be written
2145
+ --force Overwrite an existing workspace config
2146
+ `);
2147
+ }
2148
+ function printServiceHelp() {
2149
+ console.log(`Tapi service commands
2150
+
2151
+ Usage:
2152
+ tapi service status
2153
+ tapi service start
2154
+ tapi service stop
2155
+ tapi service restart
2156
+ tapi service repair
2157
+
2158
+ Commands:
2159
+ status Print installed/running status for tapi-service
2160
+ start Start tapi-service
2161
+ stop Stop tapi-service
2162
+ restart Restart tapi-service
2163
+ repair Restart tapi-service using the current installed service
2164
+ `);
2165
+ }
2166
+ function printPublishHelp() {
2167
+ console.log(`Tapi publish
2168
+
2169
+ Usage:
2170
+ tapi publish [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2171
+
2172
+ Publishes ready generated API requests from local .tapi/apis files.
1116
2173
  `);
1117
2174
  }
1118
2175
  function formatError(error) {