@topolo/cli 0.1.16 → 0.1.17

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 +467 -95
  3. package/package.json +1 -1
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
@@ -9,7 +9,7 @@ import { platform as platform2 } from "os";
9
9
  import { createInterface } from "readline/promises";
10
10
  import { stdin as input } from "process";
11
11
 
12
- // node_modules/@topolo/sdk/dist/index.js
12
+ // ../packages/topolo-sdk/dist/index.js
13
13
  function applyAuthHeaders(headers, credential) {
14
14
  if (credential.kind === "api_key") {
15
15
  headers.set("X-Api-Key", credential.apiKey);
@@ -75,6 +75,7 @@ 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",
80
81
  director: "https://director.topolo.app",
@@ -82,6 +83,7 @@ var SERVICES = {
82
83
  flow: "https://flow.topolo.app",
83
84
  forecast: "https://forecast-api.topolo.app",
84
85
  forms: "https://forms.topolo.app",
86
+ home: "https://home.topolo.app",
85
87
  insights: "https://insights.topolo.app",
86
88
  inventory: "https://inventory.topolo.app",
87
89
  learn: "https://learn-api.topolo.app",
@@ -112,11 +114,13 @@ var PLATFORM_SERVICE_IDS = {
112
114
  books: "srv_topolo_books",
113
115
  bugfix: "srv_topolo_bugfix",
114
116
  compose: "srv_topolo_compose",
117
+ consent: "srv_topolo_consent",
115
118
  crm: "srv_iCwM4jGXcwlj",
116
119
  customer_success: "srv_topolo_success",
117
120
  director: "srv_topolo_director",
118
121
  flow: "srv_topolo_flow",
119
122
  forms: "srv_topolo_forms",
123
+ home: "srv_topolo_home",
120
124
  insights: "srv_topolo_insights",
121
125
  inventory: "srv_topolo_inventory",
122
126
  localize: "srv_topolo_localize",
@@ -449,6 +453,30 @@ var APPLICATIONS = {
449
453
  }
450
454
  ]
451
455
  },
456
+ "consent": {
457
+ "id": "consent",
458
+ "appDir": "TopoloConsent",
459
+ "name": "topolo-consent",
460
+ "packageName": "topolo-consent",
461
+ "productionUrl": "https://consent.topolo.app",
462
+ "services": [
463
+ "consent"
464
+ ],
465
+ "deployTargets": [
466
+ {
467
+ "name": "topolo-consent",
468
+ "kind": "worker",
469
+ "apiUrl": "https://consent.topolo.app",
470
+ "domains": [
471
+ "consent.topolo.app"
472
+ ],
473
+ "routes": [
474
+ "consent.topolo.app"
475
+ ],
476
+ "serviceId": "consent"
477
+ }
478
+ ]
479
+ },
452
480
  "crm": {
453
481
  "id": "crm",
454
482
  "appDir": "TopoloCRM",
@@ -711,6 +739,30 @@ var APPLICATIONS = {
711
739
  }
712
740
  ]
713
741
  },
742
+ "home": {
743
+ "id": "home",
744
+ "appDir": "TopoloHome",
745
+ "name": "topolo-home",
746
+ "packageName": "topolo-home",
747
+ "productionUrl": "https://home.topolo.app",
748
+ "services": [
749
+ "home"
750
+ ],
751
+ "deployTargets": [
752
+ {
753
+ "name": "topolo-home",
754
+ "kind": "worker",
755
+ "apiUrl": "https://home.topolo.app",
756
+ "domains": [
757
+ "home.topolo.app"
758
+ ],
759
+ "routes": [
760
+ "home.topolo.app"
761
+ ],
762
+ "serviceId": "home"
763
+ }
764
+ ]
765
+ },
714
766
  "insights": {
715
767
  "id": "insights",
716
768
  "appDir": "TopoloInsights",
@@ -2167,6 +2219,16 @@ import { mkdir, readFile, writeFile, chmod } from "fs/promises";
2167
2219
  import { existsSync } from "fs";
2168
2220
  import { homedir, platform } from "os";
2169
2221
  import { dirname, join } from "path";
2222
+ var ENVIRONMENT_DEFAULT_SERVICE_URLS = {
2223
+ production: {
2224
+ developers: "https://developers.topolo.app"
2225
+ },
2226
+ staging: {
2227
+ auth: "https://auth.stg.topolo.us",
2228
+ developers: "https://developers.stg.topolo.us",
2229
+ seed: "https://seed.stg.topolo.us"
2230
+ }
2231
+ };
2170
2232
  function configPath() {
2171
2233
  const override = process.env.TOPOLO_CONFIG_PATH;
2172
2234
  if (override) return override;
@@ -2194,31 +2256,113 @@ async function writeConfig(config) {
2194
2256
  }
2195
2257
  }
2196
2258
  }
2197
- async function resolveCredential() {
2198
- const apiKey = process.env.TOPOLO_API_KEY;
2259
+ function normalizeEnvironment(value) {
2260
+ const normalized = (value ?? "production").trim().toLowerCase();
2261
+ if (normalized === "" || normalized === "production" || normalized === "prod") {
2262
+ return "production";
2263
+ }
2264
+ if (normalized === "staging" || normalized === "stage" || normalized === "stg") {
2265
+ return "staging";
2266
+ }
2267
+ throw new Error(`Unknown Topolo environment "${value}". Expected production or staging.`);
2268
+ }
2269
+ function selectedEnvironment() {
2270
+ return normalizeEnvironment(process.env.TOPOLO_ENV);
2271
+ }
2272
+ function envVarPrefix(environment) {
2273
+ return environment === "production" ? "TOPOLO_PRODUCTION" : "TOPOLO_STAGING";
2274
+ }
2275
+ function environmentLabel(environment = selectedEnvironment()) {
2276
+ return environment === "production" ? "production" : "staging";
2277
+ }
2278
+ function developerConsoleUrl(environment = selectedEnvironment()) {
2279
+ return environment === "production" ? "https://developers.topolo.app" : "https://developers.stg.topolo.us";
2280
+ }
2281
+ function oauthClientRegistrationUrl(environment = selectedEnvironment()) {
2282
+ return `${developerConsoleUrl(environment)}/developers/oauth-clients`;
2283
+ }
2284
+ function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
2285
+ return `${developerConsoleUrl(environment)}/credentials`;
2286
+ }
2287
+ function profileForEnvironment(config, environment = selectedEnvironment()) {
2288
+ const profile = config.profiles?.[environment] ?? {};
2289
+ if (environment === "production") {
2290
+ return {
2291
+ credential: config.credential,
2292
+ oauthClientId: config.oauthClientId,
2293
+ ...profile,
2294
+ serviceUrls: {
2295
+ ...config.serviceUrls ?? {},
2296
+ ...profile.serviceUrls ?? {}
2297
+ },
2298
+ agentName: profile.agentName ?? config.agentName
2299
+ };
2300
+ }
2301
+ return profile;
2302
+ }
2303
+ function writeProfileConfig(config, environment, profile) {
2304
+ const profiles = { ...config.profiles ?? {} };
2305
+ profiles[environment] = {
2306
+ ...profiles[environment] ?? {},
2307
+ ...profile
2308
+ };
2309
+ return { ...config, profiles };
2310
+ }
2311
+ function clearProfileCredential(config, environment) {
2312
+ const next = { ...config };
2313
+ if (environment === "production") {
2314
+ delete next.credential;
2315
+ delete next.oauthClientId;
2316
+ }
2317
+ const profiles = { ...config.profiles ?? {} };
2318
+ const profile = { ...profiles[environment] ?? {} };
2319
+ delete profile.credential;
2320
+ delete profile.oauthClientId;
2321
+ profiles[environment] = profile;
2322
+ return { ...next, profiles };
2323
+ }
2324
+ function resolveOAuthClientId(config, environment = selectedEnvironment()) {
2325
+ const envSpecific = process.env[`${envVarPrefix(environment)}_CLI_CLIENT_ID`];
2326
+ if (envSpecific) return envSpecific;
2327
+ if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
2328
+ return profileForEnvironment(config, environment).oauthClientId;
2329
+ }
2330
+ function credentialSource(config, environment = selectedEnvironment()) {
2331
+ const prefix = envVarPrefix(environment);
2332
+ if (process.env[`${prefix}_API_KEY`]) return `env:${prefix}_API_KEY`;
2333
+ if (process.env[`${prefix}_ACCESS_TOKEN`]) return `env:${prefix}_ACCESS_TOKEN`;
2334
+ if (process.env.TOPOLO_API_KEY) return "env:TOPOLO_API_KEY";
2335
+ if (process.env.TOPOLO_ACCESS_TOKEN) return "env:TOPOLO_ACCESS_TOKEN";
2336
+ return profileForEnvironment(config, environment).credential ? "config-file" : "none";
2337
+ }
2338
+ async function resolveCredential(environment = selectedEnvironment()) {
2339
+ const prefix = envVarPrefix(environment);
2340
+ const apiKey = process.env[`${prefix}_API_KEY`] ?? process.env.TOPOLO_API_KEY;
2199
2341
  if (apiKey) return { kind: "api_key", apiKey };
2200
- const accessToken = process.env.TOPOLO_ACCESS_TOKEN;
2342
+ const accessToken = process.env[`${prefix}_ACCESS_TOKEN`] ?? process.env.TOPOLO_ACCESS_TOKEN;
2201
2343
  if (accessToken) return { kind: "access_token", accessToken };
2202
2344
  const config = await readConfig();
2203
- return config.credential ?? null;
2345
+ return profileForEnvironment(config, environment).credential ?? null;
2204
2346
  }
2205
2347
  var REFRESH_SKEW_SECONDS = 60;
2206
- async function refreshCredentialIfNeeded() {
2207
- if (process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
2208
- return resolveCredential();
2348
+ async function refreshCredentialIfNeeded(environment = selectedEnvironment()) {
2349
+ const prefix = envVarPrefix(environment);
2350
+ if (process.env[`${prefix}_API_KEY`] || process.env[`${prefix}_ACCESS_TOKEN`] || process.env.TOPOLO_API_KEY || process.env.TOPOLO_ACCESS_TOKEN) {
2351
+ return resolveCredential(environment);
2209
2352
  }
2210
2353
  const config = await readConfig();
2211
- const cred = config.credential;
2354
+ const profile = profileForEnvironment(config, environment);
2355
+ const cred = profile.credential;
2212
2356
  if (!cred || cred.kind !== "access_token") return cred ?? null;
2213
- if (!cred.refreshToken || !cred.expiresAt || !config.oauthClientId) return cred;
2357
+ if (!cred.refreshToken || !cred.expiresAt || !profile.oauthClientId) return cred;
2214
2358
  const now = Math.floor(Date.now() / 1e3);
2215
2359
  if (cred.expiresAt - REFRESH_SKEW_SECONDS > now) return cred;
2216
2360
  try {
2217
2361
  const oauth = new TopoloOAuth({
2218
- serviceUrls: resolveServiceUrls(config.serviceUrls)
2362
+ serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
2219
2363
  });
2220
2364
  const tokens = await oauth.refreshToken({
2221
- clientId: config.oauthClientId,
2365
+ clientId: profile.oauthClientId,
2222
2366
  refreshToken: cred.refreshToken
2223
2367
  });
2224
2368
  const refreshed = {
@@ -2227,7 +2371,9 @@ async function refreshCredentialIfNeeded() {
2227
2371
  ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: cred.refreshToken },
2228
2372
  expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
2229
2373
  };
2230
- await writeConfig({ ...config, credential: refreshed });
2374
+ await writeConfig(
2375
+ environment === "production" && !config.profiles?.production ? { ...config, credential: refreshed } : writeProfileConfig(config, environment, { credential: refreshed })
2376
+ );
2231
2377
  return refreshed;
2232
2378
  } catch {
2233
2379
  return cred;
@@ -2236,10 +2382,17 @@ async function refreshCredentialIfNeeded() {
2236
2382
  function resolveAgentName(configAgentName) {
2237
2383
  return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
2238
2384
  }
2239
- function resolveServiceUrls(configUrls) {
2385
+ function resolveServiceUrls(configUrls, environment = selectedEnvironment()) {
2240
2386
  const merged = {
2387
+ ...ENVIRONMENT_DEFAULT_SERVICE_URLS[environment],
2241
2388
  ...configUrls ?? {}
2242
2389
  };
2390
+ const prefix = `${envVarPrefix(environment)}_SERVICE_URL_`;
2391
+ for (const [k, v] of Object.entries(process.env)) {
2392
+ if (k.startsWith(prefix) && v) {
2393
+ merged[k.slice(prefix.length).toLowerCase()] = v;
2394
+ }
2395
+ }
2243
2396
  for (const [k, v] of Object.entries(process.env)) {
2244
2397
  if (k.startsWith("TOPOLO_SERVICE_URL_") && v) {
2245
2398
  merged[k.slice("TOPOLO_SERVICE_URL_".length).toLowerCase()] = v;
@@ -2247,6 +2400,28 @@ function resolveServiceUrls(configUrls) {
2247
2400
  }
2248
2401
  return Object.keys(merged).length > 0 ? merged : void 0;
2249
2402
  }
2403
+ function resolvedProfileConfig(config, environment = selectedEnvironment()) {
2404
+ const profile = profileForEnvironment(config, environment);
2405
+ return {
2406
+ credential: profile.credential,
2407
+ oauthClientId: profile.oauthClientId,
2408
+ agentName: profile.agentName,
2409
+ serviceUrls: resolveServiceUrls(profile.serviceUrls, environment)
2410
+ };
2411
+ }
2412
+ function assertServiceUrlForEnvironment(service, serviceUrl, environment = selectedEnvironment()) {
2413
+ if (environment !== "staging") return;
2414
+ if (!serviceUrl) {
2415
+ throw new Error(`No staging URL is configured for Topolo service "${service}".`);
2416
+ }
2417
+ const url = serviceUrl.toLowerCase();
2418
+ if (url.includes(".stg.topolo.us") || url.includes("localhost") || url.includes("127.0.0.1") || url.includes("topolo-staging")) {
2419
+ return;
2420
+ }
2421
+ throw new Error(
2422
+ `Refusing to call ${service} in staging because its effective URL is not a staging endpoint: ${serviceUrl}`
2423
+ );
2424
+ }
2250
2425
 
2251
2426
  // src/output.ts
2252
2427
  function resolveOutputMode(override) {
@@ -2293,38 +2468,38 @@ function normalizeError(err) {
2293
2468
  }
2294
2469
 
2295
2470
  // 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
2471
  function registerAuthCommands(program2) {
2299
2472
  const auth = program2.command("auth").description("Manage Topolo credentials");
2300
2473
  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
2474
  async (opts) => {
2302
2475
  const mode = resolveOutputMode(opts.json);
2476
+ const environment = selectedEnvironment();
2303
2477
  try {
2304
2478
  if (opts.apiKey) {
2305
- await storeApiKey(opts.apiKey.trim(), mode);
2479
+ await storeApiKey(opts.apiKey.trim(), environment, mode);
2306
2480
  return;
2307
2481
  }
2308
- const clientId = opts.clientId ?? process.env.TOPOLO_CLI_CLIENT_ID;
2482
+ const config = await readConfig();
2483
+ const clientId = opts.clientId ?? resolveOAuthClientId(config, environment);
2309
2484
  if (!clientId) {
2310
2485
  if (!input.isTTY) {
2311
2486
  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}).`
2487
+ `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
2488
  );
2314
2489
  }
2315
2490
  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.
2491
+ `No OAuth client_id configured for ${environmentLabel(environment)}. Falling back to API key.
2492
+ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth client at ${oauthClientRegistrationUrl(environment)} and set the environment-specific CLI client id.
2318
2493
  `
2319
2494
  );
2320
2495
  const rl = createInterface({ input, output: process.stderr });
2321
2496
  const apiKey = (await rl.question("Paste API key: ")).trim();
2322
2497
  rl.close();
2323
2498
  if (!apiKey) throw new Error("API key is required");
2324
- await storeApiKey(apiKey, mode);
2499
+ await storeApiKey(apiKey, environment, mode);
2325
2500
  return;
2326
2501
  }
2327
- await runDeviceFlow({ clientId, scope: opts.scope, mode });
2502
+ await runDeviceFlow({ clientId, environment, scope: opts.scope, mode });
2328
2503
  } catch (err) {
2329
2504
  fail(err, mode);
2330
2505
  }
@@ -2332,40 +2507,65 @@ Visit ${API_KEY_ISSUANCE_URL} to mint one, or configure an OAuth client at ${OAU
2332
2507
  );
2333
2508
  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
2509
  const mode = resolveOutputMode(opts.json);
2510
+ const environment = selectedEnvironment();
2335
2511
  try {
2336
2512
  const config = await readConfig();
2337
- delete config.credential;
2338
- await writeConfig(config);
2339
- emit({ ok: true }, mode, () => "Logged out.");
2513
+ await writeConfig(clearProfileCredential(config, environment));
2514
+ emit(
2515
+ { ok: true, environment },
2516
+ mode,
2517
+ () => `Logged out of Topolo ${environmentLabel(environment)}.`
2518
+ );
2340
2519
  } catch (err) {
2341
2520
  fail(err, mode);
2342
2521
  }
2343
2522
  });
2344
2523
  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
2524
  const mode = resolveOutputMode(opts.json);
2525
+ const environment = selectedEnvironment();
2346
2526
  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}`);
2527
+ const config = await readConfig();
2528
+ const source = credentialSource(config, environment);
2529
+ const profile = resolvedProfileConfig(config, environment);
2530
+ emit(
2531
+ {
2532
+ environment,
2533
+ source,
2534
+ authUrl: profile.serviceUrls?.auth ?? null,
2535
+ developersUrl: profile.serviceUrls?.developers ?? null
2536
+ },
2537
+ mode,
2538
+ () => [
2539
+ `Environment: ${environmentLabel(environment)}`,
2540
+ `Credential source: ${source}`,
2541
+ `Auth URL: ${profile.serviceUrls?.auth ?? "(sdk default)"}`,
2542
+ `Developers URL: ${profile.serviceUrls?.developers ?? "(sdk default)"}`
2543
+ ].join("\n")
2544
+ );
2349
2545
  } catch (err) {
2350
2546
  fail(err, mode);
2351
2547
  }
2352
2548
  });
2353
2549
  }
2354
- async function storeApiKey(apiKey, mode) {
2550
+ async function storeApiKey(apiKey, environment, mode) {
2355
2551
  if (!apiKey) throw new Error("API key is required");
2356
2552
  const config = await readConfig();
2357
- config.credential = { kind: "api_key", apiKey };
2358
- await writeConfig(config);
2553
+ await writeConfig(
2554
+ writeProfileConfig(config, environment, {
2555
+ credential: { kind: "api_key", apiKey }
2556
+ })
2557
+ );
2359
2558
  emit(
2360
- { ok: true, credential: { kind: "api_key" } },
2559
+ { ok: true, environment, credential: { kind: "api_key" } },
2361
2560
  mode,
2362
- () => "Stored API key. Run `topolo whoami` to verify."
2561
+ () => `Stored API key for Topolo ${environmentLabel(environment)}. Run \`topolo whoami\` to verify.`
2363
2562
  );
2364
2563
  }
2365
2564
  async function runDeviceFlow(options) {
2366
2565
  const config = await readConfig();
2566
+ const resolved = resolvedProfileConfig(config, options.environment);
2367
2567
  const oauth = new TopoloOAuth({
2368
- serviceUrls: resolveServiceUrls(config.serviceUrls)
2568
+ serviceUrls: resolved.serviceUrls
2369
2569
  });
2370
2570
  const start = await oauth.requestDeviceCode({
2371
2571
  clientId: options.clientId,
@@ -2377,7 +2577,7 @@ async function runDeviceFlow(options) {
2377
2577
  if (options.mode === "pretty") {
2378
2578
  process.stderr.write(
2379
2579
  `
2380
- Sign in to Topolo
2580
+ Sign in to Topolo ${environmentLabel(options.environment)}
2381
2581
  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2382
2582
 
2383
2583
  ` + (opened ? ` Opening your browser to approve...
@@ -2405,17 +2605,21 @@ async function runDeviceFlow(options) {
2405
2605
  spinner?.stop();
2406
2606
  }
2407
2607
  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);
2608
+ await writeConfig(
2609
+ writeProfileConfig(updated, options.environment, {
2610
+ credential: {
2611
+ kind: "access_token",
2612
+ accessToken: token.access_token,
2613
+ ...token.refresh_token ? { refreshToken: token.refresh_token } : {},
2614
+ expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
2615
+ },
2616
+ oauthClientId: options.clientId
2617
+ })
2618
+ );
2416
2619
  emit(
2417
2620
  {
2418
2621
  ok: true,
2622
+ environment: options.environment,
2419
2623
  credential: {
2420
2624
  kind: "access_token",
2421
2625
  expiresIn: token.expires_in,
@@ -2491,22 +2695,23 @@ function sleep(ms) {
2491
2695
  }
2492
2696
 
2493
2697
  // src/version.ts
2494
- var CLI_VERSION = "0.1.15";
2698
+ var CLI_VERSION = "0.1.17";
2495
2699
 
2496
2700
  // src/client.ts
2497
2701
  async function buildClient(options) {
2498
- const credential = await refreshCredentialIfNeeded();
2702
+ const environment = selectedEnvironment();
2703
+ const credential = await refreshCredentialIfNeeded(environment);
2499
2704
  if (!credential) return null;
2500
2705
  const config = await readConfig();
2501
- const serviceUrls = resolveServiceUrls(config.serviceUrls);
2706
+ const profile = resolvedProfileConfig(config, environment);
2502
2707
  return createTopolo({
2503
2708
  credential,
2504
2709
  agent: {
2505
2710
  clientName: "topolo-cli",
2506
2711
  clientVersion: CLI_VERSION,
2507
- agentName: resolveAgentName(config.agentName)
2712
+ agentName: resolveAgentName(profile.agentName)
2508
2713
  },
2509
- serviceUrls,
2714
+ serviceUrls: profile.serviceUrls,
2510
2715
  requireConfirmForWrites: options?.requireConfirmForWrites,
2511
2716
  ...isDebugEnabled() ? { debug: stderrDebugLogger } : {}
2512
2717
  });
@@ -2543,6 +2748,9 @@ function registerWhoamiCommand(program2) {
2543
2748
  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
2749
  const mode = resolveOutputMode(opts.json);
2545
2750
  try {
2751
+ const environment = selectedEnvironment();
2752
+ const profile = resolvedProfileConfig(await readConfig(), environment);
2753
+ assertServiceUrlForEnvironment("auth", profile.serviceUrls?.auth, environment);
2546
2754
  const topolo = await buildClient();
2547
2755
  if (!topolo) {
2548
2756
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
@@ -2573,6 +2781,7 @@ function registerCrmCommands(program2) {
2573
2781
  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
2782
  const mode = resolveOutputMode(opts.json);
2575
2783
  try {
2784
+ await assertCrmRoutable();
2576
2785
  const topolo = await buildClient();
2577
2786
  if (!topolo) {
2578
2787
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
@@ -2590,6 +2799,7 @@ function registerCrmCommands(program2) {
2590
2799
  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
2800
  const mode = resolveOutputMode(opts.json);
2592
2801
  try {
2802
+ await assertCrmRoutable();
2593
2803
  const topolo = await buildClient();
2594
2804
  if (!topolo) {
2595
2805
  throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
@@ -2602,6 +2812,15 @@ function registerCrmCommands(program2) {
2602
2812
  }
2603
2813
  });
2604
2814
  }
2815
+ async function assertCrmRoutable() {
2816
+ const environment = selectedEnvironment();
2817
+ const profile = resolvedProfileConfig(await readConfig(), environment);
2818
+ assertServiceUrlForEnvironment(
2819
+ "crm",
2820
+ resolveServiceUrl("crm", profile.serviceUrls),
2821
+ environment
2822
+ );
2823
+ }
2605
2824
  function formatContactsTable(rows, total, page) {
2606
2825
  if (rows.length === 0) return "No contacts found.";
2607
2826
  const lines = [];
@@ -2660,6 +2879,14 @@ function registerApiCommand(program2) {
2660
2879
  if (WRITE_METHODS2.has(httpMethod) && !opts.confirm) {
2661
2880
  throw new Error(`Mutating ${httpMethod} requires --confirm.`);
2662
2881
  }
2882
+ const environment = selectedEnvironment();
2883
+ const config = await readConfig();
2884
+ const profile = resolvedProfileConfig(config, environment);
2885
+ assertServiceUrlForEnvironment(
2886
+ service,
2887
+ resolveServiceUrl(service, profile.serviceUrls),
2888
+ environment
2889
+ );
2663
2890
  let body = void 0;
2664
2891
  if (opts.data) {
2665
2892
  try {
@@ -3641,7 +3868,9 @@ import { constants } from "fs";
3641
3868
  import { access, mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
3642
3869
  import { dirname as dirname2, relative, resolve } from "path";
3643
3870
  var CLOUDFLARE_ACCOUNT_ID = "49ef1ba682ad8cfd720c86699ae17521";
3644
- var CURRENT_VERIFICATION_DATE = "2026-05-01";
3871
+ var STAGING_CLOUDFLARE_ACCOUNT_ID = "4f4e1c69a3830946f9fea7b1eb7531ac";
3872
+ var CLOUDFLARE_COMPATIBILITY_DATE = "2026-05-05";
3873
+ var CURRENT_VERIFICATION_DATE = "2026-05-06";
3645
3874
  function applicationScaffoldPayload(application, options = {}) {
3646
3875
  const target = normalizeTarget(options.target);
3647
3876
  const portfolio = target === "platform" ? normalizePortfolio(options.portfolio) : null;
@@ -4148,9 +4377,13 @@ function packageJson(application, target, options) {
4148
4377
  if (target === "platform") {
4149
4378
  dependencies["@topolo-io/auth-middleware"] = topoloPackageVersion("@topolo-io/auth-middleware", target);
4150
4379
  dependencies["@topolo/sdk"] = topoloPackageVersion("@topolo/sdk", target);
4380
+ scripts.deploy = `node -e 'console.error("\\nX Bare deploy is disabled. Use deploy:staging or deploy:production.\\n"); process.exit(1)'`;
4381
+ scripts["deploy:staging"] = "DEPLOY_ENVIRONMENT=staging VITE_TOPOLO_AUTH_URL=https://auth.stg.topolo.us npm run build && wrangler deploy --env staging";
4382
+ scripts["deploy:production"] = "DEPLOY_ENVIRONMENT=production npm run build && wrangler deploy";
4383
+ } else {
4384
+ scripts.deploy = "npm run build && wrangler deploy";
4151
4385
  }
4152
4386
  devDependencies.wrangler = "^4.40.2";
4153
- scripts.deploy = "npm run build && wrangler deploy";
4154
4387
  }
4155
4388
  return `${json({
4156
4389
  name: application.packageName,
@@ -4485,8 +4718,13 @@ function appTsx(application, target, options) {
4485
4718
  function platformAppTsx(application, options) {
4486
4719
  const appName = toTitle(application.name);
4487
4720
  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";
4721
+ const isPersonal = options.audience === "personal";
4722
+ const tagline = isPersonal ? "Topolo personal app" : "Topolo workspace";
4723
+ const workspaceTitle = isPersonal ? "Personal app" : "Workspace";
4724
+ const shellLabel = isPersonal ? "personal app shell" : "workspace shell";
4725
+ const signedSurfaceLabel = isPersonal ? "personal app" : "workspace";
4726
+ const baselineHeading = isPersonal ? "Shared personal baseline" : "Shared workspace baseline";
4727
+ 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
4728
  return [
4491
4729
  "import { Component, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from 'react';",
4492
4730
  "import { createBrowserAuthModule, type AuthUser } from '@topolo-io/auth-client';",
@@ -4512,17 +4750,17 @@ function platformAppTsx(application, options) {
4512
4750
  " app_name: APP_NAME,",
4513
4751
  ` app_tagline: '${escapeSingleQuoted(tagline)}',`,
4514
4752
  " 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.',",
4753
+ ` hero_subtitle: 'Shared landing, login, callback, auth, and ${escapeSingleQuoted(shellLabel)} wiring are already included in the generated baseline.',`,
4516
4754
  " hero_cta_text: 'Sign in',",
4517
4755
  " hero_cta_url: '/login',",
4518
4756
  " 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.',",
4757
+ ` cta_subtitle: 'Unauthenticated routes stay on the shared Topolo surfaces before users enter the signed ${escapeSingleQuoted(shellLabel)}.',`,
4520
4758
  "};",
4521
4759
  "const LOGIN_CONFIG = {",
4522
4760
  " app_name: APP_NAME,",
4523
4761
  ` app_tagline: '${escapeSingleQuoted(tagline)}',`,
4524
4762
  " hero_title: `Sign in to ${APP_NAME}`,",
4525
- " hero_subtitle: 'Use Topolo Auth to open the shared workspace shell for this application.',",
4763
+ ` hero_subtitle: 'Use Topolo Auth to open the shared ${escapeSingleQuoted(shellLabel)} for this application.',`,
4526
4764
  " authenticated_home_path: '/',",
4527
4765
  "};",
4528
4766
  "",
@@ -4542,6 +4780,7 @@ function platformAppTsx(application, options) {
4542
4780
  "function toShellUser(user: AuthUser | null): TopoloAppShellUser | null {",
4543
4781
  " if (!user) return null;",
4544
4782
  " return {",
4783
+ " id: user.id,",
4545
4784
  " name: user.name ?? user.email,",
4546
4785
  " email: user.email,",
4547
4786
  " role: user.role,",
@@ -4690,7 +4929,7 @@ function platformAppTsx(application, options) {
4690
4929
  " appId={APP_ID}",
4691
4930
  " serviceId={SERVICE_ID}",
4692
4931
  " appName={APP_NAME}",
4693
- ' subtitle="Opening your workspace..."',
4932
+ ` subtitle="Opening your ${escapeDoubleQuoted(signedSurfaceLabel)}..."`,
4694
4933
  " />",
4695
4934
  " );",
4696
4935
  " }",
@@ -4701,6 +4940,9 @@ function platformAppTsx(application, options) {
4701
4940
  " serviceId={SERVICE_ID}",
4702
4941
  " appName={APP_NAME}",
4703
4942
  " user={shellUser}",
4943
+ " orgId={user.activeContextType === 'organization' ? user.activeContextId ?? user.orgId ?? null : null}",
4944
+ " activeContextType={user.activeContextType}",
4945
+ " activeContextId={user.activeContextId}",
4704
4946
  ' activeNavItemId="home"',
4705
4947
  ` navItems={[{ id: 'home', label: '${escapeSingleQuoted(workspaceTitle)}' }]}`,
4706
4948
  ' navigationMode="topbar"',
@@ -4719,9 +4961,9 @@ function platformAppTsx(application, options) {
4719
4961
  " >",
4720
4962
  ' <div className="topolo-starter-workspace">',
4721
4963
  ' <section className="topolo-starter-card">',
4722
- " <h2>Shared workspace baseline</h2>",
4964
+ ` <h2>${baselineHeading}</h2>`,
4723
4965
  " <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.",
4966
+ ` ${baselineDescription}`,
4725
4967
  " </p>",
4726
4968
  " </section>",
4727
4969
  " </div>",
@@ -4913,7 +5155,7 @@ function wranglerToml(application, target) {
4913
5155
  const lines = [
4914
5156
  `name = "${workerName}"`,
4915
5157
  'main = "src/worker.ts"',
4916
- `compatibility_date = "${CURRENT_VERIFICATION_DATE}"`,
5158
+ `compatibility_date = "${CLOUDFLARE_COMPATIBILITY_DATE}"`,
4917
5159
  'compatibility_flags = ["nodejs_compat"]'
4918
5160
  ];
4919
5161
  if (target === "platform") {
@@ -4948,15 +5190,51 @@ function wranglerToml(application, target) {
4948
5190
  `SERVICE_ID = "${application.serviceId ?? application.id}"`,
4949
5191
  ""
4950
5192
  );
5193
+ if (target === "platform") {
5194
+ lines.push(
5195
+ "# Staging deploys use the separate Topolo Staging Cloudflare account.",
5196
+ "[env.staging]",
5197
+ `name = "${workerName}-staging"`,
5198
+ `account_id = "${STAGING_CLOUDFLARE_ACCOUNT_ID}"`,
5199
+ "workers_dev = true",
5200
+ "routes = [",
5201
+ ` { pattern = "${slug}.stg.topolo.us", custom_domain = true }`,
5202
+ "]",
5203
+ "",
5204
+ "[env.staging.vars]",
5205
+ 'AUTH_BASE_URL = "https://auth.stg.topolo.us"',
5206
+ `SERVICE_ID = "${application.serviceId ?? application.id}"`,
5207
+ ""
5208
+ );
5209
+ }
4951
5210
  return lines.join("\n");
4952
5211
  }
4953
- function workerTs(application, target, _options) {
5212
+ function workerTs(application, target, options) {
4954
5213
  if (target === "developer") return developerWorkerTs(application);
4955
- return platformWorkerTs(application);
5214
+ return platformWorkerTs(application, options);
4956
5215
  }
4957
- function platformWorkerTs(application) {
5216
+ function platformWorkerTs(application, options) {
4958
5217
  const serviceId = application.serviceId ?? application.id;
4959
5218
  const serviceName = toTitle(application.name);
5219
+ const openActionLabel = options.audience === "personal" ? "Open app" : "Open workspace";
5220
+ const bootstrapBody = options.tenancy === "household" ? [
5221
+ " const userContext = auth.user && typeof auth.user === 'object' ? (auth.user as unknown as Record<string, unknown>) : {};",
5222
+ " return json({",
5223
+ " user: auth.user,",
5224
+ " activeContextType: userContext.activeContextType ?? null,",
5225
+ " activeContextId: userContext.activeContextId ?? null,",
5226
+ " activeContext: userContext.activeContext ?? null,",
5227
+ " selectedHouseholdId: userContext.selectedHouseholdId ?? null,",
5228
+ " selectedHousehold: userContext.selectedHousehold ?? null,",
5229
+ " household: userContext.selectedHousehold ?? null,",
5230
+ " householdMemberships: userContext.householdMemberships ?? [],",
5231
+ " }, { status: 200 }, request);"
5232
+ ] : [
5233
+ " return json({",
5234
+ " user: auth.user,",
5235
+ " organization: auth.organization,",
5236
+ " }, { status: 200 }, request);"
5237
+ ];
4960
5238
  return [
4961
5239
  "import { createTopoloWidgetResponse } from '@topolo/sdk';",
4962
5240
  "import { verifyRequest } from '@topolo-io/auth-middleware';",
@@ -4995,7 +5273,7 @@ function platformWorkerTs(application) {
4995
5273
  " {",
4996
5274
  " type: 'quick-actions',",
4997
5275
  " actions: [",
4998
- " { id: 'open-workspace', label: 'Open workspace', link: '/', primary: true },",
5276
+ ` { id: 'open-workspace', label: '${escapeSingleQuoted(openActionLabel)}', link: '/', primary: true },`,
4999
5277
  " { id: 'view-health', label: 'View health', link: '/api/health' },",
5000
5278
  " ],",
5001
5279
  " },",
@@ -5004,10 +5282,7 @@ function platformWorkerTs(application) {
5004
5282
  " }",
5005
5283
  "",
5006
5284
  " if (url.pathname === '/api/bootstrap') {",
5007
- " return json({",
5008
- " user: auth.user,",
5009
- " organization: auth.organization,",
5010
- " }, { status: 200 }, request);",
5285
+ ...bootstrapBody,
5011
5286
  " }",
5012
5287
  "",
5013
5288
  " return json({ error: 'not_found' }, { status: 404 }, request);",
@@ -5102,6 +5377,7 @@ function developerWorkerTs(application) {
5102
5377
  function cloudcontrolJson(application, options) {
5103
5378
  const slug = toSlug(application.id);
5104
5379
  const host = application.productionUrl ? new URL(application.productionUrl).host : `${slug}.topolo.app`;
5380
+ const stagingHost = `${slug}.stg.topolo.us`;
5105
5381
  const deployTargets = options.api ? [
5106
5382
  {
5107
5383
  kind: "worker",
@@ -5117,6 +5393,21 @@ function cloudcontrolJson(application, options) {
5117
5393
  routes: [host],
5118
5394
  api_url: application.productionUrl,
5119
5395
  service_id: application.serviceId
5396
+ },
5397
+ {
5398
+ kind: "worker",
5399
+ environment: "staging",
5400
+ target_name: `topolo-${slug}-staging`,
5401
+ cloudflare_account_id: STAGING_CLOUDFLARE_ACCOUNT_ID,
5402
+ config_path: "wrangler.toml",
5403
+ build_command: "VITE_TOPOLO_AUTH_URL=https://auth.stg.topolo.us npm run build",
5404
+ output_dir: "dist",
5405
+ production_branch: "staging",
5406
+ healthcheck_path: "/api/health",
5407
+ domains: [stagingHost],
5408
+ routes: [stagingHost],
5409
+ api_url: `https://${stagingHost}`,
5410
+ service_id: application.serviceId
5120
5411
  }
5121
5412
  ] : [
5122
5413
  {
@@ -5152,6 +5443,12 @@ function cloudcontrolJson(application, options) {
5152
5443
  url: application.productionUrl,
5153
5444
  branch: "main",
5154
5445
  status: "unknown"
5446
+ },
5447
+ {
5448
+ name: "staging",
5449
+ url: `https://${stagingHost}`,
5450
+ branch: "staging",
5451
+ status: "unknown"
5155
5452
  }
5156
5453
  ] : [],
5157
5454
  deploy_targets: deployTargets,
@@ -5175,6 +5472,9 @@ function systemJson(application, options) {
5175
5472
  const slug = toSlug(application.id);
5176
5473
  const suiteLabel = options.audience === "personal" ? "personal" : "platform";
5177
5474
  const summaryLabel = options.audience === "personal" ? "first-party Topolo personal application" : "first-party Topolo platform application";
5475
+ 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.";
5476
+ const sensitiveData = options.tenancy === "household" ? ["identity", "personal_data", "household_data"] : ["identity", "org_data"];
5477
+ const privacySensitiveData = options.tenancy === "household" ? ["identity", "personal", "household"] : ["identity", "organization"];
5178
5478
  return `${json({
5179
5479
  id: `topolo-${slug}`,
5180
5480
  name: toTitle(application.name),
@@ -5190,7 +5490,7 @@ function systemJson(application, options) {
5190
5490
  api_contract: {
5191
5491
  type: options.api ? "curated" : "none",
5192
5492
  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."
5493
+ notes: apiNotes
5194
5494
  },
5195
5495
  primary_hosts: application.productionUrl ? [application.productionUrl] : [],
5196
5496
  doc_paths: [`internal/apps/${slug}`, `public/applications/${slug}`],
@@ -5199,12 +5499,26 @@ function systemJson(application, options) {
5199
5499
  auth_boundary: "Topolo Auth through @topolo-io/auth-client for browser sessions and @topolo-io/auth-middleware for Worker requests.",
5200
5500
  tenant_isolation: options.tenancy === "household" ? "household_scoped" : "organization_scoped",
5201
5501
  external_inputs: ["browser", ...options.api ? ["api"] : []],
5202
- sensitive_data: ["identity", "org_data"],
5502
+ sensitive_data: sensitiveData,
5203
5503
  security_review_status: "not_started",
5204
5504
  last_security_review: CURRENT_VERIFICATION_DATE,
5205
5505
  pentest_status: "not_started",
5206
5506
  evidence_doc: `internal/apps/${slug}`
5207
5507
  },
5508
+ data_privacy: {
5509
+ classification: "confidential",
5510
+ sensitive_data_classes: privacySensitiveData,
5511
+ storage_locations: ["source_control"],
5512
+ encryption_at_rest: "platform_managed",
5513
+ encryption_in_transit: "https_only",
5514
+ key_management: "cloudflare_managed",
5515
+ retention_policy: "needs_policy",
5516
+ deletion_export_status: "planned",
5517
+ logging_redaction_status: "partial",
5518
+ privacy_review_status: "not_started",
5519
+ enterprise_ready: false,
5520
+ evidence_doc: `internal/apps/${slug}`
5521
+ },
5208
5522
  dependencies: options.audience === "personal" ? ["topolo-auth"] : ["topolo-auth", "topolo-platform"],
5209
5523
  tags: ["application", "cloudflare", "typescript", suiteLabel]
5210
5524
  })}
@@ -5215,6 +5529,8 @@ function internalDocs(application, options) {
5215
5529
  const title = toTitle(application.name);
5216
5530
  const suiteLabel = options.audience === "personal" ? "personal" : "platform";
5217
5531
  const tenantLabel = options.tenancy === "household" ? "household" : "organization";
5532
+ const apiContextLabel = options.tenancy === "household" ? "active personal context and selected household context" : "organization context";
5533
+ 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
5534
  return [
5219
5535
  "---",
5220
5536
  `title: "${escapeFrontmatter(title)} Handbook"`,
@@ -5257,11 +5573,11 @@ function internalDocs(application, options) {
5257
5573
  "",
5258
5574
  "## API Reference",
5259
5575
  "",
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.",
5576
+ 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
5577
  "",
5262
5578
  "## Auth and Permissions",
5263
5579
  "",
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.",
5580
+ 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
5581
  "",
5266
5582
  "## Data Ownership",
5267
5583
  "",
@@ -5269,14 +5585,14 @@ function internalDocs(application, options) {
5269
5585
  "",
5270
5586
  "## Deployments",
5271
5587
  "",
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.",
5588
+ 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
5589
  "",
5274
5590
  "## Failure Modes",
5275
5591
  "",
5276
5592
  "- Auth service registration drifts from the generated Worker `SERVICE_ID`.",
5277
5593
  "- CloudControl metadata does not match the eventual Cloudflare project or route.",
5278
5594
  "- 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.",
5595
+ `- ${boundaryFailureText}`,
5280
5596
  "",
5281
5597
  "## Debugging",
5282
5598
  "",
@@ -5299,6 +5615,8 @@ function publicDocs(application, options) {
5299
5615
  const title = toTitle(application.name);
5300
5616
  const suiteLabel = options.audience === "personal" ? "personal" : "business";
5301
5617
  const tenantLabel = options.tenancy === "household" ? "household" : "organization";
5618
+ const apiRouteLabel = options.tenancy === "household" ? "authenticated personal and household routes" : "authenticated workspace routes";
5619
+ 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
5620
  return [
5303
5621
  "---",
5304
5622
  `title: "${escapeFrontmatter(title)}"`,
@@ -5334,7 +5652,7 @@ function publicDocs(application, options) {
5334
5652
  "",
5335
5653
  "## API Reference",
5336
5654
  "",
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.",
5655
+ 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
5656
  "",
5339
5657
  "## Auth and Permissions",
5340
5658
  "",
@@ -5346,12 +5664,12 @@ function publicDocs(application, options) {
5346
5664
  "",
5347
5665
  "## Deployments",
5348
5666
  "",
5349
- application.productionUrl ? `The planned production host is \`${application.productionUrl}\`.` : "The production host has not been assigned yet.",
5667
+ 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
5668
  "",
5351
5669
  "## Failure Modes",
5352
5670
  "",
5353
5671
  "- 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",
5672
+ `- ${dataModelFailure}`,
5355
5673
  "",
5356
5674
  "## Debugging",
5357
5675
  "",
@@ -5360,7 +5678,7 @@ function publicDocs(application, options) {
5360
5678
  "",
5361
5679
  "## Change Log / Verification",
5362
5680
  "",
5363
- `- Scaffold generated on ${CURRENT_VERIFICATION_DATE} from \`topolo apps scaffold\`.`,
5681
+ `- Scaffold generated on ${CURRENT_VERIFICATION_DATE} from \`${options.portfolio ? "topolo platform apps scaffold" : "topolo apps scaffold"}\`.`,
5364
5682
  ""
5365
5683
  ].join("\n");
5366
5684
  }
@@ -5369,8 +5687,17 @@ function serviceManifestJson(application, options) {
5369
5687
  const title = toTitle(application.name);
5370
5688
  const permissionPrefix = application.id.replace(/[^a-z0-9_]/g, "_");
5371
5689
  const resourceLabel = options.tenancy === "household" ? "household" : "workspace";
5690
+ const launcherSettings = options.tenancy === "household" ? {
5691
+ settings: {
5692
+ launcher: {
5693
+ supported_contexts: ["personal"],
5694
+ household_capable: true
5695
+ }
5696
+ }
5697
+ } : {};
5372
5698
  return `${json({
5373
5699
  serviceId: application.serviceId,
5700
+ ...launcherSettings,
5374
5701
  systems: [
5375
5702
  {
5376
5703
  systemId: `topolo-${slug}`,
@@ -5569,7 +5896,6 @@ function escapeFrontmatter(value) {
5569
5896
  }
5570
5897
 
5571
5898
  // src/commands/app-scaffold-provision.ts
5572
- var DEFAULT_DEVELOPERS_ORIGIN = "https://developers.topolo.app";
5573
5899
  var DEVELOPERS_SERVICE_ID = "svc_topolo_developers";
5574
5900
  async function provisionApplicationScaffold(payload) {
5575
5901
  if (!payload.application.productionUrl) {
@@ -5582,7 +5908,8 @@ async function provisionApplicationScaffold(payload) {
5582
5908
  "Platform scaffold provisioning requires a service id. Re-run without --no-api or pass --service."
5583
5909
  );
5584
5910
  }
5585
- const credential = await refreshCredentialIfNeeded();
5911
+ const environment = selectedEnvironment();
5912
+ const credential = await refreshCredentialIfNeeded(environment);
5586
5913
  if (!credential) {
5587
5914
  throw new Error(
5588
5915
  "topolo apps scaffold --provision requires an authenticated Topolo session. Run `topolo auth login` first."
@@ -5596,15 +5923,16 @@ async function provisionApplicationScaffold(payload) {
5596
5923
  }
5597
5924
  const identity = await client.client.introspect();
5598
5925
  const config = await readConfig();
5599
- const serviceUrls = resolveServiceUrls(config.serviceUrls) ?? {};
5600
- const developersOrigin = sanitizeOrigin(serviceUrls.developers ?? DEFAULT_DEVELOPERS_ORIGIN);
5926
+ const profile = resolvedProfileConfig(config, environment);
5927
+ const developersOrigin = sanitizeOrigin(profile.serviceUrls?.developers ?? "https://developers.topolo.app");
5928
+ assertServiceUrlForEnvironment("developers", developersOrigin, environment);
5601
5929
  const endpoint = payload.target === "platform" ? "/api/developer-console/admin/first-party-app-scaffolds" : "/api/developer-console/apps/scaffold";
5602
5930
  const headers = new Headers({
5603
5931
  "Content-Type": "application/json",
5604
5932
  "X-Service-ID": DEVELOPERS_SERVICE_ID,
5605
5933
  "X-Topolo-Client": `topolo-cli/${CLI_VERSION}`
5606
5934
  });
5607
- const agentName = resolveAgentName(config.agentName);
5935
+ const agentName = resolveAgentName(profile.agentName);
5608
5936
  if (agentName) {
5609
5937
  headers.set("X-Topolo-Agent", agentName);
5610
5938
  }
@@ -6361,39 +6689,48 @@ function registerServicesCommand(program2) {
6361
6689
  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
6690
  const mode = resolveOutputMode(opts.json);
6363
6691
  try {
6692
+ const environment = selectedEnvironment();
6364
6693
  const config = await readConfig();
6365
- const rows = buildRows(resolveServiceUrls(config.serviceUrls));
6366
- emit({ services: rows }, mode, () => formatRows2(rows));
6694
+ const rows = buildRows(config.serviceUrls, environment);
6695
+ emit({ environment, services: rows }, mode, () => formatRows2(rows, environment));
6367
6696
  } catch (err) {
6368
6697
  fail(err, mode);
6369
6698
  }
6370
6699
  });
6371
6700
  }
6372
- function buildRows(overrides) {
6701
+ function buildRows(overrides, environment = selectedEnvironment()) {
6702
+ const resolvedOverrides = resolveServiceUrls(overrides, environment);
6373
6703
  return Object.keys(DEFAULT_SERVICE_URLS).sort().map((id) => {
6374
6704
  const defaultUrl = DEFAULT_SERVICE_URLS[id];
6375
- const effectiveUrl = resolveServiceUrl(id, overrides);
6705
+ const effectiveUrl = resolveServiceUrl(id, resolvedOverrides);
6376
6706
  return {
6377
6707
  id,
6378
6708
  defaultUrl,
6379
6709
  effectiveUrl,
6380
- overridden: defaultUrl !== effectiveUrl
6710
+ overridden: defaultUrl !== effectiveUrl,
6711
+ environmentSafe: environment !== "staging" || isStagingUrl(effectiveUrl)
6381
6712
  };
6382
6713
  });
6383
6714
  }
6384
- function formatRows2(rows) {
6715
+ function formatRows2(rows, environment = selectedEnvironment()) {
6385
6716
  const idWidth = Math.max(3, ...rows.map((r) => r.id.length));
6386
6717
  const urlWidth = Math.max(7, ...rows.map((r) => r.effectiveUrl.length));
6387
6718
  const lines = [];
6388
- lines.push(`${"ID".padEnd(idWidth)} ${"URL".padEnd(urlWidth)} OVERRIDE`);
6389
- lines.push(`${"-".repeat(idWidth)} ${"-".repeat(urlWidth)} --------`);
6719
+ lines.push(`Environment: ${environment}`);
6720
+ lines.push("");
6721
+ lines.push(`${"ID".padEnd(idWidth)} ${"URL".padEnd(urlWidth)} OVERRIDE READY`);
6722
+ lines.push(`${"-".repeat(idWidth)} ${"-".repeat(urlWidth)} -------- -----`);
6390
6723
  for (const r of rows) {
6391
6724
  lines.push(
6392
- `${r.id.padEnd(idWidth)} ${r.effectiveUrl.padEnd(urlWidth)} ${r.overridden ? "yes" : ""}`
6725
+ `${r.id.padEnd(idWidth)} ${r.effectiveUrl.padEnd(urlWidth)} ${r.overridden ? "yes" : "".padEnd(8)} ${r.environmentSafe ? "yes" : "no"}`
6393
6726
  );
6394
6727
  }
6395
6728
  return lines.join("\n");
6396
6729
  }
6730
+ function isStagingUrl(value) {
6731
+ const url = value.toLowerCase();
6732
+ return url.includes(".stg.topolo.us") || url.includes("localhost") || url.includes("127.0.0.1") || url.includes("topolo-staging");
6733
+ }
6397
6734
 
6398
6735
  // src/commands/seed.ts
6399
6736
  var DEFAULT_SEED_BASE_URL = "https://seed.stg.topolo.us";
@@ -6401,9 +6738,9 @@ function seedBaseUrl() {
6401
6738
  return process.env.TOPOLO_SEED_URL || DEFAULT_SEED_BASE_URL;
6402
6739
  }
6403
6740
  async function authedFetch(path4, init = {}) {
6404
- const credential = await refreshCredentialIfNeeded();
6741
+ const credential = await refreshCredentialIfNeeded("staging");
6405
6742
  if (!credential) {
6406
- throw new Error("No credential configured. Run `topolo auth login` or set TOPOLO_API_KEY.");
6743
+ throw new Error("No staging credential configured. Run `topolo auth login --env staging` or set TOPOLO_STAGING_API_KEY.");
6407
6744
  }
6408
6745
  const headers = new Headers(init.headers || {});
6409
6746
  if (credential.kind === "api_key") headers.set("X-Api-Key", credential.apiKey);
@@ -6611,9 +6948,22 @@ function formatManifestsTable(manifests) {
6611
6948
 
6612
6949
  // src/index.ts
6613
6950
  var program = new Command();
6951
+ var argv;
6952
+ try {
6953
+ argv = consumeEnvironmentArg(process.argv);
6954
+ } catch (err) {
6955
+ process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
6956
+ `);
6957
+ process.exit(1);
6958
+ }
6614
6959
  program.name("topolo").description(
6615
6960
  "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);
6961
+ ).version(CLI_VERSION).option("--env <environment>", "Topolo environment: production or staging");
6962
+ program.hook("preAction", (thisCommand) => {
6963
+ const opts = thisCommand.opts();
6964
+ if (!opts.env) return;
6965
+ process.env.TOPOLO_ENV = normalizeEnvironment(opts.env);
6966
+ });
6617
6967
  registerAuthCommands(program);
6618
6968
  registerWhoamiCommand(program);
6619
6969
  registerCrmCommands(program);
@@ -6622,8 +6972,30 @@ registerAppsCommand(program);
6622
6972
  registerPlatformCommand(program);
6623
6973
  registerServicesCommand(program);
6624
6974
  registerSeedCommands(program);
6625
- program.parseAsync(process.argv).catch((err) => {
6975
+ program.parseAsync(argv).catch((err) => {
6626
6976
  process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}
6627
6977
  `);
6628
6978
  process.exit(1);
6629
6979
  });
6980
+ function consumeEnvironmentArg(argv2) {
6981
+ const next = argv2.slice(0, 2);
6982
+ for (let i = 2; i < argv2.length; i += 1) {
6983
+ const arg = argv2[i];
6984
+ if (!arg) continue;
6985
+ if (arg === "--env") {
6986
+ const value = argv2[i + 1];
6987
+ if (!value || value.startsWith("-")) {
6988
+ throw new Error("--env requires production or staging.");
6989
+ }
6990
+ process.env.TOPOLO_ENV = normalizeEnvironment(value);
6991
+ i += 1;
6992
+ continue;
6993
+ }
6994
+ if (arg.startsWith("--env=")) {
6995
+ process.env.TOPOLO_ENV = normalizeEnvironment(arg.slice("--env=".length));
6996
+ continue;
6997
+ }
6998
+ next.push(arg);
6999
+ }
7000
+ return next;
7001
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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": {