@tapi-dev/sdk 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,17 +26,23 @@ You can also run the CLI directly from npm without adding the package first:
26
26
  npx @tapi-dev/sdk studio install --channel pilot
27
27
  ```
28
28
 
29
- The CLI reads the release manifest from:
29
+ The CLI reads the release manifest from:
30
30
 
31
31
  ```text
32
32
  https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
33
33
  ```
34
34
 
35
- It downloads the Windows installer, verifies the manifest SHA256, caches the installer locally, and runs it. The installer cache defaults to:
35
+ It downloads the Windows installer, verifies the manifest SHA256, caches the installer locally, and runs it. The installer cache defaults to:
36
36
 
37
37
  ```text
38
- %LOCALAPPDATA%\Tapi\Studio\downloads
39
- ```
38
+ %LOCALAPPDATA%\Tapi\Studio\downloads
39
+ ```
40
+
41
+ Tapi Studio includes the matching Tapi Service build. When the installer runs,
42
+ it installs or replaces the local `tapi-service` Windows service with the
43
+ version declared in the Studio manifest. Developers do not pick a service
44
+ version separately; updating Studio updates the service version used by that
45
+ project and by apps generated from that project.
40
46
 
41
47
  Useful commands:
42
48
 
package/dist/cli.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface StudioReleaseManifest {
15
15
  commitShort?: string;
16
16
  ref?: string;
17
17
  installerKind?: string;
18
+ bundledService?: {
19
+ product?: string;
20
+ version?: string;
21
+ source?: string;
22
+ manifestUrl?: string;
23
+ };
18
24
  }
19
25
  interface StudioCliOptions {
20
26
  channel: StudioChannel;
@@ -27,6 +33,19 @@ interface StudioCliOptions {
27
33
  export declare function runCli(argv?: string[]): Promise<number>;
28
34
  export declare function parseStudioOptions(args: string[]): StudioCliOptions;
29
35
  export declare function getDefaultStudioCacheDir(): string;
36
+ export declare class CliWideEvent {
37
+ private readonly filePath;
38
+ private readonly startedAt;
39
+ private readonly data;
40
+ private readonly phases;
41
+ private constructor();
42
+ static create(eventName: string, initialFields?: Record<string, unknown>): Promise<CliWideEvent>;
43
+ phase(phase: string, fields?: Record<string, unknown>): Promise<void>;
44
+ finish(success: boolean, fields?: Record<string, unknown>): Promise<void>;
45
+ private write;
46
+ }
47
+ export declare function createCliWideEvent(eventName: string, initialFields?: Record<string, unknown>): Promise<CliWideEvent>;
48
+ export declare function getCliWideEventRoot(): string;
30
49
  export declare function getStudioExecutableCandidates(): string[];
31
50
  export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
32
51
  export declare function compareVersions(left: string, right: string): number;
package/dist/cli.js CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
5
- import { mkdir, rename, unlink } from "node:fs/promises";
5
+ import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, join, resolve } from "node:path";
8
+ import { performance } from "node:perf_hooks";
8
9
  import { Readable } from "node:stream";
9
10
  import { pipeline } from "node:stream/promises";
10
11
  import { fileURLToPath } from "node:url";
11
12
  const DEFAULT_DOWNLOADS_BASE_URL = "https://d4xaf52nfwiok.cloudfront.net";
12
13
  const DEFAULT_CHANNEL = "pilot";
13
14
  const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
15
+ const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
14
16
  const sdkVersion = readSdkVersion();
15
17
  export async function runCli(argv = process.argv.slice(2)) {
16
18
  const [command, subcommand, ...rest] = argv;
@@ -131,6 +133,141 @@ export function getDefaultStudioCacheDir() {
131
133
  }
132
134
  return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "studio");
133
135
  }
