@topolo/cli 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -4
  2. package/dist/index.js +474 -98
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -9,6 +9,7 @@ via `stdin`/`stdout`.
9
9
  - API-key authentication (long-lived, service-scoped).
10
10
  - Access-token authentication via OAuth device flow when a CLI OAuth client id is configured.
11
11
  - Stored access-token refresh when the config includes a refresh token and OAuth client id.
12
+ - First-class `production` and `staging` CLI environments with separate stored credentials, OAuth client ids, and service URL overrides.
12
13
  - Commands: `auth login/logout/status`, `whoami`, `apps`, `apps scaffold`, `services`, `crm contacts:list`, `crm contacts:get`, and generic `api` passthrough for SDK-registered services.
13
14
 
14
15
  ## Install
@@ -23,9 +24,19 @@ npx @topolo/cli whoami
23
24
 
24
25
  Credentials come from (in priority order):
25
26
 
26
- 1. `TOPOLO_API_KEY` env var
27
- 2. `TOPOLO_ACCESS_TOKEN` env var
28
- 3. Config file (`$XDG_CONFIG_HOME/topolo/config.json`, mode `0600`)
27
+ 1. Environment-specific vars such as `TOPOLO_STAGING_API_KEY` or `TOPOLO_PRODUCTION_ACCESS_TOKEN`
28
+ 2. Global vars `TOPOLO_API_KEY` or `TOPOLO_ACCESS_TOKEN`
29
+ 3. The selected environment profile in the config file (`$XDG_CONFIG_HOME/topolo/config.json`, mode `0600`)
30
+
31
+ Production is the default. Select staging with `--env staging` anywhere in the command, or set `TOPOLO_ENV=staging`:
32
+
33
+ ```bash
34
+ topolo auth status --env staging --json
35
+ topolo auth login --env staging --client-id doac_...
36
+ topolo platform apps scaffold home --env staging --write --json
37
+ ```
38
+
39
+ The staging profile uses `auth.stg.topolo.us`, `developers.stg.topolo.us`, and `seed.stg.topolo.us` by default. Other SDK service calls in staging must have an explicit staging URL override; the CLI refuses to call a production SDK URL while `--env staging` is selected.
29
40
 
30
41
  For interactive use:
31
42
 
@@ -38,7 +49,8 @@ topolo auth logout # clears the stored credential
38
49
 
39
50
  If no client id is configured, `topolo auth login` falls back to an API-key
40
51
  prompt in an interactive terminal. Non-interactive sessions should pass
41
- `--api-key` or set `TOPOLO_CLI_CLIENT_ID`.
52
+ `--api-key` or set `TOPOLO_PRODUCTION_CLI_CLIENT_ID`,
53
+ `TOPOLO_STAGING_CLI_CLIENT_ID`, or the shared `TOPOLO_CLI_CLIENT_ID`.
42
54
 
43
55
  ## Cross-org isolation
44
56
 
@@ -97,9 +109,13 @@ topolo crm contacts:get contact_abc123
97
109
  ### `topolo services`
98
110
 
99
111
  Lists every service id and effective URL known to the generated SDK registry.
112
+ In staging, `environmentSafe: false` means that service still resolves to a
113
+ production SDK default and will be refused by `topolo api` until a staging URL
114
+ override is configured.
100
115
 
101
116
  ```bash
102
117
  topolo services --json
118
+ topolo services --env staging --json
103
119
  ```
104
120
 
105
121
  ### `topolo apps`
package/dist/index.js CHANGED
@@ -75,13 +75,16 @@ var SERVICES = {
75
75
  chat: "https://chat.topolo.app",
76
76
  commerce: "https://commerce-api.topolo.app",
77
77
  compose: "https://compose.topolo.app",
78
+ consent: "https://consent.topolo.app",
78
79
  crm: "https://crm-api.topolo.app",
79
80
  customer_success: "https://success.topolo.app",
81
+ developers: "https://developers.topolo.app",
80
82
  director: "https://director.topolo.app",
81
83
  feed: "https://feed-api.topolo.app",
82
84
  flow: "https://flow.topolo.app",
83
85
  forecast: "https://forecast-api.topolo.app",
84
86
  forms: "https://forms.topolo.app",
87
+ home: "https://home.topolo.app",
85
88
  insights: "https://insights.topolo.app",
86
89
  inventory: "https://inventory.topolo.app",
87
90
  learn: "https://learn-api.topolo.app",
@@ -112,11 +115,14 @@ var PLATFORM_SERVICE_IDS = {
112
115
  books: "srv_topolo_books",
113
116
  bugfix: "srv_topolo_bugfix",
114
117
  compose: "srv_topolo_compose",
118
+ consent: "srv_topolo_consent",
115
119
  crm: "srv_iCwM4jGXcwlj",
116
120
  customer_success: "srv_topolo_success",
121
+ developers: "svc_topolo_developers",
117
122
  director: "srv_topolo_director",
118
123
  flow: "srv_topolo_flow",
119
124
  forms: "srv_topolo_forms",
125
+ home: "srv_topolo_home",
120
126
  insights: "srv_topolo_insights",
121
127
  inventory: "srv_topolo_inventory",
122
128
  localize: "srv_topolo_localize",
@@ -449,6 +455,30 @@ var APPLICATIONS = {
449
455
  }
450
456
  ]
451
457
  },
458
+ "consent": {
459
+ "id": "consent",
460
+ "appDir": "TopoloConsent",
461
+ "name": "topolo-consent",
462
+ "packageName": "topolo-consent",
463
+ "productionUrl": "https://consent.topolo.app",
464
+ "services": [
465
+ "consent"
466
+ ],
467
+ "deployTargets": [
468
+ {
469
+ "name": "topolo-consent",
470
+ "kind": "worker",
471
+ "apiUrl": "https://consent.topolo.app",
472
+ "domains": [
473
+ "consent.topolo.app"
474
+ ],
475
+ "routes": [
476
+ "consent.topolo.app"
477
+ ],
478
+ "serviceId": "consent"
479
+ }
480
+ ]
481
+ },
452
482
  "crm": {
453
483
  "id": "crm",
454
484
  "appDir": "TopoloCRM",
@@ -529,7 +559,9 @@ var APPLICATIONS = {
529
559
  "name": "topolo-developers",
530
560
  "packageName": "topolo-developers",
531
561
  "productionUrl": "https://developers.topolo.app",
532
- "services": [],
562
+ "services": [
563
+ "developers"
564
+ ],
533
565
  "deployTargets": [
534
566
  {
535
567
  "name": "topolo-catalog-cron",
@@ -541,13 +573,13 @@ var APPLICATIONS = {
541
573
  },
542
574
  {
543
575
  "name": "topolo-developers",
544
- "kind": "pages",
545
- "apiUrl": null,
576
+ "kind": "worker",
577
+ "apiUrl": "https://developers.topolo.app",
546
578
  "domains": [
547
579
  "developers.topolo.app"
548
580
  ],
549
581
  "routes": [],
550
- "serviceId": null
582
+ "serviceId": "developers"
551
583
  }
552
584
  ]
553
585
  },
@@ -711,6 +743,30 @@ var APPLICATIONS = {
711
743
  }
712
744
  ]
713
745
  },
746
+ "home": {
747
+ "id": "home",
748
+ "appDir": "TopoloHome",
749
+ "name": "topolo-home",
750
+ "packageName": "topolo-home",
751
+ "productionUrl": "https://home.topolo.app",
752
+ "services": [
753
+ "home"
754
+ ],
755
+ "deployTargets": [
756
+ {
757
+ "name": "topolo-home",
758
+ "kind": "worker",
759
+ "apiUrl": "https://home.topolo.app",
760
+ "domains": [
761
+ "home.topolo.app"
762
+ ],
763
+ "routes": [
764
+ "home.topolo.app"
765
+ ],
766
+ "serviceId": "home"
767
+ }
768
+ ]
769
+ },
714
770
  "insights": {
715
771
  "id": "insights",
716
772
  "appDir": "TopoloInsights",
@@ -2167,6 +2223,16 @@ import { mkdir, readFile, writeFile, chmod } from "fs/promises";
2167
2223
  import { existsSync } from "fs";
2168
2224
  import { homedir, platform } from "os";
2169
2225
  import { dirname, join } from "path";
2226
+ var ENVIRONMENT_DEFAULT_SERVICE_URLS = {
2227
+ production: {
2228
+ developers: "https://developers.topolo.app"
2229
+ },
2230
+ staging: {
2231
+ auth: "https://auth.stg.topolo.us",
2232
+ developers: "https://developers.stg.topolo.us",
2233
+ seed: "https://seed.stg.topolo.us"
2234
+ }
2235
+ };
2170
2236
  function configPath() {
2171
2237
  const override = process.env.TOPOLO_CONFIG_PATH;
2172
2238
  if (override) return override;
@@ -2194,31 +2260,113 @@ async function writeConfig(config) {
2194
2260
  }
2195
2261
  }
2196
2262
  }
