@descix/cli 1.0.7 → 1.0.8

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 (40) hide show
  1. package/README.md +1 -1
  2. package/agent-assets/guides/README.md +1 -1
  3. package/agent-assets/guides/dynamic-site-pattern.md +7 -16
  4. package/agent-assets/instructions/app-asset-validation.md +1 -1
  5. package/agent-assets/reference/architecture.md +20 -25
  6. package/agent-assets/reference/local-dev.md +1 -6
  7. package/agent-assets/reference/mcp-integration.md +15 -9
  8. package/agent-assets/reference/workspace-config.md +2 -2
  9. package/bin/descix.js +204 -547
  10. package/lib/command-visibility.js +213 -0
  11. package/lib/commands/corpus.js +2 -5
  12. package/lib/commands/kb.js +2 -2
  13. package/lib/commands/retired-kb-sync.js +5 -27
  14. package/lib/commands/status.js +0 -9
  15. package/lib/sdk-assets.js +28 -0
  16. package/lib/verb-invokes.generated.json +233 -0
  17. package/lib/workspace-config.js +9 -44
  18. package/package.json +6 -2
  19. package/templates/agent-claude.md +1 -3
  20. package/templates/agent-cline.md +1 -2
  21. package/templates/agent-copilot.md +1 -2
  22. package/templates/agent-cursor.md +1 -2
  23. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
  24. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
  25. package/agent-assets/rules/descix_mcp.template.mdc +0 -243
  26. package/lib/commands/briefer/errors.js +0 -88
  27. package/lib/commands/briefer/index.js +0 -322
  28. package/lib/commands/briefer/sources/canonical-sources.js +0 -129
  29. package/lib/commands/briefer/sources/entitlements.js +0 -220
  30. package/lib/commands/briefer/sources/environments.js +0 -205
  31. package/lib/commands/briefer/sources/identifiers.js +0 -214
  32. package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
  33. package/lib/commands/briefer/sources/routing.js +0 -275
  34. package/lib/commands/briefer/sources/what-is-not.js +0 -223
  35. package/lib/commands/briefer/stitcher.js +0 -134
  36. package/lib/commands/briefer/util/source-reader.js +0 -502
  37. package/lib/commands/mcp.js +0 -273
  38. package/lib/commands/update.js +0 -581
  39. package/lib/wizard/setup.js +0 -585
  40. package/templates/descix_mcp.mdc +0 -122
package/bin/descix.js CHANGED
@@ -31,8 +31,7 @@ import * as airdropCommands from '../lib/commands/airdrop.js';
31
31
  import { runInit } from '../lib/commands/init.js';
32
32
  // "May I prompt?" has ONE OWNER. No command in this file derives it.
33
33
  import { createPromptSession } from '../lib/interactive.js';
