@descix/cli 1.0.19 → 1.0.21

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
@@ -14,6 +14,7 @@ import { requireAuth } from '../lib/auth-guard.js';
14
14
  import { WorkspaceConfig, unmappedAppMessage, resolveWorkspacePath } from '../lib/workspace-config.js';
15
15
  import { CLI_VERSION } from '../lib/cli-version.js';
16
16
  import { recordInvocationOrigin } from '../lib/origin.js';
17
+ import { verifyDeployOutcome } from '../lib/deploy-outcome.js';
17
18
  // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
18
19
  import {
19
20
  getSessionInteractionId,
@@ -1641,35 +1642,41 @@ appCommand
1641
1642
 
1642
1643
  // ============ App: set-site (WS-SSGPOD — site.static workspace gap) ============
1643
1644
  //
1644
- // Canonical write path for an app's site config in workspace.json — specifically site.static,
1645
- // the relative path the dev gateway's staticSitePlugin serves at /p/{appId}/ (resolved against
1646
- // the app's localPath; "." means the localPath itself). Optionally sets site.port for
1647
- // dev-server sites. Parallel to `descix app set-port` (microservice.port); closes the
1648
- // site.static gap so workspace.json never needs hand-editing (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1649
- // Backed by WorkspaceConfig.setStaticSite (parallel to setMicroservicePort).
1650
- // It writes ONLY the local workspace.json site.{} slot.
1645
+ // Canonical write path for an app's site.{} slot in workspace.json. TWO MODES, one active
1646
+ // per write:
1647
+ // static mode (--static): site.static — the relative dir (under the app's localPath;
1648
+ // "." = the localPath itself) the gateway's staticSitePlugin
1649
+ // serves at /p/{appId}/.
1650
+ // dev-server mode (--port): site.port [+ site.protocol] — the local dev server the
1651
+ // gateway proxies /p/{appId} to (vite-style hotserving).
1652
+ // Setting one mode CLEARS the other's fields; passing BOTH flags in one invocation is the
1653
+ // explicit combined write and keeps both (the gateway then serves the DEV SERVER — site.port
1654
+ // wins — and says so on the serve banner). Parallel to `descix app set-port`
1655
+ // (microservice.port); no hand-editing of workspace.json (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
1656
+ // Backed by WorkspaceConfig.setStaticSite. It writes ONLY the local workspace.json site.{} slot.
1651
1657
  appCommand
1652
1658
  .command('set-site')
1653
- .description("Set a mapped app static-site config (writes env.products[<app>].site.static, the relative dir served at /p/<app>/). Optionally --port; --unset clears 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.")
1654
1660
  .requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
1655
- .option('--static <path>', 'Relative static-site path under the app localPath (e.g. "site"; "." = the localPath itself)')
1656
- .option('--port <port>', 'Site dev-server port (1-65535) for framework dev sites; mutually optional with --static')
1657
- .option('--unset', 'Remove site.static (and site.port) for the app, clearing the site.{} slot')
1661
+ .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
+ .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.')
1663
+ .option('--protocol <scheme>', 'Dev-server upstream scheme: "http" or "https" (default https when unset). Dev-server mode only — requires site.port. A wrong scheme fails loud at the gateway (502); there is no auto-detection.')
1664
+ .option('--unset', 'Remove site.static, site.port and site.protocol for the app, clearing the site.{} slot')
1658
1665
  .action(async (options) => {
1659
1666
  try {
1660
1667
  const appId = options.app;
1661
1668
  const workspaceConfig = await WorkspaceConfig.load();
1662
1669
 
1663
- // --unset: remove both site.static and site.port (setStaticSite cleans up empty site.{})
1670
+ // --unset: remove all site.{} fields (setStaticSite cleans up the empty slot)
1664
1671
  if (options.unset) {
1665
- await workspaceConfig.setStaticSite(appId, { static: null, port: null });
1666
- console.log(chalk.green(`\n✓ site config cleared for ${appId}\n`));
1672
+ await workspaceConfig.setStaticSite(appId, { static: null, port: null, protocol: null });
1673
+ console.log(chalk.green(`\n✓ site config cleared for ${appId} (site.static, site.port, site.protocol removed)\n`));
1667
1674
  return;
1668
1675
  }
1669
1676
 
1670
1677
  // Require at least one field to set — do not silently no-op.
1671
- if (options.static === undefined && options.port === undefined) {
1672
- console.error(chalk.red('\n❌ Nothing to set. Provide --static <path> and/or --port <n>, or --unset to clear.\n'));
1678
+ if (options.static === undefined && options.port === undefined && options.protocol === undefined) {
1679
+ console.error(chalk.red('\n❌ Nothing to set. Provide --static <path> (static mode), --port <n> [--protocol http|https] (dev-server mode), or --unset to clear.\n'));
1673
1680
  process.exit(1);
1674
1681
  }
1675
1682
 
@@ -1692,15 +1699,34 @@ appCommand
1692
1699
  fields.port = portNum;
1693
1700
  }
1694
1701
 
1695
- // setStaticSite mutates the live env entry and saves — and hard-fails (canonical
1696
- // "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
1702
+ if (options.protocol !== undefined) {
1703
+ if (options.protocol !== 'http' && options.protocol !== 'https') {
1704
+ console.error(chalk.red(`\n❌ Invalid --protocol ${JSON.stringify(options.protocol)}. Only "http" or "https" are valid (it names the scheme of your app's own dev server behind /p/${appId}).\n`));
1705
+ process.exit(1);
1706
+ }
1707
+ fields.protocol = options.protocol;
1708
+ }
1709
+
1710
+ // setStaticSite applies ONE-ACTIVE-MODE-PER-WRITE and saves — it hard-fails (canonical
1711
+ // "not mapped in workspace.json" error, or protocol-without-port) in the catch below.
1697
1712
  await workspaceConfig.setStaticSite(appId, fields);
1698
1713
 
1699
1714
  console.log(chalk.green(`\n✓ site config set for ${appId}`));
1700
- if (fields.static !== undefined) console.log(chalk.cyan(` site.static: ${fields.static}`));
1701
- if (fields.port !== undefined) console.log(chalk.cyan(` site.port: ${fields.port}`));
1702
- console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json.`));
1703
- console.log(chalk.gray(` The gateway serves site.static at /p/${appId}/ (staticSitePlugin); \`descix serve\` picks it up.\n`));
1715
+ // Print the RESULTING slot, not just the fields passed: a dev-server write keeps a
1716
+ // previously declared site.protocol (same mode, new port), and a retained field the
1717
+ // 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);
1721
+ console.log(chalk.cyan(` site is now: ${JSON.stringify(entryNow?.site)}`));
1722
+ if (fields.static !== undefined && fields.port === undefined) {
1723
+ console.log(chalk.gray(` site.port and site.protocol cleared (one active mode per write) — /p/${appId}/ now serves the static dir.`));
1724
+ } else if (fields.port !== undefined && fields.static === undefined) {
1725
+ console.log(chalk.gray(` site.static cleared (one active mode per write) — /p/${appId} now proxies to your dev server.`));
1726
+ } else if (fields.static !== undefined && fields.port !== undefined) {
1727
+ 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
+ }
1729
+ console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json; \`descix serve\` picks it up.\n`));
1704
1730
  } catch (error) {
1705
1731
  console.error(chalk.red(error.message));
1706
1732
  process.exit(1);
@@ -2564,6 +2590,28 @@ siteCommand
2564
2590
  branch: gitStatus.branch
2565
2591
  } : null,
