@descix/cli 1.0.6 → 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 (59) 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 +17 -30
  4. package/agent-assets/guides/knowledge-only-pattern.md +11 -15
  5. package/agent-assets/guides/microservice-pattern.md +12 -14
  6. package/agent-assets/guides/static-site-pattern.md +12 -15
  7. package/agent-assets/instructions/app-asset-validation.md +1 -1
  8. package/agent-assets/instructions/onboarding-flow.md +4 -4
  9. package/agent-assets/reference/app-creation.md +36 -101
  10. package/agent-assets/reference/architecture.md +43 -51
  11. package/agent-assets/reference/local-dev.md +1 -6
  12. package/agent-assets/reference/mcp-integration.md +21 -23
  13. package/agent-assets/reference/workspace-config.md +97 -340
  14. package/bin/descix.js +337 -618
  15. package/lib/api-client.js +30 -22
  16. package/lib/command-visibility.js +213 -0
  17. package/lib/commands/airdrop.js +10 -0
  18. package/lib/commands/auth.js +16 -2
  19. package/lib/commands/config.js +13 -31
  20. package/lib/commands/corpus.js +163 -59
  21. package/lib/commands/health.js +116 -21
  22. package/lib/commands/init.js +17 -45
  23. package/lib/commands/kb.js +7 -0
  24. package/lib/commands/retired-kb-sync.js +5 -27
  25. package/lib/commands/status.js +0 -9
  26. package/lib/core/Chunker.js +1 -1
  27. package/lib/core/CorpusWalker.js +238 -47
  28. package/lib/core/ManifestLoader.js +68 -0
  29. package/lib/core/Syncer.js +147 -46
  30. package/lib/interactive.js +158 -0
  31. package/lib/origin.js +3 -3
  32. package/lib/sdk-assets.js +28 -0
  33. package/lib/verb-invokes.generated.json +233 -0
  34. package/lib/wallet-file.js +36 -37
  35. package/lib/workspace-config.js +266 -199
  36. package/lib/workspace-identity.js +0 -1
  37. package/package.json +7 -3
  38. package/templates/agent-claude.md +1 -3
  39. package/templates/agent-cline.md +1 -2
  40. package/templates/agent-copilot.md +1 -2
  41. package/templates/agent-cursor.md +1 -2
  42. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
  43. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
  44. package/agent-assets/rules/descix_mcp.template.mdc +0 -243
  45. package/lib/commands/briefer/errors.js +0 -88
  46. package/lib/commands/briefer/index.js +0 -319
  47. package/lib/commands/briefer/sources/canonical-sources.js +0 -129
  48. package/lib/commands/briefer/sources/entitlements.js +0 -220
  49. package/lib/commands/briefer/sources/environments.js +0 -205
  50. package/lib/commands/briefer/sources/identifiers.js +0 -214
  51. package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
  52. package/lib/commands/briefer/sources/routing.js +0 -275
  53. package/lib/commands/briefer/sources/what-is-not.js +0 -223
  54. package/lib/commands/briefer/stitcher.js +0 -134
  55. package/lib/commands/briefer/util/source-reader.js +0 -502
  56. package/lib/commands/mcp.js +0 -273
  57. package/lib/commands/update.js +0 -581
  58. package/lib/wizard/setup.js +0 -503
  59. package/templates/descix_mcp.mdc +0 -122
package/bin/descix.js CHANGED
@@ -11,7 +11,7 @@ import { Command } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DeSciXApiClient } from '../lib/api-client.js';
13
13
  import { requireAuth } from '../lib/auth-guard.js';
14
- import { WorkspaceConfig, unmappedAppMessage } from '../lib/workspace-config.js';
14
+ import { WorkspaceConfig, unmappedAppMessage, resolveWorkspacePath, TOP_LEVEL_API_URL_REMEDY } from '../lib/workspace-config.js';
15
15
  import { CLI_VERSION } from '../lib/cli-version.js';
16
16
  import { recordInvocationOrigin } from '../lib/origin.js';
17
17
  // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
@@ -29,8 +29,9 @@ import * as buyCommands from '../lib/commands/buy.js';
29
29
  import * as creditsCommands from '../lib/commands/credits.js';
30
30
  import * as airdropCommands from '../lib/commands/airdrop.js';
31
31
  import { runInit } from '../lib/commands/init.js';
