@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 +10 -4
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +283 -23
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|