tripo-cli 0.7.2 → 0.8.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/CHANGELOG.md +29 -0
- package/README.md +16 -14
- package/dist/ai/experts.js +26 -20
- package/dist/ai/plan-card.js +73 -34
- package/dist/ai/wizard.js +19 -19
- package/dist/commands/account.js +2 -6
- package/dist/commands/ai.js +18 -16
- package/dist/commands/batch.js +58 -18
- package/dist/commands/completion.js +1 -1
- package/dist/commands/config-cmd.js +1 -1
- package/dist/commands/doctor.js +1 -3
- package/dist/commands/generate.js +50 -8
- package/dist/commands/home.js +12 -13
- package/dist/commands/login.js +125 -182
- package/dist/commands/make.js +50 -25
- package/dist/commands/mcp.js +8 -3
- package/dist/commands/process-groups.js +29 -18
- package/dist/commands/shared.js +22 -13
- package/dist/commands/task.js +12 -11
- package/dist/commands/topup.js +6 -10
- package/dist/commands/update.js +23 -11
- package/dist/commands/view.js +3 -4
- package/dist/config/config.js +1 -1
- package/dist/core/analytics.js +39 -16
- package/dist/core/client.js +53 -21
- package/dist/core/device-auth.js +49 -1
- package/dist/core/download.js +2 -1
- package/dist/core/errors.js +24 -14
- package/dist/core/output.js +0 -4
- package/dist/core/requests.js +94 -31
- package/dist/core/task-service.js +21 -20
- package/dist/core/upload.js +23 -5
- package/dist/core/watch.js +3 -0
- package/dist/knowledge/chains.js +16 -17
- package/dist/knowledge/error-catalog.js +39 -37
- package/dist/knowledge/models.js +147 -27
- package/dist/knowledge/params.js +143 -32
- package/dist/knowledge/scenarios.js +32 -55
- package/dist/pipeline/make.js +38 -30
- package/dist/pipeline/runner.js +27 -8
- package/dist/pipeline/stdin.js +40 -3
- package/dist/program.js +3 -10
- package/dist/util/input-detect.js +10 -1
- package/package.json +3 -2
- package/skill/SKILL.md +52 -15
- package/skill/commands/account.md +8 -3
- package/skill/commands/batch.md +13 -3
- package/skill/commands/generate.md +57 -23
- package/skill/commands/make.md +43 -16
- package/skill/commands/process.md +29 -24
- package/skill/commands/task.md +4 -4
- package/skill/commands/view.md +1 -1
- package/skill/common-errors.md +14 -10
- package/skill/examples/animation.md +4 -2
- package/skill/examples/ar-web.md +6 -4
- package/skill/examples/game-asset.md +18 -7
- package/skill/examples/pipes.md +2 -2
- package/skill/examples/print.md +7 -3
- package/dist/i18n.js +0 -19
package/dist/commands/view.js
CHANGED
|
@@ -10,7 +10,6 @@ import { downloadArtifacts, defaultOutputDir } from '../core/download.js';
|
|
|
10
10
|
import { UsageError } from '../core/errors.js';
|
|
11
11
|
import { emitResult, log, shouldOpen } from '../core/output.js';
|
|
12
12
|
import { startViewer } from '../core/viewer.js';
|
|
13
|
-
import { t } from '../i18n.js';
|
|
14
13
|
import { runTask } from '../core/task-service.js';
|
|
15
14
|
import { openExternal } from '../util/open.js';
|
|
16
15
|
import { printTaskResult, newClient } from './shared.js';
|
|
@@ -26,11 +25,11 @@ export function registerViewCommands(program) {
|
|
|
26
25
|
const handle = await startViewer(modelFile, target);
|
|
27
26
|
if (shouldOpen()) {
|
|
28
27
|
openExternal(handle.url);
|
|
29
|
-
log(
|
|
28
|
+
log(`preview opened: ${handle.url}`);
|
|
30
29
|
}
|
|
31
30
|
emitResult({ url: handle.url, model_file: modelFile }, `${handle.url}\n${modelFile}`);
|
|
32
31
|
if (opts.keep || shouldOpen()) {
|
|
33
|
-
log(
|
|
32
|
+
log('Ctrl+C to stop the preview server');
|
|
34
33
|
await new Promise(() => { }); // hold until interrupted
|
|
35
34
|
}
|
|
36
35
|
else {
|
|
@@ -84,7 +83,7 @@ async function resolveModelFile(target) {
|
|
|
84
83
|
const task = await client.getTask(taskId);
|
|
85
84
|
if (task.status !== 'success')
|
|
86
85
|
throw new UsageError(`task ${taskId} is ${task.status}, nothing to view yet`);
|
|
87
|
-
log(
|
|
86
|
+
log('downloading the model for preview…');
|
|
88
87
|
const dir = entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'view', taskId);
|
|
89
88
|
const artifacts = await downloadArtifacts(task, dir);
|
|
90
89
|
if (!artifacts.modelFile || !VIEWABLE.has(extname(artifacts.modelFile).toLowerCase())) {
|
package/dist/config/config.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Multi-account model: credentials live in named *profiles* (a dict plus an
|
|
5
5
|
* `active_profile` pointer — same shape as AWS CLI profiles / kubectl
|
|
6
6
|
* contexts). Everything account-bound (key, region, URL overrides) is
|
|
7
|
-
* per-profile; user preferences (
|
|
7
|
+
* per-profile; user preferences (llm, output dir) stay top-level.
|
|
8
8
|
*
|
|
9
9
|
* v0.1.0 stored a single key at the top level. That format is normalized
|
|
10
10
|
* into `profiles.default` on *every* load, so old files keep working
|
package/dist/core/analytics.js
CHANGED
|
@@ -10,10 +10,12 @@ export const ANALYTICS_APP_ID = "2908364badd34996a1cd44a5f7a71223";
|
|
|
10
10
|
const operations = new AsyncLocalStorage();
|
|
11
11
|
let activeOperation;
|
|
12
12
|
let installationId;
|
|
13
|
+
/** Account lookups held only in this process's memory: at most one per key per CLI command or MCP session. */
|
|
14
|
+
const identities = new Map();
|
|
13
15
|
const currentOperation = () => operations.getStore() ?? activeOperation;
|
|
14
16
|
export function beginOperation(command, disabled = false, info, regionHint) {
|
|
15
17
|
activeOperation = { command, started: Date.now(), disabled, finished: false, collectors: new Map(), regionHint, context: {
|
|
16
|
-
...attribution(process.env.TRIPO_CLIENT_APP, process.env.TRIPO_DISTRIBUTION_CHANNEL, info),
|
|
18
|
+
...attribution(process.env.TRIPO_CLIENT_APP, process.env.TRIPO_DISTRIBUTION_CHANNEL, info, undefined, hostClientApp()),
|
|
17
19
|
distinctId: randomUUID(), operation_id: randomUUID(), integration_mode: info ? "local_mcp" : "cli",
|
|
18
20
|
} };
|
|
19
21
|
operations.enterWith(activeOperation);
|
|
@@ -27,8 +29,15 @@ export function identifyUser(apiKey, region, apiUrl) {
|
|
|
27
29
|
return;
|
|
28
30
|
const consoleApi = process.env.TRIPO_CONSOLE_API_BASE_URL ?? REGION_DEFAULTS[region].consoleApi;
|
|
29
31
|
const key = createHash('sha256').update(JSON.stringify([region, apiUrl, consoleApi, apiKey])).digest('hex');
|
|
30
|
-
if (operation.identity?.key
|
|
31
|
-
|
|
32
|
+
if (operation.identity?.key === key)
|
|
33
|
+
return;
|
|
34
|
+
let value = identities.get(key);
|
|
35
|
+
if (!value) {
|
|
36
|
+
// A CLI command ends its lookup when it finishes; an MCP session lets it outlive the tool call, for at most 10 s.
|
|
37
|
+
const signal = operation.context.integration_mode === 'local_mcp' ? AbortSignal.timeout(10_000) : (operation.lookups ??= new AbortController()).signal;
|
|
38
|
+
identities.set(key, value = analytics.identify(apiKey, consoleApi, signal));
|
|
39
|
+
}
|
|
40
|
+
operation.identity = { key, value };
|
|
32
41
|
}
|
|
33
42
|
function collector(region = currentOperation()?.regionHint ?? resolveRegion(), apiUrl = resolveApiBaseUrl()) {
|
|
34
43
|
const operation = currentOperation();
|
|
@@ -95,6 +104,7 @@ export async function finishOperation(result, error) {
|
|
|
95
104
|
...(operation.context.integration_mode === 'local_mcp' ? { tool_name: operation.command } : {}),
|
|
96
105
|
result, duration_ms: Date.now() - operation.started, ...(error ? errorProperties(error) : {}) });
|
|
97
106
|
await flushAnalytics();
|
|
107
|
+
operation.lookups?.abort();
|
|
98
108
|
}
|
|
99
109
|
export function taskOrigin(model) {
|
|
100
110
|
const context = analyticsContext();
|
|
@@ -136,23 +146,35 @@ export function clientApp(value) {
|
|
|
136
146
|
if (typeof value !== "string")
|
|
137
147
|
return "unknown";
|
|
138
148
|
const name = value.trim().toLowerCase();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return "unknown";
|
|
149
|
+
if (name === "dsh")
|
|
150
|
+
return "deepseek";
|
|
151
|
+
const matches = ["codex", "workbuddy", "zcode", "kimi", "deepseek", "claude", "cursor", "chatgpt"]
|
|
152
|
+
.filter((app) => new RegExp(`(^|[\\s_./():-])${app}($|[\\s_./():-])`).test(name));
|
|
153
|
+
return matches.length === 1 ? matches[0] : "unknown";
|
|
154
|
+
}
|
|
155
|
+
function hostClientApp() {
|
|
156
|
+
// Session markers identify the caller; install paths and terminal names do not.
|
|
157
|
+
const hosts = [];
|
|
158
|
+
if (process.env.CODEX_THREAD_ID?.trim())
|
|
159
|
+
hosts.push("codex");
|
|
160
|
+
if (process.env.ZCODE_SESSION_ID?.trim())
|
|
161
|
+
hosts.push("zcode");
|
|
162
|
+
if (process.env.CODEBUDDY_HOST === "workbuddy-desktop")
|
|
163
|
+
hosts.push("workbuddy");
|
|
164
|
+
return hosts.length === 1 ? hosts[0] : "unknown";
|
|
144
165
|
}
|
|
145
|
-
export function attribution(explicit, distribution, info, oauthName) {
|
|
166
|
+
export function attribution(explicit, distribution, info, oauthName, hostName) {
|
|
146
167
|
const tagged = clientApp(explicit);
|
|
147
168
|
const initialized = clientApp(info?.name);
|
|
148
169
|
const oauth = clientApp(oauthName);
|
|
149
|
-
const
|
|
170
|
+
const host = clientApp(hostName);
|
|
171
|
+
const chosen = tagged !== "unknown" ? tagged : initialized !== "unknown" ? initialized : oauth !== "unknown" ? oauth : host;
|
|
150
172
|
const channels = ["codex_plugin", "workbuddy_plugin", "zcode_plugin", "kimi_plugin", "deepseek_plugin"];
|
|
151
173
|
return {
|
|
152
174
|
client_app: chosen,
|
|
153
175
|
distribution_channel: typeof distribution === "string" && channels.includes(distribution) ? distribution : "unknown",
|
|
154
|
-
attribution_method: tagged !== "unknown" ? "explicit_tag" : initialized !== "unknown" ? "mcp_client_info" : oauth !== "unknown" ? "oauth_client_name" : "unknown",
|
|
155
|
-
attribution_conflict: [tagged, initialized, oauth].some((app) => app !== "unknown" && chosen !== app),
|
|
176
|
+
attribution_method: tagged !== "unknown" ? "explicit_tag" : initialized !== "unknown" ? "mcp_client_info" : oauth !== "unknown" ? "oauth_client_name" : host !== "unknown" ? "host_environment" : "unknown",
|
|
177
|
+
attribution_conflict: [tagged, initialized, oauth, host].some((app) => app !== "unknown" && chosen !== app),
|
|
156
178
|
...(typeof info?.version === "string" && /^[a-zA-Z0-9._+-]{1,64}$/.test(info.version) && !/tsk_|bearer|token/i.test(info.version) ? { client_version: info.version } : {}),
|
|
157
179
|
};
|
|
158
180
|
}
|
|
@@ -202,7 +224,7 @@ export class Analytics {
|
|
|
202
224
|
&& (testing ? env.TRIPO_ANALYTICS_ENABLED === "1" && testReceiver : officialApi || testReceiver);
|
|
203
225
|
this.identityEnabled = this.enabled && (testing ? localApi : officialApi);
|
|
204
226
|
}
|
|
205
|
-
async identify(apiKey, consoleApi) {
|
|
227
|
+
async identify(apiKey, consoleApi, signal = AbortSignal.timeout(1000)) {
|
|
206
228
|
if (!this.identityEnabled)
|
|
207
229
|
return undefined;
|
|
208
230
|
try {
|
|
@@ -214,7 +236,7 @@ export class Analytics {
|
|
|
214
236
|
const response = await fetch(url, {
|
|
215
237
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
216
238
|
body: JSON.stringify({ apikey: apiKey, with_detail: true }),
|
|
217
|
-
redirect: "error", signal
|
|
239
|
+
redirect: "error", signal,
|
|
218
240
|
});
|
|
219
241
|
if (!response.ok)
|
|
220
242
|
return undefined;
|
|
@@ -268,9 +290,10 @@ export class Analytics {
|
|
|
268
290
|
const deadline = Date.now() + 2000;
|
|
269
291
|
while (this.queue.length && Date.now() < deadline) {
|
|
270
292
|
const batch = this.queue.splice(0, 100);
|
|
271
|
-
//
|
|
293
|
+
// A lookup may outlive its events, so wait at most 1 s for it; that leaves the send at least 1 s of the budget.
|
|
294
|
+
const wait = new Promise((resolve) => setTimeout(() => resolve(undefined), 1000).unref());
|
|
272
295
|
await Promise.all(batch.map(async ({ event, identity }) => {
|
|
273
|
-
const userId = await identity;
|
|
296
|
+
const userId = await Promise.race([identity, wait]);
|
|
274
297
|
if (userId)
|
|
275
298
|
event.data.properties.platform_user_id = userId;
|
|
276
299
|
}));
|
package/dist/core/client.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Lightweight V3 API client on top of global fetch (Node 20+).
|
|
3
3
|
* Handles the response envelope, error translation, timeouts, and
|
|
4
4
|
* exponential-backoff retries for transient failures (429/5xx/network;
|
|
5
|
-
* task creation is only resent on 429).
|
|
5
|
+
* task creation is only resent on 429). A 429 waits at least as long as the
|
|
6
|
+
* server's Retry-After / X-RateLimit-Reset hint, capped at 60s per wait.
|
|
6
7
|
*/
|
|
7
8
|
import { activeProfileName, DEFAULT_PROFILE, listProfiles, loadConfig, resolveApiBaseUrl, resolveApiKey } from '../config/config.js';
|
|
8
|
-
import { t } from '../i18n.js';
|
|
9
9
|
import { EXIT } from '../knowledge/error-catalog.js';
|
|
10
10
|
import { ApiError, AuthError, NetworkError } from './errors.js';
|
|
11
11
|
import { identifyUser, observeTask, trackEvent, taskOrigin } from './analytics.js';
|
|
@@ -27,15 +27,9 @@ export class TripoClient {
|
|
|
27
27
|
const wanted = activeProfileName(config);
|
|
28
28
|
const known = listProfiles(config).map((p) => p.name);
|
|
29
29
|
if (wanted !== DEFAULT_PROFILE && !known.includes(wanted)) {
|
|
30
|
-
throw new AuthError(
|
|
31
|
-
zh: `没有叫「${wanted}」的 profile${known.length ? `(现有: ${known.join(', ')})` : ''}`,
|
|
32
|
-
en: `profile "${wanted}" not found${known.length ? ` (have: ${known.join(', ')})` : ''}`,
|
|
33
|
-
}), t({
|
|
34
|
-
zh: `用 tripo login --profile ${wanted} 登录,或 tripo use 切换`,
|
|
35
|
-
en: `run "tripo login --profile ${wanted}" to create it, or "tripo use" to switch`,
|
|
36
|
-
}));
|
|
30
|
+
throw new AuthError(`profile "${wanted}" not found${known.length ? ` (have: ${known.join(', ')})` : ''}`, `run "tripo login --profile ${wanted}" to create it, or "tripo use" to switch`);
|
|
37
31
|
}
|
|
38
|
-
throw new AuthError(
|
|
32
|
+
throw new AuthError('no API key configured');
|
|
39
33
|
}
|
|
40
34
|
this.apiKey = key;
|
|
41
35
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
@@ -54,12 +48,35 @@ export class TripoClient {
|
|
|
54
48
|
form.append('file', data, fileName);
|
|
55
49
|
return this.send('POST', '/v3/files', { body: form });
|
|
56
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Large-file upload: presign, then PUT straight to storage. The PUT carries
|
|
53
|
+
* no Authorization header and no request timeout (files up to 150MB).
|
|
54
|
+
*/
|
|
55
|
+
async uploadFileViaPresign(format, data) {
|
|
56
|
+
const { presigned_url, file_token } = await this.request('POST', '/v3/files/presign', { format });
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await fetch(presigned_url, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
62
|
+
body: data,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
throw new NetworkError(`presigned upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
}
|
|
68
|
+
if (!response.ok)
|
|
69
|
+
throw new NetworkError(`presigned upload failed: HTTP ${response.status}`);
|
|
70
|
+
return { file_token };
|
|
71
|
+
}
|
|
57
72
|
async send(method, path, init) {
|
|
58
73
|
identifyUser(this.apiKey, this.region, this.baseUrl);
|
|
59
74
|
let lastError;
|
|
60
75
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
61
76
|
if (attempt > 0) {
|
|
62
|
-
const
|
|
77
|
+
const backoff = Math.min(1000 * 2 ** (attempt - 1), 8000);
|
|
78
|
+
const hint = lastError instanceof ApiError ? (lastError.retryAfterMs ?? 0) : 0;
|
|
79
|
+
const delay = Math.min(Math.max(backoff, hint), 60_000);
|
|
63
80
|
this.onRetry?.(attempt, delay, lastError?.message ?? 'retry');
|
|
64
81
|
await sleep(delay);
|
|
65
82
|
}
|
|
@@ -99,13 +116,14 @@ export class TripoClient {
|
|
|
99
116
|
finally {
|
|
100
117
|
clearTimeout(timer);
|
|
101
118
|
}
|
|
119
|
+
const retryAfterMs = response.status === 429 ? retryHintMs(response.headers) : undefined;
|
|
102
120
|
let envelope;
|
|
103
121
|
try {
|
|
104
122
|
envelope = (await response.json());
|
|
105
123
|
}
|
|
106
124
|
catch {
|
|
107
125
|
if (!response.ok) {
|
|
108
|
-
throw new ApiError({ httpStatus: response.status, message: `HTTP ${response.status} with non-JSON body
|
|
126
|
+
throw new ApiError({ httpStatus: response.status, message: `HTTP ${response.status} with non-JSON body`, retryAfterMs });
|
|
109
127
|
}
|
|
110
128
|
throw new NetworkError(`invalid JSON response from ${path}`);
|
|
111
129
|
}
|
|
@@ -116,6 +134,7 @@ export class TripoClient {
|
|
|
116
134
|
message: envelope.message,
|
|
117
135
|
suggestion: envelope.suggestion,
|
|
118
136
|
requestId: envelope.request_id,
|
|
137
|
+
retryAfterMs,
|
|
119
138
|
});
|
|
120
139
|
}
|
|
121
140
|
return envelope.data;
|
|
@@ -128,18 +147,23 @@ export class TripoClient {
|
|
|
128
147
|
return task;
|
|
129
148
|
}
|
|
130
149
|
/**
|
|
131
|
-
* Batch task query
|
|
132
|
-
*
|
|
150
|
+
* Batch task query, at most 100 ids per request. The server returns
|
|
151
|
+
* `{tasks: {id: detail}, missed: [id]}`.
|
|
133
152
|
*/
|
|
134
153
|
async listTasks(taskIds) {
|
|
135
|
-
const
|
|
136
|
-
const
|
|
154
|
+
const byId = {};
|
|
155
|
+
const missed = [];
|
|
156
|
+
for (let i = 0; i < taskIds.length; i += 100) {
|
|
157
|
+
const data = await this.request('POST', '/v3/tasks/list', { task_ids: taskIds.slice(i, i + 100) });
|
|
158
|
+
Object.assign(byId, data.tasks ?? {});
|
|
159
|
+
missed.push(...(data.missed ?? []));
|
|
160
|
+
}
|
|
137
161
|
for (const [id, task] of Object.entries(byId))
|
|
138
162
|
observeTask({ ...task, task_id: task.task_id ?? id }, this.region, this.baseUrl);
|
|
139
163
|
const tasks = taskIds
|
|
140
164
|
.filter((id) => byId[id])
|
|
141
165
|
.map((id) => ({ ...byId[id], task_id: byId[id].task_id ?? id }));
|
|
142
|
-
return { tasks, missed
|
|
166
|
+
return { tasks, missed };
|
|
143
167
|
}
|
|
144
168
|
getBalance() {
|
|
145
169
|
return this.request('GET', '/v3/account/balance');
|
|
@@ -157,10 +181,8 @@ export class TripoClient {
|
|
|
157
181
|
}
|
|
158
182
|
catch (err) {
|
|
159
183
|
if (err instanceof NetworkError || (err instanceof ApiError && err.exitCode === EXIT.NETWORK)) {
|
|
160
|
-
err.suggestion =
|
|
161
|
-
|
|
162
|
-
en: 'the task may already exist server-side (credits frozen); check "tripo balance" or the console before retrying, to avoid a duplicate charge',
|
|
163
|
-
});
|
|
184
|
+
err.suggestion = 'the task may already exist server-side (credits frozen); check "tripo balance" or the console before retrying, to avoid a duplicate charge';
|
|
185
|
+
err.submissionUncertain = true;
|
|
164
186
|
}
|
|
165
187
|
throw err;
|
|
166
188
|
}
|
|
@@ -169,3 +191,13 @@ export class TripoClient {
|
|
|
169
191
|
export function sleep(ms) {
|
|
170
192
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
171
193
|
}
|
|
194
|
+
/** Retry-After is in seconds; X-RateLimit-Reset is a unix timestamp in seconds. */
|
|
195
|
+
function retryHintMs(headers) {
|
|
196
|
+
const after = Number(headers.get('retry-after') ?? NaN);
|
|
197
|
+
if (Number.isFinite(after))
|
|
198
|
+
return after * 1000;
|
|
199
|
+
const reset = Number(headers.get('x-ratelimit-reset') ?? NaN);
|
|
200
|
+
if (Number.isFinite(reset))
|
|
201
|
+
return Math.max(reset * 1000 - Date.now(), 0);
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
package/dist/core/device-auth.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* /cli/device endpoints 404 and the caller falls back to paste login.
|
|
9
9
|
*/
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { chmodSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { hostname } from 'node:os';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { REGION_DEFAULTS, tripoHome } from '../config/config.js';
|
|
@@ -148,6 +148,54 @@ export async function waitForDeviceApproval(region, grant, onTick) {
|
|
|
148
148
|
trackEvent('agent_auth_result', { result: 'expired', auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
|
|
149
149
|
return { status: 'expired' };
|
|
150
150
|
}
|
|
151
|
+
/** A single poll without waiting; slow_down only means "asked too soon", so it reads as pending. */
|
|
152
|
+
export async function checkDeviceApproval(region, grant) {
|
|
153
|
+
const poll = await pollDeviceToken(region, grant.device_code);
|
|
154
|
+
if (poll.status === 'pending' || poll.status === 'slow_down')
|
|
155
|
+
return { status: 'pending' };
|
|
156
|
+
trackEvent('agent_auth_result', { result: poll.status === 'approved' ? 'success' : poll.status, auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
|
|
157
|
+
return poll;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A headless login must outlive its process: a chat agent can only relay the
|
|
161
|
+
* code by ending its turn, and its shell may be killed or suspended before the
|
|
162
|
+
* human approves. The grant is parked here (0600, removed once redeemed,
|
|
163
|
+
* denied, or expired) so the next `tripo login` resumes the code the human
|
|
164
|
+
* already has. device_code redeems the key — it never goes to stdout or logs.
|
|
165
|
+
*/
|
|
166
|
+
function pendingGrantFile() {
|
|
167
|
+
return join(tripoHome(), 'pending-login.json');
|
|
168
|
+
}
|
|
169
|
+
export function savePendingGrant(region, profile, grant) {
|
|
170
|
+
mkdirSync(tripoHome(), { recursive: true, mode: 0o700 });
|
|
171
|
+
const file = pendingGrantFile();
|
|
172
|
+
writeFileSync(file, JSON.stringify({ region, profile, expires_at: Date.now() + grant.expires_in * 1000, grant }) + '\n', { mode: 0o600 });
|
|
173
|
+
chmodSync(file, 0o600);
|
|
174
|
+
}
|
|
175
|
+
/** The parked grant for this region + profile, with its remaining lifetime as expires_in. */
|
|
176
|
+
export function loadPendingGrant(region, profile) {
|
|
177
|
+
let saved;
|
|
178
|
+
try {
|
|
179
|
+
saved = JSON.parse(readFileSync(pendingGrantFile(), 'utf8'));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const left = Math.floor(((saved.expires_at ?? 0) - Date.now()) / 1000);
|
|
185
|
+
if (left <= 0 || typeof saved.grant?.device_code !== 'string') {
|
|
186
|
+
clearPendingGrant();
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (saved.region !== region || saved.profile !== profile)
|
|
190
|
+
return undefined;
|
|
191
|
+
return { ...saved.grant, expires_in: left };
|
|
192
|
+
}
|
|
193
|
+
export function clearPendingGrant() {
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(pendingGrantFile());
|
|
196
|
+
}
|
|
197
|
+
catch { }
|
|
198
|
+
}
|
|
151
199
|
/**
|
|
152
200
|
* POST JSON and return the response envelope regardless of HTTP status —
|
|
153
201
|
* device-auth errors (4290/4291/4292) arrive as HTTP 400/429 with a JSON
|
package/dist/core/download.js
CHANGED
|
@@ -11,7 +11,7 @@ import { Readable } from 'node:stream';
|
|
|
11
11
|
import { pipeline } from 'node:stream/promises';
|
|
12
12
|
import { NetworkError } from './errors.js';
|
|
13
13
|
import { trackEvent, errorProperties } from './analytics.js';
|
|
14
|
-
const MODEL_EXTS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.stl', '.3mf', '.usdz', '.zip', '.ply']);
|
|
14
|
+
const MODEL_EXTS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.stl', '.3mf', '.usdz', '.zip', '.ply', '.splat']);
|
|
15
15
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
16
16
|
/** Collect every downloadable URL in the output map (string or {url} object). */
|
|
17
17
|
export function collectOutputUrls(output) {
|
|
@@ -97,6 +97,7 @@ export async function downloadArtifacts(task, outputDir, historyEntry, options =
|
|
|
97
97
|
status: task.status,
|
|
98
98
|
input: task.input ?? historyEntry?.payload ?? {},
|
|
99
99
|
output_fields: urls.map((u) => u.field),
|
|
100
|
+
normalize_scale: task.output?.normalize_scale,
|
|
100
101
|
credits_consumed: task.credits_consumed,
|
|
101
102
|
created_at: task.created_at,
|
|
102
103
|
completed_at: task.completed_at,
|
package/dist/core/errors.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** CLI error types carrying stable exit codes and human translations. */
|
|
2
|
-
import { t } from '../i18n.js';
|
|
3
2
|
import { EXIT, exitCodeFor, isRetryable, lookupError } from '../knowledge/error-catalog.js';
|
|
4
3
|
export class CliError extends Error {
|
|
5
4
|
exitCode;
|
|
6
5
|
suggestion;
|
|
6
|
+
/** A create request failed without a response: the paid task may exist server-side. */
|
|
7
|
+
submissionUncertain;
|
|
7
8
|
constructor(message, exitCode = EXIT.FATAL, suggestion) {
|
|
8
9
|
super(message);
|
|
9
10
|
this.exitCode = exitCode;
|
|
@@ -17,7 +18,7 @@ export class UsageError extends CliError {
|
|
|
17
18
|
}
|
|
18
19
|
export class AuthError extends CliError {
|
|
19
20
|
constructor(message, suggestion) {
|
|
20
|
-
super(message, EXIT.AUTH, suggestion ??
|
|
21
|
+
super(message, EXIT.AUTH, suggestion ?? 'run "tripo login" to configure an API key');
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
export class ApiError extends CliError {
|
|
@@ -25,9 +26,13 @@ export class ApiError extends CliError {
|
|
|
25
26
|
httpStatus;
|
|
26
27
|
requestId;
|
|
27
28
|
retryable;
|
|
29
|
+
/** Server wait hint on 429 (Retry-After / X-RateLimit-Reset). */
|
|
30
|
+
retryAfterMs;
|
|
28
31
|
constructor(opts) {
|
|
29
|
-
|
|
30
|
-
const
|
|
32
|
+
// HTTP 401 reads as an auth failure whatever code the body carries (see error-catalog).
|
|
33
|
+
const code = opts.httpStatus === 401 ? 1002 : opts.apiCode;
|
|
34
|
+
const info = code !== undefined ? lookupError(code) : undefined;
|
|
35
|
+
const translated = info?.message;
|
|
31
36
|
const parts = [translated ?? opts.message ?? `API error (HTTP ${opts.httpStatus})`];
|
|
32
37
|
if (translated && opts.message && opts.message !== translated)
|
|
33
38
|
parts.push(`(${opts.message})`);
|
|
@@ -36,6 +41,7 @@ export class ApiError extends CliError {
|
|
|
36
41
|
this.httpStatus = opts.httpStatus;
|
|
37
42
|
this.requestId = opts.requestId;
|
|
38
43
|
this.retryable = isRetryable(opts.apiCode, opts.httpStatus);
|
|
44
|
+
this.retryAfterMs = opts.retryAfterMs;
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
export class NetworkError extends CliError {
|
|
@@ -43,17 +49,24 @@ export class NetworkError extends CliError {
|
|
|
43
49
|
super(message, EXIT.NETWORK, suggestion);
|
|
44
50
|
}
|
|
45
51
|
}
|
|
52
|
+
/** The task succeeded (and was billed); only fetching its artifacts failed. */
|
|
53
|
+
export class ArtifactDownloadError extends NetworkError {
|
|
54
|
+
taskId;
|
|
55
|
+
result;
|
|
56
|
+
constructor(taskId, result, reason) {
|
|
57
|
+
super(`task ${taskId} succeeded but downloading its artifacts failed: ${reason}`, `the task is finished and billed; do not regenerate. Download it with "tripo task get ${taskId} --download"`);
|
|
58
|
+
this.taskId = taskId;
|
|
59
|
+
this.result = result;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
46
62
|
export class TaskFailedError extends CliError {
|
|
47
63
|
taskId;
|
|
48
64
|
status;
|
|
49
65
|
constructor(taskId, status, detail) {
|
|
50
|
-
const refundNote =
|
|
51
|
-
zh: '预扣积分已自动退还',
|
|
52
|
-
en: 'frozen credits have been refunded automatically',
|
|
53
|
-
});
|
|
66
|
+
const refundNote = 'frozen credits have been refunded automatically';
|
|
54
67
|
super(
|
|
55
|
-
//
|
|
56
|
-
`task ${taskId} ${status}${detail ? `: ${detail}` : ''}${status === '
|
|
68
|
+
// Only failed/cancelled tasks are refunded; a timed-out task is still running server-side.
|
|
69
|
+
`task ${taskId} ${status}${detail ? `: ${detail}` : ''}${status === 'failed' || status === 'cancelled' ? ` (${refundNote})` : ''}`, EXIT.TASK_FAILED);
|
|
57
70
|
this.taskId = taskId;
|
|
58
71
|
this.status = status;
|
|
59
72
|
}
|
|
@@ -61,8 +74,5 @@ export class TaskFailedError extends CliError {
|
|
|
61
74
|
/** Decorate insufficient-balance errors with the topup path and the gap. */
|
|
62
75
|
export function balanceSuggestion(needed, balance) {
|
|
63
76
|
const gap = needed !== undefined && balance !== undefined ? Math.max(0, needed - balance) : undefined;
|
|
64
|
-
return
|
|
65
|
-
zh: `运行 tripo topup 充值${gap !== undefined ? `(还差约 ${gap} 积分)` : ''}`,
|
|
66
|
-
en: `run "tripo topup" to add credits${gap !== undefined ? ` (about ${gap} more needed)` : ''}`,
|
|
67
|
-
});
|
|
77
|
+
return `run "tripo topup" to add credits${gap !== undefined ? ` (about ${gap} more needed)` : ''}`;
|
|
68
78
|
}
|
package/dist/core/output.js
CHANGED
|
@@ -66,7 +66,3 @@ export function emitResult(data, human) {
|
|
|
66
66
|
process.stdout.write((human ?? JSON.stringify(data, null, 2)) + '\n');
|
|
67
67
|
}
|
|
68
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
|
-
}
|