@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.
- package/README.md +130 -34
- package/agent-assets/guides/dynamic-site-pattern.md +10 -14
- package/agent-assets/guides/knowledge-only-pattern.md +11 -15
- package/agent-assets/guides/microservice-pattern.md +12 -14
- package/agent-assets/guides/static-site-pattern.md +12 -15
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/reference/app-creation.md +36 -101
- package/agent-assets/reference/architecture.md +24 -27
- package/agent-assets/reference/local-dev.md +0 -4
- package/agent-assets/reference/mcp-integration.md +14 -22
- package/agent-assets/reference/workspace-config.md +97 -340
- package/agent-assets/rules/descix_mcp.template.mdc +1 -1
- package/bin/descix.js +159 -98
- package/lib/api-client.js +30 -22
- package/lib/commands/airdrop.js +10 -0
- package/lib/commands/auth.js +33 -3
- package/lib/commands/briefer/index.js +8 -5
- package/lib/commands/config.js +13 -31
- package/lib/commands/corpus.js +161 -54
- package/lib/commands/health.js +116 -21
- package/lib/commands/init.js +25 -48
- package/lib/commands/kb.js +14 -4
- package/lib/commands/update.js +0 -1
- package/lib/core/Chunker.js +1 -1
- package/lib/core/CorpusWalker.js +238 -47
- package/lib/core/Hydrator.js +88 -11
- package/lib/core/ManifestLoader.js +68 -0
- package/lib/core/Syncer.js +147 -46
- package/lib/interactive.js +158 -0
- package/lib/origin.js +3 -3
- package/lib/wallet-file.js +36 -37
- package/lib/wizard/setup.js +104 -22
- package/lib/workspace-config.js +325 -167
- package/lib/workspace-identity.js +0 -1
- package/package.json +3 -3
- package/templates/agent-claude.md +0 -3
- package/templates/agent-cline.md +0 -3
- package/templates/agent-copilot.md +0 -3
- package/templates/agent-cursor.md +0 -3
- package/templates/drive/app/kb/General/README.md +1 -1
- package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
- package/templates/scaffolds/microservice/defaults-config.json +3 -1
- package/templates/scaffolds/microservice/manifest.json +9 -5
- package/templates/scaffolds/microservice/package.json +1 -1
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
package/lib/workspace-config.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
417
|
+
let parsed;
|
|
418
|
+
try {
|
|
419
|
+
parsed = JSON.parse(data);
|
|
224
420
|
} catch (error) {
|
|
225
|
-
|
|
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
|
-
'
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
721
|
+
* Get the API origin this workspace is configured for.
|
|
528
722
|
*
|
|
529
|
-
*
|
|
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
|
|
536
|
-
* environment
|
|
537
|
-
* (`descix config set-env
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
784
|
-
*
|
|
785
|
-
*
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@descix/cli",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
33
|
-
"@descix/sdk": "^
|
|
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",
|