@topolo/cli 0.1.2 → 0.1.3

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 (2) hide show
  1. package/dist/index.js +363 -7
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -8,7 +8,366 @@ import { spawn } from "child_process";
8
8
  import { platform as platform2 } from "os";
9
9
  import { createInterface } from "readline/promises";
10
10
  import { stdin as input } from "process";
11
- import { TopoloOAuth, TopoloAuthError } from "@topolo/sdk";
11
+
12
+ // ../packages/topolo-sdk/dist/index.js
13
+ function applyAuthHeaders(headers, credential) {
14
+ if (credential.kind === "api_key") {
15
+ headers.set("X-Api-Key", credential.apiKey);
16
+ return;
17
+ }
18
+ headers.set("Authorization", `Bearer ${credential.accessToken}`);
19
+ }
20
+ function applyAuditHeaders(headers, agent, requestId) {
21
+ headers.set("X-Topolo-Client", `${agent.clientName}/${agent.clientVersion}`);
22
+ if (agent.agentName) headers.set("X-Topolo-Agent", agent.agentName);
23
+ headers.set("X-Topolo-Request-Id", requestId);
24
+ }
25
+ function generateRequestId() {
26
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
27
+ return crypto.randomUUID();
28
+ }
29
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
30
+ }
31
+ var TopoloSdkError = class extends Error {
32
+ code;
33
+ constructor(code, message) {
34
+ super(message);
35
+ this.name = "TopoloSdkError";
36
+ this.code = code;
37
+ }
38
+ };
39
+ var TopoloAuthError = class extends TopoloSdkError {
40
+ constructor(message, code = "auth_error") {
41
+ super(code, message);
42
+ this.name = "TopoloAuthError";
43
+ }
44
+ };
45
+ var TopoloPermissionError = class extends TopoloSdkError {
46
+ required;
47
+ constructor(message, required) {
48
+ super("permission_denied", message);
49
+ this.name = "TopoloPermissionError";
50
+ this.required = required;
51
+ }
52
+ };
53
+ var TopoloHttpError = class extends TopoloSdkError {
54
+ status;
55
+ body;
56
+ service;
57
+ path;
58
+ constructor(service, path, status, body, message) {
59
+ super("http_error", message ?? `HTTP ${status} from ${service}${path}`);
60
+ this.name = "TopoloHttpError";
61
+ this.status = status;
62
+ this.body = body;
63
+ this.service = service;
64
+ this.path = path;
65
+ }
66
+ };
67
+ var DEFAULT_SERVICE_URLS = {
68
+ auth: "https://auth.topolo.app",
69
+ crm: "https://topolo-crm-worker.topolo.workers.dev"
70
+ };
71
+ var PLATFORM_SERVICE_IDS = {
72
+ crm: "srv_iCwM4jGXcwlj"
73
+ };
74
+ function resolveServiceUrl(service, overrides) {
75
+ const override = overrides?.[service];
76
+ if (override) return override;
77
+ const envKey = `TOPOLO_SERVICE_URL_${service.toUpperCase()}`;
78
+ const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
79
+ if (envValue) return envValue;
80
+ return DEFAULT_SERVICE_URLS[service];
81
+ }
82
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
83
+ var TopoloClient = class {
84
+ credential;
85
+ agent;
86
+ serviceUrls;
87
+ requireConfirmForWrites;
88
+ timeoutMs;
89
+ fetchImpl;
90
+ constructor(options) {
91
+ if (!options.credential) throw new TopoloAuthError("credential is required");
92
+ if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
93
+ this.credential = options.credential;
94
+ this.agent = options.agent;
95
+ this.serviceUrls = options.serviceUrls;
96
+ this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
97
+ this.timeoutMs = options.timeoutMs ?? 3e4;
98
+ this.fetchImpl = options.fetch ?? fetch;
99
+ }
100
+ /**
101
+ * Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
102
+ * for anything a caller would reach for; this stays exported for escape-hatch
103
+ * use and for the generic `topolo api` / MCP passthrough tool.
104
+ */
105
+ async request(opts) {
106
+ const method = opts.method ?? "GET";
107
+ if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
108
+ throw new TopoloAuthError(
109
+ `Mutating ${method} requests require { confirm: true }. This guardrail prevents agents from issuing writes without an explicit human-in-the-loop acknowledgement.`
110
+ );
111
+ }
112
+ const baseUrl = resolveServiceUrl(opts.service, this.serviceUrls);
113
+ const url = new URL(opts.path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
114
+ if (opts.query) {
115
+ for (const [k, v] of Object.entries(opts.query)) {
116
+ if (v !== void 0) url.searchParams.set(k, String(v));
117
+ }
118
+ }
119
+ const headers = new Headers();
120
+ headers.set("Accept", "application/json");
121
+ if (opts.body !== void 0) headers.set("Content-Type", "application/json");
122
+ const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
123
+ if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
124
+ applyAuthHeaders(headers, this.credential);
125
+ applyAuditHeaders(headers, this.agent, generateRequestId());
126
+ if (opts.headers) {
127
+ for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
128
+ }
129
+ const controller = new AbortController();
130
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
131
+ const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
132
+ let res;
133
+ try {
134
+ res = await this.fetchImpl(url.toString(), {
135
+ method,
136
+ headers,
137
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
138
+ signal
139
+ });
140
+ } finally {
141
+ clearTimeout(timeoutId);
142
+ }
143
+ const contentType = res.headers.get("Content-Type") ?? "";
144
+ const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
145
+ if (!res.ok) {
146
+ if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
147
+ if (res.status === 403) {
148
+ throw new TopoloPermissionError(
149
+ describeError(parsed, "Permission denied"),
150
+ extractRequired(parsed)
151
+ );
152
+ }
153
+ throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
154
+ }
155
+ return parsed;
156
+ }
157
+ /**
158
+ * Introspect the current credential. Returns the caller's resolved identity,
159
+ * organization, and permission set. Used by CLI `whoami` and by MCP to gate
160
+ * advertised tools by scope.
161
+ *
162
+ * Both JWT access tokens and platform API keys are resolved via the unified
163
+ * `GET /api/auth/me` endpoint on TopoloAuth, which does not require service
164
+ * credentials. The caller's possession of the credential secret is proof.
165
+ */
166
+ async introspect() {
167
+ const res = await this.request({
168
+ service: "auth",
169
+ method: "GET",
170
+ path: "/api/auth/me"
171
+ });
172
+ const payload = res.data ?? res;
173
+ const user = payload.user;
174
+ const organization = payload.organization;
175
+ const kind = payload.credentialType === "api_key" ? "api_key" : "access_token";
176
+ return {
177
+ kind,
178
+ user: {
179
+ id: user.id,
180
+ email: user.email,
181
+ name: user.name ?? null,
182
+ role: user.role ?? (kind === "api_key" ? "service" : "member"),
183
+ permissions: payload.permissions ?? user.permissions ?? []
184
+ },
185
+ organization: organization ? {
186
+ id: organization.id,
187
+ slug: organization.slug,
188
+ name: organization.name ?? organization.slug
189
+ } : user.orgId && user.orgSlug ? { id: user.orgId, slug: user.orgSlug, name: user.orgSlug } : null
190
+ };
191
+ }
192
+ };
193
+ function describeError(body, fallback) {
194
+ if (body && typeof body === "object") {
195
+ const maybe = body;
196
+ const resolved = pickString(maybe.message) ?? pickString(maybe.error);
197
+ if (resolved) return resolved;
198
+ }
199
+ return fallback;
200
+ }
201
+ function pickString(value) {
202
+ if (typeof value === "string" && value.trim().length > 0) return value;
203
+ if (value && typeof value === "object") {
204
+ const nested = value;
205
+ if (typeof nested.message === "string" && nested.message.trim().length > 0) return nested.message;
206
+ if (typeof nested.description === "string" && nested.description.trim().length > 0)
207
+ return nested.description;
208
+ }
209
+ return void 0;
210
+ }
211
+ function extractRequired(body) {
212
+ if (body && typeof body === "object") {
213
+ const maybe = body;
214
+ if (Array.isArray(maybe.required)) return maybe.required.map(String);
215
+ }
216
+ return [];
217
+ }
218
+ function mergeSignals(a, b) {
219
+ if (a.aborted) return a;
220
+ if (b.aborted) return b;
221
+ const controller = new AbortController();
222
+ const onAbort = () => controller.abort();
223
+ a.addEventListener("abort", onAbort, { once: true });
224
+ b.addEventListener("abort", onAbort, { once: true });
225
+ return controller.signal;
226
+ }
227
+ var CrmModule = class {
228
+ constructor(client) {
229
+ this.client = client;
230
+ }
231
+ client;
232
+ async listContacts(options = {}) {
233
+ const res = await this.client.request({
234
+ service: "crm",
235
+ path: "/api/contacts",
236
+ query: {
237
+ q: options.q,
238
+ page: options.page,
239
+ pageSize: options.pageSize
240
+ }
241
+ });
242
+ const rows = Array.isArray(res?.contacts) ? res.contacts : Array.isArray(res?.data) ? res.data : [];
243
+ return {
244
+ contacts: rows.map(toSummary),
245
+ total: res?.total ?? res?.pagination?.total ?? rows.length,
246
+ page: res?.page ?? res?.pagination?.page ?? options.page ?? 1,
247
+ pageSize: res?.pageSize ?? res?.pagination?.pageSize ?? options.pageSize ?? rows.length
248
+ };
249
+ }
250
+ async getContact(contactId) {
251
+ if (!contactId) throw new Error("contactId is required");
252
+ const res = await this.client.request({
253
+ service: "crm",
254
+ path: `/api/contacts/${encodeURIComponent(contactId)}`
255
+ });
256
+ const row = res.data ?? res;
257
+ return row ? toSummary(row) : null;
258
+ }
259
+ };
260
+ function toSummary(row) {
261
+ return {
262
+ id: row.id,
263
+ firstName: row.first ?? null,
264
+ lastName: row.last ?? null,
265
+ email: row.email ?? null,
266
+ phone: row.phone ?? null,
267
+ company: row.company ?? null,
268
+ leadStatus: row.lead_status ?? null,
269
+ lifecycleStage: row.lifecycle_stage ?? null,
270
+ updatedAt: row.updated_at ?? null
271
+ };
272
+ }
273
+ var IdentityModule = class {
274
+ constructor(client) {
275
+ this.client = client;
276
+ }
277
+ client;
278
+ /** Returns the resolved user, organization, and permission set for the
279
+ * current credential. Equivalent to `TopoloClient.introspect()` — exposed
280
+ * on this module for symmetry with the other domain modules. */
281
+ whoami() {
282
+ return this.client.introspect();
283
+ }
284
+ };
285
+ var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
286
+ var TopoloOAuth = class {
287
+ baseUrl;
288
+ fetchImpl;
289
+ constructor(options = {}) {
290
+ this.baseUrl = resolveServiceUrl("auth", options.serviceUrls);
291
+ this.fetchImpl = options.fetch ?? fetch;
292
+ }
293
+ /**
294
+ * RFC 8628 device authorization grant — starts the flow by asking the server
295
+ * for a device_code + user_code. The caller prints the user_code +
296
+ * verification_uri and then polls `pollDeviceToken` until approval.
297
+ */
298
+ async requestDeviceCode(params) {
299
+ const body = new URLSearchParams();
300
+ body.set("client_id", params.clientId);
301
+ if (params.scope) {
302
+ body.set("scope", Array.isArray(params.scope) ? params.scope.join(" ") : params.scope);
303
+ }
304
+ return this.#postForm("/api/developer-oauth/device_authorization", body);
305
+ }
306
+ /**
307
+ * Polls the token endpoint once. Returns the token pair on success, or
308
+ * throws a `TopoloAuthError` whose `code` is one of the RFC 8628 poll
309
+ * states: `authorization_pending`, `slow_down`, `access_denied`,
310
+ * `expired_token`. Callers should back off on `slow_down`, wait at least
311
+ * one `interval` on `authorization_pending`, and give up on the rest.
312
+ */
313
+ async pollDeviceToken(params) {
314
+ const body = new URLSearchParams();
315
+ body.set("grant_type", DEVICE_GRANT);
316
+ body.set("client_id", params.clientId);
317
+ body.set("device_code", params.deviceCode);
318
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
319
+ return this.#postForm("/api/developer-oauth/token", body);
320
+ }
321
+ /** Exchange an authorization code (with PKCE) for tokens. */
322
+ async exchangeAuthorizationCode(params) {
323
+ const body = new URLSearchParams();
324
+ body.set("grant_type", "authorization_code");
325
+ body.set("client_id", params.clientId);
326
+ body.set("code", params.code);
327
+ body.set("redirect_uri", params.redirectUri);
328
+ if (params.codeVerifier) body.set("code_verifier", params.codeVerifier);
329
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
330
+ return this.#postForm("/api/developer-oauth/token", body);
331
+ }
332
+ /** Rotate a refresh token for a new token pair. */
333
+ async refreshToken(params) {
334
+ const body = new URLSearchParams();
335
+ body.set("grant_type", "refresh_token");
336
+ body.set("client_id", params.clientId);
337
+ body.set("refresh_token", params.refreshToken);
338
+ if (params.clientSecret) body.set("client_secret", params.clientSecret);
339
+ return this.#postForm("/api/developer-oauth/token", body);
340
+ }
341
+ async #postForm(path, body) {
342
+ const url = new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`);
343
+ const res = await this.fetchImpl(url.toString(), {
344
+ method: "POST",
345
+ headers: {
346
+ "Content-Type": "application/x-www-form-urlencoded",
347
+ Accept: "application/json"
348
+ },
349
+ body: body.toString()
350
+ });
351
+ const contentType = res.headers.get("Content-Type") ?? "";
352
+ const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
353
+ if (!res.ok) {
354
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
355
+ const err = parsed;
356
+ throw new TopoloAuthError(err.error_description || err.error, err.error);
357
+ }
358
+ throw new TopoloHttpError("auth", path, res.status, parsed);
359
+ }
360
+ return parsed;
361
+ }
362
+ };
363
+ function createTopolo(options) {
364
+ const client = new TopoloClient(options);
365
+ return {
366
+ client,
367
+ identity: new IdentityModule(client),
368
+ crm: new CrmModule(client)
369
+ };
370
+ }
12
371
 
13
372
  // src/config.ts
14
373
  import { mkdir, readFile, writeFile, chmod } from "fs/promises";
@@ -304,11 +663,8 @@ function sleep(ms) {
304
663
  return new Promise((resolve) => setTimeout(resolve, ms));
305
664
  }
306
665
 
307
- // src/client.ts
308
- import { createTopolo } from "@topolo/sdk";
309
-
310
666
  // src/version.ts
311
- var CLI_VERSION = "0.1.2";
667
+ var CLI_VERSION = "0.1.3";
312
668
 
313
669
  // src/client.ts
314
670
  async function buildClient(options) {
@@ -423,7 +779,7 @@ function formatContact(c) {
423
779
 
424
780
  // src/commands/api.ts
425
781
  var KNOWN_SERVICES = ["auth", "crm"];
426
- var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
782
+ var WRITE_METHODS2 = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
427
783
  function registerApiCommand(program2) {
428
784
  program2.command("api <service> <method> <path>").description("Generic passthrough to a Topolo service (escape hatch for untyped endpoints)").option("-d, --data <json>", "JSON body (for POST/PUT/PATCH)").option("--confirm", "Acknowledge a mutating request").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(
429
785
  async (service, method, path, opts) => {
@@ -438,7 +794,7 @@ function registerApiCommand(program2) {
438
794
  `Unknown service "${service}". Known services: ${KNOWN_SERVICES.join(", ")}`
439
795
  );
440
796
  }
441
- if (WRITE_METHODS.has(upperMethod) && !opts.confirm) {
797
+ if (WRITE_METHODS2.has(upperMethod) && !opts.confirm) {
442
798
  throw new Error(`Mutating ${upperMethod} requires --confirm.`);
443
799
  }
444
800
  let body = void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": {
@@ -16,10 +16,10 @@
16
16
  "start": "node dist/index.js"
17
17
  },
18
18
  "dependencies": {
19
- "@topolo/sdk": "file:../packages/topolo-sdk",
20
19
  "commander": "^12.1.0"
21
20
  },
22
21
  "devDependencies": {
22
+ "@topolo/sdk": "file:../packages/topolo-sdk",
23
23
  "@types/node": "^20.12.0",
24
24
  "tsup": "^8.0.0",
25
25
  "typescript": "^5.4.0"