@topolo/cli 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +25 -6
  2. package/dist/index.js +113 -6
  3. package/package.json +7 -4
package/README.md CHANGED
@@ -137,10 +137,29 @@ Both docs cover the same CLI surface: credential handling, the JSON invocation
137
137
  contract, the write-safety `--confirm` guardrail, and the cross-org isolation
138
138
  rules. Keep them in sync when adding new commands.
139
139
 
140
- ## Phase 2 (planned)
140
+ ## Debug output
141
141
 
142
- - `topolo auth login` replaces the paste-API-key flow with an OAuth 2.1 device
143
- authorization grant issued by TopoloAuth.
144
- - Refresh-token storage + automatic renewal.
145
- - OAuth client registration lives in TopoloDevelopers — consent UI will follow
146
- the existing `@topolo/design-tokens` + `@topolo/ui-kit` patterns.
142
+ Set `TOPOLO_DEBUG=1` to trace every SDK request to stderr (stdout stays clean
143
+ so `--json` piping still works):
144
+
145
+ ```bash
146
+ TOPOLO_DEBUG=1 topolo whoami
147
+ # [topolo] → GET auth /api/auth/me (req=8f3...)
148
+ # [topolo] ← 200 GET auth /api/auth/me (142ms, req=8f3...)
149
+ ```
150
+
151
+ Request IDs match the `X-Topolo-Request-Id` header, so you can correlate a
152
+ failing CLI call with the backend access log. Accepted truthy values:
153
+ anything except `0` or `false`.
154
+
155
+ ## Token auto-refresh
156
+
157
+ If the stored access token is within 60 s of expiry, the CLI transparently
158
+ exchanges the stored refresh token for a new pair before making the request
159
+ and rewrites the config — no user action required. The OAuth `client_id` used
160
+ at login is persisted alongside the credential so refresh can re-authenticate.
161
+
162
+ If refresh fails (network, rotated/revoked refresh token), the CLI proceeds
163
+ with the stale credential and the request returns `auth_error` — run
164
+ `topolo auth login` to re-authenticate. The stored config is not cleared on
165
+ transient refresh failures.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { platform as platform2 } from "os";
9
9
  import { createInterface } from "readline/promises";
10
10
  import { stdin as input } from "process";
11
11
 
