tripo-cli 0.1.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/README.md +105 -0
- package/dist/ai/experts.js +115 -0
- package/dist/ai/llm.js +88 -0
- package/dist/ai/plan-card.js +79 -0
- package/dist/ai/wizard.js +105 -0
- package/dist/cli.js +7 -0
- package/dist/commands/account.js +27 -0
- package/dist/commands/ai.js +114 -0
- package/dist/commands/batch.js +148 -0
- package/dist/commands/completion.js +84 -0
- package/dist/commands/config-cmd.js +84 -0
- package/dist/commands/docs.js +30 -0
- package/dist/commands/doctor.js +102 -0
- package/dist/commands/generate.js +81 -0
- package/dist/commands/home.js +68 -0
- package/dist/commands/login.js +309 -0
- package/dist/commands/make.js +142 -0
- package/dist/commands/mcp.js +194 -0
- package/dist/commands/process-groups.js +166 -0
- package/dist/commands/shared.js +75 -0
- package/dist/commands/task.js +128 -0
- package/dist/commands/topup.js +61 -0
- package/dist/commands/view.js +97 -0
- package/dist/config/config.js +99 -0
- package/dist/config/context.js +44 -0
- package/dist/config/history.js +104 -0
- package/dist/core/client.js +129 -0
- package/dist/core/device-auth.js +141 -0
- package/dist/core/download.js +107 -0
- package/dist/core/errors.js +64 -0
- package/dist/core/output.js +72 -0
- package/dist/core/region.js +45 -0
- package/dist/core/requests.js +197 -0
- package/dist/core/task-service.js +167 -0
- package/dist/core/upload.js +42 -0
- package/dist/core/viewer-assets.js +24 -0
- package/dist/core/viewer.js +59 -0
- package/dist/core/watch.js +42 -0
- package/dist/i18n.js +19 -0
- package/dist/knowledge/chains.js +157 -0
- package/dist/knowledge/error-catalog.js +91 -0
- package/dist/knowledge/models.js +158 -0
- package/dist/knowledge/params.js +203 -0
- package/dist/knowledge/scenarios.js +112 -0
- package/dist/pipeline/make.js +233 -0
- package/dist/pipeline/runner.js +73 -0
- package/dist/pipeline/stdin.js +40 -0
- package/dist/program.js +147 -0
- package/dist/src/ai/experts.js +115 -0
- package/dist/src/ai/llm.js +88 -0
- package/dist/src/ai/plan-card.js +79 -0
- package/dist/src/ai/wizard.js +104 -0
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/account.js +27 -0
- package/dist/src/commands/ai.js +113 -0
- package/dist/src/commands/batch.js +148 -0
- package/dist/src/commands/completion.js +84 -0
- package/dist/src/commands/config-cmd.js +84 -0
- package/dist/src/commands/docs.js +30 -0
- package/dist/src/commands/doctor.js +94 -0
- package/dist/src/commands/generate.js +81 -0
- package/dist/src/commands/home.js +68 -0
- package/dist/src/commands/login.js +225 -0
- package/dist/src/commands/make.js +142 -0
- package/dist/src/commands/mcp.js +194 -0
- package/dist/src/commands/process-groups.js +164 -0
- package/dist/src/commands/shared.js +75 -0
- package/dist/src/commands/task.js +127 -0
- package/dist/src/commands/topup.js +61 -0
- package/dist/src/commands/view.js +97 -0
- package/dist/src/config/config.js +79 -0
- package/dist/src/config/context.js +44 -0
- package/dist/src/config/history.js +104 -0
- package/dist/src/core/client.js +120 -0
- package/dist/src/core/download.js +107 -0
- package/dist/src/core/errors.js +64 -0
- package/dist/src/core/output.js +72 -0
- package/dist/src/core/requests.js +169 -0
- package/dist/src/core/task-service.js +161 -0
- package/dist/src/core/upload.js +36 -0
- package/dist/src/core/viewer-assets.js +24 -0
- package/dist/src/core/viewer.js +59 -0
- package/dist/src/core/watch.js +41 -0
- package/dist/src/i18n.js +19 -0
- package/dist/src/knowledge/chains.js +152 -0
- package/dist/src/knowledge/error-catalog.js +91 -0
- package/dist/src/knowledge/models.js +110 -0
- package/dist/src/knowledge/params.js +174 -0
- package/dist/src/knowledge/scenarios.js +112 -0
- package/dist/src/pipeline/make.js +233 -0
- package/dist/src/pipeline/runner.js +73 -0
- package/dist/src/pipeline/stdin.js +40 -0
- package/dist/src/program.js +147 -0
- package/dist/src/types.js +2 -0
- package/dist/src/util/input-detect.js +92 -0
- package/dist/src/util/notify.js +17 -0
- package/dist/src/util/open.js +18 -0
- package/dist/src/util/spinner.js +62 -0
- package/dist/src/util/term-image.js +28 -0
- package/dist/src/version.js +10 -0
- package/dist/types.js +2 -0
- package/dist/util/input-detect.js +93 -0
- package/dist/util/notify.js +17 -0
- package/dist/util/open.js +18 -0
- package/dist/util/spinner.js +62 -0
- package/dist/util/term-image.js +28 -0
- package/dist/version.js +10 -0
- package/package.json +63 -0
- package/skill/SKILL.md +70 -0
- package/skill/commands/account.md +41 -0
- package/skill/commands/batch.md +33 -0
- package/skill/commands/generate.md +34 -0
- package/skill/commands/make.md +47 -0
- package/skill/commands/process.md +50 -0
- package/skill/commands/task.md +27 -0
- package/skill/commands/view.md +25 -0
- package/skill/common-errors.md +38 -0
- package/skill/examples/animation.md +33 -0
- package/skill/examples/ar-web.md +23 -0
- package/skill/examples/film.md +22 -0
- package/skill/examples/game-asset.md +35 -0
- package/skill/examples/pipes.md +39 -0
- package/skill/examples/print.md +31 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory-level context at .tripo/context.json — project defaults that
|
|
3
|
+
* apply when the CLI runs inside that directory (like .git discovery).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
/** Walk up from cwd to find the nearest .tripo/context.json. */
|
|
8
|
+
export function findContextFile(from = process.cwd()) {
|
|
9
|
+
let dir = from;
|
|
10
|
+
for (;;) {
|
|
11
|
+
const candidate = join(dir, '.tripo', 'context.json');
|
|
12
|
+
if (existsSync(candidate))
|
|
13
|
+
return candidate;
|
|
14
|
+
const parent = dirname(dir);
|
|
15
|
+
if (parent === dir)
|
|
16
|
+
return undefined;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function loadContext(from = process.cwd()) {
|
|
21
|
+
const file = findContextFile(from);
|
|
22
|
+
if (!file)
|
|
23
|
+
return {};
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Update (or create) the context file in the current directory. */
|
|
32
|
+
export function updateContext(patch, cwd = process.cwd()) {
|
|
33
|
+
const existing = findContextFile(cwd);
|
|
34
|
+
const file = existing ?? join(cwd, '.tripo', 'context.json');
|
|
35
|
+
let current = {};
|
|
36
|
+
if (existing) {
|
|
37
|
+
try {
|
|
38
|
+
current = JSON.parse(readFileSync(existing, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
43
|
+
writeFileSync(file, JSON.stringify({ ...current, ...patch }, null, 2) + '\n');
|
|
44
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task history at ~/.tripo/history.jsonl — powers implicit context (@last),
|
|
3
|
+
* positional references (@2), named references (@my-knight), and redo.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tripoHome } from './config.js';
|
|
8
|
+
function historyPath() {
|
|
9
|
+
return join(tripoHome(), 'history.jsonl');
|
|
10
|
+
}
|
|
11
|
+
export function appendHistory(entry) {
|
|
12
|
+
mkdirSync(tripoHome(), { recursive: true, mode: 0o700 });
|
|
13
|
+
appendFileSync(historyPath(), JSON.stringify(entry) + '\n');
|
|
14
|
+
}
|
|
15
|
+
export function readHistory() {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(historyPath(), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const entries = [];
|
|
24
|
+
for (const line of raw.split('\n')) {
|
|
25
|
+
if (!line.trim())
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
entries.push(JSON.parse(line));
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
}
|
|
32
|
+
// Later entries for the same task id override earlier ones (status updates).
|
|
33
|
+
const merged = new Map();
|
|
34
|
+
for (const e of entries)
|
|
35
|
+
merged.set(e.task_id, { ...merged.get(e.task_id), ...e });
|
|
36
|
+
return [...merged.values()];
|
|
37
|
+
}
|
|
38
|
+
export function updateHistoryStatus(taskId, status, patch) {
|
|
39
|
+
const existing = readHistory().find((e) => e.task_id === taskId);
|
|
40
|
+
if (!existing)
|
|
41
|
+
return;
|
|
42
|
+
appendHistory({ ...existing, ...patch, status, task_id: taskId });
|
|
43
|
+
}
|
|
44
|
+
export class RefError extends Error {
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a history reference:
|
|
48
|
+
* @last → most recent successful task
|
|
49
|
+
* @last:any → most recent task regardless of status
|
|
50
|
+
* @2 → 2nd most recent successful task
|
|
51
|
+
* @some-name → unique match on entry name (slug); ambiguity is an error
|
|
52
|
+
* Returns undefined when `ref` is not a reference at all.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveReference(ref) {
|
|
55
|
+
if (!ref.startsWith('@'))
|
|
56
|
+
return undefined;
|
|
57
|
+
const body = ref.slice(1);
|
|
58
|
+
const entries = readHistory();
|
|
59
|
+
const successes = entries.filter((e) => e.status === 'success');
|
|
60
|
+
if (body === 'last' || body === '') {
|
|
61
|
+
const hit = successes[successes.length - 1];
|
|
62
|
+
if (!hit)
|
|
63
|
+
throw new RefError('no successful task in history yet');
|
|
64
|
+
return hit;
|
|
65
|
+
}
|
|
66
|
+
if (body === 'last:any') {
|
|
67
|
+
const hit = entries[entries.length - 1];
|
|
68
|
+
if (!hit)
|
|
69
|
+
throw new RefError('history is empty');
|
|
70
|
+
return hit;
|
|
71
|
+
}
|
|
72
|
+
if (/^\d+$/.test(body)) {
|
|
73
|
+
const n = Number(body);
|
|
74
|
+
const hit = successes[successes.length - n];
|
|
75
|
+
if (!hit)
|
|
76
|
+
throw new RefError(`history has only ${successes.length} successful task(s), cannot resolve @${n}`);
|
|
77
|
+
return hit;
|
|
78
|
+
}
|
|
79
|
+
const matches = entries.filter((e) => e.name === body);
|
|
80
|
+
if (matches.length === 0) {
|
|
81
|
+
const fuzzy = entries.filter((e) => e.name?.includes(body));
|
|
82
|
+
if (fuzzy.length === 1)
|
|
83
|
+
return fuzzy[0];
|
|
84
|
+
if (fuzzy.length > 1) {
|
|
85
|
+
throw new RefError(`"@${body}" matches ${fuzzy.length} tasks: ${fuzzy.map((e) => `${e.name} (${e.task_id.slice(0, 8)})`).join(', ')} — use a task id instead`);
|
|
86
|
+
}
|
|
87
|
+
throw new RefError(`no task named "@${body}" in history`);
|
|
88
|
+
}
|
|
89
|
+
if (matches.length > 1) {
|
|
90
|
+
throw new RefError(`"@${body}" matches ${matches.length} tasks: ${matches.map((e) => e.task_id.slice(0, 8)).join(', ')} — use a task id instead`);
|
|
91
|
+
}
|
|
92
|
+
return matches[0];
|
|
93
|
+
}
|
|
94
|
+
/** Build a filesystem/name-safe slug from a prompt or filename. */
|
|
95
|
+
export function slugify(text, maxLen = 32) {
|
|
96
|
+
const slug = text
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.replace(/\.[a-z0-9]+$/i, '')
|
|
99
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, '-')
|
|
100
|
+
.replace(/^-+|-+$/g, '')
|
|
101
|
+
.slice(0, maxLen)
|
|
102
|
+
.replace(/-+$/, '');
|
|
103
|
+
return slug || 'task';
|
|
104
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight V3 API client on top of global fetch (Node 20+).
|
|
3
|
+
* Handles the response envelope, error translation, timeouts, and
|
|
4
|
+
* exponential-backoff retries for transient failures (429/5xx/network).
|
|
5
|
+
*/
|
|
6
|
+
import { loadConfig, resolveApiBaseUrl, resolveApiKey } from '../config/config.js';
|
|
7
|
+
import { ApiError, AuthError, NetworkError } from './errors.js';
|
|
8
|
+
export class TripoClient {
|
|
9
|
+
baseUrl;
|
|
10
|
+
apiKey;
|
|
11
|
+
timeoutMs;
|
|
12
|
+
maxRetries;
|
|
13
|
+
onRetry;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
this.baseUrl = (options.baseUrl ?? resolveApiBaseUrl(config)).replace(/\/$/, '');
|
|
17
|
+
const key = options.apiKey ?? resolveApiKey(config);
|
|
18
|
+
if (!key) {
|
|
19
|
+
throw new AuthError('no API key configured');
|
|
20
|
+
}
|
|
21
|
+
this.apiKey = key;
|
|
22
|
+
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
23
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
24
|
+
this.onRetry = options.onRetry;
|
|
25
|
+
}
|
|
26
|
+
async request(method, path, body) {
|
|
27
|
+
return this.send(method, path, {
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async uploadFile(fileName, data) {
|
|
33
|
+
const form = new FormData();
|
|
34
|
+
form.append('file', data, fileName);
|
|
35
|
+
return this.send('POST', '/v3/files', { body: form });
|
|
36
|
+
}
|
|
37
|
+
async send(method, path, init) {
|
|
38
|
+
let lastError;
|
|
39
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
40
|
+
if (attempt > 0) {
|
|
41
|
+
const delay = Math.min(1000 * 2 ** (attempt - 1), 8000);
|
|
42
|
+
this.onRetry?.(attempt, delay, lastError?.message ?? 'retry');
|
|
43
|
+
await sleep(delay);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return await this.sendOnce(method, path, init);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
lastError = err;
|
|
50
|
+
const retryable = err instanceof NetworkError || (err instanceof ApiError && err.retryable);
|
|
51
|
+
if (!retryable || attempt === this.maxRetries)
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/* c8 ignore next */
|
|
56
|
+
throw lastError ?? new NetworkError('request failed');
|
|
57
|
+
}
|
|
58
|
+
async sendOnce(method, path, init) {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
61
|
+
let response;
|
|
62
|
+
try {
|
|
63
|
+
response = await fetch(this.baseUrl + path, {
|
|
64
|
+
method,
|
|
65
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, ...init.headers },
|
|
66
|
+
body: init.body,
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
72
|
+
throw new NetworkError(controller.signal.aborted ? `request timed out after ${this.timeoutMs}ms: ${method} ${path}` : `network error: ${reason}`);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
let envelope;
|
|
78
|
+
try {
|
|
79
|
+
envelope = (await response.json());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new ApiError({ httpStatus: response.status, message: `HTTP ${response.status} with non-JSON body` });
|
|
84
|
+
}
|
|
85
|
+
throw new NetworkError(`invalid JSON response from ${path}`);
|
|
86
|
+
}
|
|
87
|
+
if (!response.ok || envelope.code !== 0) {
|
|
88
|
+
throw new ApiError({
|
|
89
|
+
apiCode: envelope.code !== 0 ? envelope.code : undefined,
|
|
90
|
+
httpStatus: response.status,
|
|
91
|
+
message: envelope.message,
|
|
92
|
+
suggestion: envelope.suggestion,
|
|
93
|
+
requestId: envelope.request_id,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return envelope.data;
|
|
97
|
+
}
|
|
98
|
+
// ---- typed convenience wrappers ----
|
|
99
|
+
getTask(taskId, step = false) {
|
|
100
|
+
const suffix = step ? '?step=1' : '';
|
|
101
|
+
return this.request('GET', `/v3/tasks/${encodeURIComponent(taskId)}${suffix}`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Batch task query. The server returns `{tasks: {id: detail}, missed: [id]}`
|
|
105
|
+
* (NOT an array — the public docs are wrong here; verified against production).
|
|
106
|
+
*/
|
|
107
|
+
async listTasks(taskIds) {
|
|
108
|
+
const data = await this.request('POST', '/v3/tasks/list', { task_ids: taskIds });
|
|
109
|
+
const byId = data.tasks ?? {};
|
|
110
|
+
const tasks = taskIds
|
|
111
|
+
.filter((id) => byId[id])
|
|
112
|
+
.map((id) => ({ ...byId[id], task_id: byId[id].task_id ?? id }));
|
|
113
|
+
return { tasks, missed: data.missed ?? [] };
|
|
114
|
+
}
|
|
115
|
+
getBalance() {
|
|
116
|
+
return this.request('GET', '/v3/account/balance');
|
|
117
|
+
}
|
|
118
|
+
getUsage() {
|
|
119
|
+
return this.request('GET', '/v3/account/usage');
|
|
120
|
+
}
|
|
121
|
+
/** Create a task; returns its id. Response may be {task_id} or a full detail. */
|
|
122
|
+
async createTask(endpoint, payload) {
|
|
123
|
+
const data = await this.request('POST', endpoint, payload);
|
|
124
|
+
return data.task_id;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function sleep(ms) {
|
|
128
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI device-authorization flow (RFC 8628) against the console backend
|
|
3
|
+
* (apiplatform.tripo3d.ai / .com — NOT the generation API host).
|
|
4
|
+
*
|
|
5
|
+
* Deliberately not part of TripoClient: different host, no Bearer auth, and
|
|
6
|
+
* its own error vocabulary (4290 expired / 4291 slow_down / 4292 denied).
|
|
7
|
+
* The flow is optional by design — when the backend feature flag is off the
|
|
8
|
+
* /cli/device endpoints 404 and the caller falls back to paste login.
|
|
9
|
+
*/
|
|
10
|
+
import { hostname } from 'node:os';
|
|
11
|
+
import { REGION_DEFAULTS } from '../config/config.js';
|
|
12
|
+
import { sleep } from './client.js';
|
|
13
|
+
export const DEVICE_CODE_EXPIRED = 4290;
|
|
14
|
+
export const DEVICE_CODE_SLOW_DOWN = 4291;
|
|
15
|
+
export const DEVICE_CODE_DENIED = 4292;
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when the device-auth endpoints are missing or unusable — callers
|
|
19
|
+
* fall back to paste. `kind` separates the two very different situations:
|
|
20
|
+
* 'not_deployed' (404 — feature flag off, a normal rollout state, fall back
|
|
21
|
+
* quietly) vs 'backend_error' (5xx — the feature is deployed but broken;
|
|
22
|
+
* fall back too, but surface the backend's message + request_id loudly so
|
|
23
|
+
* the failure is reportable instead of silently masked).
|
|
24
|
+
*/
|
|
25
|
+
export class DeviceAuthUnavailableError extends Error {
|
|
26
|
+
kind;
|
|
27
|
+
constructor(kind, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.kind = kind;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function consoleApiBaseUrl(region) {
|
|
33
|
+
return process.env.TRIPO_CONSOLE_API_BASE_URL || REGION_DEFAULTS[region].consoleApi;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The backend whitelists key names to [a-zA-Z0-9 _.-]{1,32} — hostnames with
|
|
37
|
+
* anything else (e.g. non-ASCII machine names) would get a hard 400, so strip
|
|
38
|
+
* offending characters up front and fall back to a constant when nothing
|
|
39
|
+
* survives.
|
|
40
|
+
*/
|
|
41
|
+
export function deviceKeyName(host = hostname()) {
|
|
42
|
+
const cleaned = `CLI - ${host}`.replace(/[^a-zA-Z0-9 _.-]/g, '').replace(/\s+/g, ' ').trim();
|
|
43
|
+
const capped = cleaned.slice(0, 32).trim();
|
|
44
|
+
return capped.length >= 7 ? capped : 'Tripo CLI';
|
|
45
|
+
}
|
|
46
|
+
export async function requestDeviceCode(region) {
|
|
47
|
+
const body = await post(`${consoleApiBaseUrl(region)}/cli/device/code`, { name: deviceKeyName() });
|
|
48
|
+
const grant = body.data;
|
|
49
|
+
if (!grant ||
|
|
50
|
+
typeof grant.device_code !== 'string' ||
|
|
51
|
+
typeof grant.user_code !== 'string' ||
|
|
52
|
+
typeof grant.verification_url_complete !== 'string') {
|
|
53
|
+
throw new DeviceAuthUnavailableError('not_deployed', 'malformed device code response');
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...grant,
|
|
57
|
+
expires_in: grant.expires_in > 0 ? grant.expires_in : 900,
|
|
58
|
+
interval: grant.interval > 0 ? grant.interval : 5,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export async function pollDeviceToken(region, deviceCode) {
|
|
62
|
+
const body = await post(`${consoleApiBaseUrl(region)}/cli/device/token`, { device_code: deviceCode });
|
|
63
|
+
if (body.code === 0) {
|
|
64
|
+
const data = (body.data ?? {});
|
|
65
|
+
if (data.status === 'approved' && data.apikey) {
|
|
66
|
+
return { status: 'approved', apikey: data.apikey, name: data.name };
|
|
67
|
+
}
|
|
68
|
+
return { status: 'pending' };
|
|
69
|
+
}
|
|
70
|
+
if (body.code === DEVICE_CODE_SLOW_DOWN)
|
|
71
|
+
return { status: 'slow_down' };
|
|
72
|
+
if (body.code === DEVICE_CODE_DENIED)
|
|
73
|
+
return { status: 'denied' };
|
|
74
|
+
return { status: 'expired' };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Poll until the user approves/denies or the grant expires. `onTick` fires
|
|
78
|
+
* once per poll so the caller can keep a spinner alive.
|
|
79
|
+
*/
|
|
80
|
+
export async function waitForDeviceApproval(region, grant, onTick) {
|
|
81
|
+
const deadline = Date.now() + grant.expires_in * 1000;
|
|
82
|
+
let intervalMs = grant.interval * 1000;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
await sleep(intervalMs);
|
|
85
|
+
onTick?.();
|
|
86
|
+
let poll;
|
|
87
|
+
try {
|
|
88
|
+
poll = await pollDeviceToken(region, grant.device_code);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue; // transient network errors: keep polling until the deadline
|
|
92
|
+
}
|
|
93
|
+
if (poll.status === 'slow_down') {
|
|
94
|
+
intervalMs += 5000;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (poll.status !== 'pending')
|
|
98
|
+
return poll;
|
|
99
|
+
}
|
|
100
|
+
return { status: 'expired' };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* POST JSON and return the response envelope regardless of HTTP status —
|
|
104
|
+
* device-auth errors (4290/4291/4292) arrive as HTTP 400/429 with a JSON
|
|
105
|
+
* body. 404 means the feature is not deployed; 5xx means it is deployed but
|
|
106
|
+
* broken, so the backend's message and request_id are preserved for the
|
|
107
|
+
* user to report.
|
|
108
|
+
*/
|
|
109
|
+
async function post(url, payload) {
|
|
110
|
+
let response;
|
|
111
|
+
try {
|
|
112
|
+
response = await fetch(url, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
body: JSON.stringify(payload),
|
|
116
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new DeviceAuthUnavailableError('not_deployed', `cannot reach ${url}`);
|
|
121
|
+
}
|
|
122
|
+
if (response.status === 404 || response.status === 405) {
|
|
123
|
+
throw new DeviceAuthUnavailableError('not_deployed', 'device auth endpoints not deployed');
|
|
124
|
+
}
|
|
125
|
+
let body;
|
|
126
|
+
try {
|
|
127
|
+
body = await response.json();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new DeviceAuthUnavailableError('not_deployed', 'non-JSON response from device auth endpoint');
|
|
131
|
+
}
|
|
132
|
+
const envelope = body;
|
|
133
|
+
if (typeof envelope.code !== 'number') {
|
|
134
|
+
throw new DeviceAuthUnavailableError('not_deployed', 'unexpected response shape from device auth endpoint');
|
|
135
|
+
}
|
|
136
|
+
if (response.status >= 500) {
|
|
137
|
+
const requestId = envelope.request_id ? ` [request_id=${envelope.request_id}]` : '';
|
|
138
|
+
throw new DeviceAuthUnavailableError('backend_error', `HTTP ${response.status} code=${envelope.code}: ${envelope.message ?? 'unknown error'}${requestId}`);
|
|
139
|
+
}
|
|
140
|
+
return envelope;
|
|
141
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact download: walk task.output for URL-bearing fields, fetch each into
|
|
3
|
+
* the output directory, and write task.json (the reproducibility record).
|
|
4
|
+
* Output URLs expire in ~5 minutes, so downloads happen immediately after
|
|
5
|
+
* completion and URLs are never cached.
|
|
6
|
+
*/
|
|
7
|
+
import { createWriteStream } from 'node:fs';
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { basename, extname, join } from 'node:path';
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
import { NetworkError } from './errors.js';
|
|
13
|
+
const MODEL_EXTS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.stl', '.3mf', '.usdz', '.zip', '.ply']);
|
|
14
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
15
|
+
/** Collect every downloadable URL in the output map (string or {url} object). */
|
|
16
|
+
export function collectOutputUrls(output) {
|
|
17
|
+
if (!output)
|
|
18
|
+
return [];
|
|
19
|
+
const found = [];
|
|
20
|
+
const visit = (value, path) => {
|
|
21
|
+
if (typeof value === 'string') {
|
|
22
|
+
if (/^https?:\/\//.test(value))
|
|
23
|
+
found.push({ field: path, url: value });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
value.forEach((v, i) => visit(v, `${path}[${i}]`));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (value && typeof value === 'object') {
|
|
31
|
+
for (const [k, v] of Object.entries(value))
|
|
32
|
+
visit(v, path ? `${path}.${k}` : k);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
visit(output, '');
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
function fileNameFor(field, url) {
|
|
39
|
+
const urlPath = new URL(url).pathname;
|
|
40
|
+
let ext = extname(urlPath).toLowerCase();
|
|
41
|
+
const base = field
|
|
42
|
+
.replace(/_url$/, '')
|
|
43
|
+
.replace(/[^a-z0-9_.\-[\]]/gi, '_')
|
|
44
|
+
.replace(/[[\]]/g, '_')
|
|
45
|
+
.replace(/_+/g, '_')
|
|
46
|
+
.replace(/^_|_$/g, '');
|
|
47
|
+
if (!ext) {
|
|
48
|
+
// rendered images are usually webp/png; models glb — guess from field name
|
|
49
|
+
ext = /image|preview|render/i.test(field) ? '.png' : '.bin';
|
|
50
|
+
}
|
|
51
|
+
return `${base || basename(urlPath) || 'artifact'}${ext}`;
|
|
52
|
+
}
|
|
53
|
+
async function downloadOne(url, dest) {
|
|
54
|
+
const response = await fetch(url);
|
|
55
|
+
if (!response.ok || !response.body) {
|
|
56
|
+
throw new NetworkError(`download failed (HTTP ${response.status}): ${dest}`);
|
|
57
|
+
}
|
|
58
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(dest));
|
|
59
|
+
}
|
|
60
|
+
/** Download all artifacts of a finished task + write task.json. */
|
|
61
|
+
export async function downloadArtifacts(task, outputDir, historyEntry, options = {}) {
|
|
62
|
+
await mkdir(outputDir, { recursive: true });
|
|
63
|
+
const urls = collectOutputUrls(task.output);
|
|
64
|
+
const files = [];
|
|
65
|
+
let modelFile;
|
|
66
|
+
let preview;
|
|
67
|
+
for (const { field, url } of urls) {
|
|
68
|
+
const name = fileNameFor(field, url);
|
|
69
|
+
const dest = join(outputDir, name);
|
|
70
|
+
options.onFile?.(name);
|
|
71
|
+
await downloadOne(url, dest);
|
|
72
|
+
files.push(dest);
|
|
73
|
+
const ext = extname(name).toLowerCase();
|
|
74
|
+
if (!modelFile && MODEL_EXTS.has(ext))
|
|
75
|
+
modelFile = dest;
|
|
76
|
+
if (!preview && (IMAGE_EXTS.has(ext) || /rendered|preview/i.test(field)))
|
|
77
|
+
preview = dest;
|
|
78
|
+
}
|
|
79
|
+
// Normalize the preview name so agents can rely on preview.png existing.
|
|
80
|
+
if (preview && basename(preview) !== 'preview.png') {
|
|
81
|
+
const { copyFile } = await import('node:fs/promises');
|
|
82
|
+
const canonical = join(outputDir, 'preview.png');
|
|
83
|
+
try {
|
|
84
|
+
await copyFile(preview, canonical);
|
|
85
|
+
files.push(canonical);
|
|
86
|
+
preview = canonical;
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
}
|
|
90
|
+
const record = {
|
|
91
|
+
task_id: task.task_id,
|
|
92
|
+
type: task.type,
|
|
93
|
+
status: task.status,
|
|
94
|
+
input: task.input ?? historyEntry?.payload ?? {},
|
|
95
|
+
output_fields: urls.map((u) => u.field),
|
|
96
|
+
credits_consumed: task.credits_consumed,
|
|
97
|
+
created_at: task.created_at,
|
|
98
|
+
completed_at: task.completed_at,
|
|
99
|
+
files: files.map((f) => basename(f)),
|
|
100
|
+
};
|
|
101
|
+
await writeFile(join(outputDir, 'task.json'), JSON.stringify(record, null, 2) + '\n');
|
|
102
|
+
return { outputDir, files, modelFile, preview };
|
|
103
|
+
}
|
|
104
|
+
/** Default output directory: ./tripo-out/<slug>-<id8>/ */
|
|
105
|
+
export function defaultOutputDir(baseDir, slug, taskId) {
|
|
106
|
+
return join(baseDir, 'tripo-out', `${slug}-${taskId.slice(0, 8)}`);
|
|
107
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** CLI error types carrying stable exit codes and human translations. */
|
|
2
|
+
import { t } from '../i18n.js';
|
|
3
|
+
import { EXIT, exitCodeFor, isRetryable, lookupError } from '../knowledge/error-catalog.js';
|
|
4
|
+
export class CliError extends Error {
|
|
5
|
+
exitCode;
|
|
6
|
+
suggestion;
|
|
7
|
+
constructor(message, exitCode = EXIT.FATAL, suggestion) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
this.suggestion = suggestion;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class UsageError extends CliError {
|
|
14
|
+
constructor(message, suggestion) {
|
|
15
|
+
super(message, EXIT.USAGE, suggestion);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class AuthError extends CliError {
|
|
19
|
+
constructor(message, suggestion) {
|
|
20
|
+
super(message, EXIT.AUTH, suggestion ?? t({ zh: '运行 tripo login 配置 API Key', en: 'run "tripo login" to configure an API key' }));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ApiError extends CliError {
|
|
24
|
+
apiCode;
|
|
25
|
+
httpStatus;
|
|
26
|
+
requestId;
|
|
27
|
+
retryable;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
const info = opts.apiCode !== undefined ? lookupError(opts.apiCode) : undefined;
|
|
30
|
+
const translated = info ? t(info) : undefined;
|
|
31
|
+
const parts = [translated ?? opts.message ?? `API error (HTTP ${opts.httpStatus})`];
|
|
32
|
+
if (translated && opts.message && opts.message !== translated)
|
|
33
|
+
parts.push(`(${opts.message})`);
|
|
34
|
+
super(parts.join(' '), exitCodeFor(opts.apiCode, opts.httpStatus), opts.suggestion);
|
|
35
|
+
this.apiCode = opts.apiCode;
|
|
36
|
+
this.httpStatus = opts.httpStatus;
|
|
37
|
+
this.requestId = opts.requestId;
|
|
38
|
+
this.retryable = isRetryable(opts.apiCode, opts.httpStatus);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class NetworkError extends CliError {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message, EXIT.NETWORK);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class TaskFailedError extends CliError {
|
|
47
|
+
taskId;
|
|
48
|
+
constructor(taskId, status, detail) {
|
|
49
|
+
const refundNote = t({
|
|
50
|
+
zh: '预扣积分已自动退还',
|
|
51
|
+
en: 'frozen credits have been refunded automatically',
|
|
52
|
+
});
|
|
53
|
+
super(`task ${taskId} ${status}${detail ? `: ${detail}` : ''} (${refundNote})`, EXIT.TASK_FAILED);
|
|
54
|
+
this.taskId = taskId;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Decorate insufficient-balance errors with the topup path and the gap. */
|
|
58
|
+
export function balanceSuggestion(needed, balance) {
|
|
59
|
+
const gap = needed !== undefined && balance !== undefined ? Math.max(0, needed - balance) : undefined;
|
|
60
|
+
return t({
|
|
61
|
+
zh: `运行 tripo topup 充值${gap !== undefined ? `(还差约 ${gap} 积分)` : ''}`,
|
|
62
|
+
en: `run "tripo topup" to add credits${gap !== undefined ? ` (about ${gap} more needed)` : ''}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output discipline (the iron rule):
|
|
3
|
+
* - stdout carries ONLY the final business result (JSON when piped/--json,
|
|
4
|
+
* human-readable text when interactive).
|
|
5
|
+
* - stderr carries all progress, logs, prompts, and decoration.
|
|
6
|
+
* Non-TTY environments automatically behave like --json --yes --no-open.
|
|
7
|
+
*/
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
const state = { json: false, yes: false, quiet: false, noOpen: false };
|
|
10
|
+
export function stdoutIsTTY() {
|
|
11
|
+
return Boolean(process.stdout.isTTY);
|
|
12
|
+
}
|
|
13
|
+
export function stdinIsTTY() {
|
|
14
|
+
return Boolean(process.stdin.isTTY);
|
|
15
|
+
}
|
|
16
|
+
export function configureOutput(flags) {
|
|
17
|
+
Object.assign(state, flags);
|
|
18
|
+
// Headless environments (pipes, agents, CI) get machine output + no prompts.
|
|
19
|
+
if (!stdoutIsTTY())
|
|
20
|
+
state.json = true;
|
|
21
|
+
if (!stdinIsTTY())
|
|
22
|
+
state.yes = state.yes || !stdoutIsTTY();
|
|
23
|
+
if (!stdoutIsTTY() || !stdinIsTTY())
|
|
24
|
+
state.noOpen = true;
|
|
25
|
+
}
|
|
26
|
+
export function isJsonMode() {
|
|
27
|
+
return state.json;
|
|
28
|
+
}
|
|
29
|
+
export function isYes() {
|
|
30
|
+
return state.yes;
|
|
31
|
+
}
|
|
32
|
+
export function isQuiet() {
|
|
33
|
+
return state.quiet;
|
|
34
|
+
}
|
|
35
|
+
export function shouldOpen() {
|
|
36
|
+
return !state.noOpen;
|
|
37
|
+
}
|
|
38
|
+
export function isInteractive() {
|
|
39
|
+
return stdinIsTTY() && stdoutIsTTY() && !state.yes;
|
|
40
|
+
}
|
|
41
|
+
/** Progress/log line → stderr. */
|
|
42
|
+
export function log(message) {
|
|
43
|
+
if (!state.quiet)
|
|
44
|
+
process.stderr.write(message + '\n');
|
|
45
|
+
}
|
|
46
|
+
export function warn(message) {
|
|
47
|
+
process.stderr.write(pc.yellow(`! ${message}`) + '\n');
|
|
48
|
+
}
|
|
49
|
+
export function errorLine(message) {
|
|
50
|
+
process.stderr.write(pc.red(`✗ ${message}`) + '\n');
|
|
51
|
+
}
|
|
52
|
+
export function success(message) {
|
|
53
|
+
if (!state.quiet)
|
|
54
|
+
process.stderr.write(pc.green(`✓ ${message}`) + '\n');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Final business result → stdout.
|
|
58
|
+
* JSON mode prints a single line (NDJSON-safe); human mode prints the
|
|
59
|
+
* formatted text (or a default rendering).
|
|
60
|
+
*/
|
|
61
|
+
export function emitResult(data, human) {
|
|
62
|
+
if (state.json) {
|
|
63
|
+
process.stdout.write(JSON.stringify(data) + '\n');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
process.stdout.write((human ?? JSON.stringify(data, null, 2)) + '\n');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Stream one NDJSON event to stdout (used by watch-style streaming output). */
|
|
70
|
+
export function emitNdjson(event) {
|
|
71
|
+
process.stdout.write(JSON.stringify(event) + '\n');
|
|
72
|
+
}
|