34
- import * as updateCommands from '../lib/commands/update.js';
35
- import { registerAllRetiredKbSync, refuseRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
34
+ import { registerAllRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
36
35
  import { runStatus } from '../lib/commands/status.js';
37
36
  import { runDoctor } from '../lib/commands/doctor.js';
38
37
  import { runHealth } from '../lib/commands/health.js';
@@ -40,7 +39,7 @@ import * as kbCommands from '../lib/commands/kb.js';
40
39
  import { kbVectorCell, kbCountSource } from '../lib/commands/kb-list-render.js';
41
40
  import * as corpusCommands from '../lib/commands/corpus.js';
42
41
  import * as modelConfigCommands from '../lib/commands/model-config.js';
43
- import * as brieferCommand from '../lib/commands/briefer/index.js';
42
+ import { isHelpInvocation, getCommandSurface, applyVisibility } from '../lib/command-visibility.js';
44
43
  import fs from 'fs/promises';
45
44
  import path from 'path';
46
45
  import os from 'os';
@@ -99,7 +98,8 @@ program
99
98
  .description('DeSciX CLI - Unified command-line interface')
100
99
  .version(CLI_VERSION)
101
100
  .option('--env <name>', 'Target environment: dev, demo, prod (overrides API URL)')
102
- .option('--api-url <url>', 'Direct API URL override (e.g., https://demo.descix.net)');
101
+ .option('--api-url <url>', 'Direct API URL override (e.g., https://demo.descix.net)')
102
+ .option('--admin', 'Show every command in --help, including admin-only ones (also: DESCIX_ADMIN=1). Hiding is a listing convenience only — a hidden command still runs; the server is the real gate.');
103
103
 
104
104
  // ============ Global Environment Override ============
105
105
  // Maps --env flag to DESCIX_API_URL before any command runs.
@@ -116,24 +116,27 @@ const ENV_URL_MAP = Object.fromEntries(
116
116
  // stays; the provenance is now RECORDED alongside it, in the same breath, so the owner can name
117
117
  // the actual source. Recording it here rather than deriving it in the owner keeps one owner of
118
118
  // the fact: the only code that knows a flag was passed is the code that read the flag.
119
- program.hook('preAction', (thisCommand) => {
120
- // Read from the ROOT program, not from the action command. `--env` and `--api-url` are
121
- // declared ONCE, globally, and Commander binds them to the root — an identically-named option
122
- // redeclared on a subcommand is shadowed and silently never populated (measured on 1.0.4:
123
- // `descix config init --env dev` reached its action with `{}` and rejected its own advertised
124
- // flag). One declaration, read from where Commander actually puts it.
125
- const opts = program.opts();
126
-
119
+ //
120
+ // ONE OWNER of "apply --api-url / --env to DESCIX_API_URL", called from TWO sites: the
121
+ // `preAction` hook below (which only ever fires before an ACTION runs — never for `--help`,
122
+ // since Commander short-circuits help before reaching any action), and the help-time admin-
123
+ // surface bootstrap near the bottom of this file (which runs BEFORE `program.parseAsync()`, so
124
+ // `program.opts()` is not populated yet and the two flags are read off raw argv instead). Without
125
+ // this second call site, `descix --api-url https://X --help` silently queried the DEFAULT origin
126
+ // for the admin-listing surface while claiming to target X — the same class of misreport
127
+ // `resolveEffectiveOrigin` exists to end, just newly reachable through the one invocation shape
128
+ // (`--help`) that never runs an action.
129
+ function applyGlobalOriginOverride({ apiUrl, env }) {
127
130
  // --api-url takes highest priority
128
- if (opts.apiUrl) {
129
- process.env.DESCIX_API_URL = opts.apiUrl;
130
- recordInvocationOrigin(opts.apiUrl, 'flagApiUrl');
131
+ if (apiUrl) {
132
+ process.env.DESCIX_API_URL = apiUrl;
133
+ recordInvocationOrigin(apiUrl, 'flagApiUrl');
131
134
  return;
132
135
  }
133
136
 
134
137
  // --env maps to known URLs
135
- if (opts.env) {
136
- const envName = opts.env.toLowerCase();
138
+ if (env) {
139
+ const envName = env.toLowerCase();
137
140
  if (!(envName in ENV_URL_MAP)) {
138
141
  // THE REFUSAL CARRIES THE CUSTOM-ENVIRONMENT PATH. This validator runs BEFORE any command
139
142
  // action, so it is now the only thing a developer typing `--env staging` ever sees — the
@@ -142,7 +145,7 @@ program.hook('preAction', (thisCommand) => {
142
145
  // names would strand every self-hosted or port-forwarded gateway with no next step, which
143
146
  // is the same dead end as a remedy string for a command that does not run.
144
147
  console.error(chalk.red(
145
- `Unknown environment: ${opts.env}. Known environments: ${Object.keys(ENV_URL_MAP).join(', ')}.\n\n` +
148
+ `Unknown environment: ${env}. Known environments: ${Object.keys(ENV_URL_MAP).join(', ')}.\n\n` +
146
149
  'For a self-hosted or port-forwarded gateway, name it with its origin instead:\n' +
147
150
  ` descix config set-env ${envName} --url https://...`
148
151
  ));
@@ -153,6 +156,16 @@ program.hook('preAction', (thisCommand) => {
153
156
  process.env.DESCIX_API_URL = ENV_URL_MAP[envName];
154
157
  recordInvocationOrigin(ENV_URL_MAP[envName], 'flagEnv');
155
158
  }
159
+ }
160
+
161
+ program.hook('preAction', (thisCommand) => {
162
+ // Read from the ROOT program, not from the action command. `--env` and `--api-url` are
163
+ // declared ONCE, globally, and Commander binds them to the root — an identically-named option
164
+ // redeclared on a subcommand is shadowed and silently never populated (measured on 1.0.4:
165
+ // `descix config init --env dev` reached its action with `{}` and rejected its own advertised
166
+ // flag). One declaration, read from where Commander actually puts it.
167
+ const opts = program.opts();
168
+ applyGlobalOriginOverride({ apiUrl: opts.apiUrl, env: opts.env });
156
169
  });
157
170
 
158
171
  // ============ Authentication Commands (No Auth Required) ============
@@ -302,32 +315,6 @@ program
302
315
  }
303
316
  });
304
317
 
305
- // ============ Briefer Command (Code-Grounded Mental Model Regen) ============
306
- // WS-DESCIX-BRIEFER-CLI M1: CLI scaffolding + extractor contract.
307
- // See DeSciX/DeSciX_Core/descix-cli/lib/commands/briefer/ for implementation.
308
- // Scope doc: docs/design/ws-descix-briefer-cli.md
309
- // Briefer target: DeSciX/V2_docs/architecture/platform-must-know-briefer.md
310
-
311
- program
312
- .command('briefer')
313
- .description('Regenerate platform-must-know-briefer.md from live code + gcloud + Firestore (HARD-FAIL on drift)')
314
- .option('--env <name>', 'Target environment: dev|demo|prod (also accepted at the top level: descix --env=demo briefer ...)')
315
- .option('--out <path>', 'Override output path (default: workspace-root/DeSciX/V2_docs/architecture/platform-must-know-briefer.md)')
316
- .option('--check', 'Drift-detection mode: regen to memory, diff against canonical, non-zero exit on drift')
317
- .option('-v, --verbose', 'Print per-source paths, citations, and timings')
318
- .action(async (options) => {
319
- try {
320
- // Resolve --env in priority order: subcommand explicit > program parent > 'dev'.
321
- // Commander would otherwise silently default to 'dev' even when the parent
322
- // --env=demo is supplied (because the subcommand owns its own flag space).
323
- const parentEnv = program.opts().env || null;
324
- const env = options.env || parentEnv || 'dev';
325
- await brieferCommand.runBriefer({ ...options, env });
326
- } catch (error) {
327
- console.error(chalk.red(`\n❌ Briefer command failed: ${error.message}\n`));
328
- process.exit(1);
329
- }
330
- });
331
318
 
332
319
  // ============ Buy Commands (Crypto Payments) ============
333
320
 
@@ -433,7 +420,7 @@ creditsCommand
433
420
 
434
421
  creditsCommand
435
422
  .command('grant')
436
- .description('ADMIN: grant AI credits to a user')
423
+ .description('Grant AI credits to a user')
437
424
  .requiredOption('--user <user_id>', 'Target user id')
438
425
  .requiredOption('--usd <amount>', 'USD amount to grant')
439
426
  .requiredOption('--reason <text>', 'Audit reason')
@@ -447,7 +434,7 @@ creditsCommand
447
434
 
448
435
  creditsCommand
449
436
  .command('refund')
450
- .description('ADMIN: remove AI credits from a user (e.g. after a Stripe refund)')
437
+ .description('Remove AI credits from a user (e.g. after a Stripe refund)')
451
438
  .requiredOption('--user <user_id>', 'Target user id')
452
439
  .requiredOption('--usd <amount>', 'USD amount to remove')
453
440
  .requiredOption('--reason <text>', 'Audit reason')
@@ -593,7 +580,7 @@ communityCommand
593
580
 
594
581
  communityCommand
595
582
  .command('create')
596
- .description('[ADMIN] Create a new community with token contract (requires platform admin, deploys to live Polygon)')
583
+ .description('Create a new community with token contract (requires platform admin, deploys to live Polygon)')
597
584
  .requiredOption('-n, --name <name>', 'Community display name (e.g. "SMILE")')
598
585
  .requiredOption('-t, --token <symbol>', 'Token symbol (e.g. SMILE)')
599
586
  .option('--icon <url>', 'Icon URL for the community')
@@ -661,7 +648,7 @@ communityCommand
661
648
 
662
649
  communityCommand
663
650
  .command('delete')
664
- .description('[ADMIN] Delete a community with full cascade cleanup (all apps, KBs, vectors, GCS)')
651
+ .description('Delete a community with full cascade cleanup (all apps, KBs, vectors, GCS)')
665
652
  .requiredOption('-n, --name <community_id>', 'Community ID to delete')
666
653
  .option('--dry-run', 'Preview what would be deleted without executing')
667
654
  .option('--soft', 'Soft delete only (mark as hidden, no cascade)')
@@ -770,7 +757,7 @@ communityCommand
770
757
 
771
758
  communityCommand
772
759
  .command('rename')
773
- .description('[ADMIN] Rename a community_id across all surfaces (Firestore, Products, Pinecone metadata, ServiceManifests, descix-chain registry). On-chain contract + token symbol are UNTOUCHED. Dry-run first.')
760
+ .description('Rename a community_id across all surfaces (Firestore, Products, Pinecone metadata, ServiceManifests, descix-chain registry). On-chain contract + token symbol are UNTOUCHED. Dry-run first.')
774
761
  .argument('<old_community_id>', 'Current community_id (e.g. unkamon)')
775
762
  .argument('<new_community_id>', 'New canonical community_id (e.g. unk)')
776
763
  .option('--dry-run', 'Preview the full cascade plan without executing')
@@ -1207,26 +1194,6 @@ appCommand
1207
1194
  }
1208
1195
  });
1209
1196
 
1210
- appCommand
1211
- .command('create')
1212
- // DELETED SURFACE (CEO-D-2026-08-14, SUPER-DRY: delete the old path, fail loud, no compat
1213
- // fence). `app create` created the platform record and STOPPED, leaving the caller to
1214
- // remember `app init` — two commands for one outcome. That is how apps ended up half-made
1215
- // with no knowledge base at all. `app init` now creates AND initializes in one path.
1216
- .description('[REMOVED] Use `descix app init -a <name> -c <community>` — it creates and initializes in one step.')
1217
- // Swallow whatever the old invocation passed so the caller always gets the pointer below
1218
- // rather than a generic commander arity error that names no replacement.
1219
- .allowUnknownOption(true)
1220
- .argument('[ignored...]')
1221
- .action(() => {
1222
- console.error(chalk.red('\n❌ `descix app create` has been removed.\n'));
1223
- console.error(chalk.white(' Use the single canonical path, which creates AND initializes:\n'));
1224
- console.error(chalk.cyan(' descix app init -a <app-name> -c <community> [-s <short>]\n'));
1225
- console.error(chalk.gray(' It registers the app (Products + Firestore + entitlement), guarantees'));
1226
- console.error(chalk.gray(' the default knowledge base, then registers and scaffolds it locally.'));
1227
- console.error(chalk.gray(' Creating an app without a default KB is no longer possible.\n'));
1228
- process.exit(1);
1229
- });
1230
1197
 
1231
1198
  // descix app media-upload — upload media/asset files to an app's GCS assets prefix via the
1232
1199
  // API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half).
@@ -1361,45 +1328,6 @@ appCommand
1361
1328
  }
1362
1329
  });
1363
1330
 
1364
- appCommand
1365
- .command('set-codesite')
1366
- .description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
1367
- .option('-c, --community <id>', 'Community ID (uses context if not provided)')
1368
- .option('-a, --app <id>', 'App ID (uses context if not provided)')
1369
- .requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
1370
- .action(async (options) => {
1371
- try {
1372
- const apiClient = new DeSciXApiClient();
1373
- await requireAuth(apiClient);
1374
-
1375
- // Load workspace context
1376
- const workspaceConfig = await WorkspaceConfig.load();
1377
- const ctx = workspaceConfig.resolveContextWithOptions(options);
1378
-
1379
- const communityId = ctx.communityId;
1380
- const appId = ctx.appId;
1381
-
1382
- if (!communityId || !appId) {
1383
- console.error(chalk.red('\n❌ Community and App ID required.'));
1384
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1385
- process.exit(1);
1386
- }
1387
-
1388
- const response = await apiClient.invoke('update_app_metadata', {
1389
- community_id: communityId,
1390
- app_id: appId,
1391
- ip_site_gcs_path_url: options.url
1392
- });
1393
- const result = response.message || response;
1394
-
1395
- console.log(chalk.green('\n✅ CodeSite URL updated!\n'));
1396
- console.log(chalk.cyan(` App: ${communityId}/${appId}`));
1397
- console.log(chalk.gray(` CodeSite URL: ${options.url}\n`));
1398
- } catch (error) {
1399
- console.error(chalk.red(error.message));
1400
- process.exit(1);
1401
- }
1402
- });
1403
1331
 
1404
1332
  appCommand
1405
1333
  .command('set-price')
@@ -1460,6 +1388,43 @@ appCommand
1460
1388
  }
1461
1389
  });
