@descix/cli 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/agent-assets/guides/README.md +1 -1
  3. package/agent-assets/guides/dynamic-site-pattern.md +17 -30
  4. package/agent-assets/guides/knowledge-only-pattern.md +11 -15
  5. package/agent-assets/guides/microservice-pattern.md +12 -14
  6. package/agent-assets/guides/static-site-pattern.md +12 -15
  7. package/agent-assets/instructions/app-asset-validation.md +1 -1
  8. package/agent-assets/instructions/onboarding-flow.md +4 -4
  9. package/agent-assets/reference/app-creation.md +36 -101
  10. package/agent-assets/reference/architecture.md +43 -51
  11. package/agent-assets/reference/local-dev.md +1 -6
  12. package/agent-assets/reference/mcp-integration.md +21 -23
  13. package/agent-assets/reference/workspace-config.md +97 -340
  14. package/bin/descix.js +337 -618
  15. package/lib/api-client.js +30 -22
  16. package/lib/command-visibility.js +213 -0
  17. package/lib/commands/airdrop.js +10 -0
  18. package/lib/commands/auth.js +16 -2
  19. package/lib/commands/config.js +13 -31
  20. package/lib/commands/corpus.js +163 -59
  21. package/lib/commands/health.js +116 -21
  22. package/lib/commands/init.js +17 -45
  23. package/lib/commands/kb.js +7 -0
  24. package/lib/commands/retired-kb-sync.js +5 -27
  25. package/lib/commands/status.js +0 -9
  26. package/lib/core/Chunker.js +1 -1
  27. package/lib/core/CorpusWalker.js +238 -47
  28. package/lib/core/ManifestLoader.js +68 -0
  29. package/lib/core/Syncer.js +147 -46
  30. package/lib/interactive.js +158 -0
  31. package/lib/origin.js +3 -3
  32. package/lib/sdk-assets.js +28 -0
  33. package/lib/verb-invokes.generated.json +233 -0
  34. package/lib/wallet-file.js +36 -37
  35. package/lib/workspace-config.js +266 -199
  36. package/lib/workspace-identity.js +0 -1
  37. package/package.json +7 -3
  38. package/templates/agent-claude.md +1 -3
  39. package/templates/agent-cline.md +1 -2
  40. package/templates/agent-copilot.md +1 -2
  41. package/templates/agent-cursor.md +1 -2
  42. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
  43. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
  44. package/agent-assets/rules/descix_mcp.template.mdc +0 -243
  45. package/lib/commands/briefer/errors.js +0 -88
  46. package/lib/commands/briefer/index.js +0 -319
  47. package/lib/commands/briefer/sources/canonical-sources.js +0 -129
  48. package/lib/commands/briefer/sources/entitlements.js +0 -220
  49. package/lib/commands/briefer/sources/environments.js +0 -205
  50. package/lib/commands/briefer/sources/identifiers.js +0 -214
  51. package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
  52. package/lib/commands/briefer/sources/routing.js +0 -275
  53. package/lib/commands/briefer/sources/what-is-not.js +0 -223
  54. package/lib/commands/briefer/stitcher.js +0 -134
  55. package/lib/commands/briefer/util/source-reader.js +0 -502
  56. package/lib/commands/mcp.js +0 -273
  57. package/lib/commands/update.js +0 -581
  58. package/lib/wizard/setup.js +0 -503
  59. package/templates/descix_mcp.mdc +0 -122
@@ -54,9 +54,98 @@ export function resolveWorkspacePath(workspaceRoot, localPath, label = 'app') {
54
54
  return path.resolve(workspaceRoot, localPath);
55
55
  }
56
56
 