2197
- async function resolveCredential() {
2198
- const apiKey = process.env.TOPOLO_API_KEY;
2263
+ function normalizeEnvironment(value) {
2264
+ const normalized = (value ?? "production").trim().toLowerCase();
2265
+ if (normalized === "" || normalized === "production" || normalized === "prod") {
2266
+ return "production";
2267
+ }
2268
+ if (normalized === "staging" || normalized === "stage" || normalized === "stg") {
2269
+ return "staging";
2270
+ }
2271
+ throw new Error(`Unknown Topolo environment "${value}". Expected production or staging.`);
2272
+ }
2273
+ function selectedEnvironment() {
2274
+ return normalizeEnvironment(process.env.TOPOLO_ENV);
2275
+ }
2276
+ function envVarPrefix(environment) {
2277
+ return environment === "production" ? "TOPOLO_PRODUCTION" : "TOPOLO_STAGING";
2278
+ }
2279
+ function environmentLabel(environment = selectedEnvironment()) {
2280
+ return environment === "production" ? "production" : "staging";
2281
+ }
2282
+ function developerConsoleUrl(environment = selectedEnvironment()) {
2283
+ return environment === "production" ? "https://developers.topolo.app" : "https://developers.stg.topolo.us";
2284
+ }
2285
+ function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
2286
+ return `${developerConsoleUrl(environment)}/developers/oauth-clients`;
2287
+ }
2288
+ function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
2289
+ return `${developerConsoleUrl(environment)}/credentials`;
2290
+ }
2291
+ function profileForEnvironment(config, environment = selectedEnvironment()) {
2292
+ const profile = config.profiles?.[environment] ?? {};
2293
+ if (environment === "production") {
2294
+ return {
2295
+ credential: config.credential,
2296
+ oauthClientId: config.oauthClientId,
2297
+ ...profile,
2298
+ serviceUrls: {
2299
+ ...config.serviceUrls ?? {},
2300
+ ...profile.serviceUrls ?? {}
2301
+ },
2302
+ agentName: profile.agentName ?? config.agentName
2303
+ };
2304
+ }
2305
+ return profile;
2306
+ }
2307
+ function writeProfileConfig(config, environment, profile) {
2308
+ const profiles = { ...config.profiles ?? {} };
2309
+ profiles[environment] = {
2310
+ ...profiles[environment] ?? {},
2311
+ ...profile
2312
+ };
2313
+ return { ...config, profiles };
2314
+ }
2315
+ function clearProfileCredential(config, environment) {
2316
+ const next = { ...config };
2317
+ if (environment === "production") {
2318
+ delete next.credential;
2319
+ delete next.oauthClientId;
2320
+ }
2321
+ const profiles = { ...config.profiles ?? {} };
2322
+ const profile = { ...profiles[environment] ?? {} };
2323
+ delete profile.credential;
2324
+ delete profile.oauthClientId;
2325
+ profiles[environment] = profile;
2326
+ return { ...next, profiles };
2327
+ }
2328
+ function resolveOAuthClientId(config, environment = selectedEnvironment()) {
2329
+ const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
2330
+ if (envSpecific) return envSpecific;
2331
+ if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
2332
+ return profileForEnvironment(config, environment).oauthClientId;
2333
+ }
2334
+ function credentialSource(config, environment = selectedEnvironment()) {
2335
+ const prefix = envVarPrefix(environment);
2336
+ if (process.env[`${prefix}_API_KEY`]) return `env:${prefix}_API_KEY`;
2337
+ if (process.env[`${prefix}_ACCESS_TOKEN`]) return `env:${prefix}_ACCESS_TOKEN`;
2338
+ if (process.env.TOPOLO_API_KEY) return "env:TOPOLO_API_KEY";
2339
+ if (process.env.TOPOLO_ACCESS_TOKEN) return "env:TOPOLO_ACCESS_TOKEN";
2340
+ return profileForEnvironment(config, environment).credential ? "config-file" : "none";
2341
+ }
2342
+ async function resolveCredential(environment = selectedEnvironment()) {
2343
+ const prefix = envVarPrefix(environment);
2344
+ const apiKey = process.env[`${prefix}_API_KEY`] ?? process.env.TOPOLO_API_KEY;
2199
2345
  if (apiKey) return { kind: "api_key", apiKey };
2200
- const accessToken = process.env.TOPOLO_ACCESS_TOKEN;
2346
+ const accessToken = process.env[`${prefix}_ACCESS_TOKEN`] ?? process.env.TOPOLO_ACCESS_TOKEN;
2201
2347
  if (accessToken) return { kind: "access_token", accessToken };
2202
2348
  const config = await readConfig();
2203
- return config.credential ?? null;
2349
+ return profileForEnvironment(config, environment).credential ?? null;
2204
2350
  }
2205
2351
  var REFRESH_SKEW_SECONDS = 60;