1462
1390
 
1391
+ appCommand
1392
+ .command('set-listed')
1393
+ .description('List or unlist your app in the app store (on: shown in the public store; off: not shown to non-admin visitors)')
1394
+ .argument('<state>', 'on or off')
1395
+ .requiredOption('-c, --community <id>', 'Community ID')
1396
+ .requiredOption('-a, --app <id>', 'App ID')
1397
+ .action(async (state, options) => {
1398
+ try {
1399
+ const wanted = { on: true, off: false }[String(state).toLowerCase()];
1400
+ if (wanted === undefined) {
1401
+ throw new Error(`set-listed takes 'on' or 'off', not '${state}'.`);
1402
+ }
1403
+ const apiClient = new DeSciXApiClient();
1404
+ await requireAuth(apiClient);
1405
+
1406
+ const response = await apiClient.invoke('update_app', {
1407
+ community_id: options.community,
1408
+ app_id: options.app,
1409
+ listed: wanted
1410
+ });
1411
+ const result = response.message || response;
1412
+ // The receipt is the value the SERVER holds after the write, never the value requested.
1413
+ const stored = result?.app?.listed;
1414
+ if (stored !== wanted) {
1415
+ throw new Error(`update_app returned listed=${JSON.stringify(stored)} for ${options.app}; expected ${wanted}. The store listing did not change.`);
1416
+ }
1417
+
1418
+ console.log(chalk.green(`\n✅ App ${stored ? 'listed' : 'unlisted'}.\n`));
1419
+ console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
1420
+ console.log(chalk.gray(` listed: ${stored}`));
1421
+ console.log(chalk.gray(' The store view is cached for up to 5 minutes, so the change can take that long to show.\n'));
1422
+ } catch (error) {
1423
+ console.error(chalk.red(error.message));
1424
+ process.exit(1);
1425
+ }
1426
+ });
1427
+
1463
1428
  appCommand
1464
1429
  .command('set-api-url')
1465
1430
  .description('Set API base URL for app\'s own backend')
@@ -1848,7 +1813,7 @@ appCommand
1848
1813
  // Canonical write path for an app's microservice.port in workspace.json.
1849
1814
  // `descix microservice init` READS env.products[<app>].microservice.port and hard-fails
1850
1815
  // if it is missing; this command is how that port is set, without hand-editing workspace.json.
1851
- // Backed by WorkspaceConfig.setMicroservicePort (parallel to setSitePort).
1816
+ // Backed by WorkspaceConfig.setMicroservicePort.
1852
1817
  appCommand
1853
1818
  .command('set-port')
1854
1819
  .description('Set the microservice port for a mapped app (writes env.products[<app>].microservice.port). Pass "n" to remove.')
@@ -1893,10 +1858,8 @@ appCommand
1893
1858
  // the app's localPath; "." means the localPath itself). Optionally sets site.port for
1894
1859
  // dev-server sites. Parallel to `descix app set-port` (microservice.port); closes the
1895
1860
  // site.static gap so workspace.json never needs hand-editing (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1896
- // Backed by WorkspaceConfig.setStaticSite (parallel to setSitePort/setMicroservicePort).
1897
- //
1898
- // NOTE: This is NOT `set-codesite` — that writes the Firestore ip_site_gcs_path_url (a prod
1899
- // concern). set-site writes ONLY the local workspace.json site.{} slot.
1861
+ // Backed by WorkspaceConfig.setStaticSite (parallel to setMicroservicePort).
1862
+ // It writes ONLY the local workspace.json site.{} slot.
1900
1863
  appCommand
