@descix/cli 1.0.1 → 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 (100) 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 +19 -6
  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/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. package/templates/scaffolds/site/styles.css +0 -123
@@ -1,5 +1,35 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
+ import { ENV_ORIGINS } from '@descix/app-sdk/dev';
4
+ import { resolveOrigin } from './origin.js';
5
+
6
+ /**
7
+ * ONE OWNER for turning a workspace-relative localPath into an absolute path.
8
+ *
9
+ * This was resolved four different ways across this file and its callers — three with
10
+ * path.join and one with path.resolve — and the two treatments DISAGREE on an absolute
11
+ * localPath. path.join('/ws', '/ws/app') yields '/ws/ws/app' (the "doubling"); path.resolve
12
+ * yields '/ws/app'. Concretely, getAppByAppId() and detectContext() both derive from the same
13
+ * _appIdToConfig entry, so an absolute localPath made detectContext match a directory that
14
+ * getAppByAppId reported as a doubled path that does not exist.
15
+ *
16
+ * FAIL LOUD rather than quietly absorbing it. An absolute localPath in workspace.json is a
17
+ * PORTABILITY defect, not a valid configuration: workspace.json is shared across checkouts and
18
+ * machines, and a machine-specific absolute path cannot survive either. Silently resolving it
19
+ * would make the doubling disappear while leaving the unshareable config in place. Name the
20
+ * offending entry and the config verb that fixes it.
21
+ */
22
+ export function resolveWorkspacePath(workspaceRoot, localPath, label = 'app') {
23
+ if (!localPath) return null;
24
+ if (path.isAbsolute(localPath)) {
25
+ throw new Error(
26
+ `workspace.json: localPath for '${label}' is an absolute path (${localPath}). ` +
27
+ `localPath must be relative to the workspace root so the workspace is portable across ` +
28
+ `checkouts and machines. Fix it with the config verb — never by hand-editing workspace.json.`
29
+ );
30
+ }
31
+ return path.resolve(workspaceRoot, localPath);
32
+ }
3
33
 
4
34
  /**
5
35
  * WorkspaceConfig - Manages workspace-specific configuration
@@ -18,16 +48,11 @@ export class WorkspaceConfig {
18
48
  this.version = config.version || '1.0';
19
49
  this.type = config.type || 'workspace'; // 'workspace', 'community', or 'app'
20
50
 
21
- // Legacy fields (v1 compatibility)
22
- this.primaryCommunity = config.primaryCommunity;
23
- this.directoryMappings = config.directoryMappings || {};
24
- this.additionalContexts = config.additionalContexts || [];
51
+ // Legacy fields (kept for save() PWA response conversion compatibility)
25
52
  this.defaultContext = config.defaultContext;
26
53
  this.apiUrl = config.apiUrl;
27
54
  this.environment = config.environment;
28
-
29
- // V2 community tracking (root workspace)
30
- this.communities = config.communities || {};
55
+ this.directoryMappings = config.directoryMappings || {};
31
56
 
32
57
  // V2.1 Product tracking (Unified Registry)
33
58
  this.products = config.products || {};
@@ -89,7 +114,7 @@ export class WorkspaceConfig {
89
114
  // env.platform + env.products (primary for manually crafted workspace)
90
115
  const fromEnv = this._appIdToConfig[appId];
91
116
  if (fromEnv) {
92
- const absPath = path.join(this.workspaceRoot, fromEnv.localPath);
117
+ const absPath = resolveWorkspacePath(this.workspaceRoot, fromEnv.localPath, appId);
93
118
  return {
94
119
  localPath: fromEnv.localPath,
95
120
  absolutePath: absPath,
@@ -98,33 +123,29 @@ export class WorkspaceConfig {
98
123
  };
99
124
  }
100
125
 
101
- // Legacy: products object
102
- const product = this.products[appId];
103
- if (product?.localPath) {
104
- const absPath = product.absolutePath || path.join(this.workspaceRoot, product.localPath);
105
- return {
106
- localPath: product.localPath,
107
- absolutePath: absPath,
108
- communityId: product.context?.community || null,
109
- kbId: product.kbId || 'General'
110
- };
111
- }
126
+ return null;
127
+ }
112
128
 
113
- // Legacy: communities.apps
114
- for (const [, comm] of Object.entries(this.communities || {})) {
115
- const app = comm?.apps?.[appId];
116
- if (app?.localPath) {
117
- const absPath = app.absolutePath || path.join(this.workspaceRoot, app.localPath);
118
- return {
119
- localPath: app.localPath,
120
- absolutePath: absPath,
121
- communityId: null, // Will be derived from Products on backend
122
- kbId: app.kbId || 'General'
123
- };
124
- }
125
- }
129
+ /**
130
+ * Get the absolute path to an app's site/ directory
131
+ * @param {string} appId - App identifier
132
+ * @returns {string|null} Absolute path to site/ or null if app not mapped
133
+ */
134
+ getSitePath(appId) {
135
+ const appConfig = this.getAppByAppId(appId);
136
+ if (!appConfig?.absolutePath) return null;
137
+ return path.join(appConfig.absolutePath, 'site');
138
+ }
126
139
 
