@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 +74 -22
- package/lib/deploy-outcome.js +45 -0
- package/lib/workspace-config.js +61 -22
- package/package.json +1 -1
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
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
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
|
|
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>', '
|
|
1656
|
-
.option('--port <port>', '
|
|
1657
|
-
.option('--
|
|
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
|
|
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>
|
|
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
|
-
|
|
1696
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
+
}
|
package/lib/workspace-config.js
CHANGED
|
@@ -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
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
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
|
-
*
|
|
856
|
-
*
|
|
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
|
-
|
|
896
|
+
// Compute the resulting site.{} first, so validation failures write NOTHING.
|
|
897
|
+
const site = { ...(entry.site || {}) };
|
|
873
898
|
|
|
874
|
-
//
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
//
|
|
893
|
-
if (
|
|
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();
|