57
+ /**
58
+ * The ONE text for "there is no workspace here". It is correct advice for exactly one state —
59
+ * the file does not exist — and it is a lie for every other state, which is why it now has a
60
+ * single owner instead of being retyped at each throw site.
61
+ */
62
+ const NOT_CONFIGURED_MESSAGE =
63
+ 'Workspace not configured.\n' +
64
+ 'Run "npx descix init" first to initialize your workspace.';
65
+
66
+ /**
67
+ * THE ONE OWNER of "how do I set the API origin". Named by every refusal that turns a developer
68
+ * away from a retired way of setting it (the retired setter verb, a top-level `apiUrl` key), so
69
+ * the three live surfaces are spelled once. Order: persistent known env, persistent custom
70
+ * origin, per-run.
71
+ */
72
+ export const TOP_LEVEL_API_URL_REMEDY =
73
+ 'descix config init --env dev|demo|prod # pin a known environment (writes env.apiUrl)\n' +
74
+ ' descix config set-env <name> --url <origin> # pin a custom origin under your own env name\n' +
75
+ ' descix --api-url <origin> <command> # this run only; no workspace change (e.g. serve)';
76
+
77
+ /**
78
+ * THE ONE OWNER of the "move it aside, do not delete it" remedy.
79
+ *
80
+ * TWO different diagnoses reach this SAME remedy: a workspace.json that cannot be READ
81
+ * (WorkspaceUnreadableError) and one that reads fine but carries the retired v1 schema
82
+ * (load()'s v1 branch). Each used to hand-write its own remedy and THEY DISAGREED - the v1
83
+ * branch prescribed DELETING the file outright, while its sibling forty lines above it in this
84
+ * same file told the user their contents are lost only if they delete it. Two derivations of
85
+ * one fact drift silently; this is the extracted owner both now consume.
86
+ *
87
+ * A REFUSAL MUST NEVER PRESCRIBE A REMEDY THAT DESTROYS THE THING IT IS DIAGNOSING. That rule
88
+ * was ALREADY WRITTEN IN THIS FILE, as prose, a few lines above the branch that violated it -
89
+ * and it still shipped. A rule stated beside code does not enforce itself, and proximity may
90
+ * even hurt: a reader who sees the rule assumes it is in force. So it is expressed here as a
91
+ * FUNCTION every refusal must CALL, rather than a comment every author must NOTICE.
92
+ *
93
+ * @param {string} configPath - absolute path to the workspace.json being refused
94
+ * @param {string} initVerb - the verb that creates a fresh workspace once this file is aside
95
+ * @returns {string} the remedy block (no trailing newline)
96
+ */
97
+ export function moveAsideRemedy(configPath, initVerb) {
98
+ return (
99
+ ` • Recover it: restore ${path.basename(configPath)} from version control or a backup.\n` +
100
+ ` • If you do not need its contents, move it aside first\n` +
101
+ ` (mv "${configPath}" "${configPath}.broken") and then run "${initVerb}".\n` +
102
+ ` Its contents are lost only if you delete it.`
103
+ );
104
+ }
105
+
106
+ /**
107
+ * THE ONE OWNER of "this workspace.json EXISTS and I could not read it".
108
+ *
109
+ * ABSENT and UNREADABLE are two different facts about the world and they need two different
110
+ * remedies. load() used to collapse them — every read error, JSON syntax error and permission
111
+ * error became NOT_CONFIGURED_MESSAGE — so a corrupt-but-present workspace.json reported as
112
+ * absent and the message prescribed `descix init` over a file that was still recoverable. A
113
+ * REFUSAL MUST NEVER PRESCRIBE A REMEDY THAT DESTROYS THE THING IT IS DIAGNOSING.
114
+ *
115
+ * It is a TYPE, not a message prefix, because callers have to branch on it (tryLoad returns
116
+ * null for absent and rethrows this) and matching on error text is the narrow-string-matching
117
+ * anti-pattern this file already suffered from — load()'s own v1 re-throw was an
118
+ * `error.message.includes(...)` test, and it is deleted in the same change.
119
+ *
120
+ * save() already refuses to overwrite a present-but-unparseable file and names it. This is the
121
+ * READ side of that same judgment, so the two halves of the file's lifecycle now agree.
122
+ */
123
+ export class WorkspaceUnreadableError extends Error {
124
+ constructor(configPath, cause) {
125
+ // fs errors already lead with their code ("EACCES: permission denied, open ..."); JSON.parse
126
+ // errors carry no code at all. Prefix only when it is not already there, so the reason line
127
+ // never reads "EACCES: EACCES: ...".
128
+ const raw = cause?.message || String(cause);
129
+ const detail = cause?.code && !raw.startsWith(cause.code) ? `${cause.code}: ${raw}` : raw;
130
+ super(
131
+ `Workspace config exists but could not be read.\n` +
132
+ ` File: ${configPath}\n` +
133
+ ` Reason: ${detail}\n` +
134
+ `\n` +
135
+ `This file is still on disk and has NOT been modified. Do NOT run "descix init" here —\n` +
136
+ `that command is for a workspace that is ABSENT, and this one is DAMAGED.\n` +
137
+ moveAsideRemedy(configPath, 'descix init')
138
+ );
139
+ this.name = 'WorkspaceUnreadableError';
140
+ this.code = 'WORKSPACE_UNREADABLE';
141
+ this.configPath = configPath;
142
+ this.cause = cause;
143
+ }
144
+ }
145
+
57
146
  /**
58
147
  * WorkspaceConfig - Manages workspace-specific configuration
59
- *
148
+ *
60
149
  * This is the SOLE configuration methodology for DeSciX CLI.
61
150
  * All path resolution and context detection uses workspace.json.
62
151
  *
@@ -73,13 +162,9 @@ export class WorkspaceConfig {
73
162
 
74
163
  // Legacy fields (kept for save() PWA response conversion compatibility)
75
164
  this.defaultContext = config.defaultContext;
76
- this.apiUrl = config.apiUrl;
77
165
  this.environment = config.environment;
78
166
  this.directoryMappings = config.directoryMappings || {};
79
167
 
80
- // V2.1 Product tracking (Unified Registry)
81
- this.products = config.products || {};
82
-
83
168
  // app_id → localPath map from env.platform + env.products (Unified Registry)
84
169
  this._appIdToConfig = this._buildAppIdMap(config);
85
170
 
@@ -149,6 +234,87 @@ export class WorkspaceConfig {
149
234
  return null;
150
235
  }
151
236
 
237
+ /**
238
+ * The RAW registry entry for an app — `localPath` exactly as workspace.json carries it, with NO
239
+ * path resolution and therefore no possibility of throwing on a bad stored value.
240
+ *
241
+ * This exists for exactly one caller shape: a command that is about to REPLACE an app's
242
+ * localPath. Such a command must not resolve the OUTGOING value. getAppByAppId() resolves, and
243
+ * resolving the value you are on your way to overwrite is what made `descix app set-localpath`
244
+ * unusable precisely when it was needed — a workspace already carrying a rejected localPath
245
+ * could not be repaired by the one verb whose job is repairing it, while the rejection itself
246
+ * forbade hand-editing. The verb was the prescribed remedy and the verb refused.
247
+ *
248
+ * It reads the SAME map getAppByAppId reads (_buildAppIdMap is still the one owner of "what is
249
+ * mapped"); it simply stops short of resolution. Do NOT use it to obtain a usable path — take
250
+ * getAppByAppId().absolutePath for that, so the loader's rejection still runs.
251
+ *
252
+ * @param {string} appId - App/product identifier
253
+ * @returns {{ localPath: string, communityId: string|null, kbId: string }|null}
254
+ */
255
+ getAppEntry(appId) {
256
+ if (!appId) return null;
257
+ return this._appIdToConfig?.[appId] || null;
258
+ }
259
+
260
+ /**
261
+ * THE ONE OWNER of "find the LIVE env entry for this app, or hard-fail".
262
+ *
263
+ * Live means the actual object inside this.env — mutate it and save() persists the mutation.
264
+ * getAppByAppId() and getAppEntry() both return copies and are useless for writing.
265
+ *
266
+ * The one lookup every entry setter (setMicroservicePort, setStaticSite, setLocalPath) uses. A
267
+ * private walk of env.products ONLY misses the platform app, finds nothing, writes nothing and
268
+ * still reports success.
269
+ *
270
+ * @param {string} appId - App identifier
271
+ * @returns {Object} the live env.platform or env.products[] entry
272
+ * @throws if appId is not mapped
273
+ */
274
+ _liveEnvEntry(appId) {
275
+ let entry = null;
276
+ if (this.env?.platform?.appId === appId) {
277
+ entry = this.env.platform;
278
+ } else if (Array.isArray(this.env?.products)) {
279
+ entry = this.env.products.find(p => p.appId === appId) || null;
280
+ }
281
+ if (!entry) {
282
+ throw new Error(unmappedAppMessage(appId));
283
+ }
284
+ return entry;
285
+ }
286
+
287
+ /**
288
+ * Update an app's localPath in env.platform or env.products[].
289
+ *
290
+ * The canonical write path for localPath. Backs `descix app set-localpath`.
291
+ *
292
+ * localPath MUST be workspace-root-relative. It is validated here through
293
+ * resolveWorkspacePath — the LOADER'S OWN resolver, not a second copy of its rules — so a
294
+ * value the loader will refuse on the next read is refused now, before anything is written.
295
+ * Writing a value the loader rejects is what bricked workspaces: every subsequent command
296
+ * threw, including the repair verbs, while the rejection forbade hand-editing.
297
+ *
298
+ * Persists via save() — same auto-save pattern as its siblings.
299
+ * Hard-fails if appId is not mapped in env.platform or env.products.
300
+ *
301
+ * @param {string} appId - App identifier (must exist in env.platform or env.products)
302
+ * @param {string} localPath - New path, relative to the workspace root
303
+ * @returns {Promise<string>} Path to saved config (from save())
304
+ */
305
+ async setLocalPath(appId, localPath) {
306
+ if (!appId) throw new Error('appId is required');
307
+ if (!localPath) throw new Error('localPath is required');
308
+
309
+ const entry = this._liveEnvEntry(appId);
310
+
311
+ // Validate through the loader's own resolver. Throws on an absolute path.
312
+ resolveWorkspacePath(this.workspaceRoot, localPath, appId);
313
+
314
+ entry.localPath = localPath;
315
+ return this.save();
316
+ }
317
+
152
318
  /**
153
319
  * Get the absolute path to an app's site/ directory
154
320
  * @param {string} appId - App identifier
@@ -216,54 +382,98 @@ export class WorkspaceConfig {
216
382
  *
217
383
  * Automatically searches upward from startDir to find workspace root.
218
384
  *
385
+ * ABSENT vs UNREADABLE IS DECIDED HERE AND NOWHERE ELSE. Callers consume the distinction as a
386
+ * type (WorkspaceUnreadableError), never by re-deciding it or by matching on message text.
387
+ *
219
388
  * @param {string} startDir - Directory to start searching from (default: cwd)
220
389
  * @returns {Promise<WorkspaceConfig>} Configuration object
221
- * @throws {Error} If workspace not found
390
+ * @throws {Error} NOT_CONFIGURED_MESSAGE when no workspace.json exists
391
+ * @throws {WorkspaceUnreadableError} when one exists and cannot be read or parsed
222
392
  */