32
- import * as updateCommands from '../lib/commands/update.js';
33
- import { registerAllRetiredKbSync, refuseRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
32
+ // "May I prompt?" has ONE OWNER. No command in this file derives it.
33
+ import { createPromptSession } from '../lib/interactive.js';
34
+ import { registerAllRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
34
35
  import { runStatus } from '../lib/commands/status.js';
35
36
  import { runDoctor } from '../lib/commands/doctor.js';
36
37
  import { runHealth } from '../lib/commands/health.js';
@@ -38,7 +39,7 @@ import * as kbCommands from '../lib/commands/kb.js';
38
39
  import { kbVectorCell, kbCountSource } from '../lib/commands/kb-list-render.js';
39
40
  import * as corpusCommands from '../lib/commands/corpus.js';
40
41
  import * as modelConfigCommands from '../lib/commands/model-config.js';
41
- import * as brieferCommand from '../lib/commands/briefer/index.js';
42
+ import { isHelpInvocation, getCommandSurface, applyVisibility } from '../lib/command-visibility.js';
42
43
  import fs from 'fs/promises';
43
44
  import path from 'path';
44
45
  import os from 'os';
@@ -97,7 +98,8 @@ program
97
98
  .description('DeSciX CLI - Unified command-line interface')
98
99
  .version(CLI_VERSION)
99
100
  .option('--env <name>', 'Target environment: dev, demo, prod (overrides API URL)')
100
- .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.');
101
103
 
102
104
  // ============ Global Environment Override ============
103
105
  // Maps --env flag to DESCIX_API_URL before any command runs.
@@ -114,24 +116,27 @@ const ENV_URL_MAP = Object.fromEntries(
114
116
  // stays; the provenance is now RECORDED alongside it, in the same breath, so the owner can name
115
117
  // the actual source. Recording it here rather than deriving it in the owner keeps one owner of
116
118
  // the fact: the only code that knows a flag was passed is the code that read the flag.
117
- program.hook('preAction', (thisCommand) => {
118
- // Read from the ROOT program, not from the action command. `--env` and `--api-url` are
119
- // declared ONCE, globally, and Commander binds them to the root — an identically-named option
120
- // redeclared on a subcommand is shadowed and silently never populated (measured on 1.0.4:
121
- // `descix config init --env dev` reached its action with `{}` and rejected its own advertised
122
- // flag). One declaration, read from where Commander actually puts it.
123
- const opts = program.opts();
124
-
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 }) {
125
130
  // --api-url takes highest priority
126
- if (opts.apiUrl) {
127
- process.env.DESCIX_API_URL = opts.apiUrl;
128
- recordInvocationOrigin(opts.apiUrl, 'flagApiUrl');
131
+ if (apiUrl) {
132
+ process.env.DESCIX_API_URL = apiUrl;
133
+ recordInvocationOrigin(apiUrl, 'flagApiUrl');
129
134
  return;
130
135
  }
131
136
 
132
137
  // --env maps to known URLs
133
- if (opts.env) {
134
- const envName = opts.env.toLowerCase();
138
+ if (env) {
139
+ const envName = env.toLowerCase();
135
140
  if (!(envName in ENV_URL_MAP)) {
136
141
  // THE REFUSAL CARRIES THE CUSTOM-ENVIRONMENT PATH. This validator runs BEFORE any command
137
142
  // action, so it is now the only thing a developer typing `--env staging` ever sees — the
@@ -140,7 +145,7 @@ program.hook('preAction', (thisCommand) => {
140
145
  // names would strand every self-hosted or port-forwarded gateway with no next step, which
141
146
  // is the same dead end as a remedy string for a command that does not run.
142
147
  console.error(chalk.red(
143
- `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` +
144
149
  'For a self-hosted or port-forwarded gateway, name it with its origin instead:\n' +
145
150
  ` descix config set-env ${envName} --url https://...`
146
151
  ));
@@ -151,6 +156,16 @@ program.hook('preAction', (thisCommand) => {
151
156
  process.env.DESCIX_API_URL = ENV_URL_MAP[envName];
152
157
  recordInvocationOrigin(ENV_URL_MAP[envName], 'flagEnv');
153
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 });
154
169
  });
155
170
 
156
171
  // ============ Authentication Commands (No Auth Required) ============
@@ -291,39 +306,15 @@ program
291
306
  // --env=demo is supplied (because the subcommand owns its own flag space).
292
307
  const parentEnv = program.opts().env || null;
293
308
  const env = options.env || parentEnv || 'dev';
294
- await runHealth({ ...options, env });
309
+ const result = await runHealth({ ...options, env });
310
+ // A health check that reports an unhealthy service and exits 0 is not a gate.
311
+ if (result && result.all_healthy === false) process.exitCode = 1;
295
312
  } catch (error) {
296
313
  console.error(chalk.red(`\nHealth check error: ${error.message}\n`));
297
314
  process.exit(1);
298
315
  }
299
316
  });
300
317
 
301
- // ============ Briefer Command (Code-Grounded Mental Model Regen) ============
302
- // WS-DESCIX-BRIEFER-CLI M1: CLI scaffolding + extractor contract.
303
- // See DeSciX/DeSciX_Core/descix-cli/lib/commands/briefer/ for implementation.
304
- // Scope doc: docs/design/ws-descix-briefer-cli.md
305
- // Briefer target: DeSciX/V2_docs/architecture/platform-must-know-briefer.md
306
-
307
- program
308
- .command('briefer')
309
- .description('Regenerate platform-must-know-briefer.md from live code + gcloud + Firestore (HARD-FAIL on drift)')
310
- .option('--env <name>', 'Target environment: dev|demo|prod (also accepted at the top level: descix --env=demo briefer ...)')
311
- .option('--out <path>', 'Override output path (default: workspace-root/DeSciX/V2_docs/architecture/platform-must-know-briefer.md)')
312
- .option('--check', 'Drift-detection mode: regen to memory, diff against canonical, non-zero exit on drift')
313
- .option('-v, --verbose', 'Print per-source paths, citations, and timings')
314
- .action(async (options) => {
315
- try {
316
- // Resolve --env in priority order: subcommand explicit > program parent > 'dev'.
317
- // Commander would otherwise silently default to 'dev' even when the parent
318
- // --env=demo is supplied (because the subcommand owns its own flag space).
319
- const parentEnv = program.opts().env || null;
320
- const env = options.env || parentEnv || 'dev';
321
- await brieferCommand.runBriefer({ ...options, env });
322
- } catch (error) {
323
- console.error(chalk.red(`\n❌ Briefer command failed: ${error.message}\n`));
324
- process.exit(1);
325
- }
326
- });
327
318
 
328
319
  // ============ Buy Commands (Crypto Payments) ============
329
320
 
@@ -429,7 +420,7 @@ creditsCommand
429
420
 
430
421
  creditsCommand
431
422
  .command('grant')
432
- .description('ADMIN: grant AI credits to a user')
423
+ .description('Grant AI credits to a user')
433
424
  .requiredOption('--user <user_id>', 'Target user id')
434
425
  .requiredOption('--usd <amount>', 'USD amount to grant')
435
426
  .requiredOption('--reason <text>', 'Audit reason')
@@ -443,7 +434,7 @@ creditsCommand
443
434
 
444
435
  creditsCommand
445
436
  .command('refund')
446
- .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)')
447
438
  .requiredOption('--user <user_id>', 'Target user id')
448
439
  .requiredOption('--usd <amount>', 'USD amount to remove')
449
440
  .requiredOption('--reason <text>', 'Audit reason')
@@ -589,7 +580,7 @@ communityCommand
589
580
 
590
581
  communityCommand
591
582
  .command('create')
592
- .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)')
593
584
  .requiredOption('-n, --name <name>', 'Community display name (e.g. "SMILE")')
594
585
  .requiredOption('-t, --token <symbol>', 'Token symbol (e.g. SMILE)')
595
586
  .option('--icon <url>', 'Icon URL for the community')
@@ -612,11 +603,12 @@ communityCommand
612
603
  console.log(chalk.white(` Network: Polygon (live)\n`));
613
604
 
614
605
  if (!options.yes) {
615
- const readline = await import('readline');
616
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
617
- const answer = await new Promise(resolve => {
618
- rl.question(chalk.yellow(` Create community "${options.name}" with token ${tokenSymbol} on live Polygon? [y/N] `), resolve);
606
+ const rl = createPromptSession({
607
+ what: 'descix community create',
608
+ destructive: true,
609
+ nonInteractiveForm: ['descix community create ... --yes # deploys a REAL token contract on live Polygon']
619
610
  });
611
+ const answer = await rl.askRaw(chalk.yellow(` Create community "${options.name}" with token ${tokenSymbol} on live Polygon? [y/N] `));
620
612
  rl.close();
621
613
  if (answer.toLowerCase() !== 'y') {
622
614
  console.log(chalk.gray('\n Aborted.\n'));
@@ -656,7 +648,7 @@ communityCommand
656
648
 
657
649
  communityCommand
658
650
  .command('delete')
659
- .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)')
660
652
  .requiredOption('-n, --name <community_id>', 'Community ID to delete')
661
653
  .option('--dry-run', 'Preview what would be deleted without executing')
662
654
  .option('--soft', 'Soft delete only (mark as hidden, no cascade)')
@@ -713,11 +705,15 @@ communityCommand
713
705
 
714
706
  // Confirmation prompt (unless --yes)
715
707
  if (!options.yes && hardDelete) {
716
- const readline = await import('readline');
717
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
718
- const answer = await new Promise(resolve => {
719
- rl.question(chalk.yellow(`\n Permanently delete community "${manifest.community_name}" and all ${manifest.app_count} app(s)? This cannot be undone. [y/N] `), resolve);
708
+ const rl = createPromptSession({
709
+ what: 'descix community delete',
710
+ destructive: true,
711
+ nonInteractiveForm: [
712
+ 'Re-run with --dry-run to see the plan without changing anything.',
713
+ 'descix community delete ... --yes # PERMANENTLY deletes the community and every app in it'
714
+ ]
720
715
  });
716
+ const answer = await rl.askRaw(chalk.yellow(`\n Permanently delete community "${manifest.community_name}" and all ${manifest.app_count} app(s)? This cannot be undone. [y/N] `));
721
717
  rl.close();
722
718
  if (answer.toLowerCase() !== 'y') {
723
719
  console.log(chalk.gray('\n Aborted.\n'));
@@ -761,7 +757,7 @@ communityCommand
761
757
 
762
758
  communityCommand
763
759
  .command('rename')
764
- .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.')
765
761
  .argument('<old_community_id>', 'Current community_id (e.g. unkamon)')
766
762
  .argument('<new_community_id>', 'New canonical community_id (e.g. unk)')
767
763
  .option('--dry-run', 'Preview the full cascade plan without executing')
@@ -803,12 +799,15 @@ communityCommand
803
799
  }
804
800
 
805
801
  if (!options.yes) {
806
- const readline = await import('readline');
807
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
808
- const answer = await new Promise(resolve => rl.question(
809
- chalk.yellow(`\n Execute rename ${oldId} -> ${newId}? Re-tags ${plan.pinecone.matched} vector(s) and deletes Community/${oldId} after verifying Community/${newId}. [y/N] `),
810
- resolve
811
- ));
802
+ const rl = createPromptSession({
803
+ what: 'descix community rename',
804
+ destructive: true,
805
+ nonInteractiveForm: [
806
+ 'Re-run with --dry-run to see the plan without changing anything.',
807
+ 'descix community rename ... --yes # re-tags vectors and DELETES the old community doc'
808
+ ]
809
+ });
810
+ const answer = await rl.askRaw(chalk.yellow(`\n Execute rename ${oldId} -> ${newId}? Re-tags ${plan.pinecone.matched} vector(s) and deletes Community/${oldId} after verifying Community/${newId}. [y/N] `));
812
811
  rl.close();
813
812
  if (answer.toLowerCase() !== 'y') {
814
813
  console.log(chalk.gray('\n Aborted.\n'));
@@ -1195,26 +1194,6 @@ appCommand
1195
1194
  }
1196
1195
  });
1197
1196
 
1198
- appCommand
1199
- .command('create')
1200
- // DELETED SURFACE (CEO-D-2026-08-14, SUPER-DRY: delete the old path, fail loud, no compat
1201
- // fence). `app create` created the platform record and STOPPED, leaving the caller to
1202
- // remember `app init` — two commands for one outcome. That is how apps ended up half-made
1203
- // with no knowledge base at all. `app init` now creates AND initializes in one path.
1204
- .description('[REMOVED] Use `descix app init -a <name> -c <community>` — it creates and initializes in one step.')
1205
- // Swallow whatever the old invocation passed so the caller always gets the pointer below
1206
- // rather than a generic commander arity error that names no replacement.
1207
- .allowUnknownOption(true)
1208
- .argument('[ignored...]')
1209
- .action(() => {
1210
- console.error(chalk.red('\n❌ `descix app create` has been removed.\n'));
1211
- console.error(chalk.white(' Use the single canonical path, which creates AND initializes:\n'));
1212
- console.error(chalk.cyan(' descix app init -a <app-name> -c <community> [-s <short>]\n'));
1213
- console.error(chalk.gray(' It registers the app (Products + Firestore + entitlement), guarantees'));
1214
- console.error(chalk.gray(' the default knowledge base, then registers and scaffolds it locally.'));
1215
- console.error(chalk.gray(' Creating an app without a default KB is no longer possible.\n'));
1216
- process.exit(1);
1217
- });
1218
1197
 
1219
1198
  // descix app media-upload — upload media/asset files to an app's GCS assets prefix via the
1220
1199
  // API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half).
@@ -1349,45 +1328,6 @@ appCommand
1349
1328
  }
1350
1329
  });
1351
1330
 
1352
- appCommand
1353
- .command('set-codesite')
1354
- .description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
1355
- .option('-c, --community <id>', 'Community ID (uses context if not provided)')
1356
- .option('-a, --app <id>', 'App ID (uses context if not provided)')
1357
- .requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
1358
- .action(async (options) => {
1359
- try {
1360
- const apiClient = new DeSciXApiClient();
1361
- await requireAuth(apiClient);
1362
-
1363
- // Load workspace context
1364
- const workspaceConfig = await WorkspaceConfig.load();
1365
- const ctx = workspaceConfig.resolveContextWithOptions(options);
1366
-
1367
- const communityId = ctx.communityId;
1368
- const appId = ctx.appId;
1369
-
1370
- if (!communityId || !appId) {
1371
- console.error(chalk.red('\n❌ Community and App ID required.'));
1372
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1373
- process.exit(1);
1374
- }
1375
-
1376
- const response = await apiClient.invoke('update_app_metadata', {
1377
- community_id: communityId,
1378
- app_id: appId,
1379
- ip_site_gcs_path_url: options.url
1380
- });
1381
- const result = response.message || response;
1382
-
1383
- console.log(chalk.green('\n✅ CodeSite URL updated!\n'));
1384
- console.log(chalk.cyan(` App: ${communityId}/${appId}`));
1385
- console.log(chalk.gray(` CodeSite URL: ${options.url}\n`));
1386
- } catch (error) {
1387
- console.error(chalk.red(error.message));
1388
- process.exit(1);
1389
- }
1390
- });
1391
1331
 
1392
1332
  appCommand
1393
1333
  .command('set-price')
@@ -1448,6 +1388,43 @@ appCommand
1448
1388
  }
1449
1389
  });
1450
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
+
1451
1428
  appCommand
1452
1429
  .command('set-api-url')
1453
1430
  .description('Set API base URL for app\'s own backend')
@@ -1608,11 +1585,15 @@ appCommand
1608
1585
 
1609
1586
  // Confirmation prompt (unless --yes)
1610
1587
  if (!options.yes && hardDelete) {
1611
- const readline = await import('readline');
1612
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1613
- const answer = await new Promise(resolve => {
1614
- rl.question(chalk.yellow(`\n Permanently delete ${appId}? This cannot be undone. [y/N] `), resolve);
1588
+ const rl = createPromptSession({
1589
+ what: 'descix app delete',
1590
+ destructive: true,
1591
+ nonInteractiveForm: [
1592
+ 'Re-run with --dry-run to see the plan without changing anything.',
1593
+ `descix app delete -a ${appId} --yes # PERMANENTLY deletes the app (hard delete is the default; --soft only hides it)`
1594
+ ]
1615
1595
  });
1596
+ const answer = await rl.askRaw(chalk.yellow(`\n Permanently delete ${appId}? This cannot be undone. [y/N] `));
1616
1597
  rl.close();
1617
1598
  if (answer.toLowerCase() !== 'y') {
1618
1599
  console.log(chalk.gray('\n Aborted.\n'));
@@ -1742,36 +1723,49 @@ appCommand
1742
1723
  const newPath = options.path;
1743
1724
 
1744
1725
  const workspaceConfig = await WorkspaceConfig.load();
1745
- const appConfig = workspaceConfig.getAppByAppId(appId);
1746
- if (!appConfig) {
1747
- throw new Error(`App '${appId}' is not mapped. Run 'descix app init -a ${appId}' first.`);
1748
- }
1726
+ const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1749
1727
 
1750
- // Hard-fail if path doesn't exist or is not a directory
1728
+ // Read the RAW registry entry, never the RESOLVED one. getAppByAppId() resolves the stored
1729
+ // localPath, so on a workspace already carrying a rejected value it throws here — before
1730
+ // any write is attempted. That is what made this verb refuse exactly when it was needed,
1731
+ // while the rejection told the user to fix it with the config verb and never by hand.
1732
+ // This command is about to REPLACE that value; it has no business resolving it.
1733
+ //
1734
+ // Display value only. setLocalPath() is the SINGLE authority on whether the app is mapped
1735
+ // and refuses if it is not — deciding that here as well would be two derivations of one
1736
+ // fact, and they would not even agree: _buildAppIdMap (behind getAppEntry) requires BOTH
1737
+ // appId and localPath, while the live-entry walk matches on appId alone.
1738
+ const oldPath = workspaceConfig.getAppEntry(appId)?.localPath;
1739
+
1740
+ // VALIDATE AT THE WRITE, through the LOADER'S OWN resolver — not a second copy of its rules.
1741
+ // If resolveWorkspacePath would reject this value on the next read, it is rejected now, and
1742
+ // nothing reaches disk. Writing a value the loader will refuse is what bricks a workspace.
1743
+ const absPath = resolveWorkspacePath(wsRoot, newPath, appId);
1744
+
1745
+ // Hard-fail if path doesn't exist or is not a directory — checked at the path the LOADER
1746
+ // will resolve to, not at a CWD-relative path that only exists from where you happened to
1747
+ // be standing when you typed it.
1751
1748
  let stat;
1752
1749
  try {
1753
- stat = await fs.stat(newPath);
1750
+ stat = await fs.stat(absPath);
1754
1751
  } catch {
1755
- throw new Error(`Path does not exist: ${newPath}`);
1752
+ throw new Error(
1753
+ `Path does not exist: ${absPath}\n` +
1754
+ ` (localPath '${newPath}' resolved against workspace root ${wsRoot})`
1755
+ );
1756
1756
  }
1757
1757
  if (!stat.isDirectory()) {
1758
- throw new Error(`Path is not a directory: ${newPath}`);
1758
+ throw new Error(
1759
+ `Path is not a directory: ${absPath}\n` +
1760
+ ` (localPath '${newPath}' resolved against workspace root ${wsRoot})`
1761
+ );
1759
1762
  }
1760
1763
 
1761
- // Update the localPath in workspace.json
1762
- const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1763
- const oldPath = appConfig.localPath;
1764
- appConfig.localPath = newPath;
1765
-
1766
- // Update env.products entry
1767
- const products = workspaceConfig.env?.products || [];
1768
- for (const product of products) {
1769
- if (product.appId === appId || product.app_id === appId) {
1770
- product.localPath = newPath;
1771
- break;
1772
- }
1773
- }
1774
- await workspaceConfig.save(wsRoot);
1764
+ // Write through the canonical setter, which covers env.platform AND env.products[]. The
1765
+ // loop this replaces walked env.products ONLY: on the platform app it matched nothing,
1766
+ // wrote nothing, and still printed success — a loud failure turned into a silent lie at
1767
+ // the one entry that matters most.
1768
+ await workspaceConfig.setLocalPath(appId, newPath);
1775
1769
 
1776
1770
  console.log(chalk.green(`\n✓ ${appId} local path updated`));
1777
1771
  console.log(chalk.gray(` ${oldPath} → ${newPath}\n`));
@@ -1819,7 +1813,7 @@ appCommand
1819
1813
  // Canonical write path for an app's microservice.port in workspace.json.
1820
1814
  // `descix microservice init` READS env.products[<app>].microservice.port and hard-fails
1821
1815
  // if it is missing; this command is how that port is set, without hand-editing workspace.json.
1822
- // Backed by WorkspaceConfig.setMicroservicePort (parallel to setSitePort).
1816
+ // Backed by WorkspaceConfig.setMicroservicePort.
1823
1817
  appCommand
1824
1818
  .command('set-port')
1825
1819
  .description('Set the microservice port for a mapped app (writes env.products[<app>].microservice.port). Pass "n" to remove.')
@@ -1864,10 +1858,8 @@ appCommand
1864
1858
  // the app's localPath; "." means the localPath itself). Optionally sets site.port for
1865
1859
  // dev-server sites. Parallel to `descix app set-port` (microservice.port); closes the
1866
1860
  // site.static gap so workspace.json never needs hand-editing (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1867
- // Backed by WorkspaceConfig.setStaticSite (parallel to setSitePort/setMicroservicePort).
1868
- //
1869
- // NOTE: This is NOT `set-codesite` — that writes the Firestore ip_site_gcs_path_url (a prod
1870
- // 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.
1871
1863
  appCommand
1872
1864
  .command('set-site')
1873
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.{}.")
@@ -2090,32 +2082,6 @@ KB's vectors by id-prefix). The Source column shows how each count was obtained:
2090
2082
  }
2091
2083
  });
2092
2084
 
2093
- kbCommand
2094
- .command('create')
2095
- .description('Create a new knowledge base in an app')
2096
- .requiredOption('-c, --community <id>', 'Community ID')
2097
- .requiredOption('-a, --app <id>', 'App ID')
2098
- .requiredOption('-k, --kb <id>', 'Knowledge Base ID')
2099
- .action(async (options) => {
2100
- try {
2101
- const apiClient = new DeSciXApiClient();
2102
- await requireAuth(apiClient);
2103
-
2104
- const response = await apiClient.invoke('create_skeleton_kb', {
2105
- community_id: options.community,
2106
- app_id: options.app,
2107
- kb_name: options.kb
2108
- });
2109
- const result = response.message || response;
2110
-
2111
- console.log(chalk.green('\n✅ Knowledge Base created successfully!\n'));
2112
- console.log(chalk.cyan(` KB ID: ${options.kb}`));
2113
- console.log(chalk.gray(` App: ${options.community}/${options.app}`));
2114
- } catch (error) {
2115
- console.error(chalk.red(error.message));
2116
- process.exit(1);
2117
- }
2118
- });
2119
2085
 
2120
2086
  kbCommand
2121
2087
  .command('delete')
@@ -2485,7 +2451,7 @@ driveCommand
2485
2451
 
2486
2452
  const siteCommand = program
2487
2453
  .command('site')
2488
- .description('Site lifecycle management: init, upload, servelocal');
2454
+ .description('Site lifecycle management: init, upload, status, list, delete');
2489
2455
 
2490
2456
  // site init - Copy scaffold to app
2491
2457
  siteCommand
@@ -2541,8 +2507,7 @@ siteCommand
2541
2507
  .option('-a, --app <id>', 'App ID (auto-detects from context)')
2542
2508
  .option('-p, --path <localPath>', 'Local directory to deploy (ignored when site manifest exists)', './site')
2543
2509
  .option('--preview', 'Deploy to preview path')
2544
- .option('--full', 'Force full upload (ignore delta)')
2545
- .option('--dry-run', 'Show what would be deployed')
2510
+ .option('--dry-run', 'Show what would be uploaded and which server files would be removed')
2546
2511
  .option('--no-cache', 'Set Cache-Control: no-cache')
2547
2512
  .action(async (options) => {
2548
2513
  try {
@@ -2715,92 +2680,76 @@ siteCommand
2715
2680
 
2716
2681
  const { signed_urls, upload_headers, existing_manifest, token_id, site_url } = tokenResponse.message;
2717
2682
 
2718
- // 3. Determine delta
2719
- let filesToUpload = fileList;
2720
- let filesToDelete = [];
2721
-
2722
- if (!options.full && existing_manifest) {
2723
- const delta = gitUtils.compareSyncState(localFiles, existing_manifest);
2724
- filesToUpload = fileList.filter(f =>
2725
- delta.added.includes(f.path) || delta.modified.includes(f.path)
2726
- );
2727
- filesToDelete = delta.deleted;
2728
-
2729
- console.log(chalk.gray(` Delta: ${delta.added.length} added, ${delta.modified.length} modified, ${delta.unchanged.length} unchanged, ${delta.deleted.length} deleted`));
2730
- }
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;
2731
2686
 
2732
2687
  if (options.dryRun) {
2733
2688
  console.log(chalk.yellow('\n Dry run - would upload:'));
2734
- if (filesToUpload.length === 0) {
2735
- console.log(chalk.gray(' (no files to upload)'));
2736
- } else {
2737
- filesToUpload.forEach(f => console.log(chalk.gray(` + ${f.path} (${(f.size / 1024).toFixed(1)}KB)`)));
2738
- }
2739
- if (filesToDelete.length > 0) {
2740
- console.log(chalk.yellow('\nWould delete:'));
2741
- 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}`)));
2742
2697
  }
2743
2698
  console.log(chalk.gray(`\n Site URL: ${site_url}\n`));
2744
2699
  return;
2745
2700
  }
2746
2701
 
2747
- if (filesToUpload.length === 0) {
2748
- console.log(chalk.green('\n Site already up to date on GCS.'));
2749
- console.log(chalk.gray(' Ensuring app metadata is synchronized...'));
2750
- // Fall through to confirm_site_deploy to ensure DB is updated
2751
- } else {
2752
- // 4. Upload files directly to GCS using signed URLs
2753
- 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...`));
2754
2704
 
2755
- let uploadedCount = 0;
2756
- let uploadErrors = [];
2705
+ let uploadedCount = 0;
2706
+ let uploadErrors = [];
2757
2707
 
2758
- for (const file of filesToUpload) {
2759
- const signedUrl = signed_urls[file.path];
2760
- if (!signedUrl) {
2761
- uploadErrors.push(`No signed URL for: ${file.path}`);
2762
- continue;
2763
- }
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
+ }
2764
2714
 
2765
- try {
2766
- const content = await fs.readFile(file._absolutePath);
2767
-
2768
- // Canonical contract (WS-DEPLOY-HARDENING item 4): the server binds Cache-Control
2769
- // into the v4 signed URL signature (generateSiteUploadUrls/siteAssetCacheControl).
2770
- // Ferry the exact header bag the server minted — never re-derive the policy
2771
- // client-side. A mismatched header (including a client-side --no-cache override)
2772
- // makes GCS return 403 signature-mismatch, so once the server ferries a bound
2773
- // header for this path, --no-cache is a no-op for it (falls back to legacy
2774
- // Content-Type-only behavior for paths the server didn't ferry a header for).
2775
- const ferriedHeaders = upload_headers && upload_headers[file.path];
2776
- const headers = ferriedHeaders || {
2777
- 'Content-Type': file.content_type,
2778
- ...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
2779
- };
2780
-
2781
- const response = await fetch(signedUrl, {
2782
- method: 'PUT',
2783
- headers,
2784
- body: content
2785
- });
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
+ };
2786
2730
 
2787
- if (!response.ok) {
2788
- uploadErrors.push(`Failed to upload ${file.path}: ${response.status} ${response.statusText}`);
2789
- console.log(chalk.red(` x ${file.path}`));
2790
- } else {
2791
- uploadedCount++;
2792
- console.log(chalk.green(` + ${file.path}`));
2793
- }
2794
- } catch (err) {
2795
- 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}`);
2796
2739
  console.log(chalk.red(` x ${file.path}`));
2740
+ } else {
2741
+ uploadedCount++;
2742
+ console.log(chalk.green(` + ${file.path}`));
2797
2743
  }
2744
+ } catch (err) {
2745
+ uploadErrors.push(`Error uploading ${file.path}: ${err.message}`);
2746
+ console.log(chalk.red(` x ${file.path}`));
2798
2747
  }
2748
+ }
2799
2749
 
2800
- if (uploadErrors.length > 0) {
2801
- console.log(chalk.yellow(`\n ${uploadErrors.length} errors during upload:`));
2802
- uploadErrors.forEach(e => console.log(chalk.red(` - ${e}`)));
2803
- }
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.`);
2804
2753
  }
2805
2754
 
2806
2755
  // 5. Confirm deployment (ALWAYS call this to ensure DB is updated)
@@ -2827,6 +2776,12 @@ siteCommand
2827
2776
  console.log(chalk.green(`\n Site uploaded successfully!\n`));
2828
2777
  console.log(chalk.cyan(` URL: ${result.site_url}`));
2829
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}`)));
2830
2785
  console.log(chalk.gray(` Deployed: ${result.deployed_at}`));
2831
2786
  if (result.preview) {
2832
2787
  console.log(chalk.yellow(` Mode: PREVIEW`));
@@ -2834,7 +2789,7 @@ siteCommand
2834
2789
 
2835
2790
  if (!options.preview) {
2836
2791
  console.log(chalk.gray(`\n For local development, run:`));
2837
- console.log(chalk.cyan(` descix site servelocal <port>\n`));
2792
+ console.log(chalk.cyan(` descix serve --app ${appId}\n`));
2838
2793
  }
2839
2794
  console.log('');
2840
2795
 
@@ -2844,75 +2799,6 @@ siteCommand
2844
2799
  }
2845
2800
  });
2846
2801
 
2847
- // site servelocal - Register local dev server port
2848
- siteCommand
2849
- .command('servelocal [port]')
2850
- .description('Register local dev server port (or "n" to disable)')
2851
- .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2852
- .option('-a, --app <id>', 'App ID (auto-detects from context)')
2853
- .action(async (port, options) => {
2854
- try {
2855
- const workspaceConfig = await WorkspaceConfig.load();
2856
- const ctx = workspaceConfig.resolveContextWithOptions(options);
2857
-
2858
- const appId = ctx.appId;
2859
-
2860
- if (!appId) {
2861
- console.error(chalk.red('\n❌ App ID required.'));
2862
- console.log(chalk.gray(' Either provide -a flag, or cd into an app directory\n'));
2863
- process.exit(1);
2864
- }
2865
-
2866
- // Handle disable case — setSitePort(appId, null) removes site.port / cleans site.{}
2867
- if (port === 'n' || port === 'N') {
2868
- await workspaceConfig.setSitePort(appId, null);
2869
- console.log(chalk.green(`\n✅ Local site server disabled for ${appId}\n`));
2870
- return;
2871
- }
2872
-
2873
- // Handle status query (no port argument)
2874
- if (!port) {
2875
- const appConfig = workspaceConfig.getAppByAppId(appId);
2876
- if (!appConfig) {
2877
- console.error(chalk.red(`\n❌ ${unmappedAppMessage(appId)}\n`));
2878
- process.exit(1);
2879
- }
2880
- // Read site.port from the live env entry (not from the constructed copy)
2881
- let liveEntry = null;
2882
- if (workspaceConfig.env?.platform?.appId === appId) {
2883
- liveEntry = workspaceConfig.env.platform;
2884
- } else if (Array.isArray(workspaceConfig.env?.products)) {
2885
- liveEntry = workspaceConfig.env.products.find(p => p.appId === appId) || null;
2886
- }
2887
- const currentPort = liveEntry?.site?.port;
2888
- if (currentPort) {
2889
- console.log(chalk.cyan(`\n📍 Local site server: port ${currentPort}`));
2890
- console.log(chalk.gray(` App: ${appId}\n`));
2891
- } else {
2892
- console.log(chalk.yellow(`\n⚠️ No local site server configured.`));
2893
- console.log(chalk.gray(` Usage: descix site servelocal <port>\n`));
2894
- }
2895
- return;
2896
- }
2897
-
2898
- const portNum = parseInt(port);
2899
- if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
2900
- console.error(chalk.red('\n❌ Invalid port number.\n'));
2901
- process.exit(1);
2902
- }
2903
-
2904
- // setSitePort mutates the live env entry and saves — no stale-copy problem
2905
- await workspaceConfig.setSitePort(appId, portNum);
2906
- console.log(chalk.green(`\n✅ Local site server registered!`));
2907
- console.log(chalk.cyan(` Port: ${portNum}`));
2908
- console.log(chalk.gray(` App: ${appId}`));
2909
- console.log(chalk.gray(`\n The gateway will proxy /p/${appId}/* to localhost:${portNum}\n`));
2910
-
2911
- } catch (error) {
2912
- console.error(chalk.red(`\n❌ ${error.message}\n`));
2913
- process.exit(1);
2914
- }
2915
- });
2916
2802
 
2917
2803
  // site list - List deployed files (context-aware)
2918
2804
  siteCommand
@@ -3049,15 +2935,13 @@ siteCommand
3049
2935
  }
3050
2936
 
3051
2937
  if (!options.confirm) {
3052
- const readline = await import('readline');
3053
- const rl = readline.createInterface({
3054
- input: process.stdin,
3055
- output: process.stdout
3056
- });
3057
-
3058
- const answer = await new Promise(resolve => {
3059
- rl.question(chalk.yellow(`\n⚠️ Delete site for ${communityId}/${appId}${options.preview ? ' (preview)' : ''}? (y/N) `), resolve);
2938
+ const rl = createPromptSession({
2939
+ what: 'descix site delete',
2940
+ destructive: true,
2941
+ nonInteractiveForm: [`descix site delete -c ${communityId} -a ${appId} --confirm # deletes the uploaded site files`]
3060
2942
  });
2943
+
2944
+ const answer = await rl.askRaw(chalk.yellow(`\n⚠️ Delete site for ${communityId}/${appId}${options.preview ? ' (preview)' : ''}? (y/N) `));
3061
2945
  rl.close();
3062
2946
 
3063
2947
  if (answer.toLowerCase() !== 'y') {
@@ -3243,127 +3127,6 @@ microserviceCommand
3243
3127
  }
3244
3128
  });
3245
3129
 
3246
- // microservice deploy - Cloud Run deploy (broker-first; no per-app LB for non-core apps)
3247
- // WS-MCP-SURFACE-SPLIT §4b: this is the REAL Admin/local deploy (Admin SDK) — it stays fully
3248
- // functional for operator/CI use (EVP/COS deploy runbooks invoke it). It is intentionally NOT
3249
- // advertised over MCP (not mcp:true), so the PUBLIC surface sees no deploy tool. The public
3250
- // "microservice deploy" is "coming soon" (a no-op) — satisfied here by absence from the MCP
3251
- // listing, NOT by neutering this admin path. Do not stub this out.
3252
- microserviceCommand
3253
- .command('deploy')
3254
- .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.')
3255
- .option('-c, --community <id>', 'Community ID (auto-detects from context)')
3256
- .option('-a, --app <id>', 'App ID (auto-detects from context)')
3257
- .option('--env <env>', 'Target environment: dev|demo|prod')
3258
- .option('--dry-run', 'Print deploy plan without executing gcloud')
3259
- .option('--skip-register', 'Skip manifest registration after deploy (service may self-register on boot)')
3260
- .action(async (options, command) => {
3261
- try {
3262
- const apiClient = new DeSciXApiClient();
3263
- await requireAuth(apiClient);
3264
-
3265
- const workspaceConfig = await WorkspaceConfig.load();
3266
- const ctx = workspaceConfig.resolveContextWithOptions(options);
3267
- const appId = ctx.appId;
3268
-
3269
- if (!appId) {
3270
- console.error(chalk.red('\n❌ App ID required.'));
3271
- console.log(chalk.gray(' cd into an app directory, or use -a flag\n'));
3272
- process.exit(1);
3273
- }
3274
-
3275
- const parentEnv = command?.parent?.opts?.()?.env;
3276
- const deployEnv = (options.env || parentEnv || workspaceConfig.env?.environment)?.toLowerCase();
3277
- if (!deployEnv || !['dev', 'demo', 'prod'].includes(deployEnv)) {
3278
- console.error(chalk.red('\n❌ --env is required (dev|demo|prod).'));
3279
- console.log(chalk.gray(' Example: descix --env demo microservice deploy -a powch\n'));
3280
- process.exit(1);
3281
- }
3282
-
3283
- const workspaceRoot = workspaceConfig.getWorkspaceRoot();
3284
- if (!workspaceRoot) {
3285
- throw new Error('Could not resolve workspace root (.descix/workspace.json not found)');
3286
- }
3287
-
3288
- const deployScript = path.join(
3289
- workspaceRoot,
3290
- 'DeSciX/DeSciX_Cloud/microservice/admin/scripts/deploy/deploy-service-env.sh'
3291
- );
3292
-
3293
- try {
3294
- await fs.access(deployScript);
3295
- } catch {
3296
- throw new Error(`Deploy script not found: ${deployScript}`);
3297
- }
3298
-
3299
- console.log(chalk.cyan(`\n🚀 Deploying microservice: ${appId} (${deployEnv})\n`));
3300
- if (options.dryRun) {
3301
- console.log(chalk.yellow(' Dry run — gcloud deploy will not execute\n'));
3302
- }
3303
-
3304
- const { spawnSync } = await import('child_process');
3305
- const result = spawnSync('bash', [deployScript, appId, deployEnv], {
3306
- cwd: path.dirname(deployScript),
3307
- env: {
3308
- ...process.env,
3309
- ECHO_MODE: options.dryRun ? 'true' : 'false'
3310
- },
3311
- stdio: 'inherit'
3312
- });
3313
-
3314
- if (result.status !== 0) {
3315
- process.exit(result.status || 1);
3316
- }
3317
-
3318
- if (options.skipRegister) {
3319
- console.log(chalk.gray('\n Skipped manifest registration (--skip-register).\n'));
3320
- return;
3321
- }
3322
-
3323
- const microserviceDir = workspaceConfig.getMicroservicePath(appId);
3324
- if (!microserviceDir) {
3325
- console.log(chalk.yellow('\n⚠️ Deploy complete. Run descix microservice register to register manifest.\n'));
3326
- return;
3327
- }
3328
-
3329
- const manifestPath = path.join(microserviceDir, 'manifest.json');
3330
- try {
3331
- await fs.access(manifestPath);
3332
- } catch {
3333
- console.log(chalk.yellow('\n⚠️ Deploy complete. No manifest.json — run descix microservice register when ready.\n'));
3334
- return;
3335
- }
3336
-
3337
- console.log(chalk.cyan('\n📦 Registering microservice manifest...\n'));
3338
- const registerArgs = [
3339
- process.argv[1],
3340
- 'microservice',
3341
- 'register',
3342
- '-m', manifestPath,
3343
- '-a', appId
3344
- ];
3345
- if (ctx.communityId) {
3346
- registerArgs.push('-c', ctx.communityId);
3347
- }
3348
-
3349
- const registerResult = spawnSync(process.execPath, registerArgs, {
3350
- cwd: process.cwd(),
3351
- stdio: 'inherit',
3352
- env: process.env
3353
- });
3354
-
3355
- if (registerResult.status !== 0) {
3356
- console.log(chalk.yellow('\n⚠️ Cloud Run deploy succeeded but manifest registration failed.'));
3357
- console.log(chalk.gray(` Retry: descix microservice register -m ${manifestPath} -a ${appId}\n`));
3358
- process.exit(registerResult.status || 1);
3359
- }
3360
-
3361
- console.log(chalk.green('\n✅ Microservice deploy complete (Cloud Run + manifest registration).\n'));
3362
- } catch (error) {
3363
- console.error(chalk.red(`\n❌ Deploy failed: ${error.message}\n`));
3364
- process.exit(1);
3365
- }
3366
- });
3367
3130
 
3368
3131
  // microservice register - Register with gateway
3369
3132
  microserviceCommand
@@ -3707,7 +3470,7 @@ microserviceCommand
3707
3470
  // microservice reload - Reload manifests (admin)
3708
3471
  microserviceCommand
3709
3472
  .command('reload')
3710
- .description('Reload microservice manifests (admin only)')
3473
+ .description('Reload microservice manifests')
3711
3474
  .action(async () => {
3712
3475
  try {
3713
3476
  const apiClient = new DeSciXApiClient();
@@ -3977,45 +3740,6 @@ program
3977
3740
  }
3978
3741
  });
3979
3742
 
3980
- const repCommand = program
3981
- .command('rep')
3982
- .description('Reputation operations');
3983
-
3984
- repCommand
3985
- .command('grant')
3986
- .description('Grant REP points to a user (admin/dev only)')
3987
- .requiredOption('-c, --community <id>', 'Community ID')
3988
- .requiredOption('-u, --user <id>', 'User ID')
3989
- .requiredOption('-a, --amount <amount>', 'REP amount to grant')
3990
- .option('--reason <text>', 'Reason for granting REP')
3991
- .action(async (options) => {
3992
- try {
3993
- const apiClient = new DeSciXApiClient();
3994
- await requireAuth(apiClient);
3995
-
3996
- const amount = parseInt(options.amount);
3997
- if (isNaN(amount) || amount <= 0) {
3998
- throw new Error('Amount must be a positive integer');
3999
- }
4000
-
4001
- const response = await apiClient.invoke('increment_rep', {
4002
- community_id: options.community,
4003
- user_id: options.user,
4004
- amount: amount,
4005
- reason: options.reason || 'CLI grant'
4006
- });
4007
- const result = response.message || response;
4008
-
4009
- console.log(chalk.green('\n✅ REP granted!\n'));
4010
- console.log(chalk.cyan(` User: ${options.user}`));
4011
- console.log(chalk.gray(` Community: ${options.community}`));
4012
- console.log(chalk.gray(` Amount: +${amount} REP`));
4013
- console.log(chalk.gray(` New Total: ${result.new_total || result.rep || 'N/A'}\n`));
4014
- } catch (error) {
4015
- console.error(chalk.red(error.message));
4016
- process.exit(1);
4017
- }
4018
- });
4019
3743
 
4020
3744
  // ============ Chat Commands ============
4021
3745
 
@@ -4342,17 +4066,22 @@ configCommand
4342
4066
  }
4343
4067
  });
4344
4068
 
4069
+ // `config set-url` is RETIRED, and it is retired LOUDLY rather than deleted outright: a deleted
4070
+ // subcommand only earns commander's "unknown command", which names no replacement. It used to
4071
+ // assign a top-level `apiUrl` that save() never serialized, then print a success banner over a
4072
+ // value that landed nowhere — the file was re-stamped and the origin never moved. Hidden from
4073
+ // --help; any invocation exits non-zero naming the three surfaces that actually set the origin.
4345
4074
  configCommand
4346
- .command('set-url')
4347
- .description('Set API URL')
4348
- .argument('<url>', 'API URL (e.g., https://localhost:4000)')
4349
- .option('-g, --global', 'Save to global config (~/.descixrc)')
4350
- .action(async (url, options) => {
4351
- try {
4352
- await configCommands.setUrl(url, options);
4353
- } catch (error) {
4354
- fail(error);
4355
- }
4075
+ .command('set-url', { hidden: true })
4076
+ .description('retired — see error text')
4077
+ .argument('[url]')
4078
+ .allowUnknownOption()
4079
+ .action(() => {
4080
+ fail(new Error(
4081
+ '"descix config set-url" is retired: it never wrote the origin it reported (a retired top-level\n' +
4082
+ 'key that nothing reads). Set the API origin where it is read, env.apiUrl, with one of:\n' +
4083
+ ` ${TOP_LEVEL_API_URL_REMEDY}`
4084
+ ));
4356
4085
  });
4357
4086
 
4358
4087
  configCommand
@@ -4661,57 +4390,12 @@ program
4661
4390
 
4662
4391
  // ============ MCP Commands ============
4663
4392
 
4664
- import * as mcpCommands from '../lib/commands/mcp.js';
4665
4393
  import { runClone } from '../lib/commands/clone.js';
4666
4394
 
4667
4395
  const mcpCommand = program
4668
4396
  .command('mcp')
4669
4397
  .description('MCP server operations');
4670
4398
 
4671
- mcpCommand
4672
- .command('init')
4673
- .description('Initialize MCP server for this workspace')
4674
- .action(async () => {
4675
- try {
4676
- await mcpCommands.init();
4677
- } catch (error) {
4678
- fail(error);
4679
- }
4680
- });
4681
-
4682
- mcpCommand
4683
- .command('test')
4684
- .description('Test MCP server with a sample query')
4685
- .option('-q, --query <text>', 'Test query', 'What is this knowledge base about?')
4686
- .action(async (options) => {
4687
- try {
4688
- await mcpCommands.test(options);
4689
- } catch (error) {
4690
- fail(error);
4691
- }
4692
- });
4693
-
4694
- mcpCommand
4695
- .command('config')
4696
- .description('Show current workspace configuration')
4697
- .action(async () => {
4698
- try {
4699
- await mcpCommands.config();
4700
- } catch (error) {
4701
- fail(error);
4702
- }
4703
- });
4704
-
4705
- mcpCommand
4706
- .command('quickstart')
4707
- .description('One-command setup: authenticate, configure, and test')
4708
- .action(async () => {
4709
- try {
4710
- await mcpCommands.quickstart();
4711
- } catch (error) {
4712
- fail(error);
4713
- }
4714
- });
4715
4399
 
4716
4400
  mcpCommand
4717
4401
  .command('execute')
@@ -4825,8 +4509,12 @@ program
4825
4509
  const { WorkspaceConfig } = await import('../lib/workspace-config.js');
4826
4510
  wsConfig = await WorkspaceConfig.load(process.cwd());
4827
4511
  } catch (e) {
4828
- console.error(chalk.yellow(`⚠️ Could not read .descix/workspace.json for project scope`));
4829
- console.error(chalk.gray(` Run 'descix init' first, or use --scope entitlements`));
4512
+ // RELAY the loader's own diagnosis; do not re-derive a remedy here. This site used to
4513
+ // print "Run 'descix init' first" for EVERY failure, including a corrupt-but-present
4514
+ // workspace.json — prescribing an overwrite of the file it had just failed to read,
4515
+ // and discarding the loader's message that said exactly what was wrong.
4516
+ console.error(chalk.red(e.message));
4517
+ console.error(chalk.gray(` Or use --scope entitlements, which needs no workspace.`));
4830
4518
  process.exit(1);
4831
4519
  }
4832
4520
  const appIds = new Set();
@@ -4912,55 +4600,6 @@ program
4912
4600
  }
4913
4601
  });
4914
4602
 
4915
- // ============ Update Commands (Context-Driven) ============
4916
-
4917
- const updateCommand = program
4918
- .command('update')
4919
- // `update kb` is REMOVED — kb sync has exactly one surface, `descix kb corpus sync`.
4920
- // `update app` and `update site` are NOT kb-sync surfaces and are untouched, so the
4921
- // `update` verb itself stays registered. `update all`/`auto` keep doing app and site and
4922
- // no longer touch the KB; they print where kb sync went rather than failing, because
4923
- // failing them would delete non-kb functionality this change never targeted.
4924
- .description('Context-driven resource sync for app and site (auto-detects from workspace). For knowledge bases use `descix kb corpus sync`.')
4925
- .argument('[type]', 'Update type: app, site, all (auto-detects if not specified)')
4926
- .option('-c, --community <id>', 'Community ID (optional; app_id sufficient for Unified Registry)')
4927
- .option('-a, --app <id>', 'App ID (globally unique; required if not in app directory)')
4928
- .option('--preview', 'Site: Deploy to preview URL')
4929
- .option('--build <cmd>', 'Site: Run build command first')
4930
- .option('--full', 'Force full sync (ignore delta)')
4931
- .option('--port <number>', 'Site: Register local dev server port')
4932
- .action(async (type, options) => {
4933
- try {
4934
- switch (type) {
4935
- case 'app':
4936
- await updateCommands.updateApp(options);
4937
- break;
4938
- case 'kb':
4939
- refuseRetiredKbSync('descix update kb', chalk.red);
4940
- break;
4941
- case 'site':
4942
- await updateCommands.updateSite({
4943
- ...options,
4944
- port: options.port ? parseInt(options.port) : undefined
4945
- });
4946
- break;
4947
- case 'all':
4948
- console.log(chalk.gray(` (knowledge bases are not included: use \`${CANONICAL_KB_SYNC}\`)`));
4949
- await updateCommands.updateAll(options);
4950
- break;
4951
- default:
4952
- // Auto-detect. NO blanket "KBs are excluded" line here: auto resolves to exactly
4953
- // one target, and if that target is the KB the user gets the refusal by name.
4954
- // Printing the advisory first would announce an exclusion and then refuse for
4955
- // that exact reason — an advisory that fires on correct behaviour trains people
4956
- // to ignore advisories.
4957
- await updateCommands.updateAuto(options);
4958
- }
4959
- } catch (error) {
4960
- fail(error);
4961
- }
4962
- });
4963
-
4964
4603
  // ============ Local Gateway Command ============
4965
4604
 
4966
4605
  program
@@ -5019,29 +4658,53 @@ program
5019
4658
  await authCommands.loginDevice(loginOptions);
5020
4659
  }
5021
4660
 
5022
- // Step 2: Workspace init — create workspace.json if missing
5023
- const wsConfigPath = path.join(workspaceRoot, '.descix', 'workspace.json');
5024
- let hasWorkspace = false;
5025
- try {
5026
- await fs.access(wsConfigPath);
5027
- hasWorkspace = true;
4661
+ // Step 2: Workspace init — create workspace.json only when there is none ANYWHERE UP THE TREE.
4662
+ //
4663
+ // WHERE THE WORKSPACE ROOT IS has ONE OWNER: WorkspaceConfig.findWorkspaceRoot, which walks UP
4664
+ // exactly as load() does. This step used to derive it a THIRD time with fs.access on the target
4665
+ // path only (the wizard guard was the second). Run from a SUBDIRECTORY of an existing
4666
+ // workspace, that check saw nothing, runInit created a NESTED workspace.json whose
4667
+ // workspaceRoot pointed at the subdirectory, and it SHADOWED the parent for every later
4668
+ // resolution — silently, with "Quickstart complete!" and exit 0.
4669
+ //
4670
+ // quickstart is an ONBOARDING flow: a user who asked to be set up and already IS set up has
4671
+ // SUCCEEDED, so this SKIPS and REPORTS and continues.
4672
+ const existingRoot = await WorkspaceConfig.findWorkspaceRoot(workspaceRoot);
4673
+ if (existingRoot) {
5028
4674
  console.log(chalk.green('✓ Workspace already initialized'));
5029
- } catch { /* missing */ }
5030
-
5031
- if (!hasWorkspace) {
4675
+ if (path.resolve(existingRoot) !== path.resolve(workspaceRoot)) {
4676
+ console.log(chalk.gray(` Workspace root: ${existingRoot}`));
4677
+ console.log(chalk.gray(` You are in a subdirectory of it. Not creating a second workspace`));
4678
+ console.log(chalk.gray(` here — a nested one would shadow the root for every command run`));
4679
+ console.log(chalk.gray(` from this directory.`));
4680
+ }
4681
+ } else {
5032
4682
  console.log(chalk.cyan('\n📋 Initialize Workspace\n'));
5033
- await runInit({ path: workspaceRoot });
4683
+ // runInit's signature is (apiClient, options). Passing a single object put it in the
4684
+ // apiClient slot and left options defaulting to {}, so `path` was silently discarded and
4685
+ // runInit fell back to process.cwd(). It was MASKED here only because workspaceRoot IS
4686
+ // process.cwd() in this action — the argument was in the wrong slot regardless, and would
4687
+ // have written the workspace to the wrong directory the moment that stopped being true.
4688
+ // This action has no apiClient of its own; null is passed explicitly rather than implied.
4689
+ await runInit(null, { path: workspaceRoot });
5034
4690
  }