2566
2592
  preview: options.preview || false
2593
+ }).catch(async (confirmErr) => {
2594
+ // ws-prune-504: a lost confirm RESPONSE is not a failed DEPLOY. Re-read the served
2595
+ // manifest keyed on THIS upload's token and say which it actually is — never "failed"
2596
+ // when the truth is knowable.
2597
+ console.log(chalk.yellow(`\n Confirm response lost (${confirmErr.message}) — reading the deploy record back...`));
2598
+ const verdict = await verifyDeployOutcome(apiClient, {
2599
+ communityId, appId, tokenId: token_id, preview: options.preview || false
2600
+ });
2601
+ if (verdict.outcome === 'confirmed') {
2602
+ console.log(chalk.green(`\n Deploy CONFIRMED — ${verdict.detail}.`));
2603
+ console.log(chalk.gray(` The server may still be finishing the stale-file prune; ` +
2604
+ `'descix site status -c ${communityId} -a ${appId}' shows the deploy, 'descix site list' what is served.`));
2605
+ process.exit(0);
2606
+ }
2607
+ if (verdict.outcome === 'unconfirmed') {
2608
+ console.error(chalk.red(`\n Deploy UNCONFIRMED — ${verdict.detail}.`));
2609
+ console.error(chalk.red(` Nothing was removed from the server. Re-run the upload.`));
2610
+ process.exit(1);
2611
+ }
2612
+ console.error(chalk.red(`\n Deploy outcome UNKNOWN — ${verdict.detail}.`));
2613
+ console.error(chalk.red(` Check 'descix site status -c ${communityId} -a ${appId}' before re-running.`));
2614
+ process.exit(1);
2567
2615
  });