12
- // ../packages/topolo-sdk/dist/index.js
12
+ // node_modules/@topolo/sdk/dist/index.js
13
13
  function applyAuthHeaders(headers, credential) {
14
14
  if (credential.kind === "api_key") {
15
15
  headers.set("X-Api-Key", credential.apiKey);
@@ -87,6 +87,7 @@ var TopoloClient = class {
87
87
  requireConfirmForWrites;
88
88
  timeoutMs;
89
89
  fetchImpl;
90
+ debug;
90
91
  constructor(options) {
91
92
  if (!options.credential) throw new TopoloAuthError("credential is required");
92
93
  if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
@@ -96,6 +97,14 @@ var TopoloClient = class {
96
97
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
97
98
  this.timeoutMs = options.timeoutMs ?? 3e4;
98
99
  this.fetchImpl = options.fetch ?? fetch;
100
+ this.debug = options.debug;
101
+ }
102
+ emit(event) {
103
+ if (!this.debug) return;
104
+ try {
105
+ this.debug(event);
106
+ } catch {
107
+ }
99
108
  }
100
109
  /**
101
110
  * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
@@ -122,27 +131,56 @@ var TopoloClient = class {
122
131
  const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
123
132
  if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
124
133
  applyAuthHeaders(headers, this.credential);
125
- applyAuditHeaders(headers, this.agent, generateRequestId());
134
+ const requestId = generateRequestId();
135
+ applyAuditHeaders(headers, this.agent, requestId);
126
136
  if (opts.headers) {
127
137
  for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
128
138
  }
129
139
  const controller = new AbortController();
130
140
  const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
131
141
  const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
142
+ const urlStr = url.toString();
143
+ const startedAt = Date.now();
144
+ this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
132
145
  let res;
133
146
  try {
134
- res = await this.fetchImpl(url.toString(), {
147
+ res = await this.fetchImpl(urlStr, {
135
148
  method,
136
149
  headers,
137
150
  body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
138
151
  signal
139
152
  });
153
+ } catch (err) {
154
+ this.emit({
155
+ phase: "error",
156
+ method,
157
+ service: opts.service,
158
+ path: opts.path,
159
+ url: urlStr,
160
+ requestId,
161
+ durationMs: Date.now() - startedAt,
162
+ error: err instanceof Error ? err.message : String(err)
163
+ });
164
+ throw err;
140
165
  } finally {
141
166
  clearTimeout(timeoutId);
142
167
  }
143
168
  const contentType = res.headers.get("Content-Type") ?? "";
144
169
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
145
170
  if (!res.ok) {
171
+ const durationMs = Date.now() - startedAt;
172
+ const message = describeError(parsed, `HTTP ${res.status}`);
173
+ this.emit({
174
+ phase: "error",
175
+ method,
176
+ service: opts.service,
177
+ path: opts.path,
178
+ url: urlStr,
179
+ requestId,
180
+ durationMs,
181
+ error: message,
182
+ status: res.status
183
+ });
146
184
  if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
147
185
  if (res.status === 403) {
148
186
  throw new TopoloPermissionError(
@@ -152,6 +190,16 @@ var TopoloClient = class {
152
190
  }
153
191
  throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
154
192
  }
193
+ this.emit({
194
+ phase: "response",
195
+ method,
196
+ service: opts.service,
197
+ path: opts.path,
198
+ url: urlStr,
199
+ requestId,
200
+ status: res.status,
201
+ durationMs: Date.now() - startedAt
202
+ });
155
203
  return parsed;
156
204
  }
157
205
  /**
@@ -409,6 +457,37 @@ async function resolveCredential() {
409
457
  const config = await readConfig();
410
458
  return config.credential ?? null;
411
459
  }
460
+ var REFRESH_SKEW_SECONDS = 60;
461
+ async function refreshCredentialIfNeeded() {
462
+ if (process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
463
+ return resolveCredential();
464
+ }
465
+ const config = await readConfig();
466
+ const cred = config.credential;
467
+ if (!cred || cred.kind !== "access_token") return cred ?? null;
468
+ if (!cred.refreshToken || !cred.expiresAt || !config.oauthClientId) return cred;
469
+ const now = Math.floor(Date.now() / 1e3);
470
+ if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
471
+ try {
472
+ const oauth = new TopoloOAuth({
473
+ serviceUrls: resolveServiceUrls(config.serviceUrls)
474
+ });
475
+ const tokens = await oauth.refreshToken({
476
+ clientId: config.oauthClientId,
477
+ refreshToken: cred.refreshToken
478
+ });
479
+ const refreshed = {
480
+ kind: "access_token",
481
+ accessToken: tokens.access_token,
482
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: cred.refreshToken },
483
+ expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
484
+ };
485
+ await writeConfig({ ...config, credential: refreshed });
486
+ return refreshed;
487
+ } catch {
488
+ return cred;
489
+ }
490
+ }
412
491
  function resolveAgentName(configAgentName) {
413
492
  return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
414
493
  }
@@ -585,6 +664,7 @@ async function runDeviceFlow(options) {
585
664
  ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
586
665
  expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
587
666
  };
667
+ updated.oauthClientId = options.clientId;
588
668
  await writeConfig(updated);
589
669
  emit(
590
670
  {
@@ -664,11 +744,11 @@ function sleep(ms) {
664
744
  }
665
745
 
666
746
  // src/version.ts
667
- var CLI_VERSION = "0.1.3";
747
+ var CLI_VERSION = "0.1.4";
668
748
 
669
749
  // src/client.ts
670
750
  async function buildClient(options) {
671
- const credential = await resolveCredential();
751
+ const credential = await refreshCredentialIfNeeded();
672
752
  if (!credential) return null;
673
753
  const config = await readConfig();
674
754
  const serviceUrls = resolveServiceUrls(config.serviceUrls);
@@ -680,9 +760,36 @@ async function buildClient(options) {
680
760
  agentName: resolveAgentName(config.agentName)
681
761
  },
682
762
  serviceUrls,
683
- requireConfirmForWrites: options?.requireConfirmForWrites
763
+ requireConfirmForWrites: options?.requireConfirmForWrites,
764
+ ...isDebugEnabled() ? { debug: stderrDebugLogger } : {}
684
765
  });
685
766
  }
767
+ function isDebugEnabled() {
768
+ const v = process.env.TOPOLO_DEBUG;
769
+ if (!v) return false;
770
+ return v !== "0" && v.toLowerCase() !== "false";
771
+ }
772
+ function stderrDebugLogger(event) {
773
+ if (event.phase === "request") {
774
+ process.stderr.write(
775
+ `[topolo] \u2192 ${event.method} ${event.service} ${event.path} (req=${event.requestId})
776
+ `
777
+ );
778
+ return;
779
+ }
780
+ if (event.phase === "response") {
781
+ process.stderr.write(
782
+ `[topolo] \u2190 ${event.status} ${event.method} ${event.service} ${event.path} (${event.durationMs}ms, req=${event.requestId})
783
+ `
784
+ );
785
+ return;
786
+ }
787
+ const statusPart = event.status ? ` ${event.status}` : "";
788
+ process.stderr.write(
789
+ `[topolo] \u2717${statusPart} ${event.method} ${event.service} ${event.path} (${event.durationMs}ms, req=${event.requestId}): ${event.error}
790
+ `
791
+ );
792
+ }
686
793
 
687
794
  // src/commands/whoami.ts
688
795
  function registerWhoamiCommand(program2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Command-line interface for the Topolo platform. Designed for humans and for agents (Claude, Codex, etc.) that prefer shell invocation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,16 +13,19 @@
13
13
  "build": "tsup",
14
14
  "dev": "tsup --watch",
15
15
  "typecheck": "tsc --noEmit",
16
- "start": "node dist/index.js"
16
+ "start": "node dist/index.js",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
17
19
  },
18
20
  "dependencies": {
19
21
  "commander": "^12.1.0"
20
22
  },
21
23
  "devDependencies": {
22
- "@topolo/sdk": "file:../packages/topolo-sdk",
24
+ "@topolo/sdk": "latest",
23
25
  "@types/node": "^20.12.0",
24
26
  "tsup": "^8.0.0",
25
- "typescript": "^5.4.0"
27
+ "typescript": "^5.4.0",
28
+ "vitest": "^2.1.0"
26
29
  },
27
30
  "engines": {
28
31
  "node": ">=20"