@descix/cli 1.0.0 → 1.0.3

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 (103) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +9 -9
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +209 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +29 -9
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/SERVICE_README_sdk.md +0 -399
  93. package/descix-cli-1.0.0.tgz +0 -0
  94. package/lib/commands/app-wizard.js +0 -627
  95. package/lib/commands/folder.js +0 -291
  96. package/lib/core/PathContext.js +0 -335
  97. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  98. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  99. package/templates/scaffolds/site/README.md +0 -51
  100. package/templates/scaffolds/site/app.js +0 -22
  101. package/templates/scaffolds/site/index.html +0 -42
  102. package/templates/scaffolds/site/styles.css +0 -123
  103. package/test-hydration.js +0 -150
package/bin/descix.js CHANGED
@@ -12,27 +12,122 @@ import chalk from 'chalk';
12
12
  import { DeSciXApiClient } from '../lib/api-client.js';
13
13
  import { requireAuth } from '../lib/auth-guard.js';
14
14
  import { WorkspaceConfig } from '../lib/workspace-config.js';
15
+ import { CLI_VERSION } from '../lib/cli-version.js';
16
+ // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
17
+ import {
18
+ getSessionInteractionId,
19
+ findSessionsForApp,
20
+ saveSessionAuthoritative,
21
+ clearAppSessions,
22
+ isStaleThreadError,
23
+ } from '../lib/chat-session.js';
15
24
  import { GlobalConfig } from '../lib/global-config.js';
16
25
  import * as authCommands from '../lib/commands/auth.js';
17
26
  import * as configCommands from '../lib/commands/config.js';
18
- import { runAppWizard } from '../lib/commands/app-wizard.js';
19
27
  import * as buyCommands from '../lib/commands/buy.js';
28
+ import * as creditsCommands from '../lib/commands/credits.js';
29
+ import * as airdropCommands from '../lib/commands/airdrop.js';
20
30
  import { runInit } from '../lib/commands/init.js';
21
- import * as folderCommands from '../lib/commands/folder.js';
22
31
  import * as updateCommands from '../lib/commands/update.js';
32
+ import { registerAllRetiredKbSync, refuseRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
23
33
  import { runStatus } from '../lib/commands/status.js';
24
34
  import { runDoctor } from '../lib/commands/doctor.js';
35
+ import { runHealth } from '../lib/commands/health.js';
25
36
  import * as kbCommands from '../lib/commands/kb.js';
37
+ import { kbVectorCell, kbCountSource } from '../lib/commands/kb-list-render.js';
38
+ import * as corpusCommands from '../lib/commands/corpus.js';
39
+ import * as modelConfigCommands from '../lib/commands/model-config.js';
40
+ import * as brieferCommand from '../lib/commands/briefer/index.js';
26
41
  import fs from 'fs/promises';
27
42
  import path from 'path';
28
43
  import os from 'os';
29
44
 
45
+
46
+ /**
47
+ * Emit human-facing PROGRESS for a command that can also emit JSON.
48
+ *
49
+ * When `--json` is set, stdout is a DATA CHANNEL: it must contain the JSON document and
50
+ * nothing else, or every scripted consumer breaks. Progress then goes to stderr, where a
51
+ * human piping stdout still sees it. Without `--json`, stdout is the human channel and
52
+ * progress belongs there.
53
+ *
54
+ * ONE OWNER of that decision. Before this existed, each --json command re-decided it by
55
+ * calling console.log directly, and both of them got it wrong: `mcp execute --json` prefixed
56
+ * the document with "Executing <tool>..." and `app media-upload --json` prefixed it with a
57
+ * header, a per-file listing and a per-file +/x line. Both exited 0, so a consumer saw a
58
+ * success code and an unparseable stream. Measured 2026-08-21/22 (seat BEAST): two seats had
59
+ * independently hand-rolled prefix-stripping workarounds (`tail -n +2`, `raw.find('[')`)
60
+ * rather than reporting it.
61
+ *
62
+ * @param {Object} options - the command's parsed options (read for .json)
63
+ * @param {...any} args - passed through to console.log / console.error
64
+ */
65
+ function progress(options, ...args) {
66
+ if (options && options.json) console.error(...args);
67
+ else console.log(...args);
68
+ }
69
+
70
+ /**
71
+ * THE ONE OWNER of "a command threw, and the process must stop saying why".
72
+ *
73
+ * WHY THIS EXISTS (measured 2026-08-30 on the SHIPPED artifact): 27 command actions ended in a
74
+ * bare `catch (error) { process.exit(1); }`. `DESCIX_API_URL=not-a-url descix credits balance`
75
+ * exited 1 with ZERO BYTES on stdout AND stderr — the origin owner had built an
76
+ * OriginInvalidError carrying the full remedy text and this handler threw it away. The contract
77
+ * row "a configured-but-invalid origin FAILS LOUD naming the fix" was met in the module and
78
+ * unmet on the artifact, because a message nobody prints is not a message.
79
+ *
80
+ * That is this contract's own bug class — silence — reaching the developer through the
81
+ * contract's own remedy path. A handler that discards the reason is indistinguishable from a
82
+ * crash, and it trains developers to believe the CLI has no error messages.
83
+ *
84
+ * @param {Error} error - the error that ended the command
85
+ */
86
+ function fail(error) {
87
+ const message = (error && error.message) ? error.message : String(error);
88
+ console.error(chalk.red(`\n${message}\n`));
89
+ process.exit(1);
90
+ }
91
+
30
92
  const program = new Command();
31
93
 
32
94
  program
33
95
  .name('descix')
34
96
  .description('DeSciX CLI - Unified command-line interface')
35
- .version('1.0.0');
97
+ .version(CLI_VERSION)
98
+ .option('--env <name>', 'Target environment: dev, demo, prod (overrides API URL)')
99
+ .option('--api-url <url>', 'Direct API URL override (e.g., https://demo.descix.net)');
100
+
101
+ // ============ Global Environment Override ============
102
+ // Maps --env flag to DESCIX_API_URL before any command runs.
103
+ // detectApiUrl() in api-client.js checks process.env.DESCIX_API_URL first.
104
+
105
+ // Derive ephemeral --env URL map from the canonical WorkspaceConfig.ENV_MAP
106
+ const ENV_URL_MAP = Object.fromEntries(
107
+ Object.entries(WorkspaceConfig.ENV_MAP).map(([k, v]) => [k, v.url])
108
+ );
109
+
110
+ program.hook('preAction', (thisCommand) => {
111
+ const opts = thisCommand.opts();
112
+
113
+ // --api-url takes highest priority
114
+ if (opts.apiUrl) {
115
+ process.env.DESCIX_API_URL = opts.apiUrl;
116
+ return;
117
+ }
118
+
119
+ // --env maps to known URLs
120
+ if (opts.env) {
121
+ const envName = opts.env.toLowerCase();
122
+ if (!(envName in ENV_URL_MAP)) {
123
+ console.error(chalk.red(`Unknown environment: ${opts.env}. Use: dev, demo, prod`));
124
+ process.exit(1);
125
+ }
126
+ // Every named environment resolves to a URL (including dev → cloud DEV).
127
+ // A local backend is a URL you name, not an environment.
128
+ process.env.DESCIX_API_URL = ENV_URL_MAP[envName];
129
+ }
130
+ });
36
131
 
37
132
  // ============ Authentication Commands (No Auth Required) ============
38
133
 
@@ -41,33 +136,18 @@ program
41
136
  .command('login')
42
137
  .description('Authenticate with DeSciX via device login (opens browser)')
43
138
  .option('-u, --url <url>', 'API URL override')
44
- .option('--dev', 'Use development server (https://localhost:4000)')
45
139
  .option('--wallet', 'Use direct wallet connection (advanced, not yet implemented)')
140
+ .option('--no-oauth', 'Skip the OAuth long-lived token leg (wallet-signature login only)')
141
+ .option('--scope <scope>', 'OAuth scope to request (default: mcp:read mcp:tools mcp:write mcp:admin)')
46
142
  .action(async (options) => {
47
143
  try {
48
- // Handle --dev flag
49
- if (options.dev) {
50
- const workspaceConfig = await WorkspaceConfig.tryLoad(process.cwd());
51
- if (workspaceConfig) {
52
- workspaceConfig.apiUrl = 'https://localhost:4000';
53
- workspaceConfig.environment = 'development';
54
- await workspaceConfig.save(process.cwd());
55
- } else {
56
- const gc = await GlobalConfig.load();
57
- gc.api_url = 'https://localhost:4000';
58
- gc.environment = 'development';
59
- await gc.save();
60
- }
61
- console.log(chalk.cyan('✓ Configured for development (https://localhost:4000)\n'));
62
- }
63
-
64
144
  if (options.wallet) {
65
145
  await authCommands.loginWallet();
66
146
  } else {
67
147
  await authCommands.loginDevice(options);
68
148
  }
69
149
  } catch (error) {
70
- process.exit(1);
150
+ fail(error);
71
151
  }
72
152
  });
73
153
 
@@ -78,7 +158,20 @@ program
78
158
  try {
79
159
  await authCommands.logout();
80
160
  } catch (error) {
81
- process.exit(1);
161
+ fail(error);
162
+ }
163
+ });
164
+
165
+ program
166
+ .command('admin-login')
167
+ .description('Bootstrap CLI credentials for platform admins (requires admin group membership)')
168
+ .requiredOption('-e, --email <email>', 'Admin email (must be in platform admin Google Group)')
169
+ .option('-u, --url <url>', 'API URL override')
170
+ .action(async (options) => {
171
+ try {
172
+ await authCommands.adminLogin(options);
173
+ } catch (error) {
174
+ fail(error);
82
175
  }
83
176
  });
84
177
 
@@ -121,7 +214,7 @@ program
121
214
  await requireAuth(apiClient);
122
215
  await authCommands.whoami();
123
216
  } catch (error) {
124
- process.exit(1);
217
+ fail(error);
125
218
  }
126
219
  });
127
220
 
@@ -132,7 +225,7 @@ program
132
225
  try {
133
226
  await authCommands.reconnect();
134
227
  } catch (error) {
135
- process.exit(1);
228
+ fail(error);
136
229
  }
137
230
  });
138
231
 
@@ -161,6 +254,53 @@ program
161
254
  }
162
255
  });
163
256
 
257
+ program
258
+ .command('health')
259
+ .description('Check platform service health (env-aware: local-port in DEV, gcloud+HTTPS in DEMO/PROD)')
260
+ .option('--env <name>', 'Target environment: dev|demo|prod (default: dev). Also accepted at top level: descix --env=demo health ...')
261
+ .option('-m, --microservice <name>', 'Check a specific service/app by appId')
262
+ .option('-j, --json', 'Output raw JSON')
263
+ .action(async (options) => {
264
+ try {
265
+ // Resolve --env in priority order: subcommand explicit > program parent > 'dev'.
266
+ // Commander would otherwise silently default to 'dev' even when the parent
267
+ // --env=demo is supplied (because the subcommand owns its own flag space).
268
+ const parentEnv = program.opts().env || null;
269
+ const env = options.env || parentEnv || 'dev';
270
+ await runHealth({ ...options, env });
271
+ } catch (error) {
272
+ console.error(chalk.red(`\nHealth check error: ${error.message}\n`));
273
+ process.exit(1);
274
+ }
275
+ });
276
+
277
+ // ============ Briefer Command (Code-Grounded Mental Model Regen) ============
278
+ // WS-DESCIX-BRIEFER-CLI M1: CLI scaffolding + extractor contract.
279
+ // See DeSciX/DeSciX_Core/descix-cli/lib/commands/briefer/ for implementation.
280
+ // Scope doc: docs/design/ws-descix-briefer-cli.md
281
+ // Briefer target: DeSciX/V2_docs/architecture/platform-must-know-briefer.md
282
+
283
+ program
284
+ .command('briefer')
285
+ .description('Regenerate platform-must-know-briefer.md from live code + gcloud + Firestore (HARD-FAIL on drift)')
286
+ .option('--env <name>', 'Target environment: dev|demo|prod (also accepted at the top level: descix --env=demo briefer ...)')
287
+ .option('--out <path>', 'Override output path (default: workspace-root/DeSciX/V2_docs/architecture/platform-must-know-briefer.md)')
288
+ .option('--check', 'Drift-detection mode: regen to memory, diff against canonical, non-zero exit on drift')
289
+ .option('-v, --verbose', 'Print per-source paths, citations, and timings')
290
+ .action(async (options) => {
291
+ try {
292
+ // Resolve --env in priority order: subcommand explicit > program parent > 'dev'.
293
+ // Commander would otherwise silently default to 'dev' even when the parent
294
+ // --env=demo is supplied (because the subcommand owns its own flag space).
295
+ const parentEnv = program.opts().env || null;
296
+ const env = options.env || parentEnv || 'dev';
297
+ await brieferCommand.runBriefer({ ...options, env });
298
+ } catch (error) {
299
+ console.error(chalk.red(`\n❌ Briefer command failed: ${error.message}\n`));
300
+ process.exit(1);
301
+ }
302
+ });
303
+
164
304
  // ============ Buy Commands (Crypto Payments) ============
165
305
 
166
306
  const buyCommand = program
@@ -179,7 +319,7 @@ buyCommand
179
319
  try {
180
320
  await buyCommands.createQuote(options);
181
321
  } catch (error) {
182
- process.exit(1);
322
+ fail(error);
183
323
  }
184
324
  });
185
325
 
@@ -190,7 +330,7 @@ buyCommand
190
330
  try {
191
331
  await buyCommands.checkStatus(quoteId);
192
332
  } catch (error) {
193
- process.exit(1);
333
+ fail(error);
194
334
  }
195
335
  });
196
336
 
@@ -206,7 +346,7 @@ buyCommand
206
346
  initialInterval: parseInt(options.interval)
207
347
  });
208
348
  } catch (error) {
209
- process.exit(1);
349
+ fail(error);
210
350
  }
211
351
  });
212
352
 
@@ -217,195 +357,112 @@ buyCommand
217
357
  buyCommands.listChains();
218
358
  });
219
359
 
220
- // ============ Sync Commands ============
360
+ // ============ AI Credits Commands (WS-HEADLESS-MVP-A2, CEO-D-2026-07-01 D2) ============
361
+ // Platform-wide USD AI-credits: metered RAG/agent calls debit this balance.
362
+ // NOT community tokens (those are under `descix buy`).
221
363
 
222
- const syncCommand = program
223
- .command('sync')
224
- .description('Content sync operations (context-aware)');
225
-
226
- // Context-aware sync: assets
227
- syncCommand
228
- .command('assets')
229
- .description('Sync app assets (icon, description, system_instructions) - context-aware')
230
- .option('--push', 'Push local assets to Drive (default)')
231
- .option('--pull', 'Pull assets from Drive to local')
232
- .option('--status', 'Show sync status without syncing')
233
- .option('-c, --community <id>', 'Community ID (uses context if not provided)')
234
- .option('-a, --app <id>', 'App ID (uses context if not provided)')
364
+ const creditsCommand = program
365
+ .command('credits')
366
+ .description('Platform-wide USD AI credits (metered AI consumption)');
367
+
368
+ creditsCommand
369
+ .command('balance')
370
+ .description('Show your AI-credits balance')
371
+ .action(async () => {
372
+ try {
373
+ await creditsCommands.showBalance();
374
+ } catch (error) {
375
+ fail(error);
376
+ }
377
+ });
378
+
379
+ creditsCommand
380
+ .command('history')
381
+ .description('Show your credit ledger (purchases, debits, grants)')
382
+ .option('--limit <n>', 'Max entries (default 50)')
235
383
  .action(async (options) => {
236
384
  try {
237
- const apiClient = new DeSciXApiClient();
238
- await requireAuth(apiClient);
239
-
240
- // Load workspace context
241
- const workspaceConfig = await WorkspaceConfig.load();
242
- const ctx = workspaceConfig.resolveContextWithOptions(options);
243
-
244
- const communityId = ctx.communityId;
245
- const appId = ctx.appId;
246
-
247
- if (!communityId || !appId) {
248
- console.error(chalk.red('\n❌ Community and App ID required.'));
249
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
250
- process.exit(1);
251
- }
252
-
253
- if (options.status) {
254
- console.log(chalk.cyan(`\n📊 Asset Sync Status: ${communityId}/${appId}\n`));
255
- // TODO: Implement status check
256
- console.log(chalk.gray(' Status check not yet implemented for assets.\n'));
257
- return;
258
- }
259
-
260
- if (options.pull) {
261
- console.log(chalk.cyan(`\n📥 Pulling assets from Drive: ${communityId}/${appId}\n`));
262
- // TODO: Implement pull
263
- console.log(chalk.gray(' Pull not yet implemented. Use descix update app for push.\n'));
264
- return;
265
- }
266
-
267
- // Default: push
268
- await updateCommands.updateApp(options);
269
-
385
+ await creditsCommands.showHistory(options);
270
386
  } catch (error) {
271
- console.error(chalk.red(`\n❌ ${error.message}\n`));
272
- process.exit(1);
387
+ fail(error);
273
388
  }
274
389
  });
275
390
 
