@descix/cli 1.0.5 → 1.0.7

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 (46) hide show
  1. package/README.md +130 -34
  2. package/agent-assets/guides/dynamic-site-pattern.md +10 -14
  3. package/agent-assets/guides/knowledge-only-pattern.md +11 -15
  4. package/agent-assets/guides/microservice-pattern.md +12 -14
  5. package/agent-assets/guides/static-site-pattern.md +12 -15
  6. package/agent-assets/instructions/onboarding-flow.md +4 -4
  7. package/agent-assets/reference/app-creation.md +36 -101
  8. package/agent-assets/reference/architecture.md +24 -27
  9. package/agent-assets/reference/local-dev.md +0 -4
  10. package/agent-assets/reference/mcp-integration.md +14 -22
  11. package/agent-assets/reference/workspace-config.md +97 -340
  12. package/agent-assets/rules/descix_mcp.template.mdc +1 -1
  13. package/bin/descix.js +159 -98
  14. package/lib/api-client.js +30 -22
  15. package/lib/commands/airdrop.js +10 -0
  16. package/lib/commands/auth.js +33 -3
  17. package/lib/commands/briefer/index.js +8 -5
  18. package/lib/commands/config.js +13 -31
  19. package/lib/commands/corpus.js +161 -54
  20. package/lib/commands/health.js +116 -21
  21. package/lib/commands/init.js +25 -48
  22. package/lib/commands/kb.js +14 -4
  23. package/lib/commands/update.js +0 -1
  24. package/lib/core/Chunker.js +1 -1
  25. package/lib/core/CorpusWalker.js +238 -47
  26. package/lib/core/Hydrator.js +88 -11
  27. package/lib/core/ManifestLoader.js +68 -0
  28. package/lib/core/Syncer.js +147 -46
  29. package/lib/interactive.js +158 -0
  30. package/lib/origin.js +3 -3
  31. package/lib/wallet-file.js +36 -37
  32. package/lib/wizard/setup.js +104 -22
  33. package/lib/workspace-config.js +325 -167
  34. package/lib/workspace-identity.js +0 -1
  35. package/package.json +3 -3
  36. package/templates/agent-claude.md +0 -3
  37. package/templates/agent-cline.md +0 -3
  38. package/templates/agent-copilot.md +0 -3
  39. package/templates/agent-cursor.md +0 -3
  40. package/templates/drive/app/kb/General/README.md +1 -1
  41. package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
  42. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
  43. package/templates/scaffolds/microservice/defaults-config.json +3 -1
  44. package/templates/scaffolds/microservice/manifest.json +9 -5
  45. package/templates/scaffolds/microservice/package.json +1 -1
  46. package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
@@ -2,6 +2,29 @@ import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import { ENV_ORIGINS } from '@descix/app-sdk/dev';
4
4
  import { resolveOrigin } from './origin.js';
5
+ // The one canonical KB-sync surface, from its owner. The example in requireContext() is a remedy
6
+ // a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
7
+ // that can go stale where nobody is watching.
8
+ import { CANONICAL_KB_SYNC } from './commands/retired-kb-sync.js';
9
+
10
+ /**
11
+ * THE ONE OWNER of "this app is not mapped in workspace.json, here is how to fix it".
12
+ *
13
+ * This text existed as three byte-identical copies in this file, and TWO OTHER call sites in
14
+ * bin/descix.js answered the same condition with a DIFFERENT and wrong remedy — `npx descix init`,
15
+ * which initialises a WORKSPACE and cannot map an app, so a developer who followed it stayed
16
+ * exactly as stuck. That is the mirror-drift tell: the same condition explained two ways, and the
17
+ * wrong one was the one a developer actually hit on the onboarding path.
18
+ *
19
+ * @param {string} appId - the app that is not mapped
20
+ * @returns {string}
21
+ */
22
+ export function unmappedAppMessage(appId) {
23
+ return (
24
+ `App "${appId}" is not mapped in workspace.json. ` +
25
+ 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
26
+ );
27
+ }
5
28
 