1901
1864
  .command('set-site')
1902
1865
  .description("Set a mapped app static-site config (writes env.products[<app>].site.static, the relative dir served at /p/<app>/). Optionally --port; --unset clears site.{}.")
@@ -2119,32 +2082,6 @@ KB's vectors by id-prefix). The Source column shows how each count was obtained:
2119
2082
  }
2120
2083
  });
2121
2084
 
2122
- kbCommand
2123
- .command('create')
2124
- .description('Create a new knowledge base in an app')
2125
- .requiredOption('-c, --community <id>', 'Community ID')
2126
- .requiredOption('-a, --app <id>', 'App ID')
2127
- .requiredOption('-k, --kb <id>', 'Knowledge Base ID')
2128
- .action(async (options) => {
2129
- try {
2130
- const apiClient = new DeSciXApiClient();
2131
- await requireAuth(apiClient);
2132
-
2133
- const response = await apiClient.invoke('create_skeleton_kb', {
2134
- community_id: options.community,
2135
- app_id: options.app,
2136
- kb_name: options.kb
2137
- });
2138
- const result = response.message || response;
2139
-
2140
- console.log(chalk.green('\n✅ Knowledge Base created successfully!\n'));
2141
- console.log(chalk.cyan(` KB ID: ${options.kb}`));
2142
- console.log(chalk.gray(` App: ${options.community}/${options.app}`));
2143
- } catch (error) {
2144
- console.error(chalk.red(error.message));
2145
- process.exit(1);
2146
- }
2147
- });
2148
2085
 
2149
2086
  kbCommand
2150
2087
  .command('delete')
@@ -2514,7 +2451,7 @@ driveCommand
2514
2451
 
2515
2452
  const siteCommand = program
2516
2453
  .command('site')
2517
- .description('Site lifecycle management: init, upload, servelocal');
2454
+ .description('Site lifecycle management: init, upload, status, list, delete');
2518
2455
 
2519
2456
  // site init - Copy scaffold to app
2520
2457
  siteCommand
@@ -2570,8 +2507,7 @@ siteCommand
2570
2507
  .option('-a, --app <id>', 'App ID (auto-detects from context)')
2571
2508
  .option('-p, --path <localPath>', 'Local directory to deploy (ignored when site manifest exists)', './site')
2572
2509
  .option('--preview', 'Deploy to preview path')
2573
- .option('--full', 'Force full upload (ignore delta)')
2574
- .option('--dry-run', 'Show what would be deployed')
2510
+ .option('--dry-run', 'Show what would be uploaded and which server files would be removed')
2575
2511
  .option('--no-cache', 'Set Cache-Control: no-cache')