136
+ export class CliWideEvent {
137
+ filePath;
138
+ startedAt = performance.now();
139
+ data;
140
+ phases = [];
141
+ constructor(filePath, eventName, initialFields) {
142
+ this.filePath = filePath;
143
+ this.data = {
144
+ event: eventName,
145
+ seq: 1,
146
+ timestamp: new Date().toISOString(),
147
+ trace_id: `cli_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
148
+ actor: {
149
+ mode: "tapi_cli",
150
+ pid: process.pid,
151
+ cwd: process.cwd(),
152
+ node: process.version,
153
+ platform: process.platform,
154
+ arch: process.arch,
155
+ },
156
+ phases: this.phases,
157
+ ...initialFields,
158
+ };
159
+ }
160
+ static async create(eventName, initialFields = {}) {
161
+ try {
162
+ const root = getCliWideEventRoot();
163
+ await mkdir(root, { recursive: true });
164
+ const sessionDir = join(root, sessionDirName("tapi_cli"));
165
+ await mkdir(sessionDir, { recursive: true });
166
+ await pruneCliWideEventProcessRoots(root);
167
+ const filePath = join(sessionDir, `${safeEventFilename(eventName)}_${timeFilename(new Date())}.json`);
168
+ const event = new CliWideEvent(filePath, eventName, initialFields);
169
+ await event.phase("start");
170
+ return event;
171
+ }
172
+ catch {
173
+ return new CliWideEvent(undefined, eventName, initialFields);
174
+ }
175
+ }
176
+ async phase(phase, fields = {}) {
177
+ this.phases.push({
178
+ phase,
179
+ timestamp: new Date().toISOString(),
180
+ fields: sanitizeEventFields(fields),
181
+ });
182
+ this.data.phase = phase;
183
+ await this.write();
184
+ }
185
+ async finish(success, fields = {}) {
186
+ const durationMs = Math.round((performance.now() - this.startedAt) * 10) / 10;
187
+ this.data.duration_ms = durationMs;
188
+ this.data.outcome = success ? "success" : "error";
189
+ await this.phase(success ? "finish.success" : "finish.error", fields);
190
+ }
191
+ async write() {
192
+ if (!this.filePath) {
193
+ return;
194
+ }
195
+ try {
196
+ await writeFile(this.filePath, JSON.stringify(this.data, null, 2), "utf8");
197
+ }
198
+ catch {
199
+ // Diagnostics must never make the installer path fail.
200
+ }
201
+ }
202
+ }
203
+ export async function createCliWideEvent(eventName, initialFields = {}) {
204
+ return CliWideEvent.create(eventName, initialFields);
205
+ }
206
+ export function getCliWideEventRoot() {
207
+ const override = envString("TAPI_EVENTS_ROOT");
208
+ if (override) {
209
+ return override;
210
+ }
211
+ if (process.platform === "win32") {
212
+ const localAppData = process.env.LOCALAPPDATA ??
213
+ (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
214
+ return join(localAppData, "Tapi", "logs", "events");
215
+ }
216
+ return join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "tapi", "logs", "events");
217
+ }
218
+ async function pruneCliWideEventProcessRoots(root) {
219
+ try {
220
+ const entries = await readdir(root, { withFileTypes: true });
221
+ const directories = await Promise.all(entries
222
+ .filter((entry) => entry.isDirectory())
223
+ .map(async (entry) => {
224
+ const path = join(root, entry.name);
225
+ try {
226
+ return { path, mtimeMs: (await stat(path)).mtimeMs };
227
+ }
228
+ catch {
229
+ return { path, mtimeMs: 0 };
230
+ }
231
+ }));
232
+ if (directories.length <= MAX_WIDE_EVENT_PROCESS_ROOTS) {
233
+ return;
234
+ }
235
+ directories.sort((left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path));
236
+ await Promise.all(directories
237
+ .slice(MAX_WIDE_EVENT_PROCESS_ROOTS)
238
+ .map((entry) => rm(entry.path, { recursive: true, force: true })));
239
+ }
240
+ catch {
241
+ // Best-effort retention only.
242
+ }
243
+ }
244
+ function sessionDirName(role) {
245
+ const now = new Date();
246
+ const stamp = now
247
+ .toISOString()
248
+ .replace("T", "_")
249
+ .replace(/\.\d+Z$/, "")
250
+ .replace(/:/g, "-");
251
+ return `${stamp}_${role}_pid${process.pid}`;
252
+ }
253
+ function timeFilename(date) {
254
+ return date
255
+ .toISOString()
256
+ .split("T")[1]
257
+ .replace("Z", "")
258
+ .replace(/:/g, "-");
259
+ }
260
+ function safeEventFilename(eventName) {
261
+ return `001_${eventName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\./g, "_")}`;
262
+ }
263
+ function sanitizeEventFields(fields) {
264
+ return JSON.parse(JSON.stringify(fields, (_key, value) => {
265
+ if (value instanceof Error) {
266
+ return errorDetails(value);
267
+ }
268
+ return value;
269
+ }));
270
+ }
134
271
  export function getStudioExecutableCandidates() {
135
272
  const candidates = [
136
273
  process.env.TAPI_STUDIO_EXE,
@@ -185,29 +322,84 @@ export function compareVersions(left, right) {
185
322
  return 0;
186
323
  }
187
324
  async function installStudio(options) {
188
- ensureWindowsHost();
189
- console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
190
- const manifest = await fetchStudioManifest(options.manifestUrl);
191
- ensureCompatibleManifest(manifest);
192
- await mkdir(options.cacheDir, { recursive: true });
193
- const installerPath = join(options.cacheDir, cachedInstallerName(manifest));
194
- const verified = await hasVerifiedCachedInstaller(installerPath, manifest.sha256);
195
- if (verified) {
196
- console.log(`Using cached installer: ${installerPath}`);
197
- }
198
- else {
199
- console.log(`Downloading Tapi Studio ${manifest.version}...`);
200
- await downloadAndVerify(manifest.url, installerPath, manifest.sha256);
201
- }
202
- if (options.downloadOnly) {
203
- console.log(`Downloaded installer: ${installerPath}`);
325
+ const event = await createCliWideEvent("tapi_cli.studio_install", {
326
+ sdk_version: sdkVersion,
327
+ options: installEventOptions(options),
328
+ });
329
+ try {
330
+ await event.phase("host.check", {
331
+ platform: process.platform,
332
+ arch: process.arch,
333
+ });
334
+ ensureWindowsHost();
335
+ console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
336
+ await event.phase("manifest.fetch.start", {
337
+ manifestUrl: options.manifestUrl,
338
+ channel: options.channel,
339
+ });
340
+ const manifest = await fetchStudioManifest(options.manifestUrl);
341
+ await event.phase("manifest.fetch.success", {
342
+ manifest: manifestEventSummary(manifest),
343
+ });
344
+ ensureCompatibleManifest(manifest);
345
+ await event.phase("manifest.compatible", {
346
+ sdkVersion,
347
+ minSdkVersion: manifest.minSdkVersion,
348
+ platform: manifest.platform,
349
+ });
350
+ await mkdir(options.cacheDir, { recursive: true });
351
+ const installerPath = join(options.cacheDir, cachedInstallerName(manifest));
352
+ await event.phase("cache.check.start", {
353
+ cacheDir: options.cacheDir,
354
+ installerPath,
355
+ });
356
+ const verified = await hasVerifiedCachedInstaller(installerPath, manifest.sha256);
357
+ if (verified) {
358
+ console.log(`Using cached installer: ${installerPath}`);
359
+ await event.phase("cache.hit", { installerPath });
360
+ }
361
+ else {
362
+ await event.phase("cache.miss", { installerPath });
363
+ console.log(`Downloading Tapi Studio ${manifest.version}...`);
364
+ await event.phase("download.start", {
365
+ url: manifest.url,
366
+ destination: installerPath,
367
+ expectedSha256: manifest.sha256,
368
+ });
369
+ await downloadAndVerify(manifest.url, installerPath, manifest.sha256);
370
+ await event.phase("download.verified", {
371
+ installerPath,
372
+ expectedSha256: manifest.sha256,
373
+ });
374
+ }
375
+ if (options.downloadOnly) {
376
+ console.log(`Downloaded installer: ${installerPath}`);
377
+ await event.finish(true, {
378
+ installerPath,
379
+ downloadOnly: true,
380
+ });
381
+ return 0;
382
+ }
383
+ const installerArgs = options.silent ? ["/S"] : [];
384
+ console.log(`Starting Tapi Studio installer: ${installerPath}`);
385
+ await event.phase("installer.start", {
386
+ installerPath,
387
+ args: installerArgs,
388
+ });
389
+ await runProcess(installerPath, installerArgs);
390
+ console.log("Tapi Studio installer finished.");
391
+ await event.finish(true, {
392
+ installerPath,
393
+ downloadOnly: false,
394
+ });
204
395
  return 0;
205
396
  }
206
- const installerArgs = options.silent ? ["/S"] : [];
207
- console.log(`Starting Tapi Studio installer: ${installerPath}`);
208
- await runProcess(installerPath, installerArgs);
209
- console.log("Tapi Studio installer finished.");
210
- return 0;
397
+ catch (error) {
398
+ await event.finish(false, {
399
+ error: errorDetails(error),
400
+ });
401
+ throw error;
402
+ }
211
403
  }
212
404
  async function openStudio(options) {
213
405
  ensureWindowsHost();
@@ -323,6 +515,31 @@ function cachedInstallerName(manifest) {
323
515
  const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiStudioSetup-${manifest.version}.exe`;
324
516
  return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
325
517
  }
518
+ function installEventOptions(options) {
519
+ return {
520
+ channel: options.channel,
521
+ manifestUrl: options.manifestUrl,
522
+ cacheDir: options.cacheDir,
523
+ downloadOnly: options.downloadOnly,
524
+ silent: options.silent,
525
+ exePath: options.exePath,
526
+ };
527
+ }
528
+ function manifestEventSummary(manifest) {
529
+ return {
530
+ product: manifest.product,
531
+ version: manifest.version,
532
+ channel: manifest.channel,
533
+ platform: manifest.platform,
534
+ artifactName: manifest.artifactName,
535
+ url: manifest.url,
536
+ sha256: manifest.sha256,
537
+ sizeBytes: manifest.sizeBytes,
538
+ minSdkVersion: manifest.minSdkVersion,
539
+ commitShort: manifest.commitShort,
540
+ bundledService: manifest.bundledService,
541
+ };
542
+ }
326
543
  function parseChannel(value) {
327
544
  if (value === "pilot" || value === "stable" || value === "nightly") {
328
545
  return value;
@@ -441,6 +658,49 @@ Options:
441
658
  function formatError(error) {
442
659
  return error instanceof Error ? error.message : String(error);
443
660
  }
661
+ function errorDetails(error) {
662
+ if (error instanceof Error) {
663
+ const details = {
664
+ name: error.name,
665
+ message: error.message,
666
+ stack: error.stack,
667
+ };
668
+ const cause = error.cause;
669
+ if (cause) {
670
+ details.cause = serializeError(cause);
671
+ }
672
+ return details;
673
+ }
674
+ return {
675
+ message: String(error),
676
+ };
677
+ }
678
+ function serializeError(error) {
679
+ if (error instanceof Error) {
680
+ const record = error;
681
+ const details = {
682
+ name: error.name,
683
+ message: error.message,
684
+ };
685
+ for (const key of ["code", "errno", "syscall", "address", "port"]) {
686
+ const value = record[key];
687
+ if (value !== undefined) {
688
+ details[key] = value;
689
+ }
690
+ }
691
+ const cause = error.cause;
692
+ if (cause) {
693
+ details.cause = serializeError(cause);
694
+ }
695
+ return details;
696
+ }
697
+ if (error && typeof error === "object") {
698
+ return Object.fromEntries(Object.entries(error).filter(([, value]) => value !== undefined));
699
+ }
700
+ return {
701
+ message: String(error),
702
+ };
703
+ }
444
704
  function isCliEntrypoint() {
445
705
  const invokedPath = process.argv[1];
446
706
  return Boolean(invokedPath && resolve(invokedPath) === fileURLToPath(import.meta.url));
package/dist/types.d.ts CHANGED
@@ -31,7 +31,10 @@ export interface TapiRunner {
31
31
  }
32
32
  export interface RuntimeRequirements {
33
33
  daemonRequired: boolean;
34
+ serviceRequired?: boolean;
34
35
  localControlUrl?: string;
36
+ serviceName?: string;
37
+ serviceVersion?: string;
35
38
  windowsServiceName?: string;
36
39
  [key: string]: unknown;
37
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapi-dev/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",