@tapi-dev/sdk 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -42
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +642 -31
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,50 +14,84 @@ This package is ESM-first and works in runtimes with `fetch`, including modern N
|
|
|
14
14
|
|
|
15
15
|
Tapi Studio is the desktop app used to author and test local Tapi integrations. Install the SDK first, then use the bundled CLI:
|
|
16
16
|
|
|
17
|
-
```bash
|
|
18
|
-
npm install @tapi-dev/sdk
|
|
19
|
-
npx tapi studio install --channel pilot
|
|
20
|
-
npx tapi studio open
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
```bash
|
|
18
|
+
npm install @tapi-dev/sdk
|
|
19
|
+
npx tapi studio install --channel pilot
|
|
20
|
+
npx tapi studio open
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`studio install` opens a browser sign-in before any Studio EXE download. The
|
|
24
|
+
CLI exchanges the browser credential for Firebase auth, requests a short-lived
|
|
25
|
+
Studio install token from the Tapi API, then fetches a protected manifest and
|
|
26
|
+
installer download URL. If the signed-in user is not yet approved, the server
|
|
27
|
+
submits an approval request and sends the existing email approval links to the
|
|
28
|
+
admin. After approval, rerun the same command.
|
|
29
|
+
|
|
30
|
+
You can also run the CLI directly from npm without adding the package first:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx @tapi-dev/sdk studio install --channel pilot
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The install flow talks to the Tapi API host first:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
POST /api/sdk/v1/studio/install-token
|
|
40
|
+
GET /api/sdk/v1/studio/releases/<channel>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It then downloads the Windows installer, verifies the manifest SHA256, caches
|
|
44
|
+
the installer locally, and runs it. The installer cache defaults to:
|
|
36
45
|
|
|
37
46
|
```text
|
|
38
|
-
%LOCALAPPDATA%\Tapi\Studio\downloads
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
%LOCALAPPDATA%\Tapi\Studio\downloads
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Tapi Studio includes the matching Tapi Service build. When the installer runs,
|
|
51
|
+
it installs or replaces the local `tapi-service` Windows service with the
|
|
52
|
+
version declared in the Studio manifest. Developers do not pick a service
|
|
53
|
+
version separately; updating Studio updates the service version used by that
|
|
54
|
+
project and by apps generated from that project.
|
|
55
|
+
|
|
56
|
+
Useful commands:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx tapi studio install --channel pilot
|
|
60
|
+
npx tapi studio install --channel pilot --download-only
|
|
61
|
+
npx tapi studio install --api-base-url https://api.example.com
|
|
62
|
+
npx tapi studio install --install-token tsi_your_preissued_token
|
|
63
|
+
npx tapi studio open
|
|
64
|
+
npx tapi studio doctor
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Environment overrides:
|
|
68
|
+
|
|
69
|
+
```env
|
|
70
|
+
TAPI_BASE_URL=https://your-tapi-api-host
|
|
71
|
+
TAPI_STUDIO_API_BASE_URL=https://your-tapi-api-host
|
|
72
|
+
TAPI_STUDIO_CHANNEL=pilot
|
|
73
|
+
TAPI_STUDIO_MANIFEST_URL=https://d4xaf52nfwiok.cloudfront.net/studio/channels/pilot/latest.json
|
|
74
|
+
TAPI_STUDIO_INSTALL_TOKEN=tsi_your_preissued_token
|
|
75
|
+
TAPI_STUDIO_AUTH_HTML_URL=https://rsarlong-1f92fd.gitlab.io/auth.html
|
|
76
|
+
TAPI_FIREBASE_API_KEY=AIzaSyCDZR8lWyVQcWYfFdNZa4vuL4IWEC0h6gE
|
|
77
|
+
TAPI_STUDIO_EXE=C:\Users\you\AppData\Local\Tapi Studio\Tapi Studio.exe
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`TAPI_STUDIO_MANIFEST_URL` remains useful for `studio doctor`. Direct manifest
|
|
81
|
+
overrides are not used by the protected installer path.
|
|
82
|
+
|
|
83
|
+
Server-side protected install delivery also requires:
|
|
84
|
+
|
|
85
|
+
```env
|
|
86
|
+
STUDIO_INSTALL_TOKEN_SECRET=replace-me
|
|
87
|
+
S3_BUCKET=your-private-release-bucket
|
|
88
|
+
AWS_REGION=us-east-1
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If the Studio installer objects remain publicly downloadable, users can bypass
|
|
92
|
+
the approval gate by skipping the CLI entirely.
|
|
93
|
+
|
|
94
|
+
Tapi Studio desktop releases are currently published for Windows x64.
|
|
61
95
|
|
|
62
96
|
## Quick Start
|
|
63
97
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -15,18 +15,40 @@ export interface StudioReleaseManifest {
|
|
|
15
15
|
commitShort?: string;
|
|
16
16
|
ref?: string;
|
|
17
17
|
installerKind?: string;
|
|
18
|
+
bundledService?: {
|
|
19
|
+
product?: string;
|
|
20
|
+
version?: string;
|
|
21
|
+
source?: string;
|
|
22
|
+
manifestUrl?: string;
|
|
23
|
+
};
|
|
18
24
|
}
|
|
19
25
|
interface StudioCliOptions {
|
|
20
26
|
channel: StudioChannel;
|
|
27
|
+
apiBaseUrl: string;
|
|
21
28
|
manifestUrl: string;
|
|
29
|
+
manifestUrlOverride?: string;
|
|
22
30
|
cacheDir: string;
|
|
23
31
|
downloadOnly: boolean;
|
|
24
32
|
silent: boolean;
|
|
25
33
|
exePath?: string;
|
|
34
|
+
installToken?: string;
|
|
26
35
|
}
|
|
27
36
|
export declare function runCli(argv?: string[]): Promise<number>;
|
|
28
37
|
export declare function parseStudioOptions(args: string[]): StudioCliOptions;
|
|
29
38
|
export declare function getDefaultStudioCacheDir(): string;
|
|
39
|
+
export declare class CliWideEvent {
|
|
40
|
+
private readonly filePath;
|
|
41
|
+
private readonly startedAt;
|
|
42
|
+
private readonly data;
|
|
43
|
+
private readonly phases;
|
|
44
|
+
private constructor();
|
|
45
|
+
static create(eventName: string, initialFields?: Record<string, unknown>): Promise<CliWideEvent>;
|
|
46
|
+
phase(phase: string, fields?: Record<string, unknown>): Promise<void>;
|
|
47
|
+
finish(success: boolean, fields?: Record<string, unknown>): Promise<void>;
|
|
48
|
+
private write;
|
|
49
|
+
}
|
|
50
|
+
export declare function createCliWideEvent(eventName: string, initialFields?: Record<string, unknown>): Promise<CliWideEvent>;
|
|
51
|
+
export declare function getCliWideEventRoot(): string;
|
|
30
52
|
export declare function getStudioExecutableCandidates(): string[];
|
|
31
53
|
export declare function validateStudioManifest(input: unknown): StudioReleaseManifest;
|
|
32
54
|
export declare function compareVersions(left: string, right: string): number;
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs";
|
|
5
|
-
import { mkdir, rename, unlink } from "node:fs/promises";
|
|
6
|
+
import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { basename, join, resolve } from "node:path";
|
|
9
|
+
import { performance } from "node:perf_hooks";
|
|
8
10
|
import { Readable } from "node:stream";
|
|
9
11
|
import { pipeline } from "node:stream/promises";
|
|
10
12
|
import { fileURLToPath } from "node:url";
|
|
11
13
|
const DEFAULT_DOWNLOADS_BASE_URL = "https://d4xaf52nfwiok.cloudfront.net";
|
|
14
|
+
const DEFAULT_STUDIO_API_BASE_URL = "https://determined-motivation-production.up.railway.app";
|
|
15
|
+
const DEFAULT_STUDIO_AUTH_HTML_URL = "https://rsarlong-1f92fd.gitlab.io/auth.html";
|
|
16
|
+
const DEFAULT_FIREBASE_API_KEY = "AIzaSyCDZR8lWyVQcWYfFdNZa4vuL4IWEC0h6gE";
|
|
12
17
|
const DEFAULT_CHANNEL = "pilot";
|
|
13
18
|
const SUPPORTED_STUDIO_PLATFORM = "windows-x86_64";
|
|
19
|
+
const DEFAULT_INSTALL_AUTH_TIMEOUT_MS = 120_000;
|
|
20
|
+
const MAX_WIDE_EVENT_PROCESS_ROOTS = 5;
|
|
21
|
+
class StudioInstallApprovalError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
status;
|
|
24
|
+
constructor(message, code, status) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.name = "StudioInstallApprovalError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
14
31
|
const sdkVersion = readSdkVersion();
|
|
15
32
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
16
33
|
const [command, subcommand, ...rest] = argv;
|
|
@@ -72,11 +89,31 @@ export function parseStudioOptions(args) {
|
|
|
72
89
|
continue;
|
|
73
90
|
}
|
|
74
91
|
if (arg === "--manifest") {
|
|
75
|
-
raw.
|
|
92
|
+
raw.manifestUrlOverride = requireOptionValue(args, ++index, "--manifest");
|
|
76
93
|
continue;
|
|
77
94
|
}
|
|
78
95
|
if (arg.startsWith("--manifest=")) {
|
|
79
|
-
raw.
|
|
96
|
+
raw.manifestUrlOverride = arg.slice("--manifest=".length);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (arg === "--api-base-url" || arg === "--server") {
|
|
100
|
+
raw.apiBaseUrl = requireOptionValue(args, ++index, arg);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (arg.startsWith("--api-base-url=")) {
|
|
104
|
+
raw.apiBaseUrl = arg.slice("--api-base-url=".length);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (arg.startsWith("--server=")) {
|
|
108
|
+
raw.apiBaseUrl = arg.slice("--server=".length);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (arg === "--install-token") {
|
|
112
|
+
raw.installToken = requireOptionValue(args, ++index, "--install-token");
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (arg.startsWith("--install-token=")) {
|
|
116
|
+
raw.installToken = arg.slice("--install-token=".length);
|
|
80
117
|
continue;
|
|
81
118
|
}
|
|
82
119
|
if (arg === "--cache-dir") {
|
|
@@ -109,18 +146,26 @@ export function parseStudioOptions(args) {
|
|
|
109
146
|
throw new Error(`Unknown option: ${arg}`);
|
|
110
147
|
}
|
|
111
148
|
const channel = raw.channel ?? parseChannel(envString("TAPI_STUDIO_CHANNEL") ?? DEFAULT_CHANNEL);
|
|
149
|
+
const apiBaseUrl = normalizeHttpUrl(raw.apiBaseUrl
|
|
150
|
+
?? envString("TAPI_STUDIO_API_BASE_URL")
|
|
151
|
+
?? envString("TAPI_BASE_URL")
|
|
152
|
+
?? envString("TAPI_STUDIO_SERVER_URL")
|
|
153
|
+
?? DEFAULT_STUDIO_API_BASE_URL, "Studio API base URL");
|
|
112
154
|
const downloadsBaseUrl = normalizeHttpUrl(envString("TAPI_DOWNLOADS_BASE_URL") ?? DEFAULT_DOWNLOADS_BASE_URL, "TAPI_DOWNLOADS_BASE_URL");
|
|
113
|
-
const explicitManifestUrl = raw.
|
|
155
|
+
const explicitManifestUrl = raw.manifestUrlOverride ?? envString("TAPI_STUDIO_MANIFEST_URL");
|
|
114
156
|
const manifestUrl = explicitManifestUrl
|
|
115
157
|
? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL")
|
|
116
158
|
: `${downloadsBaseUrl.replace(/\/+$/, "")}/studio/channels/${channel}/latest.json`;
|
|
117
159
|
return {
|
|
118
160
|
channel,
|
|
161
|
+
apiBaseUrl,
|
|
119
162
|
manifestUrl,
|
|
163
|
+
manifestUrlOverride: explicitManifestUrl ? normalizeHttpUrl(explicitManifestUrl, "Studio manifest URL") : undefined,
|
|
120
164
|
cacheDir: raw.cacheDir ?? getDefaultStudioCacheDir(),
|
|
121
165
|
downloadOnly: raw.downloadOnly ?? false,
|
|
122
166
|
silent: raw.silent ?? false,
|
|
123
167
|
exePath: raw.exePath ?? envString("TAPI_STUDIO_EXE"),
|
|
168
|
+
installToken: raw.installToken ?? envString("TAPI_STUDIO_INSTALL_TOKEN"),
|
|
124
169
|
};
|
|
125
170
|
}
|
|
126
171
|
export function getDefaultStudioCacheDir() {
|
|
@@ -131,6 +176,141 @@ export function getDefaultStudioCacheDir() {
|
|
|
131
176
|
}
|
|
132
177
|
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tapi", "studio");
|
|
133
178
|
}
|
|
179
|
+
export class CliWideEvent {
|
|
180
|
+
filePath;
|
|
181
|
+
startedAt = performance.now();
|
|
182
|
+
data;
|
|
183
|
+
phases = [];
|
|
184
|
+
constructor(filePath, eventName, initialFields) {
|
|
185
|
+
this.filePath = filePath;
|
|
186
|
+
this.data = {
|
|
187
|
+
event: eventName,
|
|
188
|
+
seq: 1,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
trace_id: `cli_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
|
191
|
+
actor: {
|
|
192
|
+
mode: "tapi_cli",
|
|
193
|
+
pid: process.pid,
|
|
194
|
+
cwd: process.cwd(),
|
|
195
|
+
node: process.version,
|
|
196
|
+
platform: process.platform,
|
|
197
|
+
arch: process.arch,
|
|
198
|
+
},
|
|
199
|
+
phases: this.phases,
|
|
200
|
+
...initialFields,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
static async create(eventName, initialFields = {}) {
|
|
204
|
+
try {
|
|
205
|
+
const root = getCliWideEventRoot();
|
|
206
|
+
await mkdir(root, { recursive: true });
|
|
207
|
+
const sessionDir = join(root, sessionDirName("tapi_cli"));
|
|
208
|
+
await mkdir(sessionDir, { recursive: true });
|
|
209
|
+
await pruneCliWideEventProcessRoots(root);
|
|
210
|
+
const filePath = join(sessionDir, `${safeEventFilename(eventName)}_${timeFilename(new Date())}.json`);
|
|
211
|
+
const event = new CliWideEvent(filePath, eventName, initialFields);
|
|
212
|
+
await event.phase("start");
|
|
213
|
+
return event;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return new CliWideEvent(undefined, eventName, initialFields);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async phase(phase, fields = {}) {
|
|
220
|
+
this.phases.push({
|
|
221
|
+
phase,
|
|
222
|
+
timestamp: new Date().toISOString(),
|
|
223
|
+
fields: sanitizeEventFields(fields),
|
|
224
|
+
});
|
|
225
|
+
this.data.phase = phase;
|
|
226
|
+
await this.write();
|
|
227
|
+
}
|
|
228
|
+
async finish(success, fields = {}) {
|
|
229
|
+
const durationMs = Math.round((performance.now() - this.startedAt) * 10) / 10;
|
|
230
|
+
this.data.duration_ms = durationMs;
|
|
231
|
+
this.data.outcome = success ? "success" : "error";
|
|
232
|
+
await this.phase(success ? "finish.success" : "finish.error", fields);
|
|
233
|
+
}
|
|
234
|
+
async write() {
|
|
235
|
+
if (!this.filePath) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await writeFile(this.filePath, JSON.stringify(this.data, null, 2), "utf8");
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Diagnostics must never make the installer path fail.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export async function createCliWideEvent(eventName, initialFields = {}) {
|
|
247
|
+
return CliWideEvent.create(eventName, initialFields);
|
|
248
|
+
}
|
|
249
|
+
export function getCliWideEventRoot() {
|
|
250
|
+
const override = envString("TAPI_EVENTS_ROOT");
|
|
251
|
+
if (override) {
|
|
252
|
+
return override;
|
|
253
|
+
}
|
|
254
|
+
if (process.platform === "win32") {
|
|
255
|
+
const localAppData = process.env.LOCALAPPDATA ??
|
|
256
|
+
(process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
|
|
257
|
+
return join(localAppData, "Tapi", "logs", "events");
|
|
258
|
+
}
|
|
259
|
+
return join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "tapi", "logs", "events");
|
|
260
|
+
}
|
|
261
|
+
async function pruneCliWideEventProcessRoots(root) {
|
|
262
|
+
try {
|
|
263
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
264
|
+
const directories = await Promise.all(entries
|
|
265
|
+
.filter((entry) => entry.isDirectory())
|
|
266
|
+
.map(async (entry) => {
|
|
267
|
+
const path = join(root, entry.name);
|
|
268
|
+
try {
|
|
269
|
+
return { path, mtimeMs: (await stat(path)).mtimeMs };
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return { path, mtimeMs: 0 };
|
|
273
|
+
}
|
|
274
|
+
}));
|
|
275
|
+
if (directories.length <= MAX_WIDE_EVENT_PROCESS_ROOTS) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
directories.sort((left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path));
|
|
279
|
+
await Promise.all(directories
|
|
280
|
+
.slice(MAX_WIDE_EVENT_PROCESS_ROOTS)
|
|
281
|
+
.map((entry) => rm(entry.path, { recursive: true, force: true })));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Best-effort retention only.
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function sessionDirName(role) {
|
|
288
|
+
const now = new Date();
|
|
289
|
+
const stamp = now
|
|
290
|
+
.toISOString()
|
|
291
|
+
.replace("T", "_")
|
|
292
|
+
.replace(/\.\d+Z$/, "")
|
|
293
|
+
.replace(/:/g, "-");
|
|
294
|
+
return `${stamp}_${role}_pid${process.pid}`;
|
|
295
|
+
}
|
|
296
|
+
function timeFilename(date) {
|
|
297
|
+
return date
|
|
298
|
+
.toISOString()
|
|
299
|
+
.split("T")[1]
|
|
300
|
+
.replace("Z", "")
|
|
301
|
+
.replace(/:/g, "-");
|
|
302
|
+
}
|
|
303
|
+
function safeEventFilename(eventName) {
|
|
304
|
+
return `001_${eventName.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\./g, "_")}`;
|
|
305
|
+
}
|
|
306
|
+
function sanitizeEventFields(fields) {
|
|
307
|
+
return JSON.parse(JSON.stringify(fields, (_key, value) => {
|
|
308
|
+
if (value instanceof Error) {
|
|
309
|
+
return errorDetails(value);
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
134
314
|
export function getStudioExecutableCandidates() {
|
|
135
315
|
const candidates = [
|
|
136
316
|
process.env.TAPI_STUDIO_EXE,
|
|
@@ -185,29 +365,150 @@ export function compareVersions(left, right) {
|
|
|
185
365
|
return 0;
|
|
186
366
|
}
|
|
187
367
|
async function installStudio(options) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
368
|
+
const event = await createCliWideEvent("tapi_cli.studio_install", {
|
|
369
|
+
sdk_version: sdkVersion,
|
|
370
|
+
options: installEventOptions(options),
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
await event.phase("host.check", {
|
|
374
|
+
platform: process.platform,
|
|
375
|
+
arch: process.arch,
|
|
376
|
+
});
|
|
377
|
+
ensureWindowsHost();
|
|
378
|
+
if (options.manifestUrlOverride) {
|
|
379
|
+
throw new Error("Direct Studio manifest overrides are no longer supported for install. "
|
|
380
|
+
+ "Use --channel with the protected install flow instead.");
|
|
381
|
+
}
|
|
382
|
+
let installToken = options.installToken?.trim();
|
|
383
|
+
if (installToken) {
|
|
384
|
+
await event.phase("auth.install_token.provided", {
|
|
385
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
386
|
+
channel: options.channel,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
console.log("Checking Studio install approval...");
|
|
391
|
+
await event.phase("auth.install_token.request.start", {
|
|
392
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
393
|
+
channel: options.channel,
|
|
394
|
+
});
|
|
395
|
+
installToken = await obtainStudioInstallToken(options, event);
|
|
396
|
+
await event.phase("auth.install_token.request.success", {
|
|
397
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
398
|
+
channel: options.channel,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
console.log(`Fetching Tapi Studio ${options.channel} manifest...`);
|
|
402
|
+
await event.phase("manifest.fetch.start", {
|
|
403
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
404
|
+
channel: options.channel,
|
|
405
|
+
});
|
|
406
|
+
const manifest = await fetchProtectedStudioManifest(options.apiBaseUrl, options.channel, installToken);
|
|
407
|
+
await event.phase("manifest.fetch.success", {
|
|
408
|
+
manifest: manifestEventSummary(manifest),
|
|
409
|
+
});
|
|
410
|
+
ensureCompatibleManifest(manifest);
|
|
411
|
+
await event.phase("manifest.compatible", {
|
|
412
|
+
sdkVersion,
|
|
413
|
+
minSdkVersion: manifest.minSdkVersion,
|
|
414
|
+
platform: manifest.platform,
|
|
415
|
+
});
|
|
416
|
+
await mkdir(options.cacheDir, { recursive: true });
|
|
417
|
+
const installerPath = join(options.cacheDir, cachedInstallerName(manifest));
|
|
418
|
+
await event.phase("cache.check.start", {
|
|
419
|
+
cacheDir: options.cacheDir,
|
|
420
|
+
installerPath,
|
|
421
|
+
});
|
|
422
|
+
const verified = await hasVerifiedCachedInstaller(installerPath, manifest.sha256);
|
|
423
|
+
if (verified) {
|
|
424
|
+
console.log(`Using cached installer: ${installerPath}`);
|
|
425
|
+
await event.phase("cache.hit", { installerPath });
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
await event.phase("cache.miss", { installerPath });
|
|
429
|
+
console.log(`Downloading Tapi Studio ${manifest.version}...`);
|
|
430
|
+
await event.phase("download.start", {
|
|
431
|
+
url: manifest.url,
|
|
432
|
+
destination: installerPath,
|
|
433
|
+
expectedSha256: manifest.sha256,
|
|
434
|
+
});
|
|
435
|
+
await downloadAndVerify(manifest.url, installerPath, manifest.sha256);
|
|
436
|
+
await event.phase("download.verified", {
|
|
437
|
+
installerPath,
|
|
438
|
+
expectedSha256: manifest.sha256,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (options.downloadOnly) {
|
|
442
|
+
console.log(`Downloaded installer: ${installerPath}`);
|
|
443
|
+
await event.finish(true, {
|
|
444
|
+
installerPath,
|
|
445
|
+
downloadOnly: true,
|
|
446
|
+
});
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
const installerArgs = options.silent ? ["/S"] : [];
|
|
450
|
+
console.log(`Starting Tapi Studio installer: ${installerPath}`);
|
|
451
|
+
await event.phase("installer.start", {
|
|
452
|
+
installerPath,
|
|
453
|
+
args: installerArgs,
|
|
454
|
+
});
|
|
455
|
+
await runProcess(installerPath, installerArgs);
|
|
456
|
+
console.log("Tapi Studio installer finished.");
|
|
457
|
+
await event.finish(true, {
|
|
458
|
+
installerPath,
|
|
459
|
+
downloadOnly: false,
|
|
460
|
+
});
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
await event.finish(false, {
|
|
465
|
+
error: errorDetails(error),
|
|
466
|
+
});
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function obtainStudioInstallToken(options, event) {
|
|
471
|
+
const cachedCreds = await refreshCachedFirebaseCredentials();
|
|
472
|
+
if (cachedCreds) {
|
|
473
|
+
await event.phase("auth.cache.refresh.success", {
|
|
474
|
+
uid: cachedCreds.uid,
|
|
475
|
+
authCachePath: getStudioAuthCachePath(),
|
|
476
|
+
});
|
|
477
|
+
await writeStudioAuthCache(cachedCreds);
|
|
478
|
+
try {
|
|
479
|
+
const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, cachedCreds.idToken);
|
|
480
|
+
return issued.installToken;
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
if (isApprovalTerminalError(error)) {
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
await event.phase("auth.cache.install_token.retry_interactive", {
|
|
487
|
+
error: errorDetails(error),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
197
490
|
}
|
|
198
491
|
else {
|
|
199
|
-
|
|
200
|
-
|
|
492
|
+
await event.phase("auth.cache.refresh.miss", {
|
|
493
|
+
authCachePath: getStudioAuthCachePath(),
|
|
494
|
+
});
|
|
201
495
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
496
|
+
const browserAuth = await browserGoogleSignIn();
|
|
497
|
+
await event.phase("auth.browser.callback.received", {
|
|
498
|
+
googleIdTokenPresent: Boolean(browserAuth.idToken),
|
|
499
|
+
googleAccessTokenPresent: Boolean(browserAuth.accessToken),
|
|
500
|
+
});
|
|
501
|
+
if (!browserAuth.idToken) {
|
|
502
|
+
throw new Error(browserAuth.error || browserAuth.detail || "Browser sign-in did not return a Google ID token.");
|
|
205
503
|
}
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
await
|
|
209
|
-
|
|
210
|
-
|
|
504
|
+
const firebaseCreds = await exchangeGoogleToFirebase(browserAuth.idToken, browserAuth.accessToken);
|
|
505
|
+
await writeStudioAuthCache(firebaseCreds);
|
|
506
|
+
await event.phase("auth.firebase.exchange.success", {
|
|
507
|
+
uid: firebaseCreds.uid,
|
|
508
|
+
authCachePath: getStudioAuthCachePath(),
|
|
509
|
+
});
|
|
510
|
+
const issued = await requestStudioInstallToken(options.apiBaseUrl, options.channel, firebaseCreds.idToken);
|
|
511
|
+
return issued.installToken;
|
|
211
512
|
}
|
|
212
513
|
async function openStudio(options) {
|
|
213
514
|
ensureWindowsHost();
|
|
@@ -230,6 +531,7 @@ async function runDoctor(options) {
|
|
|
230
531
|
console.log(`Tapi SDK: ${sdkVersion}`);
|
|
231
532
|
console.log(`Node: ${process.version}`);
|
|
232
533
|
console.log(`Platform: ${process.platform}/${process.arch}`);
|
|
534
|
+
console.log(`Studio API: ${options.apiBaseUrl}`);
|
|
233
535
|
console.log(`Studio channel: ${options.channel}`);
|
|
234
536
|
console.log(`Studio manifest: ${options.manifestUrl}`);
|
|
235
537
|
console.log(`Studio cache: ${options.cacheDir}`);
|
|
@@ -246,6 +548,18 @@ async function runDoctor(options) {
|
|
|
246
548
|
}
|
|
247
549
|
return 0;
|
|
248
550
|
}
|
|
551
|
+
async function fetchProtectedStudioManifest(apiBaseUrl, channel, installToken, fetchImpl = fetch) {
|
|
552
|
+
const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/studio/releases/${channel}`, {
|
|
553
|
+
headers: {
|
|
554
|
+
Accept: "application/json",
|
|
555
|
+
Authorization: `Bearer ${installToken}`,
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
if (!response.ok) {
|
|
559
|
+
throw new Error(`Failed to fetch protected Studio manifest: HTTP ${response.status}`);
|
|
560
|
+
}
|
|
561
|
+
return validateStudioManifest(await response.json());
|
|
562
|
+
}
|
|
249
563
|
async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
|
|
250
564
|
const response = await fetchImpl(manifestUrl, {
|
|
251
565
|
headers: {
|
|
@@ -257,6 +571,37 @@ async function fetchStudioManifest(manifestUrl, fetchImpl = fetch) {
|
|
|
257
571
|
}
|
|
258
572
|
return validateStudioManifest(await response.json());
|
|
259
573
|
}
|
|
574
|
+
async function requestStudioInstallToken(apiBaseUrl, channel, firebaseIdToken, fetchImpl = fetch) {
|
|
575
|
+
const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/api/sdk/v1/studio/install-token`, {
|
|
576
|
+
method: "POST",
|
|
577
|
+
headers: {
|
|
578
|
+
Accept: "application/json",
|
|
579
|
+
Authorization: `Bearer ${firebaseIdToken}`,
|
|
580
|
+
"Content-Type": "application/json",
|
|
581
|
+
},
|
|
582
|
+
body: JSON.stringify({ channel }),
|
|
583
|
+
});
|
|
584
|
+
const responseBody = await readJsonBody(response);
|
|
585
|
+
if (!response.ok) {
|
|
586
|
+
const detail = typeof responseBody?.detail === "string" ? responseBody.detail : "";
|
|
587
|
+
if (detail === "pending_approval" || detail === "access_pending") {
|
|
588
|
+
throw new StudioInstallApprovalError("Access request submitted. Check your email for approval, then rerun `npx tapi studio install`.", detail, response.status);
|
|
589
|
+
}
|
|
590
|
+
if (detail === "access_rejected") {
|
|
591
|
+
throw new StudioInstallApprovalError("Studio install access was rejected. Contact the Tapi admin if this is unexpected.", detail, response.status);
|
|
592
|
+
}
|
|
593
|
+
throw new StudioInstallApprovalError(detail || `Failed to request Studio install token: HTTP ${response.status}`, detail || "install_token_request_failed", response.status);
|
|
594
|
+
}
|
|
595
|
+
const payload = responseBody;
|
|
596
|
+
const installToken = typeof payload?.installToken === "string" ? payload.installToken.trim() : "";
|
|
597
|
+
if (!installToken) {
|
|
598
|
+
throw new Error("Studio install token response did not include installToken.");
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
installToken,
|
|
602
|
+
expiresAt: typeof payload?.expiresAt === "string" ? payload.expiresAt : undefined,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
260
605
|
async function downloadAndVerify(url, destination, expectedSha256) {
|
|
261
606
|
const partialPath = `${destination}.partial`;
|
|
262
607
|
await downloadFile(url, partialPath);
|
|
@@ -278,6 +623,198 @@ async function downloadFile(url, destination) {
|
|
|
278
623
|
}
|
|
279
624
|
await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
|
|
280
625
|
}
|
|
626
|
+
async function browserGoogleSignIn(timeoutMs = DEFAULT_INSTALL_AUTH_TIMEOUT_MS) {
|
|
627
|
+
ensureWindowsHost();
|
|
628
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
629
|
+
let timeoutHandle;
|
|
630
|
+
const cleanup = () => {
|
|
631
|
+
if (timeoutHandle) {
|
|
632
|
+
clearTimeout(timeoutHandle);
|
|
633
|
+
timeoutHandle = undefined;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
server.close();
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// ignore close races
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
const server = createServer((req, res) => {
|
|
643
|
+
const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
644
|
+
const idToken = requestUrl.searchParams.get("idToken") || "";
|
|
645
|
+
const accessToken = requestUrl.searchParams.get("accessToken") || "";
|
|
646
|
+
const error = requestUrl.searchParams.get("error") || "";
|
|
647
|
+
const detail = requestUrl.searchParams.get("detail") || "";
|
|
648
|
+
const ok = Boolean(idToken);
|
|
649
|
+
res.statusCode = 200;
|
|
650
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
651
|
+
res.end(renderBrowserSignInResponseHtml(ok));
|
|
652
|
+
cleanup();
|
|
653
|
+
if (!ok) {
|
|
654
|
+
rejectPromise(new Error(error || detail || "Browser sign-in did not return Google credentials."));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
resolvePromise({
|
|
658
|
+
accessToken: accessToken || undefined,
|
|
659
|
+
detail: detail || undefined,
|
|
660
|
+
idToken,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
timeoutHandle = setTimeout(() => {
|
|
664
|
+
cleanup();
|
|
665
|
+
rejectPromise(new Error("Browser sign-in timed out or was cancelled."));
|
|
666
|
+
}, timeoutMs);
|
|
667
|
+
server.once("error", (error) => {
|
|
668
|
+
cleanup();
|
|
669
|
+
rejectPromise(error);
|
|
670
|
+
});
|
|
671
|
+
server.listen(0, "127.0.0.1", () => {
|
|
672
|
+
const address = server.address();
|
|
673
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
674
|
+
if (!port) {
|
|
675
|
+
cleanup();
|
|
676
|
+
rejectPromise(new Error("Could not start local callback server for Studio sign-in."));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
openBrowser(`${getStudioAuthHtmlUrl()}?port=${port}`);
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
cleanup();
|
|
684
|
+
rejectPromise(error);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async function exchangeGoogleToFirebase(googleIdToken, googleAccessToken, fetchImpl = fetch) {
|
|
690
|
+
const postBody = [
|
|
691
|
+
`id_token=${encodeURIComponent(googleIdToken)}`,
|
|
692
|
+
"providerId=google.com",
|
|
693
|
+
googleAccessToken ? `access_token=${encodeURIComponent(googleAccessToken)}` : "",
|
|
694
|
+
].filter(Boolean).join("&");
|
|
695
|
+
const response = await fetchImpl(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${getFirebaseApiKey()}`, {
|
|
696
|
+
method: "POST",
|
|
697
|
+
headers: {
|
|
698
|
+
Accept: "application/json",
|
|
699
|
+
"Content-Type": "application/json",
|
|
700
|
+
},
|
|
701
|
+
body: JSON.stringify({
|
|
702
|
+
postBody,
|
|
703
|
+
requestUri: "http://127.0.0.1",
|
|
704
|
+
returnSecureToken: true,
|
|
705
|
+
returnIdpCredential: true,
|
|
706
|
+
}),
|
|
707
|
+
});
|
|
708
|
+
const responseBody = await readJsonBody(response);
|
|
709
|
+
if (!response.ok) {
|
|
710
|
+
throw new Error(`Firebase exchange failed: HTTP ${response.status}`);
|
|
711
|
+
}
|
|
712
|
+
const uid = typeof responseBody?.localId === "string" ? responseBody.localId.trim() : "";
|
|
713
|
+
const idToken = typeof responseBody?.idToken === "string" ? responseBody.idToken.trim() : "";
|
|
714
|
+
const refreshToken = typeof responseBody?.refreshToken === "string" ? responseBody.refreshToken.trim() : "";
|
|
715
|
+
if (!uid || !idToken || !refreshToken) {
|
|
716
|
+
throw new Error("Firebase exchange response is missing uid, idToken, or refreshToken.");
|
|
717
|
+
}
|
|
718
|
+
return { uid, idToken, refreshToken };
|
|
719
|
+
}
|
|
720
|
+
async function refreshCachedFirebaseCredentials(fetchImpl = fetch) {
|
|
721
|
+
const cache = readStudioAuthCache();
|
|
722
|
+
if (!cache?.refreshToken) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
const response = await fetchImpl(`https://securetoken.googleapis.com/v1/token?key=${getFirebaseApiKey()}`, {
|
|
726
|
+
method: "POST",
|
|
727
|
+
headers: {
|
|
728
|
+
Accept: "application/json",
|
|
729
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
730
|
+
},
|
|
731
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(cache.refreshToken)}`,
|
|
732
|
+
});
|
|
733
|
+
const responseBody = await readJsonBody(response);
|
|
734
|
+
if (!response.ok) {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
const uid = typeof responseBody?.user_id === "string" ? responseBody.user_id.trim() : "";
|
|
738
|
+
const idToken = typeof responseBody?.id_token === "string" ? responseBody.id_token.trim() : "";
|
|
739
|
+
const refreshToken = typeof responseBody?.refresh_token === "string" ? responseBody.refresh_token.trim() : "";
|
|
740
|
+
if (!uid || !idToken || !refreshToken) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
return { uid, idToken, refreshToken };
|
|
744
|
+
}
|
|
745
|
+
function readStudioAuthCache() {
|
|
746
|
+
const cachePath = getStudioAuthCachePath();
|
|
747
|
+
if (!existsSync(cachePath)) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const payload = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
752
|
+
const uid = typeof payload.uid === "string" ? payload.uid.trim() : "";
|
|
753
|
+
const idToken = typeof payload.id_token === "string" ? payload.id_token.trim() : "";
|
|
754
|
+
const refreshToken = typeof payload.refresh_token === "string" ? payload.refresh_token.trim() : "";
|
|
755
|
+
if (!uid || !refreshToken) {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
return { uid, idToken, refreshToken };
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function writeStudioAuthCache(credentials) {
|
|
765
|
+
await mkdir(getDefaultTapiDataDir(), { recursive: true });
|
|
766
|
+
await writeFile(getStudioAuthCachePath(), `${JSON.stringify({
|
|
767
|
+
uid: credentials.uid,
|
|
768
|
+
refresh_token: credentials.refreshToken,
|
|
769
|
+
id_token: credentials.idToken,
|
|
770
|
+
}, null, 2)}\n`, "utf8");
|
|
771
|
+
}
|
|
772
|
+
function getStudioAuthCachePath() {
|
|
773
|
+
return join(getDefaultTapiDataDir(), "auth.json");
|
|
774
|
+
}
|
|
775
|
+
function getDefaultTapiDataDir() {
|
|
776
|
+
if (process.platform === "win32") {
|
|
777
|
+
const localAppData = process.env.LOCALAPPDATA
|
|
778
|
+
?? (process.env.USERPROFILE ? join(process.env.USERPROFILE, "AppData", "Local") : join(homedir(), "AppData", "Local"));
|
|
779
|
+
return join(localAppData, "Tapi");
|
|
780
|
+
}
|
|
781
|
+
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "tapi");
|
|
782
|
+
}
|
|
783
|
+
function getStudioAuthHtmlUrl() {
|
|
784
|
+
return normalizeHttpUrl(envString("TAPI_STUDIO_AUTH_HTML_URL") ?? DEFAULT_STUDIO_AUTH_HTML_URL, "TAPI_STUDIO_AUTH_HTML_URL");
|
|
785
|
+
}
|
|
786
|
+
function getFirebaseApiKey() {
|
|
787
|
+
return (envString("TAPI_FIREBASE_API_KEY") ?? DEFAULT_FIREBASE_API_KEY).trim();
|
|
788
|
+
}
|
|
789
|
+
function openBrowser(url) {
|
|
790
|
+
const child = spawn("cmd", ["/c", "start", "", url], {
|
|
791
|
+
detached: true,
|
|
792
|
+
stdio: "ignore",
|
|
793
|
+
windowsHide: true,
|
|
794
|
+
});
|
|
795
|
+
child.unref();
|
|
796
|
+
}
|
|
797
|
+
async function readJsonBody(response) {
|
|
798
|
+
const text = await response.text();
|
|
799
|
+
if (!text.trim()) {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
return JSON.parse(text);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function isApprovalTerminalError(error) {
|
|
810
|
+
return error instanceof StudioInstallApprovalError
|
|
811
|
+
&& (error.code === "pending_approval" || error.code === "access_pending" || error.code === "access_rejected");
|
|
812
|
+
}
|
|
813
|
+
function renderBrowserSignInResponseHtml(success) {
|
|
814
|
+
return success
|
|
815
|
+
? "<!DOCTYPE html><html><body style='font-family:system-ui;text-align:center;padding:80px'><h2 style='color:#22c55e'>✓ Signed in!</h2><p>Return to your terminal.</p><script>setTimeout(()=>window.close(),1600)</script></body></html>"
|
|
816
|
+
: "<!DOCTYPE html><html><body style='font-family:system-ui;text-align:center;padding:80px'><h2 style='color:#ef4444'>⚠ Sign-in failed</h2><p>Return to your terminal for details.</p></body></html>";
|
|
817
|
+
}
|
|
281
818
|
async function hasVerifiedCachedInstaller(path, expectedSha256) {
|
|
282
819
|
if (!existsSync(path)) {
|
|
283
820
|
return false;
|
|
@@ -323,6 +860,34 @@ function cachedInstallerName(manifest) {
|
|
|
323
860
|
const rawName = manifest.artifactName || basename(new URL(manifest.url).pathname) || `TapiStudioSetup-${manifest.version}.exe`;
|
|
324
861
|
return rawName.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
325
862
|
}
|
|
863
|
+
function installEventOptions(options) {
|
|
864
|
+
return {
|
|
865
|
+
channel: options.channel,
|
|
866
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
867
|
+
manifestUrl: options.manifestUrl,
|
|
868
|
+
manifestUrlOverride: options.manifestUrlOverride,
|
|
869
|
+
cacheDir: options.cacheDir,
|
|
870
|
+
downloadOnly: options.downloadOnly,
|
|
871
|
+
silent: options.silent,
|
|
872
|
+
exePath: options.exePath,
|
|
873
|
+
installTokenProvided: Boolean(options.installToken),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function manifestEventSummary(manifest) {
|
|
877
|
+
return {
|
|
878
|
+
product: manifest.product,
|
|
879
|
+
version: manifest.version,
|
|
880
|
+
channel: manifest.channel,
|
|
881
|
+
platform: manifest.platform,
|
|
882
|
+
artifactName: manifest.artifactName,
|
|
883
|
+
url: manifest.url,
|
|
884
|
+
sha256: manifest.sha256,
|
|
885
|
+
sizeBytes: manifest.sizeBytes,
|
|
886
|
+
minSdkVersion: manifest.minSdkVersion,
|
|
887
|
+
commitShort: manifest.commitShort,
|
|
888
|
+
bundledService: manifest.bundledService,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
326
891
|
function parseChannel(value) {
|
|
327
892
|
if (value === "pilot" || value === "stable" || value === "nightly") {
|
|
328
893
|
return value;
|
|
@@ -409,7 +974,7 @@ function printHelp() {
|
|
|
409
974
|
console.log(`Tapi CLI
|
|
410
975
|
|
|
411
976
|
Usage:
|
|
412
|
-
tapi studio install [--channel pilot] [--
|
|
977
|
+
tapi studio install [--channel pilot] [--api-base-url URL]
|
|
413
978
|
tapi studio open
|
|
414
979
|
tapi studio doctor
|
|
415
980
|
tapi doctor
|
|
@@ -430,17 +995,63 @@ Usage:
|
|
|
430
995
|
tapi studio doctor [options]
|
|
431
996
|
|
|
432
997
|
Options:
|
|
433
|
-
--channel <name>
|
|
434
|
-
--
|
|
435
|
-
--
|
|
436
|
-
--
|
|
437
|
-
--
|
|
438
|
-
--
|
|
998
|
+
--channel <name> Release channel: pilot, stable, or nightly
|
|
999
|
+
--api-base-url <url> Tapi API base URL for approval and protected downloads
|
|
1000
|
+
--server <url> Alias for --api-base-url
|
|
1001
|
+
--install-token <tok> Preissued Studio install token (skips browser sign-in)
|
|
1002
|
+
--manifest <url> Exact release manifest URL for doctor only
|
|
1003
|
+
--cache-dir <path> Installer download cache directory
|
|
1004
|
+
--download-only Download and verify without running the installer
|
|
1005
|
+
--silent Run the NSIS installer with /S
|
|
1006
|
+
--exe <path> Tapi Studio executable path for open/doctor
|
|
439
1007
|
`);
|
|
440
1008
|
}
|
|
441
1009
|
function formatError(error) {
|
|
442
1010
|
return error instanceof Error ? error.message : String(error);
|
|
443
1011
|
}
|
|
1012
|
+
function errorDetails(error) {
|
|
1013
|
+
if (error instanceof Error) {
|
|
1014
|
+
const details = {
|
|
1015
|
+
name: error.name,
|
|
1016
|
+
message: error.message,
|
|
1017
|
+
stack: error.stack,
|
|
1018
|
+
};
|
|
1019
|
+
const cause = error.cause;
|
|
1020
|
+
if (cause) {
|
|
1021
|
+
details.cause = serializeError(cause);
|
|
1022
|
+
}
|
|
1023
|
+
return details;
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
message: String(error),
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function serializeError(error) {
|
|
1030
|
+
if (error instanceof Error) {
|
|
1031
|
+
const record = error;
|
|
1032
|
+
const details = {
|
|
1033
|
+
name: error.name,
|
|
1034
|
+
message: error.message,
|
|
1035
|
+
};
|
|
1036
|
+
for (const key of ["code", "errno", "syscall", "address", "port"]) {
|
|
1037
|
+
const value = record[key];
|
|
1038
|
+
if (value !== undefined) {
|
|
1039
|
+
details[key] = value;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const cause = error.cause;
|
|
1043
|
+
if (cause) {
|
|
1044
|
+
details.cause = serializeError(cause);
|
|
1045
|
+
}
|
|
1046
|
+
return details;
|
|
1047
|
+
}
|
|
1048
|
+
if (error && typeof error === "object") {
|
|
1049
|
+
return Object.fromEntries(Object.entries(error).filter(([, value]) => value !== undefined));
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
message: String(error),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
444
1055
|
function isCliEntrypoint() {
|
|
445
1056
|
const invokedPath = process.argv[1];
|
|
446
1057
|
return Boolean(invokedPath && resolve(invokedPath) === fileURLToPath(import.meta.url));
|
package/dist/types.d.ts
CHANGED
|
@@ -31,7 +31,10 @@ export interface TapiRunner {
|
|
|
31
31
|
}
|
|
32
32
|
export interface RuntimeRequirements {
|
|
33
33
|
daemonRequired: boolean;
|
|
34
|
+
serviceRequired?: boolean;
|
|
34
35
|
localControlUrl?: string;
|
|
36
|
+
serviceName?: string;
|
|
37
|
+
serviceVersion?: string;
|
|
35
38
|
windowsServiceName?: string;
|
|
36
39
|
[key: string]: unknown;
|
|
37
40
|
}
|