@descix/cli 1.0.21 → 2.0.1

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/bin/descix.js CHANGED
@@ -1082,7 +1082,6 @@ appCommand
1082
1082
  .option('-s, --short <short_name>', 'SHORT id segment (no hyphens) used when creating; the app_id is composed server-side as {community}-{short}. Defaults to --app.')
1083
1083
  .option('--overwrite', 'When creating, overwrite an existing app record intentionally')
1084
1084
  .option('--kb <name>', 'Knowledge base name', 'General')
1085
- .option('-p, --path <dir>', 'Local app directory (default: auto-detected or cwd)')
1086
1085
  .action(async (options) => {
1087
1086
  try {
1088
1087
  const apiClient = new DeSciXApiClient();
@@ -1501,111 +1500,15 @@ appCommand
1501
1500
  }
1502
1501
  });
1503
1502
 
1504
- appCommand
1505
- .command('set-localpath')
1506
- .description('Update the local directory path for a mapped app')
1507
- .requiredOption('-a, --app <app_id>', 'App ID')
1508
- .requiredOption('-p, --path <dir>', 'New local directory path')
1509
- .action(async (options) => {
1510
- try {
1511
- const appId = options.app;
1512
- const newPath = options.path;
1513
-
1514
- const workspaceConfig = await WorkspaceConfig.load();
1515
- const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1516
-
1517
- // Read the RAW registry entry, never the RESOLVED one. getAppByAppId() resolves the stored
1518
- // localPath, so on a workspace already carrying a rejected value it throws here — before
1519
- // any write is attempted. That is what made this verb refuse exactly when it was needed,
1520
- // while the rejection told the user to fix it with the config verb and never by hand.
1521
- // This command is about to REPLACE that value; it has no business resolving it.
1522
- //
1523
- // Display value only. setLocalPath() is the SINGLE authority on whether the app is mapped
1524
- // and refuses if it is not — deciding that here as well would be two derivations of one
1525
- // fact, and they would not even agree: _buildAppIdMap (behind getAppEntry) requires BOTH
1526
- // appId and localPath, while the live-entry walk matches on appId alone.
1527
- const oldPath = workspaceConfig.getAppEntry(appId)?.localPath;
1528
-
1529
- // VALIDATE AT THE WRITE, through the LOADER'S OWN resolver — not a second copy of its rules.
1530
- // If resolveWorkspacePath would reject this value on the next read, it is rejected now, and
1531
- // nothing reaches disk. Writing a value the loader will refuse is what bricks a workspace.
1532
- const absPath = resolveWorkspacePath(wsRoot, newPath, appId);
1533
-
1534
- // Hard-fail if path doesn't exist or is not a directory — checked at the path the LOADER
1535
- // will resolve to, not at a CWD-relative path that only exists from where you happened to
1536
- // be standing when you typed it.
1537
- let stat;
1538
- try {
1539
- stat = await fs.stat(absPath);
1540
- } catch {
1541
- throw new Error(
1542
- `Path does not exist: ${absPath}\n` +
1543
- ` (localPath '${newPath}' resolved against workspace root ${wsRoot})`
1544
- );
1545
- }
1546
- if (!stat.isDirectory()) {
1547
- throw new Error(
1548
- `Path is not a directory: ${absPath}\n` +
1549
- ` (localPath '${newPath}' resolved against workspace root ${wsRoot})`
1550
- );
1551
- }
1552
-
1553
- // Write through the canonical setter, which covers env.platform AND env.products[]. The
1554
- // loop this replaces walked env.products ONLY: on the platform app it matched nothing,
1555
- // wrote nothing, and still printed success — a loud failure turned into a silent lie at
1556
- // the one entry that matters most.
1557
- await workspaceConfig.setLocalPath(appId, newPath);
1558
-
1559
- console.log(chalk.green(`\n✓ ${appId} local path updated`));
1560
- console.log(chalk.gray(` ${oldPath} → ${newPath}\n`));
1561
- } catch (error) {
1562
- console.error(chalk.red(error.message));
1563
- process.exit(1);
1564
- }
1565
- });
1566
-
1567
- appCommand
1568
- .command('unmap')
1569
- .description('Remove an app from the local workspace mapping (does not delete Firestore/Pinecone data)')
1570
- .requiredOption('-a, --app <app_id>', 'App ID')
1571
- .action(async (options) => {
1572
- try {
1573
- const appId = options.app;
1574
-
1575
- const workspaceConfig = await WorkspaceConfig.load();
1576
- const appConfig = workspaceConfig.getAppByAppId(appId);
1577
- if (!appConfig) {
1578
- throw new Error(`App '${appId}' is not mapped.`);
1579
- }
1580
-
1581
- // Remove from env.products
1582
- const products = workspaceConfig.env?.products || [];
1583
- const idx = products.findIndex(p => p.appId === appId || p.app_id === appId);
1584
- if (idx === -1) {
1585
- throw new Error(`App '${appId}' is not found in env.products.`);
1586
- }
1587
- products.splice(idx, 1);
1588
-
1589
- const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1590
- await workspaceConfig.save(wsRoot);
1591
-
1592
- console.log(chalk.green(`\n✓ ${appId} removed from workspace mapping`));
1593
- console.log(chalk.gray(` Pinecone vectors, Firestore docs, and Drive data are not affected.\n`));
1594
- } catch (error) {
1595
- console.error(chalk.red(error.message));
1596
- process.exit(1);
1597
- }
1598
- });
1599
-
1600
1503
  // ============ App: set-port (WS-CLI-MESH-ROUTING-GAP) ============