127
- return null;
140
+ /**
141
+ * Get the absolute path to an app's microservice/ directory
142
+ * @param {string} appId - App identifier
143
+ * @returns {string|null} Absolute path to microservice/ or null if app not mapped
144
+ */
145
+ getMicroservicePath(appId) {
146
+ const appConfig = this.getAppByAppId(appId);
147
+ if (!appConfig?.absolutePath) return null;
148
+ return path.join(appConfig.absolutePath, 'microservice');
128
149
  }
129
150
 
130
151
  /**
@@ -178,24 +199,30 @@ export class WorkspaceConfig {
178
199
  */
179
200
  static async load(startDir = process.cwd()) {
180
201
  // First, find workspace root by searching upward
181
- // If startDir is provided, use it. If not, use process.cwd()
182
202
  const workspaceRoot = await WorkspaceConfig.findWorkspaceRoot(startDir);
183
203
  if (!workspaceRoot) {
184
- // Fallback: Check if we are running from within a package in the repo
185
- // and try to find the root by looking for .descix in parent directories
186
- // This is redundant with findWorkspaceRoot but let's be explicit about the error
187
204
  throw new Error(
188
205
  'Workspace not configured.\n' +
189
206
  'Run "npx descix init" first to initialize your workspace.'
190
207
  );
191
208
  }
192
-
209
+
193
210
  const configPath = path.join(workspaceRoot, '.descix', 'workspace.json');
194
211
  try {
195
212
  const data = await fs.readFile(configPath, 'utf-8');
196
213
  const parsed = JSON.parse(data);
214
+
215
+ // v1 format hard-error: has communities block but no env block
216
+ if (parsed.communities && !parsed.env) {
217
+ throw new Error(
218
+ 'v1 workspace format is not supported. Migrate to v2.1.\n' +
219
+ 'Delete .descix/workspace.json and re-run "descix app init" to create a v2.1 workspace.'
220
+ );
221
+ }
222
+
197
223
  return new WorkspaceConfig(parsed, workspaceRoot);
198
224
  } catch (error) {
225
+ if (error.message.includes('v1 workspace format')) throw error;
199
226
  throw new Error(
200
227
  'Workspace not configured.\n' +
201
228
  'Run "npx descix init" first to initialize your workspace.'
@@ -220,9 +247,7 @@ export class WorkspaceConfig {
220
247
 
221
248
  /**
222
249
  * Save workspace configuration in v2.1 format.
223
- * Always writes env.platform / env.products structure — never v1 communities.
224
- * If loaded from a v1 communities format (e.g. PWA device_check_status response),
225
- * auto-converts communities.apps → env.products on write.
250
+ * Always writes env.platform / env.products structure.
226
251
  * @param {string} [workspaceRoot] - Workspace root directory (uses stored root if not provided)
227
252
  * @returns {Promise<string>} Path to saved config
228
253
  */
@@ -238,25 +263,8 @@ export class WorkspaceConfig {
238
263
 
239
264
  this.workspaceRoot = root;
240
265
 
241
- // Build env block for v2.1 output.
242
- // Native v2.1 path: this.env is already populated from env.platform/env.products.
243
- // Legacy conversion path: this.env is absent but communities are populated (v1 PWA response).
244
- let envBlock = (this.env && Object.keys(this.env).length > 0) ? this.env : null;
245
-
246
- if (!envBlock && Object.keys(this.communities || {}).length > 0) {
247
- const products = [];
248
- for (const [, community] of Object.entries(this.communities)) {
249
- for (const [appId, app] of Object.entries(community.apps || {})) {
250
- if (app.localPath) {
251
- products.push({ appId, localPath: app.localPath, kbId: app.kbId || 'General' });
252
- }
253
- }
254
- }
255
- envBlock = { products };
256
- // Update in-memory state so callers see a consistent env after save
257
- this.env = envBlock;
258
- this._appIdToConfig = this._buildAppIdMap({ env: this.env, workspaceRoot: this.workspaceRoot });
259
- }
266
+ // Build env block for v2.1 output
267
+ const envBlock = (this.env && Object.keys(this.env).length > 0) ? this.env : null;
260
268
 
261
269
  const configData = {
262
270
  version: '2.1',
@@ -279,46 +287,17 @@ export class WorkspaceConfig {
279
287
  */
280
288
  computeAbsolutePaths(workspaceRoot) {
281
289
  const absWorkspaceRoot = path.resolve(workspaceRoot);
282
-
283
- for (const [communityId, community] of Object.entries(this.communities || {})) {
284
- // Compute community absolute path
285
- if (community.localPath) {
286
- community.absolutePath = path.join(absWorkspaceRoot, community.localPath);
287
- }
288
-
289
- // Compute app absolute paths
290
- for (const [appId, app] of Object.entries(community.apps || {})) {
291
- if (app.localPath) {
292
- app.absolutePath = path.join(absWorkspaceRoot, app.localPath);
293
- }
294
- }
295
- }
296
290
 
297
291
  // Compute absolute paths for products
298
292
  for (const [productId, product] of Object.entries(this.products || {})) {
299
293
  if (product.localPath) {
300
- product.absolutePath = path.join(absWorkspaceRoot, product.localPath);
294
+ product.absolutePath = resolveWorkspacePath(absWorkspaceRoot, product.localPath, productId);
301
295
  }
302
296
  }
303
297
  }
304
298
 
305
- // ============ Community Tracking Methods ============
306
-
307
- /**
308
- * Register a community in the root workspace
309
- * @param {string} communityId - Community identifier
310
- * @param {Object} communityConfig - Community configuration
311
- * @param {string} communityConfig.localPath - Local folder path
312
- * @param {string} communityConfig.tokenSymbol - Token symbol
313
- * @param {Object} [communityConfig.apps] - Apps in this community
314
- * @returns {boolean} Success status
315
- */
316
- registerCommunity(communityId, communityConfig) {
317
- // V2: Community identity is server-authoritative (Products registry).
318
- // workspace.json does not track communities. No-op.
319
- return true;
320
- }
321
-
299
+ // ============ App Registration Methods ============
300
+
322
301
  /**
323
302
  * Register an app in the v2.1 workspace (env.platform or env.products).
324
303
  * community_id is server-authoritative — not stored in workspace.json.
@@ -337,11 +316,18 @@ export class WorkspaceConfig {
337
316
  if (!this.env) this.env = {};
338
317
  if (!Array.isArray(this.env.products)) this.env.products = [];
339
318
 
319
+ // communityId is STORED, not just validated. It was previously accepted as a parameter and
320
+ // silently discarded, so `descix init -c egpt` wrote no trace of `egpt` anywhere — and
321
+ // `workspace-identity.js` (this entry's reader) then had no community to report, which is
322
+ // how the generated agent files named nobody's community. Writer and reader are a pair:
323
+ // the key order in `readIdentity()` mirrors the shape written here.
340
324
  const entry = { appId, localPath: appConfig.localPath, kbId: appConfig.kbId || 'General' };
325
+ if (communityId) entry.communityId = communityId;
341
326
 
342
327
  if (this.env.platform?.appId === appId) {
343
328
  this.env.platform.localPath = appConfig.localPath;
344
329
  if (appConfig.kbId) this.env.platform.kbId = appConfig.kbId;
330
+ if (communityId) this.env.platform.communityId = communityId;
345
331
  } else {
346
332
  const idx = this.env.products.findIndex(p => p.appId === appId);
347
333
  if (idx >= 0) {
@@ -356,85 +342,6 @@ export class WorkspaceConfig {
356
342
  return true;
357
343
  }
358
344
 
359
- /**
360
- * Get community configuration by ID
361
- * @param {string} communityId - Community identifier
362
- * @returns {Object|null} Community configuration or null
363
- */
364
- getCommunity(communityId) {
365
- return this.communities[communityId] || null;
366
- }
367
-
368
- /**
369
- * Get app configuration from a community
370
- * @param {string} communityId - Community identifier
371
- * @param {string} appId - App identifier
372
- * @returns {Object|null} App configuration or null
373
- */
374
- getApp(communityId, appId) {
375
- const community = this.communities[communityId];
376
- if (!community || !community.apps) return null;
377
- return community.apps[appId] || null;
378
- }
379
-
380
- /**
381
- * List all registered communities
382
- * @returns {Array} Array of community IDs
383
- */
384
- listCommunities() {
385
- return Object.keys(this.communities);
386
- }
387
-
388
- /**
389
- * List all apps in a community
390
- * @param {string} communityId - Community identifier
391
- * @returns {Array} Array of app IDs
392
- */
393
- listApps(communityId) {
394
- const community = this.communities[communityId];
395
- if (!community || !community.apps) return [];
396
- return Object.keys(community.apps);
397
- }
398
-
399
- /**
400
- * Remove a community registration
401
- * @param {string} communityId - Community identifier
402
- * @returns {boolean} Success status
403
- */
404
- unregisterCommunity(communityId) {
405
- if (this.communities[communityId]) {
406
- // Remove directory mappings for all apps
407
- const apps = this.communities[communityId].apps || {};
408
- for (const [appId, appConfig] of Object.entries(apps)) {
409
- if (appConfig.localPath) {
410
- delete this.directoryMappings[appConfig.localPath];
411
- }
412
- }
413
- delete this.communities[communityId];
414
- return true;
415
- }
416
- return false;
417
- }
418
-
419
- /**
420
- * Remove an app registration
421
- * @param {string} communityId - Community identifier
422
- * @param {string} appId - App identifier
423
- * @returns {boolean} Success status
424
- */
425
- unregisterApp(communityId, appId) {
426
- const community = this.communities[communityId];
427
- if (community && community.apps && community.apps[appId]) {
428
- const appConfig = community.apps[appId];
429
- if (appConfig.localPath) {
430
- delete this.directoryMappings[appConfig.localPath];
431
- }
432
- delete community.apps[appId];
433
- return true;
434
- }
435
- return false;
436
- }
437
-
438
345
  /**
439
346
  * Register a product (Unified Registry)
440
347
  * @param {string} productId - Global product identifier
@@ -535,7 +442,7 @@ export class WorkspaceConfig {
535
442
  // env.platform + env.products (Unified Registry - primary)
536
443
  if (wsRoot) {
537
444
  for (const [appId, cfg] of Object.entries(this._appIdToConfig || {})) {
538
- const appPath = path.resolve(wsRoot, cfg.localPath);
445
+ const appPath = resolveWorkspacePath(wsRoot, cfg.localPath, appId);
539
446
  if (cwd.startsWith(appPath)) {
540
447
  return {
541
448
  appId,
@@ -546,28 +453,11 @@ export class WorkspaceConfig {
546
453
  }
547
454
  }
548
455
 
549
- for (const [commId, comm] of Object.entries(this.communities || {})) {
550
- for (const [appId, app] of Object.entries(comm.apps || {})) {
551
- let appPath = app.absolutePath;
552
- if (!appPath && app.localPath && wsRoot) {
553
- appPath = path.join(wsRoot, app.localPath);
554
- }
555
-
556
- if (appPath && cwd.startsWith(appPath)) {
557
- return {
558
- communityId: commId,
559
- appId,
560
- kbId: app.kbId || 'General'
561
- };
562
- }
563
- }
564
- }
565
-
566
456
  // products object (legacy)
567
457
  for (const [productId, product] of Object.entries(this.products || {})) {
568
458
  let productPath = product.absolutePath;
569
459
  if (!productPath && product.localPath && wsRoot) {
570
- productPath = path.join(wsRoot, product.localPath);
460
+ productPath = resolveWorkspacePath(wsRoot, product.localPath, productId);
571
461
  }
572
462
 
573
463
  if (productPath && cwd.startsWith(productPath)) {
@@ -634,16 +524,303 @@ export class WorkspaceConfig {
634
524
  }
635
525
 
636
526
  /**
637
- * Get API URL for the current workspace.
638
- * In DEV mode, derives from env.platform.microservice.port (v2.1 format).
639
- * Eliminates the need for DESCIX_API_URL in local dev.
640
- * @returns {string}
527
+ * Get the API origin this workspace is configured for, or NULL when none is configured.
528
+ *
529
+ * Priority: env.apiUrl > legacy this.apiUrl. There is NO third branch. This used to end in
530
+ * `return DEFAULT_API_URL` — the shipped PROD origin — which meant an unconfigured workspace
531
+ * was indistinguishable from one that had deliberately chosen production, and every caller
532
+ * received a prod origin the developer had never picked. Resolution and the fail-loud live in
533
+ * `lib/origin.js`; this method only reports what THIS workspace file says.
534
+ *
535
+ * There is likewise no environment-name-to-localhost derivation: an environment names a cloud
536
+ * environment, and localhost is a URL you set explicitly
537
+ * (`descix config set-env dev --url https://localhost:4000`, or env.apiUrl).
538
+ *
539
+ * @returns {string|null} the configured origin, or null if this workspace names none
641
540
  */
642
541
  getApiUrl() {
643
- if (this.apiUrl) return this.apiUrl;
644
- const platformPort = this.env?.platform?.microservice?.port;
645
- if (platformPort && this.env?.environment === 'DEV') return `https://localhost:${platformPort}`;
646
- return 'https://descix.net';
542
+ // Consumes the ONE origin owner rather than re-deriving the precedence or re-spelling the
543
+ // declared default. Under (A') an unconfigured workspace resolves to the declared default
544
+ // PROD; the SOURCE that distinguishes "chose prod" from "chose nothing" is carried by
545
+ // resolveOrigin() and printed by the api-client, never by a null returned from here.
546
+ return resolveOrigin({
547
+ workspaceEnvApiUrl: this.env?.apiUrl,
548
+ legacyApiUrl: this.apiUrl,
549
+ }).origin;
550
+ }
551
+
552
+ /**
553
+ * Known environment URL mapping.
554
+ * Shared between `descix config set-env` and the `--env` global flag.
555
+ * Origins come from the ONE owner (@descix/app-sdk/dev envOrigins); this map
556
+ * adds only the CLI's own concern, the Secret Manager label.
557
+ * @type {Object.<string, {url: string, secretLabel: string}>}
558
+ */
559
+ static ENV_MAP = {
560
+ dev: { url: ENV_ORIGINS.dev, secretLabel: 'DEBUG' },
561
+ demo: { url: ENV_ORIGINS.demo, secretLabel: 'DEMO' },
562
+ prod: { url: ENV_ORIGINS.prod, secretLabel: 'LIVE' },
563
+ };
564
+
565
+ /**
566
+ * Set a WORKSPACE-LEVEL env key (env.gateway.port, env.devCerts, env.powchUrl,
567
+ * env.siteUrl) — the keys that describe the workspace itself rather than one app.
568
+ *
569
+ * These all previously had NO CLI verb, so the only way to set them was to hand-edit
570
+ * .descix/workspace.json (redteam G-6). Hand-editing a generated file is how the
571
+ * shape drifts and how a developer ends up debugging their own typo, which is the
572
+ * opposite of "it should all just work".
573
+ *
574
+ * Pass null to REMOVE a key; empty parent objects are cleaned up so the file never
575
+ * accumulates `"devCerts": {}` noise.
576
+ *
577
+ * @param {string} dottedKey - one of: gateway.port | devCerts.dir | devCerts.cert | devCerts.key | powchUrl | siteUrl
578
+ * @param {string|number|null} value
579
+ * @returns {Promise<string>} Path to saved config (from save())
580
+ */
581
+ async setEnvKey(dottedKey, value) {
582
+ const ALLOWED = ['gateway.port', 'devCerts.dir', 'devCerts.cert', 'devCerts.key', 'powchUrl', 'siteUrl'];
583
+ if (!ALLOWED.includes(dottedKey)) {
584
+ throw new Error(
585
+ `"${dottedKey}" is not a workspace-level env key.\n` +
586
+ ` Settable here: ${ALLOWED.join(', ')}\n` +
587
+ ' Per-app keys have their own verbs: descix app set-site / set-port / set-localpath.'
588
+ );
589
+ }
590
+
591
+ if (!this.env) this.env = {};
592
+ const parts = dottedKey.split('.');
593
+
594
+ if (value === null || value === undefined) {
595
+ if (parts.length === 1) {
596
+ delete this.env[parts[0]];
597
+ } else {
598
+ const parent = this.env[parts[0]];
599
+ if (parent) {
600
+ delete parent[parts[1]];
601
+ if (Object.keys(parent).length === 0) delete this.env[parts[0]];
602
+ }
603
+ }
604
+ return this.save();
605
+ }
606
+
607
+ if (parts.length === 1) {
608
+ this.env[parts[0]] = value;
609
+ } else {
610
+ if (!this.env[parts[0]]) this.env[parts[0]] = {};
611
+ this.env[parts[0]][parts[1]] = value;
612
+ }
613
+ return this.save();
614
+ }
615
+
616
+ /**
617
+ * Update an app's site.port in env.products[] (or env.platform if it is the platform app).
618
+ *
619
+ * Pass `null` to remove site.port; if site.{} becomes empty, site.{} is also deleted.
620
+ * Persists via save() at the end — same auto-save pattern as setEnvironment().
621
+ * Hard-fails if appId is not mapped in env.platform or env.products.
622
+ *
623
+ * @param {string} appId - App identifier (must exist in env.platform or env.products)
624
+ * @param {number|string|null} port - Port number to set, or null to remove site.port
625
+ * @returns {Promise<string>} Path to saved config (from save())
626
+ */
627
+ async setSitePort(appId, port) {
628
+ if (!appId) throw new Error('appId is required');
629
+
630
+ // Find the live env entry (platform or products[]) — not a copy
631
+ let entry = null;
632
+ if (this.env?.platform?.appId === appId) {
633
+ entry = this.env.platform;
634
+ } else if (Array.isArray(this.env?.products)) {
635
+ entry = this.env.products.find(p => p.appId === appId) || null;
636
+ }
637
+
638
+ if (!entry) {
639
+ throw new Error(
640
+ `App "${appId}" is not mapped in workspace.json. ` +
641
+ 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
642
+ );
643
+ }
644
+
645
+ if (port === null || port === undefined) {
646
+ // Remove site.port; clean up empty site.{}
647
+ if (entry.site) {
648
+ delete entry.site.port;
649
+ if (Object.keys(entry.site).length === 0) {
650
+ delete entry.site;
651
+ }
652
+ }
653
+ } else {
654
+ if (!entry.site) entry.site = {};
655
+ entry.site.port = port;
656
+ }
657
+
658
+ return this.save();
659
+ }
660
+
661
+ /**
662
+ * Update an app's microservice.port in env.products[] (or env.platform if it is the platform app).
663
+ *
664
+ * Parallel to setSitePort(), but operates on the entry's microservice.{} slot.
665
+ * This is the canonical write path for the microservice port that `descix microservice init`
666
+ * reads (and hard-fails on if missing). Backs the `descix app set-port` command, closing
667
+ * WS-CLI-MESH-ROUTING-GAP without hand-editing workspace.json.
668
+ *
669
+ * Pass `null` to remove microservice.port; if microservice.{} becomes empty, it is also deleted.
670
+ * Persists via save() at the end — same auto-save pattern as setSitePort()/setEnvironment().
671
+ * Hard-fails if appId is not mapped in env.platform or env.products.
672
+ *
673
+ * @param {string} appId - App identifier (must exist in env.platform or env.products)
674
+ * @param {number|string|null} port - Port number to set, or null to remove microservice.port
675
+ * @returns {Promise<string>} Path to saved config (from save())
676
+ */
677
+ async setMicroservicePort(appId, port) {
678
+ if (!appId) throw new Error('appId is required');
679
+
680
+ // Find the live env entry (platform or products[]) — not a copy
681
+ let entry = null;
682
+ if (this.env?.platform?.appId === appId) {
683
+ entry = this.env.platform;
684
+ } else if (Array.isArray(this.env?.products)) {
685
+ entry = this.env.products.find(p => p.appId === appId) || null;
686
+ }
687
+
688
+ if (!entry) {
689
+ throw new Error(
690
+ `App "${appId}" is not mapped in workspace.json. ` +
691
+ 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
692
+ );
693
+ }
694
+
695
+ if (port === null || port === undefined) {
696
+ // Remove microservice.port; clean up empty microservice.{}
697
+ if (entry.microservice) {
698
+ delete entry.microservice.port;
699
+ if (Object.keys(entry.microservice).length === 0) {
700
+ delete entry.microservice;
701
+ }
702
+ }
703
+ } else {
704
+ if (!entry.microservice) entry.microservice = {};
705
+ entry.microservice.port = port;
706
+ }
707
+
708
+ return this.save();
709
+ }
710
+
711
+ /**
712
+ * Update an app's site config in env.products[] (or env.platform if it is the platform app).
713
+ *
714
+ * Parallel to setSitePort()/setMicroservicePort(), but operates on the entry's site.{} slot's
715
+ * static-site fields. This is the canonical write path for site.static — the relative path the
716
+ * dev gateway's staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig:
717
+ * site.static is resolved against the app's localPath; "." means the localPath itself).
718
+ * Backs the `descix app set-site` command, closing the site.static workspace gap without
719
+ * hand-editing workspace.json (the org rule forbids hand edits — CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
720
+ *
721
+ * Mutates site.static and/or site.port. Pass static === null to remove site.static; pass
722
+ * port === null to remove site.port. If site.{} becomes empty after removals it is deleted.
723
+ * Persists via save() at the end — same auto-save pattern as setSitePort()/setMicroservicePort().
724
+ * Hard-fails if appId is not mapped in env.platform or env.products.
725
+ *
726
+ * @param {string} appId - App identifier (must exist in env.platform or env.products)
727
+ * @param {Object} fields - Fields to set on site.{}
728
+ * @param {string|null} [fields.static] - Relative static-site path to set, or null to remove site.static
729
+ * @param {number|string|null} [fields.port] - Site dev-server port to set, or null to remove site.port
730
+ * @returns {Promise<string>} Path to saved config (from save())
731
+ */
732
+ async setStaticSite(appId, fields = {}) {
733
+ if (!appId) throw new Error('appId is required');
734
+ if (!fields || typeof fields !== 'object') throw new Error('fields object is required');
735
+
736
+ // Find the live env entry (platform or products[]) — not a copy
737
+ let entry = null;
738
+ if (this.env?.platform?.appId === appId) {
739
+ entry = this.env.platform;
740
+ } else if (Array.isArray(this.env?.products)) {
741
+ entry = this.env.products.find(p => p.appId === appId) || null;
742
+ }
743
+
744
+ if (!entry) {
745
+ throw new Error(
746
+ `App "${appId}" is not mapped in workspace.json. ` +
747
+ 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
748
+ );
749
+ }
750
+
751
+ if (!entry.site) entry.site = {};
752
+
753
+ // site.static — set or remove
754
+ if ('static' in fields) {
755
+ if (fields.static === null || fields.static === undefined) {
756
+ delete entry.site.static;
757
+ } else {
758
+ entry.site.static = fields.static;
759
+ }
760
+ }
761
+
762
+ // site.port — set or remove (parallel to setSitePort, for static+devCommand sites)
763
+ if ('port' in fields) {
764
+ if (fields.port === null || fields.port === undefined) {
765
+ delete entry.site.port;
766
+ } else {
767
+ entry.site.port = fields.port;
768
+ }
769
+ }
770
+
771
+ // Clean up an empty site.{} so we never leave a bare {} behind
772
+ if (Object.keys(entry.site).length === 0) {
773
+ delete entry.site;
774
+ }
775
+
776
+ return this.save();
777
+ }
778
+
779
+ /**
780
+ * Persistently set the target environment in workspace.json.
781
+ *
782
+ * Updates env.environment and env.apiUrl, then saves. EVERY environment writes a
783
+ * URL — including dev, which writes the cloud DEV origin. Pointing at a local
784
+ * backend is `--url`: `descix config set-env dev --url https://localhost:4000`.
785
+ * For custom envs, uses --url or defaults to https://{name}.descix.net.
786
+ *
787
+ * @param {string} envName - Environment name (dev, demo, prod, or custom)
788
+ * @param {string|null} [apiUrl] - Explicit API URL override
789
+ * @returns {Promise<{configPath: string, environment: string, apiUrl: string, secretLabel: string}>}
790
+ */
791
+ async setEnvironment(envName, apiUrl = null) {
792
+ const normalized = envName.toLowerCase();
793
+ const known = WorkspaceConfig.ENV_MAP[normalized];
794
+
795
+ let resolvedUrl;
796
+ let secretLabel;
797
+ let envLabel;
798
+
799
+ if (known) {
800
+ resolvedUrl = apiUrl || known.url; // explicit --url overrides even for known envs
801
+ secretLabel = known.secretLabel;
802
+ envLabel = normalized.toUpperCase();
803
+ } else {
804
+ resolvedUrl = apiUrl || `https://${normalized}.descix.net`;
805
+ secretLabel = normalized.toUpperCase();
806
+ envLabel = normalized.toUpperCase();
807
+ }
808
+
809
+ // Update in-memory state
810
+ if (!this.env) this.env = {};
811
+ this.env.environment = envLabel;
812
+
813
+ this.env.apiUrl = resolvedUrl;
814
+
815
+ // Save to disk
816
+ const configPath = await this.save();
817
+
818
+ return {
819
+ configPath,
820
+ environment: envLabel,
821
+ apiUrl: resolvedUrl,
822
+ secretLabel
823
+ };
647
824
  }
648
825
 
649
826
  /**
@@ -665,44 +842,12 @@ export class WorkspaceConfig {
665
842
  */
666
843
  validate() {
667
844
  const errors = [];
668
-
669
- // For v2 root workspace with communities
670
- if (this.version === '2.0' && Object.keys(this.communities).length > 0) {
671
- for (const [communityId, community] of Object.entries(this.communities)) {
672
- if (community.apps) {
673
- for (const [appId, app] of Object.entries(community.apps)) {
674
- if (!app.localPath) {
675
- errors.push(`Community "${communityId}" app "${appId}" missing localPath`);
676
- }
677
- }
678
- }
679
- }
680
- return {
681
- isValid: errors.length === 0,
682
- errors
683
- };
684
- }
685
-
686
- // Legacy v1 validation
687
- if (!this.primaryCommunity) {
688
- errors.push('Missing primaryCommunity');
689
- }
690
-
691
- if (!this.defaultContext) {
692
- errors.push('Missing defaultContext');
693
- } else {
694
- if (!this.defaultContext.communityId) errors.push('defaultContext missing communityId');
695
- if (!this.defaultContext.appId) errors.push('defaultContext missing appId');
696
- if (!this.defaultContext.kbId) errors.push('defaultContext missing kbId');
697
- }
698
-
699
- // Validate directory mappings
700
- for (const [dir, context] of Object.entries(this.directoryMappings)) {
701
- if (!context.communityId) errors.push(`Directory "${dir}" mapping missing communityId`);
702
- if (!context.appId) errors.push(`Directory "${dir}" mapping missing appId`);
703
- if (!context.kbId) errors.push(`Directory "${dir}" mapping missing kbId`);
845
+
846
+ // v2.1 validation: must have env block
847
+ if (!this.env || Object.keys(this.env).length === 0) {
848
+ errors.push('Missing env block — workspace.json must be v2.1 format with env.platform or env.products');
704
849
  }
705
-
850
+
706
851
  return {
707
852
  isValid: errors.length === 0,
708
853
  errors