tripo-cli 0.2.1 → 0.3.1

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 CHANGED
@@ -101,8 +101,11 @@ tripo batch run assets.yaml --concurrency 2
101
101
  | `TRIPO_REGION` | `ov` or `cn` — normally unnecessary: `tripo login` probes both regions with your key and stores the right one automatically |
102
102
  | `TRIPO_API_BASE_URL` / `TRIPO_PLATFORM_BASE_URL` | endpoint overrides |
103
103
  | `TRIPO_HOME` | config/history directory (default `~/.tripo`) |
104
+ | `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | standard proxy variables, honored for all CLI traffic (plain Node fetch ignores them) |
104
105
  | `TRIPO_LLM_BASE_URL` / `TRIPO_LLM_API_KEY` / `TRIPO_LLM_MODEL` | optional OpenAI-compatible LLM for `tripo ai` conversation mode |
105
106
 
107
+ Setting `TRIPO_API_KEY` per shell — bash/zsh: `export TRIPO_API_KEY=tsk_...` · PowerShell: `$env:TRIPO_API_KEY = "tsk_..."` (persist for future sessions with `setx TRIPO_API_KEY "tsk_..."`) · CMD: `set TRIPO_API_KEY=tsk_...`
108
+
106
109
  ## Development
107
110
 
108
111
  ```bash
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { installProxyFromEnv } from './core/proxy.js';
2
3
  import { runCli } from './program.js';
3
4
  process.on('SIGINT', () => {
4
5
  process.stderr.write('\n');
5
6
  process.exit(130);
6
7
  });
8
+ await installProxyFromEnv();
7
9
  void runCli(process.argv);
@@ -35,7 +35,10 @@ export function registerDoctorCommand(program) {
35
35
  : 'missing — run "tripo login"',
36
36
  critical: true,
37
37
  });
