@spatius/cli 0.1.0-beta.0

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 ADDED
@@ -0,0 +1,2523 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ CliError,
4
+ asCliError,
5
+ buildProgram
6
+ } from "./chunk-YILIK7FE.js";
7
+
8
+ // src/cli.ts
9
+ import { CommanderError } from "commander";
10
+
11
+ // src/auth/index.ts
12
+ import { createHash as createHash2 } from "crypto";
13
+
14
+ // src/auth/client.ts
15
+ function object(value) {
16
+ if (!value || typeof value !== "object" || Array.isArray(value))
17
+ throw invalidResponse();
18
+ return value;
19
+ }
20
+ function string(value, key) {
21
+ const result = value[key];
22
+ if (typeof result !== "string" || !result.trim()) throw invalidResponse();
23
+ return result;
24
+ }
25
+ function invalidResponse() {
26
+ return new CliError(
27
+ "STUDIO_INVALID_RESPONSE",
28
+ "Studio returned an invalid response.",
29
+ { retryable: true }
30
+ );
31
+ }
32
+ var errorStatuses = {
33
+ UNAUTHORIZED: 401,
34
+ unauthorized: 401,
35
+ FORBIDDEN: 403,
36
+ PERMISSION_DENIED: 403,
37
+ forbidden: 403,
38
+ NOT_FOUND: 404,
39
+ not_found: 404,
40
+ INVALID_ARGUMENT: 400,
41
+ invalid_request: 400,
42
+ QUOTA_EXCEEDED: 429,
43
+ ALREADY_EXISTS: 409,
44
+ UNAVAILABLE: 503,
45
+ INTERNAL_SERVER_ERROR: 500
46
+ };
47
+ var StudioClient = class {
48
+ constructor(origin2, fetcher = globalThis.fetch) {
49
+ this.origin = origin2;
50
+ this.fetcher = fetcher;
51
+ }
52
+ origin;
53
+ fetcher;
54
+ async request(path, options = {}) {
55
+ const method = options.body === void 0 ? "GET" : "POST";
56
+ let response;
57
+ try {
58
+ response = await this.fetcher(new URL(path, this.origin), {
59
+ method,
60
+ headers: {
61
+ Accept: "application/json",
62
+ ...options.token ? { Authorization: `Bearer ${options.token}` } : {},
63
+ ...options.body === void 0 ? {} : { "Content-Type": "application/json" }
64
+ },
65
+ ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) },
66
+ redirect: "error",
67
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(3e4)]) : AbortSignal.timeout(3e4)
68
+ });
69
+ } catch {
70
+ if (options.signal?.aborted)
71
+ throw new CliError("INTERRUPTED", "Login was interrupted.", {
72
+ exitCode: 130
73
+ });
74
+ throw new CliError(
75
+ "STUDIO_UNAVAILABLE",
76
+ "The Studio request could not be completed.",
77
+ {
78
+ retryable: method === "GET",
79
+ recovery: "Check your connection and retry a read. Reconcile setup before retrying a creation."
80
+ }
81
+ );
82
+ }
83
+ let payload;
84
+ try {
85
+ const reader = response.body?.getReader();
86
+ const chunks = [];
87
+ let length = 0;
88
+ if (reader) {
89
+ try {
90
+ for (; ; ) {
91
+ const { done, value } = await reader.read();
92
+ if (done) break;
93
+ length += value.length;
94
+ if (length > 2 * 1024 * 1024) throw invalidResponse();
95
+ chunks.push(value);
96
+ }
97
+ } finally {
98
+ await reader.cancel().catch(() => void 0);
99
+ }
100
+ }
101
+ const text = Buffer.concat(chunks).toString("utf8");
102
+ payload = text.trim() ? object(JSON.parse(text)) : {};
103
+ } catch {
104
+ if (!response.ok) throw this.httpError(response.status);
105
+ throw invalidResponse();
106
+ }
107
+ const errors = payload.errors;
108
+ const raw = Array.isArray(errors) && errors.length ? errors[0] : payload.error;
109
+ if (raw !== void 0 && raw !== null) {
110
+ const detail = typeof raw === "object" ? raw : {};
111
+ const number = Number(detail.status);
112
+ const status = Number.isInteger(number) && number >= 400 && number <= 599 ? number : errorStatuses[String(detail.code)] ?? (response.ok ? 500 : response.status);
113
+ throw this.httpError(status);
114
+ }
115
+ if (!response.ok) throw this.httpError(response.status);
116
+ return payload;
117
+ }
118
+ httpError(status) {
119
+ return new CliError(
120
+ status === 401 ? "AUTH_REQUIRED" : status === 403 ? "STUDIO_FORBIDDEN" : status === 404 ? "STUDIO_NOT_FOUND" : "STUDIO_ERROR",
121
+ status === 401 ? "Your Studio login is no longer valid." : `Studio rejected the request (HTTP ${status}).`,
122
+ {
123
+ status,
124
+ retryable: status === 429 || status >= 500,
125
+ ...status === 401 ? { recovery: "Run spatius auth login." } : {}
126
+ }
127
+ );
128
+ }
129
+ };
130
+
131
+ // src/auth/login.ts
132
+ import { execFile } from "child_process";
133
+ import { createHash, randomBytes } from "crypto";
134
+ import { createServer } from "http";
135
+ import { promisify } from "util";
136
+ async function browserLogin(client, studioWebOrigin, options) {
137
+ if (options.signal?.aborted)
138
+ throw new CliError("INTERRUPTED", "Login was interrupted.", {
139
+ exitCode: 130
140
+ });
141
+ const verifier = randomBytes(32).toString("base64url");
142
+ const state = randomBytes(18).toString("base64url");
143
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
144
+ let redirectUri;
145
+ let requestId = "";
146
+ let complete = () => void 0;
147
+ let reject = () => void 0;
148
+ const callback = new Promise((resolve3, fail) => {
149
+ complete = resolve3;
150
+ reject = fail;
151
+ });
152
+ void callback.catch(() => void 0);
153
+ let settled = false;
154
+ const server = createServer((request, response) => {
155
+ response.setHeader("Cache-Control", "no-store");
156
+ response.setHeader("Referrer-Policy", "no-referrer");
157
+ response.setHeader("Content-Security-Policy", "default-src 'none'");
158
+ if (request.method !== "GET" || request.headers.host !== redirectUri.host || !request.url || settled) {
159
+ response.writeHead(404).end("Not found");
160
+ return;
161
+ }
162
+ const url = new URL(request.url, redirectUri);
163
+ const query = url.searchParams;
164
+ if (url.origin !== redirectUri.origin || url.pathname !== "/callback") {
165
+ response.writeHead(404).end("Not found");
166
+ return;
167
+ }
168
+ if (query.getAll("state").length !== 1 || query.get("state") !== state || query.getAll("auth_request_id").length !== 1 || query.get("auth_request_id") !== requestId) {
169
+ response.writeHead(400).end("Invalid authorization callback.");
170
+ return;
171
+ }
172
+ if (query.has("error")) {
173
+ settled = true;
174
+ response.writeHead(400).end("Authorization was declined. Return to your terminal.");
175
+ reject(
176
+ new CliError("AUTH_DECLINED", "Studio authorization was declined.")
177
+ );
178
+ return;
179
+ }
180
+ const code = query.get("auth_code");
181
+ if (!code || query.getAll("auth_code").length !== 1) {
182
+ response.writeHead(400).end("Invalid authorization callback.");
183
+ return;
184
+ }
185
+ settled = true;
186
+ response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end("Spatius authorization received. Return to your terminal.");
187
+ complete(code);
188
+ });
189
+ let timeout;
190
+ const onAbort = () => reject(
191
+ new CliError("INTERRUPTED", "Login was interrupted.", { exitCode: 130 })
192
+ );
193
+ try {
194
+ await new Promise((resolve3, fail) => {
195
+ server.once("error", fail);
196
+ server.listen(0, "127.0.0.1", () => {
197
+ server.off("error", fail);
198
+ resolve3();
199
+ });
200
+ }).catch(() => {
201
+ throw new CliError(
202
+ "AUTH_CALLBACK_UNAVAILABLE",
203
+ "The local login callback listener could not be started.",
204
+ {
205
+ recovery: "Allow local loopback connections, then run spatius auth login again."
206
+ }
207
+ );
208
+ });
209
+ redirectUri = new URL(
210
+ `http://127.0.0.1:${server.address().port}/callback`
211
+ );
212
+ const session = await client.request("/v1/cli/auth/sessions", {
213
+ body: {
214
+ clientName: "Spatius CLI",
215
+ codeChallenge: challenge,
216
+ codeChallengeMethod: "CLI_AUTH_CODE_CHALLENGE_METHOD_S256",
217
+ redirectUri: redirectUri.href,
218
+ state
219
+ },
220
+ signal: options.signal
221
+ });
222
+ requestId = string(session, "authRequestId");
223
+ const authorizeUrl = new URL(string(session, "authorizeUrl"));
224
+ if (authorizeUrl.origin !== studioWebOrigin || authorizeUrl.username || authorizeUrl.password || authorizeUrl.pathname !== `/cli/auth/${encodeURIComponent(requestId)}`) {
225
+ throw new CliError(
226
+ "UNSAFE_AUTH_URL",
227
+ "Studio returned an unexpected authorization URL."
228
+ );
229
+ }
230
+ const expiresAt = typeof session.expiresAt === "string" ? Date.parse(session.expiresAt) - Date.now() : Number.POSITIVE_INFINITY;
231
+ const expiresIn = typeof session.expiresIn === "number" && session.expiresIn > 0 ? session.expiresIn * 1e3 : Number.POSITIVE_INFINITY;
232
+ const requested = options.timeoutMs ?? 5 * 6e4;
233
+ if (!Number.isFinite(requested) || requested <= 0)
234
+ throw new CliError("INVALID_ARGUMENT", "Login timeout must be positive.");
235
+ const milliseconds = Math.max(
236
+ 1,
237
+ Math.min(
238
+ requested,
239
+ 10 * 6e4,
240
+ Number.isFinite(expiresAt) ? expiresAt : Infinity,
241
+ expiresIn
242
+ )
243
+ );
244
+ timeout = setTimeout(
245
+ () => reject(
246
+ new CliError(
247
+ "AUTH_TIMEOUT",
248
+ "Timed out waiting for Studio authorization.",
249
+ { recovery: "Run spatius auth login again." }
250
+ )
251
+ ),
252
+ milliseconds
253
+ );
254
+ options.signal?.addEventListener("abort", onAbort, { once: true });
255
+ if (options.signal?.aborted) onAbort();
256
+ options.onAuthorize?.(authorizeUrl.href);
257
+ if (!options.noBrowser) {
258
+ const executable = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
259
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", authorizeUrl.href] : [authorizeUrl.href];
260
+ await promisify(execFile)(executable, args, {
261
+ timeout: 1e4,
262
+ windowsHide: true
263
+ }).catch(() => void 0);
264
+ }
265
+ const authCode = await callback;
266
+ return await client.request("/v1/cli/auth/token", {
267
+ body: { authRequestId: requestId, authCode, codeVerifier: verifier },
268
+ signal: options.signal
269
+ });
270
+ } finally {
271
+ if (timeout) clearTimeout(timeout);
272
+ options.signal?.removeEventListener("abort", onAbort);
273
+ await new Promise((resolve3) => {
274
+ server.close(() => resolve3());
275
+ server.closeAllConnections();
276
+ });
277
+ }
278
+ }
279
+
280
+ // src/auth/storage.ts
281
+ import { randomUUID } from "crypto";
282
+ import { constants } from "fs";
283
+ import {
284
+ chmod,
285
+ lstat,
286
+ mkdir,
287
+ open,
288
+ readFile,
289
+ rename,
290
+ rm,
291
+ stat
292
+ } from "fs/promises";
293
+ import { homedir } from "os";
294
+ import { dirname, isAbsolute, join } from "path";
295
+ import { setTimeout as delay } from "timers/promises";
296
+ function ioError(error) {
297
+ return error?.code;
298
+ }
299
+ function defaultDirectory() {
300
+ if (process.platform === "win32")
301
+ return join(
302
+ process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
303
+ "Spatius"
304
+ );
305
+ const xdg = process.env.XDG_CONFIG_HOME;
306
+ return join(
307
+ xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".config"),
308
+ "spatius"
309
+ );
310
+ }
311
+ function unsafe() {
312
+ return new CliError(
313
+ "UNSAFE_AUTH_STORAGE",
314
+ "The credential path is not a private regular file or directory.",
315
+ {
316
+ recovery: "Use a private configuration directory owned by your user; remove symbolic links or shared credential files."
317
+ }
318
+ );
319
+ }
320
+ var AuthStorage = class {
321
+ directory;
322
+ filename;
323
+ constructor(directory = defaultDirectory()) {
324
+ if (!isAbsolute(directory))
325
+ throw new CliError(
326
+ "INVALID_CONFIG",
327
+ "The configuration directory must be an absolute path."
328
+ );
329
+ this.directory = directory;
330
+ this.filename = join(directory, "auth.json");
331
+ }
332
+ async ensureDirectory() {
333
+ await mkdir(this.directory, { recursive: true, mode: 448 });
334
+ const info = await lstat(this.directory);
335
+ if (!info.isDirectory() || info.isSymbolicLink() || process.getuid && info.uid !== process.getuid())
336
+ throw unsafe();
337
+ if (process.platform !== "win32") await chmod(this.directory, 448);
338
+ }
339
+ async read() {
340
+ try {
341
+ const directory = await lstat(this.directory);
342
+ if (!directory.isDirectory() || directory.isSymbolicLink() || process.getuid && directory.uid !== process.getuid())
343
+ throw unsafe();
344
+ } catch (error) {
345
+ if (ioError(error) === "ENOENT")
346
+ return { version: 1, active: {}, profiles: {} };
347
+ throw error;
348
+ }
349
+ let file;
350
+ try {
351
+ file = await open(
352
+ this.filename,
353
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
354
+ );
355
+ } catch (error) {
356
+ if (ioError(error) === "ENOENT")
357
+ return { version: 1, active: {}, profiles: {} };
358
+ if (ioError(error) === "ELOOP") throw unsafe();
359
+ throw error;
360
+ }
361
+ try {
362
+ const info = await file.stat();
363
+ if (!info.isFile() || info.nlink !== 1 || info.size > 1024 * 1024 || process.getuid && info.uid !== process.getuid() || process.platform !== "win32" && (info.mode & 63) !== 0)
364
+ throw unsafe();
365
+ const state = JSON.parse(await file.readFile("utf8"));
366
+ if (!validState(state)) throw new Error("Invalid auth state");
367
+ return state;
368
+ } catch (error) {
369
+ if (error instanceof CliError) throw error;
370
+ throw new CliError(
371
+ "AUTH_STATE_INVALID",
372
+ "The saved login state could not be read.",
373
+ {
374
+ recovery: "Restore the private auth.json file from a known backup, or remove it and run spatius auth login."
375
+ }
376
+ );
377
+ } finally {
378
+ await file.close();
379
+ }
380
+ }
381
+ async write(state) {
382
+ const temporary = join(this.directory, `.auth-${randomUUID()}.tmp`);
383
+ const file = await open(temporary, "wx", 384);
384
+ try {
385
+ await file.writeFile(`${JSON.stringify(state)}
386
+ `);
387
+ await file.sync();
388
+ } finally {
389
+ await file.close();
390
+ }
391
+ try {
392
+ await rename(temporary, this.filename);
393
+ if (process.platform !== "win32") {
394
+ const dir = await open(dirname(this.filename), "r");
395
+ try {
396
+ await dir.sync();
397
+ } finally {
398
+ await dir.close();
399
+ }
400
+ }
401
+ } finally {
402
+ await rm(temporary, { force: true });
403
+ }
404
+ }
405
+ async locked(operation) {
406
+ await this.ensureDirectory();
407
+ const lock = join(this.directory, ".auth-lock");
408
+ const ownerFile = join(lock, "owner.json");
409
+ const nonce = randomUUID();
410
+ const deadline = Date.now() + 45e3;
411
+ for (; ; ) {
412
+ try {
413
+ await mkdir(lock, { mode: 448 });
414
+ const owner = await open(ownerFile, "wx", 384);
415
+ try {
416
+ await owner.writeFile(JSON.stringify({ pid: process.pid, nonce }));
417
+ } finally {
418
+ await owner.close();
419
+ }
420
+ break;
421
+ } catch (error) {
422
+ if (ioError(error) !== "EEXIST") throw error;
423
+ const lockInfo = await lstat(lock).catch(() => void 0);
424
+ if (lockInfo && (!lockInfo.isDirectory() || lockInfo.isSymbolicLink()))
425
+ throw unsafe();
426
+ let abandoned = false;
427
+ try {
428
+ const owner = JSON.parse(await readFile(ownerFile, "utf8"));
429
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw unsafe();
430
+ try {
431
+ process.kill(owner.pid, 0);
432
+ } catch (error2) {
433
+ abandoned = ioError(error2) === "ESRCH";
434
+ }
435
+ } catch (error2) {
436
+ if (error2 instanceof CliError) throw error2;
437
+ const info = await stat(lock).catch(() => void 0);
438
+ abandoned = !!info && Date.now() - info.mtimeMs > 3e4;
439
+ }
440
+ if (abandoned) {
441
+ const stale = `${lock}.stale-${nonce}`;
442
+ try {
443
+ await rename(lock, stale);
444
+ await rm(stale, { recursive: true, force: true });
445
+ } catch (error2) {
446
+ if (ioError(error2) !== "ENOENT") throw error2;
447
+ }
448
+ continue;
449
+ }
450
+ if (Date.now() >= deadline)
451
+ throw new CliError(
452
+ "AUTH_BUSY",
453
+ "Another Spatius command is updating login state.",
454
+ {
455
+ retryable: true,
456
+ recovery: "Wait for the other command to finish, then retry."
457
+ }
458
+ );
459
+ await delay(50);
460
+ }
461
+ }
462
+ try {
463
+ return await operation(await this.read());
464
+ } finally {
465
+ const owner = JSON.parse(await readFile(ownerFile, "utf8"));
466
+ if (owner.nonce === nonce)
467
+ await rm(lock, { recursive: true, force: true });
468
+ }
469
+ }
470
+ };
471
+ function validState(value) {
472
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
473
+ const state = value;
474
+ if (state.version !== 1 || !state.active || !state.profiles || typeof state.active !== "object" || typeof state.profiles !== "object" || Array.isArray(state.active) || Array.isArray(state.profiles))
475
+ return false;
476
+ if (!Object.values(state.active).every((v) => typeof v === "string"))
477
+ return false;
478
+ return Object.values(state.profiles).every(
479
+ (profile) => profile && typeof profile === "object" && typeof profile.userId === "string" && typeof profile.consoleOrigin === "string" && typeof profile.studioOrigin === "string" && ["accessToken", "refreshToken", "expiresAt", "appId", "apiKey"].every(
480
+ (key) => profile[key] === void 0 || typeof profile[key] === "string"
481
+ ) && ["refreshPending", "pendingApp", "pendingKey"].every(
482
+ (key) => profile[key] === void 0 || typeof profile[key] === "boolean"
483
+ )
484
+ );
485
+ }
486
+
487
+ // src/auth/index.ts
488
+ var defaultAppName = "Spatius CLI";
489
+ var hash = (value) => createHash2("sha256").update(value).digest("hex");
490
+ function canonicalOrigin(value) {
491
+ const url = new URL(value);
492
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
493
+ throw new CliError(
494
+ "INVALID_CONFIG",
495
+ "Studio and Console origins must be HTTPS origins, or HTTP loopback origins for local development."
496
+ );
497
+ }
498
+ return url.origin;
499
+ }
500
+ function requireLogin() {
501
+ return new CliError(
502
+ "AUTH_REQUIRED",
503
+ "Log in to Spatius Studio before using this command.",
504
+ { recovery: "Run spatius auth login.", exitCode: 1 }
505
+ );
506
+ }
507
+ function relogin() {
508
+ return new CliError(
509
+ "AUTH_RELOGIN_REQUIRED",
510
+ "The previous token refresh did not complete safely.",
511
+ {
512
+ recovery: "Run spatius auth login. Do not retry the previous refresh token.",
513
+ exitCode: 1
514
+ }
515
+ );
516
+ }
517
+ function safeApp(app) {
518
+ return { appId: app.appId, name: app.name, createdAt: app.createdAt };
519
+ }
520
+ function parseApp(raw) {
521
+ const value = object(raw);
522
+ const keys = value.apiKeys === void 0 ? [] : value.apiKeys;
523
+ if (!Array.isArray(keys)) throw invalidResponse();
524
+ return {
525
+ appId: string(value, "appId"),
526
+ name: string(value, "name"),
527
+ createdAt: string(value, "createdAt"),
528
+ keys: keys.map((raw2) => {
529
+ const key = object(raw2);
530
+ return {
531
+ value: string(key, "apiKey"),
532
+ createdAt: string(key, "createdAt")
533
+ };
534
+ })
535
+ };
536
+ }
537
+ function applyTokens(profile, value) {
538
+ const token = object(value);
539
+ const access = string(token, "accessToken");
540
+ const refresh = string(token, "refreshToken");
541
+ let expiry = typeof token.expiresAt === "string" ? Date.parse(token.expiresAt) : NaN;
542
+ if (!Number.isFinite(expiry) && typeof token.expiresIn === "number" && token.expiresIn > 0)
543
+ expiry = Date.now() + token.expiresIn * 1e3;
544
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) throw invalidResponse();
545
+ profile.accessToken = access;
546
+ profile.refreshToken = refresh;
547
+ profile.expiresAt = new Date(expiry).toISOString();
548
+ delete profile.refreshPending;
549
+ }
550
+ var AuthManager = class {
551
+ studioOrigin;
552
+ studioWebOrigin;
553
+ consoleOrigin;
554
+ originKey;
555
+ storage;
556
+ client;
557
+ constructor(options) {
558
+ this.studioOrigin = canonicalOrigin(options.studioOrigin);
559
+ this.studioWebOrigin = canonicalOrigin(
560
+ options.studioWebOrigin ?? "https://app.spatius.ai"
561
+ );
562
+ this.consoleOrigin = canonicalOrigin(options.consoleOrigin);
563
+ this.originKey = hash(`${this.studioOrigin}
564
+ ${this.consoleOrigin}`);
565
+ this.storage = new AuthStorage(options.configDir);
566
+ this.client = new StudioClient(this.studioOrigin, options.fetch);
567
+ }
568
+ stateDirectory() {
569
+ return this.storage.directory;
570
+ }
571
+ async login(options = {}) {
572
+ const result = await browserLogin(
573
+ this.client,
574
+ this.studioWebOrigin,
575
+ options
576
+ );
577
+ const userId = string(object(result.user), "id");
578
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
579
+ userId
580
+ ))
581
+ throw invalidResponse();
582
+ return this.storage.locked(async (state) => {
583
+ const profileKey = this.profileKey(userId);
584
+ const profile = state.profiles[profileKey] ?? {
585
+ userId,
586
+ consoleOrigin: this.consoleOrigin,
587
+ studioOrigin: this.studioOrigin
588
+ };
589
+ applyTokens(profile, result.token);
590
+ state.profiles[profileKey] = profile;
591
+ state.active[this.originKey] = profileKey;
592
+ await this.storage.write(state);
593
+ return this.summary(profile);
594
+ });
595
+ }
596
+ async status() {
597
+ return this.storage.locked(async (state) => {
598
+ const profile = this.current(state, false);
599
+ if (!profile?.accessToken || !profile.refreshToken || profile.refreshPending)
600
+ return { authenticated: false };
601
+ try {
602
+ await this.verifyIdentity(state, profile);
603
+ return this.summary(profile);
604
+ } catch (error) {
605
+ if (error instanceof CliError && (error.options.status === 401 || error.code === "AUTH_RELOGIN_REQUIRED" || error.code === "AUTH_REQUIRED"))
606
+ return { authenticated: false };
607
+ throw error;
608
+ }
609
+ });
610
+ }
611
+ async logout() {
612
+ return this.storage.locked(async (state) => {
613
+ const profile = this.current(state, false);
614
+ if (!profile) return { authenticated: false, revoked: true };
615
+ let revoked = true;
616
+ try {
617
+ if (profile.refreshToken)
618
+ await this.client.request("/v1/cli/auth/token:revoke", {
619
+ body: { refreshToken: profile.refreshToken }
620
+ });
621
+ } catch {
622
+ revoked = false;
623
+ }
624
+ delete profile.accessToken;
625
+ delete profile.refreshToken;
626
+ delete profile.expiresAt;
627
+ delete profile.refreshPending;
628
+ delete profile.apiKey;
629
+ await this.storage.write(state);
630
+ return { authenticated: false, revoked };
631
+ });
632
+ }
633
+ async accessToken() {
634
+ return this.storage.locked(async (state) => {
635
+ const profile = this.current(state);
636
+ await this.ensureToken(state, profile);
637
+ return profile.accessToken;
638
+ });
639
+ }
640
+ async identity() {
641
+ return this.storage.locked(async (state) => {
642
+ const profile = this.current(state);
643
+ await this.verifyIdentity(state, profile);
644
+ return {
645
+ userId: profile.userId,
646
+ profileKey: this.profileKey(profile.userId)
647
+ };
648
+ });
649
+ }
650
+ async credentials() {
651
+ return this.storage.locked(async (state) => {
652
+ const profile = this.current(state);
653
+ await this.verifyIdentity(state, profile);
654
+ if (!profile.appId || !profile.apiKey)
655
+ throw new CliError(
656
+ "SETUP_REQUIRED",
657
+ "No app credentials are configured for this Studio account.",
658
+ { recovery: "Run spatius setup." }
659
+ );
660
+ const app = await this.getApp(state, profile, profile.appId);
661
+ if (!app.keys.some((key) => key.value === profile.apiKey)) {
662
+ delete profile.apiKey;
663
+ await this.storage.write(state);
664
+ throw new CliError(
665
+ "APP_KEY_UNAVAILABLE",
666
+ "The selected app key is no longer active.",
667
+ { recovery: "Run spatius setup to select an active key." }
668
+ );
669
+ }
670
+ return {
671
+ userId: profile.userId,
672
+ appId: profile.appId,
673
+ apiKey: profile.apiKey
674
+ };
675
+ });
676
+ }
677
+ async listApps() {
678
+ return this.storage.locked(async (state) => {
679
+ const profile = this.current(state);
680
+ await this.verifyIdentity(state, profile);
681
+ return (await this.apps(state, profile)).map(safeApp);
682
+ });
683
+ }
684
+ async setup(options = {}) {
685
+ return this.storage.locked(async (state) => {
686
+ const profile = this.current(state);
687
+ await this.verifyIdentity(state, profile);
688
+ let app;
689
+ let reused = true;
690
+ if (options.appId !== void 0) {
691
+ if (!options.appId.trim())
692
+ throw new CliError("INVALID_ARGUMENT", "An app ID is required.");
693
+ app = await this.getApp(state, profile, options.appId);
694
+ if (profile.appId !== app.appId) {
695
+ delete profile.apiKey;
696
+ delete profile.pendingKey;
697
+ }
698
+ profile.appId = app.appId;
699
+ delete profile.pendingApp;
700
+ await this.storage.write(state);
701
+ } else {
702
+ if (profile.appId) {
703
+ try {
704
+ app = await this.getApp(state, profile, profile.appId);
705
+ } catch (error) {
706
+ if (!(error instanceof CliError) || error.code !== "APP_UNAVAILABLE")
707
+ throw error;
708
+ delete profile.appId;
709
+ delete profile.apiKey;
710
+ delete profile.pendingKey;
711
+ await this.storage.write(state);
712
+ }
713
+ }
714
+ if (!app) {
715
+ app = this.matchApp(await this.apps(state, profile));
716
+ if (!app) {
717
+ if (profile.pendingApp && !options.retryUncertain)
718
+ throw this.uncertain("app");
719
+ profile.pendingApp = true;
720
+ await this.storage.write(state);
721
+ try {
722
+ const created = await this.authorized(
723
+ state,
724
+ profile,
725
+ (token) => this.client.request("/v1/apps", {
726
+ token,
727
+ body: { name: defaultAppName }
728
+ })
729
+ );
730
+ profile.appId = string(created, "appId");
731
+ delete profile.pendingApp;
732
+ await this.storage.write(state);
733
+ reused = false;
734
+ app = await this.getApp(state, profile, profile.appId);
735
+ } catch (error) {
736
+ if (profile.appId) throw error;
737
+ if (this.definiteRejection(error)) {
738
+ delete profile.pendingApp;
739
+ await this.storage.write(state);
740
+ throw error;
741
+ }
742
+ app = this.matchApp(await this.apps(state, profile));
743
+ if (!app) throw this.uncertain("app");
744
+ }
745
+ }
746
+ profile.appId = app.appId;
747
+ delete profile.pendingApp;
748
+ await this.storage.write(state);
749
+ }
750
+ }
751
+ if (!app) throw invalidResponse();
752
+ const selectKey = (candidate) => {
753
+ const cached = candidate.keys.find(
754
+ (key2) => key2.value === profile.apiKey
755
+ );
756
+ return cached?.value ?? [...candidate.keys].sort(
757
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || hash(a.value).localeCompare(hash(b.value))
758
+ )[0]?.value;
759
+ };
760
+ let key = selectKey(app);
761
+ if (!key) {
762
+ if (profile.pendingKey && !options.retryUncertain)
763
+ throw this.uncertain("key");
764
+ profile.pendingKey = true;
765
+ await this.storage.write(state);
766
+ try {
767
+ const response = await this.authorized(
768
+ state,
769
+ profile,
770
+ (token) => this.client.request(
771
+ `/v1/apps/${encodeURIComponent(app.appId)}/api-keys`,
772
+ { token, body: { appId: app.appId } }
773
+ )
774
+ );
775
+ key = string(object(response.apiKey), "apiKey");
776
+ } catch (error) {
777
+ if (this.definiteRejection(error)) {
778
+ delete profile.pendingKey;
779
+ await this.storage.write(state);
780
+ throw error;
781
+ }
782
+ key = selectKey(await this.getApp(state, profile, app.appId));
783
+ if (!key) throw this.uncertain("key");
784
+ }
785
+ }
786
+ profile.apiKey = key;
787
+ delete profile.pendingKey;
788
+ await this.storage.write(state);
789
+ return { userId: profile.userId, appId: app.appId, reused };
790
+ });
791
+ }
792
+ uncertain(kind) {
793
+ return new CliError(
794
+ "BOOTSTRAP_UNCERTAIN",
795
+ `Studio may have created the ${kind}, but it is not visible yet.`,
796
+ {
797
+ recovery: "Run spatius setup again to reconcile. Only if creation did not complete, use spatius setup --retry-uncertain; it can create a duplicate."
798
+ }
799
+ );
800
+ }
801
+ definiteRejection(error) {
802
+ return error instanceof CliError && error.options.status !== void 0 && error.options.status >= 400 && error.options.status < 500 && error.options.status !== 408;
803
+ }
804
+ profileKey(userId) {
805
+ return hash(`${this.originKey}
806
+ ${userId}`);
807
+ }
808
+ current(state, required = true) {
809
+ const key = state.active[this.originKey];
810
+ const profile = key ? state.profiles[key] : void 0;
811
+ if (!profile) {
812
+ if (required) throw requireLogin();
813
+ return void 0;
814
+ }
815
+ if (profile.consoleOrigin !== this.consoleOrigin || profile.studioOrigin !== this.studioOrigin || this.profileKey(profile.userId) !== key)
816
+ throw new CliError(
817
+ "AUTH_STATE_INVALID",
818
+ "The saved login profile does not match this environment."
819
+ );
820
+ if (required && profile.refreshPending) throw relogin();
821
+ if (required && (!profile.accessToken || !profile.refreshToken))
822
+ throw requireLogin();
823
+ return profile;
824
+ }
825
+ summary(profile) {
826
+ return {
827
+ authenticated: true,
828
+ userId: profile.userId,
829
+ profileKey: this.profileKey(profile.userId),
830
+ ...profile.appId ? { appId: profile.appId } : {},
831
+ expiresAt: profile.expiresAt
832
+ };
833
+ }
834
+ async refresh(state, profile) {
835
+ if (profile.refreshPending) throw relogin();
836
+ if (!profile.refreshToken) throw requireLogin();
837
+ profile.refreshPending = true;
838
+ await this.storage.write(state);
839
+ try {
840
+ const response = await this.client.request("/v1/cli/auth/token:refresh", {
841
+ body: { refreshToken: profile.refreshToken }
842
+ });
843
+ applyTokens(profile, response.token);
844
+ await this.storage.write(state);
845
+ } catch {
846
+ profile.refreshPending = true;
847
+ await this.storage.write(state);
848
+ throw relogin();
849
+ }
850
+ }
851
+ async ensureToken(state, profile) {
852
+ if (profile.refreshPending) throw relogin();
853
+ if (!profile.accessToken || !profile.refreshToken) throw requireLogin();
854
+ const expiry = Date.parse(profile.expiresAt ?? "");
855
+ if (!Number.isFinite(expiry) || expiry <= Date.now() + 6e4)
856
+ await this.refresh(state, profile);
857
+ }
858
+ async authorized(state, profile, operation) {
859
+ await this.ensureToken(state, profile);
860
+ try {
861
+ return await operation(profile.accessToken);
862
+ } catch (error) {
863
+ if (!(error instanceof CliError) || error.options.status !== 401)
864
+ throw error;
865
+ await this.refresh(state, profile);
866
+ return operation(profile.accessToken);
867
+ }
868
+ }
869
+ async verifyIdentity(state, profile) {
870
+ let response;
871
+ try {
872
+ response = await this.authorized(
873
+ state,
874
+ profile,
875
+ (token) => this.client.request("/v1/auth/me", { token })
876
+ );
877
+ } catch (error) {
878
+ if (error instanceof CliError && error.options.status === 404)
879
+ throw requireLogin();
880
+ throw error;
881
+ }
882
+ if (string(object(response.user), "id") !== profile.userId)
883
+ throw new CliError(
884
+ "AUTH_IDENTITY_MISMATCH",
885
+ "Studio returned a different account for the saved login.",
886
+ { recovery: "Run spatius auth logout, then spatius auth login." }
887
+ );
888
+ }
889
+ async getApp(state, profile, appId) {
890
+ try {
891
+ const response = await this.authorized(
892
+ state,
893
+ profile,
894
+ (token) => this.client.request(`/v1/apps/${encodeURIComponent(appId)}`, { token })
895
+ );
896
+ const app = parseApp(response.app);
897
+ if (app.appId !== appId) throw invalidResponse();
898
+ return app;
899
+ } catch (error) {
900
+ if (error instanceof CliError && error.options.status === 404)
901
+ throw new CliError(
902
+ "APP_UNAVAILABLE",
903
+ "The selected app does not exist or does not belong to this Studio account.",
904
+ {
905
+ recovery: "Run spatius apps list, then spatius setup --app-id <APP_ID>."
906
+ }
907
+ );
908
+ throw error;
909
+ }
910
+ }
911
+ async apps(state, profile) {
912
+ const result = /* @__PURE__ */ new Map();
913
+ const seen = /* @__PURE__ */ new Set();
914
+ let pageToken = "";
915
+ do {
916
+ const query = new URLSearchParams({ "pagination.pageSize": "100" });
917
+ if (pageToken) query.set("pagination.pageToken", pageToken);
918
+ const response = await this.authorized(
919
+ state,
920
+ profile,
921
+ (token) => this.client.request(`/v1/apps?${query}`, { token })
922
+ );
923
+ const apps = response.apps === void 0 ? [] : response.apps;
924
+ if (!Array.isArray(apps)) throw invalidResponse();
925
+ for (const raw of apps) {
926
+ const app = parseApp(raw);
927
+ result.set(app.appId, app);
928
+ }
929
+ const pagination = response.pagination === void 0 ? {} : object(response.pagination);
930
+ if (pagination.nextPageToken !== void 0 && typeof pagination.nextPageToken !== "string")
931
+ throw invalidResponse();
932
+ pageToken = pagination.nextPageToken ?? "";
933
+ if (pageToken && (seen.has(pageToken) || seen.size >= 1e3))
934
+ throw invalidResponse();
935
+ seen.add(pageToken);
936
+ } while (pageToken);
937
+ return [...result.values()];
938
+ }
939
+ matchApp(apps) {
940
+ const matches = apps.filter((app) => app.name === defaultAppName).sort(
941
+ (a, b) => a.createdAt.localeCompare(b.createdAt) || a.appId.localeCompare(b.appId)
942
+ );
943
+ if (matches.length > 1)
944
+ throw new CliError(
945
+ "APP_SELECTION_REQUIRED",
946
+ "More than one Spatius CLI app exists for this account.",
947
+ {
948
+ details: { apps: matches.map(safeApp) },
949
+ recovery: "Choose an app explicitly with spatius setup --app-id <APP_ID>."
950
+ }
951
+ );
952
+ return matches[0];
953
+ }
954
+ };
955
+
956
+ // src/workflows/index.ts
957
+ import { createHash as createHash4, randomUUID as randomUUID3 } from "crypto";
958
+ import { open as open4, mkdir as mkdir3, link, rename as rename3, unlink as unlink2, stat as stat2 } from "fs/promises";
959
+ import { dirname as dirname2, resolve as resolve2 } from "path";
960
+ import { setTimeout as delay3 } from "timers/promises";
961
+
962
+ // ../contracts/src/index.ts
963
+ var MIB = 1024 * 1024;
964
+ var PART_SIZE = 8 * MIB;
965
+ var INPUT_TTL_MS = 24 * 60 * 60 * 1e3;
966
+ var UPLOAD_TTL_MS = 60 * 60 * 1e3;
967
+ var DEFAULT_LIMITS = {
968
+ maxBytes: 2 * 1024 * MIB,
969
+ uploadsPerDay: 100,
970
+ unfinished: 10,
971
+ requestsPerSecond: 10,
972
+ burst: 20
973
+ };
974
+ var MEDIA = {
975
+ "avatar-image": { maxBytes: 5 * MIB, types: ["image/jpeg", "image/png"] },
976
+ audio: {
977
+ maxBytes: 500 * MIB,
978
+ types: [
979
+ "audio/mpeg",
980
+ "audio/mp3",
981
+ "audio/wav",
982
+ "audio/x-wav",
983
+ "audio/wave",
984
+ "audio/vnd.wave",
985
+ "audio/mp4",
986
+ "audio/x-m4a",
987
+ "audio/aac",
988
+ "audio/ogg"
989
+ ]
990
+ },
991
+ background: {
992
+ maxBytes: 50 * MIB,
993
+ types: ["image/png", "image/jpeg", "image/webp"]
994
+ }
995
+ };
996
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
997
+ function validMedia(kind, contentType, size) {
998
+ return Number.isSafeInteger(size) && size > 0 && size <= MEDIA[kind].maxBytes && MEDIA[kind].types.includes(contentType);
999
+ }
1000
+ function matchesMediaSignature(type, bytes) {
1001
+ const ascii = (start, length) => String.fromCharCode(...bytes.subarray(start, start + length));
1002
+ if (type === "image/png")
1003
+ return bytes.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v);
1004
+ if (type === "image/jpeg")
1005
+ return bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
1006
+ if (type === "image/webp")
1007
+ return ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP";
1008
+ if (["audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave"].includes(type))
1009
+ return ["RIFF", "RF64"].includes(ascii(0, 4)) && ascii(8, 4) === "WAVE";
1010
+ if (type === "audio/ogg") return ascii(0, 4) === "OggS";
1011
+ if (["audio/mp4", "audio/x-m4a"].includes(type))
1012
+ return ascii(4, 4) === "ftyp";
1013
+ if (["audio/mpeg", "audio/mp3"].includes(type))
1014
+ return ascii(0, 3) === "ID3" || bytes[0] === 255 && ((bytes[1] ?? 0) & 224) === 224;
1015
+ if (type === "audio/aac")
1016
+ return bytes[0] === 255 && ((bytes[1] ?? 0) & 246) === 240;
1017
+ return false;
1018
+ }
1019
+ var VIDEO_DEFAULTS = {
1020
+ width: 1024,
1021
+ height: 1024,
1022
+ fit: "crop",
1023
+ backgroundColor: "#000000",
1024
+ backgroundFit: "cover",
1025
+ leadInSeconds: 0,
1026
+ leadOutSeconds: 0
1027
+ };
1028
+
1029
+ // src/core/http.ts
1030
+ import { setTimeout as delay2 } from "timers/promises";
1031
+ async function readJson(response, maxBytes = 2 * 1024 * 1024) {
1032
+ const reader = response.body?.getReader();
1033
+ if (!reader)
1034
+ throw new CliError(
1035
+ "INVALID_RESPONSE",
1036
+ "The service returned an empty response."
1037
+ );
1038
+ const chunks = [];
1039
+ let size = 0;
1040
+ try {
1041
+ while (true) {
1042
+ const { done, value } = await reader.read();
1043
+ if (done) break;
1044
+ size += value.byteLength;
1045
+ if (size > maxBytes)
1046
+ throw new CliError(
1047
+ "INVALID_RESPONSE",
1048
+ "The service response exceeded the size limit."
1049
+ );
1050
+ chunks.push(value);
1051
+ }
1052
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1053
+ } catch (error) {
1054
+ await reader.cancel().catch(() => void 0);
1055
+ if (error instanceof CliError) throw error;
1056
+ throw new CliError(
1057
+ "INVALID_RESPONSE",
1058
+ "The service returned invalid JSON."
1059
+ );
1060
+ } finally {
1061
+ reader.releaseLock();
1062
+ }
1063
+ }
1064
+ function retryAfterSeconds(value) {
1065
+ if (!value) return void 0;
1066
+ const seconds = Number(value);
1067
+ const result = Number.isFinite(seconds) ? seconds : (Date.parse(value) - Date.now()) / 1e3;
1068
+ return Number.isFinite(result) ? Math.max(0, Math.ceil(result)) : void 0;
1069
+ }
1070
+ async function requestJson(url, options = {}) {
1071
+ const method = options.method ?? "GET";
1072
+ const retries = options.retry ?? method === "GET" ? 3 : 0;
1073
+ const send = options.fetch ?? globalThis.fetch;
1074
+ for (let attempt = 0; ; attempt++) {
1075
+ options.signal?.throwIfAborted();
1076
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 3e4);
1077
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
1078
+ let failure;
1079
+ try {
1080
+ const response = await send(url, {
1081
+ method,
1082
+ headers: {
1083
+ accept: "application/json",
1084
+ ...options.body === void 0 ? {} : { "content-type": "application/json" },
1085
+ ...options.headers
1086
+ },
1087
+ ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) },
1088
+ redirect: "error",
1089
+ signal
1090
+ });
1091
+ if (response.ok) return await readJson(response);
1092
+ const status = response.status;
1093
+ let code = `HTTP_${status}`;
1094
+ try {
1095
+ const data = await readJson(response, 64 * 1024);
1096
+ if (typeof data?.error?.code === "string" && /^[a-zA-Z0-9_.-]{1,100}$/.test(data.error.code))
1097
+ code = data.error.code;
1098
+ } catch {
1099
+ }
1100
+ const requestId = response.headers.get("x-request-id");
1101
+ failure = new CliError(
1102
+ code,
1103
+ `The service rejected the request (HTTP ${status}).`,
1104
+ {
1105
+ status,
1106
+ retryable: status === 429 || status >= 500,
1107
+ retryAfter: retryAfterSeconds(response.headers.get("retry-after")),
1108
+ recovery: status === 401 ? "Run spatius auth login or spatius setup to restore credentials." : status === 403 ? "Ask an administrator to verify API access and avatar permissions for this account." : status === 402 ? "Check your Avatar Creations balance in Studio." : status === 409 ? "Reuse the original request input or explicitly start a new operation." : "Inspect the operation state before retrying a creation.",
1109
+ ...requestId && /^[a-zA-Z0-9_-]{1,100}$/.test(requestId) ? { details: { requestId } } : {}
1110
+ }
1111
+ );
1112
+ } catch (error) {
1113
+ options.signal?.throwIfAborted();
1114
+ failure = error instanceof CliError ? error : new CliError(
1115
+ "NETWORK_ERROR",
1116
+ "The service request did not complete.",
1117
+ {
1118
+ retryable: true,
1119
+ recovery: "Check connectivity. Resume a saved creation instead of creating again."
1120
+ }
1121
+ );
1122
+ }
1123
+ if (attempt >= retries || !failure.options.retryable) throw failure;
1124
+ const waitSeconds = failure.options.retryAfter ?? Math.min(8, 2 ** attempt);
1125
+ if (waitSeconds > 30) throw failure;
1126
+ await delay2(waitSeconds * 1e3, void 0, { signal: options.signal });
1127
+ }
1128
+ }
1129
+
1130
+ // src/client/media.ts
1131
+ var MediaClient = class {
1132
+ constructor(options) {
1133
+ this.options = options;
1134
+ }
1135
+ options;
1136
+ async request(path, method = "GET", body, timeoutMs = 3e4, signal = this.options.signal) {
1137
+ try {
1138
+ return await requestJson(`${this.options.origin}${path}`, {
1139
+ method,
1140
+ body,
1141
+ headers: { authorization: `Bearer ${await this.options.token()}` },
1142
+ fetch: this.options.fetch,
1143
+ signal,
1144
+ retry: method === "GET",
1145
+ timeoutMs
1146
+ });
1147
+ } catch (error) {
1148
+ if (error instanceof CliError && error.code === "upload_busy")
1149
+ error.options.retryable = true;
1150
+ throw error;
1151
+ }
1152
+ }
1153
+ create(body) {
1154
+ return this.request("/v1/uploads", "POST", body);
1155
+ }
1156
+ get(id, signal) {
1157
+ return this.request(
1158
+ `/v1/uploads/${id}`,
1159
+ "GET",
1160
+ void 0,
1161
+ 3e4,
1162
+ signal ?? this.options.signal
1163
+ );
1164
+ }
1165
+ complete(id) {
1166
+ return this.request(
1167
+ `/v1/uploads/${id}/complete`,
1168
+ "POST",
1169
+ {},
1170
+ 3e5
1171
+ );
1172
+ }
1173
+ abort(id) {
1174
+ return this.request(`/v1/uploads/${id}`, "DELETE");
1175
+ }
1176
+ async part(id, number, bytes, sha256) {
1177
+ const signal = AbortSignal.any([
1178
+ AbortSignal.timeout(3e5),
1179
+ ...this.options.signal ? [this.options.signal] : []
1180
+ ]);
1181
+ let response;
1182
+ try {
1183
+ response = await (this.options.fetch ?? fetch)(
1184
+ `${this.options.origin}/v1/uploads/${id}/parts/${number}`,
1185
+ {
1186
+ method: "PUT",
1187
+ headers: {
1188
+ authorization: `Bearer ${await this.options.token()}`,
1189
+ "content-type": "application/octet-stream",
1190
+ "content-length": String(bytes.byteLength),
1191
+ "x-content-sha256": sha256
1192
+ },
1193
+ body: bytes,
1194
+ redirect: "error",
1195
+ signal
1196
+ }
1197
+ );
1198
+ } catch {
1199
+ this.options.signal?.throwIfAborted();
1200
+ throw new CliError(
1201
+ "UPLOAD_TRANSPORT_ERROR",
1202
+ "The upload part response was not received.",
1203
+ { retryable: true }
1204
+ );
1205
+ }
1206
+ const text = await boundedText(response);
1207
+ if (!response.ok) {
1208
+ let code = "UPLOAD_FAILED";
1209
+ try {
1210
+ const data = JSON.parse(text);
1211
+ if (typeof data?.error?.code === "string" && /^[a-zA-Z0-9_]+$/.test(data.error.code))
1212
+ code = data.error.code;
1213
+ } catch {
1214
+ }
1215
+ const retryAfter = Number(response.headers.get("retry-after"));
1216
+ throw new CliError(
1217
+ code,
1218
+ "The temporary media service rejected the upload part.",
1219
+ {
1220
+ status: response.status,
1221
+ retryable: response.status === 429 || response.status >= 500 || code === "upload_busy",
1222
+ ...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfter } : {}
1223
+ }
1224
+ );
1225
+ }
1226
+ try {
1227
+ return JSON.parse(text);
1228
+ } catch {
1229
+ throw new CliError(
1230
+ "INVALID_RESPONSE",
1231
+ "The upload service returned an invalid response.",
1232
+ { retryable: true }
1233
+ );
1234
+ }
1235
+ }
1236
+ };
1237
+ async function boundedText(response, max = 1024 * 1024) {
1238
+ if (!response.body) return "";
1239
+ const reader = response.body.getReader();
1240
+ const chunks = [];
1241
+ let size = 0;
1242
+ try {
1243
+ for (; ; ) {
1244
+ const next = await reader.read();
1245
+ if (next.done) break;
1246
+ size += next.value.byteLength;
1247
+ if (size > max)
1248
+ throw new CliError(
1249
+ "INVALID_RESPONSE",
1250
+ "The service response exceeded its size limit."
1251
+ );
1252
+ chunks.push(next.value);
1253
+ }
1254
+ return Buffer.concat(chunks).toString("utf8");
1255
+ } finally {
1256
+ await reader.cancel().catch(() => {
1257
+ });
1258
+ }
1259
+ }
1260
+
1261
+ // src/workflows/state.ts
1262
+ import { randomUUID as randomUUID2 } from "crypto";
1263
+ import {
1264
+ mkdir as mkdir2,
1265
+ readFile as readFile2,
1266
+ rename as rename2,
1267
+ unlink,
1268
+ writeFile,
1269
+ chmod as chmod2,
1270
+ open as open2
1271
+ } from "fs/promises";
1272
+ import { join as join2 } from "path";
1273
+ function identifier(value, label = "ID") {
1274
+ if (!UUID_PATTERN.test(value))
1275
+ throw new CliError("INVALID_ARGUMENT", `${label} must be a UUID.`, {
1276
+ exitCode: 2
1277
+ });
1278
+ return value.toLowerCase();
1279
+ }
1280
+ var StateStore = class {
1281
+ directory;
1282
+ constructor(root, profileKey) {
1283
+ if (!/^[a-zA-Z0-9_-]+$/.test(profileKey))
1284
+ throw new CliError("INVALID_PROFILE", "Invalid account profile.");
1285
+ this.directory = join2(root, "operations", profileKey);
1286
+ }
1287
+ async initialize() {
1288
+ await mkdir2(this.directory, { recursive: true, mode: 448 });
1289
+ await chmod2(this.directory, 448);
1290
+ }
1291
+ async read(id) {
1292
+ try {
1293
+ return JSON.parse(
1294
+ await readFile2(join2(this.directory, `${identifier(id)}.json`), "utf8")
1295
+ );
1296
+ } catch (error) {
1297
+ if (error instanceof CliError) throw error;
1298
+ if (error.code !== "ENOENT")
1299
+ throw new CliError(
1300
+ "OPERATION_UNREADABLE",
1301
+ "The saved operation cannot be read. Preserve it for recovery; do not submit another creation automatically."
1302
+ );
1303
+ throw new CliError(
1304
+ "OPERATION_NOT_FOUND",
1305
+ "No saved operation exists for this account and ID.",
1306
+ {
1307
+ recovery: "Use the operation ID returned by the original command under the same Studio account."
1308
+ }
1309
+ );
1310
+ }
1311
+ }
1312
+ async write(id, value) {
1313
+ await this.initialize();
1314
+ const target = join2(this.directory, `${identifier(id)}.json`);
1315
+ const temporary = `${target}.${randomUUID2()}.tmp`;
1316
+ try {
1317
+ const handle = await open2(temporary, "wx", 384);
1318
+ try {
1319
+ await handle.writeFile(JSON.stringify(value));
1320
+ await handle.sync();
1321
+ } finally {
1322
+ await handle.close();
1323
+ }
1324
+ await rename2(temporary, target);
1325
+ if (process.platform !== "win32") {
1326
+ const directory = await open2(this.directory, "r");
1327
+ try {
1328
+ await directory.sync();
1329
+ } finally {
1330
+ await directory.close();
1331
+ }
1332
+ }
1333
+ } finally {
1334
+ await unlink(temporary).catch(() => {
1335
+ });
1336
+ }
1337
+ }
1338
+ async locked(id, run) {
1339
+ await this.initialize();
1340
+ const path = join2(this.directory, `${identifier(id)}.lock`);
1341
+ for (let attempt = 0; ; attempt++) {
1342
+ try {
1343
+ await writeFile(path, String(process.pid), { mode: 384, flag: "wx" });
1344
+ break;
1345
+ } catch (error) {
1346
+ if (error.code !== "EEXIST") throw error;
1347
+ let alive = true;
1348
+ try {
1349
+ const pid = Number(await readFile2(path, "utf8"));
1350
+ if (pid > 0) process.kill(pid, 0);
1351
+ } catch (cause) {
1352
+ if (cause.code === "ESRCH") alive = false;
1353
+ }
1354
+ if (alive || attempt > 0)
1355
+ throw new CliError(
1356
+ "OPERATION_BUSY",
1357
+ "This operation is already running in another process.",
1358
+ { retryable: true }
1359
+ );
1360
+ await unlink(path).catch(() => {
1361
+ });
1362
+ }
1363
+ }
1364
+ try {
1365
+ return await run();
1366
+ } finally {
1367
+ await unlink(path).catch(() => {
1368
+ });
1369
+ }
1370
+ }
1371
+ };
1372
+
1373
+ // src/workflows/media.ts
1374
+ import { createHash as createHash3 } from "crypto";
1375
+ import { open as open3 } from "fs/promises";
1376
+ import { resolve } from "path";
1377
+ function sourceUrl(input) {
1378
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) return void 0;
1379
+ let url;
1380
+ try {
1381
+ url = new URL(input);
1382
+ } catch {
1383
+ throw new CliError("INVALID_ARGUMENT", "The source URL is invalid.", {
1384
+ exitCode: 2
1385
+ });
1386
+ }
1387
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || input.length > 4096 || /\s/.test(input)) {
1388
+ throw new CliError(
1389
+ "INVALID_ARGUMENT",
1390
+ "Sources require HTTP(S) URLs without embedded credentials, at most 4096 characters.",
1391
+ { exitCode: 2 }
1392
+ );
1393
+ }
1394
+ return input;
1395
+ }
1396
+ async function inspectMedia(file, kind, signal) {
1397
+ const path = resolve(file);
1398
+ const handle = await open3(path, "r").catch(() => {
1399
+ throw new CliError("FILE_UNREADABLE", "The input file cannot be read.", {
1400
+ exitCode: 2
1401
+ });
1402
+ });
1403
+ try {
1404
+ const stat3 = await handle.stat();
1405
+ if (!stat3.isFile() || stat3.size <= 0 || stat3.size > MEDIA[kind].maxBytes) {
1406
+ throw new CliError(
1407
+ "INVALID_MEDIA",
1408
+ `Input must be a nonempty regular file of at most ${MEDIA[kind].maxBytes} bytes.`,
1409
+ { exitCode: 2 }
1410
+ );
1411
+ }
1412
+ const header = Buffer.alloc(32);
1413
+ const first = await handle.read(header, 0, header.length, 0);
1414
+ const types = [...MEDIA[kind].types].sort(
1415
+ (a, b) => Number(b === "audio/aac") - Number(a === "audio/aac")
1416
+ );
1417
+ const contentType = types.find(
1418
+ (type) => matchesMediaSignature(type, header.subarray(0, first.bytesRead))
1419
+ );
1420
+ if (!contentType || !validMedia(kind, contentType, stat3.size))
1421
+ throw new CliError(
1422
+ "UNSUPPORTED_MEDIA",
1423
+ "The input bytes do not match a supported media type.",
1424
+ { exitCode: 2 }
1425
+ );
1426
+ const hash2 = createHash3("sha256");
1427
+ const buffer = Buffer.alloc(1024 * 1024);
1428
+ let offset = 0;
1429
+ while (offset < stat3.size) {
1430
+ signal?.throwIfAborted();
1431
+ const { bytesRead } = await handle.read(
1432
+ buffer,
1433
+ 0,
1434
+ Math.min(buffer.length, stat3.size - offset),
1435
+ offset
1436
+ );
1437
+ if (!bytesRead)
1438
+ throw new CliError(
1439
+ "INPUT_CHANGED",
1440
+ "The input file changed while being read."
1441
+ );
1442
+ hash2.update(buffer.subarray(0, bytesRead));
1443
+ offset += bytesRead;
1444
+ }
1445
+ const after = await handle.stat();
1446
+ if (after.size !== stat3.size || after.mtimeMs !== stat3.mtimeMs)
1447
+ throw new CliError(
1448
+ "INPUT_CHANGED",
1449
+ "The input file changed while being read."
1450
+ );
1451
+ return { path, size: stat3.size, contentType, sha256: hash2.digest("hex") };
1452
+ } finally {
1453
+ await handle.close();
1454
+ }
1455
+ }
1456
+
1457
+ // src/workflows/index.ts
1458
+ var Workflows = class {
1459
+ constructor(options) {
1460
+ this.options = options;
1461
+ this.media = new MediaClient({
1462
+ origin: options.mediaOrigin.replace(/\/$/, ""),
1463
+ token: () => options.auth.accessToken(),
1464
+ fetch: options.fetch,
1465
+ signal: options.signal
1466
+ });
1467
+ }
1468
+ options;
1469
+ media;
1470
+ progress(event) {
1471
+ this.options.onProgress?.(event);
1472
+ }
1473
+ async context() {
1474
+ const identity = await this.options.auth.identity();
1475
+ return {
1476
+ ...identity,
1477
+ store: new StateStore(
1478
+ this.options.auth.stateDirectory(),
1479
+ identity.profileKey
1480
+ )
1481
+ };
1482
+ }
1483
+ checkScope(journal, userId) {
1484
+ if (journal.userId !== userId || journal.consoleOrigin !== this.options.consoleOrigin || journal.mediaOrigin !== this.options.mediaOrigin) {
1485
+ throw new CliError(
1486
+ "OPERATION_SCOPE_MISMATCH",
1487
+ "The saved operation belongs to another account or service environment."
1488
+ );
1489
+ }
1490
+ }
1491
+ async upload(file, options) {
1492
+ const local = await inspectMedia(file, options.kind, this.options.signal);
1493
+ const { store, userId } = await this.context();
1494
+ const id = options.resume ? identifier(options.resume, "Upload operation ID") : randomUUID3();
1495
+ return store.locked(id, async () => {
1496
+ let journal;
1497
+ if (options.resume) {
1498
+ try {
1499
+ journal = await store.read(id);
1500
+ } catch (error) {
1501
+ if (!(error instanceof CliError) || error.code !== "OPERATION_NOT_FOUND")
1502
+ throw error;
1503
+ const remote = await this.media.get(id);
1504
+ journal = {
1505
+ version: 1,
1506
+ type: "upload",
1507
+ id,
1508
+ userId,
1509
+ consoleOrigin: this.options.consoleOrigin,
1510
+ mediaOrigin: this.options.mediaOrigin,
1511
+ kind: options.kind,
1512
+ file: local,
1513
+ upload: remote
1514
+ };
1515
+ }
1516
+ this.checkScope(journal, userId);
1517
+ if (journal.type !== "upload" || journal.kind !== options.kind || journal.file.sha256 !== local.sha256 || journal.file.size !== local.size || journal.file.contentType !== local.contentType) {
1518
+ throw new CliError(
1519
+ "INPUT_CHANGED",
1520
+ "Upload resume requires the original file bytes and media kind."
1521
+ );
1522
+ }
1523
+ } else {
1524
+ journal = {
1525
+ version: 1,
1526
+ type: "upload",
1527
+ id,
1528
+ userId,
1529
+ consoleOrigin: this.options.consoleOrigin,
1530
+ mediaOrigin: this.options.mediaOrigin,
1531
+ kind: options.kind,
1532
+ file: local
1533
+ };
1534
+ await store.write(id, journal);
1535
+ }
1536
+ this.progress({ stage: "uploading", operationId: id });
1537
+ try {
1538
+ let remote = journal.upload ? await this.media.get(journal.upload.id) : await this.media.create({
1539
+ requestId: id,
1540
+ kind: options.kind,
1541
+ size: local.size,
1542
+ contentType: local.contentType,
1543
+ sha256: local.sha256
1544
+ });
1545
+ journal.upload = remote;
1546
+ await store.write(id, journal);
1547
+ const validate = () => {
1548
+ if (remote.sha256 !== local.sha256 || remote.size !== local.size || remote.kind !== options.kind || remote.partSize !== PART_SIZE)
1549
+ throw new CliError(
1550
+ "INVALID_RESPONSE",
1551
+ "The upload service returned incompatible file metadata."
1552
+ );
1553
+ if (["expired", "aborted", "aborting"].includes(remote.status))
1554
+ throw new CliError(
1555
+ "UPLOAD_EXPIRED",
1556
+ "This upload is no longer resumable.",
1557
+ {
1558
+ recovery: "Start a new upload. Do not replace input URLs in an uncertain video submission."
1559
+ }
1560
+ );
1561
+ };
1562
+ validate();
1563
+ remote = await this.settleUpload(remote, id);
1564
+ validate();
1565
+ journal.upload = remote;
1566
+ await store.write(id, journal);
1567
+ if (remote.status === "completed")
1568
+ return this.completedUpload(remote, id);
1569
+ const handle = await open4(local.path, "r");
1570
+ try {
1571
+ for (let number = 1; number <= Math.ceil(local.size / PART_SIZE); number++) {
1572
+ if (remote.status === "completed") break;
1573
+ if (remote.acceptedParts.includes(number)) continue;
1574
+ const length = Math.min(
1575
+ PART_SIZE,
1576
+ local.size - (number - 1) * PART_SIZE
1577
+ );
1578
+ const bytes = Buffer.alloc(length);
1579
+ let read = 0;
1580
+ while (read < length) {
1581
+ const result = await handle.read(
1582
+ bytes,
1583
+ read,
1584
+ length - read,
1585
+ (number - 1) * PART_SIZE + read
1586
+ );
1587
+ if (!result.bytesRead)
1588
+ throw new CliError(
1589
+ "INPUT_CHANGED",
1590
+ "The file changed during upload."
1591
+ );
1592
+ read += result.bytesRead;
1593
+ }
1594
+ const hash2 = createHash4("sha256").update(bytes).digest("hex");
1595
+ for (let attempt = 0; ; attempt++) {
1596
+ try {
1597
+ remote = await this.media.part(remote.id, number, bytes, hash2);
1598
+ break;
1599
+ } catch (error) {
1600
+ if (!(error instanceof CliError) || !error.options.retryable || (error.options.retryAfter ?? 0) > 30 || attempt >= 4)
1601
+ throw error;
1602
+ await this.pause(
1603
+ Math.max(
1604
+ error.options.retryAfter ?? 0,
1605
+ Math.min(2 ** attempt, 15)
1606
+ ) * 1e3
1607
+ );
1608
+ remote = await this.settleUpload(
1609
+ await this.media.get(remote.id),
1610
+ id
1611
+ );
1612
+ validate();
1613
+ if (remote.acceptedParts.includes(number) || remote.status === "completed")
1614
+ break;
1615
+ }
1616
+ }
1617
+ journal.upload = remote;
1618
+ await store.write(id, journal);
1619
+ this.progress({
1620
+ stage: "uploading",
1621
+ operationId: id,
1622
+ acceptedParts: remote.acceptedParts.length,
1623
+ partCount: remote.partCount
1624
+ });
1625
+ }
1626
+ } finally {
1627
+ await handle.close();
1628
+ }
1629
+ for (let attempt = 0; ; attempt++) {
1630
+ try {
1631
+ remote = remote.status === "completed" ? remote : await this.media.complete(remote.id);
1632
+ remote = await this.settleUpload(remote, id);
1633
+ break;
1634
+ } catch (error) {
1635
+ if (!(error instanceof CliError) || !error.options.retryable || (error.options.retryAfter ?? 0) > 30 || attempt >= 4)
1636
+ throw error;
1637
+ await this.pause(
1638
+ Math.max(
1639
+ error.options.retryAfter ?? 0,
1640
+ Math.min(2 ** attempt, 15)
1641
+ ) * 1e3
1642
+ );
1643
+ remote = await this.settleUpload(
1644
+ await this.media.get(remote.id),
1645
+ id
1646
+ );
1647
+ validate();
1648
+ }
1649
+ }
1650
+ journal.upload = remote;
1651
+ await store.write(id, journal);
1652
+ return this.completedUpload(remote, id);
1653
+ } catch (error) {
1654
+ throw this.withOperation(error, id, "assets upload");
1655
+ }
1656
+ });
1657
+ }
1658
+ async settleUpload(initial, operationId) {
1659
+ let upload = initial;
1660
+ const expiry = Date.parse(initial.uploadExpiresAt);
1661
+ if (!Number.isFinite(expiry))
1662
+ throw new CliError(
1663
+ "INVALID_RESPONSE",
1664
+ "The upload service returned an invalid transfer deadline."
1665
+ );
1666
+ const serverDeadline = expiry + (initial.status === "finalizing" ? 3e5 : 0);
1667
+ const deadline = Math.min(Date.now() + 3e5, serverDeadline);
1668
+ const waiting = () => ["initializing", "finalizing"].includes(upload.status);
1669
+ if (waiting() && deadline <= Date.now())
1670
+ throw new CliError(
1671
+ "UPLOAD_EXPIRED",
1672
+ "The upload preparation deadline has expired."
1673
+ );
1674
+ const deadlineSignal = AbortSignal.timeout(
1675
+ Math.max(1, Math.ceil(deadline - Date.now()))
1676
+ );
1677
+ const signal = AbortSignal.any([
1678
+ deadlineSignal,
1679
+ ...this.options.signal ? [this.options.signal] : []
1680
+ ]);
1681
+ for (let attempt = 0; waiting(); attempt++) {
1682
+ this.progress({ stage: upload.status, operationId, uploadId: upload.id });
1683
+ try {
1684
+ const interval = Math.min(15e3, 1e3 * 2 ** Math.min(attempt, 4));
1685
+ const remaining = deadline - Date.now();
1686
+ await delay3(Math.min(interval, Math.max(1, remaining)), void 0, {
1687
+ signal
1688
+ });
1689
+ if (remaining <= interval) {
1690
+ if (deadline === serverDeadline)
1691
+ throw new CliError(
1692
+ "UPLOAD_EXPIRED",
1693
+ "The upload preparation deadline has expired."
1694
+ );
1695
+ throw new CliError(
1696
+ "UPLOAD_WAIT_TIMEOUT",
1697
+ "The upload is still initializing or verifying. Its saved state can be resumed.",
1698
+ { retryable: true }
1699
+ );
1700
+ }
1701
+ upload = await this.media.get(initial.id, signal);
1702
+ } catch (error) {
1703
+ this.options.signal?.throwIfAborted();
1704
+ if (deadlineSignal.aborted && Date.now() >= serverDeadline)
1705
+ throw new CliError(
1706
+ "UPLOAD_EXPIRED",
1707
+ "The upload preparation deadline has expired."
1708
+ );
1709
+ if (deadlineSignal.aborted)
1710
+ throw new CliError(
1711
+ "UPLOAD_WAIT_TIMEOUT",
1712
+ "The upload is still initializing or verifying. Its saved state can be resumed.",
1713
+ { retryable: true }
1714
+ );
1715
+ throw error;
1716
+ }
1717
+ if (upload.id !== initial.id || upload.sha256 !== initial.sha256 || upload.size !== initial.size || upload.kind !== initial.kind || upload.contentType !== initial.contentType || upload.partSize !== PART_SIZE) {
1718
+ throw new CliError(
1719
+ "INVALID_RESPONSE",
1720
+ "The upload identity changed while waiting."
1721
+ );
1722
+ }
1723
+ }
1724
+ if (["expired", "aborted", "aborting"].includes(upload.status) || upload.status === "uploading" && Date.parse(upload.uploadExpiresAt) <= Date.now()) {
1725
+ throw new CliError(
1726
+ "UPLOAD_EXPIRED",
1727
+ "This upload is no longer resumable.",
1728
+ {
1729
+ recovery: "Start a new upload. Do not replace input URLs in an uncertain video submission."
1730
+ }
1731
+ );
1732
+ }
1733
+ if (!["uploading", "completed"].includes(upload.status))
1734
+ throw new CliError(
1735
+ "INVALID_RESPONSE",
1736
+ "The upload service returned an invalid status."
1737
+ );
1738
+ return upload;
1739
+ }
1740
+ completedUpload(upload, operationId) {
1741
+ if (upload.status !== "completed" || !upload.url || !upload.expiresAt || !Number.isFinite(Date.parse(upload.expiresAt)) || Date.parse(upload.expiresAt) <= Date.now())
1742
+ throw new CliError(
1743
+ "UPLOAD_NOT_READY",
1744
+ "The upload is not ready for use.",
1745
+ { retryable: true }
1746
+ );
1747
+ const source = sourceUrl(upload.url);
1748
+ if (!source)
1749
+ throw new CliError(
1750
+ "INVALID_RESPONSE",
1751
+ "The upload service returned an invalid file URL."
1752
+ );
1753
+ const parsed = new URL(source);
1754
+ const localHTTP = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
1755
+ if (parsed.origin !== new URL(this.options.mediaOrigin).origin || parsed.protocol !== "https:" && !localHTTP) {
1756
+ throw new CliError(
1757
+ "INVALID_RESPONSE",
1758
+ "The upload file URL must use the configured media service over HTTPS (or loopback HTTP for local development)."
1759
+ );
1760
+ }
1761
+ return { ...upload, operationId };
1762
+ }
1763
+ getUpload(id) {
1764
+ return this.media.get(identifier(id, "Upload ID"));
1765
+ }
1766
+ abortUpload(id) {
1767
+ return this.media.abort(identifier(id, "Upload ID"));
1768
+ }
1769
+ withOperation(error, operationId, command) {
1770
+ const original = error instanceof CliError ? error : new CliError(
1771
+ "OPERATION_FAILED",
1772
+ "The operation stopped before completion."
1773
+ );
1774
+ const inherited = original.options.details;
1775
+ const plainDetails = inherited !== null && typeof inherited === "object" && (Object.getPrototypeOf(inherited) === Object.prototype || Object.getPrototypeOf(inherited) === null) ? inherited : {};
1776
+ const details = { ...plainDetails, operationId };
1777
+ const recovery = command === "assets upload" ? `Run spatius assets upload with the original file and --kind, adding --resume ${operationId}.` : `Resume with spatius ${command} --resume ${operationId}.`;
1778
+ if (this.options.signal?.aborted)
1779
+ return new CliError(
1780
+ "INTERRUPTED",
1781
+ "Operation interrupted; its progress has been saved.",
1782
+ {
1783
+ exitCode: 130,
1784
+ details,
1785
+ recovery
1786
+ }
1787
+ );
1788
+ return new CliError(original.code, original.message, {
1789
+ ...original.options,
1790
+ details,
1791
+ recovery: original.options.recovery ?? recovery
1792
+ });
1793
+ }
1794
+ async input(value, kind) {
1795
+ if (!value)
1796
+ throw new CliError("INVALID_ARGUMENT", `A ${kind} input is required.`, {
1797
+ exitCode: 2
1798
+ });
1799
+ const url = sourceUrl(value);
1800
+ return url ? { value, kind, url } : {
1801
+ value,
1802
+ kind,
1803
+ file: await inspectMedia(value, kind, this.options.signal),
1804
+ uploadOperationId: randomUUID3()
1805
+ };
1806
+ }
1807
+ videoSettings(video = {}) {
1808
+ const settings = { ...VIDEO_DEFAULTS, ...video };
1809
+ for (const dimension of [settings.width, settings.height])
1810
+ if (!Number.isInteger(dimension) || dimension < 64 || dimension > 1920 || dimension % 2)
1811
+ throw new CliError(
1812
+ "INVALID_ARGUMENT",
1813
+ "Video dimensions must be even integers from 64 to 1920.",
1814
+ { exitCode: 2 }
1815
+ );
1816
+ if (settings.width * settings.height > 2073600 || !["crop", "contain"].includes(settings.fit) || !["cover", "contain", "stretch"].includes(settings.backgroundFit) || !/^#[0-9a-f]{6}$/i.test(settings.backgroundColor))
1817
+ throw new CliError(
1818
+ "INVALID_ARGUMENT",
1819
+ "Invalid video presentation settings.",
1820
+ { exitCode: 2 }
1821
+ );
1822
+ for (const duration of [settings.leadInSeconds, settings.leadOutSeconds])
1823
+ if (!Number.isFinite(duration) || duration < 0 || duration > 60)
1824
+ throw new CliError(
1825
+ "INVALID_ARGUMENT",
1826
+ "Lead-in and lead-out must be finite seconds from 0 to 60.",
1827
+ { exitCode: 2 }
1828
+ );
1829
+ settings.backgroundColor = settings.backgroundColor.toLowerCase();
1830
+ return settings;
1831
+ }
1832
+ name(name) {
1833
+ if (name !== void 0 && (!name.trim() || name.length > 128))
1834
+ throw new CliError(
1835
+ "INVALID_ARGUMENT",
1836
+ "Name must contain 1 to 128 characters.",
1837
+ { exitCode: 2 }
1838
+ );
1839
+ return name;
1840
+ }
1841
+ async createAvatar(options) {
1842
+ if (options.resume) {
1843
+ if (options.image !== void 0 || options.name !== void 0 || options.dryRun)
1844
+ throw new CliError(
1845
+ "INVALID_ARGUMENT",
1846
+ "Resume cannot be combined with new inputs or dry-run.",
1847
+ { exitCode: 2 }
1848
+ );
1849
+ return this.create("avatar", options);
1850
+ }
1851
+ const inputs = {
1852
+ imageUrl: await this.input(options.image, "avatar-image")
1853
+ };
1854
+ return this.create("avatar", options, inputs, {
1855
+ ...this.name(options.name) !== void 0 ? { name: options.name } : {}
1856
+ });
1857
+ }
1858
+ async createVideo(options) {
1859
+ if (options.resume) {
1860
+ if ([
1861
+ options.avatarId,
1862
+ options.audio,
1863
+ options.background,
1864
+ options.name,
1865
+ options.requestId
1866
+ ].some((value) => value !== void 0) || options.video && Object.keys(options.video).length > 0 || options.dryRun)
1867
+ throw new CliError(
1868
+ "INVALID_ARGUMENT",
1869
+ "Resume cannot be combined with new inputs or dry-run.",
1870
+ { exitCode: 2 }
1871
+ );
1872
+ return this.create("video", options);
1873
+ }
1874
+ if (!options.avatarId)
1875
+ throw new CliError("INVALID_ARGUMENT", "An Avatar ID is required.", {
1876
+ exitCode: 2
1877
+ });
1878
+ const avatarId = identifier(options.avatarId, "Avatar ID");
1879
+ const inputs = {
1880
+ audioUrl: await this.input(options.audio, "audio")
1881
+ };
1882
+ if (options.background)
1883
+ inputs.backgroundUrl = await this.input(options.background, "background");
1884
+ return this.create("video", options, inputs, {
1885
+ avatarId,
1886
+ requestId: options.requestId ? identifier(options.requestId, "Request ID") : randomUUID3(),
1887
+ video: this.videoSettings(options.video),
1888
+ ...this.name(options.name) !== void 0 ? { name: options.name } : {}
1889
+ });
1890
+ }
1891
+ async create(kind, options, inputs, body) {
1892
+ if (options.dryRun)
1893
+ return {
1894
+ dryRun: true,
1895
+ method: "POST",
1896
+ path: `/v1/open/${kind === "avatar" ? "avatars" : "videos"}`,
1897
+ body,
1898
+ inputs: Object.fromEntries(
1899
+ Object.entries(inputs ?? {}).map(([key, input]) => [
1900
+ key,
1901
+ input.file ? {
1902
+ kind: input.kind,
1903
+ path: input.file.path,
1904
+ size: input.file.size,
1905
+ contentType: input.file.contentType,
1906
+ sha256: input.file.sha256,
1907
+ action: "temporary_upload"
1908
+ } : { kind: input.kind, action: "use_supplied_url" }
1909
+ ])
1910
+ )
1911
+ };
1912
+ const { store, userId } = await this.context();
1913
+ const credentials = await this.options.auth.credentials();
1914
+ const id = options.resume ? identifier(options.resume, "Operation ID") : kind === "video" ? identifier(String(body.requestId), "Request ID") : randomUUID3();
1915
+ return store.locked(id, async () => {
1916
+ let journal;
1917
+ let existing;
1918
+ try {
1919
+ existing = await store.read(id);
1920
+ } catch (error) {
1921
+ if (!(error instanceof CliError) || error.code !== "OPERATION_NOT_FOUND" || options.resume)
1922
+ throw error;
1923
+ }
1924
+ if (existing) {
1925
+ journal = existing;
1926
+ this.checkScope(journal, userId);
1927
+ if (journal.type !== kind || journal.appId !== credentials.appId)
1928
+ throw new CliError(
1929
+ "OPERATION_SCOPE_MISMATCH",
1930
+ "Resume requires the original command and App ID."
1931
+ );
1932
+ if (!options.resume && inputFingerprint(journal.body, journal.inputs) !== inputFingerprint(body, inputs)) {
1933
+ throw new CliError(
1934
+ "REQUEST_ID_CONFLICT",
1935
+ "This request ID is already associated with different inputs.",
1936
+ {
1937
+ status: 409,
1938
+ recovery: "Reuse the original inputs, or choose a new request ID for a separate render."
1939
+ }
1940
+ );
1941
+ }
1942
+ } else {
1943
+ journal = {
1944
+ version: 1,
1945
+ type: kind,
1946
+ id,
1947
+ userId,
1948
+ appId: credentials.appId,
1949
+ consoleOrigin: this.options.consoleOrigin,
1950
+ mediaOrigin: this.options.mediaOrigin,
1951
+ state: "preparing",
1952
+ inputs,
1953
+ body
1954
+ };
1955
+ await store.write(id, journal);
1956
+ }
1957
+ this.progress({ stage: journal.state, operationId: id });
1958
+ try {
1959
+ if (journal.state === "accepted" && journal.job)
1960
+ return options.wait ? {
1961
+ operationId: id,
1962
+ ...await this.waitJob(kind, journal.job.jobId, options)
1963
+ } : { operationId: id, ...journal.job };
1964
+ if (journal.state === "submitting" && kind === "avatar")
1965
+ throw new CliError(
1966
+ "SUBMISSION_UNCERTAIN",
1967
+ "Avatar creation may already have been accepted. It cannot safely be submitted again automatically.",
1968
+ {
1969
+ recovery: "Inspect spatius avatars jobs list and match the previous creation before starting another avatar."
1970
+ }
1971
+ );
1972
+ for (const [field, input] of Object.entries(journal.inputs)) {
1973
+ if (!input.url) {
1974
+ if (!input.file || !input.uploadOperationId)
1975
+ throw new CliError(
1976
+ "INVALID_OPERATION",
1977
+ "The saved input is incomplete."
1978
+ );
1979
+ const current = await inspectMedia(
1980
+ input.file.path,
1981
+ input.kind,
1982
+ this.options.signal
1983
+ );
1984
+ if (current.sha256 !== input.file.sha256 || current.size !== input.file.size)
1985
+ throw new CliError(
1986
+ "INPUT_CHANGED",
1987
+ "The local input changed since this operation started."
1988
+ );
1989
+ let exists = true;
1990
+ try {
1991
+ await store.read(input.uploadOperationId);
1992
+ } catch (error) {
1993
+ if (error instanceof CliError && error.code === "OPERATION_NOT_FOUND")
1994
+ exists = false;
1995
+ else throw error;
1996
+ }
1997
+ if (!exists)
1998
+ await store.write(input.uploadOperationId, {
1999
+ version: 1,
2000
+ type: "upload",
2001
+ id: input.uploadOperationId,
2002
+ userId,
2003
+ consoleOrigin: this.options.consoleOrigin,
2004
+ mediaOrigin: this.options.mediaOrigin,
2005
+ kind: input.kind,
2006
+ file: input.file
2007
+ });
2008
+ const uploaded = await this.upload(input.file.path, {
2009
+ kind: input.kind,
2010
+ resume: input.uploadOperationId
2011
+ });
2012
+ input.url = uploaded.url;
2013
+ input.expiresAt = uploaded.expiresAt;
2014
+ journal.body[field] = input.url;
2015
+ await store.write(id, journal);
2016
+ } else journal.body[field] = input.url;
2017
+ if (input.expiresAt && Date.parse(input.expiresAt) <= Date.now() + 30 * 6e4)
2018
+ throw new CliError(
2019
+ "SOURCE_EXPIRED",
2020
+ "A saved input URL no longer covers the preparation window.",
2021
+ {
2022
+ recovery: journal.state === "submitting" ? "Inspect existing video jobs. Do not substitute another URL under this request ID." : "Start a new operation to upload fresh inputs."
2023
+ }
2024
+ );
2025
+ }
2026
+ journal.state = "ready";
2027
+ await store.write(id, journal);
2028
+ journal.state = "submitting";
2029
+ await store.write(id, journal);
2030
+ const attempts = kind === "video" ? 3 : 1;
2031
+ for (let attempt = 0; attempt < attempts; attempt++) {
2032
+ try {
2033
+ const result = await this.consoleRequest(
2034
+ kind === "video" ? "/videos" : "/avatars",
2035
+ "POST",
2036
+ journal.body,
2037
+ false,
2038
+ this.options.signal,
2039
+ { appId: journal.appId, userId: journal.userId }
2040
+ );
2041
+ if (!result.jobId || !UUID_PATTERN.test(result.jobId))
2042
+ throw new CliError(
2043
+ "INVALID_RESPONSE",
2044
+ "Creation response did not include a valid job ID.",
2045
+ { retryable: kind === "video" }
2046
+ );
2047
+ journal.job = result;
2048
+ journal.state = "accepted";
2049
+ await store.write(id, journal);
2050
+ break;
2051
+ } catch (error) {
2052
+ const rejected = error instanceof CliError && error.options.status !== void 0 && error.options.status >= 400 && error.options.status < 500;
2053
+ if (rejected || error instanceof CliError && error.code === "OPERATION_SCOPE_MISMATCH") {
2054
+ journal.state = "ready";
2055
+ await store.write(id, journal);
2056
+ } else if (kind === "avatar") {
2057
+ throw new CliError(
2058
+ "SUBMISSION_UNCERTAIN",
2059
+ "Avatar creation may already have been accepted. Do not submit it again automatically.",
2060
+ {
2061
+ recovery: "Inspect spatius avatars jobs list and match the previous creation before starting another avatar."
2062
+ }
2063
+ );
2064
+ }
2065
+ if (!(error instanceof CliError) || !error.options.retryable || (error.options.retryAfter ?? 0) > 30 || attempt + 1 >= attempts)
2066
+ throw error;
2067
+ journal.state = "submitting";
2068
+ await store.write(id, journal);
2069
+ await this.pause(
2070
+ Math.max(error.options.retryAfter ?? 0, 2 ** attempt) * 1e3
2071
+ );
2072
+ }
2073
+ }
2074
+ return options.wait ? {
2075
+ operationId: id,
2076
+ ...await this.waitJob(kind, journal.job.jobId, options)
2077
+ } : { operationId: id, ...journal.job };
2078
+ } catch (error) {
2079
+ throw this.withOperation(
2080
+ error,
2081
+ id,
2082
+ `${kind === "avatar" ? "avatars" : "videos"} create`
2083
+ );
2084
+ }
2085
+ });
2086
+ }
2087
+ async consoleRequest(path, method = "GET", body, retry = true, signal = this.options.signal, owner) {
2088
+ const credentials = await this.options.auth.credentials();
2089
+ if (owner) {
2090
+ const identity = await this.options.auth.identity();
2091
+ if (credentials.appId !== owner.appId || identity.userId !== owner.userId || credentials.userId !== void 0 && credentials.userId !== owner.userId)
2092
+ throw new CliError(
2093
+ "OPERATION_SCOPE_MISMATCH",
2094
+ "The active account or App changed while preparing this operation. Resume under the original identity."
2095
+ );
2096
+ }
2097
+ return requestJson(
2098
+ `${this.options.consoleOrigin.replace(/\/$/, "")}/v1/open${path}`,
2099
+ {
2100
+ method,
2101
+ body,
2102
+ headers: {
2103
+ "x-app-id": credentials.appId,
2104
+ "x-api-key": credentials.apiKey
2105
+ },
2106
+ signal,
2107
+ fetch: this.options.fetch,
2108
+ retry: method === "GET" && retry
2109
+ }
2110
+ );
2111
+ }
2112
+ getAvatar(id) {
2113
+ return this.consoleRequest(
2114
+ `/avatars/${identifier(id, "Avatar ID")}`
2115
+ );
2116
+ }
2117
+ listAvatars(options = {}) {
2118
+ return this.consoleRequest(`/avatars${this.query(options)}`);
2119
+ }
2120
+ getJob(kind, id) {
2121
+ return this.consoleRequest(
2122
+ `/${kind}-jobs/${identifier(id, "Job ID")}`
2123
+ );
2124
+ }
2125
+ listJobs(kind, options = {}) {
2126
+ return this.consoleRequest(`/${kind}-jobs${this.query(options)}`);
2127
+ }
2128
+ query(options) {
2129
+ const query = new URLSearchParams();
2130
+ if (options.pageSize !== void 0) {
2131
+ if (!Number.isInteger(options.pageSize) || options.pageSize < 1 || options.pageSize > 100)
2132
+ throw new CliError(
2133
+ "INVALID_ARGUMENT",
2134
+ "Page size must be an integer from 1 to 100.",
2135
+ { exitCode: 2 }
2136
+ );
2137
+ query.set("pagination.pageSize", String(options.pageSize));
2138
+ }
2139
+ if (options.pageToken) query.set("pagination.pageToken", options.pageToken);
2140
+ for (const status of options.statuses ?? []) {
2141
+ if (!["queued", "processing", "succeeded", "failed", "expired"].includes(
2142
+ status
2143
+ ))
2144
+ throw new CliError("INVALID_ARGUMENT", "Invalid job status.", {
2145
+ exitCode: 2
2146
+ });
2147
+ query.append("statuses", status);
2148
+ }
2149
+ return query.size ? `?${query}` : "";
2150
+ }
2151
+ async waitJob(kind, id, options = {}) {
2152
+ identifier(id, "Job ID");
2153
+ const timeout = options.timeout ?? 600;
2154
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 86400)
2155
+ throw new CliError(
2156
+ "INVALID_ARGUMENT",
2157
+ "Timeout must be seconds greater than zero and at most 86400.",
2158
+ { exitCode: 2 }
2159
+ );
2160
+ const deadline = Date.now() + timeout * 1e3;
2161
+ const deadlineSignal = AbortSignal.timeout(Math.ceil(timeout * 1e3));
2162
+ const signal = AbortSignal.any([
2163
+ deadlineSignal,
2164
+ ...this.options.signal ? [this.options.signal] : []
2165
+ ]);
2166
+ for (; ; ) {
2167
+ let detail;
2168
+ try {
2169
+ detail = await this.consoleRequest(
2170
+ `/${kind}-jobs/${id}`,
2171
+ "GET",
2172
+ void 0,
2173
+ true,
2174
+ signal
2175
+ );
2176
+ } catch (error) {
2177
+ if (deadlineSignal.aborted && !this.options.signal?.aborted)
2178
+ throw this.waitTimeout(kind, id);
2179
+ throw error;
2180
+ }
2181
+ const job = detail.job;
2182
+ if (!job || !["queued", "processing", "succeeded", "failed", "expired"].includes(
2183
+ job.status
2184
+ ))
2185
+ throw new CliError(
2186
+ "INVALID_RESPONSE",
2187
+ "The job service returned an invalid status."
2188
+ );
2189
+ this.progress({
2190
+ stage: job.progress?.stage ?? job.status,
2191
+ jobId: id,
2192
+ status: job.status
2193
+ });
2194
+ if (job.status === "succeeded") return detail;
2195
+ if (job.status === "failed" || job.status === "expired")
2196
+ throw new CliError(
2197
+ job.error?.code ?? (job.status === "expired" ? "JOB_EXPIRED" : "JOB_FAILED"),
2198
+ job.error?.message ?? "The job did not succeed.",
2199
+ {
2200
+ retryable: job.error?.retryable ?? false,
2201
+ details: { jobId: id, status: job.status },
2202
+ recovery: "This job is terminal. Inspect its error before deliberately creating another job."
2203
+ }
2204
+ );
2205
+ const remaining = deadline - Date.now();
2206
+ if (remaining <= 0) throw this.waitTimeout(kind, id);
2207
+ try {
2208
+ await delay3(Math.min(15e3, remaining), void 0, { signal });
2209
+ if (remaining <= 15e3) throw this.waitTimeout(kind, id);
2210
+ } catch {
2211
+ if (this.options.signal?.aborted) this.options.signal.throwIfAborted();
2212
+ throw this.waitTimeout(kind, id);
2213
+ }
2214
+ }
2215
+ }
2216
+ waitTimeout(kind, id) {
2217
+ return new CliError(
2218
+ "WAIT_TIMEOUT",
2219
+ "The job is still running when the wait deadline ended.",
2220
+ {
2221
+ retryable: true,
2222
+ exitCode: 3,
2223
+ details: { jobId: id },
2224
+ recovery: `Continue with spatius ${kind === "avatar" ? "avatars jobs" : "videos"} wait ${id}. The remote job was not cancelled.`
2225
+ }
2226
+ );
2227
+ }
2228
+ async pause(milliseconds) {
2229
+ await delay3(milliseconds, void 0, { signal: this.options.signal });
2230
+ }
2231
+ async download(jobId, options) {
2232
+ const output = resolve2(options.output);
2233
+ const directory = dirname2(output);
2234
+ if (!options.force && await stat2(output).then(
2235
+ () => true,
2236
+ () => false
2237
+ ))
2238
+ throw new CliError(
2239
+ "OUTPUT_EXISTS",
2240
+ "The output file already exists. Use --force to replace it.",
2241
+ { exitCode: 2 }
2242
+ );
2243
+ const detail = await this.getJob("video", jobId);
2244
+ if (detail.job.status !== "succeeded" || !detail.videoUrl || detail.job.expiresAt && Date.parse(detail.job.expiresAt) <= Date.now())
2245
+ throw new CliError(
2246
+ "VIDEO_UNAVAILABLE",
2247
+ "The video is not ready or its output has expired.",
2248
+ { retryable: ["queued", "processing"].includes(detail.job.status) }
2249
+ );
2250
+ if (!sourceUrl(detail.videoUrl))
2251
+ throw new CliError(
2252
+ "INVALID_RESPONSE",
2253
+ "The video service returned an invalid download URL."
2254
+ );
2255
+ const downloadURL = new URL(detail.videoUrl);
2256
+ const consoleURL = new URL(this.options.consoleOrigin);
2257
+ const isLoopback = (hostname) => ["localhost", "127.0.0.1", "[::1]"].includes(hostname);
2258
+ if (downloadURL.protocol !== "https:" && !(downloadURL.protocol === "http:" && isLoopback(downloadURL.hostname) && consoleURL.protocol === "http:" && isLoopback(consoleURL.hostname))) {
2259
+ throw new CliError(
2260
+ "DOWNLOAD_UNAVAILABLE",
2261
+ "The video service returned an insecure output URL.",
2262
+ {
2263
+ retryable: true,
2264
+ recovery: "Request a fresh download link. Only HTTPS output links are supported outside explicitly configured loopback development."
2265
+ }
2266
+ );
2267
+ }
2268
+ await mkdir3(directory, { recursive: true });
2269
+ const temporary = `${output}.${randomUUID3()}.partial`;
2270
+ const handle = await open4(temporary, "wx", 384);
2271
+ let bytes = 0;
2272
+ try {
2273
+ const signal = AbortSignal.any([
2274
+ AbortSignal.timeout(6e5),
2275
+ ...this.options.signal ? [this.options.signal] : []
2276
+ ]);
2277
+ let response;
2278
+ try {
2279
+ response = await (this.options.fetch ?? fetch)(detail.videoUrl, {
2280
+ signal,
2281
+ redirect: "error"
2282
+ });
2283
+ } catch {
2284
+ this.options.signal?.throwIfAborted();
2285
+ throw new CliError(
2286
+ "DOWNLOAD_UNAVAILABLE",
2287
+ "The direct signed video download could not be reached.",
2288
+ {
2289
+ retryable: true,
2290
+ recovery: "Run the download command again to obtain a fresh signed URL. Output redirects are not followed."
2291
+ }
2292
+ );
2293
+ }
2294
+ if (!response.ok || !response.body) {
2295
+ await response.body?.cancel();
2296
+ throw new CliError(
2297
+ "DOWNLOAD_UNAVAILABLE",
2298
+ "The signed video download is unavailable.",
2299
+ {
2300
+ retryable: true,
2301
+ recovery: "Run the download command again to obtain a fresh signed URL."
2302
+ }
2303
+ );
2304
+ }
2305
+ const reader = response.body.getReader();
2306
+ try {
2307
+ for (; ; ) {
2308
+ const next = await reader.read();
2309
+ if (next.done) break;
2310
+ let offset = 0;
2311
+ while (offset < next.value.byteLength) {
2312
+ const result = await handle.write(next.value, offset);
2313
+ offset += result.bytesWritten;
2314
+ }
2315
+ bytes += next.value.byteLength;
2316
+ }
2317
+ } finally {
2318
+ await reader.cancel().catch(() => {
2319
+ });
2320
+ }
2321
+ if (!bytes)
2322
+ throw new CliError("DOWNLOAD_EMPTY", "The video download was empty.", {
2323
+ retryable: true
2324
+ });
2325
+ await handle.sync();
2326
+ await handle.close();
2327
+ if (options.force) await rename3(temporary, output);
2328
+ else {
2329
+ await link(temporary, output);
2330
+ await unlink2(temporary);
2331
+ }
2332
+ return { jobId, output, bytes };
2333
+ } catch (error) {
2334
+ if (error instanceof CliError) throw error;
2335
+ this.options.signal?.throwIfAborted();
2336
+ throw new CliError(
2337
+ "DOWNLOAD_FAILED",
2338
+ "The video download did not complete.",
2339
+ {
2340
+ retryable: true,
2341
+ recovery: "Run the download command again; no partial result replaced the destination."
2342
+ }
2343
+ );
2344
+ } finally {
2345
+ await handle.close().catch(() => {
2346
+ });
2347
+ await unlink2(temporary).catch(() => {
2348
+ });
2349
+ }
2350
+ }
2351
+ };
2352
+ function inputFingerprint(body, inputs) {
2353
+ const request = Object.fromEntries(
2354
+ Object.entries(body).filter(([key]) => !Object.hasOwn(inputs, key))
2355
+ );
2356
+ const sources = Object.fromEntries(
2357
+ Object.entries(inputs).map(([key, input]) => [
2358
+ key,
2359
+ input.file ? {
2360
+ kind: input.kind,
2361
+ sha256: input.file.sha256,
2362
+ size: input.file.size,
2363
+ contentType: input.file.contentType
2364
+ } : { kind: input.kind, url: input.value }
2365
+ ])
2366
+ );
2367
+ const canonical = (value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(
2368
+ Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])
2369
+ ) : value;
2370
+ return createHash4("sha256").update(JSON.stringify(canonical({ request, sources }))).digest("hex");
2371
+ }
2372
+
2373
+ // src/core/config.ts
2374
+ import { isAbsolute as isAbsolute2 } from "path";
2375
+ function origin(value, name) {
2376
+ let url;
2377
+ try {
2378
+ url = new URL(value);
2379
+ } catch {
2380
+ throw new CliError("INVALID_CONFIG", `${name} must be an absolute origin.`);
2381
+ }
2382
+ const local = ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
2383
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.protocol !== "https:" && !(local && url.protocol === "http:")) {
2384
+ throw new CliError(
2385
+ "INVALID_CONFIG",
2386
+ `${name} must be an HTTPS origin, or a localhost HTTP origin for development.`
2387
+ );
2388
+ }
2389
+ return url.origin;
2390
+ }
2391
+ function readConfig(env = process.env) {
2392
+ const configDir = env.SPATIUS_CONFIG_DIR;
2393
+ if (configDir && !isAbsolute2(configDir))
2394
+ throw new CliError(
2395
+ "INVALID_CONFIG",
2396
+ "SPATIUS_CONFIG_DIR must be absolute."
2397
+ );
2398
+ return {
2399
+ studioOrigin: origin(
2400
+ env.SPATIUS_STUDIO_URL ?? "https://api.studio.spatius.ai",
2401
+ "SPATIUS_STUDIO_URL"
2402
+ ),
2403
+ studioWebOrigin: origin(
2404
+ env.SPATIUS_STUDIO_WEB_URL ?? "https://app.spatius.ai",
2405
+ "SPATIUS_STUDIO_WEB_URL"
2406
+ ),
2407
+ consoleOrigin: origin(
2408
+ env.SPATIUS_CONSOLE_URL ?? "https://console.spatius.ai",
2409
+ "SPATIUS_CONSOLE_URL"
2410
+ ),
2411
+ mediaOrigin: origin(
2412
+ env.SPATIUS_MEDIA_URL ?? "https://cli-media.spatius.ai",
2413
+ "SPATIUS_MEDIA_URL"
2414
+ ),
2415
+ ...configDir ? { configDir } : {}
2416
+ };
2417
+ }
2418
+
2419
+ // package.json
2420
+ var package_default = {
2421
+ name: "@spatius/cli",
2422
+ version: "0.1.0-beta.0",
2423
+ description: "Spatius avatar and video workflows for coding agents",
2424
+ type: "module",
2425
+ license: "MIT",
2426
+ bin: {
2427
+ spatius: "./dist/cli.js"
2428
+ },
2429
+ engines: {
2430
+ node: ">=22"
2431
+ },
2432
+ files: [
2433
+ "dist",
2434
+ "skills",
2435
+ "docs",
2436
+ "README.md",
2437
+ "LICENSE",
2438
+ "THIRD_PARTY_NOTICES.md"
2439
+ ],
2440
+ repository: {
2441
+ type: "git",
2442
+ url: "git+https://github.com/spatius-ai/spatius-cli.git",
2443
+ directory: "packages/cli"
2444
+ },
2445
+ scripts: {
2446
+ build: "tsup && node ../../scripts/package-assets.mjs",
2447
+ dev: "tsx src/cli.ts",
2448
+ typecheck: "tsc -p tsconfig.json",
2449
+ test: "vitest run",
2450
+ prepack: "pnpm build"
2451
+ },
2452
+ dependencies: {
2453
+ commander: "^14.0.0"
2454
+ },
2455
+ devDependencies: {
2456
+ "@spatius/contracts": "workspace:*",
2457
+ tsup: "^8.5.0",
2458
+ tsx: "^4.20.0"
2459
+ },
2460
+ publishConfig: {
2461
+ access: "public",
2462
+ provenance: true
2463
+ }
2464
+ };
2465
+
2466
+ // src/cli.ts
2467
+ var controller = new AbortController();
2468
+ var interrupt = () => controller.abort(new DOMException("Interrupted", "AbortError"));
2469
+ process.once("SIGINT", interrupt);
2470
+ process.once("SIGTERM", interrupt);
2471
+ var progress = (event) => process.stderr.write(
2472
+ `${JSON.stringify({ schemaVersion: 1, ...typeof event === "object" && event !== null ? event : { event } })}
2473
+ `
2474
+ );
2475
+ var emit = (data) => process.stdout.write(
2476
+ `${JSON.stringify({ schemaVersion: 1, ok: true, data })}
2477
+ `
2478
+ );
2479
+ var context;
2480
+ var program = buildProgram(
2481
+ () => {
2482
+ if (!context) {
2483
+ const config = readConfig();
2484
+ const auth = new AuthManager(config);
2485
+ context = {
2486
+ auth,
2487
+ workflows: new Workflows({
2488
+ ...config,
2489
+ auth,
2490
+ signal: controller.signal,
2491
+ onProgress: progress
2492
+ }),
2493
+ signal: controller.signal,
2494
+ progress
2495
+ };
2496
+ }
2497
+ return context;
2498
+ },
2499
+ emit,
2500
+ package_default.version
2501
+ );
2502
+ program.configureOutput({ writeErr: () => void 0 });
2503
+ try {
2504
+ if (process.argv.length <= 2) program.outputHelp();
2505
+ else await program.parseAsync(process.argv);
2506
+ } catch (error) {
2507
+ if (error instanceof CommanderError && error.exitCode === 0)
2508
+ process.exitCode = 0;
2509
+ else {
2510
+ const e = error instanceof CommanderError ? new CliError("INVALID_ARGUMENT", error.message, {
2511
+ exitCode: 2,
2512
+ recovery: "Run spatius --help or spatius schema."
2513
+ }) : asCliError(error);
2514
+ process.stderr.write(
2515
+ `${JSON.stringify({ schemaVersion: 1, ok: false, error: { code: e.code, message: e.message, retryable: e.options.retryable ?? false, recovery: e.options.recovery, details: e.options.details } })}
2516
+ `
2517
+ );
2518
+ process.exitCode = e.options.exitCode ?? 1;
2519
+ }
2520
+ } finally {
2521
+ process.removeListener("SIGINT", interrupt);
2522
+ process.removeListener("SIGTERM", interrupt);
2523
+ }