276
- // Context-aware sync: kb
277
- syncCommand
278
- .command('kb')
279
- .description('Sync knowledge base files - context-aware')
280
- .argument('[stage]', 'Specific stage: stage1, stage2, stage3, all (default: all)')
281
- .option('--push', 'Push local to Drive (Stage 1)')
282
- .option('--pull', 'Pull from Drive to local (Stage 1)')
283
- .option('--status', 'Show sync status')
284
- .option('-c, --community <id>', 'Community ID (uses context if not provided)')
285
- .option('-a, --app <id>', 'App ID (uses context if not provided)')
286
- .option('-k, --kb <name>', 'KB name (default: General)')
287
- .action(async (stage, options) => {
391
+ creditsCommand
392
+ .command('buy')
393
+ .description('Buy AI credits with USD (Stripe checkout)')
394
+ .requiredOption('--usd <amount>', 'USD amount of credits to buy')
395
+ // The default is the origin this invocation is already talking to, NOT a fixed production
396
+ // host — see commands/credits.js. Naming a prod origin here made --help itself the misreport.
397
+ .option('--return-base <url>', 'Base URL for the checkout success/cancel landing (default: the API origin in use)')
398
+ .action(async (options) => {
288
399
  try {
289
- const apiClient = new DeSciXApiClient();
290
- await requireAuth(apiClient);
291
-
292
- // Load workspace context
293
- const workspaceConfig = await WorkspaceConfig.load();
294
- const ctx = workspaceConfig.resolveContextWithOptions(options);
295
-
296
- const communityId = ctx.communityId;
297
- const appId = ctx.appId;
298
- const kbId = options.kb || ctx.kbId || 'General';
299
-
300
- if (!communityId || !appId) {
301
- console.error(chalk.red('\n❌ Community and App ID required.'));
302
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
303
- process.exit(1);
304
- }
305
-
306
- if (options.status) {
307
- console.log(chalk.cyan(`\n📊 KB Sync Status: ${communityId}/${appId}/${kbId}\n`));
308
- // Call existing status logic
309
- const stages = stage === 'stage1' ? [1] : stage === 'stage2' ? [2] : stage === 'stage3' ? [3] : [1, 2, 3];
310
-
311
- for (const s of stages) {
312
- if (s === 2) {
313
- const response = await apiClient.invoke('get_drive_gcs_sync_status', {
314
- community_id: communityId, app_id: appId, kb_id: kbId
315
- });
316
- const result = response.message || response;
317
- console.log(chalk.yellow('Stage 2: Drive → GCS'));
318
- console.log(chalk.gray(` To Extract: ${result.to_extract || 0}`));
319
- console.log(chalk.gray(` In Sync: ${result.in_sync || 0}\n`));
320
- }
321
- if (s === 3) {
322
- const response = await apiClient.invoke('get_gcs_pinecone_sync_status', {
323
- community_id: communityId, app_id: appId, kb_id: kbId
324
- });
325
- const result = response.message || response;
326
- console.log(chalk.yellow('Stage 3: GCS → Pinecone'));
327
- console.log(chalk.gray(` To Vectorize: ${result.to_vectorize || 0}`));
328
- console.log(chalk.gray(` Total Vectors: ${result.total_vectors || 0}\n`));
329
- }
330
- }
331
- return;
332
- }
333
-
334
- // Determine what to sync
335
- const stage1Only = stage === 'stage1' || options.push;
336
- const stageToRun = stage || 'all';
337
-
338
- console.log(chalk.cyan(`\n📤 KB Sync: ${communityId}/${appId}/${kbId} (${stageToRun})\n`));
339
-
340
- await updateCommands.updateKB({
341
- ...options,
342
- kb: kbId,
343
- stage1Only: stage1Only,
344
- skipStage1: stage === 'stage2' || stage === 'stage3'
345
- });
346
-
400
+ await creditsCommands.buyCredits({ usd: options.usd, returnBase: options.returnBase });
347
401
  } catch (error) {
348
- console.error(chalk.red(`\n❌ ${error.message}\n`));
349
- process.exit(1);
402
+ fail(error);
350
403
  }
351
404
  });
352
405
 
353
- // Context-aware sync: site
354
- syncCommand
355
- .command('site')
356
- .description('Sync CodeSite to GCS - context-aware')
357
- .option('--push', 'Deploy to GCS (default)')
358
- .option('--preview', 'Deploy to preview URL')
359
- .option('--status', 'Show deployment status')
360
- .option('--port <number>', 'Register local dev server port')
361
- .option('-c, --community <id>', 'Community ID (uses context if not provided)')
362
- .option('-a, --app <id>', 'App ID (uses context if not provided)')
406
+ creditsCommand
407
+ .command('grant')
408
+ .description('ADMIN: grant AI credits to a user')
409
+ .requiredOption('--user <user_id>', 'Target user id')
410
+ .requiredOption('--usd <amount>', 'USD amount to grant')
411
+ .requiredOption('--reason <text>', 'Audit reason')
363
412
  .action(async (options) => {
364
413
  try {
365
- const apiClient = new DeSciXApiClient();
366
- await requireAuth(apiClient);
367
-
368
- // Load workspace context
369
- const workspaceConfig = await WorkspaceConfig.load();
370
- const ctx = workspaceConfig.resolveContextWithOptions(options);
371
-
372
- const communityId = ctx.communityId;
373
- const appId = ctx.appId;
374
-
375
- if (!communityId || !appId) {
376
- console.error(chalk.red('\n❌ Community and App ID required.'));
377
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
378
- process.exit(1);
379
- }
380
-
381
- if (options.status) {
382
- console.log(chalk.cyan(`\n📊 Site Status: ${communityId}/${appId}\n`));
383
- const response = await apiClient.invoke('get_site_manifest', {
384
- community_id: communityId, app_id: appId
385
- });
386
- const result = response.message || response;
387
- if (result.manifest) {
388
- console.log(chalk.gray(` Files: ${Object.keys(result.manifest.files || {}).length}`));
389
- console.log(chalk.gray(` URL: ${result.site_url}\n`));
390
- } else {
391
- console.log(chalk.gray(' No site deployed.\n'));
392
- }
393
- return;
394
- }
395
-
396
- // Default: push
397
- await updateCommands.updateSite({
398
- ...options,
399
- preview: options.preview,
400
- port: options.port ? parseInt(options.port) : undefined
401
- });
402
-
414
+ await creditsCommands.grantCredits(options);
403
415
  } catch (error) {
404
- console.error(chalk.red(`\n❌ ${error.message}\n`));
405
- process.exit(1);
416
+ fail(error);
417
+ }
418
+ });
419
+
420
+ creditsCommand
421
+ .command('refund')
422
+ .description('ADMIN: remove AI credits from a user (e.g. after a Stripe refund)')
423
+ .requiredOption('--user <user_id>', 'Target user id')
424
+ .requiredOption('--usd <amount>', 'USD amount to remove')
425
+ .requiredOption('--reason <text>', 'Audit reason')
426
+ .action(async (options) => {
427
+ try {
428
+ await creditsCommands.refundCredits(options);
429
+ } catch (error) {
430
+ fail(error);
406
431
  }
407
432
  });
408
433
 
434
+ // ============ Airdrop Commands (WS-ADMIN-B1 manual-trigger) ============
435
+ // Per CEO-D-MANUAL-TRIGGER-NO-CRON (2026-04-20), airdrop batch execution is operator-triggered
436
+ // via this CLI command group rather than Cloud Scheduler cron. Server-side access is gated on
437
+ // platform-admin membership (`isPlatformAdmin(user)`); the server emits an on_chain_log row
438
+ // with `caller.operator_email` for audit.
439
+
440
+ const airdropCommand = program
441
+ .command('airdrop')
442
+ .description('Admin airdrop migration operations (WS-ADMIN-B1)');
443
+
444
+ airdropCommand
445
+ .command('execute-queue')
446
+ .description('Manually trigger airdrop_execute_queue on the target env (admin-only)')
447
+ .option('--community <slug>', 'Community slug for per-community batch scoping (REQUIRED for --apply)')
448
+ .option('--dry-run', 'Read-only preview: encode calldata, estimate gas, validate net-zero invariant. No PK, no tx, no state mutation.')
449
+ .option('--apply', 'Live execution: requires --signer-pk-file or interactive prompt-password. Mutually exclusive with --dry-run.')
450
+ .option('--signer-pk-file <path>', 'Path to file containing admin signer PK (0x + 64 hex). Required for --apply unless prompted interactively.')
451
+ .option('--batch-size <n>', 'Cap on users processed this run (server caps at AIRDROP_MAX_RUN_USERS)')
452
+ .action(async (options) => {
453
+ try {
454
+ await airdropCommands.executeQueue(options);
455
+ } catch (error) {
456
+ fail(error);
457
+ }
458
+ });
459
+
460
+ // ============ Sync Commands: REMOVED ============
461
+ // `sync assets`, `sync site` and `sync kb` are all gone, so the `sync` GROUP is gone with
462
+ // them: a registered verb with no working children is dead weight. Every retired surface is
463
+ // registered in ONE place below (search registerAllRetiredKbSync), driven off the owner list
464
+ // in lib/commands/retired-kb-sync.js -- this file names no retired verb itself.
465
+
409
466
  // ============ Community/App Commands ============
410
467
 
411
468
  const communityCommand = program
@@ -441,17 +498,23 @@ communityCommand
441
498
  communityCommand
442
499
  .command('info')
443
500
  .description('Get community information')
444
- .requiredOption('-c, --community <id>', 'Community ID')
501
+ .option('-c, --community <id>', 'Community ID')
502
+ .option('-n, --name <id>', 'Community ID (alias for -c)')
445
503
  .action(async (options) => {
446
504
  try {
505
+ const communityId = options.community || options.name;
506
+ if (!communityId) {
507
+ console.error(chalk.red('\n Error: Community ID is required. Use -c or -n to specify.\n'));
508
+ process.exit(1);
509
+ }
447
510
  const apiClient = new DeSciXApiClient();
448
511
  await requireAuth(apiClient);
449
-
450
- const response = await apiClient.invoke('get_community', { community_id: options.community }, { allowGuest: false });
512
+
513
+ const response = await apiClient.invoke('get_community', { community_id: communityId }, { allowGuest: false });
451
514
  const result = response.message || response;
452
515
  const community = result.community;
453
-
454
- console.log(chalk.green('\n✅ Community Information:\n'));
516
+
517
+ console.log(chalk.green('\n Community Information:\n'));
455
518
  console.log(chalk.cyan(` Name: ${community.community_name}`));
456
519
  console.log(chalk.gray(` ID: ${community.community_id}`));
457
520
  console.log(chalk.gray(` Token: ${community.token_symbol}`));
@@ -502,90 +565,329 @@ communityCommand
502
565
 
503
566
  communityCommand
504
567
  .command('create')
505
- .description('Community creation is via PWA (Workspace Config). Admins: use descix-admin community create')
506
- .action(async () => {
507
- console.log(chalk.cyan('\n📦 Community creation\n'));
508
- console.log(chalk.white('Create communities in the PWA (Workspace Config / Device Setup).'));
509
- console.log(chalk.white('Platform admins can use: descix-admin community create -n "Name" -t SYMBOL\n'));
510
- console.log(chalk.gray(' descix-admin uses the same workspace root and .descix/wallet.json as this CLI.\n'));
511
- });
512
-
513
- // Deploy a token contract without creating community
514
- communityCommand
515
- .command('deploy-token')
516
- .description('Deploy a new token contract (without creating a community)')
517
- .requiredOption('-t, --token <symbol>', 'Token symbol (e.g., POWCH)')
518
- .requiredOption('-n, --name <name>', 'Token name (e.g., "Powch Token")')
519
- .option('--treasury <address>', 'Treasury wallet address (defaults to platform wallet)')
520
- .option('-c, --community <id>', 'Community ID to link the token to (optional)')
568
+ .description('[ADMIN] Create a new community with token contract (requires platform admin, deploys to live Polygon)')
569
+ .requiredOption('-n, --name <name>', 'Community display name (e.g. "SMILE")')
570
+ .requiredOption('-t, --token <symbol>', 'Token symbol (e.g. SMILE)')
571
+ .option('--icon <url>', 'Icon URL for the community')
572
+ .option('--yes', 'Skip confirmation prompt (use with caution)')
521
573
  .action(async (options) => {
522
574
  try {
523
575
  const apiClient = new DeSciXApiClient();
524
576
  await requireAuth(apiClient);
525
-
577
+
578
+ const communityId = options.token.toLowerCase();
526
579
  const tokenSymbol = options.token.toUpperCase();
527
- const tokenName = options.name;
528
-
529
- console.log(chalk.cyan('\n📦 Deploying token contract...\n'));
530
- console.log(chalk.gray(` Token Symbol: ${tokenSymbol}`));
531
- console.log(chalk.gray(` Token Name: ${tokenName}`));
532
- if (options.treasury) {
533
- console.log(chalk.gray(` Treasury: ${options.treasury}`));
534
- }
535
- if (options.community) {
536
- console.log(chalk.gray(` Community: ${options.community}`));
580
+
581
+ console.log(chalk.cyan('\n📦 Community Creation [ADMIN ONLY]\n'));
582
+ console.log(chalk.yellow(' ⚠ WARNING: This operation is IRREVERSIBLE.'));
583
+ console.log(chalk.yellow(' ⚠ DEV/DEMO environments use the LIVE Polygon blockchain.'));
584
+ console.log(chalk.yellow(' ⚠ A real token contract will be deployed on-chain.\n'));
585
+ console.log(chalk.white(` Community ID: ${communityId}`));
586
+ console.log(chalk.white(` Community Name: ${options.name}`));
587
+ console.log(chalk.white(` Token Symbol: ${tokenSymbol}`));
588
+ console.log(chalk.white(` Network: Polygon (live)\n`));
589
+
590
+ if (!options.yes) {
591
+ const readline = await import('readline');
592
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
593
+ const answer = await new Promise(resolve => {
594
+ rl.question(chalk.yellow(` Create community "${options.name}" with token ${tokenSymbol} on live Polygon? [y/N] `), resolve);
595
+ });
596
+ rl.close();
597
+ if (answer.toLowerCase() !== 'y') {
598
+ console.log(chalk.gray('\n Aborted.\n'));
599
+ return;
600
+ }
537
601
  }
538
- console.log();
539
-
540
- console.log(chalk.yellow('⏳ This may take a few minutes while transactions confirm on-chain...\n'));
541
-
542
- const response = await apiClient.invoke('deploy_contract', {
543
- template_type: 'DAITA_v2',
602
+
603
+ console.log(chalk.gray('\n Creating community skeleton...'));
604
+
605
+ const response = await apiClient.invoke('create_community_skeleton', {
606
+ community_id: communityId,
607
+ community_name: options.name,
544
608
  token_symbol: tokenSymbol,
545
- token_name: tokenName,
546
- treasury_wallet: options.treasury || null,
547
- community_id: options.community || null
609
+ icon_url: options.icon || null,
548
610
  });
549
-
550
- // Handle response wrapping
611
+
551
612
  const result = response.message || response;
552
-
553
- if (result.status !== 'OK') {
554
- throw new Error(result.message || 'Deployment failed');
555
- }
556
-
557
- const deployment = result.deployment;
558
-
559
- console.log(chalk.green('✅ Token contract deployed successfully!\n'));
560
- console.log(chalk.white('Contract Details:'));
561
- console.log(chalk.gray(` Proxy Address: ${deployment.proxy_address}`));
562
- console.log(chalk.gray(` Implementation: ${deployment.implementation_address}`));
563
- console.log(chalk.gray(` Token Symbol: ${deployment.token_symbol}`));
564
- console.log(chalk.gray(` Token Name: ${deployment.token_name}`));
565
- console.log(chalk.gray(` Network: ${deployment.network} (chainId: ${deployment.chain_id})`));
566
- console.log(chalk.gray(` TX Hash: ${deployment.tx_hash}`));
567
- console.log();
568
-
569
- if (deployment.network === 'polygon') {
570
- console.log(chalk.cyan(`View on Polygonscan: https://polygonscan.com/address/${deployment.proxy_address}`));
571
- } else if (deployment.network === 'amoy') {
572
- console.log(chalk.cyan(`View on Amoy: https://amoy.polygonscan.com/address/${deployment.proxy_address}`));
573
- }
574
-
575
- if (!options.community) {
576
- console.log(chalk.yellow('\nNext steps:'));
577
- console.log(chalk.gray(` 1. Create community: descix community create -n "Name" -t ${tokenSymbol} -p ./path --no-token-contract`));
578
- console.log(chalk.gray(` 2. Link token: descix community link-token -c <community-id> -a ${deployment.proxy_address}`));
613
+
614
+ console.log(chalk.green(`\n ✓ Community "${options.name}" created\n`));
615
+ console.log(chalk.white(` Community ID: ${result.community_id}`));
616
+ console.log(chalk.white(` Default App: ${result.app_id}`));
617
+ console.log(chalk.white(` Token Symbol: ${result.token_symbol}`));
618
+ if (result.contract_address) {
619
+ console.log(chalk.white(` Contract: ${result.contract_address}`));
579
620
  }
580
- console.log();
581
-
621
+
622
+ console.log(chalk.cyan('\n Next steps:'));
623
+ console.log(chalk.gray(` descix app init -a ${result.app_id} # Initialize KB`));
624
+ console.log(chalk.gray(` descix kb corpus sync -a ${result.app_id} # Sync content`));
625
+ console.log(chalk.gray(` descix site upload -c ${communityId} -a ${result.app_id} -p ./site # Deploy site\n`));
626
+
582
627
  } catch (error) {
583
- console.error(chalk.red(`\n❌ Error: ${error.message}`));
628
+ console.error(chalk.red(`\n Error: ${error.message}\n`));
584
629
  process.exit(1);
585
630
  }
586
631
  });
587
632
 
588
- // Link existing token contract to community
633
+ communityCommand
634
+ .command('delete')
635
+ .description('[ADMIN] Delete a community with full cascade cleanup (all apps, KBs, vectors, GCS)')
636
+ .requiredOption('-n, --name <community_id>', 'Community ID to delete')
637
+ .option('--dry-run', 'Preview what would be deleted without executing')
638
+ .option('--soft', 'Soft delete only (mark as hidden, no cascade)')
639
+ .option('--yes', 'Skip confirmation prompt')
640
+ .action(async (options) => {
641
+ try {
642
+ const apiClient = new DeSciXApiClient();
643
+ await requireAuth(apiClient);
644
+
645
+ const communityId = options.name;
646
+ const hardDelete = !options.soft;
647
+ const dryRun = !!options.dryRun;
648
+
649
+ if (dryRun) {
650
+ console.log(chalk.cyan(`\n--- DRY RUN: delete community ${communityId} ---\n`));
651
+ }
652
+
653
+ // Always call with dry_run first to get the manifest for display
654
+ const previewResponse = await apiClient.invoke('delete_community', {
655
+ community_id: communityId,
656
+ hard_delete: hardDelete,
657
+ dry_run: true
658
+ });
659
+
660
+ const preview = previewResponse.message || previewResponse;
661
+ const manifest = preview.community_manifest;
662
+
663
+ if (!manifest) {
664
+ console.error(chalk.red('\n Error: Could not retrieve community information.\n'));
665
+ process.exit(1);
666
+ }
667
+
668
+ // Display manifest
669
+ console.log(chalk.bold('Community deletion manifest:\n'));
670
+ console.log(chalk.gray(` Community: ${manifest.community_name} (${manifest.community_id})`));
671
+ console.log(chalk.gray(` Token: ${manifest.token_symbol}`));
672
+ console.log(chalk.gray(` Apps: ${manifest.app_count}`));
673
+ if (manifest.apps && manifest.apps.length > 0) {
674
+ for (const appM of manifest.apps) {
675
+ if (appM.error) {
676
+ console.log(chalk.red(` - ${appM.app_id} (error: ${appM.error})`));
677
+ } else {
678
+ const kbCount = appM.pinecone_kb_count || 0;
679
+ console.log(chalk.gray(` - ${appM.app_id}: ${kbCount} KB(s), GCS: ${appM.gcs_prefix || 'N/A'}, Products: ${appM.products_doc ? 'yes' : 'no'}`));
680
+ }
681
+ }
682
+ }
683
+ console.log(chalk.gray(` Mode: ${hardDelete ? 'HARD DELETE (permanent)' : 'soft delete (hide only)'}`));
684
+
685
+ if (dryRun) {
686
+ console.log(chalk.yellow('\n No changes were made. Remove --dry-run to execute.\n'));
687
+ return;
688
+ }
689
+
690
+ // Confirmation prompt (unless --yes)
691
+ if (!options.yes && hardDelete) {
692
+ const readline = await import('readline');
693
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
694
+ const answer = await new Promise(resolve => {
695
+ 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);
696
+ });
697
+ rl.close();
698
+ if (answer.toLowerCase() !== 'y') {
699
+ console.log(chalk.gray('\n Aborted.\n'));
700
+ return;
701
+ }
702
+ }
703
+
704
+ // Execute the actual delete
705
+ const response = await apiClient.invoke('delete_community', {
706
+ community_id: communityId,
707
+ hard_delete: hardDelete,
708
+ dry_run: false
709
+ });
710
+
711
+ const result = response.message || response;
712
+
713
+ if (result.cleanup) {
714
+ const c = result.cleanup;
715
+ console.log(chalk.green(`\n Community ${communityId} deleted.\n`));
716
+ console.log(chalk.gray(` Apps deleted: ${c.apps_deleted?.length || 0} (${c.apps_deleted?.join(', ') || 'none'})`));
717
+ if (c.apps_failed?.length > 0) {
718
+ console.log(chalk.red(` Apps failed: ${c.apps_failed.length}`));
719
+ for (const f of c.apps_failed) {
720
+ console.log(chalk.red(` - ${f.app_id}: ${f.error}`));
721
+ }
722
+ }
723
+ console.log(chalk.gray(` Pinecone vectors: ${c.total_pinecone_deleted}`));
724
+ console.log(chalk.gray(` GCS files: ${c.total_gcs_deleted}`));
725
+ console.log(chalk.gray(` Firestore docs: ${c.total_firestore_deleted}`));
726
+ console.log(chalk.gray(` Products entries: ${c.total_products_deleted}`));
727
+ console.log(chalk.gray(` ServiceManifests: ${c.total_service_manifests_deleted}`));
728
+ console.log();
729
+ } else {
730
+ console.log(chalk.green(`\n ${result.message || `Community ${communityId} deleted.`}\n`));
731
+ }
732
+ } catch (error) {
733
+ console.error(chalk.red(`\n Error: ${error.message}\n`));
734
+ process.exit(1);
735
+ }
736
+ });
737
+
738
+ communityCommand
739
+ .command('rename')
740
+ .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.')
741
+ .argument('<old_community_id>', 'Current community_id (e.g. unkamon)')
742
+ .argument('<new_community_id>', 'New canonical community_id (e.g. unk)')
743
+ .option('--dry-run', 'Preview the full cascade plan without executing')
744
+ .option('--yes', 'Skip confirmation prompt')
745
+ .action(async (oldId, newId, options) => {
746
+ try {
747
+ const apiClient = new DeSciXApiClient();
748
+ await requireAuth(apiClient);
749
+ const dryRun = !!options.dryRun;
750
+
751
+ // Always fetch the plan first (dry_run:true) for display.
752
+ const previewResp = await apiClient.invoke('rename_community', {
753
+ old_community_id: oldId, new_community_id: newId, dry_run: true
754
+ });
755
+ const preview = previewResp.message || previewResp;
756
+ const plan = preview.plan;
757
+ if (!plan) {
758
+ console.error(chalk.red('\n Error: could not retrieve rename plan.\n'));
759
+ process.exit(1);
760
+ }
761
+
762
+ console.log(chalk.bold(`\nCommunity rename plan: ${oldId} -> ${newId}\n`));
763
+ console.log(chalk.gray(` Community doc: old_exists=${plan.community_doc.old_exists}, new_exists=${plan.community_doc.new_exists}`));
764
+ console.log(chalk.gray(` Name / token: ${plan.community_doc.community_name} / ${plan.community_doc.token_symbol}`));
765
+ console.log(chalk.gray(` Apps subcollection: ${plan.community_doc.apps_subcollection.join(', ') || '(none)'}`));
766
+ console.log(chalk.gray(` Roles subcollection: ${plan.community_doc.roles_subcollection.join(', ') || '(none)'}`));
767
+ console.log(chalk.gray(` Community tree docs: ${plan.community_doc.total_docs_in_tree}`));
768
+ console.log(chalk.gray(` Products to update: ${plan.products.count} (${plan.products.app_ids.join(', ')})`));
769
+ console.log(chalk.gray(` User purchases: ${plan.user_purchases_to_repoint}`));
770
+ console.log(chalk.gray(` ServiceManifests: ${plan.service_manifests_to_retag.join(', ') || '(none)'}`));
771
+ console.log(chalk.gray(` Chain product/contract: ${plan.chain.product_doc_old || '(none)'} / ${plan.chain.contract_address || '(none)'} (symbol ${plan.chain.token_symbol}, communityId ${plan.chain.contract_community_id})`));
772
+ console.log(chalk.cyan(` Pinecone re-tag: scanned=${plan.pinecone.scanned}, matched(community=${oldId})=${plan.pinecone.matched}, legacy-id-prefixed=${plan.pinecone.legacyIdMatched}`));
773
+ console.log(chalk.gray(` by app: ${JSON.stringify(plan.pinecone.byApp)}`));
774
+ console.log(chalk.gray(` prefix counts: ${JSON.stringify(plan.pinecone.prefixCounts)}`));
775
+
776
+ if (dryRun) {
777
+ console.log(chalk.yellow('\n DRY RUN — no changes made. Remove --dry-run to execute.\n'));
778
+ return;
779
+ }
780
+
781
+ if (!options.yes) {
782
+ const readline = await import('readline');
783
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
784
+ const answer = await new Promise(resolve => rl.question(
785
+ chalk.yellow(`\n Execute rename ${oldId} -> ${newId}? Re-tags ${plan.pinecone.matched} vector(s) and deletes Community/${oldId} after verifying Community/${newId}. [y/N] `),
786
+ resolve
787
+ ));
788
+ rl.close();
789
+ if (answer.toLowerCase() !== 'y') {
790
+ console.log(chalk.gray('\n Aborted.\n'));
791
+ return;
792
+ }
793
+ }
794
+
795
+ const resp = await apiClient.invoke('rename_community', {
796
+ old_community_id: oldId, new_community_id: newId, dry_run: false
797
+ });
798
+ const result = resp.message || resp;
799
+ const ex = result.executed || {};
800
+ console.log(chalk.green(`\n ${result.message}\n`));
801
+ console.log(chalk.gray(` Community doc copied: ${ex.community_doc_copied}`));
802
+ console.log(chalk.gray(` Old community deleted: ${ex.old_community_deleted}`));
803
+ console.log(chalk.gray(` Products updated: ${ex.products_updated}`));
804
+ console.log(chalk.gray(` User purchases: ${ex.user_purchases_repointed}`));
805
+ console.log(chalk.gray(` Pinecone re-tagged: ${ex.pinecone?.updated} / matched ${ex.pinecone?.matched}`));
806
+ console.log(chalk.gray(` ServiceManifests: ${ex.service_manifests_retagged}`));
807
+ console.log(chalk.gray(` Chain re-pointed: ${ex.chain_repointed} (contract ${ex.chain_contract_address}, symbol unchanged)`));
808
+ console.log();
809
+ } catch (error) {
810
+ console.error(chalk.red(`\n Error: ${error.message}\n`));
811
+ process.exit(1);
812
+ }
813
+ });
814
+
815
+ // Deploy a token contract without creating community
816
+ communityCommand
817
+ .command('deploy-token')
818
+ .description('Deploy a new token contract (without creating a community)')
819
+ .requiredOption('-t, --token <symbol>', 'Token symbol (e.g., POWCH)')
820
+ .requiredOption('-n, --name <name>', 'Token name (e.g., "Powch Token")')
821
+ .option('--treasury <address>', 'Treasury wallet address (defaults to platform wallet)')
822
+ .option('-c, --community <id>', 'Community ID to link the token to (optional)')
823
+ .action(async (options) => {
824
+ try {
825
+ const apiClient = new DeSciXApiClient();
826
+ await requireAuth(apiClient);
827
+
828
+ const tokenSymbol = options.token.toUpperCase();
829
+ const tokenName = options.name;
830
+
831
+ console.log(chalk.cyan('\n📦 Deploying token contract...\n'));
832
+ console.log(chalk.gray(` Token Symbol: ${tokenSymbol}`));
833
+ console.log(chalk.gray(` Token Name: ${tokenName}`));
834
+ if (options.treasury) {
835
+ console.log(chalk.gray(` Treasury: ${options.treasury}`));
836
+ }
837
+ if (options.community) {
838
+ console.log(chalk.gray(` Community: ${options.community}`));
839
+ }
840
+ console.log();
841
+
842
+ console.log(chalk.yellow('⏳ This may take a few minutes while transactions confirm on-chain...\n'));
843
+
844
+ const response = await apiClient.invoke('deploy_contract', {
845
+ template_type: 'DAITA_v2',
846
+ token_symbol: tokenSymbol,
847
+ token_name: tokenName,
848
+ treasury_wallet: options.treasury || null,
849
+ community_id: options.community || null
850
+ });
851
+
852
+ // Handle response wrapping
853
+ const result = response.message || response;
854
+
855
+ if (result.status !== 'OK') {
856
+ throw new Error(result.message || 'Deployment failed');
857
+ }
858
+
859
+ const deployment = result.deployment;
860
+
861
+ console.log(chalk.green('✅ Token contract deployed successfully!\n'));
862
+ console.log(chalk.white('Contract Details:'));
863
+ console.log(chalk.gray(` Proxy Address: ${deployment.proxy_address}`));
864
+ console.log(chalk.gray(` Implementation: ${deployment.implementation_address}`));
865
+ console.log(chalk.gray(` Token Symbol: ${deployment.token_symbol}`));
866
+ console.log(chalk.gray(` Token Name: ${deployment.token_name}`));
867
+ console.log(chalk.gray(` Network: ${deployment.network} (chainId: ${deployment.chain_id})`));
868
+ console.log(chalk.gray(` TX Hash: ${deployment.tx_hash}`));
869
+ console.log();
870
+
871
+ if (deployment.network === 'polygon') {
872
+ console.log(chalk.cyan(`View on Polygonscan: https://polygonscan.com/address/${deployment.proxy_address}`));
873
+ } else if (deployment.network === 'amoy') {
874
+ console.log(chalk.cyan(`View on Amoy: https://amoy.polygonscan.com/address/${deployment.proxy_address}`));
875
+ }
876
+
877
+ if (!options.community) {
878
+ console.log(chalk.yellow('\nNext steps:'));
879
+ console.log(chalk.gray(` 1. Create community: descix community create -n "Name" -t ${tokenSymbol}`));
880
+ console.log(chalk.gray(` 2. Link token: descix community link-token -c <community-id> -a ${deployment.proxy_address}`));
881
+ }
882
+ console.log();
883
+
884
+ } catch (error) {
885
+ console.error(chalk.red(`\n❌ Error: ${error.message}`));
886
+ process.exit(1);
887
+ }
888
+ });
889
+
890
+ // Link existing token contract to community
589
891
  communityCommand
590
892
  .command('link-token')
591
893
  .description('Link an existing token contract to a community')
@@ -743,39 +1045,64 @@ appCommand
743
1045
 
744
1046
  appCommand
745
1047
  .command('init')
746
- .description('Initialize local workspace and Firestore KB for an app (idempotent)')
747
- .requiredOption('-a, --app <app_id>', 'App ID (e.g. daita)')
1048
+ // ONE PATH TO AN APP (CEO-D-2026-08-14): `app init` is THE app entry point — it creates the
1049
+ // app when it does not exist yet and then registers/scaffolds it locally. The separate
1050
+ // `app create` command is DELETED (no compat fence): two commands for one outcome is how apps
1051
+ // ended up half-made — created on the platform, never initialized — which is precisely the
1052
+ // state the DEV audit found (powch, sml, daita-ssgpod carrying zero knowledge bases).
1053
+ // Creation still routes through the single canonical server command create_app_for_community,
1054
+ // which composes the app_id and fails loud on a missing community / duplicate id / bad short
1055
+ // name. The server also guarantees the default KB, so no app can exist without one.
1056
+ .description('Create (if needed) and initialize an app: platform record + default KB + local workspace registration and scaffold. Idempotent. Pass -c to create a new app; omit it to initialize one that already exists.')
1057
+ .requiredOption('-a, --app <app_id>', 'App ID (e.g. daita). With -c this is the app NAME to create.')
1058
+ .option('-c, --community <id>', 'Community ID — REQUIRED to create an app that does not exist yet. The community must already be materialized in this environment.')
1059
+ .option('-s, --short <short_name>', 'SHORT id segment (no hyphens) used when creating; the app_id is composed server-side as {community}-{short}. Defaults to --app.')
1060
+ .option('--overwrite', 'When creating, overwrite an existing app record intentionally')
748
1061
  .option('--kb <name>', 'Knowledge base name', 'General')
749
1062
  .option('-p, --path <dir>', 'Local app directory (default: auto-detected or cwd)')
750
- .option('-c, --community <community_id>', 'Community to create app in (if not yet in Products registry)')
751
1063
  .action(async (options) => {
752
1064
  try {
753
1065
  const apiClient = new DeSciXApiClient();
754
1066
  await requireAuth(apiClient);
755
- const appId = options.app;
1067
+ let appId = options.app;
756
1068
  const kbId = options.kb || 'General';
757
1069
 
758
- // Resolve community_id from Products registry
1070
+ // Resolve community_id from the Products registry to learn whether the app exists yet.
759
1071
  let communityId;
760
1072
  try {
761
1073
  const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
762
1074
  communityId = (productCtx.message || productCtx).community_id;
763
1075
  } catch (e) {
764
- // Product not found — fall through to creation below
1076
+ // Not in Products — either we create it below (-c given) or we fail loud.
765
1077
  }
766
- // Product missing or returned null community_id — create if --community was provided
1078
+
767
1079
  if (!communityId) {
768
- if (options.community) {
769
- console.log(chalk.gray(` App '${appId}' not in Products registry. Creating in community '${options.community}'...`));
770
- await apiClient.invoke('create_app_for_community', {
771
- community_id: options.community,
772
- app_name: appId,
773
- });
774
- communityId = options.community;
775
- console.log(chalk.green(` ✓ Created App + Products + Purchase for ${appId}`));
776
- } else {
777
- throw new Error(`App '${appId}' not found in Products registry. Use --community to create it.`);
1080
+ if (!options.community) {
1081
+ throw new Error(
1082
+ `App '${appId}' does not exist yet. Pass -c <community> to create it: ` +
1083
+ `descix app init -a ${appId} -c <community> [-s <short>]`
1084
+ );
778
1085
  }
1086
+ // CREATE leg — the canonical server path. It composes the unique app_id
1087
+ // ({community}-{short}) and is authoritative; we never compose it client-side.
1088
+ const response = await apiClient.invoke('create_app_for_community', {
1089
+ community_id: options.community,
1090
+ app_name: appId,
1091
+ short_name: options.short || undefined,
1092
+ create_skeleton: false,
1093
+ overwrite: options.overwrite,
1094
+ });
1095
+ const created = response.message || response;
1096
+ appId = created.app_id || appId;
1097
+ communityId = created.community_id || options.community;
1098
+ console.log(chalk.green(`\n✅ App created: ${appId}`));
1099
+ console.log(chalk.gray(` Products: Products/${appId}`));
1100
+ console.log(chalk.gray(` Firestore: Community/${communityId}/Apps/${appId}`));
1101
+ } else if (options.community && options.community !== communityId) {
1102
+ throw new Error(
1103
+ `App '${appId}' already exists in community '${communityId}', not '${options.community}'. ` +
1104
+ `Omit -c to initialize the existing app.`
1105
+ );
779
1106
  }
780
1107
 
781
1108
  // 1. Workspace.json — register app if not already mapped
@@ -783,6 +1110,13 @@ appCommand
783
1110
  const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
784
1111
  let appPath = alreadyMapped?.absolutePath;
785
1112
 
1113
+ if (alreadyMapped && options.path) {
1114
+ throw new Error(
1115
+ `App '${appId}' is already mapped to '${alreadyMapped.localPath}'. ` +
1116
+ `Use 'descix app set-localpath -a ${appId} -p <new-path>' to update.`
1117
+ );
1118
+ }
1119
+
786
1120
  if (!alreadyMapped) {
787
1121
  const localPath = options.path || '.';
788
1122
  const wsRoot = workspaceConfig?.workspaceRoot || process.cwd();
@@ -793,15 +1127,29 @@ appCommand
793
1127
  console.log(chalk.gray(` workspace.json updated: ${appId} → ${localPath}`));
794
1128
  }
795
1129
 
796
- // 2. Create rigid app folder structure (site, kb, microservice)
1130
+ // 2. Create app folder structure (site, microservice, assets)
797
1131
  if (appPath) {
798
1132
  const siteDir = path.join(appPath, 'site');
799
- const kbDir = path.join(appPath, 'kb', kbId);
800
1133
  const msDir = path.join(appPath, 'microservice');
1134
+ const assetsDir = path.join(appPath, 'assets');
801
1135
  await fs.mkdir(siteDir, { recursive: true });
802
- await fs.mkdir(kbDir, { recursive: true });
803
1136
  await fs.mkdir(msDir, { recursive: true });
804
- console.log(chalk.gray(` Created: site/, kb/${kbId}/, microservice/`));
1137
+ await fs.mkdir(assetsDir, { recursive: true });
1138
+
1139
+ // Create template asset files if they don't exist
1140
+ const siPath = path.join(assetsDir, 'system_instructions.md');
1141
+ const descPath = path.join(assetsDir, 'app_description.md');
1142
+ try {
1143
+ await fs.access(siPath);
1144
+ } catch {
1145
+ await fs.writeFile(siPath, `# System Instructions for ${appId}\n\nYou are an AI assistant for the ${appId} application.\n`);
1146
+ }
1147
+ try {
1148
+ await fs.access(descPath);
1149
+ } catch {
1150
+ await fs.writeFile(descPath, `# ${appId}\n\nApplication description goes here.\n`);
1151
+ }
1152
+ console.log(chalk.gray(` Created: site/, microservice/, assets/`));
805
1153
  }
806
1154
 
807
1155
  // 3. Create KnowledgeBase Firestore doc (Git Mode — no Drive required)
@@ -811,9 +1159,12 @@ appCommand
811
1159
  console.log(chalk.green(`\n✓ ${appId} initialized`));
812
1160
  console.log(chalk.gray(` Community: ${communityId}`));
813
1161
  console.log(chalk.gray(` KB: ${kbId} — ${kbResult.created ? 'created' : 'already exists'}\n`));
1162
+ console.log(chalk.gray(` (every app is guaranteed a default KB at creation; an empty one`));
1163
+ console.log(chalk.gray(` says so rather than answering from general knowledge)\n`));
814
1164
  console.log(chalk.cyan('Next steps:'));
815
- console.log(chalk.gray(` Add markdown files to kb/${kbId}/ then run:`));
816
- console.log(chalk.white(` descix update kb -a ${appId}\n`));
1165
+ console.log(chalk.gray(` Create a corpus manifest at apps/${appId}/.descix/manifests/${kbId}.json`));
1166
+ console.log(chalk.gray(` then run:`));
1167
+ console.log(chalk.white(` descix kb corpus sync -a ${appId}\n`));
817
1168
  } catch (error) {
818
1169
  console.error(chalk.red(error.message));
819
1170
  process.exit(1);
@@ -821,111 +1172,153 @@ appCommand
821
1172
  });
822
1173
 
823
1174
  appCommand
824
- .command('register-folder')
825
- .description('Register your base Drive folder (must be shared with dip@descix.net)')
826
- .option('-u, --url <folderUrl>', 'Drive folder URL or ID')
827
- .action(async (options) => {
828
- try {
829
- const apiClient = new DeSciXApiClient();
830
- await requireAuth(apiClient);
831
-
832
- if (!options.url) {
833
- console.error(chalk.red('Error: Folder URL or ID required. Use -u or --url'));
834
- process.exit(1);
835
- }
836
-
837
- const response = await apiClient.invoke('register_base_folder', {
838
- folder_url: options.url
839
- });
840
- const result = response.message || response;
841
-
842
- console.log(chalk.green('\n✅ Folder registered successfully!\n'));
843
- console.log(chalk.cyan(` Folder ID: ${result.folder_id}`));
844
- console.log(chalk.gray(` Folder Name: ${result.folder_name}\n`));
845
- } catch (error) {
846
- console.error(chalk.red(error.message));
847
- process.exit(1);
848
- }
1175
+ .command('create')
1176
+ // DELETED SURFACE (CEO-D-2026-08-14, SUPER-DRY: delete the old path, fail loud, no compat
1177
+ // fence). `app create` created the platform record and STOPPED, leaving the caller to
1178
+ // remember `app init` — two commands for one outcome. That is how apps ended up half-made
1179
+ // with no knowledge base at all. `app init` now creates AND initializes in one path.
1180
+ .description('[REMOVED] Use `descix app init -a <name> -c <community>` — it creates and initializes in one step.')
1181
+ // Swallow whatever the old invocation passed so the caller always gets the pointer below
1182
+ // rather than a generic commander arity error that names no replacement.
1183
+ .allowUnknownOption(true)
1184
+ .argument('[ignored...]')
1185
+ .action(() => {
1186
+ console.error(chalk.red('\n❌ `descix app create` has been removed.\n'));
1187
+ console.error(chalk.white(' Use the single canonical path, which creates AND initializes:\n'));
1188
+ console.error(chalk.cyan(' descix app init -a <app-name> -c <community> [-s <short>]\n'));
1189
+ console.error(chalk.gray(' It registers the app (Products + Firestore + entitlement), guarantees'));
1190
+ console.error(chalk.gray(' the default knowledge base, then registers and scaffolds it locally.'));
1191
+ console.error(chalk.gray(' Creating an app without a default KB is no longer possible.\n'));
1192
+ process.exit(1);
849
1193
  });
850
1194
 
1195
+ // descix app media-upload — upload media/asset files to an app's GCS assets prefix via the
1196
+ // API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half).
1197
+ // This is the canonical, filesystem-free way to get app media (podcast audio, cover art,
1198
+ // etc.) into the platform: request a short-lived signed PUT token from the API surface
1199
+ // (`get_asset_upload_token` over /apifront, user-session authed), upload each file straight
1200
+ // to GCS, and print the ASSET REFERENCES. An app microservice then fetches an uploaded asset
1201
+ // by reference over the Core broker via `get_app_asset` (no shared local filesystem needed).
851
1202
  appCommand
852
- .command('create')
853
- .description('Create an app in a community. --quick with -c/-a for CLI-driven creation.')
854
- .option('-c, --community <id>', 'Community ID')
855
- .option('-a, --app <name>', 'App name')
856
- .option('--quick', 'Create app via CLI (registers in Products, creates Drive skeleton); requires -c and -a')
857
- .option('--overwrite', 'Overwrite existing (with --quick)')
1203
+ .command('media-upload')
1204
+ .description('Upload media/asset files to an app\'s GCS assets prefix via the API surface (returns asset references)')
1205
+ .requiredOption('-a, --app <id>', 'App ID (community is resolved server-side from Products)')
1206
+ .requiredOption('-f, --file <path...>', 'One or more local file paths to upload')
1207
+ .option('--prefix <relPath>', 'Optional sub-path under the app assets/ prefix (e.g. "shows/myshow")', '')
1208
+ .option('--json', 'Print the asset references as JSON (for scripting)')
858
1209
  .action(async (options) => {
859
1210
  try {
860
- if (options.quick && options.community && options.app) {
861
- const apiClient = new DeSciXApiClient();
862
- await requireAuth(apiClient);
1211
+ const apiClient = new DeSciXApiClient();
1212
+ await requireAuth(apiClient);
863
1213
 
864
- // Ensure Drive base folder is registered in workspace.json
865
- let wsConfig;
1214
+ const path = await import('path');
1215
+ const mime = (await import('mime-types')).default;
1216
+
1217
+ const appId = options.app;
1218
+ const localFiles = Array.isArray(options.file) ? options.file : [options.file];
1219
+ const subPrefix = (options.prefix || '').replace(/^\/+|\/+$/g, '');
1220
+
1221
+ // Build the upload descriptor list: object path under assets/ = [subPrefix/]basename.
1222
+ const fileDescriptors = [];
1223
+ for (const localPath of localFiles) {
1224
+ const abs = path.resolve(localPath);
1225
+ let stat;
866
1226
  try {
867
- wsConfig = await WorkspaceConfig.load(process.cwd());
1227
+ stat = await fs.stat(abs);
868
1228
  } catch {
869
- console.error(chalk.red('\n❌ No workspace found. Run `descix init` first.\n'));
1229
+ console.error(chalk.red(`\n❌ File not found: ${abs}\n`));
870
1230
  process.exit(1);
871
1231
  }
872
- const baseFolderId = wsConfig.driveConfig?.base_folder_id;
873
-
874
- if (!baseFolderId) {
875
- console.log(chalk.yellow('\n⚠ No Drive base folder registered in workspace.json.'));
876
- console.log(chalk.white(' App creation requires a shared Drive folder for KB and asset storage.'));
877
- console.log(chalk.white(' Share a Google Drive folder with dip@descix.net, then provide its URL.\n'));
878
-
879
- const readline = await import('readline');
880
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
881
- const folderUrl = await new Promise((resolve) => {
882
- rl.question(chalk.cyan(' Drive folder URL or ID: '), (answer) => {
883
- rl.close();
884
- resolve(answer.trim());
885
- });
886
- });
1232
+ if (!stat.isFile()) {
1233
+ console.error(chalk.red(`\n❌ Not a file: ${abs}\n`));
1234
+ process.exit(1);
1235
+ }
1236
+ const base = path.basename(abs);
1237
+ const objectPath = subPrefix ? `${subPrefix}/${base}` : base;
1238
+ fileDescriptors.push({
1239
+ path: objectPath,
1240
+ content_type: mime.lookup(abs) || 'application/octet-stream',
1241
+ size: stat.size,
1242
+ _absolutePath: abs
1243
+ });
1244
+ }
887
1245
 
888
- if (!folderUrl) {
889
- console.error(chalk.red('\n❌ Drive folder is required for app creation.\n'));
890
- process.exit(1);
891
- }
1246
+ console.log(chalk.cyan(`\n Media Upload: ${appId} → GCS assets/\n`));
1247
+ fileDescriptors.forEach(f => progress(options, chalk.gray(` • ${f.path} (${(f.size / 1024).toFixed(1)}KB, ${f.content_type})`)));
892
1248
 
893
- // Register the folder via backend (validates sharing + extracts folder ID)
894
- const regResponse = await apiClient.invoke('register_base_folder', {
895
- folder_url: folderUrl
1249
+ // 1. Request a signed-PUT upload token over the API surface.
1250
+ const tokenResponse = await apiClient.invoke('get_asset_upload_token', {
1251
+ app_id: appId,
1252
+ files: fileDescriptors.map(f => ({ path: f.path, content_type: f.content_type, size: f.size }))
1253
+ });
1254
+ const token = tokenResponse.message || tokenResponse;
1255
+ const { signed_urls, objects } = token;
1256
+
1257
+ // 2. PUT each file directly to GCS using its signed URL.
1258
+ progress(options, chalk.gray(`\n Uploading ${fileDescriptors.length} file(s)...`));
1259
+ const uploaded = [];
1260
+ const errors = [];
1261
+ for (const f of fileDescriptors) {
1262
+ const signedUrl = signed_urls?.[f.path];
1263
+ if (!signedUrl) {
1264
+ errors.push(`No signed URL for: ${f.path}`);
1265
+ progress(options, chalk.red(` x ${f.path}`));
1266
+ continue;
1267
+ }
1268
+ try {
1269
+ const content = await fs.readFile(f._absolutePath);
1270
+ const resp = await fetch(signedUrl, {
1271
+ method: 'PUT',
1272
+ headers: { 'Content-Type': f.content_type },
1273
+ body: content
896
1274
  });
897
- const regResult = regResponse.message || regResponse;
898
- console.log(chalk.green(` ✓ Folder registered: ${regResult.folder_name} (${regResult.folder_id})`));
899
-
900
- // Persist to workspace.json
901
- wsConfig.driveConfig = { base_folder_id: regResult.folder_id };
902
- await wsConfig.save();
903
- console.log(chalk.green(' ✓ Saved to workspace.json\n'));
1275
+ if (!resp.ok) {
1276
+ errors.push(`Failed ${f.path}: ${resp.status} ${resp.statusText}`);
1277
+ progress(options, chalk.red(` x ${f.path}`));
1278
+ } else {
1279
+ const obj = (objects || []).find(o => o.path === f.path) || {};
1280
+ // The asset REFERENCE an app handler / get_app_asset consumes: the gs:// URI.
1281
+ const gcsRef = obj.gcs_path
1282
+ ? `gs://${token.bucket}/${obj.gcs_path}`
1283
+ : null;
1284
+ uploaded.push({
1285
+ path: f.path,
1286
+ ref: gcsRef,
1287
+ gcs_path: obj.gcs_path || null,
1288
+ public_url: obj.public_url || null,
1289
+ content_type: f.content_type,
1290
+ size: f.size
1291
+ });
1292
+ progress(options, chalk.green(` + ${f.path}`));
1293
+ }
1294
+ } catch (err) {
1295
+ errors.push(`Error ${f.path}: ${err.message}`);
1296
+ progress(options, chalk.red(` x ${f.path}`));
904
1297
  }
1298
+ }
905
1299
 
906
- const response = await apiClient.invoke('create_app_for_community', {
907
- community_id: options.community,
908
- app_name: options.app,
909
- create_skeleton: true,
910
- overwrite: options.overwrite
1300
+ if (errors.length > 0) {
1301
+ progress(options, chalk.yellow(`\n ${errors.length} error(s):`));
1302
+ errors.forEach(e => progress(options, chalk.red(` - ${e}`)));
1303
+ }
1304
+
1305
+ if (uploaded.length === 0) {
1306
+ console.error(chalk.red('\n❌ No files uploaded.\n'));
1307
+ process.exit(1);
1308
+ }
1309
+
1310
+ if (options.json) {
1311
+ console.log('\n' + JSON.stringify({ app_id: appId, assets: uploaded }, null, 2) + '\n');
1312
+ } else {
1313
+ console.log(chalk.green(`\n ✅ Uploaded ${uploaded.length} asset(s).\n`));
1314
+ console.log(chalk.cyan(' Asset references (pass these to your app handler; the service fetches via get_app_asset):'));
1315
+ uploaded.forEach(u => {
1316
+ console.log(chalk.white(` ${u.path}`));
1317
+ console.log(chalk.gray(` ref: ${u.ref}`));
1318
+ console.log(chalk.gray(` public_url: ${u.public_url}`));
911
1319
  });
912
- const result = response.message || response;
913
- console.log(chalk.green('\n✅ App created successfully!\n'));
914
- console.log(chalk.cyan(` App ID: ${result.app_id}`));
915
- console.log(chalk.cyan(` Community: ${result.community_id}`));
916
- console.log(chalk.gray(` Products: Products/${result.app_id}`));
917
- console.log(chalk.gray(` Firestore: Community/${result.community_id}/Apps/${result.app_id}`));
918
- if (result.skeleton_result) {
919
- console.log(chalk.gray(` Drive: ${result.skeleton_result.folder_id || 'created'}`));
920
- }
921
- console.log(chalk.cyan('\n Next: descix app init -a ' + result.app_id + '\n'));
922
- return;
1320
+ console.log();
923
1321
  }
924
- console.log(chalk.cyan('\n📦 App creation\n'));
925
- console.log(chalk.white('Create apps in the PWA (Device Setup / App Manager) or via CLI:\n'));
926
- console.log(chalk.white(' descix app create --quick -c <community> -a <app-name>\n'));
927
- console.log(chalk.gray('This registers the app in the Products registry, creates Firestore'));
928
- console.log(chalk.gray('documents, grants entitlement, and creates Drive skeleton.\n'));
929
1322
  } catch (error) {
930
1323
  console.error(chalk.red(`\n❌ ${error.message}\n`));
931
1324
  process.exit(1);
@@ -933,353 +1326,27 @@ appCommand
933
1326
  });
934
1327
 
935
1328
  appCommand
936
- .command('upload')
937
- .description('Upload local files to app knowledge base')
938
- .requiredOption('-c, --community <id>', 'Community ID')
939
- .requiredOption('-a, --app <id>', 'App ID')
940
- .requiredOption('-p, --path <localPath>', 'Local folder path')
941
- .option('-k, --kb <id>', 'Knowledge Base ID', 'General')
1329
+ .command('set-codesite')
1330
+ .description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
1331
+ .option('-c, --community <id>', 'Community ID (uses context if not provided)')
1332
+ .option('-a, --app <id>', 'App ID (uses context if not provided)')
1333
+ .requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
942
1334
  .action(async (options) => {
943
1335
  try {
944
1336
  const apiClient = new DeSciXApiClient();
945
1337
  await requireAuth(apiClient);
946
1338
 
947
- const fs = await import('fs/promises');
948
- const path = await import('path');
949
-
950
- // Read files from local path
951
- const localPath = options.path;
952
- const stats = await fs.stat(localPath);
953
- if (!stats.isDirectory()) {
954
- throw new Error(`Path is not a directory: ${localPath}`);
955
- }
956
-
957
- console.log(chalk.gray(`Reading files from ${localPath}...`));
958
-
959
- // Recursively read files
960
- const files = [];
961
- async function readDir(dirPath, basePath = '') {
962
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
963
- for (const entry of entries) {
964
- const fullPath = path.join(dirPath, entry.name);
965
- const relPath = path.join(basePath, entry.name);
966
-
967
- if (entry.isDirectory()) {
968
- await readDir(fullPath, relPath);
969
- } else if (entry.isFile()) {
970
- const content = await fs.readFile(fullPath, 'utf-8');
971
- files.push({
972
- name: entry.name,
973
- path: relPath,
974
- content: Buffer.from(content).toString('base64') // Base64 encode
975
- });
976
- }
977
- }
978
- }
1339
+ // Load workspace context
1340
+ const workspaceConfig = await WorkspaceConfig.load();
1341
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
979
1342
 
980
- await readDir(localPath);
1343
+ const communityId = ctx.communityId;
1344
+ const appId = ctx.appId;
981
1345
 
982
- if (files.length === 0) {
983
- throw new Error('No files found in the specified directory');
984
- }
985
-
986
- console.log(chalk.gray(`Found ${files.length} files. Uploading...`));
987
-
988
- const response = await apiClient.invoke('upload_files_to_kb', {
989
- community_id: options.community,
990
- app_id: options.app,
991
- kb_id: options.kb,
992
- files: files
993
- });
994
- const result = response.message || response;
995
-
996
- console.log(chalk.green('\n✅ Files uploaded successfully!\n'));
997
- console.log(chalk.cyan(` Uploaded: ${result.uploaded || 0} files`));
998
- console.log(chalk.gray(` Total Vectors: ${result.totalVectors || 0}\n`));
999
-
1000
- if (result.errors && result.errors.length > 0) {
1001
- console.log(chalk.yellow(' Errors:'));
1002
- result.errors.forEach(err => {
1003
- console.log(chalk.red(` - ${err.file}: ${err.error}`));
1004
- });
1005
- }
1006
- } catch (error) {
1007
- console.error(chalk.red(error.message));
1008
- process.exit(1);
1009
- }
1010
- });
1011
-
1012
- appCommand
1013
- .command('upload-tree')
1014
- .description('Upload folder tree with subfolders as KBs')
1015
- .requiredOption('-c, --community <id>', 'Community ID')
1016
- .requiredOption('-a, --app <id>', 'App ID')
1017
- .requiredOption('-p, --path <localPath>', 'Local folder path')
1018
- .option('--confirm', 'Confirm each subfolder as KB')
1019
- .option('--dry-run', 'Show what would be uploaded without uploading')
1020
- .action(async (options) => {
1021
- try {
1022
- const apiClient = new DeSciXApiClient();
1023
- await requireAuth(apiClient);
1024
-
1025
- const fs = await import('fs/promises');
1026
- const path = await import('path');
1027
- const readline = await import('readline');
1028
-
1029
- const localPath = options.path;
1030
- const stats = await fs.stat(localPath);
1031
- if (!stats.isDirectory()) {
1032
- throw new Error(`Path is not a directory: ${localPath}`);
1033
- }
1034
-
1035
- console.log(chalk.gray(`Scanning ${localPath}...\n`));
1036
-
1037
- // Find top-level subdirectories
1038
- const entries = await fs.readdir(localPath, { withFileTypes: true });
1039
- const subdirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
1040
- const rootFiles = entries.filter(e => e.isFile() && !e.name.startsWith('.'));
1041
-
1042
- if (subdirs.length === 0) {
1043
- throw new Error('No subdirectories found. Use "app upload" for single KB upload.');
1044
- }
1045
-
1046
- console.log(chalk.cyan(`Found ${subdirs.length} subdirectories:\n`));
1047
-
1048
- // Count files in each subdirectory
1049
- const subdirInfo = [];
1050
- for (const dir of subdirs) {
1051
- const dirPath = path.join(localPath, dir.name);
1052
- const files = await readDirectoryFiles(dirPath);
1053
- subdirInfo.push({
1054
- name: dir.name,
1055
- path: dirPath,
1056
- fileCount: files.length
1057
- });
1058
- console.log(chalk.gray(` [${subdirInfo.length}] ${dir.name}/ (${files.length} files)`));
1059
- }
1060
-
1061
- if (rootFiles.length > 0) {
1062
- console.log(chalk.yellow(`\n Note: ${rootFiles.length} root-level files will go to "General" KB\n`));
1063
- }
1064
-
1065
- if (options.dryRun) {
1066
- console.log(chalk.cyan('\n✅ Dry run complete. Use without --dry-run to upload.\n'));
1067
- return;
1068
- }
1069
-
1070
- // Confirm each subdirectory if --confirm flag
1071
- const rl = readline.createInterface({
1072
- input: process.stdin,
1073
- output: process.stdout
1074
- });
1075
-
1076
- const askYesNo = (question, defaultYes = true) => {
1077
- return new Promise((resolve) => {
1078
- const suffix = defaultYes ? '(Y/n)' : '(y/N)';
1079
- rl.question(`${question} ${suffix}: `, (answer) => {
1080
- const trimmed = answer.trim().toLowerCase();
1081
- resolve(trimmed === '' ? defaultYes : (trimmed === 'y' || trimmed === 'yes'));
1082
- });
1083
- });
1084
- };
1085
-
1086
- const kbsToUpload = [];
1087
- for (const info of subdirInfo) {
1088
- if (options.confirm) {
1089
- const proceed = await askYesNo(`Create KB "${info.name}" and upload ${info.fileCount} files?`, true);
1090
- if (!proceed) {
1091
- console.log(chalk.gray(` Skipping ${info.name}\n`));
1092
- continue;
1093
- }
1094
- }
1095
- kbsToUpload.push(info);
1096
- }
1097
-
1098
- rl.close();
1099
-
1100
- if (kbsToUpload.length === 0) {
1101
- console.log(chalk.yellow('\n⚠️ No KBs selected for upload.\n'));
1102
- return;
1103
- }
1104
-
1105
- // Upload each subdirectory as a KB
1106
- for (const info of kbsToUpload) {
1107
- console.log(chalk.cyan(`\n📦 Uploading to KB "${info.name}"...`));
1108
-
1109
- // Create KB if it doesn't exist
1110
- try {
1111
- await apiClient.invoke('create_skeleton_kb', {
1112
- community_id: options.community,
1113
- app_id: options.app,
1114
- kb_name: info.name
1115
- });
1116
- console.log(chalk.gray(` ✓ KB "${info.name}" created`));
1117
- } catch (err) {
1118
- // KB might already exist, continue
1119
- if (!err.message.includes('already exists')) {
1120
- console.log(chalk.yellow(` ⚠️ Could not create KB: ${err.message}`));
1121
- }
1122
- }
1123
-
1124
- // Read files from subdirectory
1125
- const files = await readDirectoryFiles(info.path);
1126
-
1127
- if (files.length === 0) {
1128
- console.log(chalk.yellow(` ⚠️ No files found in ${info.name}/`));
1129
- continue;
1130
- }
1131
-
1132
- // Upload in batches
1133
- const BATCH_SIZE = 10;
1134
- let uploaded = 0;
1135
- let errors = [];
1136
-
1137
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
1138
- const batch = files.slice(i, i + BATCH_SIZE);
1139
- const batchNum = Math.floor(i / BATCH_SIZE) + 1;
1140
- const totalBatches = Math.ceil(files.length / BATCH_SIZE);
1141
-
1142
- try {
1143
- process.stdout.write(chalk.gray(` Batch ${batchNum}/${totalBatches}...`));
1144
- const response = await apiClient.invoke('upload_files_to_kb', {
1145
- community_id: options.community,
1146
- app_id: options.app,
1147
- kb_id: info.name,
1148
- files: batch
1149
- });
1150
- const result = response.message || response;
1151
- uploaded += result.uploaded || batch.length;
1152
- if (result.errors) {
1153
- errors.push(...result.errors);
1154
- }
1155
- console.log(chalk.green(' ✓'));
1156
- } catch (err) {
1157
- console.log(chalk.red(` ✗ ${err.message}`));
1158
- errors.push({ batch: batchNum, error: err.message });
1159
- }
1160
- }
1161
-
1162
- console.log(chalk.green(` ✅ Uploaded ${uploaded} files to KB "${info.name}"`));
1163
- if (errors.length > 0) {
1164
- console.log(chalk.yellow(` ⚠️ ${errors.length} errors during upload`));
1165
- }
1166
- }
1167
-
1168
- // Handle root files if any
1169
- if (rootFiles.length > 0) {
1170
- console.log(chalk.cyan(`\n📦 Uploading ${rootFiles.length} root files to "General" KB...`));
1171
- const rootFilesData = [];
1172
- for (const file of rootFiles) {
1173
- const filePath = path.join(localPath, file.name);
1174
- const content = await fs.readFile(filePath);
1175
- rootFilesData.push({
1176
- name: file.name,
1177
- path: file.name,
1178
- content: content.toString('base64')
1179
- });
1180
- }
1181
-
1182
- try {
1183
- const response = await apiClient.invoke('upload_files_to_kb', {
1184
- community_id: options.community,
1185
- app_id: options.app,
1186
- kb_id: 'General',
1187
- files: rootFilesData
1188
- });
1189
- const result = response.message || response;
1190
- console.log(chalk.green(` ✅ Uploaded ${result.uploaded || 0} root files`));
1191
- } catch (err) {
1192
- console.log(chalk.yellow(` ⚠️ Could not upload root files: ${err.message}`));
1193
- }
1194
- }
1195
-
1196
- console.log(chalk.green('\n✅ Upload tree complete!\n'));
1197
-
1198
- // Helper function to read directory files
1199
- async function readDirectoryFiles(dirPath, basePath = '') {
1200
- const files = [];
1201
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
1202
-
1203
- for (const entry of entries) {
1204
- const fullPath = path.join(dirPath, entry.name);
1205
- const relPath = path.join(basePath, entry.name);
1206
-
1207
- if (entry.isDirectory()) {
1208
- const subFiles = await readDirectoryFiles(fullPath, relPath);
1209
- files.push(...subFiles);
1210
- } else if (entry.isFile()) {
1211
- // Skip hidden files
1212
- if (entry.name.startsWith('.')) {
1213
- continue;
1214
- }
1215
- const content = await fs.readFile(fullPath);
1216
- files.push({
1217
- name: entry.name,
1218
- path: relPath,
1219
- content: content.toString('base64')
1220
- });
1221
- }
1222
- }
1223
-
1224
- return files;
1225
- }
1226
- } catch (error) {
1227
- console.error(chalk.red(error.message));
1228
- process.exit(1);
1229
- }
1230
- });
1231
-
1232
- appCommand
1233
- .command('sync')
1234
- .description('Sync knowledge base (vectorize files in Drive folder)')
1235
- .requiredOption('-c, --community <id>', 'Community ID')
1236
- .requiredOption('-a, --app <id>', 'App ID')
1237
- .option('-k, --kb <id>', 'Knowledge Base ID', 'General')
1238
- .action(async (options) => {
1239
- try {
1240
- const apiClient = new DeSciXApiClient();
1241
- await requireAuth(apiClient);
1242
-
1243
- const response = await apiClient.invoke('sync_knowledge_base', {
1244
- community_id: options.community,
1245
- app_id: options.app,
1246
- kb_id: options.kb
1247
- });
1248
- const result = response.message || response;
1249
-
1250
- console.log(chalk.green('\n✅ Sync initiated!\n'));
1251
- console.log(chalk.cyan(` KB: ${options.community}/${options.app}/${options.kb}`));
1252
- if (result.note) {
1253
- console.log(chalk.gray(` Note: ${result.note}\n`));
1254
- }
1255
- } catch (error) {
1256
- console.error(chalk.red(error.message));
1257
- process.exit(1);
1258
- }
1259
- });
1260
-
1261
- appCommand
1262
- .command('set-codesite')
1263
- .description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
1264
- .option('-c, --community <id>', 'Community ID (uses context if not provided)')
1265
- .option('-a, --app <id>', 'App ID (uses context if not provided)')
1266
- .requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
1267
- .action(async (options) => {
1268
- try {
1269
- const apiClient = new DeSciXApiClient();
1270
- await requireAuth(apiClient);
1271
-
1272
- // Load workspace context
1273
- const workspaceConfig = await WorkspaceConfig.load();
1274
- const ctx = workspaceConfig.resolveContextWithOptions(options);
1275
-
1276
- const communityId = ctx.communityId;
1277
- const appId = ctx.appId;
1278
-
1279
- if (!communityId || !appId) {
1280
- console.error(chalk.red('\n❌ Community and App ID required.'));
1281
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1282
- process.exit(1);
1346
+ if (!communityId || !appId) {
1347
+ console.error(chalk.red('\n❌ Community and App ID required.'));
1348
+ console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1349
+ process.exit(1);
1283
1350
  }
1284
1351
 
1285
1352
  const response = await apiClient.invoke('update_app_metadata', {
@@ -1331,33 +1398,26 @@ appCommand
1331
1398
  });
1332
1399
 
1333
1400
  appCommand
1334
- .command('set-api-url')
1335
- .description('Set API base URL for app\'s own backend')
1401
+ .command('set-name')
1402
+ .description('Set app display name')
1336
1403
  .requiredOption('-c, --community <id>', 'Community ID')
1337
1404
  .requiredOption('-a, --app <id>', 'App ID')
1338
- .requiredOption('-u, --url <url>', 'API base URL (e.g., https://api.example.com)')
1405
+ .requiredOption('--name <name>', 'Display name for the app')
1339
1406
  .action(async (options) => {
1340
1407
  try {
1341
1408
  const apiClient = new DeSciXApiClient();
1342
1409
  await requireAuth(apiClient);
1343
-
1344
- // Validate URL format
1345
- try {
1346
- new URL(options.url);
1347
- } catch {
1348
- throw new Error('Invalid URL format');
1349
- }
1350
-
1351
- const response = await apiClient.invoke('update_app_metadata', {
1410
+
1411
+ const response = await apiClient.invoke('update_app', {
1352
1412
  community_id: options.community,
1353
1413
  app_id: options.app,
1354
- api_base_url: options.url
1414
+ app_name: options.name
1355
1415
  });
1356
1416
  const result = response.message || response;
1357
-
1358
- console.log(chalk.green('\n✅ API base URL updated!\n'));
1417
+
1418
+ console.log(chalk.green('\n✅ App display name updated!\n'));
1359
1419
  console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
1360
- console.log(chalk.gray(` API URL: ${options.url}\n`));
1420
+ console.log(chalk.gray(` Name: ${options.name}\n`));
1361
1421
  } catch (error) {
1362
1422
  console.error(chalk.red(error.message));
1363
1423
  process.exit(1);
@@ -1365,69 +1425,35 @@ appCommand
1365
1425
  });
1366
1426
 
1367
1427
  appCommand
1368
- .command('deploy')
1369
- .description('Deploy a microservice for an app you own (registers manifest.json)')
1370
- .option('-m, --manifest <path>', 'Path to manifest.json (default: ./manifest.json)')
1428
+ .command('set-api-url')
1429
+ .description('Set API base URL for app\'s own backend')
1430
+ .requiredOption('-c, --community <id>', 'Community ID')
1431
+ .requiredOption('-a, --app <id>', 'App ID')
1432
+ .requiredOption('-u, --url <url>', 'API base URL (e.g., https://api.example.com)')
1371
1433
  .action(async (options) => {
1372
1434
  try {
1373
1435
  const apiClient = new DeSciXApiClient();
1374
1436
  await requireAuth(apiClient);
1375
1437
 
1376
- // Read manifest.json from current directory or specified path
1377
- const manifestPath = path.resolve(options.manifest || './manifest.json');
1378
-
1379
- console.log(chalk.cyan(`\n📦 Deploying microservice from ${manifestPath}\n`));
1380
-
1381
- let manifest;
1438
+ // Validate URL format
1382
1439
  try {
1383
- const manifestContent = await fs.readFile(manifestPath, 'utf-8');
1384
- manifest = JSON.parse(manifestContent);
1385
- } catch (error) {
1386
- if (error.code === 'ENOENT') {
1387
- console.error(chalk.red(`❌ manifest.json not found at ${manifestPath}`));
1388
- console.error(chalk.gray('\nCreate a manifest.json with your service configuration.'));
1389
- console.error(chalk.gray('See DeSciX_Core/descix-cli/templates/scaffolds/microservice/manifest.json for a template.\n'));
1390
- process.exit(1);
1391
- }
1392
- throw new Error(`Failed to parse manifest.json: ${error.message}`);
1393
- }
1394
-
1395
- // Validate required fields
1396
- if (!manifest.service?.name) {
1397
- throw new Error('manifest.service.name is required');
1398
- }
1399
- if (!manifest.service?.app_id) {
1400
- throw new Error('manifest.service.app_id is required (the app you own)');
1401
- }
1402
- if (!manifest.service?.community_id) {
1403
- throw new Error('manifest.service.community_id is required');
1404
- }
1405
- if (!manifest.service?.domain) {
1406
- throw new Error('manifest.service.domain is required');
1440
+ new URL(options.url);
1441
+ } catch {
1442
+ throw new Error('Invalid URL format');
1407
1443
  }
1408
1444
 
1409
- console.log(chalk.gray(` Service: ${manifest.service.name}`));
1410
- console.log(chalk.gray(` App: ${manifest.service.community_id}/${manifest.service.app_id}`));
1411
- console.log(chalk.gray(` Domain: ${manifest.service.domain}`));
1412
- console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}\n`));
1413
-
1414
- // Call register_service
1415
- const response = await apiClient.invoke('register_service', { manifest });
1445
+ const response = await apiClient.invoke('update_app_metadata', {
1446
+ community_id: options.community,
1447
+ app_id: options.app,
1448
+ api_base_url: options.url
1449
+ });
1416
1450
  const result = response.message || response;
1417
1451
 
1418
- console.log(chalk.green('✅ Microservice deployed successfully!\n'));
1419
- console.log(chalk.cyan(` Service: ${manifest.service.name}`));
1420
- console.log(chalk.gray(` Commands registered: ${Object.keys(manifest.commands || {}).length}`));
1421
-
1422
- if (response.app_updated) {
1423
- console.log(chalk.gray(` App api_base_url: ${response.api_base_url}`));
1424
- }
1425
-
1426
- console.log(chalk.gray('\n The service manifest cache will refresh within 5 minutes.'));
1427
- console.log(chalk.gray(' For immediate effect, an admin can run: descix microservice reload\n'));
1428
-
1452
+ console.log(chalk.green('\n✅ API base URL updated!\n'));
1453
+ console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
1454
+ console.log(chalk.gray(` API URL: ${options.url}\n`));
1429
1455
  } catch (error) {
1430
- console.error(chalk.red(`\n❌ Deployment failed: ${error.message}\n`));
1456
+ console.error(chalk.red(error.message));
1431
1457
  process.exit(1);
1432
1458
  }
1433
1459
  });
@@ -1495,144 +1521,839 @@ appCommand
1495
1521
  }
1496
1522
  });
1497
1523
 
1498
- // ============ Knowledge Base Commands ============
1499
-
1500
- const kbCommand = program
1501
- .command('kb')
1502
- .description('Knowledge base operations');
1503
-
1504
- kbCommand
1505
- .command('create')
1506
- .description('Create a new knowledge base in an app')
1507
- .requiredOption('-c, --community <id>', 'Community ID')
1508
- .requiredOption('-a, --app <id>', 'App ID')
1509
- .requiredOption('-k, --kb <id>', 'Knowledge Base ID')
1510
- .option('--drive-folder <id>', 'Google Drive folder ID (optional, will create if not provided)')
1524
+ appCommand
1525
+ .command('delete')
1526
+ .description('Delete an app with full cascade cleanup (Pinecone, GCS, Firestore, Products)')
1527
+ .requiredOption('-a, --app <app_id>', 'App ID to delete')
1528
+ .option('-c, --community <community_id>', 'Community ID (auto-resolved from Products if omitted)')
1529
+ .option('--dry-run', 'Preview what would be deleted without executing')
1530
+ .option('--soft', 'Soft delete only (mark as hidden, no cascade)')
1531
+ .option('--yes', 'Skip confirmation prompt')
1511
1532
  .action(async (options) => {
1512
1533
  try {
1513
1534
  const apiClient = new DeSciXApiClient();
1514
1535
  await requireAuth(apiClient);
1515
-
1516
- const response = await apiClient.invoke('create_skeleton_kb', {
1517
- community_id: options.community,
1518
- app_id: options.app,
1519
- kb_name: options.kb,
1520
- drive_folder_id: options.driveFolder || null
1536
+
1537
+ const appId = options.app;
1538
+ const hardDelete = !options.soft;
1539
+ const dryRun = !!options.dryRun;
1540
+
1541
+ // Resolve community_id if not provided (apiFront hydrates from Products)
1542
+ let communityId = options.community || null;
1543
+
1544
+ if (dryRun) {
1545
+ console.log(chalk.cyan(`\n--- DRY RUN: delete ${appId} ---\n`));
1546
+ }
1547
+
1548
+ const response = await apiClient.invoke('delete_app', {
1549
+ app_id: appId,
1550
+ community_id: communityId,
1551
+ hard_delete: hardDelete,
1552
+ dry_run: dryRun
1521
1553
  });
1554
+
1522
1555
  const result = response.message || response;
1523
-
1524
- console.log(chalk.green('\n✅ Knowledge Base created successfully!\n'));
1525
- console.log(chalk.cyan(` KB ID: ${options.kb}`));
1526
- console.log(chalk.gray(` App: ${options.community}/${options.app}`));
1527
- if (result.drive_folder_id) {
1528
- console.log(chalk.gray(` Drive Folder: ${result.drive_folder_id}\n`));
1529
- }
1530
- } catch (error) {
1531
- console.error(chalk.red(error.message));
1532
- process.exit(1);
1533
- }
1534
- });
1535
1556
 
1536
- // Phase 0 CLI-Centric KB Processing Commands
1537
- kbCommand
1538
- .command('pull')
1539
- .description('Pull KB content from Drive and convert to local markdown')
1540
- .option('-c, --community <id>', 'Community ID')
1541
- .option('-a, --app <id>', 'App ID')
1542
- .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1543
- .option('-v, --verbose', 'Show verbose output')
1544
- .option('--merge-mode <mode>', 'Merge mode: merge|overwrite|force-overwrite (default: merge)')
1545
- .option('--dry-run', 'Show what would happen without making changes')
1557
+ if (dryRun) {
1558
+ const m = result.deletion_manifest || result;
1559
+ console.log(chalk.bold('Deletion manifest:\n'));
1560
+ if (m.pinecone_only_orphan) {
1561
+ console.log(chalk.yellow(` ** Pinecone-only orphan: no Firestore/Products records found **\n`));
1562
+ }
1563
+ if (m.app_doc) console.log(chalk.gray(` Firestore App: ${m.app_doc}`));
1564
+ if (m.products_doc) console.log(chalk.gray(` Products Doc: ${m.products_doc}`));
1565
+ if (m.knowledgebases?.length) {
1566
+ console.log(chalk.gray(` Knowledge Bases: ${m.knowledgebases.length}`));
1567
+ for (const kb of m.knowledgebases) {
1568
+ console.log(chalk.gray(` - ${kb}`));
1569
+ }
1570
+ }
1571
+ if (m.pinecone_note) {
1572
+ console.log(chalk.yellow(` Pinecone: ${m.pinecone_note}`));
1573
+ } else {
1574
+ console.log(chalk.gray(` Pinecone filter: app_id == ${appId} (${m.pinecone_kb_count} KBs)`));
1575
+ }
1576
+ console.log(chalk.gray(` GCS prefix: ${m.gcs_prefix}`));
1577
+ console.log(chalk.gray(` IPDocs: ${m.ip_docs_count}`));
1578
+ if (m.service_manifests?.length) {
1579
+ console.log(chalk.gray(` ServiceManifests: ${m.service_manifests.join(', ')}`));
1580
+ }
1581
+ console.log(chalk.yellow('\n No changes were made. Remove --dry-run to execute.\n'));
1582
+ return;
1583
+ }
1584
+
1585
+ // Confirmation prompt (unless --yes)
1586
+ if (!options.yes && hardDelete) {
1587
+ const readline = await import('readline');
1588
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1589
+ const answer = await new Promise(resolve => {
1590
+ rl.question(chalk.yellow(`\n Permanently delete ${appId}? This cannot be undone. [y/N] `), resolve);
1591
+ });
1592
+ rl.close();
1593
+ if (answer.toLowerCase() !== 'y') {
1594
+ console.log(chalk.gray('\n Aborted.\n'));
1595
+ return;
1596
+ }
1597
+ }
1598
+
1599
+ // Display results
1600
+ if (result.cleanup) {
1601
+ const c = result.cleanup;
1602
+ console.log(chalk.green(`\n App ${appId} deleted.\n`));
1603
+ console.log(chalk.gray(` Pinecone vectors: ${c.pinecone_deleted === -1 ? 'filter-deleted' : c.pinecone_deleted} (KBs: ${c.kbs_deleted?.join(', ') || 'none'})`));
1604
+ console.log(chalk.gray(` GCS files: ${c.gcs_deleted}`));
1605
+ console.log(chalk.gray(` Firestore docs: ${c.firestore_deleted} (IPDocs)`));
1606
+ console.log(chalk.gray(` Products deleted: ${c.products_deleted ? 'yes' : 'no'}`));
1607
+ if (c.service_manifests_deleted?.length) {
1608
+ console.log(chalk.gray(` ServiceManifests: ${c.service_manifests_deleted.join(', ')}`));
1609
+ }
1610
+ console.log();
1611
+ } else {
1612
+ console.log(chalk.green(`\n ${result.message || `App ${appId} deleted.`}\n`));
1613
+ }
1614
+ } catch (error) {
1615
+ console.error(chalk.red(`\n Error: ${error.message}\n`));
1616
+ process.exit(1);
1617
+ }
1618
+ });
1619
+
1620
+ appCommand
1621
+ .command('sync-assets')
1622
+ // Canonical asset-sync surface. The legacy Drive-mode `sync assets` DUPLICATE was removed in
1623
+ // WS-MCP-SURFACE-SPLIT Step 3 §5.5; this is now the sole path.
1624
+ .description('Sync local assets (system_instructions.md, app_description.md, icon.png) to platform.')
1625
+ .requiredOption('-a, --app <app_id>', 'App ID')
1626
+ .option('-k, --kb <name>', 'Target KB for system_instructions', 'General')
1627
+ .option('-p, --path <dir>', 'App directory (default: auto-detected)')
1546
1628
  .action(async (options) => {
1547
1629
  try {
1548
- let apiClient = null;
1630
+ const apiClient = new DeSciXApiClient();
1631
+ await requireAuth(apiClient);
1632
+ const fs = await import('fs');
1633
+ const path = await import('path');
1634
+
1635
+ // Resolve app path via WorkspaceConfig (v2.1 — localPath from workspace.json)
1636
+ let appPath = options.path;
1637
+ if (!appPath) {
1638
+ try {
1639
+ const wsConfig = await WorkspaceConfig.load();
1640
+ const appConfig = wsConfig.getAppByAppId(options.app);
1641
+ if (appConfig?.absolutePath) {
1642
+ appPath = appConfig.absolutePath;
1643
+ }
1644
+ } catch { /* fall through */ }
1645
+ }
1646
+ if (!appPath) {
1647
+ console.error(chalk.red(`App '${options.app}' not found in workspace.json. Use -p to specify path.`));
1648
+ process.exit(1);
1649
+ }
1650
+
1651
+ const assetsDir = path.default.join(appPath, 'assets');
1652
+ const assets = {};
1653
+ let found = 0;
1654
+
1655
+ // system_instructions.md
1656
+ const siPath = path.default.join(assetsDir, 'system_instructions.md');
1657
+ if (fs.default.existsSync(siPath)) {
1658
+ assets.system_instructions = fs.default.readFileSync(siPath, 'utf8');
1659
+ found++;
1660
+ console.log(chalk.gray(` Found: ${siPath}`));
1661
+ }
1662
+
1663
+ // app_description.md
1664
+ const descPath = path.default.join(assetsDir, 'app_description.md');
1665
+ if (fs.default.existsSync(descPath)) {
1666
+ assets.app_description = fs.default.readFileSync(descPath, 'utf8');
1667
+ found++;
1668
+ console.log(chalk.gray(` Found: ${descPath}`));
1669
+ }
1670
+
1671
+ // icon.png
1672
+ const iconPath = path.default.join(assetsDir, 'icon.png');
1673
+ if (fs.default.existsSync(iconPath)) {
1674
+ assets.icon_base64 = fs.default.readFileSync(iconPath).toString('base64');
1675
+ found++;
1676
+ console.log(chalk.gray(` Found: ${iconPath}`));
1677
+ }
1678
+
1679
+ if (found === 0) {
1680
+ console.log(chalk.yellow(`\nNo assets found in ${assetsDir}`));
1681
+ console.log(chalk.gray('Expected: system_instructions.md, app_description.md, icon.png\n'));
1682
+ return;
1683
+ }
1684
+
1685
+ console.log(chalk.gray(`\nSyncing ${found} asset(s) for ${options.app}...`));
1686
+
1687
+ const response = await apiClient.invoke('sync_app_assets', {
1688
+ app_id: options.app,
1689
+ kb_name: options.kb,
1690
+ assets
1691
+ });
1692
+
1693
+ const result = response.message || response;
1694
+ if (result.results) {
1695
+ for (const [key, val] of Object.entries(result.results)) {
1696
+ if (val.updated) {
1697
+ console.log(chalk.green(` ${key}: synced${val.kb ? ` (KB: ${val.kb})` : ''}`));
1698
+ } else {
1699
+ console.log(chalk.red(` ${key}: failed — ${val.error || 'unknown'}`));
1700
+ }
1701
+ }
1702
+ }
1703
+ console.log(chalk.green('\nAsset sync complete.\n'));
1704
+ } catch (error) {
1705
+ console.error(chalk.red(error.message));
1706
+ process.exit(1);
1707
+ }
1708
+ });
1709
+
1710
+ appCommand
1711
+ .command('set-localpath')
1712
+ .description('Update the local directory path for a mapped app')
1713
+ .requiredOption('-a, --app <app_id>', 'App ID')
1714
+ .requiredOption('-p, --path <dir>', 'New local directory path')
1715
+ .action(async (options) => {
1716
+ try {
1717
+ const appId = options.app;
1718
+ const newPath = options.path;
1719
+
1720
+ const workspaceConfig = await WorkspaceConfig.load();
1721
+ const appConfig = workspaceConfig.getAppByAppId(appId);
1722
+ if (!appConfig) {
1723
+ throw new Error(`App '${appId}' is not mapped. Run 'descix app init -a ${appId}' first.`);
1724
+ }
1725
+
1726
+ // Hard-fail if path doesn't exist or is not a directory
1727
+ let stat;
1549
1728
  try {
1550
- apiClient = new DeSciXApiClient();
1551
- await apiClient.loadCredentials();
1729
+ stat = await fs.stat(newPath);
1552
1730
  } catch {
1553
- // Continue without API client
1731
+ throw new Error(`Path does not exist: ${newPath}`);
1554
1732
  }
1555
-
1556
- await kbCommands.runKbPull(apiClient, options);
1733
+ if (!stat.isDirectory()) {
1734
+ throw new Error(`Path is not a directory: ${newPath}`);
1735
+ }
1736
+
1737
+ // Update the localPath in workspace.json
1738
+ const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1739
+ const oldPath = appConfig.localPath;
1740
+ appConfig.localPath = newPath;
1741
+
1742
+ // Update env.products entry
1743
+ const products = workspaceConfig.env?.products || [];
1744
+ for (const product of products) {
1745
+ if (product.appId === appId || product.app_id === appId) {
1746
+ product.localPath = newPath;
1747
+ break;
1748
+ }
1749
+ }
1750
+ await workspaceConfig.save(wsRoot);
1751
+
1752
+ console.log(chalk.green(`\n✓ ${appId} local path updated`));
1753
+ console.log(chalk.gray(` ${oldPath} → ${newPath}\n`));
1557
1754
  } catch (error) {
1558
- console.error(chalk.red(`\n❌ ${error.message}\n`));
1755
+ console.error(chalk.red(error.message));
1756
+ process.exit(1);
1757
+ }
1758
+ });
1759
+
1760
+ appCommand
1761
+ .command('unmap')
1762
+ .description('Remove an app from the local workspace mapping (does not delete Firestore/Pinecone data)')
1763
+ .requiredOption('-a, --app <app_id>', 'App ID')
1764
+ .action(async (options) => {
1765
+ try {
1766
+ const appId = options.app;
1767
+
1768
+ const workspaceConfig = await WorkspaceConfig.load();
1769
+ const appConfig = workspaceConfig.getAppByAppId(appId);
1770
+ if (!appConfig) {
1771
+ throw new Error(`App '${appId}' is not mapped.`);
1772
+ }
1773
+
1774
+ // Remove from env.products
1775
+ const products = workspaceConfig.env?.products || [];
1776
+ const idx = products.findIndex(p => p.appId === appId || p.app_id === appId);
1777
+ if (idx === -1) {
1778
+ throw new Error(`App '${appId}' is not found in env.products.`);
1779
+ }
1780
+ products.splice(idx, 1);
1781
+
1782
+ const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1783
+ await workspaceConfig.save(wsRoot);
1784
+
1785
+ console.log(chalk.green(`\n✓ ${appId} removed from workspace mapping`));
1786
+ console.log(chalk.gray(` Pinecone vectors, Firestore docs, and Drive data are not affected.\n`));
1787
+ } catch (error) {
1788
+ console.error(chalk.red(error.message));
1559
1789
  process.exit(1);
1560
1790
  }
1561
1791
  });
1562
1792
 
1793
+ // ============ App: set-port (WS-CLI-MESH-ROUTING-GAP) ============
1794
+ //
1795
+ // Canonical write path for an app's microservice.port in workspace.json.
1796
+ // `descix microservice init` READS env.products[<app>].microservice.port and hard-fails
1797
+ // if it is missing; this command is how that port is set, without hand-editing workspace.json.
1798
+ // Backed by WorkspaceConfig.setMicroservicePort (parallel to setSitePort).
1799
+ appCommand
1800
+ .command('set-port')
1801
+ .description('Set the microservice port for a mapped app (writes env.products[<app>].microservice.port). Pass "n" to remove.')
1802
+ .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1803
+ .requiredOption('-p, --port <port>', 'Microservice port number (1-65535), or "n" to remove')
1804
+ .action(async (options) => {
1805
+ try {
1806
+ const appId = options.app;
1807
+ const workspaceConfig = await WorkspaceConfig.load();
1808
+
1809
+ // Disable case — setMicroservicePort(appId, null) removes microservice.port / cleans microservice.{}
1810
+ if (options.port === 'n' || options.port === 'N') {
1811
+ await workspaceConfig.setMicroservicePort(appId, null);
1812
+ console.log(chalk.green(`\n✓ Microservice port removed for ${appId}\n`));
1813
+ return;
1814
+ }
1815
+
1816
+ const portNum = parseInt(options.port, 10);
1817
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
1818
+ console.error(chalk.red('\n❌ Invalid port number. Provide an integer 1-65535, or "n" to remove.\n'));
1819
+ process.exit(1);
1820
+ }
1821
+
1822
+ // setMicroservicePort mutates the live env entry and saves — and hard-fails (canonical
1823
+ // "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
1824
+ await workspaceConfig.setMicroservicePort(appId, portNum);
1825
+
1826
+ console.log(chalk.green(`\n✓ Microservice port set for ${appId}`));
1827
+ console.log(chalk.cyan(` Port: ${portNum}`));
1828
+ console.log(chalk.gray(` Written to env.products[${appId}].microservice.port in workspace.json.`));
1829
+ console.log(chalk.gray(` The gateway routes the microservice on this port; \`descix microservice init -a ${appId}\` can now scaffold it.\n`));
1830
+ } catch (error) {
1831
+ console.error(chalk.red(error.message));
1832
+ process.exit(1);
1833
+ }
1834
+ });
1835
+
1836
+ // ============ App: set-site (WS-SSGPOD — site.static workspace gap) ============
1837
+ //
1838
+ // Canonical write path for an app's site config in workspace.json — specifically site.static,
1839
+ // the relative path the dev gateway's staticSitePlugin serves at /p/{appId}/ (resolved against
1840
+ // the app's localPath; "." means the localPath itself). Optionally sets site.port for
1841
+ // dev-server sites. Parallel to `descix app set-port` (microservice.port); closes the
1842
+ // site.static gap so workspace.json never needs hand-editing (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1843
+ // Backed by WorkspaceConfig.setStaticSite (parallel to setSitePort/setMicroservicePort).
1844
+ //
1845
+ // NOTE: This is NOT `set-codesite` — that writes the Firestore ip_site_gcs_path_url (a prod
1846
+ // concern). set-site writes ONLY the local workspace.json site.{} slot.
1847
+ appCommand
1848
+ .command('set-site')
1849
+ .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.{}.")
1850
+ .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1851
+ .option('--static <path>', 'Relative static-site path under the app localPath (e.g. "site"; "." = the localPath itself)')
1852
+ .option('--port <port>', 'Site dev-server port (1-65535) for framework dev sites; mutually optional with --static')
1853
+ .option('--unset', 'Remove site.static (and site.port) for the app, clearing the site.{} slot')
1854
+ .action(async (options) => {
1855
+ try {
1856
+ const appId = options.app;
1857
+ const workspaceConfig = await WorkspaceConfig.load();
1858
+
1859
+ // --unset: remove both site.static and site.port (setStaticSite cleans up empty site.{})
1860
+ if (options.unset) {
1861
+ await workspaceConfig.setStaticSite(appId, { static: null, port: null });
1862
+ console.log(chalk.green(`\n✓ site config cleared for ${appId}\n`));
1863
+ return;
1864
+ }
1865
+
1866
+ // Require at least one field to set — do not silently no-op.
1867
+ if (options.static === undefined && options.port === undefined) {
1868
+ console.error(chalk.red('\n❌ Nothing to set. Provide --static <path> and/or --port <n>, or --unset to clear.\n'));
1869
+ process.exit(1);
1870
+ }
1871
+
1872
+ const fields = {};
1873
+
1874
+ if (options.static !== undefined) {
1875
+ if (typeof options.static !== 'string' || options.static.trim() === '') {
1876
+ console.error(chalk.red('\n❌ --static requires a non-empty relative path (e.g. "site" or ".").\n'));
1877
+ process.exit(1);
1878
+ }
1879
+ fields.static = options.static;
1880
+ }
1881
+
1882
+ if (options.port !== undefined) {
1883
+ const portNum = parseInt(options.port, 10);
1884
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
1885
+ console.error(chalk.red('\n❌ Invalid port number. Provide an integer 1-65535.\n'));
1886
+ process.exit(1);
1887
+ }
1888
+ fields.port = portNum;
1889
+ }
1890
+
1891
+ // setStaticSite mutates the live env entry and saves — and hard-fails (canonical
1892
+ // "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
1893
+ await workspaceConfig.setStaticSite(appId, fields);
1894
+
1895
+ console.log(chalk.green(`\n✓ site config set for ${appId}`));
1896
+ if (fields.static !== undefined) console.log(chalk.cyan(` site.static: ${fields.static}`));
1897
+ if (fields.port !== undefined) console.log(chalk.cyan(` site.port: ${fields.port}`));
1898
+ console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json.`));
1899
+ console.log(chalk.gray(` The gateway serves site.static at /p/${appId}/ (staticSitePlugin); \`descix serve\` picks it up.\n`));
1900
+ } catch (error) {
1901
+ console.error(chalk.red(error.message));
1902
+ process.exit(1);
1903
+ }
1904
+ });
1905
+
1906
+
1907
+ // ============ App: open (WS-SSGPOD — dev URL resolver) ============
1908
+ //
1909
+ // Resolves an app's LOCAL GATEWAY URL (the URL served by `descix serve`, which mirrors
1910
+ // the production LB) and prints it; with --open, launches the default browser.
1911
+ // Reuses the SDK resolver resolveAppGatewayUrl() — the same workspace.json read path
1912
+ // behind buildWorkspaceProducts() that the PWA bakes into __WORKSPACE_PRODUCTS__ — so
1913
+ // the URL matches what the app store routes to:
1914
+ // - env.platform → https://localhost:{gatewayPort}/
1915
+ // - product (static) → https://localhost:{gatewayPort}/p/{appId}
1916
+ // - product (dev-server)→ https://localhost:{gatewayPort}/p/{appId}
1917
+ // Hard-fails clearly if the app is unmapped or has no site config.
1918
+ appCommand
1919
+ .command('open')
1920
+ .description('Resolve (and optionally open) an app\'s local gateway dev URL. Hard-fails if the app has no site config.')
1921
+ .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json with a site)')
1922
+ .option('--open', 'Open the resolved URL in the default browser')
1923
+ .action(async (options) => {
1924
+ try {
1925
+ const appId = options.app;
1926
+ const { resolveAppGatewayUrl } = await import('@descix/app-sdk/dev');
1927
+ const workspaceConfig = await WorkspaceConfig.load();
1928
+ const workspaceRoot = workspaceConfig.workspaceRoot || process.cwd();
1929
+
1930
+ // resolveAppGatewayUrl hard-fails (no site / unmapped / no workspace) — surfaced in catch.
1931
+ const resolved = resolveAppGatewayUrl(workspaceRoot, appId);
1932
+
1933
+ console.log(chalk.green(`\n${appId} → ${resolved.url}`));
1934
+ console.log(chalk.gray(` kind: ${resolved.kind}`));
1935
+ console.log(chalk.gray(` via: ${resolved.via}`));
1936
+ console.log(chalk.gray(` (start the gateway with \`descix serve\` on port ${resolved.gatewayPort} if it is not already running)\n`));
1937
+
1938
+ if (options.open) {
1939
+ // Use the platform-native opener — no extra npm dependency, no hardcoded fallback.
1940
+ const { spawn } = await import('node:child_process');
1941
+ const platform = process.platform;
1942
+ const opener = platform === 'darwin' ? 'open'
1943
+ : platform === 'win32' ? 'cmd'
1944
+ : 'xdg-open';
1945
+ const args = platform === 'win32' ? ['/c', 'start', '', resolved.url] : [resolved.url];
1946
+ const child = spawn(opener, args, { stdio: 'ignore', detached: true });
1947
+ child.on('error', (err) => {
1948
+ console.error(chalk.yellow(` Could not launch browser (${err.message}). Open the URL above manually.`));
1949
+ });
1950
+ child.unref();
1951
+ }
1952
+ } catch (error) {
1953
+ console.error(chalk.red(error.message));
1954
+ process.exit(1);
1955
+ }
1956
+ });
1957
+
1958
+
1959
+ // ============ App: set-default-model (WS-CONFIG-BOOTSTRAP-FIX item #10) ============
1960
+ //
1961
+ // Updates Community/{c}/Apps/{a}.default_app_model. Per CEO inheritance chain:
1962
+ // options.model > kb.kb_model_override > app.default_app_model > levelConfig.model > DEFAULT_AI_MODEL
1963
+ //
1964
+ // --clear deletes the field via FieldValue.delete() (NOT null) so the resolver falls through
1965
+ // cleanly to per-level platform defaults.
1966
+
1967
+ appCommand
1968
+ .command('set-default-model')
1969
+ .description('Set or clear App.default_app_model (model used when no KB override is set)')
1970
+ .requiredOption('-a, --app <app_id>', 'App ID (e.g. unk-cos)')
1971
+ .option('-m, --model <model_name>', 'Gemini model name (e.g. gemini-3.1-flash-lite). Mutually exclusive with --clear.')
1972
+ .option('--clear', 'Delete App.default_app_model via FieldValue.delete() (resets to platform-default inheritance)')
1973
+ .addHelpText('after', `
1974
+ NOTE: Pinning a "Pro" model (e.g., gemini-2.5-pro, gemini-3.1-pro-preview) as
1975
+ an app or KB override will fail at L1 — these models require thinking mode and
1976
+ reject L1's thinkingBudget=0. If you intend an L1-compatible override, use a
1977
+ "Flash" model (gemini-2.5-flash, gemini-3.1-flash-lite, gemini-3-flash-preview).
1978
+ `)
1979
+ .action(async (options) => {
1980
+ try {
1981
+ // Forward the parent --env flag so the audit log records the target environment.
1982
+ const parentEnv = program.opts().env || null;
1983
+ await modelConfigCommands.runAppSetDefaultModel({ ...options, env: parentEnv });
1984
+ } catch (error) {
1985
+ console.error(chalk.red(`\n\u274c ${error.message}\n`));
1986
+ process.exit(1);
1987
+ }
1988
+ });
1989
+
1990
+ // ============ Knowledge Base Commands ============
1991
+
1992
+ const kbCommand = program
1993
+ .command('kb')
1994
+ .description('Knowledge base operations');
1995
+
1563
1996
  kbCommand
1564
- .command('chunk')
1565
- .description('Generate chunks from local markdown files')
1566
- .option('-c, --community <id>', 'Community ID')
1567
- .option('-a, --app <id>', 'App ID')
1568
- .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1569
- .option('-s, --chunk-size <size>', 'Chunk size in characters (default: 512)')
1570
- .option('-o, --overlap <size>', 'Overlap between chunks (default: 64)')
1571
- .option('-v, --verbose', 'Show verbose output')
1997
+ .command('list')
1998
+ .description('List knowledge bases for an app')
1999
+ .requiredOption('-a, --app <app_id>', 'App ID')
2000
+ .option('--cached', 'Show the fast cached vector counter instead of the TRUE live Pinecone count. The cached counter is written ONLY by the CLI/PWA sync path, so it reports 0 for KBs ingested by any other route even when their vectors retrieve. Default is the live count.')
2001
+ .addHelpText('after', `
2002
+ By default the Vectors column is the TRUE live Pinecone count (server enumerates each
2003
+ KB's vectors by id-prefix). The Source column shows how each count was obtained:
2004
+ live = counted live from Pinecone (authoritative)
2005
+ cached = the fast cached counter (--cached; may under-report; verify with a live run)
2006
+ unknown = live count requested but Pinecone enumeration failed — shown as '?', NOT a fake 0
2007
+ `)
1572
2008
  .action(async (options) => {
1573
2009
  try {
1574
- await kbCommands.runKbChunk(options);
2010
+ const apiClient = new DeSciXApiClient();
2011
+ await requireAuth(apiClient);
2012
+
2013
+ // Default to the TRUE live Pinecone count. --cached opts into the fast (possibly
2014
+ // stale) cached counter that only the sync path writes.
2015
+ const live = !options.cached;
2016
+ const response = await apiClient.invoke('list_knowledge_bases', {
2017
+ app_id: options.app,
2018
+ live
2019
+ });
2020
+ const result = response.message || response;
2021
+ const kbs = result.knowledge_bases || [];
2022
+
2023
+ if (kbs.length === 0) {
2024
+ console.log(chalk.yellow(`\nNo knowledge bases found for app ${options.app}\n`));
2025
+ return;
2026
+ }
2027
+
2028
+ // Render helpers live in ../lib/commands/kb-list-render.js (unit-tested there).
2029
+ const vectorCell = (kb) => kbVectorCell(kb);
2030
+ const sourceCell = (kb) => kbCountSource(kb, live);
2031
+
2032
+ // Table output
2033
+ const nameW = Math.max(7, ...kbs.map(k => (k.name || '').length));
2034
+ const modelW = Math.max(5, ...kbs.map(k => (k.model || '').length));
2035
+ const vecW = Math.max(7, ...kbs.map(k => vectorCell(k).length));
2036
+
2037
+ console.log('\n' + chalk.bold(
2038
+ 'KB Name'.padEnd(nameW + 2) +
2039
+ 'Model'.padEnd(modelW + 2) +
2040
+ 'Instructions'.padEnd(14) +
2041
+ 'Vectors'.padEnd(vecW + 2) +
2042
+ 'Source'.padEnd(9) +
2043
+ 'Last Sync'
2044
+ ));
2045
+ console.log('-'.repeat(nameW + modelW + 49));
2046
+
2047
+ for (const kb of kbs) {
2048
+ const syncDate = kb.rag_last_sync
2049
+ ? new Date(kb.rag_last_sync._seconds ? kb.rag_last_sync._seconds * 1000 : kb.rag_last_sync).toISOString().split('T')[0]
2050
+ : '-';
2051
+ const src = sourceCell(kb);
2052
+ const srcColor = src === 'live' ? chalk.green : src === 'unknown' ? chalk.yellow : chalk.gray;
2053
+ console.log(
2054
+ chalk.cyan((kb.name || '').padEnd(nameW + 2)) +
2055
+ chalk.gray((kb.model || '').padEnd(modelW + 2)) +
2056
+ (kb.system_instructions === 'present' ? chalk.green('present') : chalk.gray('empty')).padEnd(14 + 10) +
2057
+ vectorCell(kb).padEnd(vecW + 2) +
2058
+ srcColor(src.padEnd(9)) +
2059
+ syncDate
2060
+ );
2061
+ }
2062
+ console.log();
1575
2063
  } catch (error) {
1576
- console.error(chalk.red(`\n❌ ${error.message}\n`));
2064
+ console.error(chalk.red(error.message));
1577
2065
  process.exit(1);
1578
2066
  }
1579
2067
  });
1580
2068
 
1581
2069
  kbCommand
1582
- .command('sync')
1583
- .description('Sync local chunks to Pinecone via service layer')
1584
- .option('-c, --community <id>', 'Community ID')
1585
- .option('-a, --app <id>', 'App ID')
1586
- .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1587
- .option('-v, --verbose', 'Show verbose output')
2070
+ .command('create')
2071
+ .description('Create a new knowledge base in an app')
2072
+ .requiredOption('-c, --community <id>', 'Community ID')
2073
+ .requiredOption('-a, --app <id>', 'App ID')
2074
+ .requiredOption('-k, --kb <id>', 'Knowledge Base ID')
1588
2075
  .action(async (options) => {
1589
2076
  try {
1590
2077
  const apiClient = new DeSciXApiClient();
1591
2078
  await requireAuth(apiClient);
1592
2079
 
1593
- await kbCommands.runKbSync(apiClient, options);
2080
+ const response = await apiClient.invoke('create_skeleton_kb', {
2081
+ community_id: options.community,
2082
+ app_id: options.app,
2083
+ kb_name: options.kb
2084
+ });
2085
+ const result = response.message || response;
2086
+
2087
+ console.log(chalk.green('\n✅ Knowledge Base created successfully!\n'));
2088
+ console.log(chalk.cyan(` KB ID: ${options.kb}`));
2089
+ console.log(chalk.gray(` App: ${options.community}/${options.app}`));
1594
2090
  } catch (error) {
1595
- console.error(chalk.red(`\n❌ ${error.message}\n`));
2091
+ console.error(chalk.red(error.message));
1596
2092
  process.exit(1);
1597
2093
  }
1598
2094
  });
1599
2095
 
1600
2096
  kbCommand
1601
- .command('build')
1602
- .description('Full KB pipeline: [push staging] → pull → chunk → sync')
1603
- .option('-c, --community <id>', 'Community ID')
1604
- .option('-a, --app <id>', 'App ID')
1605
- .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1606
- .option('-s, --chunk-size <size>', 'Chunk size in characters (default: 2000)')
1607
- .option('-o, --overlap <size>', 'Overlap between chunks (default: 500)')
1608
- .option('-v, --verbose', 'Show verbose output')
1609
- .option('-i, --interactive', 'Enable interactive prompts for conflicts')
1610
- .option('--skip-staging', 'Skip staging push step')
1611
- .option('--merge-mode <mode>', 'Pull merge mode: merge|overwrite|force-overwrite (default: merge)')
1612
- .option('--dry-run', 'Show what would happen without making changes')
2097
+ .command('delete')
2098
+ .description('Delete a knowledge base registry doc (refuses a non-empty KB unless --force)')
2099
+ .requiredOption('-a, --app <app_id>', 'App ID')
2100
+ .requiredOption('-k, --kb <kb_name>', 'KB name')
2101
+ .option('--force', 'Purge the KB\'s live vectors and delete it even if non-empty. An empty KB deletes without this flag.')
2102
+ .addHelpText('after', `
2103
+ Fail-safe: the server reads the TRUE live Pinecone count. A KB with live vectors (or an
2104
+ undetermined count) is REFUSED unless --force. With --force the vectors are purged BEFORE
2105
+ the registry doc is removed, so a removed doc never strands orphan vectors.`)
1613
2106
  .action(async (options) => {
1614
2107
  try {
1615
2108
  const apiClient = new DeSciXApiClient();
1616
2109
  await requireAuth(apiClient);
1617
-
1618
- await kbCommands.runKbBuild(apiClient, options);
2110
+
2111
+ const response = await apiClient.invoke('delete_knowledge_base', {
2112
+ app_id: options.app,
2113
+ kb_name: options.kb,
2114
+ force: !!options.force
2115
+ });
2116
+ const result = response.message || response;
2117
+
2118
+ console.log(chalk.green('\n✅ Knowledge base deleted.\n'));
2119
+ console.log(chalk.cyan(` KB: ${options.kb}`));
2120
+ console.log(chalk.gray(` App: ${result.community_id || '?'}/${options.app}`));
2121
+ if (result.vectors_purged) {
2122
+ console.log(chalk.gray(` Vectors purged: ${result.vectors_purged}`));
2123
+ }
2124
+ console.log();
1619
2125
  } catch (error) {
1620
- console.error(chalk.red(`\n❌ ${error.message}\n`));
2126
+ console.error(chalk.red(error.message));
1621
2127
  process.exit(1);
1622
2128
  }
1623
2129
  });
1624
2130
 
2131
+
2132
+ // ============ KB: set-/clear-override-model (WS-CONFIG-BOOTSTRAP-FIX item #10) ============
2133
+ //
2134
+ // Updates Community/{c}/Apps/{a}/KnowledgeBases/{k}.kb_model_override.
2135
+ // Per CEO practice, KB overrides should be MINIMIZED — only use when a specific KB needs
2136
+ // a model different from App.default_app_model (e.g., a tuned model for one KB).
2137
+ //
2138
+ // clear-override-model deletes the field via FieldValue.delete() (NOT null) per tripwire #2.
2139
+
1625
2140
  kbCommand
1626
- .command('push')
1627
- .description('Push staging files to Drive')
1628
- .option('-c, --community <id>', 'Community ID')
1629
- .option('-a, --app <id>', 'App ID')
1630
- .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
2141
+ .command('set-override-model')
2142
+ .description('Set KB.kb_model_override (per-KB model selection)')
2143
+ .requiredOption('-a, --app <app_id>', 'App ID')
2144
+ .requiredOption('-k, --kb <kb_name>', 'KB name')
2145
+ .requiredOption('-m, --model <model_name>', 'Gemini model name (e.g. gemini-2.5-pro)')
2146
+ .addHelpText('after', `
2147
+ NOTE: Pinning a "Pro" model (e.g., gemini-2.5-pro, gemini-3.1-pro-preview) as
2148
+ an app or KB override will fail at L1 — these models require thinking mode and
2149
+ reject L1's thinkingBudget=0. If you intend an L1-compatible override, use a
2150
+ "Flash" model (gemini-2.5-flash, gemini-3.1-flash-lite, gemini-3-flash-preview).
2151
+ `)
2152
+ .action(async (options) => {
2153
+ try {
2154
+ const parentEnv = program.opts().env || null;
2155
+ await modelConfigCommands.runKbSetOverrideModel({ ...options, env: parentEnv });
2156
+ } catch (error) {
2157
+ console.error(chalk.red(`\n\u274c ${error.message}\n`));
2158
+ process.exit(1);
2159
+ }
2160
+ });
2161
+
2162
+ kbCommand
2163
+ .command('clear-override-model')
2164
+ .description('Delete KB.kb_model_override via FieldValue.delete() (resets to App.default_app_model inheritance)')
2165
+ .requiredOption('-a, --app <app_id>', 'App ID')
2166
+ .requiredOption('-k, --kb <kb_name>', 'KB name')
2167
+ .action(async (options) => {
2168
+ try {
2169
+ const parentEnv = program.opts().env || null;
2170
+ await modelConfigCommands.runKbClearOverrideModel({ ...options, env: parentEnv });
2171
+ } catch (error) {
2172
+ console.error(chalk.red(`\n\u274c ${error.message}\n`));
2173
+ process.exit(1);
2174
+ }
2175
+ });
2176
+
2177
+ // `kb chunk` and `kb sync` (low-level Git-mode steps) are REMOVED. Their implementations
2178
+ // runKbChunk/runKbSync are deleted from lib/commands/kb.js. Both names exit non-zero naming
2179
+ // `descix kb corpus sync`.
2180
+ // EVERY retired kb-sync surface is registered here, by ITERATING the owner list -- no verb
2181
+ // name is typed in this file. Adding a surface to RETIRED_KB_SYNC_SURFACES registers it;
2182
+ // nothing can be registered without being in that list.
2183
+ registerAllRetiredKbSync({ program, kb: kbCommand }, chalk.red);
2184
+
2185
+
2186
+ // app records — APP DATA PLANE structured record store (CEO-D-2026-06-02-APP-DATA-PLANE)
2187
+ // Treat your app like a database table: put/query/get/delete structured records
2188
+ // with custom metadata. Records live in the app data plane (Firestore document
2189
+ // collections), NOT the Pinecone KB. `query` is a STRUCTURED, strongly-consistent
2190
+ // metadata-filtered scan (non-ANN) returning full records; for semantic ANN search
2191
+ // use `descix chat`.
2192
+ //
2193
+ // Canonical surface: `descix app records ...` (commands app_records_*).
2194
+ // ─────────────────────────────────────────────────────────────────────────────
2195
+
2196
+ /**
2197
+ * Attach the four records subcommands (put/query/get/delete) to a parent command.
2198
+ * @param {import('commander').Command} parent - the `records` parent command
2199
+ * @param {string} cmdPrefix - backend command prefix ('app_records')
2200
+ */
2201
+ function attachRecordsSubcommands(parent, cmdPrefix) {
2202
+ parent
2203
+ .command('put')
2204
+ .description('Store/replace records in your app data store with custom metadata (your app as a database table). You supply file_id (+ optional chunk_idx); the id is built for you.')
2205
+ .requiredOption('-a, --app <app_id>', 'App ID')
2206
+ .requiredOption('-k, --kb <kb_id>', 'Record collection ID (acts like a table)')
2207
+ .requiredOption('-r, --records <json>', 'JSON array of records: [{ "file_id", "text"?, "chunk_idx"?, ...customMetadata }] — id is built from file_id (+ chunk_idx); pass a full "id" only for back-compat. Example: \'[{"file_id":"e1","text":"...","type":"episode","show":"x"}]\'')
2208
+ .option('--mode <mode>', 'upsert (default) | create — "create" is an atomic all-or-nothing conditional create: it fails if any key already exists and reports who holds it, instead of overwriting', 'upsert')
2209
+ .action(async (options) => {
2210
+ try {
2211
+ const apiClient = new DeSciXApiClient();
2212
+ await requireAuth(apiClient);
2213
+ let records;
2214
+ try { records = JSON.parse(options.records); } catch (e) { throw new Error(`--records must be valid JSON array: ${e.message}`); }
2215
+ if (!Array.isArray(records)) throw new Error('--records must be a JSON array');
2216
+ const response = await apiClient.invoke(`${cmdPrefix}_put`, { app_id: options.app, kb_id: options.kb, records, mode: options.mode });
2217
+ const result = response.message || response;
2218
+ // A refused conditional create is a DEFINITE answer, not a warning: report it as a
2219
+ // failure with the holder hint and a non-zero exit so scripts branch on it correctly.
2220
+ if (result.claimed === false) {
2221
+ console.error(chalk.red(`\n❌ claim refused — ${result.message || 'key already exists'}\n`));
2222
+ process.exit(1);
2223
+ }
2224
+ console.log(chalk.green(`\n✓ ${result.message || 'records put'}\n`));
2225
+ } catch (error) {
2226
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
2227
+ process.exit(1);
2228
+ }
2229
+ });
2230
+
2231
+ parent
2232
+ .command('query')
2233
+ .description('STRUCTURED metadata-filtered scan (non-ANN) returning full records: "all records where type=episode AND show=X"')
2234
+ .requiredOption('-a, --app <app_id>', 'App ID')
2235
+ .requiredOption('-k, --kb <kb_id>', 'Record collection ID')
2236
+ .option('-f, --filter <json>', 'Metadata predicate JSON. Scalar fields: $eq/$ne/$in or a bare value; ARRAY fields: $contains. e.g. \'{"type":"episode","tags":{"$contains":"handoff"}}\'', '{}')
2237
+ .option('--fields <csv>', 'Comma-separated metadata projection (omit or "*" for all metadata)')
2238
+ .option('--limit <n>', 'Cap on returned records', (v) => parseInt(v, 10))
2239
+ .action(async (options) => {
2240
+ try {
2241
+ const apiClient = new DeSciXApiClient();
2242
+ await requireAuth(apiClient);
2243
+ let filter;
2244
+ try { filter = JSON.parse(options.filter); } catch (e) { throw new Error(`--filter must be valid JSON: ${e.message}`); }
2245
+ const fields = options.fields ? options.fields.split(',').map(s => s.trim()).filter(Boolean) : undefined;
2246
+ const params = { app_id: options.app, kb_id: options.kb, filter };
2247
+ if (fields) params.fields = fields;
2248
+ if (options.limit) params.limit = options.limit;
2249
+ const response = await apiClient.invoke(`${cmdPrefix}_query`, params);
2250
+ const result = response.message || response;
2251
+ console.log(chalk.gray(`\n${result.message || ''}`));
2252
+ console.log(JSON.stringify(result.records || [], null, 2));
2253
+ console.log();
2254
+ } catch (error) {
2255
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
2256
+ process.exit(1);
2257
+ }
2258
+ });
2259
+
2260
+ parent
2261
+ .command('get')
2262
+ .description('Fetch specific records by id (point lookup) with an arbitrary metadata projection')
2263
+ .requiredOption('-a, --app <app_id>', 'App ID')
2264
+ .requiredOption('-k, --kb <kb_id>', 'Record collection ID')
2265
+ .requiredOption('-i, --ids <csv>', 'Comma-separated record ids to fetch')
2266
+ .option('--fields <csv>', 'Comma-separated metadata projection (omit or "*" for all metadata)')
2267
+ .action(async (options) => {
2268
+ try {
2269
+ const apiClient = new DeSciXApiClient();
2270
+ await requireAuth(apiClient);
2271
+ const ids = options.ids.split(',').map(s => s.trim()).filter(Boolean);
2272
+ const fields = options.fields ? options.fields.split(',').map(s => s.trim()).filter(Boolean) : undefined;
2273
+ const params = { app_id: options.app, kb_id: options.kb, ids };
2274
+ if (fields) params.fields = fields;
2275
+ const response = await apiClient.invoke(`${cmdPrefix}_get`, params);
2276
+ const result = response.message || response;
2277
+ console.log(chalk.gray(`\n${result.message || ''}`));
2278
+ console.log(JSON.stringify(result.records || [], null, 2));
2279
+ console.log();
2280
+ } catch (error) {
2281
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
2282
+ process.exit(1);
2283
+ }
2284
+ });
2285
+
2286
+ parent
2287
+ .command('delete')
2288
+ .description('Delete records by id (or by file_id grouping key)')
2289
+ .requiredOption('-a, --app <app_id>', 'App ID')
2290
+ .requiredOption('-k, --kb <kb_id>', 'Record collection ID')
2291
+ .option('-i, --ids <csv>', 'Comma-separated record ids to delete')
2292
+ .option('--file-ids <csv>', 'Comma-separated file_id grouping keys to delete')
2293
+ .action(async (options) => {
2294
+ try {
2295
+ const apiClient = new DeSciXApiClient();
2296
+ await requireAuth(apiClient);
2297
+ const ids = options.ids ? options.ids.split(',').map(s => s.trim()).filter(Boolean) : [];
2298
+ const file_ids = options.fileIds ? options.fileIds.split(',').map(s => s.trim()).filter(Boolean) : [];
2299
+ if (ids.length === 0 && file_ids.length === 0) throw new Error('Provide --ids and/or --file-ids');
2300
+ const response = await apiClient.invoke(`${cmdPrefix}_delete`, { app_id: options.app, kb_id: options.kb, ids, file_ids });
2301
+ const result = response.message || response;
2302
+ console.log(chalk.green(`\n✓ ${result.message || 'records deleted'}\n`));
2303
+ } catch (error) {
2304
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
2305
+ process.exit(1);
2306
+ }
2307
+ });
2308
+ }
2309
+
2310
+ // Canonical: `descix app records ...`
2311
+ const appRecordsCommand = appCommand
2312
+ .command('records')
2313
+ .description('Use your app as a queryable database (put/query/get/delete structured records with custom metadata). Backed by the app data plane (Firestore), not the Pinecone KB.');
2314
+ attachRecordsSubcommands(appRecordsCommand, 'app_records');
2315
+
2316
+
2317
+ // Corpus sub-commands (git-aware RAG sync via manifests)
2318
+ const corpusCommand = kbCommand
2319
+ .command('corpus')
2320
+ .description('Git-aware corpus sync via manifests');
2321
+
2322
+ corpusCommand
2323
+ .command('sync')
2324
+ .description('Sync corpus files to Pinecone using manifest definitions')
2325
+ .requiredOption('-a, --app <id>', 'App ID')
2326
+ .option('-c, --community <community_id>', 'Community ID (optional; resolved from Products when omitted)')
2327
+ .option('-k, --kb <name>', 'KB name (syncs specific manifest; default: all)')
1631
2328
  .option('-v, --verbose', 'Show verbose output')
1632
- .option('-i, --interactive', 'Enable interactive prompts for conflicts')
1633
- .option('--on-conflict <action>', 'Conflict handling: overwrite|skip (default: overwrite)')
1634
- .option('--no-move', 'Do not move files to .processed after upload')
1635
- .option('--dry-run', 'Show what would happen without making changes')
2329
+ .option('--ref <ref>', 'Override the git ref for ALL manifest sources (e.g., --ref ws-admin-b1). Precedence: --ref > manifest source.ref > "main".')
2330
+ .option('--rebuild', 'Reconcile Pinecone against the current manifest walk: enumerate remote file_ids, purge any not in the current corpus, then re-sync from scratch. Use to recover from accumulated stale-chunk drift. Prompts before deleting unless --yes is supplied.')
2331
+ .option('--dry-run', 'Enumerate would-be-purged file_ids and would-be-upserted chunks without ANY Pinecone writes. Exit 0 if no drift, 1 if drift. Read-only.')
2332
+ .option('--show-walk', 'Print the resolved ref + the first 50 walked files BEFORE any Pinecone operations. Useful for verifying --ref / manifest source resolution.')
2333
+ .option('--yes', 'Skip the interactive purge confirmation in --rebuild mode. Use in scripting/CI.')
2334
+ .action(async (options) => {
2335
+ try {
2336
+ const apiClient = new DeSciXApiClient();
2337
+ await requireAuth(apiClient);
2338
+
2339
+ const result = await corpusCommands.runCorpusSync(apiClient, options);
2340
+ // Dry-run exit code per Deliverable A: 0 if no drift, 1 if drift would be applied.
2341
+ if (result && result.dryRun) {
2342
+ process.exit(result.drift ? 1 : 0);
2343
+ }
2344
+ } catch (error) {
2345
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
2346
+ process.exit(1);
2347
+ }
2348
+ });
2349
+
2350
+ corpusCommand
2351
+ .command('status')
2352
+ .description('Show corpus sync state (files, chunks, last sync, resolved ref)')
2353
+ .requiredOption('-a, --app <id>', 'App ID')
2354
+ .option('-k, --kb <name>', 'KB name (default: all)')
2355
+ .option('--ref <ref>', 'Preview status as if --ref were applied to a sync (does not change manifests)')
2356
+ .option('-v, --verbose', 'Show verbose output including change detection')
1636
2357
  .action(async (options) => {
1637
2358
  try {
1638
2359
  let apiClient = null;
@@ -1640,48 +2361,96 @@ kbCommand
1640
2361
  apiClient = new DeSciXApiClient();
1641
2362
  await apiClient.loadCredentials();
1642
2363
  } catch {
1643
- // Continue without API client
2364
+ // Status can work offline
1644
2365
  }
1645
-
1646
- await kbCommands.runKbPush(apiClient, {
1647
- ...options,
1648
- moveToProcessed: options.move !== false
1649
- });
2366
+
2367
+ await corpusCommands.runCorpusStatus(apiClient, options);
1650
2368
  } catch (error) {
1651
2369
  console.error(chalk.red(`\n❌ ${error.message}\n`));
1652
2370
  process.exit(1);
1653
2371
  }
1654
2372
  });
1655
2373
 
2374
+ // ============ KB Doctor (M3, 2026-04-20) ============
2375
+ // Compare local sync-state vs Pinecone vectorCount and scan recent sync
2376
+ // logs for per-file 0-chunk warnings. Exits non-zero on drift > threshold.
1656
2377
  kbCommand
1657
- .command('status')
1658
- .description('Show KB sync status (local vs Pinecone)')
2378
+ .command('doctor')
2379
+ .description('Detect drift between local sync-state and live Pinecone vector count')
2380
+ .requiredOption('-a, --app <id>', 'App ID')
2381
+ .requiredOption('-k, --kb <name>', 'KB name')
2382
+ .option('-t, --threshold <ratio>', 'Drift threshold as a fraction (default: 0.05)', parseFloat)
2383
+ .option('--live', 'Compute vectorCount from the TRUE live Pinecone scope (bypass the cached counter, which lies after an interrupted op)')
2384
+ .option('--reconcile', 'Compute the live count AND write it back to rag_vector_count so the cached read is truthful again (implies --live)')
2385
+ .action(async (options) => {
2386
+ try {
2387
+ const apiClient = new DeSciXApiClient();
2388
+ await requireAuth(apiClient);
2389
+ await kbCommands.runKbDoctor(apiClient, options);
2390
+ } catch (error) {
2391
+ console.error(chalk.red(`\n❌ ${error.message}\n`));
2392
+ process.exit(1);
2393
+ }
2394
+ });
2395
+
2396
+ // ============ Drive Commands ============
2397
+
2398
+ const driveCommand = program
2399
+ .command('drive')
2400
+ .description('Drive content authoring: pull from Drive, push staging to Drive');
2401
+
2402
+ driveCommand
2403
+ .command('pull')
2404
+ .description('Pull content from Drive and convert to local markdown')
1659
2405
  .option('-c, --community <id>', 'Community ID')
1660
2406
  .option('-a, --app <id>', 'App ID')
1661
2407
  .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
2408
+ .option('--folder <id_or_url>', 'Override Drive folder (raw ID or full Drive URL) — one-time import without modifying workspace.json')
2409
+ .option('-v, --verbose', 'Show verbose output')
2410
+ .option('--merge-mode <mode>', 'Merge mode: merge|overwrite|force-overwrite (default: merge)')
2411
+ .option('--dry-run', 'Show what would happen without making changes')
1662
2412
  .action(async (options) => {
1663
2413
  try {
1664
- const apiClient = new DeSciXApiClient();
1665
- await requireAuth(apiClient);
1666
-
1667
- await kbCommands.runKbStatus(apiClient, options);
2414
+ let apiClient = null;
2415
+ try {
2416
+ apiClient = new DeSciXApiClient();
2417
+ await apiClient.loadCredentials();
2418
+ } catch {
2419
+ // Continue without API client
2420
+ }
2421
+
2422
+ await kbCommands.runKbPull(apiClient, options);
1668
2423
  } catch (error) {
1669
2424
  console.error(chalk.red(`\n❌ ${error.message}\n`));
1670
2425
  process.exit(1);
1671
2426
  }
1672
2427
  });
1673
2428
 
1674
- kbCommand
1675
- .command('compare')
1676
- .description('Show file-level deltas between local and Drive')
2429
+ driveCommand
2430
+ .command('push')
2431
+ .description('Push staging files to Drive')
1677
2432
  .option('-c, --community <id>', 'Community ID')
1678
2433
  .option('-a, --app <id>', 'App ID')
1679
2434
  .option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
1680
- .option('--staging', 'Only show staging → Drive delta')
1681
- .option('--local', 'Only show local KB ↔ Drive delta')
2435
+ .option('-v, --verbose', 'Show verbose output')
2436
+ .option('-i, --interactive', 'Enable interactive prompts for conflicts')
2437
+ .option('--on-conflict <action>', 'Conflict handling: overwrite|skip (default: overwrite)')
2438
+ .option('--no-move', 'Do not move files to .processed after upload')
2439
+ .option('--dry-run', 'Show what would happen without making changes')
1682
2440
  .action(async (options) => {
1683
2441
  try {
1684
- await kbCommands.runKbCompare(null, options);
2442
+ let apiClient = null;
2443
+ try {
2444
+ apiClient = new DeSciXApiClient();
2445
+ await apiClient.loadCredentials();
2446
+ } catch {
2447
+ // Continue without API client
2448
+ }
2449
+
2450
+ await kbCommands.runKbPush(apiClient, {
2451
+ ...options,
2452
+ moveToProcessed: options.move !== false
2453
+ });
1685
2454
  } catch (error) {
1686
2455
  console.error(chalk.red(`\n❌ ${error.message}\n`));
1687
2456
  process.exit(1);
@@ -1712,16 +2481,16 @@ siteCommand
1712
2481
  process.exit(1);
1713
2482
  }
1714
2483
 
1715
- const appConfig = workspaceConfig.getApp(ctx.communityId, ctx.appId);
2484
+ const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
1716
2485
  if (!appConfig) {
1717
2486
  console.error(chalk.red('\n❌ App not found in workspace.json.'));
1718
2487
  console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
1719
2488
  process.exit(1);
1720
2489
  }
1721
-
1722
- const appPath = appConfig.absolutePath ||
2490
+
2491
+ const appPath = appConfig.absolutePath ||
1723
2492
  path.join(workspaceConfig.getWorkspaceRoot(), appConfig.localPath);
1724
-
2493
+
1725
2494
  console.log(chalk.cyan('\n📁 Adding site scaffold...\n'));
1726
2495
 
1727
2496
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
@@ -1740,13 +2509,13 @@ siteCommand
1740
2509
  }
1741
2510
  });
1742
2511
 
1743
- // site upload - Deploy to GCS (context-aware, replaces deploy)
2512
+ // site upload - Deploy to GCS (context-aware, manifest-aware)
1744
2513
  siteCommand
1745
2514
  .command('upload')
1746
- .description('Upload site to GCS and update app metadata')
2515
+ .description('Upload site to GCS and update app metadata (uses .descix/manifests/site.json when present)')
1747
2516
  .option('-c, --community <id>', 'Community ID (auto-detects from context)')
1748
2517
  .option('-a, --app <id>', 'App ID (auto-detects from context)')
1749
- .option('-p, --path <localPath>', 'Local directory to deploy', './site')
2518
+ .option('-p, --path <localPath>', 'Local directory to deploy (ignored when site manifest exists)', './site')
1750
2519
  .option('--preview', 'Deploy to preview path')
1751
2520
  .option('--full', 'Force full upload (ignore delta)')
1752
2521
  .option('--dry-run', 'Show what would be deployed')
@@ -1754,93 +2523,190 @@ siteCommand
1754
2523
  .action(async (options) => {
1755
2524
  try {
1756
2525
  const apiClient = new DeSciXApiClient();
2526
+
2527
+ // WS-DEPLOY-HARDENING item 7: `site upload` must never target a local backend by
2528
+ // inheritance. detectApiUrl() checks process.env.DESCIX_API_URL first, then
2529
+ // WorkspaceConfig.getApiUrl() (env.apiUrl, else the shipped PROD default), then
2530
+ // GlobalConfig. `--env` and `--api-url` are folded into DESCIX_API_URL by the
2531
+ // preAction hook BEFORE this action runs — so checking DESCIX_API_URL here reflects
2532
+ // whether THIS invocation named its target. A workspace pinned at a local backend
2533
+ // (env.apiUrl = https://localhost:4000) is a standing setting, not a deploy target:
2534
+ // site deploys go to a cloud env, so that combination is a hard failure.
2535
+ const resolvedApiUrl = await apiClient.ensureBaseUrl();
2536
+ const isLocalApiUrl = /^https?:\/\/(localhost|127(?:\.\d{1,3}){3})(?::\d+)?/i.test(resolvedApiUrl) ||
2537
+ /:4000(?:\/|$)/.test(resolvedApiUrl);
2538
+ const explicitlyResolvedViaEnv = !!process.env.DESCIX_API_URL;
2539
+ if (isLocalApiUrl && !explicitlyResolvedViaEnv) {
2540
+ console.error(chalk.red(
2541
+ `\n❌ Refusing site upload against a local backend (${resolvedApiUrl}). ` +
2542
+ `Site deploys target a cloud env — pass --env=<dev|demo|prod> or export DESCIX_API_URL=https://<env>.descix.net\n`
2543
+ ));
2544
+ process.exit(1);
2545
+ }
2546
+
1757
2547
  await requireAuth(apiClient);
1758
-
2548
+
1759
2549
  // Load workspace context
1760
2550
  const workspaceConfig = await WorkspaceConfig.load();
1761
2551
  const ctx = workspaceConfig.resolveContextWithOptions(options);
1762
-
1763
- const communityId = ctx.communityId;
2552
+
2553
+ let communityId = ctx.communityId;
1764
2554
  const appId = ctx.appId;
1765
-
1766
- if (!communityId || !appId) {
1767
- console.error(chalk.red('\n❌ Community and App ID required.'));
1768
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
2555
+
2556
+ if (!appId) {
2557
+ console.error(chalk.red('\n❌ App ID required.'));
2558
+ console.log(chalk.gray(' Either provide -a flag, or cd into an app directory\n'));
1769
2559
  process.exit(1);
1770
2560
  }
1771
-
1772
- // Import GitUtils for file hashing
1773
- const { GitUtils } = await import('@descix/sdk/integrations/git');
2561
+
2562
+ // Resolve app root from workspace config
2563
+ const appConfig = workspaceConfig.getAppByAppId(appId);
2564
+
2565
+ // Derive communityId from workspace config if not provided.
2566
+ // Fallback: for NFT apps, community_id === app_id (convention).
2567
+ // The backend will validate this via hydrateCommunityIdFromProducts.
2568
+ if (!communityId) {
2569
+ communityId = appConfig?.communityId || appId;
2570
+ }
2571
+ const appRoot = appConfig?.absolutePath || path.resolve('.');
2572
+
2573
+ // Check for site manifest (.descix/manifests/site.json)
2574
+ const { loadSiteManifest } = await import('../lib/core/ManifestLoader.js');
2575
+ const siteManifest = await loadSiteManifest(appRoot);
2576
+
2577
+ // Import mime for content types
1774
2578
  const mime = (await import('mime-types')).default;
1775
-
1776
- const localPath = path.resolve(options.path);
1777
-
1778
- // Check if directory exists
1779
- try {
1780
- const stat = await fs.stat(localPath);
1781
- if (!stat.isDirectory()) {
1782
- throw new Error(`Path is not a directory: ${localPath}`);
2579
+ const { GitUtils } = await import('@descix/sdk/integrations/git');
2580
+ const gitUtils = new GitUtils(process.cwd());
2581
+ const gitStatus = await gitUtils.getStatus();
2582
+
2583
+ let fileList; // Array<{ path, hash, size, content_type }>
2584
+ let localFiles; // Object for delta comparison { path: { hash, size } }
2585
+ let sourceLabel; // For display
2586
+
2587
+ if (siteManifest) {
2588
+ // ── Manifest-driven flow ──
2589
+ const { walkSite } = await import('../lib/core/SiteWalker.js');
2590
+
2591
+ // Get env-specific build config from backend (GTM vars + base path)
2592
+ const buildConfigResult = await apiClient.invoke('get_site_build_config', {
2593
+ app_id: appId,
2594
+ community_id: communityId
2595
+ });
2596
+ // Canonical-contract ferry (engineering-culture mandate 2026-06-18): the server's
2597
+ // get_site_build_config owns the build-env schema — forward EVERY VITE_* key it
2598
+ // returns instead of hand-mirroring the field list (drift bug class; a hand mirror
2599
+ // silently dropped VITE_POWCH_FIRST_PARTY_ORIGINS, baking deny-by-default [] into
2600
+ // the Powch PWA and killing the first-party email ToS auto-grant).
2601
+ const buildEnvVars = Object.fromEntries(
2602
+ Object.entries(buildConfigResult.message || {}).filter(([k]) => k.startsWith('VITE_'))
2603
+ );
2604
+ console.log(chalk.cyan(` Build env: base=${buildConfigResult.message.VITE_BASE_PATH}, gtm=${buildConfigResult.message.VITE_GTM_ID}\n`));
2605
+
2606
+ // Run buildCommand if specified
2607
+ if (siteManifest.buildCommand) {
2608
+ if (options.dryRun) {
2609
+ console.log(chalk.yellow(`\n Dry run - skipping build (${siteManifest.buildCommand})`));
2610
+ console.log(chalk.gray(' Build env vars that would be injected:'));
2611
+ Object.entries(buildEnvVars).forEach(([k, v]) => console.log(chalk.gray(` ${k}=${v}`)));
2612
+ } else {
2613
+ console.log(chalk.cyan(`\n Running build: ${siteManifest.buildCommand}\n`));
2614
+ const { exec } = await import('child_process');
2615
+ const { promisify } = await import('util');
2616
+ const execAsync = promisify(exec);
2617
+ await execAsync(siteManifest.buildCommand, {
2618
+ cwd: appRoot,
2619
+ env: { ...process.env, ...buildEnvVars }
2620
+ });
2621
+ console.log(chalk.green(' Build completed.\n'));
2622
+ }
1783
2623
  }
1784
- } catch (err) {
1785
- if (err.code === 'ENOENT') {
1786
- throw new Error(`Directory not found: ${localPath}`);
2624
+
2625
+ const walkResult = await walkSite(siteManifest, appRoot);
2626
+ sourceLabel = `manifest (${siteManifest._manifestPath})`;
2627
+
2628
+ fileList = walkResult.files.map(f => ({
2629
+ path: f.deployPath,
2630
+ hash: f.hash,
2631
+ size: f.size,
2632
+ content_type: mime.lookup(f.deployPath) || 'application/octet-stream',
2633
+ _absolutePath: f.absolutePath // kept for upload reads
2634
+ }));
2635
+
2636
+ // Build localFiles object for delta comparison
2637
+ localFiles = {};
2638
+ for (const f of fileList) {
2639
+ localFiles[f.path] = { hash: f.hash, size: f.size };
1787
2640
  }
1788
- throw err;
2641
+ } else {
2642
+ // ── Legacy flat-directory flow (unchanged) ──
2643
+ const localPath = path.resolve(options.path);
2644
+ sourceLabel = localPath;
2645
+
2646
+ try {
2647
+ const stat = await fs.stat(localPath);
2648
+ if (!stat.isDirectory()) {
2649
+ throw new Error(`Path is not a directory: ${localPath}`);
2650
+ }
2651
+ } catch (err) {
2652
+ if (err.code === 'ENOENT') {
2653
+ throw new Error(`Directory not found: ${localPath}`);
2654
+ }
2655
+ throw err;
2656
+ }
2657
+
2658
+ const rawFiles = await gitUtils.getFileHashes(localPath);
2659
+ localFiles = rawFiles;
2660
+ fileList = Object.entries(rawFiles).map(([filePath, info]) => ({
2661
+ path: filePath,
2662
+ hash: info.hash,
2663
+ size: info.size,
2664
+ content_type: mime.lookup(filePath) || 'application/octet-stream',
2665
+ _absolutePath: path.join(localPath, filePath)
2666
+ }));
1789
2667
  }
1790
-
1791
- // Get Git status
1792
- const gitUtils = new GitUtils(process.cwd());
1793
- const gitStatus = await gitUtils.getStatus();
1794
-
1795
- console.log(chalk.cyan(`\n🚀 Site Upload: ${localPath} → GCS\n`));
2668
+
2669
+ console.log(chalk.cyan(`\n Site Upload: ${appId} → GCS\n`));
2670
+ console.log(chalk.gray(` Source: ${sourceLabel}`));
1796
2671
  console.log(chalk.gray(` Target: ${communityId}/${appId}`));
1797
2672
  if (options.preview) console.log(chalk.yellow(` Mode: PREVIEW`));
1798
2673
  if (gitStatus.isGitRepo) {
1799
2674
  console.log(chalk.gray(` Git: ${gitStatus.branch} @ ${gitStatus.shortHash}`));
1800
2675
  }
1801
-
1802
- // 1. Compute local file hashes
1803
- const localFiles = await gitUtils.getFileHashes(localPath);
1804
- const fileList = Object.entries(localFiles).map(([filePath, info]) => ({
1805
- path: filePath,
1806
- hash: info.hash,
1807
- size: info.size,
1808
- content_type: mime.lookup(filePath) || 'application/octet-stream'
1809
- }));
1810
-
2676
+
1811
2677
  console.log(chalk.gray(`\n Found ${fileList.length} files\n`));
1812
-
2678
+
1813
2679
  if (fileList.length === 0) {
1814
- console.log(chalk.yellow(' No files found in directory. Nothing to deploy.\n'));
2680
+ console.log(chalk.yellow(' No files found. Nothing to deploy.\n'));
1815
2681
  return;
1816
2682
  }
1817
-
2683
+
1818
2684
  // 2. Request deploy token with file list
1819
2685
  const tokenResponse = await apiClient.invoke('get_site_deploy_token', {
1820
2686
  community_id: communityId,
1821
2687
  app_id: appId,
1822
- files: fileList,
2688
+ files: fileList.map(f => ({ path: f.path, hash: f.hash, size: f.size, content_type: f.content_type })),
1823
2689
  preview: options.preview || false
1824
2690
  });
1825
-
1826
- const { signed_urls, existing_manifest, token_id, site_url } = tokenResponse.message;
1827
-
2691
+
2692
+ const { signed_urls, upload_headers, existing_manifest, token_id, site_url } = tokenResponse.message;
2693
+
1828
2694
  // 3. Determine delta
1829
2695
  let filesToUpload = fileList;
1830
2696
  let filesToDelete = [];
1831
-
2697
+
1832
2698
  if (!options.full && existing_manifest) {
1833
2699
  const delta = gitUtils.compareSyncState(localFiles, existing_manifest);
1834
- filesToUpload = fileList.filter(f =>
2700
+ filesToUpload = fileList.filter(f =>
1835
2701
  delta.added.includes(f.path) || delta.modified.includes(f.path)
1836
2702
  );
1837
2703
  filesToDelete = delta.deleted;
1838
-
2704
+
1839
2705
  console.log(chalk.gray(` Delta: ${delta.added.length} added, ${delta.modified.length} modified, ${delta.unchanged.length} unchanged, ${delta.deleted.length} deleted`));
1840
2706
  }
1841
-
2707
+
1842
2708
  if (options.dryRun) {
1843
- console.log(chalk.yellow('\n📋 Dry run - would upload:'));
2709
+ console.log(chalk.yellow('\n Dry run - would upload:'));
1844
2710
  if (filesToUpload.length === 0) {
1845
2711
  console.log(chalk.gray(' (no files to upload)'));
1846
2712
  } else {
@@ -1853,56 +2719,66 @@ siteCommand
1853
2719
  console.log(chalk.gray(`\n Site URL: ${site_url}\n`));
1854
2720
  return;
1855
2721
  }
1856
-
2722
+
1857
2723
  if (filesToUpload.length === 0) {
1858
- console.log(chalk.green('\n✅ Site already up to date on GCS.'));
2724
+ console.log(chalk.green('\n Site already up to date on GCS.'));
1859
2725
  console.log(chalk.gray(' Ensuring app metadata is synchronized...'));
1860
- // Fall through to confirm_site_deploy to ensure DB is updated with relative path
2726
+ // Fall through to confirm_site_deploy to ensure DB is updated
1861
2727
  } else {
1862
2728
  // 4. Upload files directly to GCS using signed URLs
1863
2729
  console.log(chalk.gray(`\n Uploading ${filesToUpload.length} files...`));
1864
-
2730
+
1865
2731
  let uploadedCount = 0;
1866
2732
  let uploadErrors = [];
1867
-
2733
+
1868
2734
  for (const file of filesToUpload) {
1869
2735
  const signedUrl = signed_urls[file.path];
1870
2736
  if (!signedUrl) {
1871
2737
  uploadErrors.push(`No signed URL for: ${file.path}`);
1872
2738
  continue;
1873
2739
  }
1874
-
2740
+
1875
2741
  try {
1876
- const content = await fs.readFile(path.join(localPath, file.path));
1877
-
2742
+ const content = await fs.readFile(file._absolutePath);
2743
+
2744
+ // Canonical contract (WS-DEPLOY-HARDENING item 4): the server binds Cache-Control
2745
+ // into the v4 signed URL signature (generateSiteUploadUrls/siteAssetCacheControl).
2746
+ // Ferry the exact header bag the server minted — never re-derive the policy
2747
+ // client-side. A mismatched header (including a client-side --no-cache override)
2748
+ // makes GCS return 403 signature-mismatch, so once the server ferries a bound
2749
+ // header for this path, --no-cache is a no-op for it (falls back to legacy
2750
+ // Content-Type-only behavior for paths the server didn't ferry a header for).
2751
+ const ferriedHeaders = upload_headers && upload_headers[file.path];
2752
+ const headers = ferriedHeaders || {
2753
+ 'Content-Type': file.content_type,
2754
+ ...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
2755
+ };
2756
+
1878
2757
  const response = await fetch(signedUrl, {
1879
2758
  method: 'PUT',
1880
- headers: {
1881
- 'Content-Type': file.content_type,
1882
- ...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
1883
- },
2759
+ headers,
1884
2760
  body: content
1885
2761
  });
1886
-
2762
+
1887
2763
  if (!response.ok) {
1888
2764
  uploadErrors.push(`Failed to upload ${file.path}: ${response.status} ${response.statusText}`);
1889
- console.log(chalk.red(` ✗ ${file.path}`));
2765
+ console.log(chalk.red(` x ${file.path}`));
1890
2766
  } else {
1891
2767
  uploadedCount++;
1892
- console.log(chalk.green(` ✓ ${file.path}`));
2768
+ console.log(chalk.green(` + ${file.path}`));
1893
2769
  }
1894
2770
  } catch (err) {
1895
2771
  uploadErrors.push(`Error uploading ${file.path}: ${err.message}`);
1896
- console.log(chalk.red(` ✗ ${file.path}`));
2772
+ console.log(chalk.red(` x ${file.path}`));
1897
2773
  }
1898
2774
  }
1899
-
2775
+
1900
2776
  if (uploadErrors.length > 0) {
1901
- console.log(chalk.yellow(`\n⚠️ ${uploadErrors.length} errors during upload:`));
2777
+ console.log(chalk.yellow(`\n ${uploadErrors.length} errors during upload:`));
1902
2778
  uploadErrors.forEach(e => console.log(chalk.red(` - ${e}`)));
1903
2779
  }
1904
2780
  }
1905
-
2781
+
1906
2782
  // 5. Confirm deployment (ALWAYS call this to ensure DB is updated)
1907
2783
  const confirmResponse = await apiClient.invoke('confirm_site_deploy', {
1908
2784
  community_id: communityId,
@@ -1913,7 +2789,7 @@ siteCommand
1913
2789
  app_id: appId,
1914
2790
  community_id: communityId,
1915
2791
  files: localFiles,
1916
- source_path: options.path
2792
+ source_path: siteManifest ? 'manifest' : options.path
1917
2793
  },
1918
2794
  git_info: gitStatus.isGitRepo ? {
1919
2795
  commit: gitStatus.shortHash,
@@ -1921,25 +2797,25 @@ siteCommand
1921
2797
  } : null,
1922
2798
  preview: options.preview || false
1923
2799
  });
1924
-
2800
+
1925
2801
  const result = confirmResponse.message;
1926
-
1927
- console.log(chalk.green(`\n✅ Site uploaded successfully!\n`));
2802
+
2803
+ console.log(chalk.green(`\n Site uploaded successfully!\n`));
1928
2804
  console.log(chalk.cyan(` URL: ${result.site_url}`));
1929
2805
  console.log(chalk.gray(` Files: ${result.files_count}`));
1930
2806
  console.log(chalk.gray(` Deployed: ${result.deployed_at}`));
1931
2807
  if (result.preview) {
1932
2808
  console.log(chalk.yellow(` Mode: PREVIEW`));
1933
2809
  }
1934
-
2810
+
1935
2811
  if (!options.preview) {
1936
2812
  console.log(chalk.gray(`\n For local development, run:`));
1937
2813
  console.log(chalk.cyan(` descix site servelocal <port>\n`));
1938
2814
  }
1939
2815
  console.log('');
1940
-
2816
+
1941
2817
  } catch (error) {
1942
- console.error(chalk.red(`\n❌ Upload failed: ${error.message}\n`));
2818
+ console.error(chalk.red(`\n Upload failed: ${error.message}\n`));
1943
2819
  process.exit(1);
1944
2820
  }
1945
2821
  });
@@ -1954,68 +2830,61 @@ siteCommand
1954
2830
  try {
1955
2831
  const workspaceConfig = await WorkspaceConfig.load();
1956
2832
  const ctx = workspaceConfig.resolveContextWithOptions(options);
1957
-
1958
- const communityId = ctx.communityId;
2833
+
1959
2834
  const appId = ctx.appId;
1960
-
1961
- if (!communityId || !appId) {
1962
- console.error(chalk.red('\n❌ Community and App ID required.'));
1963
- console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
1964
- process.exit(1);
1965
- }
1966
-
1967
- const appConfig = workspaceConfig.getApp(communityId, appId);
1968
- if (!appConfig) {
1969
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
1970
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2835
+
2836
+ if (!appId) {
2837
+ console.error(chalk.red('\n❌ App ID required.'));
2838
+ console.log(chalk.gray(' Either provide -a flag, or cd into an app directory\n'));
1971
2839
  process.exit(1);
1972
2840
  }
1973
-
1974
- // Handle disable case
2841
+
2842
+ // Handle disable case — setSitePort(appId, null) removes site.port / cleans site.{}
1975
2843
  if (port === 'n' || port === 'N') {
1976
- if (appConfig.site) {
1977
- delete appConfig.site.port;
1978
- if (Object.keys(appConfig.site).length === 0) {
1979
- delete appConfig.site;
1980
- }
1981
- }
1982
- await workspaceConfig.save();
1983
- console.log(chalk.green(`\n✅ Local site server disabled for ${communityId}/${appId}\n`));
2844
+ await workspaceConfig.setSitePort(appId, null);
2845
+ console.log(chalk.green(`\n✅ Local site server disabled for ${appId}\n`));
1984
2846
  return;
1985
2847
  }
1986
-
1987
- // Handle port registration
2848
+
2849
+ // Handle status query (no port argument)
1988
2850
  if (!port) {
1989
- // Show current status
1990
- const currentPort = appConfig.site?.port;
2851
+ const appConfig = workspaceConfig.getAppByAppId(appId);
2852
+ if (!appConfig) {
2853
+ console.error(chalk.red('\n❌ App not found in workspace.json.'));
2854
+ console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2855
+ process.exit(1);
2856
+ }
2857
+ // Read site.port from the live env entry (not from the constructed copy)
2858
+ let liveEntry = null;
2859
+ if (workspaceConfig.env?.platform?.appId === appId) {
2860
+ liveEntry = workspaceConfig.env.platform;
2861
+ } else if (Array.isArray(workspaceConfig.env?.products)) {
2862
+ liveEntry = workspaceConfig.env.products.find(p => p.appId === appId) || null;
2863
+ }
2864
+ const currentPort = liveEntry?.site?.port;
1991
2865
  if (currentPort) {
1992
2866
  console.log(chalk.cyan(`\n📍 Local site server: port ${currentPort}`));
1993
- console.log(chalk.gray(` App: ${communityId}/${appId}\n`));
2867
+ console.log(chalk.gray(` App: ${appId}\n`));
1994
2868
  } else {
1995
2869
  console.log(chalk.yellow(`\n⚠️ No local site server configured.`));
1996
2870
  console.log(chalk.gray(` Usage: descix site servelocal <port>\n`));
1997
2871
  }
1998
2872
  return;
1999
2873
  }
2000
-
2874
+
2001
2875
  const portNum = parseInt(port);
2002
2876
  if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
2003
2877
  console.error(chalk.red('\n❌ Invalid port number.\n'));
2004
2878
  process.exit(1);
2005
2879
  }
2006
-
2007
- if (!appConfig.site) appConfig.site = {};
2008
- appConfig.site.port = portNum;
2009
- if (!appConfig.site.devCommand) {
2010
- appConfig.site.devCommand = 'npm run dev';
2011
- }
2012
-
2013
- await workspaceConfig.save();
2880
+
2881
+ // setSitePort mutates the live env entry and saves — no stale-copy problem
2882
+ await workspaceConfig.setSitePort(appId, portNum);
2014
2883
  console.log(chalk.green(`\n✅ Local site server registered!`));
2015
2884
  console.log(chalk.cyan(` Port: ${portNum}`));
2016
- console.log(chalk.gray(` App: ${communityId}/${appId}`));
2017
- console.log(chalk.gray(`\n The PWA will proxy /apps/${communityId}/${appId}/* to localhost:${portNum}\n`));
2018
-
2885
+ console.log(chalk.gray(` App: ${appId}`));
2886
+ console.log(chalk.gray(`\n The gateway will proxy /p/${appId}/* to localhost:${portNum}\n`));
2887
+
2019
2888
  } catch (error) {
2020
2889
  console.error(chalk.red(`\n❌ ${error.message}\n`));
2021
2890
  process.exit(1);
@@ -2221,42 +3090,99 @@ microserviceCommand
2221
3090
  process.exit(1);
2222
3091
  }
2223
3092
 
2224
- const appConfig = workspaceConfig.getApp(ctx.communityId, ctx.appId);
3093
+ const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
2225
3094
  if (!appConfig) {
2226
3095
  console.error(chalk.red('\n❌ App not found in workspace.json.'));
2227
3096
  console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2228
3097
  process.exit(1);
2229
3098
  }
2230
-
2231
- const appPath = appConfig.absolutePath ||
3099
+
3100
+ const appPath = appConfig.absolutePath ||
2232
3101
  path.join(workspaceConfig.getWorkspaceRoot(), appConfig.localPath);
2233
-
3102
+
3103
+ // Resolve microservice port from workspace.json before scaffold copy.
3104
+ // Hard-fail early if missing — a scaffolded microservice without a known port
3105
+ // cannot be routed by the gateway and cannot start on a deterministic port.
3106
+ const env = workspaceConfig.env || {};
3107
+ let microservicePort = null;
3108
+ if (env.platform?.appId === ctx.appId) {
3109
+ microservicePort = env.platform?.microservice?.port || null;
3110
+ } else if (Array.isArray(env.products)) {
3111
+ const productEntry = env.products.find(p => p.appId === ctx.appId);
3112
+ microservicePort = productEntry?.microservice?.port || null;
3113
+ }
3114
+
3115
+ if (!microservicePort) {
3116
+ console.error(chalk.red(`\n❌ App '${ctx.appId}' has no microservice.port in workspace.json.`));
3117
+ console.error(chalk.red(` A port is required so the gateway knows where to route traffic.`));
3118
+ console.log(chalk.gray(`\n Set it with the config verb (never hand-edit workspace.json):`));
3119
+ console.log(chalk.gray(` descix app set-port -a ${ctx.appId} --port <your-port>\n`));
3120
+ process.exit(1);
3121
+ }
3122
+
2234
3123
  console.log(chalk.cyan('\n📁 Adding microservice scaffold...\n'));
2235
-
3124
+
2236
3125
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
2237
- const stats = await copyScaffold('microservice', appPath, {
2238
- verbose: true,
2239
- force: options.force
3126
+ const stats = await copyScaffold('microservice', appPath, {
3127
+ verbose: true,
3128
+ force: options.force
2240
3129
  });
2241
-
3130
+
2242
3131
  // Configuration Injection
2243
3132
  const microserviceDir = path.join(appPath, 'microservice');
2244
3133
  const defaultsPath = path.join(microserviceDir, 'defaults-config.json');
3134
+ const manifestPath = path.join(microserviceDir, 'manifest.json');
2245
3135
  const overridesPath = path.join(microserviceDir, 'dev-overrides.json');
2246
-
2247
- // 1. Inject Context into defaults-config.json
3136
+
3137
+ // 1. Inject Context + Port into defaults-config.json
2248
3138
  try {
2249
3139
  const defaultsContent = await fs.readFile(defaultsPath, 'utf-8');
2250
3140
  const defaults = JSON.parse(defaultsContent);
2251
3141
  defaults.community_id = ctx.communityId;
2252
3142
  defaults.app_id = ctx.appId;
3143
+ defaults.LOCAL_PORT = microservicePort;
2253
3144
  await fs.writeFile(defaultsPath, JSON.stringify(defaults, null, 2));
2254
- console.log(chalk.gray(` ✓ Injected context into defaults-config.json`));
3145
+ console.log(chalk.gray(` ✓ Injected context + port into defaults-config.json`));
2255
3146
  } catch (err) {
2256
3147
  console.warn(chalk.yellow(` ⚠ Could not update defaults-config.json: ${err.message}`));
2257
3148
  }
2258
-
2259
- // 2. Inject Credentials into dev-overrides.json
3149
+
3150
+ // 1b. Claim the scaffold's package.json for THIS service.
3151
+ // The template ships as "@descix/service-starter" — a real package name that is not
3152
+ // this developer's. Left unclaimed, every scaffolded service on the machine reports the
3153
+ // same identity to npm and to anything reading package.name, and `npm install` in the
3154
+ // service resolves against a name the developer does not own.
3155
+ try {
3156
+ const pkgPath = path.join(microserviceDir, 'package.json');
3157
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
3158
+ pkg.name = ctx.appId;
3159
+ pkg.description = `${ctx.appId} microservice on the DeSciX mesh`;
3160
+ pkg.version = '0.1.0';
3161
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
3162
+ console.log(chalk.gray(` ✓ Claimed package.json as '${ctx.appId}'`));
3163
+ } catch (err) {
3164
+ console.warn(chalk.yellow(` ⚠ Could not claim package.json: ${err.message}`));
3165
+ }
3166
+
3167
+ // 2. Inject Context + Port into manifest.json
3168
+ try {
3169
+ const manifestContent = await fs.readFile(manifestPath, 'utf-8');
3170
+ const manifest = JSON.parse(manifestContent);
3171
+ manifest.service.app_id = ctx.appId;
3172
+ manifest.service.community_id = ctx.communityId;
3173
+ manifest.service.name = ctx.appId;
3174
+ // No domain is injected. A service does not declare its own domain — the platform derives
3175
+ // it ({app_id}.{SITE_DOMAIN}) at registration, and refuses a manifest that declares one.
3176
+ // The literal this replaced was `${ctx.appId}.descix.net`: the PROD host, scaffolded into
3177
+ // every service regardless of the env it would be registered against.
3178
+ manifest.service.debugPort = microservicePort;
3179
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
3180
+ console.log(chalk.gray(` ✓ Injected context + port into manifest.json`));
3181
+ } catch (err) {
3182
+ console.warn(chalk.yellow(` ⚠ Could not update manifest.json: ${err.message}`));
3183
+ }
3184
+
3185
+ // 3. Inject Credentials into dev-overrides.json
2260
3186
  try {
2261
3187
  const apiClient = new DeSciXApiClient();
2262
3188
  const credentials = await apiClient.loadCredentials();
@@ -2294,10 +3220,132 @@ microserviceCommand
2294
3220
  }
2295
3221
  });
2296
3222
 
3223
+ // microservice deploy - Cloud Run deploy (broker-first; no per-app LB for non-core apps)
3224
+ // WS-MCP-SURFACE-SPLIT §4b: this is the REAL Admin/local deploy (Admin SDK) — it stays fully
3225
+ // functional for operator/CI use (EVP/COS deploy runbooks invoke it). It is intentionally NOT
3226
+ // advertised over MCP (not mcp:true), so the PUBLIC surface sees no deploy tool. The public
3227
+ // "microservice deploy" is "coming soon" (a no-op) — satisfied here by absence from the MCP
3228
+ // listing, NOT by neutering this admin path. Do not stub this out.
3229
+ microserviceCommand
3230
+ .command('deploy')
3231
+ .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.')
3232
+ .option('-c, --community <id>', 'Community ID (auto-detects from context)')
3233
+ .option('-a, --app <id>', 'App ID (auto-detects from context)')
3234
+ .option('--env <env>', 'Target environment: dev|demo|prod')
3235
+ .option('--dry-run', 'Print deploy plan without executing gcloud')
3236
+ .option('--skip-register', 'Skip manifest registration after deploy (service may self-register on boot)')
3237
+ .action(async (options, command) => {
3238
+ try {
3239
+ const apiClient = new DeSciXApiClient();
3240
+ await requireAuth(apiClient);
3241
+
3242
+ const workspaceConfig = await WorkspaceConfig.load();
3243
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
3244
+ const appId = ctx.appId;
3245
+
3246
+ if (!appId) {
3247
+ console.error(chalk.red('\n❌ App ID required.'));
3248
+ console.log(chalk.gray(' cd into an app directory, or use -a flag\n'));
3249
+ process.exit(1);
3250
+ }
3251
+
3252
+ const parentEnv = command?.parent?.opts?.()?.env;
3253
+ const deployEnv = (options.env || parentEnv || workspaceConfig.env?.environment)?.toLowerCase();
3254
+ if (!deployEnv || !['dev', 'demo', 'prod'].includes(deployEnv)) {
3255
+ console.error(chalk.red('\n❌ --env is required (dev|demo|prod).'));
3256
+ console.log(chalk.gray(' Example: descix --env demo microservice deploy -a powch\n'));
3257
+ process.exit(1);
3258
+ }
3259
+
3260
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
3261
+ if (!workspaceRoot) {
3262
+ throw new Error('Could not resolve workspace root (.descix/workspace.json not found)');
3263
+ }
3264
+
3265
+ const deployScript = path.join(
3266
+ workspaceRoot,
3267
+ 'DeSciX/DeSciX_Cloud/microservice/admin/scripts/deploy/deploy-service-env.sh'
3268
+ );
3269
+
3270
+ try {
3271
+ await fs.access(deployScript);
3272
+ } catch {
3273
+ throw new Error(`Deploy script not found: ${deployScript}`);
3274
+ }
3275
+
3276
+ console.log(chalk.cyan(`\n🚀 Deploying microservice: ${appId} (${deployEnv})\n`));
3277
+ if (options.dryRun) {
3278
+ console.log(chalk.yellow(' Dry run — gcloud deploy will not execute\n'));
3279
+ }
3280
+
3281
+ const { spawnSync } = await import('child_process');
3282
+ const result = spawnSync('bash', [deployScript, appId, deployEnv], {
3283
+ cwd: path.dirname(deployScript),
3284
+ env: {
3285
+ ...process.env,
3286
+ ECHO_MODE: options.dryRun ? 'true' : 'false'
3287
+ },
3288
+ stdio: 'inherit'
3289
+ });
3290
+
3291
+ if (result.status !== 0) {
3292
+ process.exit(result.status || 1);
3293
+ }
3294
+
3295
+ if (options.skipRegister) {
3296
+ console.log(chalk.gray('\n Skipped manifest registration (--skip-register).\n'));
3297
+ return;
3298
+ }
3299
+
3300
+ const microserviceDir = workspaceConfig.getMicroservicePath(appId);
3301
+ if (!microserviceDir) {
3302
+ console.log(chalk.yellow('\n⚠️ Deploy complete. Run descix microservice register to register manifest.\n'));
3303
+ return;
3304
+ }
3305
+
3306
+ const manifestPath = path.join(microserviceDir, 'manifest.json');
3307
+ try {
3308
+ await fs.access(manifestPath);
3309
+ } catch {
3310
+ console.log(chalk.yellow('\n⚠️ Deploy complete. No manifest.json — run descix microservice register when ready.\n'));
3311
+ return;
3312
+ }
3313
+
3314
+ console.log(chalk.cyan('\n📦 Registering microservice manifest...\n'));
3315
+ const registerArgs = [
3316
+ process.argv[1],
3317
+ 'microservice',
3318
+ 'register',
3319
+ '-m', manifestPath,
3320
+ '-a', appId
3321
+ ];
3322
+ if (ctx.communityId) {
3323
+ registerArgs.push('-c', ctx.communityId);
3324
+ }
3325
+
3326
+ const registerResult = spawnSync(process.execPath, registerArgs, {
3327
+ cwd: process.cwd(),
3328
+ stdio: 'inherit',
3329
+ env: process.env
3330
+ });
3331
+
3332
+ if (registerResult.status !== 0) {
3333
+ console.log(chalk.yellow('\n⚠️ Cloud Run deploy succeeded but manifest registration failed.'));
3334
+ console.log(chalk.gray(` Retry: descix microservice register -m ${manifestPath} -a ${appId}\n`));
3335
+ process.exit(registerResult.status || 1);
3336
+ }
3337
+
3338
+ console.log(chalk.green('\n✅ Microservice deploy complete (Cloud Run + manifest registration).\n'));
3339
+ } catch (error) {
3340
+ console.error(chalk.red(`\n❌ Deploy failed: ${error.message}\n`));
3341
+ process.exit(1);
3342
+ }
3343
+ });
3344
+
2297
3345
  // microservice register - Register with gateway
2298
3346
  microserviceCommand
2299
3347
  .command('register')
2300
- .description('Register microservice with gateway (requires SERVICE_README)')
3348
+ .description('Register microservice with gateway (-r <local SERVICE_README>; git-mode only — no Drive)')
2301
3349
  .option('-m, --manifest <path>', 'Path to manifest.json', './manifest.json')
2302
3350
  .option('-r, --readme <path>', 'Path to local SERVICE_README file')
2303
3351
  .option('-c, --community <id>', 'Community ID (auto-detects from context)')
@@ -2366,63 +3414,23 @@ microserviceCommand
2366
3414
  console.log(chalk.gray(` App: ${appId}`));
2367
3415
  console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}\n`));
2368
3416
 
2369
- // Option 1: Local README file provided via --readme
2370
- if (options.readme) {
2371
- const readmePath = path.resolve(options.readme);
2372
- console.log(chalk.cyan(`📄 Reading local README: ${readmePath}\n`));
2373
-
2374
- let readmeContent;
2375
- try {
2376
- readmeContent = await fs.readFile(readmePath, 'utf-8');
2377
- } catch (error) {
2378
- if (error.code === 'ENOENT') {
2379
- console.error(chalk.red(`❌ README file not found: ${readmePath}`));
2380
- process.exit(1);
2381
- }
2382
- throw new Error(`Failed to read README: ${error.message}`);
2383
- }
2384
-
2385
- // Register with local README content for vectorization
2386
- const response = await apiClient.invoke('register_service', {
2387
- manifest,
2388
- readme_content: readmeContent
2389
- });
2390
-
2391
- console.log(chalk.green('✅ Microservice registered successfully!\n'));
2392
- console.log(chalk.cyan(` Service: ${serviceName}`));
2393
- console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
2394
- console.log(chalk.gray(` README vectorized for tell_me_how discovery\n`));
2395
-
2396
- // Option 2: Check for SERVICE_README in Drive folder
2397
- } else if (!options.skipReadmeCheck) {
2398
- console.log(chalk.cyan(`🔍 Checking for ${readmeFileName} in app Drive folder...\n`));
2399
-
2400
- const checkResponse = await apiClient.invoke('check_service_readme_exists', {
2401
- community_id: communityId,
2402
- app_id: appId,
2403
- service_name: serviceName
2404
- });
2405
-
2406
- const readmeExists = checkResponse.message?.exists || checkResponse.exists || false;
2407
-
2408
- if (!readmeExists) {
2409
- // Output AI-friendly instructions for creating the README
2410
- outputServiceReadmeInstructions(serviceName, appId, communityId, manifest);
2411
- process.exit(1);
2412
- }
2413
-
2414
- console.log(chalk.green(`✅ Found ${readmeFileName}\n`));
2415
-
2416
- // Read README content from Drive
2417
- const readmeResponse = await apiClient.invoke('get_service_readme_content', {
2418
- community_id: communityId,
2419
- app_id: appId,
2420
- service_name: serviceName
2421
- });
3417
+ // Option 1: Local README file provided via --readme
3418
+ if (options.readme) {
3419
+ const readmePath = path.resolve(options.readme);
3420
+ console.log(chalk.cyan(`📄 Reading local README: ${readmePath}\n`));
2422
3421
 
2423
- const readmeContent = readmeResponse.message?.content || readmeResponse.content || '';
3422
+ let readmeContent;
3423
+ try {
3424
+ readmeContent = await fs.readFile(readmePath, 'utf-8');
3425
+ } catch (error) {
3426
+ if (error.code === 'ENOENT') {
3427
+ console.error(chalk.red(`❌ README file not found: ${readmePath}`));
3428
+ process.exit(1);
3429
+ }
3430
+ throw new Error(`Failed to read README: ${error.message}`);
3431
+ }
2424
3432
 
2425
- // Register with README content for vectorization
3433
+ // Register with local README content for vectorization
2426
3434
  const response = await apiClient.invoke('register_service', {
2427
3435
  manifest,
2428
3436
  readme_content: readmeContent
@@ -2433,9 +3441,11 @@ microserviceCommand
2433
3441
  console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
2434
3442
  console.log(chalk.gray(` README vectorized for tell_me_how discovery\n`));
2435
3443
 
2436
- // Option 3: Skip README check
2437
- } else {
2438
- // Skip README check - register without vectorization
3444
+ // Option 2: Explicit skip — register without README vectorization (git-mode only).
3445
+ // (Drive-folder README fallback removed per CEO-D-2026-06-01-MESH-AUTH-DRIVE-REMOVAL:
3446
+ // git corpus sync is the only canonical KB sync; -r <local README> is the only
3447
+ // README source. Nothing reads SERVICE_README from Drive anymore.)
3448
+ } else if (options.skipReadmeCheck) {
2439
3449
  console.log(chalk.yellow('⚠️ Skipping README check (tool discovery will be limited)\n'));
2440
3450
 
2441
3451
  const response = await apiClient.invoke('register_service', { manifest });
@@ -2444,6 +3454,11 @@ microserviceCommand
2444
3454
  console.log(chalk.cyan(` Service: ${serviceName}`));
2445
3455
  console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
2446
3456
  console.log(chalk.yellow(` ⚠️ No README - service won't appear in tell_me_how results\n`));
3457
+
3458
+ // No -r and no --skip-readme-check: instruct the LOCAL README path (never Drive).
3459
+ } else {
3460
+ outputServiceReadmeInstructions(serviceName, appId, communityId, manifest);
3461
+ process.exit(1);
2447
3462
  }
2448
3463
 
2449
3464
  } catch (error) {
@@ -2484,8 +3499,8 @@ microserviceCommand
2484
3499
  await fs.access(manifestPath);
2485
3500
  } catch {
2486
3501
  // If not in current directory, try to find the microservice path for detected context
2487
- if (ctx.communityId && ctx.appId && workspaceConfig.workspaceRoot) {
2488
- const appConfig = workspaceConfig.getApp(ctx.communityId, ctx.appId);
3502
+ if (ctx.appId && workspaceConfig.workspaceRoot) {
3503
+ const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
2489
3504
  if (appConfig) {
2490
3505
  const microservicePath = path.join(
2491
3506
  workspaceConfig.workspaceRoot,
@@ -2694,7 +3709,7 @@ microserviceCommand
2694
3709
  // microservice register-delegate - Register delegate key
2695
3710
  microserviceCommand
2696
3711
  .command('register-delegate')
2697
- .description('Register service delegate key for Intelligent Mesh')
3712
+ .description('Provision the service delegate key (SERVICE_KEY) that authenticates mesh/loopback calls — run this if your service gets HTTP 401 calling /apifront or another service')
2698
3713
  .option('-c, --community <id>', 'Community ID (auto-detects from context)')
2699
3714
  .option('-a, --app <id>', 'App ID (auto-detects from context)')
2700
3715
  .option('-s, --slot <id>', 'Service slot ID (uses first available if not provided)')
@@ -2733,20 +3748,28 @@ microserviceCommand
2733
3748
  // Fetch entitlements
2734
3749
  const entitlementsResponse = await apiClient.invoke('fetch_my_purchases', {});
2735
3750
  const entitlements = entitlementsResponse.message || {};
2736
- const serviceSlots = entitlements.service_slots || [];
2737
-
3751
+ const allSlots = entitlements.service_slots || [];
3752
+
3753
+ // Service slots come ONLY from subscriptions right now. NFT-based slots are FUTURE
3754
+ // functionality (app/NFT association is not wired yet) — never select them here.
3755
+ // CEO-D-2026-06-01-MESH-AUTH-DRIVE-REMOVAL (Fix C).
3756
+ const serviceSlots = allSlots.filter(slot => slot.type === 'subscription');
3757
+
2738
3758
  if (serviceSlots.length === 0) {
2739
- console.error(chalk.red('❌ No service slots available.'));
2740
- console.log(chalk.white(' You need a "Runner NFT" or Subscription to create a service.'));
3759
+ console.error(chalk.red('❌ No subscription service slot available.'));
3760
+ console.log(chalk.white(' Service slots are provided by a subscription. A subscription is required to'));
3761
+ console.log(chalk.white(' provision a delegate. (NFT-based slots are future functionality and are'));
3762
+ console.log(chalk.white(' not selectable yet.)'));
2741
3763
  process.exit(1);
2742
3764
  }
2743
-
2744
- // Select slot
3765
+
3766
+ // Select slot (subscription slots only)
2745
3767
  let selectedSlot = serviceSlots[0];
2746
3768
  if (options.slot) {
2747
- selectedSlot = serviceSlots.find(s => (s.nft_id || s.id) === options.slot);
3769
+ selectedSlot = serviceSlots.find(s => (s.id || s.nft_id) === options.slot);
2748
3770
  if (!selectedSlot) {
2749
- console.error(chalk.red(`❌ Slot ${options.slot} not found in your entitlements.`));
3771
+ console.error(chalk.red(`❌ Subscription slot ${options.slot} not found in your entitlements.`));
3772
+ console.log(chalk.gray(' Only subscription slots are selectable. Run without -s to use the first available.'));
2750
3773
  process.exit(1);
2751
3774
  }
2752
3775
  }
@@ -2805,6 +3828,27 @@ microserviceCommand
2805
3828
  }
2806
3829
  });
2807
3830
 
3831
+ // microservice restart - Kill + relaunch a local microservice (DEV only)
3832
+ microserviceCommand
3833
+ .command('restart <name>')
3834
+ .description('Restart a local microservice (DEV only; DEMO/PROD use deploy scripts)')
3835
+ .option('--env <env>', 'Target environment: dev | demo | prod', 'dev')
3836
+ .action(async (name, options) => {
3837
+ try {
3838
+ const { restartMicroservice } = await import('../lib/commands/microservice-restart.js');
3839
+ const { runHealth } = await import('../lib/commands/health.js');
3840
+ await restartMicroservice({
3841
+ name,
3842
+ env: options.env,
3843
+ deps: { runHealth }
3844
+ });
3845
+ console.log();
3846
+ } catch (error) {
3847
+ console.error(chalk.red(`\n${error.message}\n`));
3848
+ process.exit(1);
3849
+ }
3850
+ });
3851
+
2808
3852
  // ============ Role Commands ============
2809
3853
 
2810
3854
  const roleCommand = program
@@ -2950,131 +3994,225 @@ repCommand
2950
3994
  }
2951
3995
  });
2952
3996
 
2953
- // ============ Chat Session Management ============
2954
-
2955
- /**
2956
- * Get interaction_id from session file
2957
- * @param {string} communityId
2958
- * @param {string} appId
2959
- * @returns {Promise<string|null>}
2960
- */
2961
- async function getSessionInteractionId(communityId, appId) {
2962
- const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
2963
- try {
2964
- const data = JSON.parse(await fs.readFile(sessionPath, 'utf-8'));
2965
- return data.interaction_id || null;
2966
- } catch {
2967
- return null;
2968
- }
2969
- }
2970
-
2971
- /**
2972
- * Save interaction_id to session file
2973
- * @param {string} communityId
2974
- * @param {string} appId
2975
- * @param {string} interactionId
2976
- */
2977
- async function saveSessionInteractionId(communityId, appId, interactionId) {
2978
- const sessionDir = path.join(os.homedir(), '.descix', 'sessions');
2979
- await fs.mkdir(sessionDir, { recursive: true });
2980
- const sessionPath = path.join(sessionDir, `${communityId}_${appId}.json`);
2981
- await fs.writeFile(sessionPath, JSON.stringify({
2982
- interaction_id: interactionId,
2983
- updated: Date.now()
2984
- }, null, 2));
2985
- }
2986
-
2987
- /**
2988
- * Clear session file
2989
- * @param {string} communityId
2990
- * @param {string} appId
2991
- */
2992
- async function clearSession(communityId, appId) {
2993
- const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
2994
- try {
2995
- await fs.unlink(sessionPath);
2996
- } catch {
2997
- // File doesn't exist, that's fine
2998
- }
2999
- }
3000
-
3001
3997
  // ============ Chat Commands ============
3002
3998
 
3003
3999
  program
3004
4000
  .command('chat [question...]')
3005
4001
  .description('Chat with an app agent. Usage: descix chat "Your question" or descix chat -q "Your question"')
3006
- .option('-c, --community <id>', 'Community ID (defaults to descix)')
3007
- .option('-a, --app <id>', 'App ID (defaults to agent)')
4002
+ .option('-c, --community <id>', 'Community ID (optional; server resolves it from Products)')
4003
+ .option('-a, --app <id>', 'App ID (required unless run from inside a mapped app directory)')
3008
4004
  .option('-q, --question <text>', 'Question to ask (alternative to positional argument)')
3009
- .option('-k, --kb <id>', 'Knowledge Base ID', 'General')
4005
+ .option('-k, --kb <id...>', 'Knowledge Base ID(s) — repeat for multi-KB, use * for all')
4006
+ .option('--apps <ids>', 'Comma-separated app IDs for cross-app query')
4007
+ .option('--level <n>', 'Intelligence level (1-5)', parseInt)
4008
+ .option('--model <name>', 'Explicit model override')
4009
+ .option('--thinking <budget>', 'Thinking token budget (-1=dynamic, 0=off, N=fixed)', parseInt)
4010
+ .option('--tokens', 'Show token usage in response footer')
3010
4011
  .option('--new', 'Start a new conversation (clear session)')
3011
4012
  .action(async (questionArgs, options) => {
3012
4013
  try {
3013
4014
  const apiClient = new DeSciXApiClient();
3014
4015
  await requireAuth(apiClient);
3015
-
4016
+
3016
4017
  // Get question from positional args or -q option
3017
4018
  const question = options.question || (questionArgs && questionArgs.join(' '));
3018
4019
  if (!question) {
3019
4020
  console.error(chalk.red('Error: Question required. Usage: descix chat "Your question" or descix chat -q "Your question"'));
3020
4021
  process.exit(1);
3021
4022
  }
3022
-
3023
- // Auto-detect app from current directory if not specified
3024
- let communityId = options.community;
3025
- let appId = options.app;
3026
-
4023
+
4024
+ // Multi-app mode: --apps daita,unk-beast
4025
+ if (options.apps) {
4026
+ const appIds = options.apps.split(',').map(s => s.trim());
4027
+ const apps = appIds.map(id => {
4028
+ const kbNames = options.kb && !options.kb.includes('General') ? options.kb : undefined;
4029
+ return kbNames ? { app_id: id, knowledgebase_names: kbNames } : { app_id: id };
4030
+ });
4031
+
4032
+ console.log(chalk.gray(`Asking ${appIds.join(', ')}...`));
4033
+
4034
+ const response = await apiClient.invoke('ask_multiple_apps', {
4035
+ apps,
4036
+ user_input: question,
4037
+ intelligence_level: options.level,
4038
+ model: options.model,
4039
+ thinking_budget: options.thinking,
4040
+ streaming: false
4041
+ });
4042
+ const result = response.message || response;
4043
+
4044
+ console.log(chalk.green('\n\u2705 Response:\n'));
4045
+ console.log(chalk.white(result.response || result.text || JSON.stringify(result, null, 2)));
4046
+
4047
+ if (result.apps_queried) {
4048
+ console.log(chalk.cyan(`\nApps queried: ${result.apps_queried.join(', ')}`));
4049
+ }
4050
+
4051
+ const sources = result.sources || [];
4052
+ if (sources.length > 0) {
4053
+ console.log(chalk.cyan('\n\ud83d\udcda Sources:'));
4054
+ sources.forEach((src, i) => {
4055
+ const fileName = src.fileName || src.file_path || src.source || src;
4056
+ const fileId = src.fileId || src.id || '';
4057
+ const score = src.score || src.similarity || 0;
4058
+ const scoreStr = score ? ` (score: ${score.toFixed(3)})` : '';
4059
+ const idStr = fileId ? ` [ID: ${fileId}]` : '';
4060
+ console.log(chalk.gray(` ${i + 1}. ${fileName}${idStr}${scoreStr}`));
4061
+ });
4062
+ }
4063
+
4064
+ // Token usage footer
4065
+ if (options.tokens && result.usage) {
4066
+ const u = result.usage;
4067
+ const thinkStr = u.thinking_tokens ? ` + ${u.thinking_tokens} thinking` : '';
4068
+ console.log(chalk.gray(`\n[${result.model_used || 'unknown'} | ${u.input_tokens} in + ${u.output_tokens} out${thinkStr} = ${u.total_tokens} tokens]`));
4069
+ }
4070
+ console.log('');
4071
+ return;
4072
+ }
4073
+
4074
+ // Single-app mode.
4075
+ // WS-R7-PREREQS (CEO-D-2026-07-04-R7-PREREQS-RESCOPE ruling 3): the hardcoded
4076
+ // communityId||'descix' / appId||'agent' fallbacks are REMOVED. app_id must be
4077
+ // resolvable (flag or workspace context) — FAIL LOUD otherwise. community_id is
4078
+ // server-authoritative: the response carries the Products-resolved value and the
4079
+ // session file is keyed from it.
4080
+ let communityId = options.community || null;
4081
+ let appId = options.app || null;
4082
+
3027
4083
  if (!communityId || !appId) {
3028
4084
  try {
3029
- const { PathContext } = await import('../lib/core/PathContext.js');
3030
- const ctx = await PathContext.tryLoad();
3031
- if (ctx) {
3032
- const detected = ctx.detectContext();
4085
+ const wsConfig = await WorkspaceConfig.tryLoad();
4086
+ if (wsConfig) {
4087
+ const detected = wsConfig.detectContext();
3033
4088
  if (detected) {
3034
- communityId = communityId || detected.communityId;
3035
- appId = appId || detected.appId;
4089
+ communityId = communityId || detected.communityId || null;
4090
+ appId = appId || detected.appId || null;
3036
4091
  }
3037
4092
  }
3038
4093
  } catch {
3039
- // Fall through to defaults
4094
+ // No workspace context — resolution below decides, no defaults
3040
4095
  }
3041
4096
  }
3042
-
3043
- // Fall back to defaults if still not set
3044
- communityId = communityId || 'descix';
3045
- appId = appId || 'agent';
3046
-
3047
- // Get session interaction_id unless --new flag
4097
+
4098
+ if (!appId) {
4099
+ console.error(chalk.red(
4100
+ 'Error: no app resolved. Pass -a/--app <app_id> or run from inside an app directory ' +
4101
+ 'mapped in .descix/workspace.json. (The legacy hardcoded descix/agent fallback was ' +
4102
+ 'removed — it silently mis-keyed sessions.)'
4103
+ ));
4104
+ process.exit(1);
4105
+ }
4106
+
4107
+ // Session continuity: exact key when the community is known client-side; otherwise
4108
+ // the app's newest session file (app_id is globally unique) carries the thread.
3048
4109
  let previousInteractionId = null;
3049
4110
  if (!options.new) {
3050
- previousInteractionId = await getSessionInteractionId(communityId, appId);
4111
+ if (communityId) {
4112
+ previousInteractionId = await getSessionInteractionId(communityId, appId);
4113
+ } else {
4114
+ const sessions = await findSessionsForApp(appId);
4115
+ if (sessions.length > 0) {
4116
+ previousInteractionId = await getSessionInteractionId(sessions[0].communityId, appId);
4117
+ }
4118
+ }
3051
4119
  } else {
3052
- // Clear session if --new flag
3053
- await clearSession(communityId, appId);
3054
- }
3055
-
3056
- console.log(chalk.gray(`Asking ${communityId}/${appId}...`));
3057
-
3058
- const response = await apiClient.invoke('ask_question_to_app', {
4120
+ await clearAppSessions(communityId, appId);
4121
+ }
4122
+
4123
+ // Resolve KB param — single or multi.
4124
+ //
4125
+ // ws-mcp-surface-basics — ONE NORMALIZATION OWNER. `descix chat` and the
4126
+ // ask_question_to_app MCP tool are not two backends: both invoke the SAME
4127
+ // ask_question_to_app command over /apifront/. The only thing that had diverged was the
4128
+ // CLIENT-SIDE default — the CLI substituted a hardcoded 'General' while MCP callers sent
4129
+ // nothing and let the server decide. That hardcoded fallback (anti-pattern #7) is why the
4130
+ // CLI reported "Default KB not found" against apps whose KBs are not named 'General': the
4131
+ // CLI asserted a KB the app never had. When the caller names no KB we now send NO kb param
4132
+ // and the server's resolver (pineconeService.resolveKbNameScope, fed by
4133
+ // utils.DEFAULT_KNOWLEDGEBASE_NAME) is the sole owner of what "default" means.
4134
+ const kbList = options.kb || null;
4135
+ const useMultiKb = !!kbList && (kbList.length > 1 || kbList.includes('*'));
4136
+
4137
+ console.log(chalk.gray(`Asking ${communityId ? communityId + '/' : ''}${appId}...`));
4138
+
4139
+ const invokeParams = {
3059
4140
  app_id: appId,
3060
- knowledgebase_name: options.kb,
3061
4141
  user_input: question,
3062
4142
  previous_interaction_id: previousInteractionId,
3063
- streaming: false
3064
- });
4143
+ streaming: false,
4144
+ intelligence_level: options.level,
4145
+ model: options.model,
4146
+ thinking_budget: options.thinking,
4147
+ };
4148
+
4149
+ if (useMultiKb) {
4150
+ invokeParams.knowledgebase_names = kbList;
4151
+ } else if (kbList) {
4152
+ invokeParams.knowledgebase_name = kbList[0];
4153
+ }
4154
+
4155
+ // Stale-thread self-heal. Exactly ONE retry, and only for the typed 400 — see
4156
+ // isStaleThreadError and the asymmetry note above it. Anything else (including the 403
4157
+ // "this thread is not yours") propagates untouched to the catch below, which prints it
4158
+ // and exits non-zero.
4159
+ let response;
4160
+ let healedFromInteractionId = null;
4161
+ try {
4162
+ response = await apiClient.invoke('ask_question_to_app', invokeParams);
4163
+ } catch (error) {
4164
+ if (!isStaleThreadError(error, previousInteractionId)) throw error;
4165
+
4166
+ healedFromInteractionId = previousInteractionId;
4167
+ await clearAppSessions(communityId, appId);
4168
+
4169
+ // Single retry with no thread — byte-identical to what `--new` would have sent.
4170
+ // NO loop: if this one fails, its error propagates.
4171
+ previousInteractionId = null;
4172
+ invokeParams.previous_interaction_id = null;
4173
+ response = await apiClient.invoke('ask_question_to_app', invokeParams);
4174
+ }
4175
+
4176
+ // Notice goes to STDERR, deliberately. stdout stays exactly the answer (and stays
4177
+ // valid JSON for any `--json`-style consumer), while a human at a terminal always sees
4178
+ // it and a `| jq` pipeline can never swallow it. The user must never be left believing
4179
+ // continuity survived when it did not.
4180
+ if (healedFromInteractionId) {
4181
+ console.error(chalk.yellow(
4182
+ `Note: the previous conversation thread for ${appId} was no longer available ` +
4183
+ `(expired or retired) — it has been discarded and a NEW thread was started. ` +
4184
+ `This answer does not carry any earlier context.`
4185
+ ));
4186
+ }
4187
+
3065
4188
  const result = response.message || response;
3066
-
3067
- // Save new interaction_id for next message
4189
+
4190
+ // Save new interaction_id for next message, keyed by the SERVER-resolved
4191
+ // community (authoritative — Products-hydrated). Client-resolved (-c flag /
4192
+ // workspace context) is only accepted when the server did not return one
4193
+ // (older backend); with NEITHER available this FAILS LOUD after printing the
4194
+ // response — the session must never be keyed under a guessed community.
3068
4195
  if (result.interaction_id) {
3069
- await saveSessionInteractionId(communityId, appId, result.interaction_id);
4196
+ const authoritativeCommunityId = result.community_id || communityId;
4197
+ if (!authoritativeCommunityId) {
4198
+ console.log(chalk.white(result.response || result.text || ''));
4199
+ console.error(chalk.red(
4200
+ '\nError: cannot key the chat session — the server did not return community_id ' +
4201
+ 'and none was resolvable client-side. The response above was NOT session-saved. ' +
4202
+ 'Pass -c/--community, or update the backend (ask_question_to_app must return the ' +
4203
+ 'Products-resolved community_id per CEO-D-2026-07-04-R7-PREREQS-RESCOPE).'
4204
+ ));
4205
+ process.exit(1);
4206
+ }
4207
+ await saveSessionAuthoritative(authoritativeCommunityId, appId, result.interaction_id);
3070
4208
  }
3071
-
3072
- console.log(chalk.green('\n✅ Response:\n'));
4209
+
4210
+ console.log(chalk.green('\n\u2705 Response:\n'));
3073
4211
  console.log(chalk.white(result.response || result.text || JSON.stringify(result, null, 2)));
3074
-
4212
+
3075
4213
  const sources = result.sources || response.sources || response.message?.sources || [];
3076
4214
  if (sources && sources.length > 0) {
3077
- console.log(chalk.cyan('\n📚 Sources:'));
4215
+ console.log(chalk.cyan('\n\ud83d\udcda Sources:'));
3078
4216
  sources.forEach((src, i) => {
3079
4217
  const fileName = src.fileName || src.file_path || src.source || src;
3080
4218
  const fileId = src.fileId || src.id || '';
@@ -3084,6 +4222,14 @@ program
3084
4222
  console.log(chalk.gray(` ${i + 1}. ${fileName}${idStr}${scoreStr}`));
3085
4223
  });
3086
4224
  }
4225
+
4226
+ // Token usage footer
4227
+ if (options.tokens && result.usage) {
4228
+ const u = result.usage;
4229
+ const thinkStr = u.thinking_tokens ? ` + ${u.thinking_tokens} thinking` : '';
4230
+ const levelStr = options.level ? `level ${options.level}` : `level ${result.usage?.intelligence_level || '2'}`;
4231
+ console.log(chalk.gray(`\n[${result.model_used || 'unknown'} | ${levelStr} | ${u.input_tokens} in + ${u.output_tokens} out${thinkStr} = ${u.total_tokens} tokens]`));
4232
+ }
3087
4233
  console.log('');
3088
4234
  } catch (error) {
3089
4235
  console.error(chalk.red(error.message));
@@ -3094,14 +4240,19 @@ program
3094
4240
  program
3095
4241
  .command('new-chat')
3096
4242
  .description('Clear chat session for an app (start fresh conversation)')
3097
- .option('-c, --community <id>', 'Community ID (defaults to descix)')
3098
- .option('-a, --app <id>', 'App ID (defaults to agent)')
4243
+ .option('-c, --community <id>', 'Community ID (optional; clears only that community key)')
4244
+ .option('-a, --app <id>', 'App ID (required)')
3099
4245
  .action(async (options) => {
3100
4246
  try {
3101
- const communityId = options.community || 'descix';
3102
- const appId = options.app || 'agent';
3103
- await clearSession(communityId, appId);
3104
- console.log(chalk.green(`Chat session cleared for ${communityId}/${appId}. Next chat will start fresh.`));
4247
+ // WS-R7-PREREQS: hardcoded descix/agent fallbacks REMOVED — an app id is required.
4248
+ const appId = options.app || null;
4249
+ if (!appId) {
4250
+ console.error(chalk.red('Error: -a/--app <app_id> is required (the legacy hardcoded descix/agent fallback was removed).'));
4251
+ process.exit(1);
4252
+ }
4253
+ const cleared = await clearAppSessions(options.community || null, appId);
4254
+ const scope = options.community ? `${options.community}/${appId}` : appId;
4255
+ console.log(chalk.green(`Chat session cleared for ${scope} (${cleared} session file(s)). Next chat will start fresh.`));
3105
4256
  } catch (error) {
3106
4257
  console.error(chalk.red(error.message));
3107
4258
  process.exit(1);
@@ -3118,16 +4269,26 @@ program
3118
4269
  const apiClient = new DeSciXApiClient();
3119
4270
  await requireAuth(apiClient);
3120
4271
 
4272
+ // APP entitlements: use the product_type:'APP' fast-path. The no-filter call
4273
+ // returns the STORE BUNDLE (which has no top-level `apps` key — apps are nested
4274
+ // under each community), so `result.apps` was always undefined → "Apps (0)" even
4275
+ // when the user holds entitlements. The fast-path returns the user's actual
4276
+ // purchased App objects (userManagement.js fetch_my_purchases APP branch).
4277
+ const appResp = await apiClient.invoke('fetch_my_purchases', { product_type: 'APP' });
4278
+ const appResult = appResp.message || appResp;
4279
+ const apps = appResult.apps || [];
4280
+
4281
+ // COMMUNITY entitlements: the standard call exposes the user's owned community
4282
+ // IDs via `my_community_ids` (the top-level `communities` field is the store
4283
+ // catalog, not the user's overlay).
3121
4284
  const response = await apiClient.invoke('fetch_my_purchases', {});
3122
4285
  const result = response.message || response;
3123
-
3124
- const communities = result.communities || [];
3125
- const apps = result.apps || [];
3126
-
4286
+ const communityIds = result.my_community_ids || [];
4287
+
3127
4288
  console.log(chalk.green('\n✅ Your Purchases:\n'));
3128
- console.log(chalk.cyan(`Communities (${communities.length}):`));
3129
- communities.forEach((c, idx) => {
3130
- console.log(chalk.yellow(` ${idx + 1}. ${c.community_name} (${c.community_id})`));
4289
+ console.log(chalk.cyan(`Communities (${communityIds.length}):`));
4290
+ communityIds.forEach((cid, idx) => {
4291
+ console.log(chalk.yellow(` ${idx + 1}. ${cid}`));
3131
4292
  });
3132
4293
  console.log();
3133
4294
  console.log(chalk.cyan(`Apps (${apps.length}):`));
@@ -3154,7 +4315,7 @@ configCommand
3154
4315
  try {
3155
4316
  await configCommands.show();
3156
4317
  } catch (error) {
3157
- process.exit(1);
4318
+ fail(error);
3158
4319
  }
3159
4320
  });
3160
4321
 
@@ -3167,143 +4328,87 @@ configCommand
3167
4328
  try {
3168
4329
  await configCommands.setUrl(url, options);
3169
4330
  } catch (error) {
3170
- process.exit(1);
4331
+ fail(error);
3171
4332
  }
3172
4333
  });
3173
4334
 
3174
4335
  configCommand
3175
4336
  .command('init')
3176
- .description('Initialize configuration')
3177
- .option('--dev', 'Initialize for development (https://localhost:4000)')
4337
+ .description('Initialize configuration for an environment (requires --env)')
4338
+ // `--dev` DELETED, not deprecated: its absence meant PRODUCTION, so the most common
4339
+ // invocation (`descix config init`) wrote a prod origin nobody named. There is no default.
4340
+ .option('--env <name>', 'Environment to initialize: dev, demo or prod')
4341
+ .option('--url <url>', 'Explicit origin (for a self-hosted or port-forwarded gateway)')
3178
4342
  .option('-g, --global', 'Save to global config (~/.descixrc)')
3179
4343
  .action(async (options) => {
3180
4344
  try {
3181
- const env = options.dev ? 'dev' : 'prod';
3182
- await configCommands.init(env, options);
4345
+ await configCommands.init(options.env, options);
3183
4346
  } catch (error) {
3184
- process.exit(1);
4347
+ fail(error);
3185
4348
  }
3186
4349
  });
3187
4350
 
3188
4351
  configCommand
3189
- .command('set-sync-mode')
3190
- .description('Set KB sync mode for an app (git or drive)')
3191
- .argument('<mode>', 'Sync mode: "git" (local-first) or "drive" (PWA-first)')
3192
- .option('-c, --community <id>', 'Community ID')
3193
- .option('-a, --app <id>', 'App ID')
3194
- .action(async (mode, options) => {
4352
+ .command('set-env')
4353
+ .description('Set target environment persistently (updates workspace.json, auto-reconnects)')
4354
+ .argument('<env>', 'Environment: dev, demo, prod, or custom name')
4355
+ .option('-u, --url <url>', 'API URL override (for custom environments)')
4356
+ .action(async (env, options) => {
3195
4357
  try {
3196
- await configCommands.setSyncMode(mode, options);
4358
+ await configCommands.setEnv(env, options);
3197
4359
  } catch (error) {
3198
- console.error(chalk.red(`\n❌ ${error.message}\n`));
3199
- process.exit(1);
4360
+ fail(error);
3200
4361
  }
3201
4362
  });
3202
4363
 
3203
- // ============ Documentation Commands ============
3204
-
3205
- const docsCommand = program
3206
- .command('docs')
3207
- .description('Documentation operations');
4364
+ configCommand
4365
+ .command('set-gateway-port')
4366
+ .description('Set the port `descix serve` listens on (env.gateway.port)')
4367
+ .argument('<port>', 'Port number 1-65535, or "none" to remove')
4368
+ .action(async (port) => {
4369
+ try {
4370
+ await configCommands.setGatewayPort(port === 'none' ? null : port);
4371
+ } catch (error) {
4372
+ fail(error);
4373
+ }
4374
+ });
3208
4375
 
3209
- docsCommand
3210
- .command('sync')
3211
- .description('Sync README/ folder to platform-docs KB for RAG search')
3212
- .option('--path <path>', 'Path to README folder (default: ./README)', './README')
4376
+ configCommand
4377
+ .command('set-dev-certs')
4378
+ .description('Point local dev servers at your own TLS cert pair (env.devCerts) — the gateway AND every app behind it')
4379
+ .option('-d, --dir <path>', 'Directory holding cert.pem + key.pem')
4380
+ .option('-c, --cert <path>', 'Certificate file')
4381
+ .option('-k, --key <path>', 'Private key file')
4382
+ .option('--clear', 'Remove the setting and fall back to the SDK-tracked SAN pair')
3213
4383
  .action(async (options) => {
3214
4384
  try {
3215
- const apiClient = new DeSciXApiClient();
3216
- await requireAuth(apiClient);
3217
-
3218
- const fs = await import('fs/promises');
3219
- const path = await import('path');
3220
-
3221
- const readmePath = path.resolve(options.path || './README');
3222
-
3223
- console.log(chalk.cyan(`\n📚 Syncing documentation to platform-docs KB\n`));
3224
- console.log(chalk.gray(`Reading from: ${readmePath}\n`));
3225
-
3226
- // Check if README folder exists
3227
- try {
3228
- const stats = await fs.stat(readmePath);
3229
- if (!stats.isDirectory()) {
3230
- throw new Error(`Path is not a directory: ${readmePath}`);
3231
- }
3232
- } catch (error) {
3233
- if (error.code === 'ENOENT') {
3234
- throw new Error(`README folder not found: ${readmePath}`);
3235
- }
3236
- throw error;
3237
- }
3238
-
3239
- // Recursively read all .md files
3240
- const files = [];
3241
- async function readDir(dirPath, basePath = '') {
3242
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
3243
- for (const entry of entries) {
3244
- const fullPath = path.join(dirPath, entry.name);
3245
- const relPath = path.join(basePath, entry.name);
3246
-
3247
- if (entry.isDirectory()) {
3248
- // Skip node_modules and .git
3249
- if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) {
3250
- continue;
3251
- }
3252
- await readDir(fullPath, relPath);
3253
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
3254
- const content = await fs.readFile(fullPath, 'utf-8');
3255
- files.push({
3256
- name: entry.name,
3257
- path: relPath,
3258
- content: Buffer.from(content).toString('base64') // Base64 encode
3259
- });
3260
- }
3261
- }
3262
- }
3263
-
3264
- await readDir(readmePath);
3265
-
3266
- if (files.length === 0) {
3267
- throw new Error('No .md files found in README folder');
3268
- }
3269
-
3270
- console.log(chalk.gray(`Found ${files.length} markdown files. Uploading...\n`));
3271
-
3272
- // Upload files to platform-docs KB
3273
- const response = await apiClient.invoke('upload_files_to_kb', {
3274
- community_id: 'descix',
3275
- app_id: 'platform-docs',
3276
- kb_id: 'General',
3277
- files: files
3278
- });
3279
- const result = response.message || response;
3280
-
3281
- console.log(chalk.green(`\n✅ Uploaded ${result.uploaded || 0} files\n`));
3282
-
3283
- // Sync to RAG
3284
- console.log(chalk.gray('Syncing to RAG vectors...\n'));
3285
- const syncResponse = await apiClient.invoke('sync_knowledge_base', {
3286
- community_id: 'descix',
3287
- app_id: 'platform-docs',
3288
- kb_id: 'General'
3289
- });
3290
- const syncResult = syncResponse.message || syncResponse;
3291
-
3292
- console.log(chalk.green('✅ Documentation synced to RAG!\n'));
3293
- console.log(chalk.cyan(` Total Vectors: ${syncResult.vectorCount || result.totalVectors || 0}\n`));
3294
-
3295
- if (result.errors && result.errors.length > 0) {
3296
- console.log(chalk.yellow(' Errors:'));
3297
- result.errors.forEach(err => {
3298
- console.log(chalk.red(` - ${err.file}: ${err.error}`));
3299
- });
3300
- }
3301
-
3302
- console.log(chalk.gray('\nQuery documentation with: descix rag query -c descix -a platform-docs -k General -q "your question"\n'));
3303
-
4385
+ await configCommands.setDevCerts(options);
3304
4386
  } catch (error) {
3305
- console.error(chalk.red(error.message));
3306
- process.exit(1);
4387
+ fail(error);
4388
+ }
4389
+ });
4390
+
4391
+ configCommand
4392
+ .command('set-powch-url')
4393
+ .description('Set where Powch lives (env.powchUrl) — its OWN origin; Powch is cross-origin from the shell by design')
4394
+ .argument('<url>', 'Powch origin (e.g. https://powch.dev.descix.net), or "none" to remove')
4395
+ .action(async (url) => {
4396
+ try {
4397
+ await configCommands.setPowchUrl(url === 'none' ? null : url);
4398
+ } catch (error) {
4399
+ fail(error);
4400
+ }
4401
+ });
4402
+
4403
+ configCommand
4404
+ .command('set-site-url')
4405
+ .description('Set the App Shell origin `descix serve` proxies / to (env.siteUrl)')
4406
+ .argument('<url>', 'App Shell origin, or "none" to remove')
4407
+ .action(async (url) => {
4408
+ try {
4409
+ await configCommands.setSiteUrl(url === 'none' ? null : url);
4410
+ } catch (error) {
4411
+ fail(error);
3307
4412
  }
3308
4413
  });
3309
4414
 
@@ -3460,8 +4565,9 @@ function outputServiceReadmeInstructions(serviceName, appId, communityId, manife
3460
4565
 
3461
4566
  console.log(chalk.white(`Your microservice needs a ${readmeFileName} file for tool discovery.
3462
4567
 
3463
- 📁 Required file: ${readmeFileName}
3464
- 📍 Location: Google Drive folder for app '${appId}' in community '${communityId}'
4568
+ 📁 Required file: ${readmeFileName} (local file in your microservice directory)
4569
+ ▶️ Pass it explicitly: descix microservice register -r ./${readmeFileName}
4570
+ (or re-run with --skip-readme-check to register without tool discovery)
3465
4571
 
3466
4572
  This README is vectorized and used by the \`tell_me_how\` command to help
3467
4573
  users and AI agents discover your service's capabilities.
@@ -3537,7 +4643,7 @@ mcpCommand
3537
4643
  try {
3538
4644
  await mcpCommands.init();
3539
4645
  } catch (error) {
3540
- process.exit(1);
4646
+ fail(error);
3541
4647
  }
3542
4648
  });
3543
4649
 
@@ -3549,7 +4655,7 @@ mcpCommand
3549
4655
  try {
3550
4656
  await mcpCommands.test(options);
3551
4657
  } catch (error) {
3552
- process.exit(1);
4658
+ fail(error);
3553
4659
  }
3554
4660
  });
3555
4661
 
@@ -3560,7 +4666,7 @@ mcpCommand
3560
4666
  try {
3561
4667
  await mcpCommands.config();
3562
4668
  } catch (error) {
3563
- process.exit(1);
4669
+ fail(error);
3564
4670
  }
3565
4671
  });
3566
4672
 
@@ -3571,6 +4677,66 @@ mcpCommand
3571
4677
  try {
3572
4678
  await mcpCommands.quickstart();
3573
4679
  } catch (error) {
4680
+ fail(error);
4681
+ }
4682
+ });
4683
+
4684
+ mcpCommand
4685
+ .command('execute')
4686
+ .description('Execute a registered MCP tool by name with JSON parameters')
4687
+ .requiredOption('--tool <name>', 'Tool/command name (e.g., beast_get_initiatives, beast_update_stream)')
4688
+ .option('--params <json>', 'JSON parameters for the tool', '{}')
4689
+ .option('-a, --app <app_id>', 'App context (sets app_id in params if not already present)')
4690
+ .option('--json', 'Output raw JSON response')
4691
+ .action(async (options) => {
4692
+ try {
4693
+ const apiClient = new DeSciXApiClient();
4694
+ await apiClient.initialize();
4695
+ await requireAuth(apiClient);
4696
+
4697
+ // Parse JSON params
4698
+ let params;
4699
+ try {
4700
+ params = JSON.parse(options.params);
4701
+ } catch (parseError) {
4702
+ console.error(chalk.red(`Invalid JSON in --params: ${parseError.message}`));
4703
+ console.error(chalk.gray('Example: --params \'{"initiative_id": "agentic-memory"}\''));
4704
+ process.exit(1);
4705
+ }
4706
+
4707
+ // Inject app_id if provided via --app flag and not already in params
4708
+ if (options.app && !params.app_id) {
4709
+ params.app_id = options.app;
4710
+ }
4711
+
4712
+ progress(options, chalk.gray(`Executing ${options.tool}...`));
4713
+
4714
+ const response = await apiClient.invoke(options.tool, params);
4715
+
4716
+ if (options.json) {
4717
+ // Raw JSON output for piping/scripting
4718
+ console.log(JSON.stringify(response, null, 2));
4719
+ } else {
4720
+ // Human-readable output
4721
+ const result = response.message || response;
4722
+
4723
+ if (result.success !== undefined) {
4724
+ console.log(chalk.green(`\nSuccess: ${result.message || 'Command executed successfully'}`));
4725
+ } else {
4726
+ console.log(chalk.green('\nResult:'));
4727
+ }
4728
+
4729
+ // Pretty-print the result, excluding verbose fields
4730
+ const displayResult = typeof result === 'object' ? result : { response: result };
4731
+ console.log(chalk.white(JSON.stringify(displayResult, null, 2)));
4732
+ }
4733
+
4734
+ console.log('');
4735
+ } catch (error) {
4736
+ console.error(chalk.red(`Error: ${error.message}`));
4737
+ if (error.response) {
4738
+ console.error(chalk.gray(JSON.stringify(error.response, null, 2)));
4739
+ }
3574
4740
  process.exit(1);
3575
4741
  }
3576
4742
  });
@@ -3581,7 +4747,7 @@ program
3581
4747
  .command('tell-me-how')
3582
4748
  .description('Discover platform tools using natural language (Intelligent MCP Mesh)')
3583
4749
  .argument('<question>', 'What do you want to accomplish? Use natural language.')
3584
- .option('-s, --scope <scope>', 'Search scope: project, entitlements (default), discovery', 'entitlements')
4750
+ .option('-s, --scope <scope>', 'Search scope (validated against the published tell_me_how enum): bootstrap, artifact, project, entitlements (default), discovery', 'entitlements')
3585
4751
  .option('-j, --json', 'Output raw JSON response')
3586
4752
  .action(async (question, options) => {
3587
4753
  try {
@@ -3590,52 +4756,59 @@ program
3590
4756
 
3591
4757
  const { scope, json: jsonOutput } = options;
3592
4758
 
3593
- // Validate scope
3594
- const validScopes = ['project', 'entitlements', 'discovery'];
4759
+ // Validate scope against the PUBLISHED enum, never a hand-copy. This list used to be a
4760
+ // third mirror (['project','entitlements','discovery']) of a contract owned in
4761
+ // @descix/platform-api/mcp-tools, and on 2026-08-18 all three copies disagreed: the handler
4762
+ // knew 6 scopes, the published enum 4, this CLI 3 — so 'artifact' and 'bootstrap' were
4763
+ // rejected here despite working on the wire. One owner, read at runtime.
4764
+ const { NATIVE_MCP_TOOLS } = await import('@descix/platform-api/mcp-tools');
4765
+ const validScopes = NATIVE_MCP_TOOLS
4766
+ .find(t => t.name === 'tell_me_how')?.inputSchema?.properties?.scope?.enum;
4767
+ if (!Array.isArray(validScopes) || validScopes.length === 0) {
4768
+ console.error(chalk.red("tell_me_how scope enum missing from @descix/platform-api/mcp-tools — cannot validate."));
4769
+ process.exit(1);
4770
+ }
3595
4771
  if (!validScopes.includes(scope)) {
3596
4772
  console.error(chalk.red(`Invalid scope '${scope}'. Must be one of: ${validScopes.join(', ')}`));
3597
4773
  process.exit(1);
3598
4774
  }
3599
4775
 
3600
- // Get project context for 'project' scope
4776
+ // Project context for 'project' scope, read from the v2.1 workspace.
4777
+ //
4778
+ // This previously read `primaryCommunity`, `directoryMappings` and `defaultContext` — all
4779
+ // v1-format keys. A v2.1 workspace ({version, workspaceRoot, type, env{...}, driveConfig})
4780
+ // has NONE of them, so this always produced {community_ids:[], app_ids:[]} and the server
4781
+ // rejected every call with "project scope requires project_context with community_ids or
4782
+ // app_ids". `--scope project` was dead on this surface for exactly as long as v2.1 has been
4783
+ // the format.
4784
+ //
4785
+ // v2.1 carries APP IDS ONLY (env.platform.appId, env.products[].appId) — no community ids.
4786
+ // We therefore send app_ids alone and let the server resolve ownership from Products.
4787
+ // Deriving a community_id by splitting an app_id would violate the platform invariant that
4788
+ // app ids are OPAQUE to routing and lookups.
3601
4789
  let project_context = null;
3602
4790
  if (scope === 'project') {
4791
+ let wsConfig;
3603
4792
  try {
3604
4793
  const { WorkspaceConfig } = await import('../lib/workspace-config.js');
3605
- const wsConfig = await WorkspaceConfig.load(process.cwd());
3606
- if (wsConfig) {
3607
- // Extract community_ids from primaryCommunity and directoryMappings
3608
- const communityIds = new Set();
3609
- const appIds = new Set();
3610
-
3611
- if (wsConfig.primaryCommunity) {
3612
- communityIds.add(wsConfig.primaryCommunity);
3613
- }
3614
-
3615
- // Extract from directoryMappings
3616
- for (const mapping of Object.values(wsConfig.directoryMappings || {})) {
3617
- if (mapping.communityId) communityIds.add(mapping.communityId);
3618
- if (mapping.appId) appIds.add(mapping.appId);
3619
- }
3620
-
3621
- // Extract from defaultContext
3622
- if (wsConfig.defaultContext?.communityId) {
3623
- communityIds.add(wsConfig.defaultContext.communityId);
3624
- }
3625
- if (wsConfig.defaultContext?.appId) {
3626
- appIds.add(wsConfig.defaultContext.appId);
3627
- }
3628
-
3629
- project_context = {
3630
- community_ids: Array.from(communityIds),
3631
- app_ids: Array.from(appIds)
3632
- };
3633
- }
4794
+ wsConfig = await WorkspaceConfig.load(process.cwd());
3634
4795
  } catch (e) {
3635
4796
  console.error(chalk.yellow(`⚠️ Could not read .descix/workspace.json for project scope`));
3636
4797
  console.error(chalk.gray(` Run 'descix init' first, or use --scope entitlements`));
3637
4798
  process.exit(1);
3638
4799
  }
4800
+ const appIds = new Set();
4801
+ if (wsConfig?.env?.platform?.appId) appIds.add(wsConfig.env.platform.appId);
4802
+ for (const product of wsConfig?.env?.products || []) {
4803
+ if (product?.appId) appIds.add(product.appId);
4804
+ }
4805
+ // Fail LOUD rather than sending an empty context the server can only reject.
4806
+ if (appIds.size === 0) {
4807
+ console.error(chalk.red("project scope: no apps found in .descix/workspace.json (env.platform.appId / env.products[].appId)."));
4808
+ console.error(chalk.gray(" Use --scope entitlements, or add products to the workspace."));
4809
+ process.exit(1);
4810
+ }
4811
+ project_context = { app_ids: Array.from(appIds) };
3639
4812
  }
3640
4813
 
3641
4814
  console.log(chalk.cyan(`\n🔍 Searching for tools... (scope: ${scope})\n`));
@@ -3707,78 +4880,19 @@ program
3707
4880
  }
3708
4881
  });
3709
4882
 
3710
- // ============ Folder Commands ============
3711
-
3712
- const folderCommand = program
3713
- .command('folder')
3714
- .description('Drive folder management');
3715
-
3716
- folderCommand
3717
- .command('set')
3718
- .description('Set folder ID for an entity')
3719
- .option('--user <folderId>', 'Set user base folder')
3720
- .option('--community <communityId>', 'Set community folder (requires folderId positional)')
3721
- .option('--app <communityApp>', 'Set app folder: "community app" (requires folderId positional)')
3722
- .option('--kb <communityAppKb>', 'Set KB folder: "community app kb" (requires folderId positional)')
3723
- .argument('[folderId]', 'Folder ID (for community/app/kb)')
3724
- .action(async (folderId, options) => {
3725
- try {
3726
- await folderCommands.setFolder({ ...options, folderId });
3727
- } catch (error) {
3728
- process.exit(1);
3729
- }
3730
- });
3731
-
3732
- folderCommand
3733
- .command('get')
3734
- .description('Get folder info for an entity')
3735
- .option('--user', 'Get user base folder')
3736
- .option('--community <communityId>', 'Get community folder')
3737
- .option('--app <communityApp>', 'Get app folder: "community app"')
3738
- .option('--kb <communityAppKb>', 'Get KB folder: "community app kb"')
3739
- .action(async (options) => {
3740
- try {
3741
- await folderCommands.getFolder(options);
3742
- } catch (error) {
3743
- process.exit(1);
3744
- }
3745
- });
3746
-
3747
- folderCommand
3748
- .command('allocate')
3749
- .description('Allocate a new folder for an entity')
3750
- .option('--app <communityApp>', 'Allocate app folder: "community app"')
3751
- .option('--kb <communityAppKb>', 'Allocate KB folder: "community app kb"')
3752
- .option('-n, --name <name>', 'Custom folder name')
3753
- .action(async (options) => {
3754
- try {
3755
- await folderCommands.allocateFolder(options);
3756
- } catch (error) {
3757
- process.exit(1);
3758
- }
3759
- });
3760
-
3761
- folderCommand
3762
- .command('validate <folderId>')
3763
- .description('Validate folder access')
3764
- .action(async (folderId, options) => {
3765
- try {
3766
- await folderCommands.validateFolder(folderId, options);
3767
- } catch (error) {
3768
- process.exit(1);
3769
- }
3770
- });
3771
-
3772
4883
  // ============ Update Commands (Context-Driven) ============
3773
4884
 
3774
4885
  const updateCommand = program
3775
4886
  .command('update')
3776
- .description('Context-driven resource sync (auto-detects from workspace)')
3777
- .argument('[type]', 'Update type: app, kb, site, all (auto-detects if not specified)')
4887
+ // `update kb` is REMOVED — kb sync has exactly one surface, `descix kb corpus sync`.
4888
+ // `update app` and `update site` are NOT kb-sync surfaces and are untouched, so the
4889
+ // `update` verb itself stays registered. `update all`/`auto` keep doing app and site and
4890
+ // no longer touch the KB; they print where kb sync went rather than failing, because
4891
+ // failing them would delete non-kb functionality this change never targeted.
4892
+ .description('Context-driven resource sync for app and site (auto-detects from workspace). For knowledge bases use `descix kb corpus sync`.')
4893
+ .argument('[type]', 'Update type: app, site, all (auto-detects if not specified)')
3778
4894
  .option('-c, --community <id>', 'Community ID (optional; app_id sufficient for Unified Registry)')
3779
4895
  .option('-a, --app <id>', 'App ID (globally unique; required if not in app directory)')
3780
- .option('--kb <name>', 'Specific KB to update (for kb type)')
3781
- .option('--stage1', 'KB: Local to Drive only (skip vectorization)')
3782
4896
  .option('--preview', 'Site: Deploy to preview URL')
3783
4897
  .option('--build <cmd>', 'Site: Run build command first')
3784
4898
  .option('--full', 'Force full sync (ignore delta)')
@@ -3790,7 +4904,7 @@ const updateCommand = program
3790
4904
  await updateCommands.updateApp(options);
3791
4905
  break;
3792
4906
  case 'kb':
3793
- await updateCommands.updateKB({ ...options, stage1Only: options.stage1 });
4907
+ refuseRetiredKbSync('descix update kb', chalk.red);
3794
4908
  break;
3795
4909
  case 'site':
3796
4910
  await updateCommands.updateSite({
@@ -3799,14 +4913,19 @@ const updateCommand = program
3799
4913
  });
3800
4914
  break;
3801
4915
  case 'all':
4916
+ console.log(chalk.gray(` (knowledge bases are not included: use \`${CANONICAL_KB_SYNC}\`)`));
3802
4917
  await updateCommands.updateAll(options);
3803
4918
  break;
3804
4919
  default:
3805
- // Auto-detect
4920
+ // Auto-detect. NO blanket "KBs are excluded" line here: auto resolves to exactly
4921
+ // one target, and if that target is the KB the user gets the refusal by name.
4922
+ // Printing the advisory first would announce an exclusion and then refuse for
4923
+ // that exact reason — an advisory that fires on correct behaviour trains people
4924
+ // to ignore advisories.
3806
4925
  await updateCommands.updateAuto(options);
3807
4926
  }
3808
4927
  } catch (error) {
3809
- process.exit(1);
4928
+ fail(error);
3810
4929
  }
3811
4930
  });
3812
4931
 
@@ -3814,15 +4933,22 @@ const updateCommand = program
3814
4933
 
3815
4934
  program
3816
4935
  .command('serve')
3817
- .description('Start the unified local gateway (reverse-proxy for PWA, Core, Powch, microservices)')
3818
- .option('-p, --port <port>', 'Gateway port', '5173')
4936
+ .description('Start the unified local gateway (one HTTPS origin: App Shell at /, your app at /p/<app>, /apifront to the API)')
4937
+ .option('-p, --port <port>', 'Gateway port for this run (default: env.gateway.port, else 5173)')
3819
4938
  .option('-w, --workspace <path>', 'Workspace root override')
4939
+ .option('--site-url <url>', 'App Shell target for this run (default: env.siteUrl, a local platform site, else the API origin)')
4940
+ .option('-a, --app <id>', 'App to serve standalone (default: detected from the directory you are standing in)')
3820
4941
  .action(async (options) => {
3821
4942
  try {
3822
4943
  const { runServe } = await import('../lib/commands/serve.js');
3823
4944
  await runServe({
3824
- port: parseInt(options.port, 10),
4945
+ // No default here: an unset flag must stay UNSET so the gateway's
4946
+ // resolveGatewayPort can see env.gateway.port. A CLI-side default would
4947
+ // shadow the workspace's own port — the map/server disagreement bug.
4948
+ port: options.port !== undefined ? parseInt(options.port, 10) : undefined,
3825
4949
  workspaceRoot: options.workspace || process.cwd(),
4950
+ siteUrl: options.siteUrl,
4951
+ app: options.app,
3826
4952
  });
3827
4953
  } catch (error) {
3828
4954
  console.error(chalk.red('Gateway error:', error.message));
@@ -3836,7 +4962,6 @@ program
3836
4962
  .command('quickstart')
3837
4963
  .description('One-command setup: auth → workspace → agent files → MCP config')
3838
4964
  .option('-u, --url <url>', 'API URL override')
3839
- .option('--dev', 'Use development server (https://localhost:4000)')
3840
4965
  .action(async (options) => {
3841
4966
  const { generateAgentFiles, generateMcpConfig } = await import('../lib/agent-files.js');
3842
4967
  const { WalletFileManager } = await import('../lib/wallet-file.js');
@@ -3859,7 +4984,6 @@ program
3859
4984
  if (needsLogin) {
3860
4985
  const loginOptions = {};
3861
4986
  if (options.url) loginOptions.url = options.url;
3862
- if (options.dev) loginOptions.dev = true;
3863
4987
  await authCommands.loginDevice(loginOptions);
3864
4988
  }
3865
4989