6
29
  /**
7
30
  * ONE OWNER for turning a workspace-relative localPath into an absolute path.
@@ -31,9 +54,98 @@ export function resolveWorkspacePath(workspaceRoot, localPath, label = 'app') {
31
54
  return path.resolve(workspaceRoot, localPath);
32
55
  }
33
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
+
34
146
  /**
35
147
  * WorkspaceConfig - Manages workspace-specific configuration
36
- *
148
+ *
37
149
  * This is the SOLE configuration methodology for DeSciX CLI.
38
150
  * All path resolution and context detection uses workspace.json.
39
151
  *
@@ -50,13 +162,9 @@ export class WorkspaceConfig {
50
162
 
51
163
  // Legacy fields (kept for save() PWA response conversion compatibility)
52
164
  this.defaultContext = config.defaultContext;
53
- this.apiUrl = config.apiUrl;
54
165
  this.environment = config.environment;
55
166
  this.directoryMappings = config.directoryMappings || {};
56
167
 
57
- // V2.1 Product tracking (Unified Registry)
58
- this.products = config.products || {};
59
-
60
168
  // app_id → localPath map from env.platform + env.products (Unified Registry)
61
169
  this._appIdToConfig = this._buildAppIdMap(config);
62
170
 
@@ -126,6 +234,90 @@ export class WorkspaceConfig {
126
234
  return null;
127
235
  }
128
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
+ * This existed as three byte-identical copies (setSitePort, setMicroservicePort,
267
+ * setStaticSite) and `set-localpath` hand-rolled a FOURTH variant in bin/descix.js that
268
+ * walked env.products ONLY. That omission is the whole reason the platform app silently
269
+ * failed: the loop found nothing, wrote nothing, and the command still printed success.
270
+ * A fourth copy here would have reproduced the same class of bug, so there is now one.
271
+ *
272
+ * @param {string} appId - App identifier
273
+ * @returns {Object} the live env.platform or env.products[] entry
274
+ * @throws if appId is not mapped
275
+ */
276
+ _liveEnvEntry(appId) {
277
+ let entry = null;
278
+ if (this.env?.platform?.appId === appId) {
279
+ entry = this.env.platform;
280
+ } else if (Array.isArray(this.env?.products)) {
281
+ entry = this.env.products.find(p => p.appId === appId) || null;
282
+ }
283
+ if (!entry) {
284
+ throw new Error(unmappedAppMessage(appId));
285
+ }
286
+ return entry;
287
+ }
288
+
289
+ /**
290
+ * Update an app's localPath in env.platform or env.products[].
291
+ *
292
+ * The canonical write path for localPath, and the sibling that was missing while
293
+ * setSitePort/setMicroservicePort/setStaticSite all existed. Backs `descix app set-localpath`.
294
+ *
295
+ * localPath MUST be workspace-root-relative. It is validated here through
296
+ * resolveWorkspacePath — the LOADER'S OWN resolver, not a second copy of its rules — so a
297
+ * value the loader will refuse on the next read is refused now, before anything is written.
298
+ * Writing a value the loader rejects is what bricked workspaces: every subsequent command
299
+ * threw, including the repair verbs, while the rejection forbade hand-editing.
300
+ *
301
+ * Persists via save() — same auto-save pattern as its siblings.
302
+ * Hard-fails if appId is not mapped in env.platform or env.products.
303
+ *
304
+ * @param {string} appId - App identifier (must exist in env.platform or env.products)
305
+ * @param {string} localPath - New path, relative to the workspace root
306
+ * @returns {Promise<string>} Path to saved config (from save())
307
+ */
308
+ async setLocalPath(appId, localPath) {
309
+ if (!appId) throw new Error('appId is required');
310
+ if (!localPath) throw new Error('localPath is required');
311
+
312
+ const entry = this._liveEnvEntry(appId);
313
+
314
+ // Validate through the loader's own resolver. Throws on an absolute path.
315
+ resolveWorkspacePath(this.workspaceRoot, localPath, appId);
316
+
317
+ entry.localPath = localPath;
318
+ return this.save();
319
+ }
320
+
129
321
  /**
130
322
  * Get the absolute path to an app's site/ directory
131
323
  * @param {string} appId - App identifier
@@ -193,54 +385,98 @@ export class WorkspaceConfig {
193
385
  *
194
386
  * Automatically searches upward from startDir to find workspace root.
195
387
  *
388
+ * ABSENT vs UNREADABLE IS DECIDED HERE AND NOWHERE ELSE. Callers consume the distinction as a
389
+ * type (WorkspaceUnreadableError), never by re-deciding it or by matching on message text.
390
+ *
196
391
  * @param {string} startDir - Directory to start searching from (default: cwd)
197
392
  * @returns {Promise<WorkspaceConfig>} Configuration object
198
- * @throws {Error} If workspace not found
393
+ * @throws {Error} NOT_CONFIGURED_MESSAGE when no workspace.json exists
394
+ * @throws {WorkspaceUnreadableError} when one exists and cannot be read or parsed
199
395
  */