2568
2616
 
2569
2617
  const result = confirmResponse.message;
@@ -2577,6 +2625,10 @@ siteCommand
2577
2625
  }
2578
2626
  console.log(chalk.gray(` Removed from the server: ${pruned.length}`));
2579
2627
  pruned.forEach(f => console.log(chalk.gray(` - ${f}`)));
2628
+ if (result.prune_status === 'partial') {
2629
+ console.log(chalk.yellow(` Prune PARTIAL: ${result.prune_remaining_count} stale file(s) not yet removed (soft budget).`));
2630
+ console.log(chalk.yellow(` ${result.prune_remedy}`));
2631
+ }
2580
2632
  console.log(chalk.gray(` Deployed: ${result.deployed_at}`));
2581
2633
  if (result.preview) {
2582
2634
  console.log(chalk.yellow(` Mode: PREVIEW`));
@@ -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
+ }
@@ -845,15 +845,27 @@ export class WorkspaceConfig {
845
845
  /**
846
846
  * Update an app's site config in env.products[] (or env.platform if it is the platform app).
847
847
  *
848
- * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot's
849
- * static-site fields. This is the canonical write path for site.static — the relative path the
850
- * dev gateway's staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig:
851
- * site.static is resolved against the app's localPath; "." means the localPath itself).
848
+ * Parallel to setMicroservicePort(), but operates on the entry's site.{} slot. This is the
849
+ * canonical write path for site.static — the relative path the dev gateway's
850
+ * staticSitePlugin serves at /p/{appId}/ (see createViteProxyConfig: site.static is
851
+ * resolved against the app's localPath; "." means the localPath itself) — and for
852
+ * site.port / site.protocol, the dev-server upstream the gateway proxies /p/{appId} to.
852
853
  * Backs the `descix app set-site` command, closing the site.static workspace gap without
853
854
  * hand-editing workspace.json (the org rule forbids hand edits — CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
854
855
  *
855
- * Mutates site.static and/or site.port. Pass static === null to remove site.static; pass
856
- * port === null to remove site.port. If site.{} becomes empty after removals it is deleted.
856
+ * ONE ACTIVE MODE PER WRITE (measured 2026-10-02, EGPT-Evidence: `set-site --port` then
857
+ * `set-site --static` left BOTH fields, and the gateway silently served the dead dev-server
858
+ * port). Setting `static` without `port` in the same call CLEARS site.port and
859
+ * site.protocol; setting `port` without `static` CLEARS site.static. Passing BOTH in one
860
+ * call is the explicit combined write and keeps both — the gateway then serves the dev
861
+ * server (site.port wins; createViteProxyConfig), stated on the serve banner.
862
+ *
863
+ * site.protocol ("http" | "https", default https when absent) names the scheme of the
864
+ * dev-server upstream. It is meaningless without site.port, and a write that would leave
865
+ * protocol without port is refused loud — no auto-detection, no try-both fallback.
866
+ *
867
+ * Pass null for a field to remove it (what `--unset` does for all three). Removals clear
868
+ * only the named field. If site.{} becomes empty after removals it is deleted.
857
869
  * Persists via save() at the end — same auto-save pattern as setMicroservicePort().
858
870
  * Hard-fails if appId is not mapped in env.platform or env.products.
859
871
  *
@@ -861,37 +873,64 @@ export class WorkspaceConfig {
861
873
  * @param {Object} fields - Fields to set on site.{}
862
874
  * @param {string|null} [fields.static] - Relative static-site path to set, or null to remove site.static
863
875
  * @param {number|string|null} [fields.port] - Site dev-server port to set, or null to remove site.port
876
+ * @param {string|null} [fields.protocol] - Dev-server upstream scheme "http"|"https", or null to remove site.protocol
864
877
  * @returns {Promise<string>} Path to saved config (from save())
865
878
  */
866
879
  async setStaticSite(appId, fields = {}) {
867
880
  if (!appId) throw new Error('appId is required');
868
881
  if (!fields || typeof fields !== 'object') throw new Error('fields object is required');
869
882
 
883
+ const settingStatic = 'static' in fields && fields.static !== null && fields.static !== undefined;
884
+ const settingPort = 'port' in fields && fields.port !== null && fields.port !== undefined;
885
+ const settingProtocol = 'protocol' in fields && fields.protocol !== null && fields.protocol !== undefined;
886
+
887
+ if (settingProtocol && fields.protocol !== 'http' && fields.protocol !== 'https') {
888
+ throw new Error(
889
+ `site.protocol must be "http" or "https" (got ${JSON.stringify(fields.protocol)}). ` +
890
+ `It names the scheme of the app's own dev server behind the gateway's /p/${appId} proxy.`
891
+ );
892
+ }
893
+
870
894
  const entry = this._liveEnvEntry(appId);
871
895
 
872
- if (!entry.site) entry.site = {};
896
+ // Compute the resulting site.{} first, so validation failures write NOTHING.
897
+ const site = { ...(entry.site || {}) };
873
898
 
874
- // site.static — set or remove
875
- if ('static' in fields) {
876
- if (fields.static === null || fields.static === undefined) {
877
- delete entry.site.static;
878
- } else {
879
- entry.site.static = fields.static;
880
- }
899
+ // One active mode per write (see doc comment above).
900
+ if (settingStatic && !settingPort) {
901
+ delete site.port;
902
+ delete site.protocol;
903
+ }
904
+ if (settingPort && !settingStatic) {
905
+ delete site.static;
881
906
  }
882
907
 
883
- // site.port — set or remove (for static+devCommand sites)
908
+ if ('static' in fields) {
909
+ if (fields.static === null || fields.static === undefined) delete site.static;
910
+ else site.static = fields.static;
911
+ }
884
912
  if ('port' in fields) {
885
- if (fields.port === null || fields.port === undefined) {
886
- delete entry.site.port;
887
- } else {
888
- entry.site.port = fields.port;
889
- }
913
+ if (fields.port === null || fields.port === undefined) delete site.port;
914
+ else site.port = fields.port;
915
+ }
916
+ if ('protocol' in fields) {
917
+ if (fields.protocol === null || fields.protocol === undefined) delete site.protocol;
918
+ else site.protocol = fields.protocol;
890
919
  }
891
920
 
892
- // Clean up an empty site.{} so we never leave a bare {} behind
893
- if (Object.keys(entry.site).length === 0) {
921
+ // protocol describes the dev-server upstream — without a port it describes nothing.
922
+ if ('protocol' in site && !('port' in site)) {
923
+ throw new Error(
924
+ `site.protocol is only valid in dev-server mode: set it together with --port ` +
925
+ `(or on an app whose site.port is already set). Nothing was written for ${appId}.`
926
+ );
927
+ }
928
+
929
+ if (Object.keys(site).length === 0) {
930
+ // Clean up an empty site.{} so we never leave a bare {} behind
894
931
  delete entry.site;
932
+ } else {
933
+ entry.site = site;
895
934
  }
896
935
 
897
936
  return this.save();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",