@topolo/cli 0.1.34 → 0.2.2

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/dist/index.js CHANGED
@@ -50,6 +50,12 @@ var TopoloPermissionError = class extends TopoloSdkError {
50
50
  this.required = required;
51
51
  }
52
52
  };
53
+ var TopoloConfirmationRequiredError = class extends TopoloSdkError {
54
+ constructor(message) {
55
+ super("confirmation_required", message);
56
+ this.name = "TopoloConfirmationRequiredError";
57
+ }
58
+ };
53
59
  var TopoloHttpError = class extends TopoloSdkError {
54
60
  status;
55
61
  body;
@@ -1112,6 +1118,7 @@ var APPLICATIONS = {
1112
1118
  "topolo.io"
1113
1119
  ],
1114
1120
  "routes": [
1121
+ "one.topolo.io",
1115
1122
  "topolo.io",
1116
1123
  "www.topolo.io"
1117
1124
  ],
@@ -2065,7 +2072,6 @@ var TOPOLO_RESOURCE_TYPE_HEADER = "X-Topolo-Resource-Type";
2065
2072
  var TOPOLO_RESOURCE_ID_HEADER = "X-Topolo-Resource-ID";
2066
2073
  var POSITIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
2067
2074
  var NEGATIVE_CACHE_TTL_MS = 60 * 1e3;
2068
- var CATALOG_CACHE_TTL_MS = 60 * 1e3;
2069
2075
  var TopoloClient = class _TopoloClient {
2070
2076
  credential;
2071
2077
  agent;
@@ -2074,9 +2080,9 @@ var TopoloClient = class _TopoloClient {
2074
2080
  requireConfirmForWrites;
2075
2081
  timeoutMs;
2076
2082
  fetchImpl;
2083
+ refreshCredential;
2084
+ refreshInFlight = null;
2077
2085
  debug;
2078
- serviceCatalogCache = null;
2079
- actionCatalogCache = null;
2080
2086
  /**
2081
2087
  * Process-wide cache for slug → app-id lookups. Keyed by
2082
2088
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -2101,6 +2107,7 @@ var TopoloClient = class _TopoloClient {
2101
2107
  this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
2102
2108
  this.timeoutMs = options.timeoutMs ?? 3e4;
2103
2109
  this.fetchImpl = options.fetch ?? fetch;
2110
+ this.refreshCredential = options.refreshCredential;
2104
2111
  this.debug = options.debug;
2105
2112
  }
2106
2113
  emit(event) {
@@ -2118,11 +2125,11 @@ var TopoloClient = class _TopoloClient {
2118
2125
  async request(opts) {
2119
2126
  const method = opts.method ?? "GET";
2120
2127
  if (this.requireConfirmForWrites && WRITE_METHODS.has(method) && !opts.confirm) {
2121
- throw new TopoloAuthError(
2128
+ throw new TopoloConfirmationRequiredError(
2122
2129
  `Mutating ${method} requests require { confirm: true }. This guardrail prevents agents from issuing writes without an explicit human-in-the-loop acknowledgement.`
2123
2130
  );
2124
2131
  }
2125
- const resolvedService = await this.resolveRequestService(opts.service);
2132
+ const resolvedService = opts.baseUrl ? { serviceKey: normalizeLookupKey(opts.service), baseUrl: requireHttpOrigin(opts.baseUrl) } : await this.resolveRequestService(opts.service);
2126
2133
  const baseUrl = resolvedService.baseUrl;
2127
2134
  const url = new URL(opts.path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
2128
2135
  if (opts.query) {
@@ -2153,20 +2160,36 @@ var TopoloClient = class _TopoloClient {
2153
2160
  headers.set(TOPOLO_RESOURCE_TYPE_HEADER, resourceType);
2154
2161
  headers.set(TOPOLO_RESOURCE_ID_HEADER, resourceId);
2155
2162
  }
2156
- const controller = new AbortController();
2157
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
2158
- const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
2159
2163
  const urlStr = url.toString();
2160
2164
  const startedAt = Date.now();
2161
2165
  this.emit({ phase: "request", method, service: resolvedService.serviceKey, path: opts.path, url: urlStr, requestId });
2166
+ const fetchOnce = async () => {
2167
+ const controller = new AbortController();
2168
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
2169
+ const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
2170
+ try {
2171
+ return await this.fetchImpl(urlStr, {
2172
+ method,
2173
+ headers: new Headers(headers),
2174
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
2175
+ signal
2176
+ });
2177
+ } finally {
2178
+ clearTimeout(timeoutId);
2179
+ }
2180
+ };
2162
2181
  let res;
2163
2182
  try {
2164
- res = await this.fetchImpl(urlStr, {
2165
- method,
2166
- headers,
2167
- body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
2168
- signal
2169
- });
2183
+ const attemptedCredential = this.credential;
2184
+ res = await fetchOnce();
2185
+ if (res.status === 401 && attemptedCredential.kind === "access_token" && this.refreshCredential) {
2186
+ await res.body?.cancel().catch(() => void 0);
2187
+ const refreshed = await this.refreshRejectedAccessToken(attemptedCredential.accessToken);
2188
+ if (refreshed) {
2189
+ applyAuthHeaders(headers, refreshed);
2190
+ res = await fetchOnce();
2191
+ }
2192
+ }
2170
2193
  } catch (err) {
2171
2194
  this.emit({
2172
2195
  phase: "error",
@@ -2179,8 +2202,6 @@ var TopoloClient = class _TopoloClient {
2179
2202
  error: err instanceof Error ? err.message : String(err)
2180
2203
  });
2181
2204
  throw err;
2182
- } finally {
2183
- clearTimeout(timeoutId);
2184
2205
  }
2185
2206
  const contentType = res.headers.get("Content-Type") ?? "";
2186
2207
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
@@ -2219,6 +2240,19 @@ var TopoloClient = class _TopoloClient {
2219
2240
  });
2220
2241
  return parsed;
2221
2242
  }
2243
+ async refreshRejectedAccessToken(attemptedAccessToken) {
2244
+ if (this.credential.kind === "access_token" && this.credential.accessToken !== attemptedAccessToken) {
2245
+ return this.credential;
2246
+ }
2247
+ if (!this.refreshCredential) return null;
2248
+ this.refreshInFlight ??= this.refreshCredential().then((credential) => {
2249
+ if (credential) this.credential = credential;
2250
+ return credential;
2251
+ }).catch(() => null).finally(() => {
2252
+ this.refreshInFlight = null;
2253
+ });
2254
+ return this.refreshInFlight;
2255
+ }
2222
2256
  async resolveRequestService(service) {
2223
2257
  const serviceKey = normalizeLookupKey(service);
2224
2258
  if (!serviceKey) {
@@ -2244,9 +2278,8 @@ var TopoloClient = class _TopoloClient {
2244
2278
  );
2245
2279
  }
2246
2280
  async resolveLiveCallableService(serviceKey) {
2247
- const catalog = await this.listServices();
2248
- const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
2249
- if (!match) return null;
2281
+ const match = await this.getService(serviceKey).catch(() => null);
2282
+ if (!match || !catalogEntryMatches(match, serviceKey)) return null;
2250
2283
  const baseUrl = resolveCatalogServiceUrl(match, {
2251
2284
  serviceKey,
2252
2285
  overrides: this.serviceUrls
@@ -2320,12 +2353,13 @@ var TopoloClient = class _TopoloClient {
2320
2353
  * The live Auth catalog is the authority because it is scoped to the caller's
2321
2354
  * organization and effective permissions.
2322
2355
  */
2323
- async listServices() {
2324
- const now = Date.now();
2325
- if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
2326
- return this.serviceCatalogCache.entries;
2327
- }
2328
- const res = await this.request({ service: "developers", method: "GET", path: "/api/services/catalog" });
2356
+ async searchServices(options = {}) {
2357
+ const res = await this.request({
2358
+ service: "developers",
2359
+ method: "GET",
2360
+ path: "/api/services/catalog",
2361
+ query: catalogSearchQuery(options)
2362
+ });
2329
2363
  const raw = res.data?.services ?? res.services;
2330
2364
  if (!Array.isArray(raw)) {
2331
2365
  throw new TopoloSdkError(
@@ -2335,6 +2369,7 @@ var TopoloClient = class _TopoloClient {
2335
2369
  }
2336
2370
  const entries = [];
2337
2371
  for (const item of raw) {
2372
+ if (!isRecord(item)) continue;
2338
2373
  const s = item;
2339
2374
  const appId = typeof s.app_id === "string" ? s.app_id : typeof s.id === "string" ? s.id : null;
2340
2375
  if (!appId) continue;
@@ -2359,11 +2394,30 @@ var TopoloClient = class _TopoloClient {
2359
2394
  apiKeyResources
2360
2395
  });
2361
2396
  }
2362
- this.serviceCatalogCache = {
2363
- entries,
2364
- expiresAt: now + CATALOG_CACHE_TTL_MS
2365
- };
2366
- return entries;
2397
+ const page = normalizeCatalogPage(
2398
+ res.data?.page ?? res.page
2399
+ );
2400
+ return { services: entries, page };
2401
+ }
2402
+ /** @deprecated Prefer searchServices() so callers retain the continuation cursor. */
2403
+ async listServices(options = {}) {
2404
+ return (await this.searchServices(options)).services;
2405
+ }
2406
+ async getService(application) {
2407
+ const needle = application.trim();
2408
+ if (!needle) throw new TopoloSdkError("unknown_service", "application is required");
2409
+ const res = await this.request({
2410
+ service: "developers",
2411
+ method: "GET",
2412
+ path: "/api/services/catalog",
2413
+ query: { application: needle }
2414
+ });
2415
+ const raw = res.data?.application ?? res.application;
2416
+ const entry = normalizeServiceCatalogEntry(raw);
2417
+ if (!entry) {
2418
+ throw new TopoloSdkError("unknown_service", `Unknown Topolo application "${application}".`);
2419
+ }
2420
+ return entry;
2367
2421
  }
2368
2422
  /**
2369
2423
  * List callable actions from the live registry-canonical catalog (definitions
@@ -2371,16 +2425,15 @@ var TopoloClient = class _TopoloClient {
2371
2425
  * caller's organization and effective permissions; no bundled/static app
2372
2426
  * action list is used as an authorization source.
2373
2427
  */
2374
- async listActions(options = {}) {
2375
- const now = Date.now();
2376
- if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
2377
- return this.actionCatalogCache.entries;
2428
+ async searchActions(options) {
2429
+ if (!options.service?.trim()) {
2430
+ throw new TopoloSdkError("service_required", "service is required for bounded action discovery");
2378
2431
  }
2379
2432
  const res = await this.request({
2380
2433
  service: "developers",
2381
2434
  method: "GET",
2382
2435
  path: "/api/actions/catalog",
2383
- ...options.service ? { query: { service: options.service } } : {}
2436
+ query: { service: options.service, ...catalogSearchQuery(options) }
2384
2437
  });
2385
2438
  const raw = res.data?.actions ?? res.actions;
2386
2439
  if (!Array.isArray(raw)) {
@@ -2390,36 +2443,96 @@ var TopoloClient = class _TopoloClient {
2390
2443
  );
2391
2444
  }
2392
2445
  const entries = raw.map((item) => normalizeActionCatalogEntry(item)).filter((entry) => Boolean(entry));
2393
- if (!options.service) {
2394
- this.actionCatalogCache = {
2395
- entries,
2396
- expiresAt: now + CATALOG_CACHE_TTL_MS
2397
- };
2446
+ return {
2447
+ actions: entries,
2448
+ page: normalizeCatalogPage(
2449
+ res.data?.page ?? res.page
2450
+ )
2451
+ };
2452
+ }
2453
+ async getActionCatalogSummary(service) {
2454
+ const boundedService = service.trim();
2455
+ if (!boundedService) {
2456
+ throw new TopoloSdkError("service_required", "service is required for bounded action discovery");
2398
2457
  }
2399
- return entries;
2458
+ const res = await this.request({
2459
+ service: "developers",
2460
+ method: "GET",
2461
+ path: "/api/actions/catalog",
2462
+ query: { service: boundedService, summary: "true" }
2463
+ });
2464
+ const raw = res.data?.summary ?? res.summary;
2465
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2466
+ throw new TopoloSdkError("action_catalog_summary_unavailable", "Action catalog summary is unavailable.");
2467
+ }
2468
+ const record = raw;
2469
+ if (record.algorithm !== "sha256-v1" || typeof record.digest !== "string" || !/^[a-f0-9]{64}$/.test(record.digest) || !Number.isSafeInteger(record.actionCount) || Number(record.actionCount) < 0) {
2470
+ throw new TopoloSdkError("action_catalog_summary_unavailable", "Action catalog summary is invalid.");
2471
+ }
2472
+ return {
2473
+ algorithm: "sha256-v1",
2474
+ digest: record.digest,
2475
+ actionCount: Number(record.actionCount)
2476
+ };
2477
+ }
2478
+ /** @deprecated Prefer searchActions() so callers retain the continuation cursor. */
2479
+ async listActions(options) {
2480
+ if (!options) {
2481
+ throw new TopoloSdkError("service_required", "service is required for bounded action discovery");
2482
+ }
2483
+ return (await this.searchActions(options)).actions;
2400
2484
  }
2401
2485
  async getAction(action) {
2402
- const needle = normalizeActionLookupKey(action);
2403
- const actions = await this.listActions();
2404
- const match = actions.find((entry) => actionEntryMatches(entry, needle));
2405
- if (!match) {
2486
+ const actionId = action.trim();
2487
+ if (!actionId) {
2406
2488
  throw new TopoloSdkError(
2407
2489
  "unknown_action",
2408
- `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`
2490
+ "action id is required"
2409
2491
  );
2410
2492
  }
2493
+ let res;
2494
+ try {
2495
+ res = await this.request({
2496
+ service: "developers",
2497
+ method: "GET",
2498
+ path: "/api/actions/catalog",
2499
+ query: { actionId }
2500
+ });
2501
+ } catch (error) {
2502
+ if (error instanceof TopoloHttpError && error.status === 404) {
2503
+ throw new TopoloSdkError("unknown_action", `Unknown or unavailable Topolo action "${action}".`);
2504
+ }
2505
+ throw error;
2506
+ }
2507
+ const raw = res.data?.action ?? res.action;
2508
+ const match = normalizeActionCatalogEntry(raw);
2509
+ if (!match) throw new TopoloSdkError("unknown_action", `Unknown or unavailable Topolo action "${action}".`);
2411
2510
  return match;
2412
2511
  }
2413
2512
  async callAction(action, input2 = {}, options = {}) {
2414
2513
  const entry = await this.getAction(action);
2514
+ const agentAccess = entry.agentAccess ?? (entry.destructive ? "off" : entry.readOnly && !entry.requiresConfirmation ? "auto" : "confirm");
2515
+ if (agentAccess === "off") {
2516
+ throw new TopoloPermissionError(
2517
+ `Topolo action "${entry.actionId}" is not available for agent invocation.`,
2518
+ entry.requiredPermission ? [entry.requiredPermission] : []
2519
+ );
2520
+ }
2521
+ if ((agentAccess === "confirm" || entry.requiresConfirmation || entry.destructive) && options.confirm !== true) {
2522
+ throw new TopoloConfirmationRequiredError(
2523
+ `Topolo action "${entry.actionId}" requires { confirm: true } before invocation.`
2524
+ );
2525
+ }
2415
2526
  const mapped = mapActionInput(entry, input2);
2416
2527
  const service = entry.appSlug || entry.appId;
2417
2528
  return this.request({
2418
2529
  service,
2530
+ ...entry.apiBaseUrl ? { baseUrl: entry.apiBaseUrl } : {},
2419
2531
  method: entry.method,
2420
2532
  path: mapped.path,
2421
2533
  ...mapped.query ? { query: mapped.query } : {},
2422
2534
  ...mapped.body !== void 0 ? { body: mapped.body } : {},
2535
+ ...options.resource ? { resource: options.resource } : {},
2423
2536
  headers: {
2424
2537
  [TOPOLO_ACTION_ID_HEADER]: entry.actionId,
2425
2538
  [TOPOLO_ACTION_NAME_HEADER]: entry.name
@@ -2557,6 +2670,18 @@ function serviceKeyCandidates2(slug) {
2557
2670
  function normalizeLookupKey(value) {
2558
2671
  return String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
2559
2672
  }
2673
+ function requireHttpOrigin(value) {
2674
+ let url;
2675
+ try {
2676
+ url = new URL(value);
2677
+ } catch {
2678
+ throw new TopoloSdkError("invalid_action_target", `Invalid action API origin "${value}".`);
2679
+ }
2680
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password) {
2681
+ throw new TopoloSdkError("invalid_action_target", `Invalid action API origin "${value}".`);
2682
+ }
2683
+ return url.origin;
2684
+ }
2560
2685
  function deriveDevelopersFromAuth(authUrl) {
2561
2686
  if (!authUrl) return null;
2562
2687
  try {
@@ -2583,6 +2708,47 @@ function catalogEntryLookupKeys(entry) {
2583
2708
  }
2584
2709
  return [...keys];
2585
2710
  }
2711
+ function catalogSearchQuery(options) {
2712
+ const query = {};
2713
+ if (typeof options.query === "string" && options.query.trim()) query.query = options.query.trim();
2714
+ if (typeof options.cursor === "string" && options.cursor.trim()) query.cursor = options.cursor.trim();
2715
+ if (typeof options.limit === "number" && Number.isFinite(options.limit)) query.limit = Math.trunc(options.limit);
2716
+ return query;
2717
+ }
2718
+ function normalizeCatalogPage(value) {
2719
+ if (!isRecord(value)) return { nextCursor: null, hasMore: false };
2720
+ return {
2721
+ nextCursor: typeof value.nextCursor === "string" && value.nextCursor ? value.nextCursor : null,
2722
+ hasMore: value.hasMore === true
2723
+ };
2724
+ }
2725
+ function normalizeServiceCatalogEntry(item) {
2726
+ if (!isRecord(item)) return null;
2727
+ const appId = typeof item.app_id === "string" ? item.app_id : typeof item.id === "string" ? item.id : null;
2728
+ if (!appId) return null;
2729
+ const permissions = Array.isArray(item.granted_permissions) ? item.granted_permissions.filter(
2730
+ (permission) => typeof permission === "string" && permission.trim().length > 0
2731
+ ) : [];
2732
+ const catalogApiKeyResources = normalizeApiKeyResourceMetadataList(
2733
+ item.api_key_resources ?? item.apiKeyResources
2734
+ );
2735
+ const apiKeyResource = normalizeApiKeyResourceMetadata(item.api_key_resource ?? item.apiKeyResource) ?? catalogApiKeyResources[0] ?? null;
2736
+ const apiKeyResources = catalogApiKeyResources.length > 0 ? catalogApiKeyResources : apiKeyResource ? [apiKeyResource] : [];
2737
+ return {
2738
+ appId,
2739
+ slug: typeof item.app_slug === "string" ? item.app_slug : null,
2740
+ name: typeof item.name === "string" ? item.name : null,
2741
+ description: typeof item.description === "string" ? item.description : null,
2742
+ apiBaseUrl: typeof item.api_base_url === "string" ? item.api_base_url : typeof item.apiBaseUrl === "string" ? item.apiBaseUrl : null,
2743
+ launchUrl: typeof item.launch_url === "string" ? item.launch_url : typeof item.launchUrl === "string" ? item.launchUrl : null,
2744
+ status: typeof item.status === "string" ? item.status : null,
2745
+ surfaceKind: typeof item.surface_kind === "string" ? item.surface_kind : null,
2746
+ launchable: item.launchable === true,
2747
+ permissions,
2748
+ apiKeyResource,
2749
+ apiKeyResources
2750
+ };
2751
+ }
2586
2752
  function normalizeActionCatalogEntry(item) {
2587
2753
  if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2588
2754
  const a = item;
@@ -2596,6 +2762,8 @@ function normalizeActionCatalogEntry(item) {
2596
2762
  }
2597
2763
  return {
2598
2764
  actionId,
2765
+ target: pickActionString(a.target_key) ?? pickActionString(a.target) ?? null,
2766
+ apiBaseUrl: pickActionString(a.api_base_url) ?? pickActionString(a.apiBaseUrl) ?? null,
2599
2767
  name,
2600
2768
  toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
2601
2769
  title: pickActionString(a.title) ?? name,
@@ -2607,6 +2775,7 @@ function normalizeActionCatalogEntry(item) {
2607
2775
  path: path4,
2608
2776
  permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? "",
2609
2777
  requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? "",
2778
+ authorizationMode: pickAuthorizationMode(a.authorization_mode ?? a.authorizationMode),
2610
2779
  inputSchema: plainObject(a.input_schema ?? a.inputSchema),
2611
2780
  outputSchema: plainObject(a.output_schema ?? a.outputSchema),
2612
2781
  readOnly: Boolean(a.read_only ?? a.readOnly),
@@ -2626,16 +2795,12 @@ function pickActionString(value) {
2626
2795
  function pickAgentAccess(value) {
2627
2796
  return value === "auto" || value === "confirm" || value === "off" ? value : null;
2628
2797
  }
2798
+ function pickAuthorizationMode(value) {
2799
+ return value === "runtime" ? "runtime" : "static";
2800
+ }
2629
2801
  function plainObject(value) {
2630
2802
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2631
2803
  }
2632
- function normalizeActionLookupKey(value) {
2633
- return String(value || "").trim().toLowerCase();
2634
- }
2635
- function actionEntryMatches(entry, needle) {
2636
- if (!needle) return false;
2637
- return [entry.actionId, entry.name, entry.toolName].filter((value) => typeof value === "string" && value.length > 0).some((value) => value.toLowerCase() === needle);
2638
- }
2639
2804
  function mapActionInput(action, input2) {
2640
2805
  const remaining = { ...input2 || {} };
2641
2806
  const path4 = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
@@ -3039,6 +3204,9 @@ var TopoloOAuth2 = class {
3039
3204
  return parsed;
3040
3205
  }
3041
3206
  };
3207
+ function contextKey(contextType, organizationId) {
3208
+ return contextType === "organization" && organizationId ? `org:${organizationId}` : "personal";
3209
+ }
3042
3210
  var ENVIRONMENT_DEFAULT_SERVICE_URLS = {
3043
3211
  production: {
3044
3212
  developers: "https://developers.topolo.app"
@@ -3132,6 +3300,18 @@ function writeProfileConfig(config, environment, profile) {
3132
3300
  };
3133
3301
  return { ...config, profiles };
3134
3302
  }
3303
+ function writeContextCredential(config, environment, ctx, makeActive = true) {
3304
+ const key = contextKey(ctx.contextType, ctx.organizationId);
3305
+ const profile = profileForEnvironment(config, environment);
3306
+ const contexts = { ...profile.contexts ?? {}, [key]: ctx };
3307
+ const next = { contexts };
3308
+ if (makeActive) {
3309
+ next.activeContext = key;
3310
+ next.credential = ctx.credential;
3311
+ next.oauthClientId = ctx.oauthClientId ?? profile.oauthClientId;
3312
+ }
3313
+ return writeProfileConfig(config, environment, next);
3314
+ }
3135
3315
  function clearProfileCredential(config, environment) {
3136
3316
  const next = { ...config };
3137
3317
  if (environment === "production") {
@@ -3163,7 +3343,7 @@ async function resolveCredential(environment = selectedEnvironment()) {
3163
3343
  return profileForEnvironment(config, environment).credential ?? null;
3164
3344
  }
3165
3345
  var REFRESH_SKEW_SECONDS = 60;
3166
- async function refreshCredentialIfNeeded(environment = selectedEnvironment()) {
3346
+ async function refreshCredentialIfNeeded(environment = selectedEnvironment(), options = {}) {
3167
3347
  const prefix = envVarPrefix(environment);
3168
3348
  if (process.env[`${prefix}_API_KEY`] || process.env[`${prefix}_ACCESS_TOKEN`] || process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
3169
3349
  return resolveCredential(environment);
@@ -3174,7 +3354,7 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment()) {
3174
3354
  if (!cred || cred.kind !== "access_token") return cred ?? null;
3175
3355
  if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
3176
3356
  const now = Math.floor(Date.now() / 1e3);
3177
- if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
3357
+ if (!options.force && cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
3178
3358
  try {
3179
3359
  const oauth = new TopoloOAuth2({
3180
3360
  serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
@@ -3189,9 +3369,20 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment()) {
3189
3369
  ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: cred.refreshToken },
3190
3370
  expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
3191
3371
  };
3192
- await writeConfig(
3193
- environment === "production" && !config.profiles?.production ? { ...config, credential: refreshed } : writeProfileConfig(config, environment, { credential: refreshed })
3194
- );
3372
+ const activeContext = profile.activeContext;
3373
+ const activeEntry = activeContext ? profile.contexts?.[activeContext] : void 0;
3374
+ const updated = activeContext && activeEntry ? writeProfileConfig(config, environment, {
3375
+ credential: refreshed,
3376
+ contexts: {
3377
+ ...profile.contexts ?? {},
3378
+ [activeContext]: {
3379
+ ...activeEntry,
3380
+ credential: refreshed,
3381
+ oauthClientId: activeEntry.oauthClientId ?? profile.oauthClientId
3382
+ }
3383
+ }
3384
+ }) : environment === "production" && !config.profiles?.production ? { ...config, credential: refreshed } : writeProfileConfig(config, environment, { credential: refreshed });
3385
+ await writeConfig(updated);
3195
3386
  return refreshed;
3196
3387
  } catch {
3197
3388
  return cred;
@@ -3243,7 +3434,14 @@ function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
3243
3434
  function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
3244
3435
  return `${developerConsoleUrl(environment)}/credentials`;
3245
3436
  }
3246
- var DEFAULT_CLI_CLIENT_IDS = {};
3437
+ var DEFAULT_CLI_CLIENT_IDS = {
3438
+ // First-party `topolo-cli` device-flow client, seeded per environment by Auth
3439
+ // (core/TopoloAuth/scripts/seed-first-party-oauth-clients.sql). Each env's
3440
+ // Auth must have this client before the CLI ships pointing at it.
3441
+ production: "topolo-cli",
3442
+ staging: "topolo-cli",
3443
+ development: "topolo-cli"
3444
+ };
3247
3445
  function resolveOAuthClientId(config, environment = selectedEnvironment()) {
3248
3446
  const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
3249
3447
  if (envSpecific) return envSpecific;
@@ -3301,18 +3499,43 @@ function normalizeError(err) {
3301
3499
  const code = err.code;
3302
3500
  const hintMap = {
3303
3501
  auth_error: "Run `topolo auth login` or set TOPOLO_API_KEY.",
3502
+ confirmation_required: "Review the action and rerun with --confirm to allow it.",
3304
3503
  permission_denied: "Check your API key scopes or role assignments in TopoloDevelopers."
3305
3504
  };
3306
3505
  return {
3307
3506
  message: err.message,
3308
3507
  code,
3309
3508
  hint: code ? hintMap[code] : void 0,
3310
- exitCode: code === "auth_error" ? 2 : code === "permission_denied" ? 3 : 1
3509
+ exitCode: code === "auth_error" || code === "confirmation_required" ? 2 : code === "permission_denied" ? 3 : 1
3311
3510
  };
3312
3511
  }
3313
3512
  return { message: String(err), exitCode: 1 };
3314
3513
  }
3315
3514
 
3515
+ // src/version.ts
3516
+ import { readFileSync } from "fs";
3517
+ import { dirname as dirname2, join as join2 } from "path";
3518
+ import { fileURLToPath } from "url";
3519
+ function readCliVersion() {
3520
+ try {
3521
+ const here = dirname2(fileURLToPath(import.meta.url));
3522
+ const candidates = [
3523
+ join2(here, "package.json"),
3524
+ join2(here, "..", "package.json")
3525
+ ];
3526
+ for (const path4 of candidates) {
3527
+ try {
3528
+ const pkg = JSON.parse(readFileSync(path4, "utf8"));
3529
+ if (typeof pkg.version === "string" && pkg.version) return pkg.version;
3530
+ } catch {
3531
+ }
3532
+ }
3533
+ } catch {
3534
+ }
3535
+ return "0.0.0";
3536
+ }
3537
+ var CLI_VERSION = readCliVersion();
3538
+
3316
3539
  // src/commands/auth.ts
3317
3540
  function registerAuthCommands(program2) {
3318
3541
  const auth = program2.command("auth").description("Manage Topolo credentials");
@@ -3450,18 +3673,15 @@ async function runDeviceFlow(options) {
3450
3673
  } finally {
3451
3674
  spinner?.stop();
3452
3675
  }
3676
+ const credential = {
3677
+ kind: "access_token",
3678
+ accessToken: token.access_token,
3679
+ ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
3680
+ expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
3681
+ };
3682
+ const context = await resolveLoginContext(credential, resolved.serviceUrls, options.clientId);
3453
3683
  const updated = await readConfig();
3454
- await writeConfig(
3455
- writeProfileConfig(updated, options.environment, {
3456
- credential: {
3457
- kind: "access_token",
3458
- accessToken: token.access_token,
3459
- ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
3460
- expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
3461
- },
3462
- oauthClientId: options.clientId
3463
- })
3464
- );
3684
+ await writeConfig(writeContextCredential(updated, options.environment, context));
3465
3685
  emit(
3466
3686
  {
3467
3687
  ok: true,
@@ -3476,6 +3696,29 @@ async function runDeviceFlow(options) {
3476
3696
  () => "\n \u2713 Signed in. Run `topolo whoami` to see your identity."
3477
3697
  );
3478
3698
  }
3699
+ async function resolveLoginContext(credential, serviceUrls, clientId) {
3700
+ try {
3701
+ const topolo = createTopolo({
3702
+ credential,
3703
+ agent: { clientName: "topolo-cli", clientVersion: CLI_VERSION },
3704
+ serviceUrls
3705
+ });
3706
+ const me = await topolo.identity.whoami();
3707
+ const org = me.organization;
3708
+ if (org?.id) {
3709
+ return {
3710
+ contextType: "organization",
3711
+ organizationId: org.id,
3712
+ organizationSlug: org.slug,
3713
+ organizationName: org.name,
3714
+ credential,
3715
+ oauthClientId: clientId
3716
+ };
3717
+ }
3718
+ } catch {
3719
+ }
3720
+ return { contextType: "personal", credential, oauthClientId: clientId };
3721
+ }
3479
3722
  function tryOpenBrowser(url) {
3480
3723
  try {
3481
3724
  const os = platform2();
@@ -3540,30 +3783,6 @@ function sleep(ms) {
3540
3783
  return new Promise((resolve4) => setTimeout(resolve4, ms));
3541
3784
  }
3542
3785
 
3543
- // src/version.ts
3544
- import { readFileSync } from "fs";
3545
- import { dirname as dirname2, join as join2 } from "path";
3546
- import { fileURLToPath } from "url";
3547
- function readCliVersion() {
3548
- try {
3549
- const here = dirname2(fileURLToPath(import.meta.url));
3550
- const candidates = [
3551
- join2(here, "package.json"),
3552
- join2(here, "..", "package.json")
3553
- ];
3554
- for (const path4 of candidates) {
3555
- try {
3556
- const pkg = JSON.parse(readFileSync(path4, "utf8"));
3557
- if (typeof pkg.version === "string" && pkg.version) return pkg.version;
3558
- } catch {
3559
- }
3560
- }
3561
- } catch {
3562
- }
3563
- return "0.0.0";
3564
- }
3565
- var CLI_VERSION = readCliVersion();
3566
-
3567
3786
  // src/client.ts
3568
3787
  async function buildClient(options) {
3569
3788
  const environment = selectedEnvironment();
@@ -3580,6 +3799,7 @@ async function buildClient(options) {
3580
3799
  },
3581
3800
  serviceUrls: profile.serviceUrls,
3582
3801
  requireConfirmForWrites: options?.requireConfirmForWrites,
3802
+ ...credential.kind === "access_token" && credential.refreshToken ? { refreshCredential: () => refreshCredentialIfNeeded(environment, { force: true }) } : {},
3583
3803
  ...isDebugEnabled() ? { debug: stderrDebugLogger } : {}
3584
3804
  });
3585
3805
  }
@@ -3642,93 +3862,6 @@ function formatPretty(info) {
3642
3862
  return lines.join("\n");
3643
3863
  }
3644
3864
 
3645
- // src/commands/actions.ts
3646
- function registerActionsCommand(program2) {
3647
- const actions = program2.command("actions").description("List and call permission-scoped Topolo actions from the live catalog").option("-s, --service <service>", "Filter by app id, slug, or name").option("--json", "Output JSON", true).option("--no-json", "Output human-readable table").action(async (opts) => {
3648
- const mode = resolveOutputMode(opts.json);
3649
- try {
3650
- const topolo = await buildClient();
3651
- if (!topolo) {
3652
- throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
3653
- }
3654
- const rows = await topolo.client.listActions({ service: opts.service });
3655
- emit({ actions: rows }, mode, () => formatActionRows(rows));
3656
- } catch (err) {
3657
- fail(err, mode);
3658
- }
3659
- });
3660
- actions.command("get <action>").description("Describe one Topolo action by action id, name, or tool name").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (action, opts) => {
3661
- const mode = resolveOutputMode(opts.json);
3662
- try {
3663
- const topolo = await buildClient();
3664
- if (!topolo) {
3665
- throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
3666
- }
3667
- const entry = await topolo.client.getAction(action);
3668
- emit(entry, mode, () => formatActionDetail(entry));
3669
- } catch (err) {
3670
- fail(err, mode);
3671
- }
3672
- });
3673
- actions.command("call <action>").description("Call a permission-scoped Topolo action").option("-d, --data <json>", "JSON action input", "{}").option("--confirm", "Acknowledge a mutating action").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (action, opts) => {
3674
- const mode = resolveOutputMode(opts.json);
3675
- try {
3676
- const topolo = await buildClient();
3677
- if (!topolo) {
3678
- throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
3679
- }
3680
- const input2 = parseJsonObject(opts.data || "{}");
3681
- const result = await topolo.client.callAction(action, input2, { confirm: opts.confirm === true });
3682
- emit(result, mode, () => JSON.stringify(result, null, 2));
3683
- } catch (err) {
3684
- fail(err, mode);
3685
- }
3686
- });
3687
- }
3688
- function parseJsonObject(value) {
3689
- let parsed;
3690
- try {
3691
- parsed = JSON.parse(value);
3692
- } catch {
3693
- throw new Error("--data must be valid JSON");
3694
- }
3695
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3696
- throw new Error("--data must be a JSON object");
3697
- }
3698
- return parsed;
3699
- }
3700
- function formatActionRows(rows) {
3701
- if (rows.length === 0) return "No actions available for this credential.";
3702
- const serviceWidth = Math.max(7, ...rows.map((row) => (row.appSlug || row.appId).length));
3703
- const nameWidth = Math.max(6, ...rows.map((row) => row.name.length));
3704
- const methodWidth = Math.max(6, ...rows.map((row) => row.method.length));
3705
- const lines = [
3706
- `${"SERVICE".padEnd(serviceWidth)} ${"ACTION".padEnd(nameWidth)} ${"METHOD".padEnd(methodWidth)} CONFIRM PERMISSION`,
3707
- `${"-".repeat(serviceWidth)} ${"-".repeat(nameWidth)} ${"-".repeat(methodWidth)} ------- ----------`
3708
- ];
3709
- for (const row of rows) {
3710
- const confirmation = row.requiresConfirmation ? "yes" : "no";
3711
- lines.push(
3712
- `${(row.appSlug || row.appId).padEnd(serviceWidth)} ${row.name.padEnd(nameWidth)} ${row.method.padEnd(methodWidth)} ${confirmation.padEnd(7)} ${row.requiredPermission}`
3713
- );
3714
- }
3715
- return lines.join("\n");
3716
- }
3717
- function formatActionDetail(action) {
3718
- return [
3719
- `Action: ${action.name}`,
3720
- `ID: ${action.actionId}`,
3721
- `Tool: ${action.toolName || "-"}`,
3722
- `Service: ${action.appSlug || action.appId}`,
3723
- `Method: ${action.method}`,
3724
- `Path: ${action.path}`,
3725
- `Permission: ${action.requiredPermission}`,
3726
- `Confirmation: ${action.requiresConfirmation ? "required" : "not required"}`,
3727
- "",
3728
- action.description
3729
- ].join("\n");
3730
- }
3731
-
3732
3865
  // src/commands/api.ts
3733
3866
  var STATIC_APP_IDS = new Set(Object.keys(DEFAULT_APP_URLS));
3734
3867
  var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
@@ -3811,25 +3944,132 @@ function resolveConfiguredServiceUrl2(service, serviceUrls) {
3811
3944
  withoutTopolo,
3812
3945
  withoutTopolo.replace(/-/g, "_")
3813
3946
  ];
3814
- for (const candidate of candidates) {
3815
- const url = serviceUrls?.[candidate];
3816
- if (typeof url === "string" && url.trim()) return url.trim();
3947
+ for (const candidate of candidates) {
3948
+ const url = serviceUrls?.[candidate];
3949
+ if (typeof url === "string" && url.trim()) return url.trim();
3950
+ }
3951
+ return void 0;
3952
+ }
3953
+ function parseQueryPairs(pairs) {
3954
+ if (!pairs || pairs.length === 0) return void 0;
3955
+ const out = {};
3956
+ for (const pair of pairs) {
3957
+ const eq = pair.indexOf("=");
3958
+ if (eq <= 0) {
3959
+ throw new Error(`Invalid --query entry "${pair}". Expected key=value.`);
3960
+ }
3961
+ const key = pair.slice(0, eq);
3962
+ const value = pair.slice(eq + 1);
3963
+ out[key] = value;
3964
+ }
3965
+ return out;
3966
+ }
3967
+
3968
+ // src/commands/actions.ts
3969
+ function registerActionsCommand(program2) {
3970
+ const actions = program2.command("actions").description("Search permission-scoped Topolo actions for one application").option("-s, --service <service>", "App id, slug, or name (required for search)").option("-q, --query <query>", "Filter by action id, name, or title").option("--cursor <cursor>", "Continuation cursor from the previous page").option("--limit <limit>", "Page size (1-100)", "25").option("--json", "Output JSON").option("--no-json", "Output human-readable table").action(async (opts) => {
3971
+ const mode = resolveOutputMode(opts.json);
3972
+ try {
3973
+ const topolo = await buildClient();
3974
+ if (!topolo) {
3975
+ throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
3976
+ }
3977
+ if (!opts.service?.trim()) throw new Error("--service is required for action search");
3978
+ const result = await topolo.client.searchActions({
3979
+ service: opts.service.trim(),
3980
+ query: opts.query,
3981
+ cursor: opts.cursor,
3982
+ limit: parsePageSize(opts.limit)
3983
+ });
3984
+ emit(result, mode, () => formatActionRows(result.actions, result.page.nextCursor));
3985
+ } catch (err) {
3986
+ fail(err, mode);
3987
+ }
3988
+ });
3989
+ actions.command("get <action>").description("Describe one Topolo action by exact action id").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
3990
+ const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
3991
+ try {
3992
+ const topolo = await buildClient();
3993
+ if (!topolo) {
3994
+ throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
3995
+ }
3996
+ const entry = await topolo.client.getAction(action);
3997
+ emit(entry, mode, () => formatActionDetail(entry));
3998
+ } catch (err) {
3999
+ fail(err, mode);
4000
+ }
4001
+ });
4002
+ actions.command("call <action>").description("Call a permission-scoped Topolo action").option("-d, --data <json>", "JSON action input", "{}").option("--resource-type <type>", "App-local resource alias, such as workspace").option("--resource-id <id>", "Resource id for the selected resource type").option("--confirm", "Acknowledge a mutating action").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (action, opts, command) => {
4003
+ const mode = resolveOutputMode(opts.json ?? command.parent?.opts().json);
4004
+ try {
4005
+ const topolo = await buildClient();
4006
+ if (!topolo) {
4007
+ throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
4008
+ }
4009
+ const input2 = parseJsonObject(opts.data || "{}");
4010
+ const resource = parseResourceOption(opts.resourceType, opts.resourceId);
4011
+ const result = await topolo.client.callAction(action, input2, {
4012
+ confirm: opts.confirm === true,
4013
+ ...resource ? { resource } : {}
4014
+ });
4015
+ emit(result, mode, () => JSON.stringify(result, null, 2));
4016
+ } catch (err) {
4017
+ fail(err, mode);
4018
+ }
4019
+ });
4020
+ }
4021
+ function parseJsonObject(value) {
4022
+ let parsed;
4023
+ try {
4024
+ parsed = JSON.parse(value);
4025
+ } catch {
4026
+ throw new Error("--data must be valid JSON");
4027
+ }
4028
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4029
+ throw new Error("--data must be a JSON object");
4030
+ }
4031
+ return parsed;
4032
+ }
4033
+ function formatActionRows(rows, nextCursor = null) {
4034
+ if (rows.length === 0) return "No actions available for this credential.";
4035
+ const serviceWidth = Math.max(7, ...rows.map((row) => (row.appSlug || row.appId).length));
4036
+ const nameWidth = Math.max(6, ...rows.map((row) => row.name.length));
4037
+ const methodWidth = Math.max(6, ...rows.map((row) => row.method.length));
4038
+ const lines = [
4039
+ `${"SERVICE".padEnd(serviceWidth)} ${"ACTION".padEnd(nameWidth)} ${"METHOD".padEnd(methodWidth)} CONFIRM PERMISSION`,
4040
+ `${"-".repeat(serviceWidth)} ${"-".repeat(nameWidth)} ${"-".repeat(methodWidth)} ------- ----------`
4041
+ ];
4042
+ for (const row of rows) {
4043
+ const confirmation = row.requiresConfirmation ? "yes" : "no";
4044
+ lines.push(
4045
+ `${(row.appSlug || row.appId).padEnd(serviceWidth)} ${row.name.padEnd(nameWidth)} ${row.method.padEnd(methodWidth)} ${confirmation.padEnd(7)} ${row.requiredPermission}`
4046
+ );
3817
4047
  }
3818
- return void 0;
4048
+ if (nextCursor) lines.push("", `Next cursor: ${nextCursor}`);
4049
+ return lines.join("\n");
3819
4050
  }
3820
- function parseQueryPairs(pairs) {
3821
- if (!pairs || pairs.length === 0) return void 0;
3822
- const out = {};
3823
- for (const pair of pairs) {
3824
- const eq = pair.indexOf("=");
3825
- if (eq <= 0) {
3826
- throw new Error(`Invalid --query entry "${pair}". Expected key=value.`);
3827
- }
3828
- const key = pair.slice(0, eq);
3829
- const value = pair.slice(eq + 1);
3830
- out[key] = value;
4051
+ function parsePageSize(value) {
4052
+ const parsed = Number.parseInt(value || "25", 10);
4053
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) {
4054
+ throw new Error("--limit must be an integer between 1 and 100");
3831
4055
  }
3832
- return out;
4056
+ return parsed;
4057
+ }
4058
+ function formatActionDetail(action) {
4059
+ return [
4060
+ `Action: ${action.name}`,
4061
+ `ID: ${action.actionId}`,
4062
+ `Tool: ${action.toolName || "-"}`,
4063
+ `Service: ${action.appSlug || action.appId}`,
4064
+ `Target: ${action.target || "-"}`,
4065
+ `API URL: ${action.apiBaseUrl || "-"}`,
4066
+ `Method: ${action.method}`,
4067
+ `Path: ${action.path}`,
4068
+ `Permission: ${action.requiredPermission}`,
4069
+ `Confirmation: ${action.requiresConfirmation ? "required" : "not required"}`,
4070
+ "",
4071
+ action.description
4072
+ ].join("\n");
3833
4073
  }
3834
4074
 
3835
4075
  // src/commands/apps.ts
@@ -5073,6 +5313,12 @@ function firstPartyAppPath(appDir, _audience) {
5073
5313
  return `apps/${appDir}`;
5074
5314
  }
5075
5315
 
5316
+ // src/commands/app-scaffold-cloudflare-constants.ts
5317
+ var CLOUDFLARE_ACCOUNT_ID = "49ef1ba682ad8cfd720c86699ae17521";
5318
+ var STAGING_CLOUDFLARE_ACCOUNT_ID = "4f4e1c69a3830946f9fea7b1eb7531ac";
5319
+ var DEVELOPMENT_CLOUDFLARE_ACCOUNT_ID = "0ebdeec262a245481a721fc4f9d6d798";
5320
+ var CLOUDFLARE_COMPATIBILITY_DATE = "2026-05-05";
5321
+
5076
5322
  // src/commands/app-scaffold-package-template.ts
5077
5323
  function packageJson(application, target, options) {
5078
5324
  const dependencies = {};
@@ -5111,16 +5357,43 @@ function packageJson(application, target, options) {
5111
5357
  devDependencies["@cloudflare/workers-types"] = "^4.20260413.1";
5112
5358
  if (target === "platform") {
5113
5359
  dependencies["@topolo-io/worker-runtime"] = topoloPackageVersion("@topolo-io/worker-runtime", target);
5360
+ dependencies["@topolo-io/auth-middleware"] = topoloPackageVersion("@topolo-io/auth-middleware", target);
5361
+ dependencies["@topolo-io/error-middleware"] = topoloPackageVersion("@topolo-io/error-middleware", target);
5362
+ dependencies["@topolo-io/security-headers"] = topoloPackageVersion("@topolo-io/security-headers", target);
5363
+ dependencies["@topolo-io/resource-context"] = topoloPackageVersion("@topolo-io/resource-context", target);
5364
+ dependencies["@topolo-io/data"] = topoloPackageVersion("@topolo-io/data", target);
5114
5365
  dependencies["@topolo/sdk"] = topoloPackageVersion("@topolo/sdk", target);
5115
5366
  dependencies.hono = "^4";
5367
+ dependencies.zod = "^4.4.3";
5116
5368
  scripts.deploy = `node -e 'console.error("\\nX Bare deploy is disabled. Use deploy:staging or deploy:production.\\n"); process.exit(1)'`;
5117
- scripts["deploy:staging"] = "DEPLOY_ENVIRONMENT=staging VITE_TOPOLO_AUTH_URL=https://auth.stg.topolo.us npm run build && wrangler deploy --config apps/api/wrangler.toml --env staging && wrangler deploy --config apps/web/wrangler.toml --env staging";
5118
- scripts["deploy:production"] = "DEPLOY_ENVIRONMENT=production VITE_TOPOLO_AUTH_URL=https://auth.topolo.app npm run build && wrangler deploy --config apps/api/wrangler.toml --env production && wrangler deploy --config apps/web/wrangler.toml --env production";
5369
+ scripts["deploy:staging"] = `DEPLOY_ENVIRONMENT=staging CLOUDFLARE_ACCOUNT_ID=${STAGING_CLOUDFLARE_ACCOUNT_ID} python3 .github/scripts/standard_deploy.py local-deploy`;
5370
+ scripts["deploy:production"] = `DEPLOY_ENVIRONMENT=production CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID} python3 .github/scripts/standard_deploy.py local-deploy`;
5119
5371
  } else {
5120
5372
  dependencies["@topolo/sdk"] = topoloPackageVersion("@topolo/sdk", target);
5121
5373
  scripts.deploy = "npm run build && wrangler deploy";
5122
5374
  }
5123
- devDependencies.wrangler = "^4.40.2";
5375
+ devDependencies.wrangler = "^4.91.0";
5376
+ }
5377
+ if (target === "platform") {
5378
+ const testDirs = [];
5379
+ if (options.browser) testDirs.push("apps/web");
5380
+ if (options.api) testDirs.push("apps/api");
5381
+ if (testDirs.length > 0) {
5382
+ const chain = (withCoverage) => testDirs.map((dir, index) => {
5383
+ const enter = index === 0 ? `cd ${dir}` : `cd ../${dir.split("/").pop()}`;
5384
+ return `${enter} && vitest run --config vitest.config.ts --passWithNoTests${withCoverage ? " --coverage" : ""}`;
5385
+ }).join(" && ");
5386
+ scripts.test = chain(false);
5387
+ scripts.coverage = chain(true);
5388
+ scripts.typecheck = testDirs.map((dir) => `tsc --noEmit -p ${dir}/tsconfig.json`).join(" && ");
5389
+ devDependencies.vitest = "^3.2.4";
5390
+ devDependencies["@vitest/coverage-v8"] = "^3.2.4";
5391
+ if (options.browser) {
5392
+ devDependencies.jsdom = "^29.1.1";
5393
+ devDependencies["@testing-library/react"] = "^16.3.2";
5394
+ devDependencies["@testing-library/user-event"] = "^14.6.1";
5395
+ }
5396
+ }
5124
5397
  }
5125
5398
  if (target === "platform" && hasScaffoldResources(options.resources)) {
5126
5399
  scripts["check:deploy-config"] = "wrangler deploy --config apps/api/wrangler.toml --env staging --dry-run --outdir .wrangler/api-build && wrangler deploy --config apps/web/wrangler.toml --env staging --dry-run --outdir .wrangler/web-build";
@@ -5140,17 +5413,21 @@ function topoloPackageVersion(packageName, target) {
5140
5413
  if (target === "developer") {
5141
5414
  if (packageName === "@topolo/login") return "^0.1.0";
5142
5415
  if (packageName === "@topolo-io/auth-client") return "^1.0.0";
5143
- if (packageName === "@topolo/sdk") return "^0.4.9";
5416
+ if (packageName === "@topolo/sdk") return "^0.8.1";
5144
5417
  return "^0.1.0";
5145
5418
  }
5146
5419
  const platformPackageVersions = {
5147
- "@topolo/sdk": "^0.4.9",
5420
+ "@topolo/sdk": "^0.8.1",
5148
5421
  "@topolo/login": "^0.1.0",
5149
5422
  "@topolo-io/app-funnel": "^0.1.6",
5150
5423
  "@topolo-io/app-launcher": "^0.1.15",
5151
5424
  "@topolo-io/app-shell": "^0.5.54",
5152
5425
  "@topolo-io/auth-client": "^1.2.1",
5153
- "@topolo-io/auth-middleware": "^0.3.4",
5426
+ "@topolo-io/auth-middleware": "^0.4.0",
5427
+ "@topolo-io/error-middleware": "^0.6.0",
5428
+ "@topolo-io/security-headers": "^0.2.2",
5429
+ "@topolo-io/resource-context": "^0.3.0",
5430
+ "@topolo-io/data": "^0.1.2",
5154
5431
  "@topolo-io/worker-runtime": "^0.1.11",
5155
5432
  "@topolo-io/design-tokens": "^0.1.8",
5156
5433
  "@topolo-io/onboarding": "^0.9.5",
@@ -5186,7 +5463,12 @@ function resourceConfig(application, profile) {
5186
5463
  workflowProductionName: `topolo-${slug}-import`,
5187
5464
  workflowStagingName: `topolo-${slug}-import-staging`,
5188
5465
  containerProductionName: `topolo-${slug}-processor`,
5189
- containerStagingName: `topolo-${slug}-processor-staging`
5466
+ containerStagingName: `topolo-${slug}-processor-staging`,
5467
+ d1DevelopmentName: `topolo-${slug}-core-development`,
5468
+ r2DevelopmentName: `topolo-${slug}-assets-development`,
5469
+ queueDevelopmentName: `topolo-${slug}-processing-development`,
5470
+ workflowDevelopmentName: `topolo-${slug}-import-development`,
5471
+ containerDevelopmentName: `topolo-${slug}-processor-development`
5190
5472
  };
5191
5473
  }
5192
5474
  return {
@@ -5204,7 +5486,12 @@ function resourceConfig(application, profile) {
5204
5486
  workflowProductionName: `topolo-${slug}-workflow`,
5205
5487
  workflowStagingName: `topolo-${slug}-workflow-staging`,
5206
5488
  containerProductionName: `topolo-${slug}-processor`,
5207
- containerStagingName: `topolo-${slug}-processor-staging`
5489
+ containerStagingName: `topolo-${slug}-processor-staging`,
5490
+ d1DevelopmentName: `topolo-${slug}-core-development`,
5491
+ r2DevelopmentName: `topolo-${slug}-assets-development`,
5492
+ queueDevelopmentName: `topolo-${slug}-queue-development`,
5493
+ workflowDevelopmentName: `topolo-${slug}-workflow-development`,
5494
+ containerDevelopmentName: `topolo-${slug}-processor-development`
5208
5495
  };
5209
5496
  }
5210
5497
 
@@ -5377,6 +5664,64 @@ function tsconfigJson(api, target) {
5377
5664
  })}
5378
5665
  `;
5379
5666
  }
5667
+ var TSCONFIG_TEST_EXCLUDE = [
5668
+ "src/**/*.test.ts",
5669
+ "src/**/*.test.tsx",
5670
+ "src/**/*.test.mts",
5671
+ "src/**/*.spec.ts",
5672
+ "src/**/*.spec.tsx",
5673
+ "test"
5674
+ ];
5675
+ function webTsconfigJson() {
5676
+ return `${json({
5677
+ compilerOptions: {
5678
+ target: "ES2022",
5679
+ useDefineForClassFields: true,
5680
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
5681
+ allowJs: false,
5682
+ skipLibCheck: true,
5683
+ esModuleInterop: true,
5684
+ allowSyntheticDefaultImports: true,
5685
+ strict: true,
5686
+ forceConsistentCasingInFileNames: true,
5687
+ module: "ESNext",
5688
+ moduleResolution: "Bundler",
5689
+ resolveJsonModule: true,
5690
+ isolatedModules: true,
5691
+ noEmit: true,
5692
+ jsx: "react-jsx",
5693
+ types: ["vite/client"]
5694
+ },
5695
+ include: ["src"],
5696
+ references: [],
5697
+ exclude: TSCONFIG_TEST_EXCLUDE
5698
+ })}
5699
+ `;
5700
+ }
5701
+ function apiTsconfigJson() {
5702
+ return `${json({
5703
+ compilerOptions: {
5704
+ target: "ES2022",
5705
+ lib: ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
5706
+ allowJs: false,
5707
+ skipLibCheck: true,
5708
+ esModuleInterop: true,
5709
+ allowSyntheticDefaultImports: true,
5710
+ strict: true,
5711
+ forceConsistentCasingInFileNames: true,
5712
+ module: "ESNext",
5713
+ moduleResolution: "Bundler",
5714
+ resolveJsonModule: true,
5715
+ isolatedModules: true,
5716
+ noEmit: true,
5717
+ types: ["@cloudflare/workers-types", "node"]
5718
+ },
5719
+ include: ["src"],
5720
+ references: [],
5721
+ exclude: TSCONFIG_TEST_EXCLUDE
5722
+ })}
5723
+ `;
5724
+ }
5380
5725
  function gitignore() {
5381
5726
  return [
5382
5727
  "node_modules/",
@@ -6118,11 +6463,6 @@ function developerAppTsx(application) {
6118
6463
  ].join("\n");
6119
6464
  }
6120
6465
 
6121
- // src/commands/app-scaffold-cloudflare-constants.ts
6122
- var CLOUDFLARE_ACCOUNT_ID = "49ef1ba682ad8cfd720c86699ae17521";
6123
- var STAGING_CLOUDFLARE_ACCOUNT_ID = "4f4e1c69a3830946f9fea7b1eb7531ac";
6124
- var CLOUDFLARE_COMPATIBILITY_DATE = "2026-05-05";
6125
-
6126
6466
  // src/commands/app-scaffold-worker-template.ts
6127
6467
  function webWorkerTs(application) {
6128
6468
  const serviceName = toTitle(application.name);
@@ -6177,6 +6517,29 @@ function platformWebWranglerToml(application) {
6177
6517
  'binding = "API"',
6178
6518
  `service = "topolo-${slug}-api"`,
6179
6519
  "",
6520
+ "[env.development]",
6521
+ `name = "topolo-${slug}-development"`,
6522
+ `account_id = "${DEVELOPMENT_CLOUDFLARE_ACCOUNT_ID}"`,
6523
+ "workers_dev = true",
6524
+ "",
6525
+ "[env.development.vars]",
6526
+ `APP_ID = "${application.appId ?? ""}"`,
6527
+ `VITE_TOPOLO_APP_ID = "${application.appId ?? ""}"`,
6528
+ "",
6529
+ "[[env.development.routes]]",
6530
+ `pattern = "${slug}.topolo.dev"`,
6531
+ "custom_domain = true",
6532
+ "",
6533
+ "[env.development.assets]",
6534
+ 'directory = "./dist"',
6535
+ 'binding = "ASSETS"',
6536
+ 'not_found_handling = "single-page-application"',
6537
+ 'run_worker_first = ["/api/*"]',
6538
+ "",
6539
+ "[[env.development.services]]",
6540
+ 'binding = "API"',
6541
+ `service = "topolo-${slug}-api-development"`,
6542
+ "",
6180
6543
  "[env.staging]",
6181
6544
  `name = "topolo-${slug}-staging"`,
6182
6545
  `account_id = "${STAGING_CLOUDFLARE_ACCOUNT_ID}"`,
@@ -6248,6 +6611,22 @@ function platformApiWranglerToml(application, resources, profile) {
6248
6611
  ""
6249
6612
  ];
6250
6613
  appendResourceWranglerBlocks(lines, resources, resource, "production");
6614
+ lines.push(
6615
+ "[env.development]",
6616
+ `name = "topolo-${slug}-api-development"`,
6617
+ `account_id = "${DEVELOPMENT_CLOUDFLARE_ACCOUNT_ID}"`,
6618
+ "workers_dev = true",
6619
+ "",
6620
+ "[env.development.vars]",
6621
+ 'AUTH_BASE_URL = "https://auth.topolo.dev"',
6622
+ 'NEXUS_GATEWAY_URL = "https://nexus.topolo.dev"',
6623
+ `NEXUS_APP_ID = "${slug}"`,
6624
+ `APP_ID = "${application.appId ?? ""}"`,
6625
+ `VITE_TOPOLO_APP_ID = "${application.appId ?? ""}"`,
6626
+ 'ENVIRONMENT = "development"',
6627
+ ""
6628
+ );
6629
+ appendResourceWranglerBlocks(lines, resources, resource, "development");
6251
6630
  lines.push(
6252
6631
  "[env.staging]",
6253
6632
  `name = "topolo-${slug}-api-staging"`,
@@ -6281,13 +6660,14 @@ function platformApiWranglerToml(application, resources, profile) {
6281
6660
  return lines.join("\n");
6282
6661
  }
6283
6662
  function appendResourceWranglerBlocks(lines, resources, resource, environment) {
6284
- const prefix = environment === "staging" ? "env.staging." : environment === "production-env" ? "env.production." : "";
6285
- const envName = environment === "staging" ? "Staging" : "Production";
6663
+ const prefix = environment === "staging" ? "env.staging." : environment === "development" ? "env.development." : environment === "production-env" ? "env.production." : "";
6664
+ const envName = environment === "staging" ? "Staging" : environment === "development" ? "Development" : "Production";
6665
+ const pick = (dev, staging, prod) => environment === "development" ? dev : environment === "staging" ? staging : prod;
6286
6666
  if (resources.d1) {
6287
6667
  lines.push(
6288
6668
  `[[${prefix}d1_databases]]`,
6289
6669
  `binding = "${resource.d1Binding}"`,
6290
- `database_name = "${environment === "staging" ? resource.d1StagingName : resource.d1ProductionName}"`,
6670
+ `database_name = "${pick(resource.d1DevelopmentName, resource.d1StagingName, resource.d1ProductionName)}"`,
6291
6671
  'database_id = "provision-after-scaffold"',
6292
6672
  'migrations_dir = "migrations"',
6293
6673
  ""
@@ -6297,7 +6677,7 @@ function appendResourceWranglerBlocks(lines, resources, resource, environment) {
6297
6677
  lines.push(
6298
6678
  `[[${prefix}r2_buckets]]`,
6299
6679
  `binding = "${resource.r2Binding}"`,
6300
- `bucket_name = "${environment === "staging" ? resource.r2StagingName : resource.r2ProductionName}"`,
6680
+ `bucket_name = "${pick(resource.r2DevelopmentName, resource.r2StagingName, resource.r2ProductionName)}"`,
6301
6681
  ""
6302
6682
  );
6303
6683
  }
@@ -6305,10 +6685,10 @@ function appendResourceWranglerBlocks(lines, resources, resource, environment) {
6305
6685
  lines.push(
6306
6686
  `[[${prefix}queues.producers]]`,
6307
6687
  `binding = "${resource.queueBinding}"`,
6308
- `queue = "${environment === "staging" ? resource.queueStagingName : resource.queueProductionName}"`,
6688
+ `queue = "${pick(resource.queueDevelopmentName, resource.queueStagingName, resource.queueProductionName)}"`,
6309
6689
  "",
6310
6690
  `[[${prefix}queues.consumers]]`,
6311
- `queue = "${environment === "staging" ? resource.queueStagingName : resource.queueProductionName}"`,
6691
+ `queue = "${pick(resource.queueDevelopmentName, resource.queueStagingName, resource.queueProductionName)}"`,
6312
6692
  ""
6313
6693
  );
6314
6694
  }
@@ -6316,7 +6696,7 @@ function appendResourceWranglerBlocks(lines, resources, resource, environment) {
6316
6696
  lines.push(
6317
6697
  `[[${prefix}workflows]]`,
6318
6698
  `binding = "${resource.workflowBinding}"`,
6319
- `name = "${environment === "staging" ? resource.workflowStagingName : resource.workflowProductionName}"`,
6699
+ `name = "${pick(resource.workflowDevelopmentName, resource.workflowStagingName, resource.workflowProductionName)}"`,
6320
6700
  `class_name = "${resource.workflowClassName}"`,
6321
6701
  ""
6322
6702
  );
@@ -6514,6 +6894,8 @@ function platformWorkerTs(application, options) {
6514
6894
  "import { Hono } from 'hono';",
6515
6895
  "import { createTopoloWidgetResponse, type TopoloWidgetDraft } from '@topolo/sdk';",
6516
6896
  "import { createTopoloWorkerRuntime, type TopoloRuntimeContext } from '@topolo-io/worker-runtime';",
6897
+ "import { withSecurityHeaders } from '@topolo-io/security-headers';",
6898
+ "import { withErrorReporting } from '@topolo-io/error-middleware';",
6517
6899
  "import { SCAFFOLD_APP_ID, SCAFFOLD_APP_NAME, SCAFFOLD_SERVICE_SLUG, resolveRuntimeAppId } from './app-identity';",
6518
6900
  ...options.resources.workflow ? ["import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';"] : [],
6519
6901
  "",
@@ -6604,11 +6986,20 @@ function platformWorkerTs(application, options) {
6604
6986
  " return handleRequest(c.req.raw, c.env, rt);",
6605
6987
  "});",
6606
6988
  "",
6989
+ "// Platform security headers + structured error reporting wrap the whole app",
6990
+ "// (outermost), so headers are stamped even on error-middleware 5xx responses.",
6991
+ "const securedFetch = withSecurityHeaders(",
6992
+ " withErrorReporting(",
6993
+ " (request: Request, env: Env, ctx: ExecutionContext) => api.fetch(request, env, ctx),",
6994
+ " { appName: SCAFFOLD_APP_NAME, appId: SCAFFOLD_SERVICE_SLUG },",
6995
+ " ),",
6996
+ ") as (request: Request, env: Env, ctx: ExecutionContext) => Response | Promise<Response>;",
6997
+ "",
6607
6998
  "export default {",
6608
6999
  " fetch: runtime.worker((request, env, ctx, rt) => {",
6609
7000
  " const bindings = env as Env & { __rt?: TopoloRuntimeContext<Env> };",
6610
7001
  " bindings.__rt = rt;",
6611
- " return api.fetch(request, bindings, ctx as ExecutionContext);",
7002
+ " return securedFetch(request, bindings, ctx as ExecutionContext);",
6612
7003
  " }),",
6613
7004
  "};",
6614
7005
  "",
@@ -6961,9 +7352,12 @@ function topoloAppManifestJson(application, options) {
6961
7352
  $schema: "./../../topolo-platform/tooling/app-manifest-schema.json",
6962
7353
  name: `topolo-${slug}`,
6963
7354
  appId: application.appId,
6964
- primaryTarget: "web",
6965
7355
  compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE,
6966
7356
  environments: {
7357
+ development: {
7358
+ accountId: DEVELOPMENT_CLOUDFLARE_ACCOUNT_ID,
7359
+ domain: "topolo.dev"
7360
+ },
6967
7361
  staging: {
6968
7362
  accountId: STAGING_CLOUDFLARE_ACCOUNT_ID,
6969
7363
  domain: "stg.topolo.us"
@@ -7004,6 +7398,15 @@ function cloudcontrolJson(application, options) {
7004
7398
  resource_name: resources.d1StagingName,
7005
7399
  resource_id: "provision-after-scaffold",
7006
7400
  migrations_dir: "migrations"
7401
+ },
7402
+ {
7403
+ resource_type: "d1",
7404
+ binding: resources.d1Binding,
7405
+ environment: "development",
7406
+ account_id: DEVELOPMENT_CLOUDFLARE_ACCOUNT_ID,
7407
+ resource_name: resources.d1DevelopmentName,
7408
+ resource_id: "provision-after-scaffold",
7409
+ migrations_dir: "migrations"
7007
7410
  }
7008
7411
  );
7009
7412
  }
@@ -7624,6 +8027,74 @@ function envExample(application) {
7624
8027
  function generateOpaqueAppId() {
7625
8028
  return `app_${randomBytes(9).toString("base64url")}`;
7626
8029
  }
8030
+ function standardScriptStub() {
8031
+ return `#!/usr/bin/env python3
8032
+ """Canonical-fetching stub for .github/scripts/standard_*.py.
8033
+
8034
+ Replaces a vendored copy. At runtime, finds the canonical sibling in
8035
+ \`<repo-parent>/topolo-platform/tooling/<name>.py\` and execs it, passing argv
8036
+ through, so every consumer runs the current platform script on every
8037
+ invocation (no drifted per-app copies).
8038
+
8039
+ Override: set TOPOLO_PLATFORM_DIR to the absolute path of the topolo-platform
8040
+ repo when the canonical is in a non-default checkout layout (e.g. CI). If no
8041
+ canonical can be found, exits 2 with a clear message rather than silently
8042
+ falling back.
8043
+ """
8044
+ from __future__ import annotations
8045
+
8046
+ import os
8047
+ import sys
8048
+
8049
+ STUB_NAME = os.path.basename(__file__) # standard_ci.py OR standard_deploy.py
8050
+ HERE = os.path.dirname(os.path.abspath(__file__))
8051
+
8052
+
8053
+ def find_canonical() -> str | None:
8054
+ # 1. Explicit override (CI runners with non-standard layouts).
8055
+ override = os.environ.get('TOPOLO_PLATFORM_DIR')
8056
+ if override:
8057
+ path = os.path.join(override, 'tooling', STUB_NAME)
8058
+ if os.path.exists(path):
8059
+ return path
8060
+ # 2. Walk up looking for a sibling topolo-platform/tooling/<name>.py.
8061
+ cursor = HERE
8062
+ for _ in range(10):
8063
+ candidate = os.path.normpath(os.path.join(cursor, '..', 'topolo-platform', 'tooling', STUB_NAME))
8064
+ if os.path.exists(candidate):
8065
+ return candidate
8066
+ parent = os.path.dirname(cursor)
8067
+ if parent == cursor:
8068
+ break
8069
+ cursor = parent
8070
+ # 3. node_modules-based delivery (future): @topolo-io/standard-ci.
8071
+ cursor = HERE
8072
+ for _ in range(10):
8073
+ candidate = os.path.normpath(os.path.join(cursor, 'node_modules', '@topolo-io', 'standard-ci', 'bin', STUB_NAME))
8074
+ if os.path.exists(candidate):
8075
+ return candidate
8076
+ parent = os.path.dirname(cursor)
8077
+ if parent == cursor:
8078
+ break
8079
+ cursor = parent
8080
+ return None
8081
+
8082
+
8083
+ def main() -> None:
8084
+ canonical = find_canonical()
8085
+ if not canonical:
8086
+ sys.stderr.write(
8087
+ f'[topolo-canonical-stub] could not locate canonical {STUB_NAME}.\\n'
8088
+ f' Set TOPOLO_PLATFORM_DIR or checkout topolo-platform as a sibling of this app.\\n'
8089
+ )
8090
+ sys.exit(2)
8091
+ os.execv(sys.executable, [sys.executable, canonical, *sys.argv[1:]])
8092
+
8093
+
8094
+ if __name__ == '__main__':
8095
+ main()
8096
+ `;
8097
+ }
7627
8098
  function applicationScaffoldPayload(application, options = {}) {
7628
8099
  const target = normalizeTarget(options.target);
7629
8100
  const portfolio = target === "platform" ? normalizePortfolio(options.portfolio) : null;
@@ -7642,6 +8113,9 @@ function applicationScaffoldPayload(application, options = {}) {
7642
8113
  const containerAppId = normalizeContainerAppId(options.containerAppId, surfaceModel);
7643
8114
  const profile = normalizeScaffoldProfile(options.profile);
7644
8115
  const resources = resolveScaffoldResources(profile, options);
8116
+ if (target === "platform" && api && !profile) {
8117
+ resources.d1 = true;
8118
+ }
7645
8119
  const ownerType = target === "platform" ? "topolo_first_party" : "third_party";
7646
8120
  const distributionModel = target === "platform" ? "marketplace" : "organization_internal";
7647
8121
  const appDir = options.appDir ?? catalogApplication?.appDir ?? (target === "platform" ? `Topolo${toPascalCase(id)}` : `topolo-${slug}`);
@@ -7788,6 +8262,28 @@ function scaffoldCapabilities(options) {
7788
8262
  function scaffoldFiles(options) {
7789
8263
  const { application, target } = options;
7790
8264
  const slug = toSlug(application.id);
8265
+ const tablePrefix = slug.replace(/-/g, "_");
8266
+ const tenantColumn = options.payload.tenancy === "household" ? "household_id" : "organization_id";
8267
+ const workspacesMigrationSql = [
8268
+ `CREATE TABLE IF NOT EXISTS ${tablePrefix}_workspaces (`,
8269
+ " id TEXT PRIMARY KEY,",
8270
+ ` ${tenantColumn} TEXT NOT NULL,`,
8271
+ " name TEXT NOT NULL,",
8272
+ " slug TEXT NOT NULL,",
8273
+ " created_by_user_id TEXT,",
8274
+ " created_by_email TEXT,",
8275
+ " created_at TEXT NOT NULL,",
8276
+ " updated_at TEXT NOT NULL,",
8277
+ ` UNIQUE(${tenantColumn}, slug)`,
8278
+ ");",
8279
+ "",
8280
+ `CREATE INDEX IF NOT EXISTS idx_${tablePrefix}_workspaces_${tenantColumn}`,
8281
+ ` ON ${tablePrefix}_workspaces(${tenantColumn});`,
8282
+ "",
8283
+ `CREATE INDEX IF NOT EXISTS idx_${tablePrefix}_workspaces_slug`,
8284
+ ` ON ${tablePrefix}_workspaces(${tenantColumn}, slug);`,
8285
+ ""
8286
+ ].join("\n");
7791
8287
  const base = target === "platform" ? firstPartyAppPath(application.appDir, options.payload.audience) : application.appDir;
7792
8288
  const browserBase = target === "platform" ? `${base}/apps/web` : base;
7793
8289
  const apiBase = target === "platform" ? `${base}/apps/api` : base;
@@ -7845,15 +8341,6 @@ function scaffoldFiles(options) {
7845
8341
  resources: options.payload.resources
7846
8342
  })
7847
8343
  },
7848
- {
7849
- path: `${base}/tsconfig.json`,
7850
- purpose: "TypeScript configuration for the generated app.",
7851
- requiredSignals: [
7852
- "uses strict mode",
7853
- "includes browser DOM types for React and Worker types for API-backed apps"
7854
- ],
7855
- content: tsconfigJson(options.api, target)
7856
- },
7857
8344
  {
7858
8345
  path: `${browserBase}/vite.config.ts`,
7859
8346
  purpose: "Vite browser build configuration.",
@@ -7911,6 +8398,31 @@ function scaffoldFiles(options) {
7911
8398
  content: stylesCss()
7912
8399
  }
7913
8400
  ];
8401
+ if (target === "platform") {
8402
+ if (options.browser) {
8403
+ files.push({
8404
+ path: `${browserBase}/tsconfig.json`,
8405
+ purpose: "TypeScript configuration for the browser surface (DOM + vite types).",
8406
+ requiredSignals: ["uses strict mode", "includes browser DOM types for React"],
8407
+ content: webTsconfigJson()
8408
+ });
8409
+ }
8410
+ if (options.api) {
8411
+ files.push({
8412
+ path: `${apiBase}/tsconfig.json`,
8413
+ purpose: "TypeScript configuration for the API worker surface (Workers types).",
8414
+ requiredSignals: ["uses strict mode", "includes Cloudflare Workers types"],
8415
+ content: apiTsconfigJson()
8416
+ });
8417
+ }
8418
+ } else {
8419
+ files.push({
8420
+ path: `${base}/tsconfig.json`,
8421
+ purpose: "TypeScript configuration for the generated app.",
8422
+ requiredSignals: ["uses strict mode", "includes browser DOM types for React and Worker types for API-backed apps"],
8423
+ content: tsconfigJson(options.api, target)
8424
+ });
8425
+ }
7914
8426
  if (target === "platform") {
7915
8427
  files.push(
7916
8428
  {
@@ -7951,6 +8463,33 @@ function scaffoldFiles(options) {
7951
8463
  ],
7952
8464
  content: options.ciWorkflow ?? ""
7953
8465
  },
8466
+ {
8467
+ path: `${base}/.github/scripts/standard_deploy.py`,
8468
+ purpose: "Canonical-fetching deploy stub (execs topolo-platform/tooling/standard_deploy.py).",
8469
+ requiredSignals: [
8470
+ "never a vendored copy of the canonical script",
8471
+ "runs the current platform standard_deploy.py on every invocation"
8472
+ ],
8473
+ content: standardScriptStub()
8474
+ },
8475
+ {
8476
+ path: `${base}/.github/scripts/standard_ci.py`,
8477
+ purpose: "Canonical-fetching CI stub (execs topolo-platform/tooling/standard_ci.py).",
8478
+ requiredSignals: [
8479
+ "never a vendored copy of the canonical script",
8480
+ "runs the current platform standard_ci.py on every invocation"
8481
+ ],
8482
+ content: standardScriptStub()
8483
+ },
8484
+ {
8485
+ path: `${base}/.github/scripts/standard_ops.py`,
8486
+ purpose: "Canonical-fetching ops stub (execs topolo-platform/tooling/standard_ops.py).",
8487
+ requiredSignals: [
8488
+ "never a vendored copy of the canonical script",
8489
+ "runs the current platform standard_ops.py on every invocation"
8490
+ ],
8491
+ content: standardScriptStub()
8492
+ },
7954
8493
  {
7955
8494
  path: `system-apps/TopoloDocs/apps/web/src/content/systems/topolo-${slug}.json`,
7956
8495
  purpose: "Canonical system registry entry.",
@@ -8074,6 +8613,25 @@ function scaffoldFiles(options) {
8074
8613
  portfolio: options.payload.portfolio
8075
8614
  })
8076
8615
  });
8616
+ if (target === "platform") {
8617
+ files.push({
8618
+ path: `${browserBase}/vitest.config.ts`,
8619
+ purpose: "Vitest config for the browser surface.",
8620
+ requiredSignals: [
8621
+ "runs component/unit tests under jsdom",
8622
+ "keeps the generated browser surface testable from day one"
8623
+ ],
8624
+ content: `import { defineConfig } from 'vitest/config';
8625
+
8626
+ export default defineConfig({
8627
+ test: {
8628
+ include: ['test/**/*.test.{ts,tsx}'],
8629
+ environment: 'jsdom',
8630
+ },
8631
+ });
8632
+ `
8633
+ });
8634
+ }
8077
8635
  }
8078
8636
  if (options.api) {
8079
8637
  files.push(
@@ -8130,8 +8688,45 @@ function scaffoldFiles(options) {
8130
8688
  "binds the frontend Worker to the API Worker for /api/* requests"
8131
8689
  ],
8132
8690
  content: platformWebWranglerToml(application)
8691
+ },
8692
+ {
8693
+ path: `${apiBase}/vitest.config.ts`,
8694
+ purpose: "Vitest config for the API worker surface.",
8695
+ requiredSignals: [
8696
+ "runs worker/unit tests under the node environment",
8697
+ "keeps the generated service testable from day one"
8698
+ ],
8699
+ content: `import { defineConfig } from 'vitest/config';
8700
+
8701
+ export default defineConfig({
8702
+ test: {
8703
+ include: ['test/**/*.test.{js,mjs,mts,ts,tsx}'],
8704
+ environment: 'node',
8705
+ },
8706
+ });
8707
+ `
8708
+ },
8709
+ {
8710
+ path: `${apiBase}/test/health.test.ts`,
8711
+ purpose: "Smoke test so the API test suite is non-empty from scaffold.",
8712
+ requiredSignals: [
8713
+ "asserts the scaffold boots",
8714
+ "is replaced by real route/repository tests as the surface grows"
8715
+ ],
8716
+ content: "import { describe, expect, it } from 'vitest';\n\n// Smoke test so the API test suite is non-empty from day one. Replace with\n// real route/repository tests as the surface grows.\ndescribe('api scaffold', () => {\n it('boots', () => {\n expect(true).toBe(true);\n });\n});\n"
8133
8717
  }
8134
8718
  );
8719
+ if (options.payload.resources.d1) {
8720
+ files.push({
8721
+ path: `${apiBase}/migrations/0001_initial.sql`,
8722
+ purpose: "Canonical multi-tenant baseline: the app workspaces table.",
8723
+ requiredSignals: [
8724
+ `${tablePrefix}_workspaces carries ${tenantColumn} on every row`,
8725
+ "every domain table added later carries the tenant + workspace columns"
8726
+ ],
8727
+ content: workspacesMigrationSql
8728
+ });
8729
+ }
8135
8730
  }
8136
8731
  }
8137
8732
  return files;
@@ -8372,15 +8967,20 @@ var CLOUDCONTROL_LOCAL_APPLICATION = {
8372
8967
  deployTargets: []
8373
8968
  };
8374
8969
  function registerAppsCommand(program2) {
8375
- const apps = program2.command("apps").description("List Topolo platform applications available to the current credential").option("--json", "Output JSON", true).option("--no-json", "Output human-readable table").action(async (opts) => {
8970
+ const apps = program2.command("apps").description("Search Topolo platform applications available to the current credential").option("-q, --query <query>", "Filter by app id, slug, or name").option("--cursor <cursor>", "Continuation cursor from the previous page").option("--limit <limit>", "Page size (1-100)", "25").option("--json", "Output JSON", true).option("--no-json", "Output human-readable table").action(async (opts) => {
8376
8971
  const mode = resolveOutputMode(opts.json);
8377
8972
  try {
8378
8973
  const topolo = await buildClient();
8379
8974
  if (!topolo) {
8380
8975
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
8381
8976
  }
8382
- const rows = applicationRowsFromCatalog(await topolo.client.listServices());
8383
- emit({ applications: rows }, mode, () => formatRows(rows));
8977
+ const result = await topolo.client.searchServices({
8978
+ query: opts.query,
8979
+ cursor: opts.cursor,
8980
+ limit: parsePageSize2(opts.limit)
8981
+ });
8982
+ const rows = applicationRowsFromCatalog(result.services);
8983
+ emit({ applications: rows, page: result.page }, mode, () => formatRows(rows));
8384
8984
  } catch (err) {
8385
8985
  fail(err, mode);
8386
8986
  }
@@ -8392,7 +8992,8 @@ function registerAppsCommand(program2) {
8392
8992
  if (!topolo) {
8393
8993
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
8394
8994
  }
8395
- const app = resolveCatalogApplication(await topolo.client.listServices(), application);
8995
+ const entry = await topolo.client.getService(application);
8996
+ const app = applicationRowsFromCatalog([entry])[0];
8396
8997
  emit({ application: app }, mode, () => formatApplicationRow(app));
8397
8998
  } catch (err) {
8398
8999
  fail(err, mode);
@@ -8512,16 +9113,12 @@ function applicationRowsFromCatalog(catalog) {
8512
9113
  deployTargets: 0
8513
9114
  })).sort((left, right) => left.id.localeCompare(right.id));
8514
9115
  }
8515
- function resolveCatalogApplication(catalog, application) {
8516
- const needle = application.trim().toLowerCase();
8517
- const rows = applicationRowsFromCatalog(catalog);
8518
- const match = rows.find(
8519
- (row) => row.id.toLowerCase() === needle || row.name.toLowerCase() === needle || row.services.some((service) => service.toLowerCase() === needle)
8520
- );
8521
- if (!match) {
8522
- throw new Error(`Unknown application "${application}". Run \`topolo apps --json\` to discover applications available to this credential.`);
9116
+ function parsePageSize2(value) {
9117
+ const parsed = Number.parseInt(value || "25", 10);
9118
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) {
9119
+ throw new Error("--limit must be an integer between 1 and 100");
8523
9120
  }
8524
- return match;
9121
+ return parsed;
8525
9122
  }
8526
9123
  function resolveApplication(application) {
8527
9124
  const id = application.trim().toLowerCase();
@@ -9074,7 +9671,7 @@ async function requirePlatformAdminSession() {
9074
9671
 
9075
9672
  // src/commands/services.ts
9076
9673
  function registerServicesCommand(program2) {
9077
- program2.command("services").description("List every Topolo service the SDK can route to").option("--json", "Output JSON", true).option("--no-json", "Output human-readable table").action(async (opts) => {
9674
+ program2.command("services").description("Search Topolo services the SDK can route to").option("-q, --query <query>", "Filter by app id, slug, or name").option("--cursor <cursor>", "Continuation cursor from the previous page").option("--limit <limit>", "Page size (1-100)", "25").option("--json", "Output JSON", true).option("--no-json", "Output human-readable table").action(async (opts) => {
9078
9675
  const mode = resolveOutputMode(opts.json);
9079
9676
  try {
9080
9677
  const environment = selectedEnvironment();
@@ -9083,8 +9680,13 @@ function registerServicesCommand(program2) {
9083
9680
  if (!topolo) {
9084
9681
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
9085
9682
  }
9086
- const rows = buildRows(await topolo.client.listServices(), config.serviceUrls, environment);
9087
- emit({ environment, services: rows }, mode, () => formatRows2(rows, environment));
9683
+ const result = await topolo.client.searchServices({
9684
+ query: opts.query,
9685
+ cursor: opts.cursor,
9686
+ limit: parsePageSize3(opts.limit)
9687
+ });
9688
+ const rows = buildRows(result.services, config.serviceUrls, environment);
9689
+ emit({ environment, services: rows, page: result.page }, mode, () => formatRows2(rows, environment, result.page.nextCursor));
9088
9690
  } catch (err) {
9089
9691
  fail(err, mode);
9090
9692
  }
@@ -9111,7 +9713,7 @@ function buildRows(catalog, overrides, environment = selectedEnvironment()) {
9111
9713
  };
9112
9714
  }).sort((left, right) => left.id.localeCompare(right.id));
9113
9715
  }
9114
- function formatRows2(rows, environment = selectedEnvironment()) {
9716
+ function formatRows2(rows, environment = selectedEnvironment(), nextCursor = null) {
9115
9717
  const idWidth = Math.max(3, ...rows.map((r) => r.id.length));
9116
9718
  const urlWidth = Math.max(7, ...rows.map((r) => (r.effectiveUrl ?? "-").length));
9117
9719
  const lines = [];
@@ -9126,8 +9728,14 @@ function formatRows2(rows, environment = selectedEnvironment()) {
9126
9728
  `${r.id.padEnd(idWidth)} ${effectiveUrl.padEnd(urlWidth)} ${r.overridden ? "yes" : "".padEnd(8)} ${r.environmentSafe ? "yes" : "no"} ${apiKeyResource}`
9127
9729
  );
9128
9730
  }
9731
+ if (nextCursor) lines.push("", `Next cursor: ${nextCursor}`);
9129
9732
  return lines.join("\n");
9130
9733
  }
9734
+ function parsePageSize3(value) {
9735
+ const parsed = Number.parseInt(value || "25", 10);
9736
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) throw new Error("--limit must be an integer between 1 and 100");
9737
+ return parsed;
9738
+ }
9131
9739
  function isStagingUrl(value) {
9132
9740
  const url = value.toLowerCase();
9133
9741
  return url.includes(".stg.topolo.us") || url.includes("localhost") || url.includes("127.0.0.1") || url.includes("topolo-staging");
@@ -9900,6 +10508,162 @@ function formatStatus(rows) {
9900
10508
  ).join("\n");
9901
10509
  }
9902
10510
 
10511
+ // src/commands/context.ts
10512
+ function makeClient(credential, serviceUrls) {
10513
+ return createTopolo({
10514
+ credential,
10515
+ agent: { clientName: "topolo-cli", clientVersion: CLI_VERSION },
10516
+ serviceUrls
10517
+ });
10518
+ }
10519
+ function unwrap(res) {
10520
+ const r = res ?? {};
10521
+ return r.data ?? r;
10522
+ }
10523
+ async function fetchMe(credential, serviceUrls) {
10524
+ const topolo = makeClient(credential, serviceUrls);
10525
+ const data = unwrap(await topolo.client.request({ service: "auth", method: "GET", path: "/api/auth/me" }));
10526
+ const user = data.user ?? {};
10527
+ const rawMemberships = user.organizationMemberships || data.organizations || [];
10528
+ const memberships = rawMemberships.filter((m) => m && m.id);
10529
+ const activeType = user.orgId ? "organization" : "personal";
10530
+ return { memberships, activeType, activeOrgId: user.orgId || void 0 };
10531
+ }
10532
+ function credentialFromTokens(tokens) {
10533
+ const accessToken = tokens.accessToken || tokens.access_token;
10534
+ const refreshToken = tokens.refreshToken || tokens.refresh_token;
10535
+ const expiresIn = tokens.expiresIn || tokens.expires_in;
10536
+ const expiresAtRaw = tokens.expiresAt || tokens.expires_at;
10537
+ return {
10538
+ kind: "access_token",
10539
+ accessToken,
10540
+ ...refreshToken ? { refreshToken } : {},
10541
+ ...expiresAtRaw ? { expiresAt: expiresAtRaw } : expiresIn ? { expiresAt: Math.floor(Date.now() / 1e3) + expiresIn } : {}
10542
+ };
10543
+ }
10544
+ async function exchangeForContext(credential, serviceUrls, target) {
10545
+ const topolo = makeClient(credential, serviceUrls);
10546
+ const data = unwrap(
10547
+ await topolo.client.request({
10548
+ service: "auth",
10549
+ method: "PUT",
10550
+ path: "/api/me/active-context",
10551
+ body: {
10552
+ contextType: target.type,
10553
+ contextId: target.type === "organization" ? target.id : null
10554
+ },
10555
+ confirm: true
10556
+ })
10557
+ );
10558
+ const tokens = data.tokens || {};
10559
+ return credentialFromTokens(tokens);
10560
+ }
10561
+ function labelFor(ctx) {
10562
+ if (ctx.contextType === "personal") return "Personal";
10563
+ return ctx.organizationName || ctx.organizationSlug || ctx.organizationId || "organization";
10564
+ }
10565
+ function registerContextCommand(program2) {
10566
+ const context = program2.command("context").description("Show and switch the active context (personal or an organization)");
10567
+ context.command("ls", { isDefault: true }).alias("list").description("List the contexts you can act in and which is active").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
10568
+ const mode = resolveOutputMode(opts.json);
10569
+ try {
10570
+ const environment = selectedEnvironment();
10571
+ const config = await readConfig();
10572
+ const profile = profileForEnvironment(config, environment);
10573
+ const stored = profile.contexts ?? {};
10574
+ const activeKey = profile.activeContext;
10575
+ const rows = /* @__PURE__ */ new Map();
10576
+ for (const [key, ctx] of Object.entries(stored)) {
10577
+ rows.set(key, { key, type: ctx.contextType, label: labelFor(ctx), signedIn: true, active: key === activeKey });
10578
+ }
10579
+ const cred = await refreshCredentialIfNeeded(environment);
10580
+ if (cred) {
10581
+ try {
10582
+ const me = await fetchMe(cred, resolvedProfileConfig(config, environment).serviceUrls);
10583
+ const personalKey = contextKey("personal");
10584
+ if (!rows.has(personalKey)) {
10585
+ rows.set(personalKey, { key: personalKey, type: "personal", label: "Personal", signedIn: false, active: personalKey === activeKey });
10586
+ }
10587
+ for (const m of me.memberships) {
10588
+ const key = contextKey("organization", m.id);
10589
+ if (!rows.has(key)) {
10590
+ rows.set(key, { key, type: "organization", label: m.name || m.slug || m.id, signedIn: false, active: key === activeKey });
10591
+ }
10592
+ }
10593
+ } catch {
10594
+ }
10595
+ }
10596
+ const list = [...rows.values()];
10597
+ emit({ environment, active: activeKey ?? null, contexts: list }, mode, () => {
10598
+ if (list.length === 0) return "No contexts yet. Run `topolo auth login` first.";
10599
+ const lines = [`Contexts (${environmentLabel(environment)}):`, ""];
10600
+ for (const r of list) {
10601
+ const marks = [r.active ? "active" : "", r.signedIn ? "signed in" : "available"].filter(Boolean).join(", ");
10602
+ lines.push(` ${r.active ? "*" : " "} ${r.label} (${r.type}) [${marks}]`);
10603
+ }
10604
+ lines.push("", "Switch with: topolo context use <organization|personal>");
10605
+ return lines.join("\n");
10606
+ });
10607
+ } catch (err) {
10608
+ fail(err, mode);
10609
+ }
10610
+ });
10611
+ context.command("use <target>").description('Switch the active context (an organization name/slug, or "personal")').option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (target, opts) => {
10612
+ const mode = resolveOutputMode(opts.json);
10613
+ try {
10614
+ const environment = selectedEnvironment();
10615
+ const config = await readConfig();
10616
+ const profile = profileForEnvironment(config, environment);
10617
+ const serviceUrls = resolvedProfileConfig(config, environment).serviceUrls;
10618
+ const cred = await refreshCredentialIfNeeded(environment);
10619
+ if (!cred) {
10620
+ throw new Error("Not signed in. Run `topolo auth login` first.");
10621
+ }
10622
+ const wantPersonal = /^(personal|me)$/i.test(target.trim());
10623
+ let resolved;
10624
+ if (wantPersonal) {
10625
+ resolved = { contextType: "personal" };
10626
+ } else {
10627
+ const me = await fetchMe(cred, serviceUrls);
10628
+ const needle = target.trim().toLowerCase();
10629
+ const match = me.memberships.find(
10630
+ (m) => m.id === target || m.slug?.toLowerCase() === needle || m.name?.toLowerCase() === needle
10631
+ );
10632
+ if (!match) {
10633
+ const names = me.memberships.map((m) => m.slug || m.name || m.id);
10634
+ throw new Error(
10635
+ `You are not a member of "${target}". Available: personal${names.length ? ", " + names.join(", ") : ""}.`
10636
+ );
10637
+ }
10638
+ resolved = {
10639
+ contextType: "organization",
10640
+ organizationId: match.id,
10641
+ organizationSlug: match.slug,
10642
+ organizationName: match.name
10643
+ };
10644
+ }
10645
+ const key = contextKey(resolved.contextType, resolved.organizationId);
10646
+ const existing = profile.contexts?.[key];
10647
+ const newCredential = await exchangeForContext(cred, serviceUrls, {
10648
+ type: resolved.contextType,
10649
+ id: resolved.organizationId
10650
+ });
10651
+ resolved.credential = newCredential;
10652
+ resolved.oauthClientId = existing?.oauthClientId ?? profile.oauthClientId;
10653
+ const updated = writeContextCredential(config, environment, resolved, true);
10654
+ await writeConfig(updated);
10655
+ emit(
10656
+ { environment, active: key, context: { type: resolved.contextType, label: labelFor(resolved) } },
10657
+ mode,
10658
+ () => `
10659
+ \u2713 Now acting as ${labelFor(resolved)} (${resolved.contextType}).`
10660
+ );
10661
+ } catch (err) {
10662
+ fail(err, mode);
10663
+ }
10664
+ });
10665
+ }
10666
+
9903
10667
  // src/index.ts
9904
10668
  var program = new Command();
9905
10669
  var argv;
@@ -9929,6 +10693,7 @@ registerPlatformCommand(program);
9929
10693
  registerServicesCommand(program);
9930
10694
  registerSeedCommands(program);
9931
10695
  registerSkillsCommand(program);
10696
+ registerContextCommand(program);
9932
10697
  program.parseAsync(argv).catch((err) => {
9933
10698
  process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
9934
10699
  `);