5035
4691
 
4692
+ // EVERY REMAINING STEP TARGETS THE RESOLVED ROOT, not the directory the command was typed in.
4693
+ // They read the workspace to learn the app, community and origin they must state, so pointing
4694
+ // them at a subdirectory that deliberately has no workspace.json makes them hard-fail on the
4695
+ // absence this command just chose to preserve. Resolving the root once and ferrying it is the
4696
+ // same one-owner rule that fixed the check above.
4697
+ const targetRoot = existingRoot || workspaceRoot;
4698
+
5036
4699
  // Step 3: Generate agent instruction files
5037
4700
  console.log(chalk.cyan('\n📋 Generating Agent Instructions\n'));
5038
- const written = await generateAgentFiles(workspaceRoot);
4701
+ const written = await generateAgentFiles(targetRoot);
5039
4702
  for (const f of written) {
5040
4703
  console.log(chalk.green(` ✓ ${f}`));
5041
4704
  }
5042
4705
 
5043
4706
  // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
5044
- const mcpWritten = await generateMcpConfig(workspaceRoot);
4707
+ const mcpWritten = await generateMcpConfig(targetRoot);
5045
4708
  if (mcpWritten) {
5046
4709
  console.log(chalk.green(' ✓ .vscode/mcp.json'));
5047
4710
  } else {
@@ -5049,11 +4712,9 @@ program
5049
4712
  }
5050
4713
 
5051
4714
  // Step 5: Copy SDK assets
5052
- try {
5053
- const { pullSdkAssets } = await import('../lib/wizard/setup.js');
5054
- const pulled = await pullSdkAssets(workspaceRoot);
5055
- if (pulled) console.log(chalk.green(' ✓ .descix/sdk-assets/'));
5056
- } 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/'));
5057
4718
 
5058
4719
  // Done
5059
4720
  console.log(chalk.green('\n✅ Quickstart complete!\n'));
@@ -5076,11 +4737,69 @@ program
5076
4737
  }
5077
4738
  });
5078
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
+
5079
4798
  // Parse arguments
5080
- program.parse();
4799
+ await program.parseAsync(process.argv);
5081
4800
 
5082
4801
  // Show help if no command provided
5083
- if (!process.argv.slice(2).length) {
4802
+ if (!rawArgv.length) {
5084
4803
  program.outputHelp();
5085
4804
  }
5086
4805