@descix/cli 1.0.1 → 1.0.4
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/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +21 -15
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +220 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +19 -6
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeSciX CLI OAuth client — WS-HEADLESS-MVP-A1
|
|
3
|
+
* (mcp-oauth-longlived-tokens-design-2026-06-30.md §2.1/§2.2)
|
|
4
|
+
*
|
|
5
|
+
* The gcloud-ADC-style long-lived credential layer for the CLI/MCP:
|
|
6
|
+
* - PKCE S256 pair generation (binds code redemption to THIS process)
|
|
7
|
+
* - Dynamic Client Registration (RFC 7591) for the CLI public client
|
|
8
|
+
* - authorization_code redemption (the device→OAuth bridge hands the code
|
|
9
|
+
* back through device_check_status — no browser redirect surface)
|
|
10
|
+
* - refresh_token grant (silent refresh; the AS rotates on use)
|
|
11
|
+
* - RFC 7009 revocation (descix logout)
|
|
12
|
+
*
|
|
13
|
+
* These are the OAuth AS's OWN HTTP endpoints (/oauth/*), NOT /apifront commands —
|
|
14
|
+
* the CLI POSTs to them directly, exactly as any OAuth client would (design §5:
|
|
15
|
+
* "CLI-is-HTTP-only — the CLI just POSTs to /oauth/token"). Everything else in the
|
|
16
|
+
* CLI still routes /apifront/ via DeSciXApiClient.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from 'crypto';
|
|
20
|
+
import axios from 'axios';
|
|
21
|
+
|
|
22
|
+
/** OOB redirect URI for the CLI public client — the bridge returns the code via the
|
|
23
|
+
* device-status poll, never via redirect, but the OAuth records still carry a
|
|
24
|
+
* registered redirect_uri which redemption must echo (RFC 6749 §4.1.3). */
|
|
25
|
+
export const OOB_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob';
|
|
26
|
+
|
|
27
|
+
/** Default scope requested by `descix login`. The scope is a CEILING, not a grant:
|
|
28
|
+
* write/admin are honored only on internal-posture deployments and, even there, the
|
|
29
|
+
* server's checkCommandPermission decides per-user. On public deployments the wider
|
|
30
|
+
* scopes are inert by construction (design §2.3). */
|
|
31
|
+
export const DEFAULT_OAUTH_SCOPE = 'mcp:read mcp:tools mcp:write mcp:admin';
|
|
32
|
+
|
|
33
|
+
/** Generate a PKCE S256 verifier/challenge pair. */
|
|
34
|
+
export function generatePkcePair() {
|
|
35
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
36
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
37
|
+
return { verifier, challenge };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shared axios config for the AS endpoints (self-signed local dev handling
|
|
41
|
+
* mirrors DeSciXApiClient). */
|
|
42
|
+
async function axiosConfigFor(baseUrl) {
|
|
43
|
+
const config = { timeout: 30000, validateStatus: () => true };
|
|
44
|
+
if (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1')) {
|
|
45
|
+
const https = await import('https');
|
|
46
|
+
config.httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
47
|
+
}
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register the CLI as an OAuth public client (RFC 7591 DCR).
|
|
53
|
+
* @returns {Promise<string>} client_id
|
|
54
|
+
*/
|
|
55
|
+
export async function registerOAuthClient(baseUrl, { clientName = 'descix-cli' } = {}) {
|
|
56
|
+
const config = await axiosConfigFor(baseUrl);
|
|
57
|
+
const resp = await axios.post(`${baseUrl}/oauth/register`, {
|
|
58
|
+
client_name: clientName,
|
|
59
|
+
redirect_uris: [OOB_REDIRECT_URI],
|
|
60
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
61
|
+
response_types: ['code'],
|
|
62
|
+
token_endpoint_auth_method: 'none',
|
|
63
|
+
}, config);
|
|
64
|
+
if (resp.status !== 201 || !resp.data?.client_id) {
|
|
65
|
+
throw new Error(`OAuth client registration failed (HTTP ${resp.status}): ${JSON.stringify(resp.data)}`);
|
|
66
|
+
}
|
|
67
|
+
return resp.data.client_id;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** POST /oauth/token — shared form-encoded grant helper. */
|
|
71
|
+
async function tokenGrant(baseUrl, form) {
|
|
72
|
+
const config = await axiosConfigFor(baseUrl);
|
|
73
|
+
config.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
74
|
+
const resp = await axios.post(`${baseUrl}/oauth/token`, new URLSearchParams(form).toString(), config);
|
|
75
|
+
if (resp.status !== 200 || !resp.data?.access_token) {
|
|
76
|
+
const err = new Error(`OAuth token grant '${form.grant_type}' failed (HTTP ${resp.status}): ` +
|
|
77
|
+
`${resp.data?.error || 'unknown'} — ${resp.data?.error_description || JSON.stringify(resp.data)}`);
|
|
78
|
+
err.oauthError = resp.data?.error || null;
|
|
79
|
+
err.httpStatus = resp.status;
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
return resp.data; // { access_token, token_type, expires_in, refresh_token, scope }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Redeem the device-bridge authorization code (PKCE) for the token pair.
|
|
87
|
+
* @returns {Promise<Object>} { access_token, expires_in, refresh_token, scope }
|
|
88
|
+
*/
|
|
89
|
+
export async function redeemAuthorizationCode(baseUrl, { code, clientId, codeVerifier }) {
|
|
90
|
+
return tokenGrant(baseUrl, {
|
|
91
|
+
grant_type: 'authorization_code',
|
|
92
|
+
code,
|
|
93
|
+
redirect_uri: OOB_REDIRECT_URI,
|
|
94
|
+
client_id: clientId,
|
|
95
|
+
code_verifier: codeVerifier,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Silent refresh (gcloud-ADC analogue). The AS ROTATES the refresh token on use —
|
|
101
|
+
* callers MUST persist BOTH returned tokens or the next refresh trips reuse-detection
|
|
102
|
+
* and revokes the whole chain.
|
|
103
|
+
* @returns {Promise<Object>} { access_token, expires_in, refresh_token, scope }
|
|
104
|
+
*/
|
|
105
|
+
export async function refreshOAuthTokens(baseUrl, { refreshToken, clientId }) {
|
|
106
|
+
return tokenGrant(baseUrl, {
|
|
107
|
+
grant_type: 'refresh_token',
|
|
108
|
+
refresh_token: refreshToken,
|
|
109
|
+
client_id: clientId,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* RFC 7009 revocation (used by `descix logout`). 200 is returned even for
|
|
115
|
+
* unknown tokens; only a transport/server failure throws.
|
|
116
|
+
*/
|
|
117
|
+
export async function revokeOAuthToken(baseUrl, { token, clientId }) {
|
|
118
|
+
const config = await axiosConfigFor(baseUrl);
|
|
119
|
+
config.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
120
|
+
const resp = await axios.post(`${baseUrl}/oauth/revoke`, new URLSearchParams({
|
|
121
|
+
token,
|
|
122
|
+
token_type_hint: 'refresh_token',
|
|
123
|
+
...(clientId ? { client_id: clientId } : {}),
|
|
124
|
+
}).toString(), config);
|
|
125
|
+
if (resp.status !== 200) {
|
|
126
|
+
throw new Error(`OAuth revocation failed (HTTP ${resp.status}): ${JSON.stringify(resp.data)}`);
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the persisted `oauth` credential block for wallet.json from a token response.
|
|
133
|
+
* Single shape owner — ensureSession/login/logout all consume this contract.
|
|
134
|
+
*/
|
|
135
|
+
export function buildOAuthCredentialBlock({ clientId, tokens, tokenEndpointBase, scope }) {
|
|
136
|
+
return {
|
|
137
|
+
client_id: clientId,
|
|
138
|
+
access_token: tokens.access_token,
|
|
139
|
+
access_token_expires_at: new Date(Date.now() + (tokens.expires_in * 1000)).toISOString(),
|
|
140
|
+
refresh_token: tokens.refresh_token,
|
|
141
|
+
scope: tokens.scope || scope || null,
|
|
142
|
+
token_endpoint: `${tokenEndpointBase}/oauth/token`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Is the cached OAuth access token still fresh (with clock-skew margin)? */
|
|
147
|
+
export function isOAuthAccessTokenFresh(oauthBlock, skewMs = 60000) {
|
|
148
|
+
if (!oauthBlock?.access_token || !oauthBlock?.access_token_expires_at) return false;
|
|
149
|
+
const expiresAt = Date.parse(oauthBlock.access_token_expires_at);
|
|
150
|
+
if (Number.isNaN(expiresAt)) return false;
|
|
151
|
+
return (expiresAt - Date.now()) > skewMs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default {
|
|
155
|
+
OOB_REDIRECT_URI,
|
|
156
|
+
DEFAULT_OAUTH_SCOPE,
|
|
157
|
+
generatePkcePair,
|
|
158
|
+
registerOAuthClient,
|
|
159
|
+
redeemAuthorizationCode,
|
|
160
|
+
refreshOAuthTokens,
|
|
161
|
+
revokeOAuthToken,
|
|
162
|
+
buildOAuthCredentialBlock,
|
|
163
|
+
isOAuthAccessTokenFresh,
|
|
164
|
+
};
|
package/lib/origin.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ONE OWNER OF "which DeSciX origin does this invocation talk to, and who chose it".
|
|
3
|
+
*
|
|
4
|
+
* WHY (measured 2026-08-29, published @descix/cli 1.0.1): `getApiUrl()` on an unconfigured
|
|
5
|
+
* workspace returned the production origin and said NOTHING. A developer who had configured
|
|
6
|
+
* nothing and a developer who had deliberately chosen production produced byte-identical
|
|
7
|
+
* results, and the CLI then reported that origin back in agent-instruction files, in
|
|
8
|
+
* `config show`, and in the wallet it minted.
|
|
9
|
+
*
|
|
10
|
+
* THE RULE THIS ENFORCES (contract I1 as amended by (A'), rev 2, 2026-08-30):
|
|
11
|
+
* · An unconfigured workspace resolves to the DECLARED DEFAULT, production. That is the
|
|
12
|
+
* shipped product's target and CEO-D-2026-08-18 stands. The PROD target was never the
|
|
13
|
+
* defect.
|
|
14
|
+
* · SILENCE was the defect. Every resolution carries its SOURCE, and every network-bound
|
|
15
|
+
* command PRINTS it — always, not only on the default. "Chose prod" and "chose nothing"
|
|
16
|
+
* are distinguished by `source`, never by a null nobody sees.
|
|
17
|
+
* · A CONFIGURED-but-invalid origin still FAILS LOUD, naming the fix. Landing on the default
|
|
18
|
+
* is a product decision; silently discarding something the developer actually typed is not.
|
|
19
|
+
*
|
|
20
|
+
* NO SECOND SPELLING OF THE DEFAULT. The origin table has ONE owner, `@descix/app-sdk/dev`,
|
|
21
|
+
* and this module IMPORTS the production origin from it rather than writing the literal down.
|
|
22
|
+
* That is deliberate and it is load-bearing for acceptance A1: `grep` of the packed tarball for
|
|
23
|
+
* the production literal outside the one owner returns 0 because there is no second copy to
|
|
24
|
+
* find -- not even here.
|
|
25
|
+
*
|
|
26
|
+
* This file imports nothing from the CLI, so it can be consumed by `workspace-config.js`
|
|
27
|
+
* without a cycle.
|
|
28
|
+
*/
|
|
29
|
+
import { DEFAULT_API_URL } from '@descix/app-sdk/dev';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The single spelling of "are these two origins the same origin" — trailing slashes and
|
|
33
|
+
* surrounding whitespace do not make an origin different.
|
|
34
|
+
*
|
|
35
|
+
* Exported because other modules compare origins against ENV_MAP and MUST use the same
|
|
36
|
+
* comparison this module resolves with; a second private copy is how the two drift.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} origin
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeOrigin(origin) {
|
|
42
|
+
return String(origin).trim().replace(/\/+$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Thrown when a source NAMED an origin and that origin is not usable. Carries a `code` so
|
|
47
|
+
* callers can recognise it without matching on message text.
|
|
48
|
+
*
|
|
49
|
+
* This is the loud failure I1 requires. It replaced `OriginUnresolvedError`, which fired when
|
|
50
|
+
* NOTHING was configured — a case that is no longer a failure at all under (A'), because the
|
|
51
|
+
* declared default answers it. The old error was DELETED rather than fenced: an unreachable
|
|
52
|
+
* error path kept "just in case" is the compat fence CEO-D-2026-07-26 forbids.
|
|
53
|
+
*/
|
|
54
|
+
export class OriginInvalidError extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'OriginInvalidError';
|
|
58
|
+
this.code = 'ORIGIN_INVALID';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The resolution order, most explicit first. Each entry is [human-readable source, key].
|
|
64
|
+
* The ORDER is the contract; the array is the only place it is written down.
|
|
65
|
+
*/
|
|
66
|
+
const PRECEDENCE = [
|
|
67
|
+
['DESCIX_API_URL (or --api-url / --env)', 'envVar'],
|
|
68
|
+
['.descix/workspace.json env.apiUrl', 'workspaceEnvApiUrl'],
|
|
69
|
+
['.descix/workspace.json apiUrl (legacy)', 'legacyApiUrl'],
|
|
70
|
+
['~/.descix/config.json api_url', 'globalApiUrl'],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** The remedy text every failure prints. One spelling, so every surface says the same thing. */
|
|
74
|
+
export const ORIGIN_REMEDY =
|
|
75
|
+
'Choose one explicitly:\n' +
|
|
76
|
+
' descix config init --env dev|demo|prod (persists env.apiUrl in .descix/workspace.json)\n' +
|
|
77
|
+
' descix --env dev|demo|prod <command> (this invocation only)\n' +
|
|
78
|
+
' export DESCIX_API_URL=https://... (this shell only)';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The SOURCE label for a resolution nobody configured. Exported so the printed env line, the
|
|
82
|
+
* tests and any consumer all use one spelling instead of three that drift.
|
|
83
|
+
*
|
|
84
|
+
* The wording is the contract's own (I1, rev 2): it names the state AND the one command that
|
|
85
|
+
* changes it, because a developer who reads "prod" and does not want prod needs the fix in the
|
|
86
|
+
* same line, not in a doc.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_ORIGIN_SOURCE =
|
|
89
|
+
'default — no workspace config; `descix config init --env dev` targets DEV';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Is this string usable as an API origin? Absolute http(s) URLs only.
|
|
93
|
+
* @param {string} value
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function isUsableOrigin(value) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = new URL(String(value).trim());
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the API origin from the sources a caller has in hand.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} sources
|
|
110
|
+
* @param {string|null} [sources.envVar] process.env.DESCIX_API_URL
|
|
111
|
+
* @param {string|null} [sources.workspaceEnvApiUrl] workspace.json env.apiUrl
|
|
112
|
+
* @param {string|null} [sources.legacyApiUrl] workspace.json top-level apiUrl
|
|
113
|
+
* @param {string|null} [sources.globalApiUrl] ~/.descix/config.json api_url
|
|
114
|
+
* @returns {{ origin: string, source: string, isDefault: boolean }} the origin AND which source
|
|
115
|
+
* supplied it — the source is returned because "where did this come from" is the
|
|
116
|
+
* question every misreport in this contract failed to answer. It NEVER returns null
|
|
117
|
+
* and it never throws for an absent configuration.
|
|
118
|
+
* @throws {OriginInvalidError} when a source NAMED an origin that cannot be used. Falling back
|
|
119
|
+
* to the default here would silently discard what the developer typed, which is the
|
|
120
|
+
* same class of defect from the other direction.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveOrigin(sources = {}) {
|
|
123
|
+
for (const [label, key] of PRECEDENCE) {
|
|
124
|
+
const value = sources[key];
|
|
125
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
126
|
+
if (!isUsableOrigin(value)) {
|
|
127
|
+
throw new OriginInvalidError(
|
|
128
|
+
`The DeSciX API origin configured in ${label} is not a usable origin: "${String(value).trim()}".\n` +
|
|
129
|
+
'An origin must be an absolute http(s) URL, e.g. https://dev.descix.net.\n\n' +
|
|
130
|
+
ORIGIN_REMEDY,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { origin: normalizeOrigin(value), source: label, isDefault: false };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Nothing was configured. That is not a failure — it is the shipped product's declared
|
|
137
|
+
// target, and the caller is required to PRINT that it landed here.
|
|
138
|
+
return {
|
|
139
|
+
origin: normalizeOrigin(DEFAULT_API_URL),
|
|
140
|
+
source: DEFAULT_ORIGIN_SOURCE,
|
|
141
|
+
isDefault: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-context DeSciX api-client — the CLI's REST api-client, packaged for reuse inside
|
|
3
|
+
* an APP MICROSERVICE running in the cloud.
|
|
4
|
+
*
|
|
5
|
+
* AUTH MODEL (CEO-D-2026-06-02-APP-MICROSERVICE-IS-CLI-CLIENT-WALLET-SIG):
|
|
6
|
+
* "An app microservice is just THE CLI's REST api-client running in the cloud." It
|
|
7
|
+
* authenticates to /apifront EXACTLY like the CLI does — it holds the DEVELOPER'S OWN
|
|
8
|
+
* durable credential (wallet_address + the `signature` from .descix/wallet.json), presents
|
|
9
|
+
* it to `reconnect_by_wallet` to mint a session access_token, and makes /apifront calls AS
|
|
10
|
+
* THE DEVELOPER. There is NO new apifront auth path: this is the existing CLI wallet-signature
|
|
11
|
+
* -> session path (api-client buildRequestBody + ensureSession -> reconnect_by_wallet).
|
|
12
|
+
* NO SA-OIDC, NO delegate signature / X-NFT-ID / SERVICE_KEY, NO Signer, NO OWNER_SIGNATURE.
|
|
13
|
+
*
|
|
14
|
+
* Difference from the CLI: a Cloud Run service has no `.descix/wallet.json` and no TTY. So:
|
|
15
|
+
* - the developer credential is supplied EXPLICITLY (read from the service's config — the
|
|
16
|
+
* GITIGNORED dev-overrides.json in dev / a SECRET in prod, NEVER checked-in
|
|
17
|
+
* defaults-config.json, because the signature is a credential), and
|
|
18
|
+
* - on a session-refresh failure it HARD-FAILS (serviceMode) — it never tries interactive
|
|
19
|
+
* device login.
|
|
20
|
+
*
|
|
21
|
+
* The credential is the developer's own / their responsibility (their API call), same risk
|
|
22
|
+
* profile as wallet.json. HARD-FAIL (no fallback) if wallet_address or signature is absent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { DeSciXApiClient } from './api-client.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a headless service-context api-client bound to a Core broker URL and the developer's
|
|
29
|
+
* durable wallet credential.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} cfg
|
|
32
|
+
* @param {string} cfg.baseUrl - Core broker base URL (e.g. utils.CORE_API_URL). The
|
|
33
|
+
* api-client appends `/apifront/`. Required.
|
|
34
|
+
* @param {string} cfg.walletAddress - The developer's wallet address. Required.
|
|
35
|
+
* @param {string} cfg.signature - The developer's durable `signature` (from their
|
|
36
|
+
* .descix/wallet.json). Required.
|
|
37
|
+
* @param {string} [cfg.userId] - Optional developer user_id (resolved by reconnect_by_wallet
|
|
38
|
+
* if omitted).
|
|
39
|
+
* @returns {{ client: DeSciXApiClient, invoke: (command: string, params?: object) => Promise<any> }}
|
|
40
|
+
* `client` is the underlying api-client; `invoke(command, params)` returns Core's
|
|
41
|
+
* `message` payload (the same shape `fetchAppAsset`'s invoker expects).
|
|
42
|
+
*/
|
|
43
|
+
export function createServiceApiClient({ baseUrl, walletAddress, signature, userId } = {}) {
|
|
44
|
+
// The api-client appends `/apifront/`; accept either a bare origin or a `.../apifront` URL
|
|
45
|
+
// and normalize to the origin so we never double-append.
|
|
46
|
+
if (!baseUrl || typeof baseUrl !== 'string') {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'createServiceApiClient: baseUrl is required (the Core broker URL, e.g. utils.CORE_API_URL). No fallback.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!walletAddress || typeof walletAddress !== 'string') {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'createServiceApiClient: walletAddress is required — the developer credential. Put it in the ' +
|
|
54
|
+
'GITIGNORED dev-overrides.json (dev) / a secret (prod), never defaults-config.json. No fallback.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!signature || typeof signature !== 'string') {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'createServiceApiClient: signature is required — the developer durable credential (from their ' +
|
|
60
|
+
'.descix/wallet.json). Put it in the GITIGNORED dev-overrides.json (dev) / a secret (prod), ' +
|
|
61
|
+
'never defaults-config.json. No fallback.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const origin = baseUrl.replace(/\/apifront\/?$/, '').replace(/\/$/, '');
|
|
66
|
+
|
|
67
|
+
const client = new DeSciXApiClient({ baseUrl: origin, serviceMode: true });
|
|
68
|
+
// Provide the developer credential directly — NO wallet-file / workspace walk in a service.
|
|
69
|
+
client.setCredentials({
|
|
70
|
+
userId: userId || null,
|
|
71
|
+
accessToken: null, // minted on first call via reconnect_by_wallet
|
|
72
|
+
walletAddress,
|
|
73
|
+
signature,
|
|
74
|
+
communityId: null,
|
|
75
|
+
tokenSymbol: null,
|
|
76
|
+
});
|
|
77
|
+
// Mark initialized so the client never tries to discover a workspace/wallet on disk.
|
|
78
|
+
client._initialized = true;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Invoke a Core command AS the developer. Returns Core's `message` payload on success
|
|
82
|
+
* (matching the meshLoopback/fetchAppAsset contract it replaces).
|
|
83
|
+
*/
|
|
84
|
+
async function invoke(command, params = {}) {
|
|
85
|
+
if (!command || typeof command !== 'string') {
|
|
86
|
+
throw new Error('serviceApiClient.invoke: command (string) is required.');
|
|
87
|
+
}
|
|
88
|
+
const data = await client.invoke(command, params);
|
|
89
|
+
return data?.message ?? data;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { client, invoke };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default createServiceApiClient;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ONE OWNER OF "which environment was this credential obtained against".
|
|
3
|
+
*
|
|
4
|
+
* WHY (measured 2026-08-29): wallet.json carries no environment field at all, so a DEV
|
|
5
|
+
* credential is byte-indistinguishable from a PROD one. Nothing downstream can tell a developer
|
|
6
|
+
* that the session in their workspace was minted somewhere other than where they are pointing.
|
|
7
|
+
*
|
|
8
|
+
* SCOPE — WRITE ONLY, DELIBERATELY. This module RECORDS the origin a wallet was obtained
|
|
9
|
+
* against. It does NOT refuse a mismatched wallet, and nothing here should grow into a check.
|
|
10
|
+
* Enforcement needs a migration story for every wallet already on disk WITHOUT this field
|
|
11
|
+
* (they are all of them, today), and shipping half an enforcement inside a publish is how a
|
|
12
|
+
* guard that fires on correct code gets born. Enforcement is a separate row.
|
|
13
|
+
*
|
|
14
|
+
* The environment NAME comes from `environment-report.js::environmentNameFor` — the one owner
|
|
15
|
+
* of that derivation, shared with the printed env line — so this file holds no second copy of
|
|
16
|
+
* either the origin table or the matching rule.
|
|
17
|
+
*/
|
|
18
|
+
// The origin -> env-name derivation used to live in this file. It now has ONE owner, consumed
|
|
19
|
+
// here and by the printed env line, because two derivations of one fact is the mirror drift
|
|
20
|
+
// this contract exists to end.
|
|
21
|
+
import { environmentNameFor } from './environment-report.js';
|
|
22
|
+
import { normalizeOrigin } from './origin.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The environment stamp to persist alongside a freshly obtained credential.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} apiUrl - the origin the credential was actually obtained against
|
|
28
|
+
* (i.e. `apiClient.baseUrl` at the moment of the exchange)
|
|
29
|
+
* @returns {{ apiUrl: string, environment: string }} `environment` is the ENV_MAP name whose
|
|
30
|
+
* url matches (`dev` | `demo` | `prod`), or `'custom'` when the origin is a real one
|
|
31
|
+
* that simply is not in the map — a self-hosted or port-forwarded gateway.
|
|
32
|
+
*
|
|
33
|
+
* @throws when called with no origin. There is no default: guessing which environment a
|
|
34
|
+
* credential came from is exactly the misreport this contract exists to end, and every
|
|
35
|
+
* caller has the origin in hand.
|
|
36
|
+
*/
|
|
37
|
+
export function walletEnvironmentStamp(apiUrl) {
|
|
38
|
+
if (!apiUrl || String(apiUrl).trim() === '') {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'walletEnvironmentStamp: refusing to stamp a credential with no origin — ' +
|
|
41
|
+
'the environment a wallet was obtained against cannot be guessed.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
apiUrl: normalizeOrigin(apiUrl),
|
|
47
|
+
environment: environmentNameFor(apiUrl),
|
|
48
|
+
};
|
|
49
|
+
}
|
package/lib/wallet-file.js
CHANGED
|
@@ -37,7 +37,15 @@ export class WalletFileManager {
|
|
|
37
37
|
try {
|
|
38
38
|
const data = await fs.readFile(walletPath, 'utf-8');
|
|
39
39
|
const walletData = JSON.parse(data);
|
|
40
|
-
|
|
40
|
+
|
|
41
|
+
// WS-HEADLESS-MVP-A1 §2.5 (parent plan §10.4) — API_KEY is the vocabulary-aligned
|
|
42
|
+
// ALIAS of the wallet signature (the durable headless bearer credential). Alias-only
|
|
43
|
+
// (CEO §7 Q4): `signature` stays canonical on disk; a file authored with only
|
|
44
|
+
// API_KEY is normalized on read.
|
|
45
|
+
if (walletData && walletData.API_KEY && !walletData.signature) {
|
|
46
|
+
walletData.signature = walletData.API_KEY;
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
if (this.validateWalletFile(walletData)) {
|
|
42
50
|
return walletData;
|
|
43
51
|
} else {
|
|
@@ -66,13 +74,19 @@ export class WalletFileManager {
|
|
|
66
74
|
throw new Error('Invalid wallet data structure');
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
// WS-HEADLESS-MVP-A1 §2.5 — keep the API_KEY alias mirrored to the canonical
|
|
78
|
+
// signature so the on-disk file self-documents the alias (alias-only rename).
|
|
79
|
+
if (walletData.signature) {
|
|
80
|
+
walletData.API_KEY = walletData.signature;
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
// Ensure directory exists
|
|
70
84
|
const dir = path.dirname(walletPath);
|
|
71
85
|
await fs.mkdir(dir, { recursive: true });
|
|
72
86
|
|
|
73
87
|
// Write file with restricted permissions (600)
|
|
74
88
|
await fs.writeFile(walletPath, JSON.stringify(walletData, null, 2), { mode: 0o600 });
|
|
75
|
-
|
|
89
|
+
|
|
76
90
|
return true;
|
|
77
91
|
} catch (error) {
|
|
78
92
|
console.error(`[WalletFile] Error saving wallet file: ${error.message}`);
|
package/lib/wizard/setup.js
CHANGED
|
@@ -22,6 +22,7 @@ import chalk from 'chalk';
|
|
|
22
22
|
import ora from 'ora';
|
|
23
23
|
import inquirer from 'inquirer';
|
|
24
24
|
import { DeSciXApiClient } from '../api-client.js';
|
|
25
|
+
import { ENV_ORIGINS } from '@descix/app-sdk/dev';
|
|
25
26
|
import { requireAuth, isAuthenticated } from '../auth-guard.js';
|
|
26
27
|
import * as authCommands from '../commands/auth.js';
|
|
27
28
|
import * as fs from 'fs/promises';
|
|
@@ -116,7 +117,7 @@ async function generateRulesFile(workspacePath, entitlements, primaryCommunity)
|
|
|
116
117
|
// Customize with workspace-specific values
|
|
117
118
|
const customized = template
|
|
118
119
|
.replace(/\{\{SDK_ASSETS_PATH\}\}/g, '.descix/sdk-assets')
|
|
119
|
-
.replace(/\{\{PRIMARY_COMMUNITY\}\}/g, primaryCommunity || '
|
|
120
|
+
.replace(/\{\{PRIMARY_COMMUNITY\}\}/g, primaryCommunity || 'daita')
|
|
120
121
|
.replace(/\{\{ENTITLEMENTS_JSON\}\}/g, JSON.stringify(entitlementsSummary, null, 2))
|
|
121
122
|
.replace(/\{\{SDK_VERSION\}\}/g, SDK_VERSION)
|
|
122
123
|
.replace(/\{\{WORKSPACE_PATH\}\}/g, workspacePath);
|
|
@@ -148,7 +149,7 @@ async function autoRegisterSdkService(apiClient) {
|
|
|
148
149
|
// Vectorize for tell_me_how discovery
|
|
149
150
|
await apiClient.invoke('vectorize_service_readme', {
|
|
150
151
|
service_name: 'sdk',
|
|
151
|
-
community_id: '
|
|
152
|
+
community_id: 'daita',
|
|
152
153
|
app_id: 'docs',
|
|
153
154
|
readme_content: readmeContent
|
|
154
155
|
});
|
|
@@ -216,7 +217,7 @@ export async function runSetupWizard(options = {}) {
|
|
|
216
217
|
name: 'environment',
|
|
217
218
|
message: 'Which environment are you connecting to?',
|
|
218
219
|
choices: [
|
|
219
|
-
{ name:
|
|
220
|
+
{ name: `Production (${ENV_ORIGINS.prod})`, value: 'production' },
|
|
220
221
|
{ name: 'Local Development (localhost:4000)', value: 'local' },
|
|
221
222
|
{ name: 'Custom URL', value: 'custom' }
|
|
222
223
|
],
|
|
@@ -225,7 +226,7 @@ export async function runSetupWizard(options = {}) {
|
|
|
225
226
|
]);
|
|
226
227
|
|
|
227
228
|
if (environment === 'production') {
|
|
228
|
-
apiUrl =
|
|
229
|
+
apiUrl = ENV_ORIGINS.prod;
|
|
229
230
|
} else if (environment === 'local') {
|
|
230
231
|
apiUrl = 'https://localhost:4000';
|
|
231
232
|
} else {
|
|
@@ -348,9 +349,9 @@ export async function runSetupWizard(options = {}) {
|
|
|
348
349
|
}
|
|
349
350
|
|
|
350
351
|
// Derive primary community from entitlements
|
|
351
|
-
let primaryCommunity = '
|
|
352
|
+
let primaryCommunity = 'daita';
|
|
352
353
|
if (entitlements.communities.length > 0) {
|
|
353
|
-
primaryCommunity = entitlements.communities[0].community_id || entitlements.communities[0].id || '
|
|
354
|
+
primaryCommunity = entitlements.communities[0].community_id || entitlements.communities[0].id || 'daita';
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
// Step 4: MCP Configuration
|
|
@@ -443,7 +444,7 @@ export async function runSetupWizard(options = {}) {
|
|
|
443
444
|
additionalContexts: [],
|
|
444
445
|
defaultContext: {
|
|
445
446
|
communityId: primaryCommunity,
|
|
446
|
-
appId: '
|
|
447
|
+
appId: 'daita',
|
|
447
448
|
kbId: 'General'
|
|
448
449
|
},
|
|
449
450
|
apiUrl: apiUrl,
|
|
@@ -490,8 +491,8 @@ export async function runSetupWizard(options = {}) {
|
|
|
490
491
|
console.log(chalk.white('If MCP fails, use CLI directly:'));
|
|
491
492
|
console.log(chalk.gray(' descix purchases # View your entitlements'));
|
|
492
493
|
console.log(chalk.gray(' descix tell-me-how "question" # Discover tools'));
|
|
493
|
-
console.log(chalk.gray(' descix sync
|
|
494
|
-
console.log(chalk.gray(' descix
|
|
494
|
+
console.log(chalk.gray(' descix app sync-assets -a Y # Sync app assets'));
|
|
495
|
+
console.log(chalk.gray(' descix kb corpus sync -a Y # Sync knowledge base\n'));
|
|
495
496
|
|
|
496
497
|
} catch (error) {
|
|
497
498
|
spinner.fail('Setup failed');
|