2576
2512
  .action(async (options) => {
2577
2513
  try {
@@ -2744,92 +2680,76 @@ siteCommand
2744
2680
 
2745
2681
  const { signed_urls, upload_headers, existing_manifest, token_id, site_url } = tokenResponse.message;
2746
2682
 
2747
- // 3. Determine delta
2748
- let filesToUpload = fileList;
2749
- let filesToDelete = [];
2750
-
2751
- if (!options.full && existing_manifest) {
2752
- const delta = gitUtils.compareSyncState(localFiles, existing_manifest);
2753
- filesToUpload = fileList.filter(f =>
2754
- delta.added.includes(f.path) || delta.modified.includes(f.path)
2755
- );
2756
- filesToDelete = delta.deleted;
2757
-
2758
- console.log(chalk.gray(` Delta: ${delta.added.length} added, ${delta.modified.length} modified, ${delta.unchanged.length} unchanged, ${delta.deleted.length} deleted`));
2759
- }
2683
+ // 3. A deploy REPLACES the site: every file of this build is uploaded, and on confirm the
2684
+ // server deletes every site file this build does not contain (confirm_site_deploy).
2685
+ const filesToUpload = fileList;
2760
2686
 
2761
2687
  if (options.dryRun) {
2762
2688
  console.log(chalk.yellow('\n Dry run - would upload:'));
2763
- if (filesToUpload.length === 0) {
2764
- console.log(chalk.gray(' (no files to upload)'));
2765
- } else {
2766
- filesToUpload.forEach(f => console.log(chalk.gray(` + ${f.path} (${(f.size / 1024).toFixed(1)}KB)`)));
2767
- }
2768
- if (filesToDelete.length > 0) {
2769
- console.log(chalk.yellow('\nWould delete:'));
2770
- filesToDelete.forEach(f => console.log(chalk.gray(` - ${f}`)));
2689
+ filesToUpload.forEach(f => console.log(chalk.gray(` + ${f.path} (${(f.size / 1024).toFixed(1)}KB)`)));
2690
+ // The server's last deploy manifest names what it holds; the confirm step lists the
2691
+ // storage prefix itself, so this preview is the manifest's view of what would go.
2692
+ const lastDeployed = Object.keys(existing_manifest?.files || {});
2693
+ const wouldRemove = lastDeployed.filter(p => !(p in localFiles)).sort();
2694
+ if (wouldRemove.length > 0) {
2695
+ console.log(chalk.yellow('\n Would remove from the server (in the last deploy, not in this build):'));
2696
+ wouldRemove.forEach(f => console.log(chalk.gray(` - ${f}`)));
2771
2697
  }
2772
2698
  console.log(chalk.gray(`\n Site URL: ${site_url}\n`));
2773
2699
  return;
2774
2700
  }
2775
2701
 
2776
- if (filesToUpload.length === 0) {
2777
- console.log(chalk.green('\n Site already up to date on GCS.'));
2778
- console.log(chalk.gray(' Ensuring app metadata is synchronized...'));
2779
- // Fall through to confirm_site_deploy to ensure DB is updated
2780
- } else {
2781
- // 4. Upload files directly to GCS using signed URLs
2782
- console.log(chalk.gray(`\n Uploading ${filesToUpload.length} files...`));
2702
+ // 4. Upload files directly to GCS using signed URLs
2703
+ console.log(chalk.gray(`\n Uploading ${filesToUpload.length} files...`));
2783
2704
 
2784
- let uploadedCount = 0;
2785
- let uploadErrors = [];
2705
+ let uploadedCount = 0;
2706
+ let uploadErrors = [];
2786
2707
 
2787
- for (const file of filesToUpload) {
2788
- const signedUrl = signed_urls[file.path];
2789
- if (!signedUrl) {
2790
- uploadErrors.push(`No signed URL for: ${file.path}`);
2791
- continue;
2792
- }
2708
+ for (const file of filesToUpload) {
2709
+ const signedUrl = signed_urls[file.path];
2710
+ if (!signedUrl) {
2711
+ uploadErrors.push(`No signed URL for: ${file.path}`);
2712
+ continue;
2713
+ }
2793
2714
 
2794
- try {
2795
- const content = await fs.readFile(file._absolutePath);
2796
-
2797
- // Canonical contract (WS-DEPLOY-HARDENING item 4): the server binds Cache-Control
2798
- // into the v4 signed URL signature (generateSiteUploadUrls/siteAssetCacheControl).
2799
- // Ferry the exact header bag the server minted — never re-derive the policy
2800
- // client-side. A mismatched header (including a client-side --no-cache override)
2801
- // makes GCS return 403 signature-mismatch, so once the server ferries a bound
2802
- // header for this path, --no-cache is a no-op for it (falls back to legacy
2803
- // Content-Type-only behavior for paths the server didn't ferry a header for).
2804
- const ferriedHeaders = upload_headers && upload_headers[file.path];
2805
- const headers = ferriedHeaders || {
2806
- 'Content-Type': file.content_type,
2807
- ...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
2808
- };
2809
-
2810
- const response = await fetch(signedUrl, {
2811
- method: 'PUT',
2812
- headers,
2813
- body: content
2814
- });
2715
+ try {
2716
+ const content = await fs.readFile(file._absolutePath);
2717
+
2718
+ // Canonical contract (WS-DEPLOY-HARDENING item 4): the server binds Cache-Control
2719
+ // into the v4 signed URL signature (generateSiteUploadUrls/siteAssetCacheControl).
2720
+ // Ferry the exact header bag the server minted — never re-derive the policy
2721
+ // client-side. A mismatched header (including a client-side --no-cache override)
2722
+ // makes GCS return 403 signature-mismatch, so once the server ferries a bound
2723
+ // header for this path, --no-cache is a no-op for it (falls back to legacy
2724
+ // Content-Type-only behavior for paths the server didn't ferry a header for).
2725
+ const ferriedHeaders = upload_headers && upload_headers[file.path];
2726
+ const headers = ferriedHeaders || {
2727
+ 'Content-Type': file.content_type,
2728
+ ...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
2729
+ };
2815
2730
 
2816
- if (!response.ok) {
2817
- uploadErrors.push(`Failed to upload ${file.path}: ${response.status} ${response.statusText}`);
2818
- console.log(chalk.red(` x ${file.path}`));
2819
- } else {
2820
- uploadedCount++;
2821
- console.log(chalk.green(` + ${file.path}`));
2822
- }
2823
- } catch (err) {
2824
- uploadErrors.push(`Error uploading ${file.path}: ${err.message}`);
2731
+ const response = await fetch(signedUrl, {
2732
+ method: 'PUT',
2733
+ headers,
2734
+ body: content
2735
+ });
2736
+
2737
+ if (!response.ok) {
2738
+ uploadErrors.push(`Failed to upload ${file.path}: ${response.status} ${response.statusText}`);
2825
2739
  console.log(chalk.red(` x ${file.path}`));
2740
+ } else {
2741
+ uploadedCount++;
2742
+ console.log(chalk.green(` + ${file.path}`));
2826
2743
  }
2744
+ } catch (err) {
2745
+ uploadErrors.push(`Error uploading ${file.path}: ${err.message}`);
2746
+ console.log(chalk.red(` x ${file.path}`));
2827
2747
  }
2748
+ }
2828
2749
 
2829
- if (uploadErrors.length > 0) {
2830
- console.log(chalk.yellow(`\n ${uploadErrors.length} errors during upload:`));
2831
- uploadErrors.forEach(e => console.log(chalk.red(` - ${e}`)));
2832
- }
2750
+ if (uploadErrors.length > 0) {
2751
+ uploadErrors.forEach(e => console.log(chalk.red(` - ${e}`)));
2752
+ throw new Error(`${uploadErrors.length} of ${filesToUpload.length} files failed to upload. The deploy was not confirmed and nothing was removed from the server.`);
2833
2753
  }
2834
2754
 
2835
2755
  // 5. Confirm deployment (ALWAYS call this to ensure DB is updated)
@@ -2856,6 +2776,12 @@ siteCommand
2856
2776
  console.log(chalk.green(`\n Site uploaded successfully!\n`));
2857
2777
  console.log(chalk.cyan(` URL: ${result.site_url}`));
2858
2778
  console.log(chalk.gray(` Files: ${result.files_count}`));
2779
+ const pruned = Array.isArray(result.pruned_files) ? result.pruned_files : null;
2780
+ if (pruned === null) {
2781
+ throw new Error('confirm_site_deploy returned no pruned_files list: this server does not replace the site on deploy, so files from earlier builds may still be served.');
2782
+ }
2783
+ console.log(chalk.gray(` Removed from the server: ${pruned.length}`));
2784
+ pruned.forEach(f => console.log(chalk.gray(` - ${f}`)));
2859
2785
  console.log(chalk.gray(` Deployed: ${result.deployed_at}`));
2860
2786
  if (result.preview) {
2861
2787
  console.log(chalk.yellow(` Mode: PREVIEW`));
@@ -2863,7 +2789,7 @@ siteCommand
2863
2789
 
2864
2790
  if (!options.preview) {
2865
2791
  console.log(chalk.gray(`\n For local development, run:`));
2866
- console.log(chalk.cyan(` descix site servelocal <port>\n`));
2792
+ console.log(chalk.cyan(` descix serve --app ${appId}\n`));
2867
2793
  }
2868
2794
  console.log('');
2869
2795
 
@@ -2873,75 +2799,6 @@ siteCommand
2873
2799
  }
2874
2800
  });
2875
2801
 
2876
- // site servelocal - Register local dev server port
2877
- siteCommand
2878
- .command('servelocal [port]')
2879
- .description('Register local dev server port (or "n" to disable)')
2880
- .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2881
- .option('-a, --app <id>', 'App ID (auto-detects from context)')
2882
- .action(async (port, options) => {
2883
- try {
2884
- const workspaceConfig = await WorkspaceConfig.load();
2885
- const ctx = workspaceConfig.resolveContextWithOptions(options);
2886
-
2887
- const appId = ctx.appId;
2888
-
2889
- if (!appId) {
2890
- console.error(chalk.red('\n❌ App ID required.'));
2891
- console.log(chalk.gray(' Either provide -a flag, or cd into an app directory\n'));
2892
- process.exit(1);
2893
- }
2894
-
2895
- // Handle disable case — setSitePort(appId, null) removes site.port / cleans site.{}
2896
- if (port === 'n' || port === 'N') {
2897
- await workspaceConfig.setSitePort(appId, null);
2898
- console.log(chalk.green(`\n✅ Local site server disabled for ${appId}\n`));
2899
- return;
2900
- }
2901
-
2902
- // Handle status query (no port argument)
2903
- if (!port) {
2904
- const appConfig = workspaceConfig.getAppByAppId(appId);
2905
- if (!appConfig) {
2906
- console.error(chalk.red(`\n❌ ${unmappedAppMessage(appId)}\n`));
2907
- process.exit(1);
2908
- }
2909
- // Read site.port from the live env entry (not from the constructed copy)
2910
- let liveEntry = null;
2911
- if (workspaceConfig.env?.platform?.appId === appId) {
2912
- liveEntry = workspaceConfig.env.platform;
2913
- } else if (Array.isArray(workspaceConfig.env?.products)) {
2914
- liveEntry = workspaceConfig.env.products.find(p => p.appId === appId) || null;
2915
- }
2916
- const currentPort = liveEntry?.site?.port;
2917
- if (currentPort) {
2918
- console.log(chalk.cyan(`\n📍 Local site server: port ${currentPort}`));
2919
- console.log(chalk.gray(` App: ${appId}\n`));
2920
- } else {
2921
- console.log(chalk.yellow(`\n⚠️ No local site server configured.`));
2922
- console.log(chalk.gray(` Usage: descix site servelocal <port>\n`));
2923
- }
2924
- return;
2925
- }
2926
-
2927
- const portNum = parseInt(port);
2928
- if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
2929
- console.error(chalk.red('\n❌ Invalid port number.\n'));
2930
- process.exit(1);
2931
- }
2932
-
2933
- // setSitePort mutates the live env entry and saves — no stale-copy problem
2934
- await workspaceConfig.setSitePort(appId, portNum);
2935
- console.log(chalk.green(`\n✅ Local site server registered!`));
2936
- console.log(chalk.cyan(` Port: ${portNum}`));
2937
- console.log(chalk.gray(` App: ${appId}`));
2938
- console.log(chalk.gray(`\n The gateway will proxy /p/${appId}/* to localhost:${portNum}\n`));
2939
-
2940
- } catch (error) {
2941
- console.error(chalk.red(`\n❌ ${error.message}\n`));
2942
- process.exit(1);
2943
- }
2944
- });
2945
2802
 
2946
2803
  // site list - List deployed files (context-aware)
2947
2804
  siteCommand
@@ -3270,127 +3127,6 @@ microserviceCommand
3270
3127
  }
3271
3128
  });
3272
3129
 
3273
- // microservice deploy - Cloud Run deploy (broker-first; no per-app LB for non-core apps)
3274
- // WS-MCP-SURFACE-SPLIT §4b: this is the REAL Admin/local deploy (Admin SDK) — it stays fully
3275
- // functional for operator/CI use (EVP/COS deploy runbooks invoke it). It is intentionally NOT
3276
- // advertised over MCP (not mcp:true), so the PUBLIC surface sees no deploy tool. The public
3277
- // "microservice deploy" is "coming soon" (a no-op) — satisfied here by absence from the MCP
3278
- // listing, NOT by neutering this admin path. Do not stub this out.
3279
- microserviceCommand
3280
- .command('deploy')
3281
- .description('[ADMIN/LOCAL] Deploy microservice to Cloud Run (uses deploy-service-env.sh; powch re-provisions platform NEG only). Public MCP deploy is coming soon; this real deploy is Admin/local-only.')
3282
- .option('-c, --community <id>', 'Community ID (auto-detects from context)')
3283
- .option('-a, --app <id>', 'App ID (auto-detects from context)')
3284
- .option('--env <env>', 'Target environment: dev|demo|prod')
3285
- .option('--dry-run', 'Print deploy plan without executing gcloud')
3286
- .option('--skip-register', 'Skip manifest registration after deploy (service may self-register on boot)')
3287
- .action(async (options, command) => {
3288
- try {
3289
- const apiClient = new DeSciXApiClient();
3290
- await requireAuth(apiClient);
3291
-
3292
- const workspaceConfig = await WorkspaceConfig.load();
3293
- const ctx = workspaceConfig.resolveContextWithOptions(options);
3294
- const appId = ctx.appId;
3295
-
3296
- if (!appId) {
3297
- console.error(chalk.red('\n❌ App ID required.'));
3298
- console.log(chalk.gray(' cd into an app directory, or use -a flag\n'));
3299
- process.exit(1);
3300
- }
3301
-
3302
- const parentEnv = command?.parent?.opts?.()?.env;
3303
- const deployEnv = (options.env || parentEnv || workspaceConfig.env?.environment)?.toLowerCase();
3304
- if (!deployEnv || !['dev', 'demo', 'prod'].includes(deployEnv)) {
3305
- console.error(chalk.red('\n❌ --env is required (dev|demo|prod).'));
3306
- console.log(chalk.gray(' Example: descix --env demo microservice deploy -a powch\n'));
3307
- process.exit(1);
3308
- }
3309
-
3310
- const workspaceRoot = workspaceConfig.getWorkspaceRoot();
3311
- if (!workspaceRoot) {
3312
- throw new Error('Could not resolve workspace root (.descix/workspace.json not found)');
3313
- }
3314
-
3315
- const deployScript = path.join(
3316
- workspaceRoot,
3317
- 'DeSciX/DeSciX_Cloud/microservice/admin/scripts/deploy/deploy-service-env.sh'
3318
- );
3319
-
3320
- try {
3321
- await fs.access(deployScript);
3322
- } catch {
3323
- throw new Error(`Deploy script not found: ${deployScript}`);
3324
- }
3325
-
3326
- console.log(chalk.cyan(`\n🚀 Deploying microservice: ${appId} (${deployEnv})\n`));
3327
- if (options.dryRun) {
3328
- console.log(chalk.yellow(' Dry run — gcloud deploy will not execute\n'));
3329
- }
3330
-
3331
- const { spawnSync } = await import('child_process');
3332
- const result = spawnSync('bash', [deployScript, appId, deployEnv], {
3333
- cwd: path.dirname(deployScript),
3334
- env: {
3335
- ...process.env,
3336
- ECHO_MODE: options.dryRun ? 'true' : 'false'
3337
- },
3338
- stdio: 'inherit'
3339
- });
3340
-
3341
- if (result.status !== 0) {
3342
- process.exit(result.status || 1);
3343
- }
3344
-
3345
- if (options.skipRegister) {
3346
- console.log(chalk.gray('\n Skipped manifest registration (--skip-register).\n'));
3347
- return;
3348
- }
3349
-
3350
- const microserviceDir = workspaceConfig.getMicroservicePath(appId);
3351
- if (!microserviceDir) {
3352
- console.log(chalk.yellow('\n⚠️ Deploy complete. Run descix microservice register to register manifest.\n'));
3353
- return;
3354
- }
3355
-
3356
- const manifestPath = path.join(microserviceDir, 'manifest.json');
3357
- try {
3358
- await fs.access(manifestPath);
3359
- } catch {
3360
- console.log(chalk.yellow('\n⚠️ Deploy complete. No manifest.json — run descix microservice register when ready.\n'));
3361
- return;
3362
- }
3363
-
3364
- console.log(chalk.cyan('\n📦 Registering microservice manifest...\n'));
3365
- const registerArgs = [
3366
- process.argv[1],
3367
- 'microservice',
3368
- 'register',
3369
- '-m', manifestPath,
3370
- '-a', appId
3371
- ];
3372
- if (ctx.communityId) {
3373
- registerArgs.push('-c', ctx.communityId);
3374
- }
3375
-
3376
- const registerResult = spawnSync(process.execPath, registerArgs, {
3377
- cwd: process.cwd(),
3378
- stdio: 'inherit',
3379
- env: process.env
3380
- });
3381
-
3382
- if (registerResult.status !== 0) {
3383
- console.log(chalk.yellow('\n⚠️ Cloud Run deploy succeeded but manifest registration failed.'));
3384
- console.log(chalk.gray(` Retry: descix microservice register -m ${manifestPath} -a ${appId}\n`));
3385
- process.exit(registerResult.status || 1);
3386
- }
3387
-
3388
- console.log(chalk.green('\n✅ Microservice deploy complete (Cloud Run + manifest registration).\n'));
3389
- } catch (error) {
3390
- console.error(chalk.red(`\n❌ Deploy failed: ${error.message}\n`));
3391
- process.exit(1);
3392
- }
3393
- });
3394
3130
 
3395
3131
  // microservice register - Register with gateway
3396
3132
  microserviceCommand
@@ -3734,7 +3470,7 @@ microserviceCommand
3734
3470
  // microservice reload - Reload manifests (admin)
3735
3471
  microserviceCommand
3736
3472
  .command('reload')
3737
- .description('Reload microservice manifests (admin only)')
3473
+ .description('Reload microservice manifests')
3738
3474
  .action(async () => {
3739
3475
  try {
3740
3476
  const apiClient = new DeSciXApiClient();
@@ -4004,45 +3740,6 @@ program
4004
3740
  }
4005
3741
  });
4006
3742
 
4007
- const repCommand = program
4008
- .command('rep')
4009
- .description('Reputation operations');
4010
-
4011
- repCommand
4012
- .command('grant')
4013
- .description('Grant REP points to a user (admin/dev only)')
4014
- .requiredOption('-c, --community <id>', 'Community ID')
4015
- .requiredOption('-u, --user <id>', 'User ID')
4016
- .requiredOption('-a, --amount <amount>', 'REP amount to grant')
4017
- .option('--reason <text>', 'Reason for granting REP')
4018
- .action(async (options) => {
4019
- try {
4020
- const apiClient = new DeSciXApiClient();
4021
- await requireAuth(apiClient);
4022
-
4023
- const amount = parseInt(options.amount);
4024
- if (isNaN(amount) || amount <= 0) {
4025
- throw new Error('Amount must be a positive integer');
4026
- }
4027
-
4028
- const response = await apiClient.invoke('increment_rep', {
4029
- community_id: options.community,
4030
- user_id: options.user,
4031
- amount: amount,
4032
- reason: options.reason || 'CLI grant'
4033
- });
4034
- const result = response.message || response;
4035
-
4036
- console.log(chalk.green('\n✅ REP granted!\n'));
4037
- console.log(chalk.cyan(` User: ${options.user}`));
4038
- console.log(chalk.gray(` Community: ${options.community}`));
4039
- console.log(chalk.gray(` Amount: +${amount} REP`));
4040
- console.log(chalk.gray(` New Total: ${result.new_total || result.rep || 'N/A'}\n`));
4041
- } catch (error) {
4042
- console.error(chalk.red(error.message));
4043
- process.exit(1);
4044
- }
4045
- });
4046
3743
 
4047
3744
  // ============ Chat Commands ============
4048
3745
 
@@ -4693,57 +4390,12 @@ program
4693
4390
 
4694
4391
  // ============ MCP Commands ============
4695
4392
 
4696
- import * as mcpCommands from '../lib/commands/mcp.js';
4697
4393
  import { runClone } from '../lib/commands/clone.js';
4698
4394
 
4699
4395
  const mcpCommand = program
4700
4396
  .command('mcp')
4701
4397
  .description('MCP server operations');
4702
4398
 
4703
- mcpCommand
4704
- .command('init')
4705
- .description('Initialize MCP server for this workspace')
4706
- .action(async () => {
4707
- try {
4708
- await mcpCommands.init();
4709
- } catch (error) {
4710
- fail(error);
4711
- }
4712
- });
4713
-
4714
- mcpCommand
4715
- .command('test')
4716
- .description('Test MCP server with a sample query')
4717
- .option('-q, --query <text>', 'Test query', 'What is this knowledge base about?')
4718
- .action(async (options) => {
4719
- try {
4720
- await mcpCommands.test(options);
4721
- } catch (error) {
4722
- fail(error);
4723
- }
4724
- });
4725
-
4726
- mcpCommand
4727
- .command('config')
4728
- .description('Show current workspace configuration')
4729
- .action(async () => {
4730
- try {
4731
- await mcpCommands.config();
4732
- } catch (error) {
4733
- fail(error);
4734
- }
4735
- });
4736
-
4737
- mcpCommand
4738
- .command('quickstart')
4739
- .description('One-command setup: authenticate, configure, and test')
4740
- .action(async () => {
4741
- try {
4742
- await mcpCommands.quickstart();
4743
- } catch (error) {
4744
- fail(error);
4745
- }
4746
- });
4747
4399
 
4748
4400
  mcpCommand
4749
4401
  .command('execute')
@@ -4948,55 +4600,6 @@ program
4948
4600
  }