200
396
  static async load(startDir = process.cwd()) {
201
397
  // First, find workspace root by searching upward
202
398
  const workspaceRoot = await WorkspaceConfig.findWorkspaceRoot(startDir);
203
399
  if (!workspaceRoot) {
204
- throw new Error(
205
- 'Workspace not configured.\n' +
206
- 'Run "npx descix init" first to initialize your workspace.'
207
- );
400
+ throw new Error(NOT_CONFIGURED_MESSAGE);
208
401
  }
209
402
 
210
403
  const configPath = path.join(workspaceRoot, '.descix', 'workspace.json');
211
- try {
212
- const data = await fs.readFile(configPath, 'utf-8');
213
- const parsed = JSON.parse(data);
214
404
 
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
- }
405
+ let data;
406
+ try {
407
+ data = await fs.readFile(configPath, 'utf-8');
408
+ } catch (error) {
409
+ // ENOENT is the only genuinely ABSENT case reachable here: findWorkspaceRoot stat'd this
410
+ // file a moment ago, so ENOENT means it disappeared in between. Everything else — EACCES
411
+ // on a file we can see but not open, EISDIR, EIO — is a file that EXISTS. Reporting any of
412
+ // those as "not configured" is what sent users to `descix init` over recoverable data.
413
+ if (error.code === 'ENOENT') throw new Error(NOT_CONFIGURED_MESSAGE);
414
+ throw new WorkspaceUnreadableError(configPath, error);
415
+ }
222
416
 
223
- return new WorkspaceConfig(parsed, workspaceRoot);
417
+ let parsed;
418
+ try {
419
+ parsed = JSON.parse(data);
224
420
  } catch (error) {
225
- if (error.message.includes('v1 workspace format')) throw error;
421
+ // The file is present and holds bytes we could not parse. Name the parse error and the
422
+ // path; never absorb this into "not configured".
423
+ throw new WorkspaceUnreadableError(configPath, error);
424
+ }
425
+
426
+ // v1 format hard-error: has communities block but no env block.
427
+ // This throw now sits OUTSIDE any catch, so it propagates on its own. The previous
428
+ // `error.message.includes('v1 workspace format')` re-throw was narrow string matching
429
+ // (DeSciX anti-pattern #6) guarding a catch that should never have been this wide.
430
+ if (parsed.communities && !parsed.env) {
226
431
  throw new Error(
227
- 'Workspace not configured.\n' +
228
- 'Run "npx descix init" first to initialize your workspace.'
432
+ 'v1 workspace format is not supported. Migrate to v2.1.\n' +
433
+ ` File: ${configPath}\n` +
434
+ '\n' +
435
+ 'This file is still on disk and has NOT been modified. There is no automatic v1 → v2.1\n' +
436
+ 'migration: no surviving code reads the v1 layout, so its contents cannot be converted\n' +
437
+ 'for you — but they are still readable BY YOU, and this file is the only copy of them.\n' +
438
+ moveAsideRemedy(configPath, 'descix app init')
229
439
  );
230
440
  }
441
+
442
+ // A v2.1 file carrying a TOP-LEVEL `apiUrl` is refused by name. That key is retired: no
443
+ // reader consumes it (the origin owner resolves `env.apiUrl` only) and save() never writes
444
+ // it, so a developer who set it by hand would be silently ignored on every invocation —
445
+ // exactly what the retired setter verb used to do from the other side, printing a success
446
+ // banner over a value that landed nowhere. The file is otherwise fine; the remedy is to remove the
447
+ // one key and set the origin where it is read. Nothing here is destructive.
448
+ if (typeof parsed.apiUrl === 'string' && parsed.apiUrl.trim() !== '') {
449
+ throw new Error(
450
+ 'workspace.json carries a top-level "apiUrl", which is a retired v1 key that nothing reads.\n' +
451
+ ` File: ${configPath}\n` +
452
+ ` Value: ${parsed.apiUrl.trim()}\n` +
453
+ '\n' +
454
+ 'The API origin lives at env.apiUrl. Remove the top-level key, then set the origin with:\n' +
455
+ ` ${TOP_LEVEL_API_URL_REMEDY}`
456
+ );
457
+ }
458
+
459
+ return new WorkspaceConfig(parsed, workspaceRoot);
231
460
  }
