@tapi-dev/sdk 0.1.7 → 0.1.9

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,7 +1318,26 @@ async function obtainStudioInstallToken(options, event) {
602
1318
  }
603
1319
  async function openStudio(options) {
604
1320
  ensureWindowsHost();
605
- const exePath = options.exePath ?? getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
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
+ }
1332
+ let exePath = findStudioExecutable(options);
1333
+ if (!exePath && !options.exePath) {
1334
+ console.log("Tapi Studio executable was not found. Installing Tapi Studio now...");
1335
+ const installCode = await installStudio(options);
1336
+ if (installCode !== 0) {
1337
+ return installCode;
1338
+ }
1339
+ exePath = findStudioExecutable(options);
1340
+ }
606
1341
  if (!exePath || !existsSync(exePath)) {
607
1342
  console.error("Tapi Studio executable was not found.");
608
1343
  console.error("Run `npx tapi studio install --channel pilot`, or set TAPI_STUDIO_EXE to the installed executable path.");
@@ -610,6 +1345,7 @@ async function openStudio(options) {
610
1345
  }
611
1346
  const child = spawn(exePath, [], {
612
1347
  detached: true,
1348
+ env: buildStudioLaunchEnv(options),
613
1349
  stdio: "ignore",
614
1350
  windowsHide: false,
615
1351
  });
@@ -617,6 +1353,141 @@ async function openStudio(options) {
617
1353
  console.log(`Opened Tapi Studio: ${exePath}`);
618
1354
  return 0;
619
1355
  }
1356
+ function findStudioExecutable(options) {
1357
+ if (options.exePath) {
1358
+ return existsSync(options.exePath) ? options.exePath : undefined;
1359
+ }
1360
+ return getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
1361
+ }
1362
+ async function ensurePortableStudioServer(options) {
1363
+ const event = await createCliWideEvent("tapi_cli.studio_server_ensure", {
1364
+ sdk_version: sdkVersion,
1365
+ options: installEventOptions(options),
1366
+ });
1367
+ try {
1368
+ let installToken = options.installToken?.trim();
1369
+ if (!installToken) {
1370
+ await event.phase("auth.install_token.request.start", {
1371
+ apiBaseUrl: options.apiBaseUrl,
1372
+ channel: options.channel,
1373
+ });
1374
+ installToken = await obtainStudioInstallToken(options, event);
1375
+ await event.phase("auth.install_token.request.success", {
1376
+ apiBaseUrl: options.apiBaseUrl,
1377
+ channel: options.channel,
1378
+ });
1379
+ }
1380
+ await event.phase("manifest.fetch.start", {
1381
+ apiBaseUrl: options.apiBaseUrl,
1382
+ channel: options.channel,
1383
+ });
1384
+ const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
1385
+ ensureCompatibleManifest(manifest);
1386
+ await event.phase("manifest.fetch.success", {
1387
+ manifest: manifestEventSummary(manifest),
1388
+ });
1389
+ if (!isPortableStudioServerManifest(manifest)) {
1390
+ await event.finish(true, {
1391
+ installerKind: manifest.installerKind || "legacy",
1392
+ });
1393
+ return null;
1394
+ }
1395
+ const existing = findPortableStudioServerExe(portableStudioServerReleaseDir(manifest), manifest);
1396
+ if (existing) {
1397
+ await event.finish(true, {
1398
+ serverExe: existing,
1399
+ cacheHit: true,
1400
+ });
1401
+ return { exePath: existing, manifest };
1402
+ }
1403
+ const exePath = await installPortableStudioServer(options, manifest, event);
1404
+ await event.finish(true, {
1405
+ serverExe: exePath,
1406
+ cacheHit: false,
1407
+ });
1408
+ return { exePath, manifest };
1409
+ }
1410
+ catch (error) {
1411
+ await event.finish(false, {
1412
+ error: errorDetails(error),
1413
+ });
1414
+ throw error;
1415
+ }
1416
+ }
1417
+ async function installPortableStudioServer(options, manifest, event) {
1418
+ await mkdir(options.cacheDir, { recursive: true });
1419
+ const artifactPath = join(options.cacheDir, cachedInstallerName(manifest));
1420
+ const verified = await hasVerifiedCachedInstaller(artifactPath, manifest.sha256);
1421
+ if (verified) {
1422
+ console.log(`Using cached Tapi Studio server artifact: ${artifactPath}`);
1423
+ await event.phase("cache.hit", { artifactPath });
1424
+ }
1425
+ else {
1426
+ console.log(`Downloading Tapi Studio server ${manifest.version}...`);
1427
+ await event.phase("download.start", {
1428
+ url: manifest.url,
1429
+ destination: artifactPath,
1430
+ expectedSha256: manifest.sha256,
1431
+ });
1432
+ await downloadAndVerify(manifest.url, artifactPath, manifest.sha256, "Tapi Studio server artifact");
1433
+ await event.phase("download.verified", {
1434
+ artifactPath,
1435
+ expectedSha256: manifest.sha256,
1436
+ });
1437
+ }
1438
+ if (options.downloadOnly) {
1439
+ console.log(`Downloaded Tapi Studio server artifact: ${artifactPath}`);
1440
+ return artifactPath;
1441
+ }
1442
+ const releaseDir = portableStudioServerReleaseDir(manifest);
1443
+ await extractZip(artifactPath, releaseDir);
1444
+ const exePath = findPortableStudioServerExe(releaseDir, manifest);
1445
+ if (!exePath) {
1446
+ throw new Error(`Tapi Studio server executable was not found after extraction: ${releaseDir}`);
1447
+ }
1448
+ console.log(`Installed Tapi Studio server ${manifest.version}: ${releaseDir}`);
1449
+ return exePath;
1450
+ }
1451
+ async function launchPortableStudioServer(exePath, options) {
1452
+ const host = "127.0.0.1";
1453
+ const port = await chooseStudioPort();
1454
+ const env = buildStudioLaunchEnv(options);
1455
+ env.TAPI_RUNTIME_MODE = "installed";
1456
+ env.TAPI_RUNTIME_NAMESPACE = "installed";
1457
+ env.TAPI_RUNTIME_CONTROL_PORT = env.TAPI_RUNTIME_CONTROL_PORT || "8765";
1458
+ env.TAPI_PROCESS_ROLE = "studio_server";
1459
+ env.TAPI_STUDIO_OPEN_BROWSER = "0";
1460
+ env.TAPI_STUDIO_HOST = host;
1461
+ env.TAPI_STUDIO_PORT = String(port);
1462
+ env.TAPI_STUDIO_SERVER_PORT = String(port);
1463
+ const child = spawn(exePath, [], {
1464
+ detached: true,
1465
+ env,
1466
+ stdio: "ignore",
1467
+ windowsHide: true,
1468
+ });
1469
+ child.unref();
1470
+ const url = `http://${host}:${port}`;
1471
+ openBrowser(url);
1472
+ console.log(`Opened Tapi Studio: ${url}`);
1473
+ }
1474
+ async function chooseStudioPort(preferred = 18766) {
1475
+ for (let port = preferred; port < preferred + 25; port += 1) {
1476
+ if (await isPortAvailable(port)) {
1477
+ return port;
1478
+ }
1479
+ }
1480
+ throw new Error(`No available local Studio port found starting at ${preferred}.`);
1481
+ }
1482
+ async function isPortAvailable(port) {
1483
+ return await new Promise((resolvePromise) => {
1484
+ const server = createNetServer();
1485
+ server.once("error", () => resolvePromise(false));
1486
+ server.listen(port, "127.0.0.1", () => {
1487
+ server.close(() => resolvePromise(true));
1488
+ });
1489
+ });
1490
+ }
620
1491
  async function runDoctor(options) {
621
1492
  console.log(`Tapi SDK: ${sdkVersion}`);
622
1493
  console.log(`Node: ${process.version}`);
@@ -625,6 +1496,8 @@ async function runDoctor(options) {
625
1496
  console.log(`Studio channel: ${options.channel}`);
626
1497
  console.log(`Studio manifest: ${options.manifestUrl}`);
627
1498
  console.log(`Studio cache: ${options.cacheDir}`);
1499
+ console.log(`Workspace: ${options.workspaceRoot ?? "not found"}`);
1500
+ console.log(`Project: ${options.projectId ?? "not set"}`);
628
1501
  const exePath = options.exePath ?? getStudioExecutableCandidates().find((candidate) => existsSync(candidate));
629
1502
  console.log(`Studio executable: ${exePath && existsSync(exePath) ? exePath : "not found"}`);
630
1503
  try {
@@ -638,6 +1511,27 @@ async function runDoctor(options) {
638
1511
  }
639
1512
  return 0;
640
1513
  }
1514
+ function buildStudioLaunchEnv(options) {
1515
+ const env = { ...process.env };
1516
+ env.TAPI_STUDIO_API_BASE_URL = options.apiBaseUrl;
1517
+ env.TAPI_BASE_URL = env.TAPI_BASE_URL || options.apiBaseUrl;
1518
+ if (options.workspaceRoot) {
1519
+ env.TAPI_WORKSPACE_ROOT = options.workspaceRoot;
1520
+ }
1521
+ if (options.workspace?.configPath) {
1522
+ env.TAPI_WORKSPACE_CONFIG = options.workspace.configPath;
1523
+ }
1524
+ if (options.projectId) {
1525
+ env.TAPI_PROJECT_ID = options.projectId;
1526
+ }
1527
+ if (options.projectSlug) {
1528
+ env.TAPI_PROJECT_SLUG = options.projectSlug;
1529
+ }
1530
+ if (options.workspaceMode) {
1531
+ env.TAPI_STUDIO_WORKSPACE_MODE = "1";
1532
+ }
1533
+ return env;
1534
+ }
641
1535
  async function fetchProtectedStudioManifest(apiBaseUrl, channel, installToken, fetchImpl = fetch) {
642
1536
  const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/studio/releases/${channel}`, {
643
1537
  headers: {
@@ -654,6 +1548,22 @@ async function fetchProtectedStudioManifest(apiBaseUrl, channel, installToken, f
654
1548
  }
655
1549
  return validateStudioManifest(responseBody);
656
1550
  }
1551
+ async function fetchProtectedServiceManifest(apiBaseUrl, channel, installToken, fetchImpl = fetch) {
1552
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/service/releases/${channel}`, {
1553
+ headers: {
1554
+ Accept: "application/json",
1555
+ Authorization: `Bearer ${installToken}`,
1556
+ },
1557
+ });
1558
+ const responseBody = await readJsonBody(response);
1559
+ if (!response.ok) {
1560
+ const detail = typeof responseBody?.detail === "string" ? responseBody.detail : "";
1561
+ throw new Error(detail
1562
+ ? `Failed to fetch protected Tapi Service manifest: HTTP ${response.status} (${detail})`
1563
+ : `Failed to fetch protected Tapi Service manifest: HTTP ${response.status}`);
1564
+ }
1565
+ return validateServiceReleaseManifest(responseBody);
1566
+ }
657
1567
  async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
658
1568
  const response = await fetchImpl(manifestUrl, {
659
1569
  headers: {
@@ -696,24 +1606,24 @@ async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, f
696
1606
  expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : undefined,
697
1607
  };
698
1608
  }
699
- async function downloadAndVerify(url, destination, expectedSha256) {
1609
+ async function downloadAndVerify(url, destination, expectedSha256, label = "Studio installer") {
700
1610
  const partialPath = `${destination}.partial`;
701
- await downloadFile(url, partialPath);
1611
+ await downloadFile(url, partialPath, label);
702
1612
  const actualSha256 = await sha256File(partialPath);
703
1613
  if (actualSha256 !== expectedSha256.toLowerCase()) {
704
1614
  await unlinkIfExists(partialPath);
705
- throw new Error(`Studio installer checksum mismatch. Expected ${expectedSha256}, got ${actualSha256}.`);
1615
+ throw new Error(`${label} checksum mismatch. Expected ${expectedSha256}, got ${actualSha256}.`);
706
1616
  }
707
1617
  await rename(partialPath, destination);
708
1618
  console.log(`Verified SHA256: ${actualSha256}`);
709
1619
  }
710
- async function downloadFile(url, destination) {
1620
+ async function downloadFile(url, destination, label = "download") {
711
1621
  const response = await fetch(url);
712
1622
  if (!response.ok) {
713
- throw new Error(`Failed to download Studio installer: HTTP ${response.status}`);
1623
+ throw new Error(`Failed to download ${label}: HTTP ${response.status}`);
714
1624
  }
715
1625
  if (!response.body) {
716
- throw new Error("Studio installer response did not include a body.");
1626
+ throw new Error(`${label} response did not include a body.`);
717
1627
  }
718
1628
  await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
719
1629
  }
@@ -937,6 +1847,32 @@ async function runProcess(command, args) {
937
1847
  });
938
1848
  });
939
1849
  }
1850
+ async function runProcessCapture(command, args) {
1851
+ return await new Promise((resolvePromise, rejectPromise) => {
1852
+ const child = spawn(command, args, {
1853
+ stdio: ["ignore", "pipe", "pipe"],
1854
+ windowsHide: true,
1855
+ });
1856
+ let stdout = "";
1857
+ let stderr = "";
1858
+ child.stdout.on("data", (chunk) => {
1859
+ stdout += String(chunk);
1860
+ });
1861
+ child.stderr.on("data", (chunk) => {
1862
+ stderr += String(chunk);
1863
+ });
1864
+ child.once("error", rejectPromise);
1865
+ child.once("exit", (code) => {
1866
+ const err = stderr.trim();
1867
+ if (code === 0) {
1868
+ resolvePromise(stdout);
1869
+ }
1870
+ else {
1871
+ rejectPromise(new Error(err || `Process exited with code ${code ?? "unknown"}.`));
1872
+ }
1873
+ });
1874
+ });
1875
+ }
940
1876
  function ensureWindowsHost() {
941
1877
  if (process.platform !== "win32" || process.arch !== "x64") {
942
1878
  throw new Error("Tapi Studio desktop installer is currently published for Windows x64 only.");
@@ -950,10 +1886,58 @@ function ensureCompatibleManifest(manifest) {
950
1886
  throw new Error(`Tapi Studio ${manifest.version} requires @tapi-dev/sdk >= ${manifest.minSdkVersion}; installed SDK is ${sdkVersion}.`);
951
1887
  }
952
1888
  }
1889
+ function ensureCompatibleServiceManifest(manifest) {
1890
+ if (manifest.platform !== SUPPORTED_STUDIO_PLATFORM) {
1891
+ throw new Error(`This SDK expected ${SUPPORTED_STUDIO_PLATFORM}, but the Tapi Service manifest points to ${manifest.platform}.`);
1892
+ }
1893
+ }
953
1894
  function cachedInstallerName(manifest) {
954
1895
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiStudioSetup-${manifest.version}.exe`;
955
1896
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
956
1897
  }
1898
+ function isPortableStudioServerManifest(manifest) {
1899
+ const kind = String(manifest.installerKind || "").toLowerCase();
1900
+ return kind === "portable-server" || manifest.artifactName.toLowerCase().endsWith(".zip");
1901
+ }
1902
+ function portableStudioServerReleaseDir(manifest) {
1903
+ return join(getDefaultPortableStudioServerReleasesDir(), safePathSegment(manifest.version));
1904
+ }
1905
+ function getDefaultPortableStudioServerReleasesDir() {
1906
+ if (process.platform === "win32") {
1907
+ const localAppData = process.env.LOCALAPPDATA ??
1908
+ (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
1909
+ return join(localAppData, "Tapi", "Studio", "server", "releases");
1910
+ }
1911
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "studio", "server", "releases");
1912
+ }
1913
+ function findPortableStudioServerExe(releaseDir, manifest) {
1914
+ const executable = manifest.serverExecutable || "tapi-studio-server.exe";
1915
+ const baseName = executable.toLowerCase().endsWith(".exe")
1916
+ ? executable.slice(0, -4)
1917
+ : executable;
1918
+ const candidates = [
1919
+ join(releaseDir, executable),
1920
+ join(releaseDir, baseName, `${baseName}.exe`),
1921
+ join(releaseDir, "tapi-studio-server.exe"),
1922
+ join(releaseDir, "tapi-studio-server", "tapi-studio-server.exe"),
1923
+ ];
1924
+ return candidates.find((candidate) => existsSync(candidate));
1925
+ }
1926
+ function cachedServiceArtifactName(manifest) {
1927
+ const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiService-${manifest.version}.zip`;
1928
+ return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
1929
+ }
1930
+ async function extractZip(zipPath, destination) {
1931
+ await rm(destination, { recursive: true, force: true });
1932
+ await mkdir(destination, { recursive: true });
1933
+ await runProcessCapture("powershell.exe", [
1934
+ "-NoProfile",
1935
+ "-ExecutionPolicy",
1936
+ "Bypass",
1937
+ "-Command",
1938
+ `Expand-Archive -LiteralPath ${powerShellSingleQuoted(zipPath)} -DestinationPath ${powerShellSingleQuoted(destination)} -Force`,
1939
+ ]);
1940
+ }
957
1941
  function installEventOptions(options) {
958
1942
  return {
959
1943
  channel: options.channel,
@@ -967,6 +1951,22 @@ function installEventOptions(options) {
967
1951
  installTokenProvided: Boolean(options.installToken),
968
1952
  };
969
1953
  }
1954
+ function serviceManifestEventSummary(manifest) {
1955
+ return {
1956
+ product: manifest.product,
1957
+ version: manifest.version,
1958
+ channel: manifest.channel,
1959
+ platform: manifest.platform,
1960
+ artifactName: manifest.artifactName,
1961
+ url: manifest.url,
1962
+ sha256: manifest.sha256,
1963
+ sizeBytes: manifest.sizeBytes,
1964
+ serviceHostExecutable: manifest.serviceHostExecutable,
1965
+ workerExecutable: manifest.workerExecutable,
1966
+ installer: manifest.installer,
1967
+ commitShort: manifest.commitShort,
1968
+ };
1969
+ }
970
1970
  function manifestEventSummary(manifest) {
971
1971
  return {
972
1972
  product: manifest.product,
@@ -982,6 +1982,12 @@ function manifestEventSummary(manifest) {
982
1982
  bundledService: manifest.bundledService,
983
1983
  };
984
1984
  }
1985
+ function safePathSegment(value) {
1986
+ return String(value || "release").replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "release";
1987
+ }
1988
+ function powerShellSingleQuoted(value) {
1989
+ return `'${String(value).replace(/'/g, "''")}'`;
1990
+ }
985
1991
  function parseChannel(value) {
986
1992
  if (value === "pilot" || value === "stable" || value === "nightly") {
987
1993
  return value;
@@ -1067,52 +2073,117 @@ function readSdkVersion() {
1067
2073
  function printHelp() {
1068
2074
  console.log(`Tapi CLI
1069
2075
 
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
2076
+ Usage:
2077
+ tapi init --project PROJECT
2078
+ tapi link --project PROJECT
2079
+ tapi studio install [--channel pilot] [--api-base-url URL]
2080
+ tapi studio
2081
+ tapi studio open
2082
+ tapi studio doctor
2083
+ tapi apis describe <namespace.operation>
2084
+ tapi apis sync
2085
+ tapi apis generate
2086
+ tapi publish
2087
+ tapi service status
2088
+ tapi doctor
2089
+
2090
+ Commands:
2091
+ init Create .tapi/project.json for this repo
2092
+ link Rebind this repo to an existing Tapi project
2093
+ studio install Download, verify, and run the Tapi Studio installer
2094
+ studio Open Tapi Studio for this repo
2095
+ studio open Open Tapi Studio for this repo
2096
+ studio doctor Check local SDK and Studio release configuration
2097
+ apis describe Print a generated website API input/output contract
2098
+ apis sync Save the published API catalog to .tapi/generated
2099
+ apis generate Generate a TypeScript runtime wrapper from the catalog
2100
+ publish Upload local .tapi API drafts and publish ready requests
2101
+ service Inspect or control the local Tapi Windows service
2102
+ doctor Alias for studio doctor
1083
2103
  `);
1084
2104
  }
1085
2105
  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
2106
+ console.log(`Tapi generated website API commands
2107
+
2108
+ Usage:
2109
+ tapi apis describe <namespace.operation> [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2110
+ tapi apis sync [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2111
+ tapi apis generate [--api-base-url URL] [--api-key KEY] [--project PROJECT] [--out FILE]
2112
+
2113
+ Options:
2114
+ --api-base-url <url> Tapi API base URL
2115
+ --server <url> Alias for --api-base-url
2116
+ --api-key <key> Tapi API key; defaults to TAPI_API_KEY
2117
+ --project <id> Tapi project id; defaults to TAPI_PROJECT_ID or .tapi/project.json
2118
+ --catalog <path> Catalog JSON output path
2119
+ --out <path> Generated TypeScript output path
1096
2120
  `);
1097
2121
  }
1098
2122
  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:
2123
+ console.log(`Tapi Studio commands
2124
+
2125
+ Usage:
2126
+ tapi studio [options]
2127
+ tapi studio install [options]
2128
+ tapi studio open [options]
2129
+ tapi studio doctor [options]
2130
+
2131
+ Options:
1107
2132
  --channel <name> Release channel: pilot, stable, or nightly
1108
2133
  --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
2134
+ --server <url> Alias for --api-base-url
2135
+ --workspace <path> Search this directory for .tapi/project.json
2136
+ --no-workspace Do not load .tapi/project.json
2137
+ --project <id> Tapi project id for workspace-bound Studio
2138
+ --project-slug <slug> Optional display slug for the bound project
2139
+ --install-token <tok> Preissued Studio install token (skips browser sign-in)
2140
+ --manifest <url> Exact release manifest URL for doctor only
2141
+ --cache-dir <path> Installer download cache directory
2142
+ --download-only Download and verify without running the installer
2143
+ --silent Run the NSIS installer with /S
2144
+ --exe <path> Tapi Studio executable path for open/doctor
2145
+ `);
2146
+ }
2147
+ function printWorkspaceHelp(command) {
2148
+ console.log(`Tapi workspace ${command}
2149
+
2150
+ Usage:
2151
+ tapi ${command} --project PROJECT [--api-base-url URL] [--root PATH] [--force]
2152
+
2153
+ Options:
2154
+ --project <id> Tapi project id/name to bind this repo to
2155
+ --project-slug <slug> Optional display slug
2156
+ --api-base-url <url> Tapi API base URL stored in .tapi/project.json
2157
+ --server <url> Alias for --api-base-url
2158
+ --root <path> Directory where .tapi/project.json should be written
2159
+ --force Overwrite an existing workspace config
2160
+ `);
2161
+ }
2162
+ function printServiceHelp() {
2163
+ console.log(`Tapi service commands
2164
+
2165
+ Usage:
2166
+ tapi service status
2167
+ tapi service start
2168
+ tapi service stop
2169
+ tapi service restart
2170
+ tapi service repair
2171
+
2172
+ Commands:
2173
+ status Print installed/running status for tapi-service
2174
+ start Start tapi-service
2175
+ stop Stop tapi-service
2176
+ restart Restart tapi-service
2177
+ repair Restart tapi-service using the current installed service
2178
+ `);
2179
+ }
2180
+ function printPublishHelp() {
2181
+ console.log(`Tapi publish
2182
+
2183
+ Usage:
2184
+ tapi publish [--api-base-url URL] [--api-key KEY] [--project PROJECT]
2185
+
2186
+ Publishes ready generated API requests from local .tapi/apis files.
1116
2187
  `);
1117
2188
  }
1118
2189
  function formatError(error) {