4949
4601
  });
4950
4602
 
4951
- // ============ Update Commands (Context-Driven) ============
4952
-
4953
- const updateCommand = program
4954
- .command('update')
4955
- // `update kb` is REMOVED — kb sync has exactly one surface, `descix kb corpus sync`.
4956
- // `update app` and `update site` are NOT kb-sync surfaces and are untouched, so the
4957
- // `update` verb itself stays registered. `update all`/`auto` keep doing app and site and
4958
- // no longer touch the KB; they print where kb sync went rather than failing, because
4959
- // failing them would delete non-kb functionality this change never targeted.
4960
- .description('Context-driven resource sync for app and site (auto-detects from workspace). For knowledge bases use `descix kb corpus sync`.')
4961
- .argument('[type]', 'Update type: app, site, all (auto-detects if not specified)')
4962
- .option('-c, --community <id>', 'Community ID (optional; app_id sufficient for Unified Registry)')
4963
- .option('-a, --app <id>', 'App ID (globally unique; required if not in app directory)')
4964
- .option('--preview', 'Site: Deploy to preview URL')
4965
- .option('--build <cmd>', 'Site: Run build command first')
4966
- .option('--full', 'Force full sync (ignore delta)')
4967
- .option('--port <number>', 'Site: Register local dev server port')
4968
- .action(async (type, options) => {
4969
- try {
4970
- switch (type) {
4971
- case 'app':
4972
- await updateCommands.updateApp(options);
4973
- break;
4974
- case 'kb':
4975
- refuseRetiredKbSync('descix update kb', chalk.red);
4976
- break;
4977
- case 'site':
4978
- await updateCommands.updateSite({
4979
- ...options,
4980
- port: options.port ? parseInt(options.port) : undefined
4981
- });
4982
- break;
4983
- case 'all':
4984
- console.log(chalk.gray(` (knowledge bases are not included: use \`${CANONICAL_KB_SYNC}\`)`));
4985
- await updateCommands.updateAll(options);
4986
- break;
4987
- default:
4988
- // Auto-detect. NO blanket "KBs are excluded" line here: auto resolves to exactly
4989
- // one target, and if that target is the KB the user gets the refusal by name.
4990
- // Printing the advisory first would announce an exclusion and then refuse for
4991
- // that exact reason — an advisory that fires on correct behaviour trains people
4992
- // to ignore advisories.
4993
- await updateCommands.updateAuto(options);
4994
- }
4995
- } catch (error) {
4996
- fail(error);
4997
- }
4998
- });
4999
-
5000
4603
  // ============ Local Gateway Command ============