232
-
461
+
233
462
  /**
234
463
  * Try to load workspace configuration, return null if not found
235
464
  * Useful for commands that need to check if workspace exists
236
- *
465
+ *
466
+ * RETURNS NULL FOR ABSENT, THROWS FOR UNREADABLE. "There is no workspace here" and "the
467
+ * workspace here is damaged" are different answers and only the first one is a soft no.
468
+ * Swallowing the second made a corrupt workspace look like a fresh directory, which is how a
469
+ * damaged workspace silently fell back to the DEFAULT (prod) origin instead of stopping.
470
+ *
237
471
  * @param {string} startDir - Directory to start searching from
238
- * @returns {Promise<WorkspaceConfig|null>}
472
+ * @returns {Promise<WorkspaceConfig|null>} config, or null when no workspace.json exists
473
+ * @throws {WorkspaceUnreadableError} when one exists and cannot be read or parsed
239
474
  */
240
475
  static async tryLoad(startDir = process.cwd()) {
241
476
  try {
242
477
  return await WorkspaceConfig.load(startDir);
243
- } catch {
478
+ } catch (error) {
479
+ if (error instanceof WorkspaceUnreadableError) throw error;
244
480
  return null;
245
481
  }
246
482
  }
@@ -248,6 +484,17 @@ export class WorkspaceConfig {
248
484
  /**
249
485
  * Save workspace configuration in v2.1 format.
250
486
  * Always writes env.platform / env.products structure.
487
+ *
488
+ * PRESERVES KEYS IT DOES NOT UNDERSTAND. This method used to serialize a fixed field list from
489
+ * memory over the whole file, so ANY top-level key it did not itself enumerate was silently
490
+ * destroyed by the next `descix app set-port` — a writer re-serializing a known schema and
491
+ * discarding the rest. That is the same defect class as a record writer dropping a field it was
492
+ * never taught about, and it is invisible: the write succeeds, the file looks well-formed, and
493
+ * the loss is only discovered by whoever needed the missing key.
494
+ *
495
+ * The on-disk file is re-read at save time rather than trusted from construction, because the
496
+ * instance may have been held across another writer's save.
497
+ *
251
498
  * @param {string} [workspaceRoot] - Workspace root directory (uses stored root if not provided)
252
499
  * @returns {Promise<string>} Path to saved config
253
500
  */
@@ -266,7 +513,32 @@ export class WorkspaceConfig {
266
513
  // Build env block for v2.1 output
267
514
  const envBlock = (this.env && Object.keys(this.env).length > 0) ? this.env : null;
268
515
 
516
+ // The keys this writer OWNS. Everything else on disk is carried through untouched.
517
+ const OWNED = ['version', 'workspaceRoot', 'type', 'env', 'driveConfig'];
518
+
519
+ let onDisk = {};
520
+ try {
521
+ const existing = await fs.readFile(configPath, 'utf-8');
522
+ const parsed = JSON.parse(existing);
523
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) onDisk = parsed;
524
+ } catch (err) {
525
+ // ENOENT is the ordinary first save. A PRESENT-BUT-UNPARSEABLE file is different: carrying
526
+ // on would overwrite content we could not read, so refuse and name the file.
527
+ if (err.code !== 'ENOENT') {
528
+ throw new Error(
529
+ `Refusing to overwrite ${configPath}: it exists but could not be read as JSON ` +
530
+ `(${err.message}). Fix or remove it — saving now would destroy whatever it holds.`
531
+ );
532
+ }
533
+ }
534
+
535
+ const carried = {};
536
+ for (const [k, v] of Object.entries(onDisk)) {
537
+ if (!OWNED.includes(k)) carried[k] = v;
538
+ }
539
+
269
540
  const configData = {
541
+ ...carried,
270
542
  version: '2.1',
271
543
  workspaceRoot: path.resolve(root),
272
544
  type: this.type,
@@ -279,23 +551,6 @@ export class WorkspaceConfig {
279
551
  return configPath;
280
552
  }
281
553
 
282
- /**
283
- * Compute and store absolute paths for all communities and apps
284
- * Called automatically by save() to ensure paths are pre-computed
285
- *
286
- * @param {string} workspaceRoot - Workspace root directory
287
- */
288
- computeAbsolutePaths(workspaceRoot) {
289
- const absWorkspaceRoot = path.resolve(workspaceRoot);
290
-
291
- // Compute absolute paths for products
292
- for (const [productId, product] of Object.entries(this.products || {})) {
293
- if (product.localPath) {
294
- product.absolutePath = resolveWorkspacePath(absWorkspaceRoot, product.localPath, productId);
295
- }
296
- }
297
- }
298
-
299
554
  // ============ App Registration Methods ============
300
555
 
301
556
  /**
@@ -313,6 +568,14 @@ export class WorkspaceConfig {
313
568
  throw new Error('appId and localPath are required');
314
569
  }
315
570
 
571
+ // Validate through THE LOADER'S OWN RESOLVER — the same owner setLocalPath consumes, never a
572
+ // second copy of its rules. Three writers persisted a localPath and only ONE validated it, so
573
+ // `descix app init -p /abs` wrote an absolute value, created directories at that arbitrary
574
+ // absolute location, printed success, and bricked the workspace on the NEXT read — the loader
575
+ // refused the value for every command including the repair verbs, while the refusal forbade
576
+ // hand-editing. A path this CLI accepts must be honoured, or refused before anything is written.
577
+ resolveWorkspacePath(this.workspaceRoot, appConfig.localPath, appId);
578
+
316
579
  if (!this.env) this.env = {};
317
580
  if (!Array.isArray(this.env.products)) this.env.products = [];
318
581
 
@@ -342,58 +605,6 @@ export class WorkspaceConfig {
342
605
  return true;
343
606
  }
344
607
 
345
- /**
346
- * Register a product (Unified Registry)
347
- * @param {string} productId - Global product identifier
348
- * @param {Object} productConfig - Product configuration
349
- * @returns {boolean} Success status
350
- */
351
- registerProduct(productId, productConfig) {
352
- if (!productId || !productConfig.localPath) {
353
- throw new Error('productId and localPath are required');
354
- }
355
-
356
- this.products[productId] = {
357
- type: productConfig.type || 'APP',
358
- localPath: productConfig.localPath,
359
- context: productConfig.context || {},
360
- registeredAt: new Date().toISOString()
361
- };
362
-
363
- this.version = '2.0';
364
- return true;
365
- }
366
-
367
- /**
368
- * Get product configuration
369
- * @param {string} productId
370
- * @returns {Object|null}
371
- */
372
- getProduct(productId) {
373
- return this.products[productId] || null;
374
- }
375
-
376
- /**
377
- * List all registered products
378
- * @returns {Array} Array of product IDs
379
- */
380
- listProducts() {
381
- return Object.keys(this.products);
382
- }
383
-
384
- /**
385
- * Remove a product registration
386
- * @param {string} productId
387
- * @returns {boolean}
388
- */
389
- unregisterProduct(productId) {
390
- if (this.products[productId]) {
391
- delete this.products[productId];
392
- return true;
393
- }
394
- return false;
395
- }
396
-
397
608
  /**
398
609
  * Resolve context based on file path
399
610
  *
@@ -453,23 +664,6 @@ export class WorkspaceConfig {
453
664
  }
454
665
  }
455
666
 
456
- // products object (legacy)
457
- for (const [productId, product] of Object.entries(this.products || {})) {
458
- let productPath = product.absolutePath;
459
- if (!productPath && product.localPath && wsRoot) {
460
- productPath = resolveWorkspacePath(wsRoot, product.localPath, productId);
461
- }
462
-
463
- if (productPath && cwd.startsWith(productPath)) {
464
- return {
465
- productId,
466
- communityId: product.context?.community || null,
467
- appId: product.context?.app || productId,
468
- kbId: product.kbId || 'General'
469
- };
470
- }
471
- }
472
-
473
667
  return null;
474
668
  }
475
669
 
@@ -508,7 +702,7 @@ export class WorkspaceConfig {
508
702
  'Options:\n' +
509
703
  ' 1. cd into an app directory\n' +
510
704
  ' 2. Use flag: -a <app_id>\n\n' +
511
- 'Example: npx descix update kb -a daita'
705
+ `Example: npx ${CANONICAL_KB_SYNC} -a daita`
512
706
  );
513
707
  }
514
708
 
@@ -524,19 +718,20 @@ export class WorkspaceConfig {
524
718
  }
525
719
 
526
720
  /**
527
- * Get the API origin this workspace is configured for, or NULL when none is configured.
721
+ * Get the API origin this workspace is configured for.
528
722
  *
529
- * Priority: env.apiUrl > legacy this.apiUrl. There is NO third branch. This used to end in
723
+ * The workspace names its origin at `env.apiUrl` and nowhere else. This used to end in
530
724
  * `return DEFAULT_API_URL` — the shipped PROD origin — which meant an unconfigured workspace
531
725
  * was indistinguishable from one that had deliberately chosen production, and every caller
532
726
  * received a prod origin the developer had never picked. Resolution and the fail-loud live in
533
727
  * `lib/origin.js`; this method only reports what THIS workspace file says.
534
728
  *
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).
729
+ * There is no environment-name-to-localhost derivation: an environment names a cloud
730
+ * environment. A local origin is either per-run (`descix --api-url https://localhost:4000
731
+ * serve ...`) or a custom env (`descix config set-env local --url ...`) — see
732
+ * TOP_LEVEL_API_URL_REMEDY, the one owner of that list.
538
733
  *
539
- * @returns {string|null} the configured origin, or null if this workspace names none
734
+ * @returns {string} the configured origin, or the declared default when this workspace names none
540
735
  */
541
736
  getApiUrl() {
542
737
  // Consumes the ONE origin owner rather than re-deriving the precedence or re-spelling the
@@ -545,7 +740,6 @@ export class WorkspaceConfig {
545
740
  // resolveOrigin() and printed by the api-client, never by a null returned from here.
546
741
  return resolveOrigin({
547
742
  workspaceEnvApiUrl: this.env?.apiUrl,
548
- legacyApiUrl: this.apiUrl,
549
743
  }).origin;
550
744
  }
551
745
 
@@ -627,20 +821,7 @@ export class WorkspaceConfig {
627
821
  async setSitePort(appId, port) {
628
822
  if (!appId) throw new Error('appId is required');
629
823
 
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
- }
824
+ const entry = this._liveEnvEntry(appId);
644
825
 
645
826
  if (port === null || port === undefined) {
646
827
  // Remove site.port; clean up empty site.{}
@@ -677,20 +858,7 @@ export class WorkspaceConfig {
677
858
  async setMicroservicePort(appId, port) {
678
859
  if (!appId) throw new Error('appId is required');
679
860
 
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
- }
861
+ const entry = this._liveEnvEntry(appId);
694
862
 
695
863
  if (port === null || port === undefined) {
696
864
  // Remove microservice.port; clean up empty microservice.{}
@@ -733,20 +901,7 @@ export class WorkspaceConfig {
733
901
  if (!appId) throw new Error('appId is required');
734
902
  if (!fields || typeof fields !== 'object') throw new Error('fields object is required');
735
903
 
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
- }
904
+ const entry = this._liveEnvEntry(appId);
750
905
 
751
906
  if (!entry.site) entry.site = {};
752
907
 
@@ -780,9 +935,12 @@ export class WorkspaceConfig {
780
935
  * Persistently set the target environment in workspace.json.
781
936
  *
782
937
  * 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.
938
+ * URL — including dev, which writes the cloud DEV origin. For custom envs, uses --url or
939
+ * defaults to https://{name}.descix.net. The `config set-env` verb refuses `--url` on a
940
+ * KNOWN name (it sends you to `config init --env`), so a local backend is either per-run —
941
+ * `descix --api-url https://localhost:4000 <command>`, no workspace change — or a custom name
942
+ * (`set-env local --url ...`). Mind that cloud-core derives DEPLOY_ENV and port auto-detect
943
+ * only from `env.environment === 'DEV'`, so a custom name is not a DEV workspace.
786
944
  *
787
945
  * @param {string} envName - Environment name (dev, demo, prod, or custom)
788
946
  * @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.5",
3
+ "version": "1.0.7",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,8 +29,8 @@
29
29
  "author": "DeSciX",
30
30
  "license": "SEE LICENSE IN LICENSE.md",
31
31
  "dependencies": {
32
- "@descix/app-sdk": "^0.1.2",
33
- "@descix/sdk": "^1.0.0",
32
+ "@descix/app-sdk": "^0.1.5",
33
+ "@descix/sdk": "^2.0.0",
34
34
  "@modelcontextprotocol/sdk": "^1.25.1",
35
35
  "axios": "^1.7.9",
36
36
  "chalk": "^5.6.2",