@descix/cli 1.0.20 → 2.0.0

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.
@@ -159,12 +159,10 @@ export async function runStatus(options = {}) {
159
159
  console.log(`Config File: ${path.join(workspaceRoot, '.descix', 'workspace.json')}`);
160
160
 
161
161
  try {
162
- // List all configured apps from env.platform + env.products
163
- const platform = workspaceConfig.env?.platform;
164
- const products = workspaceConfig.env?.products || [];
165
- const allApps = [platform?.appId, ...products.map(p => p.appId)].filter(Boolean);
166
- if (allApps.length > 0) {
167
- console.log(`Local Apps: ${allApps.join(', ')}`);
162
+ // v3 single-app: the one app this workspace holds
163
+ const wsAppId = workspaceConfig.env?.app?.appId;
164
+ if (wsAppId) {
165
+ console.log(`App: ${wsAppId}`);
168
166
  }
169
167
 
170
168
  // WHAT THE WORKSPACE FILE ITSELF SAYS — deliberately NOT a resolved origin. The resolved
@@ -0,0 +1,45 @@
1
+ /**
2
+ * verifyDeployOutcome — the honest answer when a confirm_site_deploy response is LOST.
3
+ *
4
+ * ws-prune-504 (measured on prod 2026-10-10): a large prune held the confirm past the
5
+ * gateway's patience; the client saw "Upload failed: HTTP 504" while the deploy had in fact
6
+ * confirmed and the prune was finishing server-side. A lost RESPONSE is not a failed DEPLOY —
7
+ * and a tool that says "failed" when it does not know is lying in one direction.
8
+ *
9
+ * The read-back is keyed on the deploy TOKEN: get_site_deploy_token minted token_id for THIS
10
+ * upload, and confirm_site_deploy stamps it into the served manifest's deploy.token_id. If the
11
+ * served manifest carries our token, OUR deploy confirmed — no weaker signal (git sha, file
12
+ * count) can be forged by a concurrent deploy into a false positive the token cannot.
13
+ *
14
+ * @param {{invoke: (cmd: string, params: object) => Promise<any>}} apiClient
15
+ * @param {{communityId: string, appId: string, tokenId: string, preview?: boolean}} sent
16
+ * @returns {Promise<{outcome: 'confirmed'|'unconfirmed'|'unknown', detail: string, deploy?: object}>}
17
+ */
18
+ export async function verifyDeployOutcome(apiClient, { communityId, appId, tokenId, preview = false }) {
19
+ if (!tokenId) {
20
+ return { outcome: 'unknown', detail: 'no token_id in hand — cannot key the read-back' };
21
+ }
22
+ let response;
23
+ try {
24
+ response = await apiClient.invoke('get_site_manifest', {
25
+ community_id: communityId,
26
+ app_id: appId,
27
+ preview,
28
+ });
29
+ } catch (err) {
30
+ return { outcome: 'unknown', detail: `read-back failed too: ${err.message}` };
31
+ }
32
+ const payload = response?.message ?? response;
33
+ const deploy = payload?.manifest?.deploy;
34
+ if (!payload?.exists || !deploy) {
35
+ return { outcome: 'unconfirmed', detail: 'no deploy manifest is served for this app' };
36
+ }
37
+ if (deploy.token_id === tokenId) {
38
+ return { outcome: 'confirmed', detail: `served manifest carries this upload's token (deployed_at ${deploy.deployed_at})`, deploy };
39
+ }
40
+ return {
41
+ outcome: 'unconfirmed',
42
+ detail: `served manifest is a DIFFERENT deploy (token ${deploy.token_id || 'none'}, deployed_at ${deploy.deployed_at || 'unknown'})`,
43
+ deploy,
44
+ };
45
+ }
@@ -34,7 +34,6 @@
34
34
  "app set-listed": [
35
35
  "update_app"
36
36
  ],
37
- "app set-localpath": [],
38
37
  "app set-name": [
39
38
  "update_app"
40
39
  ],
@@ -49,7 +48,6 @@
49
48
  "app sync-assets": [
50
49
  "sync_app_assets"
51
50
  ],
52
- "app unmap": [],
53
51
  "buy chains": [],
54
52
  "buy poll": [
55
53
  "get_quote_status"
@@ -218,7 +216,8 @@
218
216
  "site upload": [
219
217
  "confirm_site_deploy",
220
218
  "get_site_build_config",
221
- "get_site_deploy_token"
219
+ "get_site_deploy_token",
220
+ "get_site_manifest"
222
221
  ],
223
222
  "stats": [
224
223
  "get_user_community_stats"
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
- import { ENV_ORIGINS } from '@descix/app-sdk/dev';
3
+ import { ENV_ORIGINS, resolveWorkspaceApps } from '@descix/app-sdk/dev';
4
4
  import { resolveOrigin } from './origin.js';
5
5
  // The one canonical KB-sync surface, from its owner. The example in requireContext() is a remedy
6
6
  // a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
@@ -22,8 +22,9 @@ import { npxCommand } from './invocation.js';
22
22
  */
23
23
  export function unmappedAppMessage(appId) {
24
24
  return (
25
- `App "${appId}" is not mapped in workspace.json. ` +
26
- 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
25
+ `App "${appId}" is not this workspace's app (workspace.json env.app). ` +
26
+ 'A v3 workspace is single-app: run commands for another app from ITS repo root, ' +
27
+ 'or run `descix app init -a <id>` in a fresh repo to create its workspace.'
27
28
  );
28
29
  }
29
30
 
@@ -55,6 +56,55 @@ export function resolveWorkspacePath(workspaceRoot, localPath, label = 'app') {
55
56
  return path.resolve(workspaceRoot, localPath);
56
57
  }
57
58
 
59
+ /**
60
+ * The local app set of a loaded workspace — THE one CLI-side entry to resolveWorkspaceApps.
61
+ * Every command that enumerates local apps (health, microservice restart) goes through here, so
62
+ * a config that carries no workspaceRoot FAILS LOUD in one place instead of each caller quietly
63
+ * substituting the cwd and enumerating a different workspace than the one it was handed.
64
+ *
65
+ * @param {WorkspaceConfig} wsConfig
66
+ * @returns {{ shellApp: Object|null, apps: Object[] }} see resolveWorkspaceApps
67
+ */
68
+ export function resolveLocalApps(wsConfig) {
69
+ if (!wsConfig?.workspaceRoot) {
70
+ throw new Error(
71
+ 'Cannot enumerate the workspace apps: the workspace config carries no workspaceRoot. ' +
72
+ 'Load it with WorkspaceConfig.load() (which sets it) rather than constructing it bare.'
73
+ );
74
+ }
75
+ return resolveWorkspaceApps(wsConfig.workspaceRoot);
76
+ }
77
+
78
+ /**
79
+ * `descix app init` runs where the APP REPO is — its root becomes the workspace root. A nested
80
+ * checkout used to WALK UP, find a parent workspace and silently register into it (the measured
81
+ * trap: an app repo cloned inside another workspace mapped itself into the parent's registry).
82
+ * Under v3 single-app there is nothing to register into: a workspace found at an ANCESTOR of the
83
+ * init directory is a refusal, loud, before anything is written anywhere.
84
+ *
85
+ * A workspace AT the directory itself is fine — that is the re-init case.
86
+ *
87
+ * @param {string} dir - the directory `app init` is about to make a workspace root
88
+ * @throws when a workspace.json exists at a strict ancestor of dir
89
+ */
90
+ export async function assertNoParentWorkspace(dir = process.cwd()) {
91
+ const resolved = path.resolve(dir);
92
+ const found = await WorkspaceConfig.findWorkspaceRoot(resolved);
93
+ if (found && path.resolve(found) !== resolved) {
94
+ throw new Error(
95
+ `This directory is nested inside another DeSciX workspace.\n` +
96
+ ` Here: ${resolved}\n` +
97
+ ` Parent workspace: ${found} (.descix/workspace.json)\n` +
98
+ `\n` +
99
+ `A v3 workspace is single-app: the app repo's own root is its workspace root, and\n` +
100
+ `"descix app init" refuses to run inside someone else's workspace rather than\n` +
101
+ `silently registering this checkout into it.\n` +
102
+ `Move this checkout OUT of ${found} (sibling checkouts, one gateway config, is the\n` +
103
+ `supported local layout), then run "descix app init" again from the repo root.`
104
+ );
105
+ }
106
+ }
107
+
58
108
  /**
59
109
  * The ONE text for "there is no workspace here". It is correct advice for exactly one state —
60
110
  * the file does not exist — and it is a lie for every other state, which is why it now has a
@@ -166,7 +216,7 @@ export class WorkspaceConfig {
166
216
  this.environment = config.environment;
167
217
  this.directoryMappings = config.directoryMappings || {};
168
218
 
169
- // app_id → localPath map from env.platform + env.products (Unified Registry)
219
+ // app_id → config map from env.app (v3 single-app)
170
220
  this._appIdToConfig = this._buildAppIdMap(config);
171
221
 
172
222
  // Environment URLs for descix-serve gateway routing
@@ -177,50 +227,39 @@ export class WorkspaceConfig {
177
227
  }
178
228
 
179
229
  /**
180
- * Build app_id → config map from env.platform and env.products
181
- * Unified Registry: app_id is globally unique; localPath maps to directory
230
+ * Build the app_id → config map from env.app (v3 single-app).
231
+ *
232
+ * v3 holds exactly ONE app and its localPath is implicitly '.': the app repo's root IS the
233
+ * workspace root. The map shape survives (one entry) so every reader keyed on appId keeps
234
+ * its contract; the retired v2.1 multi-app walk (env.platform + env.products) is refused by
235
+ * load() and has no reader here any more.
236
+ *
182
237
  * @param {Object} config - Parsed workspace.json
183
238
  * @returns {Object} Map of appId -> { localPath, communityId?, kbId }
184
239
  */
185
240
  _buildAppIdMap(config) {
186
241
  const map = {};
187
- const env = config.env || {};
188
- const wsRoot = config.workspaceRoot || this.workspaceRoot;
189
-
190
- // env.platform (e.g. daita -> DeSciX_Cloud)
191
- if (env.platform?.appId && env.platform?.localPath) {
192
- map[env.platform.appId] = {
193
- localPath: env.platform.localPath,
194
- communityId: env.platform.communityId || null,
195
- kbId: env.platform.kbId || 'General'
242
+ const app = config.env?.app;
243
+ if (app?.appId) {
244
+ map[app.appId] = {
245
+ localPath: '.',
246
+ communityId: app.communityId || null,
247
+ kbId: app.kbId || 'General'
196
248
  };
197
249
  }
198
-
199
- // env.products array (e.g. powch -> DeSciX_Powch)
200
- const products = Array.isArray(env.products) ? env.products : [];
201
- for (const p of products) {
202
- if (p?.appId && p?.localPath) {
203
- map[p.appId] = {
204
- localPath: p.localPath,
205
- communityId: p.communityId || null,
206
- kbId: p.kbId || 'General'
207
- };
208
- }
209
- }
210
-
211
250
  return map;
212
251
  }
213
252
 
214
253
  /**
215
254
  * Get app config by app_id (Unified Registry - app_id is globally unique)
216
- * Resolves from env.platform, env.products, communities, or products
255
+ * Resolves from env.app (v3 single-app)
217
256
  * @param {string} appId - App/product identifier
218
257
  * @returns {Object|null} { localPath, absolutePath?, communityId?, kbId } or null
219
258
  */
220
259
  getAppByAppId(appId) {
221
260
  if (!appId || !this.workspaceRoot) return null;
222
261
 
223
- // env.platform + env.products (primary for manually crafted workspace)
262
+ // env.app (v3 single-app)
224
263
  const fromEnv = this._appIdToConfig[appId];
225
264
  if (fromEnv) {
226
265
  const absPath = resolveWorkspacePath(this.workspaceRoot, fromEnv.localPath, appId);
@@ -235,87 +274,29 @@ export class WorkspaceConfig {
235
274
  return null;
236
275
  }
237
276
 
238
- /**
239
- * The RAW registry entry for an app — `localPath` exactly as workspace.json carries it, with NO
240
- * path resolution and therefore no possibility of throwing on a bad stored value.
241
- *
242
- * This exists for exactly one caller shape: a command that is about to REPLACE an app's
243
- * localPath. Such a command must not resolve the OUTGOING value. getAppByAppId() resolves, and
244
- * resolving the value you are on your way to overwrite is what made `descix app set-localpath`
245
- * unusable precisely when it was needed — a workspace already carrying a rejected localPath
246
- * could not be repaired by the one verb whose job is repairing it, while the rejection itself
247
- * forbade hand-editing. The verb was the prescribed remedy and the verb refused.
248
- *
249
- * It reads the SAME map getAppByAppId reads (_buildAppIdMap is still the one owner of "what is
250
- * mapped"); it simply stops short of resolution. Do NOT use it to obtain a usable path — take
251
- * getAppByAppId().absolutePath for that, so the loader's rejection still runs.
252
- *
253
- * @param {string} appId - App/product identifier
254
- * @returns {{ localPath: string, communityId: string|null, kbId: string }|null}
255
- */
256
- getAppEntry(appId) {
257
- if (!appId) return null;
258
- return this._appIdToConfig?.[appId] || null;
259
- }
277
+
260
278
 
261
279
  /**
262
280
  * THE ONE OWNER of "find the LIVE env entry for this app, or hard-fail".
263
281
  *
264
282
  * Live means the actual object inside this.env — mutate it and save() persists the mutation.
265
- * getAppByAppId() and getAppEntry() both return copies and are useless for writing.
283
+ * getAppByAppId() returns copies and is useless for writing.
266
284
  *
267
- * The one lookup every entry setter (setMicroservicePort, setStaticSite, setLocalPath) uses. A
268
- * private walk of env.products ONLY misses the platform app, finds nothing, writes nothing and
269
- * still reports success.
285
+ * The one lookup every entry setter (setMicroservicePort, setStaticSite) uses. In v3 the only
286
+ * live entry is env.app; any other id hard-fails with the canonical unmapped message.
270
287
  *
271
288
  * @param {string} appId - App identifier
272
- * @returns {Object} the live env.platform or env.products[] entry
273
- * @throws if appId is not mapped
289
+ * @returns {Object} the live env.app entry
290
+ * @throws if appId is not THE app
274
291
  */
275
292
  _liveEnvEntry(appId) {
276
- let entry = null;
277
- if (this.env?.platform?.appId === appId) {
278
- entry = this.env.platform;
279
- } else if (Array.isArray(this.env?.products)) {
280
- entry = this.env.products.find(p => p.appId === appId) || null;
281
- }
293
+ const entry = this.env?.app?.appId === appId ? this.env.app : null;
282
294
  if (!entry) {
283
295
  throw new Error(unmappedAppMessage(appId));
284
296
  }
285
297
  return entry;
286
298
  }
287
299
 
288
- /**
289
- * Update an app's localPath in env.platform or env.products[].
290
- *
291
- * The canonical write path for localPath. Backs `descix app set-localpath`.
292
- *
293
- * localPath MUST be workspace-root-relative. It is validated here through
294
- * resolveWorkspacePath — the LOADER'S OWN resolver, not a second copy of its rules — so a
295
- * value the loader will refuse on the next read is refused now, before anything is written.
296
- * Writing a value the loader rejects is what bricked workspaces: every subsequent command
297
- * threw, including the repair verbs, while the rejection forbade hand-editing.
298
- *
299
- * Persists via save() — same auto-save pattern as its siblings.
300
- * Hard-fails if appId is not mapped in env.platform or env.products.
301
- *
302
- * @param {string} appId - App identifier (must exist in env.platform or env.products)
303
- * @param {string} localPath - New path, relative to the workspace root
304
- * @returns {Promise<string>} Path to saved config (from save())
305
- */
306
- async setLocalPath(appId, localPath) {
307
- if (!appId) throw new Error('appId is required');
308
- if (!localPath) throw new Error('localPath is required');
309
-
310
- const entry = this._liveEnvEntry(appId);
311
-
312
- // Validate through the loader's own resolver. Throws on an absolute path.
313
- resolveWorkspacePath(this.workspaceRoot, localPath, appId);
314
-
315
- entry.localPath = localPath;
316
- return this.save();
317
- }
318
-
319
300
  /**
320
301
  * Get the absolute path to an app's site/ directory
321
302
  * @param {string} appId - App identifier
@@ -437,6 +418,28 @@ export class WorkspaceConfig {
437
418
  );
438
419
  }
439
420
 
421
+ // v2.1 MULTI-APP hard-error: env.platform / env.products are the retired mapping model —
422
+ // one workspace registering OTHER repos' apps by localPath. v3 is single-app (CEO
423
+ // 2026-10-09; fork (b) 2026-10-10): the app repo's root IS the workspace root, and local
424
+ // multi-app dev is sibling checkouts behind one gateway config. Same posture as the v1
425
+ // refusal above: loud, non-destructive, the remedy is one command per app repo.
426
+ if (parsed.env && (parsed.env.platform || parsed.env.products)) {
427
+ throw new Error(
428
+ 'v2.1 multi-app workspace format is not supported. Migrate to v3 (single-app).\n' +
429
+ ` File: ${configPath}\n` +
430
+ '\n' +
431
+ 'This file is still on disk and has NOT been modified. v3 holds exactly ONE app\n' +
432
+ '(env.app) and the app repo\'s own root is the workspace root. To migrate:\n' +
433
+ ' • In EACH app repo this file mapped, run from that repo\'s root:\n' +
434
+ ' descix config init --env <dev|demo|prod> # pins env.apiUrl\n' +
435
+ ' descix app init -a <app_id> # writes the v3 env.app block\n' +
436
+ ' • A gateway hosting several local apps lists their repo roots in ITS OWN\n' +
437
+ ' workspace.json under env.gateway.appRepos (fork (b): sibling checkouts, one\n' +
438
+ ' gateway config).\n' +
439
+ moveAsideRemedy(configPath, 'descix app init')
440
+ );
441
+ }
442
+
440
443
  // A v2.1 file carrying a TOP-LEVEL `apiUrl` is refused by name. That key is retired: no
441
444
  // reader consumes it (the origin owner resolves `env.apiUrl` only) and save() never writes
442
445
  // it, so a developer who set it by hand would be silently ignored on every invocation —
@@ -480,8 +483,8 @@ export class WorkspaceConfig {
480
483
  }
481
484
 
482
485
  /**
483
- * Save workspace configuration in v2.1 format.
484
- * Always writes env.platform / env.products structure.
486
+ * Save workspace configuration in v3 (single-app) format: env.app, never
487
+ * env.platform / env.products.
485
488
  *
486
489
  * PRESERVES KEYS IT DOES NOT UNDERSTAND. This method used to serialize a fixed field list from
487
490
  * memory over the whole file, so ANY top-level key it did not itself enumerate was silently
@@ -508,7 +511,7 @@ export class WorkspaceConfig {
508
511
 
509
512
  this.workspaceRoot = root;
510
513
 
511
- // Build env block for v2.1 output
514
+ // Build env block for v3 output
512
515
  const envBlock = (this.env && Object.keys(this.env).length > 0) ? this.env : null;
513
516
 
514
517
  // The keys this writer OWNS. Everything else on disk is carried through untouched.
@@ -537,7 +540,7 @@ export class WorkspaceConfig {
537
540
 
538
541
  const configData = {
539
542
  ...carried,
540
- version: '2.1',
543
+ version: '3.0',
541
544
  workspaceRoot: path.resolve(root),
542
545
  type: this.type,
543
546
  };
@@ -552,54 +555,47 @@ export class WorkspaceConfig {
552
555
  // ============ App Registration Methods ============
553
556
 
554
557
  /**
555
- * Register an app in the v2.1 workspace (env.platform or env.products).
556
- * community_id is server-authoritative — not stored in workspace.json.
557
- * @param {string} communityId - Accepted for call-site compatibility but not stored
558
+ * Register THE app of this v3 workspace (env.app).
559
+ *
560
+ * Single-app is the FORMAT, not a convention: a workspace that already holds a different app
561
+ * refuses loudly — there is no second slot to put one in, and silently replacing env.app
562
+ * would re-point every path-derived surface (site/, microservice/, manifests) at a different
563
+ * platform record. Re-registering the SAME app is idempotent (the `app init` re-run case) and
564
+ * may update kbId / communityId.
565
+ *
566
+ * localPath is GONE as an input: the app repo's root is the workspace root, always ('.').
567
+ *
568
+ * communityId is STORED, not just validated. It was previously accepted as a parameter and
569
+ * silently discarded, so `descix init -c egpt` wrote no trace of `egpt` anywhere — and
570
+ * `workspace-identity.js` (this entry's reader) then had no community to report, which is
571
+ * how the generated agent files named nobody's community. Writer and reader are a pair.
572
+ *
573
+ * @param {string} communityId - The app's community (server-authoritative value, mirrored here)
558
574
  * @param {string} appId - App identifier (globally unique)
559
- * @param {Object} appConfig - App configuration
560
- * @param {string} appConfig.localPath - Local folder path relative to workspaceRoot
561
- * @param {string} [appConfig.kbId] - Default knowledge base ID
575
+ * @param {Object} [appConfig] - { kbId? }
562
576
  * @returns {boolean} Success status
563
577
  */
564
- registerApp(communityId, appId, appConfig) {
565
- if (!appId || !appConfig.localPath) {
566
- throw new Error('appId and localPath are required');
578
+ registerApp(communityId, appId, appConfig = {}) {
579
+ if (!appId) {
580
+ throw new Error('appId is required');
567
581
  }
568
582
 
569
- // Validate through THE LOADER'S OWN RESOLVER — the same owner setLocalPath consumes, never a
570
- // second copy of its rules. Three writers persisted a localPath and only ONE validated it, so
571
- // `descix app init -p /abs` wrote an absolute value, created directories at that arbitrary
572
- // absolute location, printed success, and bricked the workspace on the NEXT read — the loader
573
- // refused the value for every command including the repair verbs, while the refusal forbade
574
- // hand-editing. A path this CLI accepts must be honoured, or refused before anything is written.
575
- resolveWorkspacePath(this.workspaceRoot, appConfig.localPath, appId);
576
-
577
583
  if (!this.env) this.env = {};
578
- if (!Array.isArray(this.env.products)) this.env.products = [];
579
-
580
- // communityId is STORED, not just validated. It was previously accepted as a parameter and
581
- // silently discarded, so `descix init -c egpt` wrote no trace of `egpt` anywhere — and
582
- // `workspace-identity.js` (this entry's reader) then had no community to report, which is
583
- // how the generated agent files named nobody's community. Writer and reader are a pair:
584
- // the key order in `readIdentity()` mirrors the shape written here.
585
- const entry = { appId, localPath: appConfig.localPath, kbId: appConfig.kbId || 'General' };
586
- if (communityId) entry.communityId = communityId;
587
-
588
- if (this.env.platform?.appId === appId) {
589
- this.env.platform.localPath = appConfig.localPath;
590
- if (appConfig.kbId) this.env.platform.kbId = appConfig.kbId;
591
- if (communityId) this.env.platform.communityId = communityId;
592
- } else {
593
- const idx = this.env.products.findIndex(p => p.appId === appId);
594
- if (idx >= 0) {
595
- this.env.products[idx] = { ...this.env.products[idx], ...entry };
596
- } else {
597
- this.env.products.push(entry);
598
- }
584
+ const existing = this.env.app;
585
+ if (existing?.appId && existing.appId !== appId) {
586
+ throw new Error(
587
+ `This workspace already holds app '${existing.appId}' — a v3 workspace is single-app.\n` +
588
+ `To work on '${appId}', clone or create its OWN repo and run "descix app init" from\n` +
589
+ `that repo's root (sibling checkouts, one gateway config, is the supported local layout).`
590
+ );
599
591
  }
600
592
 
593
+ const entry = { ...(existing || {}), appId, kbId: appConfig.kbId || existing?.kbId || 'General' };
594
+ if (communityId) entry.communityId = communityId;
595
+ this.env.app = entry;
596
+
601
597
  this._appIdToConfig = this._buildAppIdMap({ env: this.env, workspaceRoot: this.workspaceRoot });
602
- this.version = '2.1';
598
+ this.version = '3.0';
603
599
  return true;
604
600
  }
605
601
 
@@ -648,7 +644,7 @@ export class WorkspaceConfig {
648
644
  const cwd = path.resolve(startDir);
649
645
  const wsRoot = this.workspaceRoot ? path.resolve(this.workspaceRoot) : null;
650
646
 
651
- // env.platform + env.products (Unified Registry - primary)
647
+ // env.app (v3 single-app): at most one entry
652
648
  if (wsRoot) {
653
649
  for (const [appId, cfg] of Object.entries(this._appIdToConfig || {})) {
654
650
  const appPath = resolveWorkspacePath(wsRoot, cfg.localPath, appId);
@@ -776,7 +772,7 @@ export class WorkspaceConfig {
776
772
  throw new Error(
777
773
  `"${dottedKey}" is not a workspace-level env key.\n` +
778
774
  ` Settable here: ${ALLOWED.join(', ')}\n` +
779
- ' Per-app keys have their own verbs: descix app set-site / set-port / set-localpath.'
775
+ ' Per-app keys have their own verbs: descix app set-site / set-port.'
780
776
  );
781
777
  }
782
778
 
@@ -806,7 +802,7 @@ export class WorkspaceConfig {
806
802
  }
807
803
 
808
804
  /**
809
- * Update an app's microservice.port in env.products[] (or env.platform if it is the platform app).
805
+ * Update the app's microservice.port on env.app (v3 single-app).
810
806
  *
811
807
  * Operates on the entry's microservice.{} slot.
812
808
  * This is the canonical write path for the microservice port that `descix microservice init`
@@ -815,9 +811,9 @@ export class WorkspaceConfig {
815
811
  *
816
812
  * Pass `null` to remove microservice.port; if microservice.{} becomes empty, it is also deleted.
817
813
  * Persists via save() at the end — same auto-save pattern as setEnvironment().
818
- * Hard-fails if appId is not mapped in env.platform or env.products.
814
+ * Hard-fails if appId is not THE app (env.app).
819
815
  *
820
- * @param {string} appId - App identifier (must exist in env.platform or env.products)
816
+ * @param {string} appId - App identifier (must be env.app.appId)
821
817
  * @param {number|string|null} port - Port number to set, or null to remove microservice.port
822
818
  * @returns {Promise<string>} Path to saved config (from save())
823
819
  */
@@ -843,7 +839,7 @@ export class WorkspaceConfig {
843
839
  }
844
840
 
845
841
  /**
846
- * Update an app's site config in env.products[] (or env.platform if it is the platform app).
842
+ * Update the app's site config on env.app (v3 single-app).
847
843
  *
848
844
  * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot. This is the
849
845
  * canonical write path for site.static — the relative path the dev gateway's
@@ -867,9 +863,9 @@ export class WorkspaceConfig {
867
863
  * Pass null for a field to remove it (what `--unset` does for all three). Removals clear
868
864
  * only the named field. If site.{} becomes empty after removals it is deleted.
869
865
  * Persists via save() at the end — same auto-save pattern as setMicroservicePort().
870
- * Hard-fails if appId is not mapped in env.platform or env.products.
866
+ * Hard-fails if appId is not THE app (env.app).
871
867
  *
872
- * @param {string} appId - App identifier (must exist in env.platform or env.products)
868
+ * @param {string} appId - App identifier (must be env.app.appId)
873
869
  * @param {Object} fields - Fields to set on site.{}
874
870
  * @param {string|null} [fields.static] - Relative static-site path to set, or null to remove site.static
875
871
  * @param {number|string|null} [fields.port] - Site dev-server port to set, or null to remove site.port
@@ -1006,9 +1002,9 @@ export class WorkspaceConfig {
1006
1002
  validate() {
1007
1003
  const errors = [];
1008
1004
 
1009
- // v2.1 validation: must have env block
1005
+ // v3 validation: must have env block
1010
1006
  if (!this.env || Object.keys(this.env).length === 0) {
1011
- errors.push('Missing env block — workspace.json must be v2.1 format with env.platform or env.products');
1007
+ errors.push('Missing env block — workspace.json must be v3 format with env.app');
1012
1008
  }
1013
1009
 
1014
1010
  return {
@@ -24,10 +24,9 @@ import { resolveOrigin } from './origin.js';
24
24
  /**
25
25
  * Read the workspace's identity from a parsed `.descix/workspace.json`.
26
26
  *
27
- * The key order below mirrors `WorkspaceConfig.registerApp()` — `env.platform` for the
28
- * workspace's own platform app, then `env.products[]` for a registered product, then the
29
- * legacy `defaultContext`. If that order ever diverges from the writer's, the agent files go
30
- * wrong again, so the two are commented as a pair.
27
+ * v3 single-app: the one key the writer (`WorkspaceConfig.registerApp()`) writes is `env.app`,
28
+ * so that is the one key read here — writer and reader are a pair. The legacy `defaultContext`
29
+ * survives as the last resort for files written before the env block existed.
31
30
  *
32
31
  * @param {object} config - the PARSED workspace.json (not a WorkspaceConfig instance, so that
33
32
  * `agent-files.js` can stay usable from the VS Code extension where it
@@ -37,22 +36,16 @@ import { resolveOrigin } from './origin.js';
37
36
  */
38
37
  export function readIdentity(config = {}) {
39
38
  const env = config.env || {};
40
- const platform = env.platform || {};
41
- const products = Array.isArray(env.products) ? env.products : [];
42
- // The first registered product is the workspace's app when there is no platform app. A
43
- // workspace with several products has no single identity, and `init` only ever writes one.
44
- const product = products[0] || {};
39
+ const app = env.app || {};
45
40
  const legacy = config.defaultContext || {};
46
41
 
47
42
  const appId =
48
- platform.appId ||
49
- product.appId ||
43
+ app.appId ||
50
44
  legacy.appId ||
51
45
  null;
52
46
 
53
47
  const communityId =
54
- platform.communityId ||
55
- product.communityId ||
48
+ app.communityId ||
56
49
  legacy.communityId ||
57
50
  config.primaryCommunity ||
58
51
  null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.20",
3
+ "version": "2.0.0",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "author": "DeSciX",
31
31
  "license": "SEE LICENSE IN LICENSE.md",
32
32
  "dependencies": {
33
- "@descix/app-sdk": "^0.2.0",
33
+ "@descix/app-sdk": "^0.3.0",
34
34
  "@descix/sdk": "^3.0.0",
35
35
  "@modelcontextprotocol/sdk": "^1.25.1",
36
36
  "axios": "^1.7.9",