38
- if (configFileExists()) {
38
+ // Skipped on Windows: Node fakes POSIX mode bits there (a writable file
39
+ // always reads 0666), so the comparison can never pass — the file is
40
+ // protected by the user profile's NTFS ACLs instead.
41
+ if (configFileExists() && process.platform !== 'win32') {
39
42
  try {
40
43
  const mode = statSync(join(tripoHome(), 'config.json')).mode & 0o777;
41
44
  checks.push({
@@ -67,7 +70,7 @@ export function registerDoctorCommand(program) {
67
70
  // simply live in the other region. Probe and hand over the exact fix.
68
71
  if (err instanceof CliError && err.exitCode === EXIT.AUTH && !process.env.TRIPO_API_BASE_URL) {
69
72
  const detected = await detectRegionForKey(key);
70
- if (detected && detected.region !== region) {
73
+ if (detected.ok && detected.region !== region) {
71
74
  detail += ` — key belongs to region "${detected.region}"; run: tripo config set region ${detected.region}`;
72
75
  }
73
76
  }
@@ -12,7 +12,7 @@ import * as p from '@clack/prompts';
12
12
  import { activeProfileName, DEFAULT_PROFILE, deleteProfile, describeKeySource, isValidProfileName, listProfiles, loadConfig, maskKey, REGION_DEFAULTS, resolveApiKey, resolvePlatformBaseUrl, resolveRegion, setActiveProfile, updateProfile, } from '../config/config.js';
13
13
  import { TripoClient } from '../core/client.js';
14
14
  import { DeviceAuthUnavailableError, requestDeviceCode, waitForDeviceApproval, } from '../core/device-auth.js';
15
- import { AuthError, CliError, UsageError } from '../core/errors.js';
15
+ import { AuthError, CliError, NetworkError, UsageError } from '../core/errors.js';
16
16
  import { detectRegionForKey } from '../core/region.js';
17
17
  import { emitResult, isInteractive, log, shouldOpen, success, warn } from '../core/output.js';
18
18
  import { t } from '../i18n.js';
@@ -230,6 +230,12 @@ async function runHeadlessLogin(region, profile, paste) {
230
230
  }
231
231
  catch (err) {
232
232
  if (err instanceof DeviceAuthUnavailableError) {
233
+ if (err.kind === 'unreachable') {
234
+ throw new CliError(t({
235
+ zh: `连不上授权服务(${err.message})— 请检查本机网络/防火墙,出网走 HTTP 代理时设置 HTTPS_PROXY;或改用 TRIPO_API_KEY / tripo login --key tsk_...`,
236
+ en: `cannot reach the authorization service (${err.message}) — check this machine's network/firewall (set HTTPS_PROXY when behind a proxy), or use TRIPO_API_KEY / "tripo login --key tsk_..."`,
237
+ }), EXIT.NETWORK);
238
+ }
233
239
  // No paste fallback without a TTY — fail with every remaining option spelled out.
234
240
  throw new CliError(t({
235
241
  zh: `浏览器授权服务不可用(${err.message});请设置 TRIPO_API_KEY,运行 tripo login --key tsk_...,或在交互终端里运行 tripo login`,
@@ -291,6 +297,14 @@ async function deviceFlowWithFallback(region, profile) {
291
297
  en: `device authorization backend error (${err.message}) — please report this; falling back to paste login`,
292
298
  }));
293
299
  }
300
+ else if (err.kind === 'unreachable') {
301
+ // The console API could not be reached at all — the same network
302
+ // problem will likely hit key validation next, so say it loudly now.
303
+ warn(t({
304
+ zh: `连不上授权服务(${err.message})— 疑似网络/代理问题(出网走 HTTP 代理时请设置 HTTPS_PROXY);已改用粘贴方式登录`,
305
+ en: `cannot reach the authorization service (${err.message}) — likely a network/proxy issue (set HTTPS_PROXY if this machine needs a proxy); falling back to paste login`,
306
+ }));
307
+ }
294
308
  else {
295
309
  log(t({ zh: '浏览器授权服务暂未开通,改用粘贴方式登录', en: 'device authorization not available yet — falling back to paste login' }));
296
310
  }
@@ -375,7 +389,10 @@ function safeTimeZone() {
375
389
  * email-first; cn: +86 SMS-first) live entirely on the website either way.
376
390
  */
377
391
  async function keyFromConsole(region, openBrowser) {
378
- const keysUrl = `${platformUrlFor(region)}/keys`;
392
+ // The console SPA nests every page under a locale segment (/:lang/keys);
393
+ // a bare /keys is swallowed by the ":lang" route and lands on the home
394
+ // page. Both regions' backends use /en in their canonical URLs.
395
+ const keysUrl = `${platformUrlFor(region)}/en/keys`;
379
396
  if (openBrowser && shouldOpen()) {
380
397
  openExternal(keysUrl);
381
398
  log(t({ zh: `已打开控制台 API Keys 页面(未弹出请手动访问):\n ${keysUrl}`, en: `opened the console API Keys page (visit manually if it didn't open):\n ${keysUrl}` }));
@@ -429,11 +446,8 @@ async function validateAndSave(key, selectedRegion, profile) {
429
446
  }
430
447
  }
431
448
  const detected = await detectRegionForKey(key);
432
- if (!detected) {
433
- throw new AuthError(t({
434
- zh: 'Key 验证失败:国内(openapi.tripo3d.com)和海外(openapi.tripo3d.ai)都不接受这把 Key — 无效或已被删除',
435
- en: 'key validation failed: rejected by both cn (openapi.tripo3d.com) and ov (openapi.tripo3d.ai) — invalid or deleted',
436
- }));
449
+ if (!detected.ok) {
450
+ throw regionDetectionFailure(detected.failures);
437
451
  }
438
452
  if (selectedRegion && selectedRegion !== detected.region) {
439
453
  warn(t({
@@ -444,14 +458,49 @@ async function validateAndSave(key, selectedRegion, profile) {
444
458
  finishSave(key, detected.region, detected.balance.balance, profile);
445
459
  return { region: detected.region, balance: detected.balance.balance };
446
460
  }
461
+ const REGION_HOSTS = { ov: 'openapi.tripo3d.ai', cn: 'openapi.tripo3d.com' };
462
+ /**
463
+ * Turn the per-region probe failures into an honest error. "Invalid or
464
+ * deleted" is only ever claimed when both regions *answered* and rejected
465
+ * the key; any unreachable side turns the verdict into a network problem —
466
+ * the classic case being a firewalled/proxy-only server where a perfectly
467
+ * good key used to be reported as dead.
468
+ */
469
+ function regionDetectionFailure(failures) {
470
+ if (failures.ov === 'rejected' && failures.cn === 'rejected') {
471
+ return new AuthError(t({
472
+ zh: 'Key 验证失败:国内(openapi.tripo3d.com)和海外(openapi.tripo3d.ai)都不接受这把 Key — 无效或已被删除',
473
+ en: 'key validation failed: rejected by both cn (openapi.tripo3d.com) and ov (openapi.tripo3d.ai) — invalid or deleted',
474
+ }));
475
+ }
476
+ const networkHint = t({
477
+ zh: '检查本机网络/防火墙;出网需要 HTTP 代理时,设置 HTTPS_PROXY 环境变量后重试;tripo doctor 可自检',
478
+ en: 'check this machine\'s network/firewall; if outbound traffic needs an HTTP proxy, set HTTPS_PROXY and retry; "tripo doctor" helps diagnose',
479
+ });
480
+ if (failures.ov === 'unreachable' && failures.cn === 'unreachable') {
481
+ return new NetworkError(t({
482
+ zh: '无法验证 Key:海外(openapi.tripo3d.ai)和国内(openapi.tripo3d.com)都连接失败 — 这是网络问题,不代表 Key 无效',
483
+ en: 'could not validate the key: both ov (openapi.tripo3d.ai) and cn (openapi.tripo3d.com) are unreachable — a network problem, not proof of a bad key',
484
+ }), networkHint);
485
+ }
486
+ const down = failures.ov === 'unreachable' ? 'ov' : 'cn';
487
+ const up = down === 'ov' ? 'cn' : 'ov';
488
+ return new NetworkError(t({
489
+ zh: `Key 验证失败:${up === 'cn' ? '国内' : '海外'}站(${REGION_HOSTS[up]})拒绝了这把 Key,而${down === 'cn' ? '国内' : '海外'}站(${REGION_HOSTS[down]})无法连接 — 如果 Key 属于${down === 'cn' ? '国内' : '海外'}站,请先解决网络问题`,
490
+ en: `key validation failed: ${up} (${REGION_HOSTS[up]}) rejected the key and ${down} (${REGION_HOSTS[down]}) is unreachable — if the key belongs to ${down}, fix the network first`,
491
+ }), networkHint);
492
+ }
447
493
  function finishSave(key, region, balance, profile) {
448
494
  // Saving activates the profile: "the account I just logged into is the
449
495
  // account I am using" — no second command needed for the common case.
450
496
  updateProfile(profile, { api_key: key, region });
451
497
  const where = profile === DEFAULT_PROFILE ? '~/.tripo/config.json' : `~/.tripo/config.json · profile "${profile}"`;
498
+ // "mode 0600" is only true where POSIX modes exist; on Windows the file
499
+ // relies on the user profile's NTFS ACLs, so the claim would be wrong.
500
+ const mode = process.platform === 'win32' ? '' : t({ zh: ',权限 0600', en: ', mode 0600' });
452
501
  success(t({
453
- zh: `Key 验证通过(region=${region}),当前余额 ${balance} 积分(已保存到 ${where},权限 0600)`,
454
- en: `key verified (region=${region}); balance ${balance} credits (saved to ${where}, mode 0600)`,
502
+ zh: `Key 验证通过(region=${region}),当前余额 ${balance} 积分(已保存到 ${where}${mode})`,
503
+ en: `key verified (region=${region}); balance ${balance} credits (saved to ${where}${mode})`,
455
504
  }));
456
505
  if (Number(balance) === 0) {
457
506
  warn(t({
@@ -19,11 +19,14 @@ export const DEVICE_CODE_DENIED = 4292;
19
19
  const REQUEST_TIMEOUT_MS = 15_000;
20
20
  /**
21
21
  * Thrown when the device-auth endpoints are missing or unusable — callers
22
- * fall back to paste. `kind` separates the two very different situations:
22
+ * fall back to paste. `kind` separates three very different situations:
23
23
  * 'not_deployed' (404 — feature flag off, a normal rollout state, fall back
24
- * quietly) vs 'backend_error' (5xx — the feature is deployed but broken;
25
- * fall back too, but surface the backend's message + request_id loudly so
26
- * the failure is reportable instead of silently masked).
24
+ * quietly), 'backend_error' (5xx — the feature is deployed but broken; fall
25
+ * back too, but surface the backend's message + request_id loudly so the
26
+ * failure is reportable instead of silently masked), and 'unreachable'
27
+ * (fetch itself failed — firewall/DNS/proxy; the same network problem will
28
+ * hit key validation next, so callers must warn about the network, not
29
+ * pretend the feature simply is not rolled out yet).
27
30
  */
28
31
  export class DeviceAuthUnavailableError extends Error {
29
32
  kind;
@@ -149,7 +152,7 @@ async function post(url, payload) {
149
152
  });
150
153
  }
151
154
  catch {
152
- throw new DeviceAuthUnavailableError('not_deployed', `cannot reach ${url}`);
155
+ throw new DeviceAuthUnavailableError('unreachable', `cannot reach ${url}`);
153
156
  }
154
157
  if (response.status === 404 || response.status === 405) {
155
158
  throw new DeviceAuthUnavailableError('not_deployed', 'device auth endpoints not deployed');
@@ -39,8 +39,8 @@ export class ApiError extends CliError {
39
39
  }
40
40
  }
41
41
  export class NetworkError extends CliError {
42
- constructor(message) {
43
- super(message, EXIT.NETWORK);
42
+ constructor(message, suggestion) {
43
+ super(message, EXIT.NETWORK, suggestion);
44
44
  }
45
45
  }
46
46
  export class TaskFailedError extends CliError {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Node's built-in fetch (undici) ignores the HTTP_PROXY / HTTPS_PROXY /
3
+ * NO_PROXY environment variables, so on proxy-only networks (a common
4
+ * server setup) every CLI request fails with a network error while curl on
5
+ * the same machine works fine. When a proxy is configured in the
6
+ * environment, swap global fetch for undici's, driven by an
7
+ * EnvHttpProxyAgent — it routes through HTTP(S)_PROXY and honors NO_PROXY
8
+ * exclusions. Without proxy env vars this is a no-op, so the zero-config
9
+ * path stays on the built-in fetch.
10
+ */
11
+ export async function installProxyFromEnv() {
12
+ const { env } = process;
13
+ if (!(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy))
14
+ return false;
15
+ // Imported lazily so undici only loads when a proxy is actually configured.
16
+ const { EnvHttpProxyAgent, fetch: undiciFetch } = await import('undici');
17
+ // The constructor emits an "EnvHttpProxyAgent is experimental" warning on
18
+ // stderr — noise on every command for proxy users; swallow just that.
19
+ const emitWarning = process.emitWarning;
20
+ process.emitWarning = () => { };
21
+ let dispatcher;
22
+ try {
23
+ dispatcher = new EnvHttpProxyAgent();
24
+ }
25
+ finally {
26
+ process.emitWarning = emitWarning;
27
+ }
28
+ globalThis.fetch = ((input, init) => undiciFetch(input, { ...init, dispatcher }));
29
+ return true;
30
+ }
@@ -2,10 +2,15 @@
2
2
  * Region auto-detection: a Tripo API key is valid in exactly one region
3
3
  * (ov = openapi.tripo3d.ai, cn = openapi.tripo3d.com), so instead of asking
4
4
  * the user "which region?" we probe both with the key and let the facts
5
- * decide. First successful probe wins; both failing means the key is dead.
5
+ * decide. First successful probe wins. When neither succeeds, the failure
6
+ * kind is reported per region: only an explicit auth rejection from a
7
+ * server that answered proves anything about the key — an unreachable host
8
+ * (firewall, DNS, missing proxy) must never be blamed on the key.
6
9
  */
7
10
  import { REGION_DEFAULTS } from '../config/config.js';
8
11
  import { TripoClient } from './client.js';
12
+ import { CliError } from './errors.js';
13
+ import { EXIT } from '../knowledge/error-catalog.js';
9
14
  const PROBE_TIMEOUT_MS = 12_000;
10
15
  export function defaultProbeBases() {
11
16
  return { ov: REGION_DEFAULTS.ov.api, cn: REGION_DEFAULTS.cn.api };
@@ -14,7 +19,6 @@ export function defaultProbeBases() {
14
19
  * Probe both regions concurrently; resolve as soon as one accepts the key
15
20
  * (the other side may be slow/unreachable from this network — e.g. the
16
21
  * overseas host from mainland China — and must not delay the answer).
17
- * Returns undefined when both regions reject or are unreachable.
18
22
  */
19
23
  export async function detectRegionForKey(key, bases = defaultProbeBases()) {
20
24
  const probe = async (region) => {
@@ -27,18 +31,25 @@ export async function detectRegionForKey(key, bases = defaultProbeBases()) {
27
31
  });
28
32
  return { region, balance: await client.getBalance() };
29
33
  }
30
- catch {
31
- return undefined;
34
+ catch (err) {
35
+ // Only an authentication error is a verdict on the key; everything
36
+ // else (NetworkError, 5xx, malformed response) means "no answer".
37
+ return err instanceof CliError && err.exitCode === EXIT.AUTH ? 'rejected' : 'unreachable';
32
38
  }
33
39
  };
34
40
  return new Promise((resolve) => {
41
+ const failures = {};
35
42
  let pending = 2;
36
43
  for (const region of ['ov', 'cn']) {
37
- void probe(region).then((hit) => {
38
- if (hit)
39
- resolve(hit);
40
- else if (--pending === 0)
41
- resolve(undefined);
44
+ void probe(region).then((outcome) => {
45
+ if (typeof outcome === 'string') {
46
+ failures[region] = outcome;
47
+ if (--pending === 0)
48
+ resolve({ ok: false, failures });
49
+ }
50
+ else {
51
+ resolve({ ok: true, ...outcome });
52
+ }
42
53
  });
43
54
  }
44
55
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tripo-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Tripo 3D intelligent CLI — turn a prompt or an image into engine-ready 3D assets from your terminal (Tripo V3 API)",
5
5
  "keywords": [
6
6
  "tripo",
@@ -48,6 +48,7 @@
48
48
  "@clack/prompts": "^1.7.0",
49
49
  "commander": "^15.0.0",
50
50
  "picocolors": "^1.1.1",
51
+ "undici": "^6.28.0",
51
52
  "yaml": "^2.9.0"
52
53
  },
53
54
  "devDependencies": {
package/skill/SKILL.md CHANGED
@@ -8,6 +8,7 @@ One command turns a text prompt or an image into local files: a 3D model (glb/fb
8
8
  ```bash
9
9
  npm install -g tripo-cli # or: npx tripo-cli ...
10
10
  export TRIPO_API_KEY=tsk_... # highest-priority auth; no interaction needed
11
+ # (PowerShell: $env:TRIPO_API_KEY = "tsk_...")
11
12
  tripo doctor # verify key, network, balance
12
13
  ```
13
14
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  ```bash
6
6
  export TRIPO_API_KEY=tsk_... # zero interaction when a key is already provisioned
7
+ # (PowerShell: $env:TRIPO_API_KEY = "tsk_...")
7
8
  tripo login # humans in a terminal: interactive browser flow
8
9
  tripo login --region ov|cn # agents/CI without a key: headless device flow — prints a
9
10
  # verification URL + one-time code on stderr, waits for the