2206
- async function refreshCredentialIfNeeded() {
2207
- if (process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
2208
- return resolveCredential();
2352
+ async function refreshCredentialIfNeeded(environment = selectedEnvironment()) {
2353
+ const prefix = envVarPrefix(environment);
2354
+ if (process.env[`${prefix}_API_KEY`] || process.env[`${prefix}_ACCESS_TOKEN`] || process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
2355
+ return resolveCredential(environment);
2209
2356
  }
2210
2357
  const config = await readConfig();
2211
- const cred = config.credential;
2358
+ const profile = profileForEnvironment(config, environment);
2359
+ const cred = profile.credential;
2212
2360
  if (!cred || cred.kind !== "access_token") return cred ?? null;
2213
- if (!cred.refreshToken || !cred.expiresAt || !config.oauthClientId) return cred;
2361
+ if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
2214
2362
  const now = Math.floor(Date.now() / 1e3);
2215
2363
  if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
2216
2364
  try {
2217
2365
  const oauth = new TopoloOAuth({
2218
- serviceUrls: resolveServiceUrls(config.serviceUrls)
2366
+ serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
2219
2367
  });
2220
2368
  const tokens = await oauth.refreshToken({
2221
- clientId: config.oauthClientId,
2369
+ clientId: profile.oauthClientId,
2222
2370
  refreshToken: cred.refreshToken
2223
2371
  });
2224
2372
  const refreshed = {
@@ -2227,7 +2375,9 @@ async function refreshCredentialIfNeeded() {
2227
2375
  ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: cred.refreshToken },
2228
2376
  expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
2229
2377
  };
2230
- await writeConfig({ ...config, credential: refreshed });
2378
+ await writeConfig(
2379
+ environment === "production" && !config.profiles?.production ? { ...config, credential: refreshed } : writeProfileConfig(config, environment, { credential: refreshed })
2380
+ );
2231
2381
  return refreshed;
2232
2382
  } catch {
2233
2383
  return cred;
@@ -2236,10 +2386,17 @@ async function refreshCredentialIfNeeded() {
2236
2386
  function resolveAgentName(configAgentName) {
2237
2387
  return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
2238
2388
  }
2239
- function resolveServiceUrls(configUrls) {
2389
+ function resolveServiceUrls(configUrls, environment = selectedEnvironment()) {
2240
2390
  const merged = {
2391
+ ...ENVIRONMENT_DEFAULT_SERVICE_URLS[environment],
2241
2392
  ...configUrls ?? {}
2242
2393
  };
2394
+ const prefix = `${envVarPrefix(environment)}_SERVICE_URL_`;
2395
+ for (const [k, v] of Object.entries(process.env)) {
2396
+ if (k.startsWith(prefix) && v) {
2397
+ merged[k.slice(prefix.length).toLowerCase()] = v;
2398
+ }
2399
+ }
2243
2400
  for (const [k, v] of Object.entries(process.env)) {
2244
2401
  if (k.startsWith("TOPOLO_SERVICE_URL_") && v) {
2245
2402
  merged[k.slice("TOPOLO_SERVICE_URL_".length).toLowerCase()] = v;
@@ -2247,6 +2404,28 @@ function resolveServiceUrls(configUrls) {
2247
2404
  }
2248
2405
  return Object.keys(merged).length > 0 ? merged : void 0;
2249
2406
  }
2407
+ function resolvedProfileConfig(config, environment = selectedEnvironment()) {
2408
+ const profile = profileForEnvironment(config, environment);
2409
+ return {
2410
+ credential: profile.credential,
2411
+ oauthClientId: profile.oauthClientId,
2412
+ agentName: profile.agentName,
2413
+ serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
2414
+ };
2415
+ }
2416
+ function assertServiceUrlForEnvironment(service, serviceUrl, environment = selectedEnvironment()) {
2417
+ if (environment !== "staging") return;
2418
+ if (!serviceUrl) {
2419
+ throw new Error(`No staging URL is configured for Topolo service "${service}".`);
2420
+ }
2421
+ const url = serviceUrl.toLowerCase();
2422
+ if (url.includes(".stg.topolo.us") || url.includes("localhost") || url.includes("127.0.0.1") || url.includes("topolo-staging")) {
2423
+ return;
2424
+ }
2425
+ throw new Error(
2426
+ `Refusing to call ${service} in staging because its effective URL is not a staging endpoint: ${serviceUrl}`
2427
+ );
2428
+ }
2250
2429
 
2251
2430
  // src/output.ts
2252
2431
  function resolveOutputMode(override) {
@@ -2293,38 +2472,38 @@ function normalizeError(err) {
2293
2472
  }
2294
2473
 
2295
2474
  // src/commands/auth.ts
2296
- var API_KEY_ISSUANCE_URL = "https://developers.topolo.app/credentials";
2297
- var OAUTH_CLIENT_REGISTRATION_URL = "https://developers.topolo.app/developers/oauth-clients";
2298
2475
  function registerAuthCommands(program2) {
2299
2476
  const auth = program2.command("auth").description("Manage Topolo credentials");
2300
2477
  auth.command("login").description("Authenticate with Topolo via OAuth device flow (default) or store an API key").option("--api-key <key>", "Store an API key directly, skipping the OAuth flow").option("--client-id <id>", "OAuth client_id (or set TOPOLO_CLI_CLIENT_ID)").option("--scope <scope>", "Scopes to request (space-separated)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(
2301
2478
  async (opts) => {
2302
2479
  const mode = resolveOutputMode(opts.json);
2480
+ const environment = selectedEnvironment();
2303
2481
  try {
2304
2482
  if (opts.apiKey) {
2305
- await storeApiKey(opts.apiKey.trim(), mode);
2483
+ await storeApiKey(opts.apiKey.trim(), environment, mode);
2306
2484
  return;
2307
2485
  }
2308
- const clientId = opts.clientId ?? process.env.TOPOLO_CLI_CLIENT_ID;
2486
+ const config = await readConfig();
2487
+ const clientId = opts.clientId ?? resolveOAuthClientId(config, environment);
2309
2488
  if (!clientId) {
2310
2489
  if (!input.isTTY) {
2311
2490
  throw new Error(
2312
- `No OAuth client_id configured and stdin is not a TTY. Either register an OAuth client at ${OAUTH_CLIENT_REGISTRATION_URL} and export TOPOLO_CLI_CLIENT_ID, or pass --api-key <key> (mint one at ${API_KEY_ISSUANCE_URL}).`
2491
+ `No OAuth client_id configured and stdin is not a TTY. Either register an OAuth client at ${oauthClientRegistrationUrl(environment)} and export TOPOLO_${environment === "staging" ? "STAGING" : "PRODUCTION"}_CLI_CLIENT_ID, or pass --api-key <key> (mint one at ${apiKeyIssuanceUrl(environment)}).`
2313
2492
  );
2314
2493
  }
2315
2494
  process.stderr.write(
2316
- `No OAuth client_id configured. Falling back to API key.
2317
- Visit ${API_KEY_ISSUANCE_URL} to mint one, or configure an OAuth client at ${OAUTH_CLIENT_REGISTRATION_URL} and set TOPOLO_CLI_CLIENT_ID.
2495
+ `No OAuth client_id configured for ${environmentLabel(environment)}. Falling back to API key.
2496
+ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth client at ${oauthClientRegistrationUrl(environment)} and set the environment-specific CLI client id.
2318
2497
  `
2319
2498
  );
2320
2499
  const rl = createInterface({ input, output: process.stderr });
2321
2500
  const apiKey = (await rl.question("Paste API key: ")).trim();
2322
2501
  rl.close();
2323
2502
  if (!apiKey) throw new Error("API key is required");
2324
- await storeApiKey(apiKey, mode);
2503
+ await storeApiKey(apiKey, environment, mode);
2325
2504
  return;
2326
2505
  }
2327
- await runDeviceFlow({ clientId, scope: opts.scope, mode });
2506
+ await runDeviceFlow({ clientId, environment, scope: opts.scope, mode });
2328
2507
  } catch (err) {
2329
2508
  fail(err, mode);
2330
2509
  }
@@ -2332,40 +2511,65 @@ Visit ${API_KEY_ISSUANCE_URL} to mint one, or configure an OAuth client at ${OAU
2332
2511
  );
2333
2512
  auth.command("logout").description("Remove stored credentials from this machine").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
2334
2513
  const mode = resolveOutputMode(opts.json);
2514
+ const environment = selectedEnvironment();
2335
2515
  try {
2336
2516
  const config = await readConfig();
2337
- delete config.credential;
2338
- await writeConfig(config);
2339
- emit({ ok: true }, mode, () => "Logged out.");
2517
+ await writeConfig(clearProfileCredential(config, environment));
2518
+ emit(
2519
+ { ok: true, environment },
2520
+ mode,
2521
+ () => `Logged out of Topolo ${environmentLabel(environment)}.`
2522
+ );
2340
2523
  } catch (err) {
2341
2524
  fail(err, mode);
2342
2525
  }
2343
2526
  });
2344
2527
  auth.command("status").description("Show which credential source is in use (env vs config file)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
2345
2528
  const mode = resolveOutputMode(opts.json);
2529
+ const environment = selectedEnvironment();
2346
2530
  try {
2347
- const source = process.env.TOPOLO_API_KEY ? "env:TOPOLO_API_KEY" : process.env.TOPOLO_ACCESS_TOKEN ? "env:TOPOLO_ACCESS_TOKEN" : (await readConfig()).credential ? "config-file" : "none";
2348
- emit({ source }, mode, () => `Credential source: ${source}`);
2531
+ const config = await readConfig();
2532
+ const source = credentialSource(config, environment);
2533
+ const profile = resolvedProfileConfig(config, environment);
2534
+ emit(
2535
+ {
2536
+ environment,
2537
+ source,
2538
+ authUrl: profile.serviceUrls?.auth ?? null,
2539
+ developersUrl: profile.serviceUrls?.developers ?? null
2540
+ },
2541
+ mode,
2542
+ () => [
2543
+ `Environment: ${environmentLabel(environment)}`,
2544
+ `Credential source: ${source}`,
2545
+ `Auth URL: ${profile.serviceUrls?.auth ?? "(sdk default)"}`,
2546
+ `Developers URL: ${profile.serviceUrls?.developers ?? "(sdk default)"}`
2547
+ ].join("\n")
2548
+ );
2349
2549
  } catch (err) {
2350
2550
  fail(err, mode);
2351
2551
  }
2352
2552
  });
2353
2553
  }
2354
- async function storeApiKey(apiKey, mode) {
2554
+ async function storeApiKey(apiKey, environment, mode) {
2355
2555
  if (!apiKey) throw new Error("API key is required");
2356
2556
  const config = await readConfig();
2357
- config.credential = { kind: "api_key", apiKey };
2358
- await writeConfig(config);
2557
+ await writeConfig(
2558
+ writeProfileConfig(config, environment, {
2559
+ credential: { kind: "api_key", apiKey }
2560
+ })
2561
+ );
2359
2562
  emit(
2360
- { ok: true, credential: { kind: "api_key" } },
2563
+ { ok: true, environment, credential: { kind: "api_key" } },
2361
2564
  mode,
2362
- () => "Stored API key. Run `topolo whoami` to verify."
2565
+ () => `Stored API key for Topolo ${environmentLabel(environment)}. Run \`topolo whoami\` to verify.`
2363
2566
  );
2364
2567
  }
2365
2568
  async function runDeviceFlow(options) {
2366
2569
  const config = await readConfig();
2570
+ const resolved = resolvedProfileConfig(config, options.environment);
2367
2571
  const oauth = new TopoloOAuth({
2368
- serviceUrls: resolveServiceUrls(config.serviceUrls)
2572
+ serviceUrls: resolved.serviceUrls
2369
2573
  });
2370
2574
  const start = await oauth.requestDeviceCode({
2371
2575
  clientId: options.clientId,
@@ -2377,7 +2581,7 @@ async function runDeviceFlow(options) {
2377
2581
  if (options.mode === "pretty") {
2378
2582
  process.stderr.write(
2379
2583
  `
2380
- Sign in to Topolo
2584
+ Sign in to Topolo ${environmentLabel(options.environment)}
2381
2585
  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2382
2586
 
2383
2587
  ` + (opened ? ` Opening your browser to approve...
@@ -2405,17 +2609,21 @@ async function runDeviceFlow(options) {
2405
2609
  spinner?.stop();
2406
2610
  }
2407
2611
  const updated = await readConfig();
2408
- updated.credential = {
2409
- kind: "access_token",
2410
- accessToken: token.access_token,
2411
- ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
2412
- expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
2413
- };
2414
- updated.oauthClientId = options.clientId;
2415
- await writeConfig(updated);
2612
+ await writeConfig(
2613
+ writeProfileConfig(updated, options.environment, {
2614
+ credential: {
2615
+ kind: "access_token",
2616
+ accessToken: token.access_token,
2617
+ ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
2618
+ expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
2619
+ },
2620
+ oauthClientId: options.clientId
2621
+ })
2622
+ );
2416
2623
  emit(
2417
2624
  {
2418
2625
  ok: true,
2626
+ environment: options.environment,
2419
2627
  credential: {
2420
2628
  kind: "access_token",
2421
2629
  expiresIn: token.expires_in,
@@ -2491,22 +2699,23 @@ function sleep(ms) {
2491
2699
  }
2492
2700
 
2493
2701
  // src/version.ts
2494
- var CLI_VERSION = "0.1.15";
2702
+ var CLI_VERSION = "0.1.17";
2495
2703
 
2496
2704
  // src/client.ts
2497
2705
  async function buildClient(options) {
2498
- const credential = await refreshCredentialIfNeeded();
2706
+ const environment = selectedEnvironment();
2707
+ const credential = await refreshCredentialIfNeeded(environment);
2499
2708
  if (!credential) return null;
2500
2709
  const config = await readConfig();
2501
- const serviceUrls = resolveServiceUrls(config.serviceUrls);
2710
+ const profile = resolvedProfileConfig(config, environment);
2502
2711
  return createTopolo({
2503
2712
  credential,
2504
2713
  agent: {
2505
2714
  clientName: "topolo-cli",
2506
2715
  clientVersion: CLI_VERSION,
2507
- agentName: resolveAgentName(config.agentName)
2716
+ agentName: resolveAgentName(profile.agentName)
2508
2717
  },
2509
- serviceUrls,
2718
+ serviceUrls: profile.serviceUrls,
2510
2719
  requireConfirmForWrites: options?.requireConfirmForWrites,
2511
2720
  ...isDebugEnabled() ? { debug: stderrDebugLogger } : {}
2512
2721
  });
@@ -2543,6 +2752,9 @@ function registerWhoamiCommand(program2) {
2543
2752
  program2.command("whoami").description("Show the user, organization, and scopes attached to the current credential").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
2544
2753
  const mode = resolveOutputMode(opts.json);
2545
2754
  try {
2755
+ const environment = selectedEnvironment();
2756
+ const profile = resolvedProfileConfig(await readConfig(), environment);
2757
+ assertServiceUrlForEnvironment("auth", profile.serviceUrls?.auth, environment);
2546
2758
  const topolo = await buildClient();
2547
2759
  if (!topolo) {
2548
2760
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
@@ -2573,6 +2785,7 @@ function registerCrmCommands(program2) {
2573
2785
  crm.command("contacts:list").description("List contacts in the authenticated organization").option("-q, --query <text>", "Full-text search").option("-p, --page <n>", "Page number (1-indexed)", (v) => parseInt(v, 10), 1).option("-s, --page-size <n>", "Page size", (v) => parseInt(v, 10), 25).option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (opts) => {
2574
2786
  const mode = resolveOutputMode(opts.json);
2575
2787
  try {
2788
+ await assertCrmRoutable();
2576
2789
  const topolo = await buildClient();
2577
2790
  if (!topolo) {
2578
2791
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
@@ -2590,6 +2803,7 @@ function registerCrmCommands(program2) {
2590
2803
  crm.command("contacts:get <id>").description("Fetch a single contact by id").option("--json", "Output JSON").option("--no-json", "Output human-readable text").action(async (id, opts) => {
2591
2804
  const mode = resolveOutputMode(opts.json);
2592
2805
  try {
2806
+ await assertCrmRoutable();
2593
2807
  const topolo = await buildClient();
2594
2808
  if (!topolo) {
2595
2809
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
@@ -2602,6 +2816,15 @@ function registerCrmCommands(program2) {
2602
2816
  }
2603
2817
  });
2604
2818
  }
2819
+ async function assertCrmRoutable() {
2820
+ const environment = selectedEnvironment();
2821
+ const profile = resolvedProfileConfig(await readConfig(), environment);
2822
+ assertServiceUrlForEnvironment(
2823
+ "crm",
2824
+ resolveServiceUrl("crm", profile.serviceUrls),
2825
+ environment
2826
+ );
2827
+ }
2605
2828
  function formatContactsTable(rows, total, page) {
2606
2829
  if (rows.length === 0) return "No contacts found.";
2607
2830
  const lines = [];
@@ -2660,6 +2883,14 @@ function registerApiCommand(program2) {
2660
2883
  if (WRITE_METHODS2.has(httpMethod) && !opts.confirm) {
2661
2884
  throw new Error(`Mutating ${httpMethod} requires --confirm.`);
2662
2885
  }
2886
+ const environment = selectedEnvironment();
2887
+ const config = await readConfig();
2888
+ const profile = resolvedProfileConfig(config, environment);
2889
+ assertServiceUrlForEnvironment(
2890
+ service,
2891
+ resolveServiceUrl(service, profile.serviceUrls),
2892
+ environment
2893
+ );
2663
2894
  let body = void 0;
2664
2895
  if (opts.data) {
2665
2896
  try {
@@ -3641,7 +3872,9 @@ import { constants } from "fs";
3641
3872
  import { access, mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
3642
3873
  import { dirname as dirname2, relative, resolve } from "path";
3643
3874
  var CLOUDFLARE_ACCOUNT_ID = "49ef1ba682ad8cfd720c86699ae17521";
3644
- var CURRENT_VERIFICATION_DATE = "2026-05-01";
3875
+ var STAGING_CLOUDFLARE_ACCOUNT_ID = "4f4e1c69a3830946f9fea7b1eb7531ac";
3876
+ var CLOUDFLARE_COMPATIBILITY_DATE = "2026-05-05";
3877
+ var CURRENT_VERIFICATION_DATE = "2026-05-06";
3645
3878
  function applicationScaffoldPayload(application, options = {}) {
3646
3879
  const target = normalizeTarget(options.target);
3647
3880
  const portfolio = target === "platform" ? normalizePortfolio(options.portfolio) : null;
@@ -4148,9 +4381,13 @@ function packageJson(application, target, options) {
4148
4381
  if (target === "platform") {
4149
4382
  dependencies["@topolo-io/auth-middleware"] = topoloPackageVersion("@topolo-io/auth-middleware", target);
4150
4383
  dependencies["@topolo/sdk"] = topoloPackageVersion("@topolo/sdk", target);
4384
+ scripts.deploy = `node -e 'console.error("\\nX Bare deploy is disabled. Use deploy:staging or deploy:production.\\n"); process.exit(1)'`;
4385
+ scripts["deploy:staging"] = "DEPLOY_ENVIRONMENT=staging VITE_TOPOLO_AUTH_URL=https://auth.stg.topolo.us npm run build && wrangler deploy --env staging";
4386
+ scripts["deploy:production"] = "DEPLOY_ENVIRONMENT=production npm run build && wrangler deploy";
4387
+ } else {
4388
+ scripts.deploy = "npm run build && wrangler deploy";
4151
4389
  }
4152
4390
  devDependencies.wrangler = "^4.40.2";
4153
- scripts.deploy = "npm run build && wrangler deploy";
4154
4391
  }
4155
4392
  return `${json({
4156
4393
  name: application.packageName,
@@ -4485,8 +4722,13 @@ function appTsx(application, target, options) {
4485
4722
  function platformAppTsx(application, options) {
4486
4723
  const appName = toTitle(application.name);
4487
4724
  const serviceId = application.serviceId ?? application.id;
4488
- const tagline = options.audience === "personal" ? "Topolo personal app" : "Topolo workspace";
4489
- const workspaceTitle = options.audience === "personal" ? "Personal app" : "Workspace";
4725
+ const isPersonal = options.audience === "personal";
4726
+ const tagline = isPersonal ? "Topolo personal app" : "Topolo workspace";
4727
+ const workspaceTitle = isPersonal ? "Personal app" : "Workspace";
4728
+ const shellLabel = isPersonal ? "personal app shell" : "workspace shell";
4729
+ const signedSurfaceLabel = isPersonal ? "personal app" : "workspace";
4730
+ const baselineHeading = isPersonal ? "Shared personal baseline" : "Shared workspace baseline";
4731
+ const baselineDescription = isPersonal ? "This scaffold starts on the shared landing and login surfaces, completes sign-in on the shared callback path, and renders the signed personal app inside the shared Topolo shell." : "This scaffold starts on the shared landing and login surfaces, completes sign-in on the shared callback path, and renders the signed workspace inside the shared Topolo shell.";
4490
4732
  return [
4491
4733
  "import { Component, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from 'react';",
4492
4734
  "import { createBrowserAuthModule, type AuthUser } from '@topolo-io/auth-client';",
@@ -4512,17 +4754,17 @@ function platformAppTsx(application, options) {
4512
4754
  " app_name: APP_NAME,",
4513
4755
  ` app_tagline: '${escapeSingleQuoted(tagline)}',`,
4514
4756
  " hero_title: `Open ${APP_NAME} in Topolo`,",
4515
- " hero_subtitle: 'Shared landing, login, callback, auth, and workspace shell wiring are already included in the generated baseline.',",
4757
+ ` hero_subtitle: 'Shared landing, login, callback, auth, and ${escapeSingleQuoted(shellLabel)} wiring are already included in the generated baseline.',`,
4516
4758
  " hero_cta_text: 'Sign in',",
4517
4759
  " hero_cta_url: '/login',",
4518
4760
  " cta_title: 'Start from the shared platform entry flow',",
4519
- " cta_subtitle: 'Unauthenticated routes stay on the shared Topolo surfaces before users enter the signed workspace shell.',",
4761
+ ` cta_subtitle: 'Unauthenticated routes stay on the shared Topolo surfaces before users enter the signed ${escapeSingleQuoted(shellLabel)}.',`,
4520
4762
  "};",
4521
4763
  "const LOGIN_CONFIG = {",
4522
4764
  " app_name: APP_NAME,",
4523
4765
  ` app_tagline: '${escapeSingleQuoted(tagline)}',`,
4524
4766
  " hero_title: `Sign in to ${APP_NAME}`,",
4525
- " hero_subtitle: 'Use Topolo Auth to open the shared workspace shell for this application.',",
4767
+ ` hero_subtitle: 'Use Topolo Auth to open the shared ${escapeSingleQuoted(shellLabel)} for this application.',`,
4526
4768
  " authenticated_home_path: '/',",
4527
4769
  "};",
4528
4770
  "",
@@ -4542,6 +4784,7 @@ function platformAppTsx(application, options) {
4542
4784
  "function toShellUser(user: AuthUser | null): TopoloAppShellUser | null {",
4543
4785
  " if (!user) return null;",
4544
4786
  " return {",
4787
+ " id: user.id,",
4545
4788
  " name: user.name ?? user.email,",
4546
4789
  " email: user.email,",
4547
4790
  " role: user.role,",
@@ -4690,7 +4933,7 @@ function platformAppTsx(application, options) {
4690
4933
  " appId={APP_ID}",
4691
4934
  " serviceId={SERVICE_ID}",
4692
4935
  " appName={APP_NAME}",
4693
- ' subtitle="Opening your workspace..."',
4936
+ ` subtitle="Opening your ${escapeDoubleQuoted(signedSurfaceLabel)}..."`,
4694
4937
  " />",
4695
4938
  " );",
4696
4939
  " }",
@@ -4701,6 +4944,9 @@ function platformAppTsx(application, options) {
4701
4944
  " serviceId={SERVICE_ID}",
4702
4945
  " appName={APP_NAME}",
4703
4946
  " user={shellUser}",
4947
+ " orgId={user.activeContextType === 'organization' ? user.activeContextId ?? user.orgId ?? null : null}",
4948
+ " activeContextType={user.activeContextType}",
4949
+ " activeContextId={user.activeContextId}",
4704
4950
  ' activeNavItemId="home"',
4705
4951
  ` navItems={[{ id: 'home', label: '${escapeSingleQuoted(workspaceTitle)}' }]}`,
4706
4952
  ' navigationMode="topbar"',
@@ -4719,9 +4965,9 @@ function platformAppTsx(application, options) {
4719
4965
  " >",
4720
4966
  ' <div className="topolo-starter-workspace">',
4721
4967
  ' <section className="topolo-starter-card">',
4722
- " <h2>Shared workspace baseline</h2>",
4968
+ ` <h2>${baselineHeading}</h2>`,
4723
4969
  " <p>",
4724
- " This scaffold starts on the shared landing and login surfaces, completes sign-in on the shared callback path, and renders the signed workspace inside the shared Topolo shell.",
4970
+ ` ${baselineDescription}`,
4725
4971
  " </p>",
4726
4972
  " </section>",
4727
4973
  " </div>",
@@ -4913,7 +5159,7 @@ function wranglerToml(application, target) {
4913
5159
  const lines = [
4914
5160
  `name = "${workerName}"`,
4915
5161
  'main = "src/worker.ts"',
4916
- `compatibility_date = "${CURRENT_VERIFICATION_DATE}"`,
5162
+ `compatibility_date = "${CLOUDFLARE_COMPATIBILITY_DATE}"`,
4917
5163
  'compatibility_flags = ["nodejs_compat"]'
4918
5164
  ];
4919
5165
  if (target === "platform") {
@@ -4948,15 +5194,51 @@ function wranglerToml(application, target) {
4948
5194
  `SERVICE_ID = "${application.serviceId ?? application.id}"`,
4949
5195
  ""
4950
5196
  );
5197
+ if (target === "platform") {
5198
+ lines.push(
5199
+ "# Staging deploys use the separate Topolo Staging Cloudflare account.",
5200
+ "[env.staging]",
5201
+ `name = "${workerName}-staging"`,
5202
+ `account_id = "${STAGING_CLOUDFLARE_ACCOUNT_ID}"`,
5203
+ "workers_dev = true",
5204
+ "routes = [",
5205
+ ` { pattern = "${slug}.stg.topolo.us", custom_domain = true }`,
5206
+ "]",
5207
+ "",
5208
+ "[env.staging.vars]",
5209
+ 'AUTH_BASE_URL = "https://auth.stg.topolo.us"',
5210
+ `SERVICE_ID = "${application.serviceId ?? application.id}"`,
5211
+ ""
5212
+ );
5213
+ }
4951
5214
  return lines.join("\n");
4952
5215
  }
4953
- function workerTs(application, target, _options) {
5216
+ function workerTs(application, target, options) {
4954
5217
  if (target === "developer") return developerWorkerTs(application);
4955
- return platformWorkerTs(application);
5218
+ return platformWorkerTs(application, options);
4956
5219
  }
4957
- function platformWorkerTs(application) {
5220
+ function platformWorkerTs(application, options) {
4958
5221
  const serviceId = application.serviceId ?? application.id;
4959
5222
  const serviceName = toTitle(application.name);
5223
+ const openActionLabel = options.audience === "personal" ? "Open app" : "Open workspace";
5224
+ const bootstrapBody = options.tenancy === "household" ? [
5225
+ " const userContext = auth.user && typeof auth.user === 'object' ? (auth.user as unknown as Record<string, unknown>) : {};",
5226
+ " return json({",
5227
+ " user: auth.user,",
5228
+ " activeContextType: userContext.activeContextType ?? null,",
5229
+ " activeContextId: userContext.activeContextId ?? null,",
5230
+ " activeContext: userContext.activeContext ?? null,",
5231
+ " selectedHouseholdId: userContext.selectedHouseholdId ?? null,",
5232
+ " selectedHousehold: userContext.selectedHousehold ?? null,",
5233
+ " household: userContext.selectedHousehold ?? null,",
5234
+ " householdMemberships: userContext.householdMemberships ?? [],",
5235
+ " }, { status: 200 }, request);"
5236
+ ] : [
5237
+ " return json({",
5238
+ " user: auth.user,",
5239
+ " organization: auth.organization,",
5240
+ " }, { status: 200 }, request);"
5241
+ ];
4960
5242
  return [
4961
5243
  "import { createTopoloWidgetResponse } from '@topolo/sdk';",
4962
5244
  "import { verifyRequest } from '@topolo-io/auth-middleware';",
@@ -4995,7 +5277,7 @@ function platformWorkerTs(application) {
4995
5277
  " {",
4996
5278
  " type: 'quick-actions',",
4997
5279
  " actions: [",
4998
- " { id: 'open-workspace', label: 'Open workspace', link: '/', primary: true },",
5280
+ ` { id: 'open-workspace', label: '${escapeSingleQuoted(openActionLabel)}', link: '/', primary: true },`,
4999
5281
  " { id: 'view-health', label: 'View health', link: '/api/health' },",
5000
5282
  " ],",
5001
5283
  " },",
@@ -5004,10 +5286,7 @@ function platformWorkerTs(application) {
5004
5286
  " }",
5005
5287
  "",
5006
5288
  " if (url.pathname === '/api/bootstrap') {",
5007
- " return json({",
5008
- " user: auth.user,",
5009
- " organization: auth.organization,",
5010
- " }, { status: 200 }, request);",
5289
+ ...bootstrapBody,
5011
5290
  " }",
5012
5291
  "",
5013
5292
  " return json({ error: 'not_found' }, { status: 404 }, request);",
@@ -5102,6 +5381,7 @@ function developerWorkerTs(application) {
5102
5381
  function cloudcontrolJson(application, options) {
5103
5382
  const slug = toSlug(application.id);
5104
5383
  const host = application.productionUrl ? new URL(application.productionUrl).host : `${slug}.topolo.app`;
5384
+ const stagingHost = `${slug}.stg.topolo.us`;
5105
5385
  const deployTargets = options.api ? [
5106
5386
  {
5107
5387
  kind: "worker",
@@ -5117,6 +5397,21 @@ function cloudcontrolJson(application, options) {
5117
5397
  routes: [host],
5118
5398
  api_url: application.productionUrl,
5119
5399
  service_id: application.serviceId
5400
+ },
5401
+ {
5402
+ kind: "worker",
5403
+ environment: "staging",
5404
+ target_name: `topolo-${slug}-staging`,
5405
+ cloudflare_account_id: STAGING_CLOUDFLARE_ACCOUNT_ID,
5406
+ config_path: "wrangler.toml",
5407
+ build_command: "VITE_TOPOLO_AUTH_URL=https://auth.stg.topolo.us npm run build",
5408
+ output_dir: "dist",
5409
+ production_branch: "staging",
5410
+ healthcheck_path: "/api/health",
5411
+ domains: [stagingHost],
5412
+ routes: [stagingHost],
5413
+ api_url: `https://${stagingHost}`,
5414
+ service_id: application.serviceId
5120
5415
  }
5121
5416
  ] : [
5122
5417
  {
@@ -5152,6 +5447,12 @@ function cloudcontrolJson(application, options) {
5152
5447
  url: application.productionUrl,
5153
5448
  branch: "main",
5154
5449
  status: "unknown"
5450
+ },
5451
+ {
5452
+ name: "staging",
5453
+ url: `https://${stagingHost}`,
5454
+ branch: "staging",
5455
+ status: "unknown"
5155
5456
  }
5156
5457
  ] : [],
5157
5458
  deploy_targets: deployTargets,
@@ -5175,6 +5476,9 @@ function systemJson(application, options) {
5175
5476
  const slug = toSlug(application.id);
5176
5477
  const suiteLabel = options.audience === "personal" ? "personal" : "platform";
5177
5478
  const summaryLabel = options.audience === "personal" ? "first-party Topolo personal application" : "first-party Topolo platform application";
5479
+ const apiNotes = options.api ? options.tenancy === "household" ? "Protected Worker routes validate Topolo Auth credentials and derive active personal context plus selected household context from the credential. The browser baseline starts on shared landing and login routes, completes auth on /auth/callback, and dedupes React when linked Topolo packages are installed locally." : "Protected Worker routes validate Topolo Auth credentials and derive organization context from the credential. The browser baseline starts on shared landing and login routes, completes auth on /auth/callback, and dedupes React when linked Topolo packages are installed locally." : "Browser-only application surface using shared Topolo Auth landing/login/callback routes, shared shell packages, and React runtime dedupe.";
5480
+ const sensitiveData = options.tenancy === "household" ? ["identity", "personal_data", "household_data"] : ["identity", "org_data"];
5481
+ const privacySensitiveData = options.tenancy === "household" ? ["identity", "personal", "household"] : ["identity", "organization"];
5178
5482
  return `${json({
5179
5483
  id: `topolo-${slug}`,
5180
5484
  name: toTitle(application.name),
@@ -5190,7 +5494,7 @@ function systemJson(application, options) {
5190
5494
  api_contract: {
5191
5495
  type: options.api ? "curated" : "none",
5192
5496
  source: options.api ? `PlatformApplications/${application.appDir}/src/worker.ts` : `PlatformApplications/${application.appDir}/src/App.tsx`,
5193
- notes: options.api ? "Protected Worker routes validate Topolo Auth credentials and derive organization context from the credential. The browser baseline starts on shared landing and login routes, completes auth on /auth/callback, and dedupes React when linked Topolo packages are installed locally." : "Browser-only application surface using shared Topolo Auth landing/login/callback routes, shared shell packages, and React runtime dedupe."
5497
+ notes: apiNotes
5194
5498
  },
5195
5499
  primary_hosts: application.productionUrl ? [application.productionUrl] : [],
5196
5500
  doc_paths: [`internal/apps/${slug}`, `public/applications/${slug}`],
@@ -5199,12 +5503,26 @@ function systemJson(application, options) {
5199
5503
  auth_boundary: "Topolo Auth through @topolo-io/auth-client for browser sessions and @topolo-io/auth-middleware for Worker requests.",
5200
5504
  tenant_isolation: options.tenancy === "household" ? "household_scoped" : "organization_scoped",
5201
5505
  external_inputs: ["browser", ...options.api ? ["api"] : []],
5202
- sensitive_data: ["identity", "org_data"],
5506
+ sensitive_data: sensitiveData,
5203
5507
  security_review_status: "not_started",
5204
5508
  last_security_review: CURRENT_VERIFICATION_DATE,
5205
5509
  pentest_status: "not_started",
5206
5510
  evidence_doc: `internal/apps/${slug}`
5207
5511
  },
5512
+ data_privacy: {
5513
+ classification: "confidential",
5514
+ sensitive_data_classes: privacySensitiveData,
5515
+ storage_locations: ["source_control"],
5516
+ encryption_at_rest: "platform_managed",
5517
+ encryption_in_transit: "https_only",
5518
+ key_management: "cloudflare_managed",
5519
+ retention_policy: "needs_policy",
5520
+ deletion_export_status: "planned",
5521
+ logging_redaction_status: "partial",
5522
+ privacy_review_status: "not_started",
5523
+ enterprise_ready: false,
5524
+ evidence_doc: `internal/apps/${slug}`
5525
+ },
5208
5526
  dependencies: options.audience === "personal" ? ["topolo-auth"] : ["topolo-auth", "topolo-platform"],
5209
5527
  tags: ["application", "cloudflare", "typescript", suiteLabel]
5210
5528
  })}
@@ -5215,6 +5533,8 @@ function internalDocs(application, options) {
5215
5533
  const title = toTitle(application.name);
5216
5534
  const suiteLabel = options.audience === "personal" ? "personal" : "platform";
5217
5535
  const tenantLabel = options.tenancy === "household" ? "household" : "organization";
5536
+ const apiContextLabel = options.tenancy === "household" ? "active personal context and selected household context" : "organization context";
5537
+ const boundaryFailureText = options.tenancy === "household" ? "Domain data is added without a household-scoped backend boundary." : "Domain data is added without an organization-scoped backend boundary.";
5218
5538
  return [
5219
5539
  "---",
5220
5540
  `title: "${escapeFrontmatter(title)} Handbook"`,
@@ -5257,11 +5577,11 @@ function internalDocs(application, options) {
5257
5577
  "",
5258
5578
  "## API Reference",
5259
5579
  "",
5260
- options.api ? "The generated API baseline exposes `/api/health`, `/api/widget`, and `/api/bootstrap`. `/api/widget` returns the SDK widget response contract for TopoloOne, while `/api/bootstrap` requires Topolo Auth validation and returns the verified user plus organization context." : "This generated baseline is browser-only and does not expose a callable API service.",
5580
+ options.api ? `The generated API baseline exposes \`/api/health\`, \`/api/widget\`, and \`/api/bootstrap\`. \`/api/widget\` returns the SDK widget response contract for TopoloOne, while \`/api/bootstrap\` requires Topolo Auth validation and returns the verified user plus ${apiContextLabel}.` : "This generated baseline is browser-only and does not expose a callable API service.",
5261
5581
  "",
5262
5582
  "## Auth and Permissions",
5263
5583
  "",
5264
- options.api ? `Browser sessions and Worker requests must be validated through Topolo Auth under service id \`${application.serviceId}\`. Worker routes derive organization context from the verified credential.` : "Browser sessions must be established through Topolo Auth and rendered inside the shared Topolo shell.",
5584
+ options.api ? `Browser sessions and Worker requests must be validated through Topolo Auth under service id \`${application.serviceId}\`. Worker routes derive ${apiContextLabel} from the verified credential.` : "Browser sessions must be established through Topolo Auth and rendered inside the shared Topolo shell.",
5265
5585
  "",
5266
5586
  "## Data Ownership",
5267
5587
  "",
@@ -5269,14 +5589,14 @@ function internalDocs(application, options) {
5269
5589
  "",
5270
5590
  "## Deployments",
5271
5591
  "",
5272
- application.productionUrl ? `The planned production host is \`${application.productionUrl}\`. Deployment metadata starts in \`topolo.cloudcontrol.json\` and must stay aligned with CloudControl before live release.` : "No production host has been assigned yet. Deployment metadata must be completed before live release.",
5592
+ application.productionUrl ? `The planned production host is \`${application.productionUrl}\`. Staging mirrors the scaffold at \`https://${slug}.stg.topolo.us\` through the \`wrangler.toml\` staging environment and Topolo Staging Cloudflare account. Deployment metadata starts in \`topolo.cloudcontrol.json\` and must stay aligned with CloudControl before live release.` : "No production host has been assigned yet. Deployment metadata must be completed before live release.",
5273
5593
  "",
5274
5594
  "## Failure Modes",
5275
5595
  "",
5276
5596
  "- Auth service registration drifts from the generated Worker `SERVICE_ID`.",
5277
5597
  "- CloudControl metadata does not match the eventual Cloudflare project or route.",
5278
5598
  "- linked Topolo packages resolve a second React runtime and the browser shell renders blank instead of mounting the shared surfaces.",
5279
- "- Domain data is added without an organization-scoped backend boundary.",
5599
+ `- ${boundaryFailureText}`,
5280
5600
  "",
5281
5601
  "## Debugging",
5282
5602
  "",
@@ -5299,6 +5619,8 @@ function publicDocs(application, options) {
5299
5619
  const title = toTitle(application.name);
5300
5620
  const suiteLabel = options.audience === "personal" ? "personal" : "business";
5301
5621
  const tenantLabel = options.tenancy === "household" ? "household" : "organization";
5622
+ const apiRouteLabel = options.tenancy === "household" ? "authenticated personal and household routes" : "authenticated workspace routes";
5623
+ const dataModelFailure = options.tenancy === "household" ? "household-scoped data models are not implemented until domain work begins" : "organization-scoped data models are not implemented until domain work begins";
5302
5624
  return [
5303
5625
  "---",
5304
5626
  `title: "${escapeFrontmatter(title)}"`,
@@ -5334,7 +5656,7 @@ function publicDocs(application, options) {
5334
5656
  "",
5335
5657
  "## API Reference",
5336
5658
  "",
5337
- options.api ? "The generated API baseline exposes authenticated workspace routes under `/api/*` and validates requests through Topolo Auth." : "This generated baseline is browser-only and does not expose a callable API service.",
5659
+ options.api ? `The generated API baseline exposes ${apiRouteLabel} under \`/api/*\` and validates requests through Topolo Auth.` : "This generated baseline is browser-only and does not expose a callable API service.",
5338
5660
  "",
5339
5661
  "## Auth and Permissions",
5340
5662
  "",
@@ -5346,12 +5668,12 @@ function publicDocs(application, options) {
5346
5668
  "",
5347
5669
  "## Deployments",
5348
5670
  "",
5349
- application.productionUrl ? `The planned production host is \`${application.productionUrl}\`.` : "The production host has not been assigned yet.",
5671
+ application.productionUrl ? `The planned production host is \`${application.productionUrl}\`. Staging mirrors the scaffold at \`https://${slug}.stg.topolo.us\`.` : "The production host has not been assigned yet.",
5350
5672
  "",
5351
5673
  "## Failure Modes",
5352
5674
  "",
5353
5675
  "- service registration and app routing can drift while the product is still planned",
5354
- "- organization-scoped data models are not implemented until domain work begins",
5676
+ `- ${dataModelFailure}`,
5355
5677
  "",
5356
5678
  "## Debugging",
5357
5679
  "",
@@ -5360,7 +5682,7 @@ function publicDocs(application, options) {
5360
5682
  "",
5361
5683
  "## Change Log / Verification",
5362
5684
  "",
5363
- `- Scaffold generated on ${CURRENT_VERIFICATION_DATE} from \`topolo apps scaffold\`.`,
5685
+ `- Scaffold generated on ${CURRENT_VERIFICATION_DATE} from \`${options.portfolio ? "topolo platform apps scaffold" : "topolo apps scaffold"}\`.`,
5364
5686
  ""
5365
5687
  ].join("\n");
5366
5688
  }
@@ -5369,8 +5691,17 @@ function serviceManifestJson(application, options) {
5369
5691
  const title = toTitle(application.name);
5370
5692
  const permissionPrefix = application.id.replace(/[^a-z0-9_]/g, "_");
5371
5693
  const resourceLabel = options.tenancy === "household" ? "household" : "workspace";
5694
+ const launcherSettings = options.tenancy === "household" ? {
5695
+ settings: {
5696
+ launcher: {
5697
+ supported_contexts: ["personal"],
5698
+ household_capable: true
5699
+ }
5700
+ }
5701
+ } : {};
5372
5702
  return `${json({
5373
5703
  serviceId: application.serviceId,
5704
+ ...launcherSettings,
5374
5705
  systems: [
5375
5706
  {
5376
5707
  systemId: `topolo-${slug}`,
@@ -5569,7 +5900,6 @@ function escapeFrontmatter(value) {
5569
5900
  }
5570
5901
 
5571
5902
  // src/commands/app-scaffold-provision.ts
5572
- var DEFAULT_DEVELOPERS_ORIGIN = "https://developers.topolo.app";
5573
5903
  var DEVELOPERS_SERVICE_ID = "svc_topolo_developers";
5574
5904
  async function provisionApplicationScaffold(payload) {
5575
5905
  if (!payload.application.productionUrl) {
@@ -5582,7 +5912,8 @@ async function provisionApplicationScaffold(payload) {
5582
5912
  "Platform scaffold provisioning requires a service id. Re-run without --no-api or pass --service."
5583
5913
  );
5584
5914
  }
5585
- const credential = await refreshCredentialIfNeeded();
5915
+ const environment = selectedEnvironment();
5916
+ const credential = await refreshCredentialIfNeeded(environment);
5586
5917
  if (!credential) {
5587
5918
  throw new Error(
5588
5919
  "topolo apps scaffold --provision requires an authenticated Topolo session. Run `topolo auth login` first."
@@ -5596,15 +5927,16 @@ async function provisionApplicationScaffold(payload) {
5596
5927
  }
5597
5928
  const identity = await client.client.introspect();
5598
5929
  const config = await readConfig();
5599
- const serviceUrls = resolveServiceUrls(config.serviceUrls) ?? {};
5600
- const developersOrigin = sanitizeOrigin(serviceUrls.developers ?? DEFAULT_DEVELOPERS_ORIGIN);
5930
+ const profile = resolvedProfileConfig(config, environment);
5931
+ const developersOrigin = sanitizeOrigin(profile.serviceUrls?.developers ?? "https://developers.topolo.app");
5932
+ assertServiceUrlForEnvironment("developers", developersOrigin, environment);
5601
5933
  const endpoint = payload.target === "platform" ? "/api/developer-console/admin/first-party-app-scaffolds" : "/api/developer-console/apps/scaffold";
5602
5934
  const headers = new Headers({
5603
5935
  "Content-Type": "application/json",
5604
5936
  "X-Service-ID": DEVELOPERS_SERVICE_ID,
5605
5937
  "X-Topolo-Client": `topolo-cli/${CLI_VERSION}`
5606
5938
  });
5607
- const agentName = resolveAgentName(config.agentName);
5939
+ const agentName = resolveAgentName(profile.agentName);
5608
5940
  if (agentName) {
5609
5941
  headers.set("X-Topolo-Agent", agentName);
5610
5942
  }
@@ -6361,39 +6693,48 @@ function registerServicesCommand(program2) {
6361
6693
  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) => {
6362
6694
  const mode = resolveOutputMode(opts.json);
6363
6695
  try {
6696
+ const environment = selectedEnvironment();
6364
6697
  const config = await readConfig();
6365
- const rows = buildRows(resolveServiceUrls(config.serviceUrls));
6366
- emit({ services: rows }, mode, () => formatRows2(rows));
6698
+ const rows = buildRows(config.serviceUrls, environment);
6699
+ emit({ environment, services: rows }, mode, () => formatRows2(rows, environment));
6367
6700
  } catch (err) {
6368
6701
  fail(err, mode);
6369
6702
  }
6370
6703
  });
6371
6704
  }
6372
- function buildRows(overrides) {
6705
+ function buildRows(overrides, environment = selectedEnvironment()) {
6706
+ const resolvedOverrides = resolveServiceUrls(overrides, environment);
6373
6707
  return Object.keys(DEFAULT_SERVICE_URLS).sort().map((id) => {
6374
6708
  const defaultUrl = DEFAULT_SERVICE_URLS[id];
6375
- const effectiveUrl = resolveServiceUrl(id, overrides);
6709
+ const effectiveUrl = resolveServiceUrl(id, resolvedOverrides);
6376
6710
  return {
6377
6711
  id,
6378
6712
  defaultUrl,
6379
6713
  effectiveUrl,
6380
- overridden: defaultUrl !== effectiveUrl
6714
+ overridden: defaultUrl !== effectiveUrl,
6715
+ environmentSafe: environment !== "staging" || isStagingUrl(effectiveUrl)
6381
6716
  };
6382
6717
  });
6383
6718
  }
6384
- function formatRows2(rows) {
6719
+ function formatRows2(rows, environment = selectedEnvironment()) {
6385
6720
  const idWidth = Math.max(3, ...rows.map((r) => r.id.length));
6386
6721
  const urlWidth = Math.max(7, ...rows.map((r) => r.effectiveUrl.length));
6387
6722
  const lines = [];
6388
- lines.push(`${"ID".padEnd(idWidth)} ${"URL".padEnd(urlWidth)} OVERRIDE`);
6389
- lines.push(`${"-".repeat(idWidth)} ${"-".repeat(urlWidth)} --------`);
6723
+ lines.push(`Environment: ${environment}`);
6724
+ lines.push("");
6725
+ lines.push(`${"ID".padEnd(idWidth)} ${"URL".padEnd(urlWidth)} OVERRIDE READY`);
6726
+ lines.push(`${"-".repeat(idWidth)} ${"-".repeat(urlWidth)} -------- -----`);
6390
6727
  for (const r of rows) {
6391
6728
  lines.push(
6392
- `${r.id.padEnd(idWidth)} ${r.effectiveUrl.padEnd(urlWidth)} ${r.overridden ? "yes" : ""}`
6729
+ `${r.id.padEnd(idWidth)} ${r.effectiveUrl.padEnd(urlWidth)} ${r.overridden ? "yes" : "".padEnd(8)} ${r.environmentSafe ? "yes" : "no"}`
6393
6730
  );
6394
6731
  }
6395
6732
  return lines.join("\n");
6396
6733
  }
6734
+ function isStagingUrl(value) {
6735
+ const url = value.toLowerCase();
6736
+ return url.includes(".stg.topolo.us") || url.includes("localhost") || url.includes("127.0.0.1") || url.includes("topolo-staging");
6737
+ }
6397
6738
 
6398
6739
  // src/commands/seed.ts
6399
6740
  var DEFAULT_SEED_BASE_URL = "https://seed.stg.topolo.us";
@@ -6401,9 +6742,9 @@ function seedBaseUrl() {
6401
6742
  return process.env.TOPOLO_SEED_URL || DEFAULT_SEED_BASE_URL;
6402
6743
  }
6403
6744
  async function authedFetch(path4, init = {}) {
6404
- const credential = await refreshCredentialIfNeeded();
6745
+ const credential = await refreshCredentialIfNeeded("staging");
6405
6746
  if (!credential) {
6406
- throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
6747
+ throw new Error("No staging credential configured. Run `topolo auth login --env staging` or set TOPOLO_STAGING_API_KEY.");
6407
6748
  }
6408
6749
  const headers = new Headers(init.headers || {});
6409
6750
  if (credential.kind === "api_key") headers.set("X-Api-Key", credential.apiKey);
@@ -6611,9 +6952,22 @@ function formatManifestsTable(manifests) {
6611
6952
 
6612
6953
  // src/index.ts
6613
6954
  var program = new Command();
6955
+ var argv;
6956
+ try {
6957
+ argv = consumeEnvironmentArg(process.argv);
6958
+ } catch (err) {
6959
+ process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
6960
+ `);
6961
+ process.exit(1);
6962
+ }
6614
6963
  program.name("topolo").description(
6615
6964
  "Topolo CLI. Public commands are scoped to the authenticated organization or developer workspace, and first-party Topolo scaffolding lives under the internal `topolo platform` namespace."
6616
- ).version(CLI_VERSION);
6965
+ ).version(CLI_VERSION).option("--env <environment>", "Topolo environment: production or staging");
6966
+ program.hook("preAction", (thisCommand) => {
6967
+ const opts = thisCommand.opts();
6968
+ if (!opts.env) return;
6969
+ process.env.TOPOLO_ENV = normalizeEnvironment(opts.env);
6970
+ });
6617
6971
  registerAuthCommands(program);
6618
6972
  registerWhoamiCommand(program);
6619
6973
  registerCrmCommands(program);
@@ -6622,8 +6976,30 @@ registerAppsCommand(program);
6622
6976
  registerPlatformCommand(program);
6623
6977
  registerServicesCommand(program);
6624
6978
  registerSeedCommands(program);
6625
- program.parseAsync(process.argv).catch((err) => {
6979
+ program.parseAsync(argv).catch((err) => {
6626
6980
  process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
6627
6981
  `);
6628
6982
  process.exit(1);
6629
6983
  });
6984
+ function consumeEnvironmentArg(argv2) {
6985
+ const next = argv2.slice(0, 2);
6986
+ for (let i = 2; i < argv2.length; i += 1) {
6987
+ const arg = argv2[i];
6988
+ if (!arg) continue;
6989
+ if (arg === "--env") {
6990
+ const value = argv2[i + 1];
6991
+ if (!value || value.startsWith("-")) {
6992
+ throw new Error("--env requires production or staging.");
6993
+ }
6994
+ process.env.TOPOLO_ENV = normalizeEnvironment(value);
6995
+ i += 1;
6996
+ continue;
6997
+ }
6998
+ if (arg.startsWith("--env=")) {
6999
+ process.env.TOPOLO_ENV = normalizeEnvironment(arg.slice("--env=".length));
7000
+ continue;
7001
+ }
7002
+ next.push(arg);
7003
+ }
7004
+ return next;
7005
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Command-line interface for Topolo developers, operators, and agents, with internal first-party scaffolding available under the topolo platform namespace.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "commander": "^12.1.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@topolo/sdk": "^0.2.10",
24
+ "@topolo/sdk": "^0.2.11",
25
25
  "@types/node": "^20.12.0",
26
26
  "tsup": "^8.0.0",
27
27
  "typescript": "^5.4.0",