223
393
  static async load(startDir = process.cwd()) {
224
394
  // First, find workspace root by searching upward
225
395
  const workspaceRoot = await WorkspaceConfig.findWorkspaceRoot(startDir);
226
396
  if (!workspaceRoot) {
227
- throw new Error(
228
- 'Workspace not configured.\n' +
229
- 'Run "npx descix init" first to initialize your workspace.'
230
- );
397
+ throw new Error(NOT_CONFIGURED_MESSAGE);
231
398
  }
232
399
 
233
400
  const configPath = path.join(workspaceRoot, '.descix', 'workspace.json');
234
- try {
235
- const data = await fs.readFile(configPath, 'utf-8');
236
- const parsed = JSON.parse(data);
237
401
 
238
- // v1 format hard-error: has communities block but no env block
239
- if (parsed.communities && !parsed.env) {
240
- throw new Error(
241
- 'v1 workspace format is not supported. Migrate to v2.1.\n' +
242
- 'Delete .descix/workspace.json and re-run "descix app init" to create a v2.1 workspace.'
243
- );
244
- }
402
+ let data;
403
+ try {
404
+ data = await fs.readFile(configPath, 'utf-8');
405
+ } catch (error) {
406
+ // ENOENT is the only genuinely ABSENT case reachable here: findWorkspaceRoot stat'd this
407
+ // file a moment ago, so ENOENT means it disappeared in between. Everything else — EACCES
408
+ // on a file we can see but not open, EISDIR, EIO — is a file that EXISTS. Reporting any of
409
+ // those as "not configured" is what sent users to `descix init` over recoverable data.
410
+ if (error.code === 'ENOENT') throw new Error(NOT_CONFIGURED_MESSAGE);
411
+ throw new WorkspaceUnreadableError(configPath, error);
412
+ }
245
413
 
246
- return new WorkspaceConfig(parsed, workspaceRoot);
414
+ let parsed;
415
+ try {
416
+ parsed = JSON.parse(data);
247
417
  } catch (error) {
248
- if (error.message.includes('v1 workspace format')) throw error;
418
+ // The file is present and holds bytes we could not parse. Name the parse error and the
419
+ // path; never absorb this into "not configured".
420
+ throw new WorkspaceUnreadableError(configPath, error);
421
+ }
422
+
423
+ // v1 format hard-error: has communities block but no env block.
424
+ // This throw now sits OUTSIDE any catch, so it propagates on its own. The previous
425
+ // `error.message.includes('v1 workspace format')` re-throw was narrow string matching
426
+ // (DeSciX anti-pattern #6) guarding a catch that should never have been this wide.
427
+ if (parsed.communities && !parsed.env) {
249
428
  throw new Error(
250
- 'Workspace not configured.\n' +
251
- 'Run "npx descix init" first to initialize your workspace.'
429
+ 'v1 workspace format is not supported. Migrate to v2.1.\n' +
430
+ ` File: ${configPath}\n` +
431
+ '\n' +
432
+ 'This file is still on disk and has NOT been modified. There is no automatic v1 → v2.1\n' +
433
+ 'migration: no surviving code reads the v1 layout, so its contents cannot be converted\n' +
434
+ 'for you — but they are still readable BY YOU, and this file is the only copy of them.\n' +
435
+ moveAsideRemedy(configPath, 'descix app init')
252
436
  );
253
437
  }
438
+
439
+ // A v2.1 file carrying a TOP-LEVEL `apiUrl` is refused by name. That key is retired: no
440
+ // reader consumes it (the origin owner resolves `env.apiUrl` only) and save() never writes
441
+ // it, so a developer who set it by hand would be silently ignored on every invocation —
442
+ // exactly what the retired setter verb used to do from the other side, printing a success
443
+ // banner over a value that landed nowhere. The file is otherwise fine; the remedy is to remove the
444
+ // one key and set the origin where it is read. Nothing here is destructive.
445
+ if (typeof parsed.apiUrl === 'string' && parsed.apiUrl.trim() !== '') {
446
+ throw new Error(
447
+ 'workspace.json carries a top-level "apiUrl", which is a retired v1 key that nothing reads.\n' +
448
+ ` File: ${configPath}\n` +
449
+ ` Value: ${parsed.apiUrl.trim()}\n` +
450
+ '\n' +
451
+ 'The API origin lives at env.apiUrl. Remove the top-level key, then set the origin with:\n' +
452
+ ` ${TOP_LEVEL_API_URL_REMEDY}`
453
+ );
454
+ }
455
+
456
+ return new WorkspaceConfig(parsed, workspaceRoot);
254
457
  }
255
-
458
+
256
459
  /**
257
460
  * Try to load workspace configuration, return null if not found
258
461
  * Useful for commands that need to check if workspace exists
259
- *
462
+ *
463
+ * RETURNS NULL FOR ABSENT, THROWS FOR UNREADABLE. "There is no workspace here" and "the
464
+ * workspace here is damaged" are different answers and only the first one is a soft no.
465
+ * Swallowing the second made a corrupt workspace look like a fresh directory, which is how a
466
+ * damaged workspace silently fell back to the DEFAULT (prod) origin instead of stopping.
467
+ *
260
468
  * @param {string} startDir - Directory to start searching from
261
- * @returns {Promise<WorkspaceConfig|null>}
469
+ * @returns {Promise<WorkspaceConfig|null>} config, or null when no workspace.json exists
470
+ * @throws {WorkspaceUnreadableError} when one exists and cannot be read or parsed
262
471
  */
263
472
  static async tryLoad(startDir = process.cwd()) {
264
473
  try {
265
474
  return await WorkspaceConfig.load(startDir);
266
- } catch {
475
+ } catch (error) {
476
+ if (error instanceof WorkspaceUnreadableError) throw error;
267
477
  return null;
268
478
  }
269
479
  }
@@ -338,23 +548,6 @@ export class WorkspaceConfig {
338
548
  return configPath;
339
549
  }
340
550
 
341
- /**
342
- * Compute and store absolute paths for all communities and apps
343
- * Called automatically by save() to ensure paths are pre-computed
344
- *
345
- * @param {string} workspaceRoot - Workspace root directory
346
- */
347
- computeAbsolutePaths(workspaceRoot) {
348
- const absWorkspaceRoot = path.resolve(workspaceRoot);
349
-
350
- // Compute absolute paths for products
351
- for (const [productId, product] of Object.entries(this.products || {})) {
352
- if (product.localPath) {
353
- product.absolutePath = resolveWorkspacePath(absWorkspaceRoot, product.localPath, productId);
354
- }
355
- }
356
- }
357
-
358
551
  // ============ App Registration Methods ============
359
552
 
360
553
  /**
@@ -372,6 +565,14 @@ export class WorkspaceConfig {
372
565
  throw new Error('appId and localPath are required');
373
566
  }
374
567
 
568
+ // Validate through THE LOADER'S OWN RESOLVER — the same owner setLocalPath consumes, never a
569
+ // second copy of its rules. Three writers persisted a localPath and only ONE validated it, so
570
+ // `descix app init -p /abs` wrote an absolute value, created directories at that arbitrary
571
+ // absolute location, printed success, and bricked the workspace on the NEXT read — the loader
572
+ // refused the value for every command including the repair verbs, while the refusal forbade
573
+ // hand-editing. A path this CLI accepts must be honoured, or refused before anything is written.
574
+ resolveWorkspacePath(this.workspaceRoot, appConfig.localPath, appId);
575
+
375
576
  if (!this.env) this.env = {};
376
577
  if (!Array.isArray(this.env.products)) this.env.products = [];
377
578
 
@@ -401,58 +602,6 @@ export class WorkspaceConfig {
401
602
  return true;
402
603
  }
403
604
 
404
- /**
405
- * Register a product (Unified Registry)
406
- * @param {string} productId - Global product identifier
407
- * @param {Object} productConfig - Product configuration
408
- * @returns {boolean} Success status
409
- */
410
- registerProduct(productId, productConfig) {
411
- if (!productId || !productConfig.localPath) {
412
- throw new Error('productId and localPath are required');
413
- }
414
-
415
- this.products[productId] = {
416
- type: productConfig.type || 'APP',
417
- localPath: productConfig.localPath,
418
- context: productConfig.context || {},
419
- registeredAt: new Date().toISOString()
420
- };
421
-
422
- this.version = '2.0';
423
- return true;
424
- }
425
-
426
- /**
427
- * Get product configuration
428
- * @param {string} productId
429
- * @returns {Object|null}
430
- */
431
- getProduct(productId) {
432
- return this.products[productId] || null;
433
- }
434
-
435
- /**
436
- * List all registered products
437
- * @returns {Array} Array of product IDs
438
- */
439
- listProducts() {
440
- return Object.keys(this.products);
441
- }
442
-
443
- /**
444
- * Remove a product registration
445
- * @param {string} productId
446
- * @returns {boolean}
447
- */
448
- unregisterProduct(productId) {
449
- if (this.products[productId]) {
450
- delete this.products[productId];
451
- return true;
452
- }
453
- return false;
454
- }
455
-
456
605
  /**
457
606
  * Resolve context based on file path
458
607
  *
@@ -512,23 +661,6 @@ export class WorkspaceConfig {
512
661
  }
513
662
  }
514
663
 
515
- // products object (legacy)
516
- for (const [productId, product] of Object.entries(this.products || {})) {
517
- let productPath = product.absolutePath;
518
- if (!productPath && product.localPath && wsRoot) {
519
- productPath = resolveWorkspacePath(wsRoot, product.localPath, productId);
520
- }
521
-
522
- if (productPath && cwd.startsWith(productPath)) {
523
- return {
524
- productId,
525
- communityId: product.context?.community || null,
526
- appId: product.context?.app || productId,
527
- kbId: product.kbId || 'General'
528
- };
529
- }
530
- }
531
-
532
664
  return null;
533
665
  }
534
666
 
@@ -583,19 +715,20 @@ export class WorkspaceConfig {
583
715
  }
584
716
 
585
717
  /**
586
- * Get the API origin this workspace is configured for, or NULL when none is configured.
718
+ * Get the API origin this workspace is configured for.
587
719
  *
588
- * Priority: env.apiUrl > legacy this.apiUrl. There is NO third branch. This used to end in
720
+ * The workspace names its origin at `env.apiUrl` and nowhere else. This used to end in
589
721
  * `return DEFAULT_API_URL` — the shipped PROD origin — which meant an unconfigured workspace
590
722
  * was indistinguishable from one that had deliberately chosen production, and every caller
591
723
  * received a prod origin the developer had never picked. Resolution and the fail-loud live in
592
724
  * `lib/origin.js`; this method only reports what THIS workspace file says.
593
725
  *
594
- * There is likewise no environment-name-to-localhost derivation: an environment names a cloud
595
- * environment, and localhost is a URL you set explicitly
596
- * (`descix config set-env dev --url https://localhost:4000`, or env.apiUrl).
726
+ * There is no environment-name-to-localhost derivation: an environment names a cloud
727
+ * environment. A local origin is either per-run (`descix --api-url https://localhost:4000
728
+ * serve ...`) or a custom env (`descix config set-env local --url ...`) — see
729
+ * TOP_LEVEL_API_URL_REMEDY, the one owner of that list.
597
730
  *
598
- * @returns {string|null} the configured origin, or null if this workspace names none
731
+ * @returns {string} the configured origin, or the declared default when this workspace names none
599
732
  */
600
733
  getApiUrl() {
601
734
  // Consumes the ONE origin owner rather than re-deriving the precedence or re-spelling the
@@ -604,7 +737,6 @@ export class WorkspaceConfig {
604
737
  // resolveOrigin() and printed by the api-client, never by a null returned from here.
605
738
  return resolveOrigin({
606
739
  workspaceEnvApiUrl: this.env?.apiUrl,
607
- legacyApiUrl: this.apiUrl,
608
740
  }).origin;
609
741
  }
610
742
 
@@ -672,60 +804,16 @@ export class WorkspaceConfig {
672
804
  return this.save();
673
805
  }
674
806
 
675
- /**
676
- * Update an app's site.port in env.products[] (or env.platform if it is the platform app).
677
- *
678
- * Pass `null` to remove site.port; if site.{} becomes empty, site.{} is also deleted.
679
- * Persists via save() at the end — same auto-save pattern as setEnvironment().
680
- * Hard-fails if appId is not mapped in env.platform or env.products.
681
- *
682
- * @param {string} appId - App identifier (must exist in env.platform or env.products)
683
- * @param {number|string|null} port - Port number to set, or null to remove site.port
684
- * @returns {Promise<string>} Path to saved config (from save())
685
- */
686
- async setSitePort(appId, port) {
687
- if (!appId) throw new Error('appId is required');
688
-
689
- // Find the live env entry (platform or products[]) — not a copy
690
- let entry = null;
691
- if (this.env?.platform?.appId === appId) {
692
- entry = this.env.platform;
693
- } else if (Array.isArray(this.env?.products)) {
694
- entry = this.env.products.find(p => p.appId === appId) || null;
695
- }
696
-
697
- if (!entry) {
698
- throw new Error(
699
- unmappedAppMessage(appId)
700
- );
701
- }
702
-
703
- if (port === null || port === undefined) {
704
- // Remove site.port; clean up empty site.{}
705
- if (entry.site) {
706
- delete entry.site.port;
707
- if (Object.keys(entry.site).length === 0) {
708
- delete entry.site;
709
- }
710
- }
711
- } else {
712
- if (!entry.site) entry.site = {};
713
- entry.site.port = port;
714
- }
715
-
716
- return this.save();
717
- }
718
-
719
807
  /**
720
808
  * Update an app's microservice.port in env.products[] (or env.platform if it is the platform app).
721
809
  *
722
- * Parallel to setSitePort(), but operates on the entry's microservice.{} slot.
810
+ * Operates on the entry's microservice.{} slot.
723
811
  * This is the canonical write path for the microservice port that `descix microservice init`
724
812
  * reads (and hard-fails on if missing). Backs the `descix app set-port` command, closing
725
813
  * WS-CLI-MESH-ROUTING-GAP without hand-editing workspace.json.
726
814
  *
727
815
  * Pass `null` to remove microservice.port; if microservice.{} becomes empty, it is also deleted.
728
- * Persists via save() at the end — same auto-save pattern as setSitePort()/setEnvironment().
816
+ * Persists via save() at the end — same auto-save pattern as setEnvironment().
729
817
  * Hard-fails if appId is not mapped in env.platform or env.products.
730
818
  *
731
819
  * @param {string} appId - App identifier (must exist in env.platform or env.products)
@@ -735,19 +823,7 @@ export class WorkspaceConfig {
735
823
  async setMicroservicePort(appId, port) {
736
824
  if (!appId) throw new Error('appId is required');
737
825
 
738
- // Find the live env entry (platform or products[]) — not a copy
739
- let entry = null;
740
- if (this.env?.platform?.appId === appId) {
741
- entry = this.env.platform;
742
- } else if (Array.isArray(this.env?.products)) {
743
- entry = this.env.products.find(p => p.appId === appId) || null;
744
- }
745
-
746
- if (!entry) {
747
- throw new Error(
748
- unmappedAppMessage(appId)
749
- );
750
- }
826
+ const entry = this._liveEnvEntry(appId);
751
827
 
752
828
  if (port === null || port === undefined) {
753
829
  // Remove microservice.port; clean up empty microservice.{}
@@ -768,7 +844,7 @@ export class WorkspaceConfig {
768
844
  /**
769
845
  * Update an app's site config in env.products[] (or env.platform if it is the platform app).
770
846
  *
771
- * Parallel to setSitePort()/setMicroservicePort(), but operates on the entry's site.{} slot's
847
+ * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot's
772
848
  * static-site fields. This is the canonical write path for site.static — the relative path the
773
849
  * dev gateway's staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig:
774
850
  * site.static is resolved against the app's localPath; "." means the localPath itself).
@@ -777,7 +853,7 @@ export class WorkspaceConfig {
777
853
  *
778
854
  * Mutates site.static and/or site.port. Pass static === null to remove site.static; pass
779
855
  * port === null to remove site.port. If site.{} becomes empty after removals it is deleted.
780
- * Persists via save() at the end — same auto-save pattern as setSitePort()/setMicroservicePort().
856
+ * Persists via save() at the end — same auto-save pattern as setMicroservicePort().
781
857
  * Hard-fails if appId is not mapped in env.platform or env.products.
782
858
  *
783
859
  * @param {string} appId - App identifier (must exist in env.platform or env.products)
@@ -790,19 +866,7 @@ export class WorkspaceConfig {
790
866
  if (!appId) throw new Error('appId is required');
791
867
  if (!fields || typeof fields !== 'object') throw new Error('fields object is required');
792
868
 
793
- // Find the live env entry (platform or products[]) — not a copy
794
- let entry = null;
795
- if (this.env?.platform?.appId === appId) {
796
- entry = this.env.platform;
797
- } else if (Array.isArray(this.env?.products)) {
798
- entry = this.env.products.find(p => p.appId === appId) || null;
799
- }
800
-
801
- if (!entry) {
802
- throw new Error(
803
- unmappedAppMessage(appId)
804
- );
805
- }
869
+ const entry = this._liveEnvEntry(appId);
806
870
 
807
871
  if (!entry.site) entry.site = {};
808
872
 
@@ -815,7 +879,7 @@ export class WorkspaceConfig {
815
879
  }
816
880
  }
817
881
 
818
- // site.port — set or remove (parallel to setSitePort, for static+devCommand sites)
882
+ // site.port — set or remove (for static+devCommand sites)
819
883
  if ('port' in fields) {
820
884
  if (fields.port === null || fields.port === undefined) {
821
885
  delete entry.site.port;
@@ -836,9 +900,12 @@ export class WorkspaceConfig {
836
900
  * Persistently set the target environment in workspace.json.
837
901
  *
838
902
  * Updates env.environment and env.apiUrl, then saves. EVERY environment writes a
839
- * URL — including dev, which writes the cloud DEV origin. Pointing at a local
840
- * backend is `--url`: `descix config set-env dev --url https://localhost:4000`.
841
- * For custom envs, uses --url or defaults to https://{name}.descix.net.
903
+ * URL — including dev, which writes the cloud DEV origin. For custom envs, uses --url or
904
+ * defaults to https://{name}.descix.net. The `config set-env` verb refuses `--url` on a
905
+ * KNOWN name (it sends you to `config init --env`), so a local backend is either per-run —
906
+ * `descix --api-url https://localhost:4000 <command>`, no workspace change — or a custom name
907
+ * (`set-env local --url ...`). Mind that cloud-core derives DEPLOY_ENV and port auto-detect
908
+ * only from `env.environment === 'DEV'`, so a custom name is not a DEV workspace.
842
909
  *
843
910
  * @param {string} envName - Environment name (dev, demo, prod, or custom)
844
911
  * @param {string|null} [apiUrl] - Explicit API URL override
@@ -68,7 +68,6 @@ export function readIdentity(config = {}) {
68
68
  const resolved = resolveOrigin({
69
69
  envVar: process.env.DESCIX_API_URL,
70
70
  workspaceEnvApiUrl: env.apiUrl,
71
- legacyApiUrl: config.apiUrl,
72
71
  });
73
72
 
74
73
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,8 @@
16
16
  "scripts": {
17
17
  "test": "node --test \"tests/*.test.js\"",
18
18
  "pack": "npm pack",
19
- "check:doc-paths": "node scripts/check-doc-paths.js"
19
+ "check:doc-paths": "node scripts/check-doc-paths.js",
20
+ "briefer": "node scripts/briefer/run.mjs"
20
21
  },
21
22
  "keywords": [
22
23
  "descix",
@@ -29,7 +30,7 @@
29
30
  "author": "DeSciX",
30
31
  "license": "SEE LICENSE IN LICENSE.md",
31
32
  "dependencies": {
32
- "@descix/app-sdk": "^0.1.2",
33
+ "@descix/app-sdk": "^0.1.5",
33
34
  "@descix/sdk": "^2.0.0",
34
35
  "@modelcontextprotocol/sdk": "^1.25.1",
35
36
  "axios": "^1.7.9",
@@ -41,6 +42,9 @@
41
42
  "ora": "^9.0.0",
42
43
  "@descix/platform-api": "^2.0.0"
43
44
  },
45
+ "devDependencies": {
46
+ "acorn": "^8.18.0"
47
+ },
44
48
  "files": [
45
49
  "bin",
46
50
  "lib",