5001
4604
 
5002
4605
  program
@@ -5065,9 +4668,7 @@ program
5065
4668
  // resolution — silently, with "Quickstart complete!" and exit 0.
5066
4669
  //
5067
4670
  // quickstart is an ONBOARDING flow: a user who asked to be set up and already IS set up has
5068
- // SUCCEEDED, so this SKIPS and REPORTS and continues. `descix mcp quickstart` refuses non-zero
5069
- // on the same fact because it was asked specifically to CREATE a workspace and cannot honour
5070
- // that. Different verbs, different contracts, ONE OWNER for the underlying fact.
4671
+ // SUCCEEDED, so this SKIPS and REPORTS and continues.
5071
4672
  const existingRoot = await WorkspaceConfig.findWorkspaceRoot(workspaceRoot);
5072
4673
  if (existingRoot) {
5073
4674
  console.log(chalk.green('✓ Workspace already initialized'));
@@ -5111,11 +4712,9 @@ program
5111
4712
  }
5112
4713
 
5113
4714
  // Step 5: Copy SDK assets
5114
- try {
5115
- const { pullSdkAssets } = await import('../lib/wizard/setup.js');
5116
- const pulled = await pullSdkAssets(targetRoot);
5117
- if (pulled) console.log(chalk.green(' ✓ .descix/sdk-assets/'));
5118
- } catch { /* setup.js pullSdkAssets may not be exported — skip */ }
4715
+ const { pullSdkAssets } = await import('../lib/sdk-assets.js');
4716
+ await pullSdkAssets(targetRoot);
4717
+ console.log(chalk.green(' ✓ .descix/sdk-assets/'));
5119
4718
 