1601
1504
  //
1602
1505
  // Canonical write path for an app's microservice.port in workspace.json.
1603
- // `descix microservice init` READS env.products[<app>].microservice.port and hard-fails
1506
+ // `descix microservice init` READS env.app.microservice.port and hard-fails
1604
1507
  // if it is missing; this command is how that port is set, without hand-editing workspace.json.
1605
1508
  // Backed by WorkspaceConfig.setMicroservicePort.
1606
1509
  appCommand
1607
1510
  .command('set-port')
1608
- .description('Set the microservice port for a mapped app (writes env.products[<app>].microservice.port). Pass "n" to remove.')
1511
+ .description('Set the microservice port for this workspace\'s app (writes env.app.microservice.port). Pass "n" to remove.')
1609
1512
  .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1610
1513
  .requiredOption('-p, --port <port>', 'Microservice port number (1-65535), or "n" to remove')
1611
1514
  .action(async (options) => {
@@ -1632,7 +1535,7 @@ appCommand
1632
1535
 
1633
1536
  console.log(chalk.green(`\n✓ Microservice port set for ${appId}`));
1634
1537
  console.log(chalk.cyan(` Port: ${portNum}`));
1635
- console.log(chalk.gray(` Written to env.products[${appId}].microservice.port in workspace.json.`));
1538
+ console.log(chalk.gray(` Written to env.app.microservice.port in workspace.json.`));
1636
1539
  console.log(chalk.gray(` The gateway routes the microservice on this port; \`descix microservice init -a ${appId}\` can now scaffold it.\n`));
1637
1540
  } catch (error) {
1638
1541
  console.error(chalk.red(error.message));
@@ -1656,7 +1559,7 @@ appCommand
1656
1559
  // Backed by WorkspaceConfig.setStaticSite. It writes ONLY the local workspace.json site.{} slot.
1657
1560
  appCommand
1658
1561
  .command('set-site')
1659
- .description("Set a mapped app's site config (env.products[<app>].site). One active mode per write: --static <dir> (built site served at /p/<app>/) or --port <n> [--protocol http|https] (dev server proxied at /p/<app>); setting one clears the other. Pass both flags together to keep both — the gateway then serves the dev server (site.port wins). --unset clears site.{} entirely.")
1562
+ .description("Set this workspace app's site config (env.app.site). One active mode per write: --static <dir> (built site served at /p/<app>/) or --port <n> [--protocol http|https] (dev server proxied at /p/<app>); setting one clears the other. Pass both flags together to keep both — the gateway then serves the dev server (site.port wins). --unset clears site.{} entirely.")
1660
1563
  .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1661
1564
  .option('--static <path>', 'Static mode: relative dir under the app localPath (e.g. "site"; "." = the localPath itself). Alone, it clears site.port and site.protocol (one active mode per write).')
1662
1565
  .option('--port <port>', 'Dev-server mode: proxy /p/<app> to your local dev server on this port (1-65535). Alone, it clears site.static. With --static in the same call, both are kept and the gateway serves the dev server.')
@@ -1715,9 +1618,7 @@ appCommand
1715
1618
  // Print the RESULTING slot, not just the fields passed: a dev-server write keeps a
1716
1619
  // previously declared site.protocol (same mode, new port), and a retained field the
1717
1620
  // output does not show is invisible state — the whole defect class this command fixes.
1718
- const entryNow = workspaceConfig.env?.platform?.appId === appId
1719
- ? workspaceConfig.env.platform
1720
- : (workspaceConfig.env?.products || []).find(p => p.appId === appId);
1621
+ const entryNow = workspaceConfig.env?.app?.appId === appId ? workspaceConfig.env.app : null;
1721
1622
  console.log(chalk.cyan(` site is now: ${JSON.stringify(entryNow?.site)}`));
1722
1623
  if (fields.static !== undefined && fields.port === undefined) {
1723
1624
  console.log(chalk.gray(` site.port and site.protocol cleared (one active mode per write) — /p/${appId}/ now serves the static dir.`));
@@ -1726,7 +1627,7 @@ appCommand
1726
1627
  } else if (fields.static !== undefined && fields.port !== undefined) {
1727
1628
  console.log(chalk.yellow(` Combined write: BOTH modes kept. The gateway serves the DEV SERVER (site.port wins); site.static is used only after you clear site.port (--static alone, or --unset).`));
1728
1629
  }
1729
- console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json; \`descix serve\` picks it up.\n`));
1630
+ console.log(chalk.gray(` Written to env.app.site in workspace.json; \`descix serve\` picks it up.\n`));
1730
1631
  } catch (error) {
1731
1632
  console.error(chalk.red(error.message));
1732
1633
  process.exit(1);
@@ -1741,9 +1642,9 @@ appCommand
1741
1642
  // Reuses the SDK resolver resolveAppGatewayUrl() — the same workspace.json read path
1742
1643
  // behind buildWorkspaceProducts() that the PWA bakes into __WORKSPACE_PRODUCTS__ — so
1743
1644
  // the URL matches what the app store routes to:
1744
- // - env.platform → https://localhost:{gatewayPort}/
1745
- // - product (static) → https://localhost:{gatewayPort}/p/{appId}
1746
- // - product (dev-server)→ https://localhost:{gatewayPort}/p/{appId}
1645
+ // - the gateway shell → https://localhost:{gatewayPort}/
1646
+ // - an app (static) → https://localhost:{gatewayPort}/p/{appId}
1647
+ // - an app (dev-server) → https://localhost:{gatewayPort}/p/{appId}
1747
1648
  // Hard-fails clearly if the app is unmapped or has no site config.
1748
1649
  appCommand
1749
1650
  .command('open')
@@ -2857,13 +2758,7 @@ microserviceCommand
2857
2758
  // Hard-fail early if missing — a scaffolded microservice without a known port
2858
2759
  // cannot be routed by the gateway and cannot start on a deterministic port.
2859
2760
  const env = workspaceConfig.env || {};
2860
- let microservicePort = null;
2861
- if (env.platform?.appId === ctx.appId) {
2862
- microservicePort = env.platform?.microservice?.port || null;
2863
- } else if (Array.isArray(env.products)) {
2864
- const productEntry = env.products.find(p => p.appId === ctx.appId);
2865
- microservicePort = productEntry?.microservice?.port || null;
2866
- }
2761
+ const microservicePort = env.app?.appId === ctx.appId ? (env.app.microservice?.port || null) : null;
2867
2762
 
2868
2763
  if (!microservicePort) {
2869
2764
  console.error(chalk.red(`\n❌ App '${ctx.appId}' has no microservice.port in workspace.json.`));
@@ -4206,7 +4101,7 @@ program
4206
4101
  // app_ids". `--scope project` was dead on this surface for exactly as long as v2.1 has been
4207
4102
  // the format.
4208
4103
  //
4209
- // v2.1 carries APP IDS ONLY (env.platform.appId, env.products[].appId) — no community ids.
4104
+ // v3 carries ONE APP ID (env.app.appId) — no community ids.
4210
4105
  // We therefore send app_ids alone and let the server resolve ownership from Products.
4211
4106
  // Deriving a community_id by splitting an app_id would violate the platform invariant that
4212
4107
  // app ids are OPAQUE to routing and lookups.
@@ -4226,14 +4121,11 @@ program
4226
4121
  process.exit(1);
4227
4122
  }
4228
4123
  const appIds = new Set();
4229
- if (wsConfig?.env?.platform?.appId) appIds.add(wsConfig.env.platform.appId);
4230
- for (const product of wsConfig?.env?.products || []) {
4231
- if (product?.appId) appIds.add(product.appId);
4232
- }
4124
+ if (wsConfig?.env?.app?.appId) appIds.add(wsConfig.env.app.appId);
4233
4125
  // Fail LOUD rather than sending an empty context the server can only reject.
4234
4126
  if (appIds.size === 0) {
4235
- console.error(chalk.red("project scope: no apps found in .descix/workspace.json (env.platform.appId / env.products[].appId)."));
4236
- console.error(chalk.gray(" Use --scope entitlements, or add products to the workspace."));
4127
+ console.error(chalk.red("project scope: this workspace holds no app (workspace.json env.app)."));
4128
+ console.error(chalk.gray(" Use --scope entitlements, or run `descix app init` in this repo."));
4237
4129
  process.exit(1);
4238
4130
  }
4239
4131
  project_context = { app_ids: Array.from(appIds) };
@@ -12,27 +12,24 @@
12
12
  import chalk from 'chalk';
13
13
  import fs from 'fs/promises';
14
14
  import path from 'path';
15
- import { WorkspaceConfig, resolveWorkspacePath } from '../workspace-config.js';
15
+ import { WorkspaceConfig, assertNoParentWorkspace } from '../workspace-config.js';
16
16
  import { refreshCommunityIdentity, printIdentityReceipt } from './communityIdentity.js';
17
17
  import { generateAgentFiles } from '../agent-files.js';
18
18
 
19
19
  /**
20
20
  * @param {Object} apiClient an authenticated DeSciXApiClient
21
- * @param {{app: string, community?: string, short?: string, overwrite?: boolean, kb?: string, path?: string}} options
21
+ * @param {{app: string, community?: string, short?: string, overwrite?: boolean, kb?: string}} options
22
22
  * @returns {Promise<{appId: string, communityId: string, appPath: string, kbId: string}>}
23
23
  */
24
24
  export async function runAppInit(apiClient, options) {
25
25
  let appId = options.app;
26
26
  const kbId = options.kb || 'General';
27
27
 
28
- // Refuse an unusable -p BEFORE anything is written — server-side included. The same resolver
29
- // the workspace loader applies on every read decides it, so a value accepted here is one the
30
- // next read accepts too. Refusing only at registration came AFTER a `-c` create, leaving a
31
- // platform app with no local registration (measured 2026-09-18, daita-docs).
32
- const workspaceConfig = await WorkspaceConfig.tryLoad();
33
- if (options.path) {
34
- resolveWorkspacePath(workspaceConfig?.workspaceRoot || process.cwd(), options.path, appId);
35
- }
28
+ // v3 single-app: THIS directory becomes (or already is) the workspace root. A workspace at an
29
+ // ANCESTOR is a refusal, loud, BEFORE anything is written anywhere — the walk-up used to
30
+ // silently register a nested checkout into the parent workspace (the measured trap).
31
+ await assertNoParentWorkspace(process.cwd());
32
+ const workspaceConfig = await WorkspaceConfig.tryLoad(process.cwd());
36
33
 
37
34
  // Resolve community_id from the Products registry to learn whether the app exists yet.
38
35
  let communityId;
@@ -74,26 +71,15 @@ export async function runAppInit(apiClient, options) {
74
71
  );
75
72
  }
76
73
 
77
- // 1. Workspace.json — register app if not already mapped
78
- const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
79
- let appPath = alreadyMapped?.absolutePath;
80
-
81
- if (alreadyMapped && options.path) {
82
- throw new Error(
83
- `App '${appId}' is already mapped to '${alreadyMapped.localPath}'. ` +
84
- `Use 'descix app set-localpath -a ${appId} -p <new-path>' to update.`
85
- );
86
- }
87
-
74
+ // 1. Workspace.json — THIS repo root is the workspace root and holds exactly this app (v3).
75
+ // registerApp is idempotent for the same id and REFUSES a different one: a repo that already
76
+ // holds another app errors loudly instead of being silently re-pointed.
88
77
  const wsRoot = workspaceConfig?.workspaceRoot || process.cwd();
89
- if (!alreadyMapped) {
90
- const localPath = options.path || '.';
91
- const cfg = workspaceConfig || new WorkspaceConfig({}, wsRoot);
92
- cfg.registerApp(communityId, appId, { localPath, kbId });
93
- await cfg.save(wsRoot);
94
- appPath = path.resolve(wsRoot, localPath);
95
- console.log(chalk.gray(` workspace.json updated: ${appId} → ${localPath}`));
96
- }
78
+ const cfg = workspaceConfig || new WorkspaceConfig({ version: '3.0' }, wsRoot);
79
+ cfg.registerApp(communityId, appId, { kbId });
80
+ await cfg.save(wsRoot);
81
+ const appPath = wsRoot;
82
+ console.log(chalk.gray(` workspace.json: env.app = ${appId} (this repo root)`));
97
83
 
98
84
  // 2. Create app folder structure (site, microservice, assets)
99
85
  const assetsDir = path.join(appPath, 'assets');
@@ -22,9 +22,18 @@ const POLL_INTERVAL_MS = 3000; // 3 seconds
22
22
  const MAX_POLL_TIME_MS = 15 * 60 * 1000; // 15 minutes
23
23
 
24
24
  /**
25
- * Open browser to URL
25
+ * Open browser to URL — INTERACTIVE SESSIONS ONLY.
26
+ *
27
+ * A non-interactive invocation (a spawned test harness, CI, any piped run) must NEVER reach
28
+ * into the operator's live browser: measured 2026-10-10, the CLI test suite repeatedly seized
29
+ * the CEO's active tab because every spawned `quickstart`/`login` walked straight into the
30
+ * device flow's `open`. No TTY on stdout = print the URL, open nothing. DESCIX_NO_BROWSER=1
31
+ * forces the same for interactive runs that want it.
26
32
  */
27
33
  async function openBrowser(url) {
34
+ if (!process.stdout.isTTY || process.env.DESCIX_NO_BROWSER === '1') {
35
+ return false; // the caller already prints the URL on a false return
36
+ }
28
37
  const platform = process.platform;
29
38
  let command;
30
39
 
@@ -34,12 +34,10 @@ export async function show() {
34
34
  console.log(chalk.yellow(` ⚠ This workspace pins ${normalizeOrigin(pinned)} (${environmentNameFor(pinned)}), but ${effective.source} overrides it for this shell.`));
35
35
  }
36
36
 
37
- // Show mapped apps (v2.1 env.products)
38
- const platform = workspaceConfig.env?.platform;
39
- const products = workspaceConfig.env?.products || [];
40
- const allApps = [platform?.appId, ...products.map(p => p.appId)].filter(Boolean);
41
- if (allApps.length > 0) {
42
- console.log(chalk.white(` Apps: ${allApps.join(', ')}`));
37
+ // v3 single-app: the one app this workspace holds
38
+ const appId = workspaceConfig.env?.app?.appId;
39
+ if (appId) {
40
+ console.log(chalk.white(` App: ${appId}`));
43
41
  }
44
42
 
45
43
  console.log('');
@@ -373,7 +373,7 @@ export async function runCorpusSync(apiClient, options) {
373
373
  const appConfig = workspaceConfig.getAppByAppId(appId);
374
374
  if (!appConfig) {
375
375
  spinner.fail(`App not found: ${appId}`);
376
- throw new Error(`App "${appId}" not found in workspace.json. Check env.products[].`);
376
+ throw new Error(`App "${appId}" is not this workspace's app (workspace.json env.app).`);
377
377
  }
378
378
 
379
379
  const appRoot = appConfig.absolutePath;
@@ -36,7 +36,7 @@ import { promisify } from 'util';
36
36
  import { localUpstreamOrigin, resolveGatewayPort } from '@descix/app-sdk/dev';
37
37
  import https from 'https';
38
38
  import http from 'http';
39
- import { WorkspaceConfig } from '../workspace-config.js';
39
+ import { WorkspaceConfig, resolveLocalApps } from '../workspace-config.js';
40
40
 
41
41
  const execAsync = promisify(exec);
42
42
 
@@ -109,47 +109,27 @@ function defaultHttpsProbe(url, timeoutMs = 8000, { insecure = false } = {}) {
109
109
  */
110
110
  function buildServiceRegistry(wsConfig) {
111
111
  const services = [];
112
- const env = wsConfig.env || {};
113
112
 
114
- if (env.platform) {
115
- const name = env.platform.appId || 'platform';
116
- if (env.platform.microservice?.port) {
113
+ // The local app set has ONE owner (workspaceApps, v3 fork (b)): this workspace's own
114
+ // env.app plus every repo env.gateway lists, each read from its OWN workspace.json.
115
+ const { apps } = resolveLocalApps(wsConfig);
116
+ for (const app of apps) {
117
+ if (app.microservice?.port) {
117
118
  services.push({
118
- name,
119
- appId: env.platform.appId,
120
- port: env.platform.microservice.port,
119
+ name: app.appId,
120
+ appId: app.appId,
121
+ port: app.microservice.port,
121
122
  type: 'microservice',
122
- origin: localUpstreamOrigin(env.platform.microservice, 'env.platform.microservice'),
123
+ origin: localUpstreamOrigin(app.microservice, `env.app.microservice (${app.appId})`),
123
124
  });
124
125
  }
125
- if (env.platform.site?.port) {
126
+ if (app.site?.port) {
126
127
  services.push({
127
- name: `${name}-site`,
128
- appId: env.platform.appId,
129
- port: env.platform.site.port,
128
+ name: `${app.appId}-site`,
129
+ appId: app.appId,
130
+ port: app.site.port,
130
131
  type: 'site',
131
- origin: localUpstreamOrigin(env.platform.site, 'env.platform.site'),
132
- });
133
- }
134
- }
135
-
136
- for (const product of (env.products || [])) {
137
- if (product.microservice?.port) {
138
- services.push({
139
- name: product.appId,
140
- appId: product.appId,
141
- port: product.microservice.port,
142
- type: 'microservice',
143
- origin: localUpstreamOrigin(product.microservice, `env.products[${product.appId}].microservice`),
144
- });
145
- }
146
- if (product.site?.port) {
147
- services.push({
148
- name: `${product.appId}-site`,
149
- appId: product.appId,
150
- port: product.site.port,
151
- type: 'site',
152
- origin: localUpstreamOrigin(product.site, `env.products[${product.appId}].site`),
132
+ origin: localUpstreamOrigin(app.site, `env.app.site (${app.appId})`),
153
133
  });
154
134
  }
155
135
  }
@@ -279,15 +259,9 @@ async function probeGcloudAuth(execFn) {
279
259
  * - prod: only descix/egpt/powch (cutover #1 in-scope); raise on out-of-scope filter
280
260
  */
281
261
  function resolveAppsForCloudProbe(env, wsConfig, filterAppId) {
282
- const allApps = [];
283
- if (wsConfig.env?.platform?.appId) {
284
- allApps.push(wsConfig.env.platform.appId);
285
- }
286
- for (const product of (wsConfig.env?.products || [])) {
287
- if (product.appId) allApps.push(product.appId);
288
- }
289
- // Dedupe
290
- const unique = [...new Set(allApps)];
262
+ // The app set has ONE owner (workspaceApps): env.app + every repo env.gateway lists.
263
+ const { apps } = resolveLocalApps(wsConfig);
264
+ const unique = [...new Set(apps.map(a => a.appId))];
291
265
 
292
266
  if (env === 'prod') {
293
267
  // PROD scope guard: only in-scope hosts allowed.
@@ -163,10 +163,9 @@ export async function runInit(apiClient, options = {}) {
163
163
  }
164
164
 
165
165
  console.log(chalk.gray(`\n${existingConfig ? 'Updating' : 'Writing'} .descix/workspace.json...\n`));
166
- const config = existingConfig || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, projectPath);
166
+ const config = existingConfig || new WorkspaceConfig({ version: '3.0', type: 'workspace', env: {} }, projectPath);
167
167
  if (existingConfig && options.force && config.env) {
168
- config.env.products = [];
169
- delete config.env.platform;
168
+ delete config.env.app;
170
169
  }
171
170
  // NO APP IS REGISTERED HERE. The app id is the platform's: `app init` creates the app and
172
171
  // registers the id the server returns (with -c it is composed from the community and the
@@ -11,7 +11,7 @@
11
11
  * - compare: Show file-level deltas
12
12
  *
13
13
  * Architecture:
14
- * - Uses WorkspaceConfig for all path resolution (supports v2.1 env.platform/products format)
14
+ * - Uses WorkspaceConfig for all path resolution (v3 single-app env.app format)
15
15
  * - All operations delegate to lib/core/ modules
16
16
  * - Supports interactive and unattended (agent) modes
17
17
  * - CLI handles UX (prompts, progress, errors)
@@ -2,11 +2,11 @@
2
2
  * Microservice Restart Command (DEV only)
3
3
  *
4
4
  * Eliminates the "hand-kill + nohup-relaunch" tribal-knowledge workflow:
5
- * 1. Resolve the service from workspace.json `env.platform` / `env.products`
5
+ * 1. Resolve the service from the workspace's app set (env.app / env.gateway)
6
6
  * (HARD-FAIL on unknown / ambiguous / missing devCommand).
7
7
  * 2. Find the running PID via `lsof -iTCP:<port> -sTCP:LISTEN` (or `ps`).
8
8
  * 3. SIGTERM → wait up to 5s → SIGKILL.
9
- * 4. Re-launch the canonical devCommand from `{workspaceRoot}/{localPath}/microservice/`
9
+ * 4. Re-launch the canonical devCommand from the app repo's own `microservice/` dir
10
10
  * detached via `setsid + nohup`, redirecting stdout/stderr to a canonical log.
11
11
  * 5. Poll until the port is bound again; print new PID + log path.
12
12
  * 6. Run `descix health -m <name>` and print the result.
@@ -27,7 +27,7 @@ import { exec, spawn } from 'child_process';
27
27
  import { promisify } from 'util';
28
28
  import fs from 'fs/promises';
29
29
  import path from 'path';
30
- import { WorkspaceConfig } from '../workspace-config.js';
30
+ import { WorkspaceConfig, resolveLocalApps } from '../workspace-config.js';
31
31
 
32
32
  const execAsync = promisify(exec);
33
33
 
@@ -44,28 +44,25 @@ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
44
  * are restart-eligible (devCommand is the canonical launch signature).
45
45
  *
46
46
  * @param {WorkspaceConfig} wsConfig
47
- * @returns {Array<{name, localPath, port, devCommand, microserviceDir}>}
47
+ * @returns {Array<{name, dir, port, devCommand, microserviceDir}>}
48
48
  */
49
49
  export function buildRestartRegistry(wsConfig) {
50
- const env = wsConfig.env || {};
51
- const root = wsConfig.workspaceRoot;
52
50
  const out = [];
53
51
 
54
- const collect = (entry) => {
55
- if (!entry?.appId || !entry?.localPath) return;
56
- const ms = entry.microservice;
57
- if (!ms || !ms.port) return;
52
+ // The local app set has ONE owner (workspaceApps, v3 fork (b)): this workspace's own
53
+ // env.app plus every repo env.gateway lists, each read from its OWN workspace.json.
54
+ const { apps } = resolveLocalApps(wsConfig);
55
+ for (const app of apps) {
56
+ const ms = app.microservice;
57
+ if (!ms || !ms.port) continue;
58
58
  out.push({
59
- name: entry.appId,
60
- localPath: entry.localPath,
59
+ name: app.appId,
60
+ dir: app.dir,
61
61
  port: ms.port,
62
62
  devCommand: ms.devCommand || null,
63
- microserviceDir: path.join(root, entry.localPath, 'microservice')
63
+ microserviceDir: path.join(app.dir, 'microservice')
64
64
  });
65
- };
66
-
67
- collect(env.platform);
68
- for (const p of (env.products || [])) collect(p);
65
+ }
69
66
  return out;
70
67
  }
71
68
 
@@ -75,7 +72,7 @@ export function buildRestartRegistry(wsConfig) {
75
72
  *
76
73
  * @param {WorkspaceConfig} wsConfig
77
74
  * @param {string} name
78
- * @returns {{name, localPath, port, devCommand, microserviceDir}}
75
+ * @returns {{name, dir, port, devCommand, microserviceDir}}
79
76
  */
80
77
  export function resolveService(wsConfig, name) {
81
78
  const registry = buildRestartRegistry(wsConfig);
@@ -84,24 +81,19 @@ export function resolveService(wsConfig, name) {
84
81
  if (matches.length === 0) {
85
82
  const candidates = registry.map((s) => s.name).join(', ') || '(none)';
86
83
  throw new Error(
87
- `Service '${name}' not found in workspace.json env.platform/env.products.\n` +
84
+ `Service '${name}' is not served by this workspace (env.app / env.gateway).\n` +
88
85
  ` Restart-eligible services (with microservice.port + microservice.devCommand): ${candidates}\n` +
89
86
  ` Add an entry to .descix/workspace.json or run 'descix microservice list'.`
90
87
  );
91
88
  }
92
- if (matches.length > 1) {
93
- throw new Error(
94
- `Service name '${name}' is ambiguous — matches ${matches.length} entries: ` +
95
- matches.map((m) => `${m.name}@${m.localPath}`).join(', ')
96
- );
97
- }
98
-
89
+ // No ambiguity branch: duplicate app ids are refused upstream by the app-set owner
90
+ // (resolveWorkspaceApps), so at most one entry can match here.
99
91
  const svc = matches[0];
100
92
  if (!svc.devCommand) {
101
93
  throw new Error(
102
94
  `Service '${name}' has no microservice.devCommand in workspace.json — ` +
103
95
  `cannot determine canonical launch signature. ` +
104
- `Add 'devCommand' under env.products[].microservice to enable restart.`
96
+ `Add 'devCommand' under env.app.microservice in that app repo's workspace.json to enable restart.`
105
97
  );
106
98
  }
107
99
  return svc;
@@ -173,17 +165,20 @@ export async function killPid(pid, opts = {}) {
173
165
 
174
166
  /**
175
167
  * Resolve the canonical log path for a restart.
176
- * apps/<community>/<app>/.descix/logs/<service>-<ts>.log
168
+ * <app repo root>/.descix/logs/<service>-<ts>.log
169
+ * The app repo root is the entry's own `dir` (workspaceApps): under v3 each app lives in its
170
+ * own repo, so there is no workspace-wide apps/<community>/ tree to hang logs under.
177
171
  * Falls back to /tmp/<service>-restart-<ts>.log if the canonical dir
178
172
  * cannot be created.
173
+ *
174
+ * @param {{name: string, dir: string}} svc - a restart-registry entry
179
175
  */
180
- export async function resolveLogPath(wsConfig, serviceName, opts = {}) {
176
+ export async function resolveLogPath(svc, opts = {}) {
181
177
  const deps = opts.deps || {};
182
178
  const fsImpl = deps.fs || fs;
183
179
  const ts = (opts.timestamp || new Date().toISOString()).replace(/[:.]/g, '-');
184
- const root = wsConfig.workspaceRoot;
185
- const community = wsConfig.env?.communityId || 'unkamon';
186
- const canonicalDir = path.join(root, 'apps', community, serviceName, '.descix', 'logs');
180
+ const serviceName = svc.name;
181
+ const canonicalDir = path.join(svc.dir, '.descix', 'logs');
187
182
  try {
188
183
  await fsImpl.mkdir(canonicalDir, { recursive: true });
189
184
  return {
@@ -303,7 +298,7 @@ export async function restartMicroservice(args) {
303
298
  }
304
299
 
305
300
  // Step 2: resolve log path.
306
- const logRes = await resolveLogPath(wsConfig, svc.name, { deps });
301
+ const logRes = await resolveLogPath(svc, { deps });
307
302
  if (logRes.fallback) {
308
303
  log(chalk.yellow(` Canonical log dir unavailable (${logRes.fallbackReason}); using ${logRes.path}`));
309
304
  } else {
@@ -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
@@ -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"