5120
4719
  // Done
5121
4720
  console.log(chalk.green('\n✅ Quickstart complete!\n'));
@@ -5138,11 +4737,69 @@ program
5138
4737
  }
5139
4738
  });
5140
4739
 
4740
+ // ============ Public-vs-admin help listing (CEO ruling, see lib/command-visibility.js) ============
4741
+ //
4742
+ // The class comes from the server (`get_command_surface`, guest-allowed) at the moment help is
4743
+ // about to render — never a committed table. Only a help-rendering invocation pays the network
4744
+ // cost: isHelpInvocation() is a fast, offline, syntactic check (no parsing, no I/O) so every
4745
+ // ordinary command keeps running at today's speed. `--admin` / `DESCIX_ADMIN=1` skip this
4746
+ // entirely, which is what "show everything" means — nothing is ever hidden unless this block
4747
+ // explicitly hides it.
4748
+ const rawArgv = process.argv.slice(2);
4749
+ const adminMode = rawArgv.includes('--admin') || process.env.DESCIX_ADMIN === '1';
4750
+
4751
+ /** Read `--flag <value>` or `--flag=<value>` out of raw argv, pre-parse — Commander has not run yet at this point. */
4752
+ function rawFlagValue(argv, flag) {
4753
+ const i = argv.indexOf(flag);
4754
+ if (i !== -1) return argv[i + 1];
4755
+ const joined = argv.find((tok) => tok.startsWith(`${flag}=`));
4756
+ return joined === undefined ? undefined : joined.slice(flag.length + 1);
4757
+ }
4758
+
4759
+ if (!adminMode && isHelpInvocation(program, rawArgv, { valueFlags: ['--env', '--api-url'], booleanFlags: ['--admin'] })) {
4760
+ // Apply --api-url / --env BEFORE constructing the client. The `preAction` hook above does the
4761
+ // same thing but only ever fires before an ACTION runs — Commander short-circuits straight to
4762
+ // help output without one, so for a help-rendering invocation this is the only place that
4763
+ // ever applies these flags. Without it, `descix --api-url https://X --help` silently asked
4764
+ // the DEFAULT origin (not X) whether each command is admin.
4765
+ applyGlobalOriginOverride({ apiUrl: rawFlagValue(rawArgv, '--api-url'), env: rawFlagValue(rawArgv, '--env') });
4766
+
4767
+ // BOUNDED, regardless of what the underlying HTTP client's own timeout is: "help must stay
4768
+ // fast" is a promise about THIS invocation, not about how patient axios feels like being
4769
+ // against an origin that accepts the TCP connection and then never answers.
4770
+ const SURFACE_FETCH_TIMEOUT_MS = 5000;
4771
+ let timeoutHandle;
4772
+ try {
4773
+ const apiClient = new DeSciXApiClient();
4774
+ await apiClient.ensureInitialized();
4775
+ const { commands } = await Promise.race([
4776
+ getCommandSurface(apiClient.baseUrl, apiClient),
4777
+ new Promise((_, reject) => {
4778
+ timeoutHandle = setTimeout(
4779
+ () => reject(new Error(`timed out after ${SURFACE_FETCH_TIMEOUT_MS}ms`)),
4780
+ SURFACE_FETCH_TIMEOUT_MS
4781
+ );
4782
+ timeoutHandle.unref?.(); // never itself the reason the process stays alive
4783
+ }),
4784
+ ]);
4785
+ applyVisibility(program, commands);
4786
+ } catch (error) {
4787
+ // FAIL LOUD ABOUT THE DEGRADATION, NEVER SILENTLY: help must still render (everything
4788
+ // visible, the safe default) — offline, an old server without the command, or any other
4789
+ // fetch failure is not a reason to hang or crash `--help`.
4790
+ console.error(chalk.gray(
4791
+ `(admin-only commands could not be filtered from this listing: ${error.message}. Showing everything. Use --admin to always see everything.)`
4792
+ ));
4793
+ } finally {
4794
+ clearTimeout(timeoutHandle);
4795
+ }
4796
+ }
4797
+
5141
4798
  // Parse arguments
5142
- program.parse();
4799
+ await program.parseAsync(process.argv);
5143
4800
 
5144
4801
  // Show help if no command provided
5145
- if (!process.argv.slice(2).length) {
4802
+ if (!rawArgv.length) {
5146
4803
  program.outputHelp();
5147
4804
  }
5148
4805