@descix/cli 1.0.4 → 1.0.6
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/README.md +130 -34
- package/agent-assets/reference/local-dev.md +0 -4
- package/bin/descix.js +61 -29
- package/lib/api-client.js +9 -0
- package/lib/commands/auth.js +17 -1
- package/lib/commands/config.js +44 -3
- package/lib/commands/doctor.js +88 -21
- package/lib/commands/init.js +8 -3
- package/lib/commands/kb.js +7 -4
- package/lib/commands/status.js +28 -4
- package/lib/commands/update.js +0 -1
- package/lib/core/Hydrator.js +88 -11
- package/lib/origin.js +82 -9
- package/lib/workspace-config.js +63 -7
- package/package.json +2 -2
- package/templates/agent-claude.md +0 -3
- package/templates/agent-cline.md +0 -3
- package/templates/agent-copilot.md +0 -3
- package/templates/agent-cursor.md +0 -3
- package/templates/drive/app/kb/General/README.md +1 -1
- package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
- package/templates/scaffolds/microservice/defaults-config.json +3 -1
- package/templates/scaffolds/microservice/manifest.json +9 -5
- package/templates/scaffolds/microservice/package.json +1 -1
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
package/lib/commands/doctor.js
CHANGED
|
@@ -13,7 +13,6 @@ import fs from 'fs/promises';
|
|
|
13
13
|
import { exec } from 'child_process';
|
|
14
14
|
import { promisify } from 'util';
|
|
15
15
|
import { DeSciXApiClient } from '../api-client.js';
|
|
16
|
-
import { GlobalConfig } from '../global-config.js';
|
|
17
16
|
import { isAuthenticated } from '../auth-guard.js';
|
|
18
17
|
|
|
19
18
|
const execAsync = promisify(exec);
|
|
@@ -62,6 +61,28 @@ async function checkNode() {
|
|
|
62
61
|
* Check API Connectivity
|
|
63
62
|
*/
|
|
64
63
|
async function checkConnectivity(apiClient) {
|
|
64
|
+
// NO NETWORK VERDICT ON AN ORIGIN WE DO NOT HAVE. Measured on published 1.0.4: doctor was
|
|
65
|
+
// handed `baseUrl: globalConfig.api_url`, which is legitimately null on a machine that has
|
|
66
|
+
// never written ~/.descix/config.json, and then called `fetch(null)`. That throws, so doctor
|
|
67
|
+
// reported `[✗] API Connectivity — Unreachable / Fix: Check your internet connection.
|
|
68
|
+
// API URL: null` — UNCONFIGURED STATE MISREPORTED AS A NETWORK FAILURE, sending the developer
|
|
69
|
+
// to debug their wifi over a config fact. A red verdict on a null origin is not a measurement
|
|
70
|
+
// of anything; neither would a green one be.
|
|
71
|
+
//
|
|
72
|
+
// This is now unreachable through runDoctor, which resolves via the origin owner (never null,
|
|
73
|
+
// or a loud OriginInvalidError). It stays as a hard guard because "the caller always passes a
|
|
74
|
+
// good origin" is exactly the assumption that produced the defect.
|
|
75
|
+
if (!apiClient.baseUrl) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
message: 'NOT CHECKED — no origin resolved',
|
|
79
|
+
remediation:
|
|
80
|
+
'This is a configuration fact, not a network problem, and no connectivity result is\n' +
|
|
81
|
+
' reported for it. Choose an origin:\n' +
|
|
82
|
+
' descix config init --env dev|demo|prod',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
try {
|
|
66
87
|
// Just check if we can reach the health endpoint or root
|
|
67
88
|
// The API client doesn't expose a raw ping, but we can try a lightweight call
|
|
@@ -148,10 +169,11 @@ export async function checkGcloud() {
|
|
|
148
169
|
const version = stdout.split('\n')[0].trim(); // e.g. "Google Cloud SDK 456.0.0"
|
|
149
170
|
return { ok: true, message: version };
|
|
150
171
|
} catch (error) {
|
|
151
|
-
return {
|
|
152
|
-
ok: false,
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
advisory: true,
|
|
175
|
+
message: 'Not installed or not in PATH',
|
|
176
|
+
remediation: `Only needed to administer DeSciX infrastructure. If you are building on DeSciX, ignore this.\n Install (admins): https://cloud.google.com/sdk/docs/install`
|
|
155
177
|
};
|
|
156
178
|
}
|
|
157
179
|
}
|
|
@@ -166,10 +188,11 @@ export async function checkAdc() {
|
|
|
166
188
|
await fs.access(process.env.GOOGLE_APPLICATION_CREDENTIALS);
|
|
167
189
|
return { ok: true, message: 'Configured via Env Var' };
|
|
168
190
|
} catch {
|
|
169
|
-
return {
|
|
170
|
-
ok: false,
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
advisory: true,
|
|
194
|
+
message: 'Env Var set but file missing',
|
|
195
|
+
remediation: `Check GOOGLE_APPLICATION_CREDENTIALS path: ${process.env.GOOGLE_APPLICATION_CREDENTIALS}`
|
|
173
196
|
};
|
|
174
197
|
}
|
|
175
198
|
}
|
|
@@ -191,10 +214,11 @@ export async function checkAdc() {
|
|
|
191
214
|
// For now, existence is a good enough proxy for "attempted setup"
|
|
192
215
|
return { ok: true, message: 'Configured (Default Location)' };
|
|
193
216
|
} catch {
|
|
194
|
-
return {
|
|
195
|
-
ok: false,
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
advisory: true,
|
|
220
|
+
message: 'Missing Credentials',
|
|
221
|
+
remediation: `Only needed for Google Drive-backed KB authoring and infrastructure admin.\n If you are building on DeSciX, ignore this.\n Admins run: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/drive.file,https://www.googleapis.com/auth/drive`
|
|
198
222
|
};
|
|
199
223
|
}
|
|
200
224
|
}
|
|
@@ -209,9 +233,26 @@ export async function runDoctor(options = {}) {
|
|
|
209
233
|
const results = [];
|
|
210
234
|
|
|
211
235
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
236
|
+
// THE ORIGIN COMES FROM THE ONE OWNER. This was `new DeSciXApiClient({ baseUrl:
|
|
237
|
+
// globalConfig.api_url })` — a THIRD independent derivation of the origin, beside status's
|
|
238
|
+
// and beside the real one. ~/.descix/config.json names no origin on a fresh machine, so
|
|
239
|
+
// doctor ran its whole diagnosis against `null`. Passing NO baseUrl routes through
|
|
240
|
+
// initialize() → resolveOrigin(), which never returns null, fails loud on an origin that was
|
|
241
|
+
// named but unusable, and prints the `env:` line doctor was missing.
|
|
242
|
+
const apiClient = new DeSciXApiClient();
|
|
243
|
+
try {
|
|
244
|
+
await apiClient.ensureInitialized();
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// A CONFIGURED-BUT-INVALID origin is not a diagnosis to render in a results table — it is
|
|
247
|
+
// the answer, and it already carries its own remedy from the origin owner. Fail loud
|
|
248
|
+
// rather than continuing into checks whose results would all be meaningless.
|
|
249
|
+
spinner.stop();
|
|
250
|
+
console.error(chalk.red(`\n${error.message}\n`));
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
215
256
|
// 1. Node Version
|
|
216
257
|
spinner.text = 'Checking Node.js...';
|
|
217
258
|
const nodeRes = await checkNode();
|
|
@@ -247,9 +288,19 @@ export async function runDoctor(options = {}) {
|
|
|
247
288
|
// Print Results
|
|
248
289
|
let hasFailures = false;
|
|
249
290
|
|
|
291
|
+
// AN ADVISORY IS NOT A FAILURE. gcloud and ADC are administrator dependencies: an external
|
|
292
|
+
// developer building ON DeSciX has no business holding Google ADC credentials, yet doctor
|
|
293
|
+
// showed them a red [✗] and exited non-zero for not having them — telling every non-admin
|
|
294
|
+
// caller their environment is broken when it is correct and complete. A check that cries
|
|
295
|
+
// wolf on correct behaviour trains people to ignore the checks that matter.
|
|
250
296
|
results.forEach(res => {
|
|
251
297
|
if (res.ok) {
|
|
252
298
|
console.log(`${chalk.green('[✓]')} ${chalk.white(res.label)} ${chalk.gray(res.message ? `(${res.message})` : '')}`);
|
|
299
|
+
} else if (res.advisory) {
|
|
300
|
+
console.log(`${chalk.gray('[-]')} ${chalk.white(res.label)} ${chalk.gray(`(${res.message} — not required)`)}`);
|
|
301
|
+
if (res.remediation) {
|
|
302
|
+
console.log(` ${chalk.gray('Note:')} ${chalk.gray(res.remediation)}`);
|
|
303
|
+
}
|
|
253
304
|
} else {
|
|
254
305
|
hasFailures = true;
|
|
255
306
|
console.log(`${chalk.red('[✗]')} ${chalk.white(res.label)}`);
|
|
@@ -259,19 +310,35 @@ export async function runDoctor(options = {}) {
|
|
|
259
310
|
}
|
|
260
311
|
}
|
|
261
312
|
});
|
|
262
|
-
|
|
313
|
+
|
|
263
314
|
console.log();
|
|
264
|
-
|
|
315
|
+
|
|
316
|
+
// COVERAGE BOUNDARY, printed on green as well as red: the reader of a pass must see where
|
|
317
|
+
// the pass stops.
|
|
318
|
+
console.log(chalk.gray(
|
|
319
|
+
'Checked: Node version, API origin resolution + reachability, session, local write\n' +
|
|
320
|
+
'permissions. [-] rows are admin-only dependencies and never fail this command. NOT\n' +
|
|
321
|
+
'checked: your app\'s own build, chain state, or any credential\'s permissions at the API.'
|
|
322
|
+
));
|
|
323
|
+
console.log();
|
|
324
|
+
|
|
265
325
|
if (hasFailures) {
|
|
266
326
|
console.log(chalk.yellow('⚠️ Some checks failed. Please review the issues above.'));
|
|
267
|
-
process.exit(1)
|
|
327
|
+
// `process.exitCode` rather than `process.exit(1)`: it lets Node drain stdout before
|
|
328
|
+
// exiting, where an immediate process.exit can truncate buffered output on a pipe.
|
|
329
|
+
// NO DEFECT WAS MEASURED HERE — this is a hardening, and the distinction matters. A
|
|
330
|
+
// suspected "exit code says 0 while the text says failed" was investigated on published
|
|
331
|
+
// 1.0.4 and DID NOT REPRODUCE: the 0 came from reading `$?` after a shell PIPELINE, which
|
|
332
|
+
// reports the exit of the last stage (`head`), not of `descix`. Recorded so the next
|
|
333
|
+
// reader does not re-open it: 1.0.4 exits 1 correctly.
|
|
334
|
+
process.exitCode = 1;
|
|
268
335
|
} else {
|
|
269
336
|
console.log(chalk.green('✨ All checks passed! Your environment is ready.'));
|
|
270
337
|
}
|
|
271
|
-
|
|
338
|
+
|
|
272
339
|
} catch (error) {
|
|
273
340
|
spinner.fail('Doctor crashed');
|
|
274
341
|
console.error(chalk.red(`\nUnexpected error: ${error.message}`));
|
|
275
|
-
process.
|
|
342
|
+
process.exitCode = 1;
|
|
276
343
|
}
|
|
277
344
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -13,6 +13,10 @@ import { dirname, join } from 'path';
|
|
|
13
13
|
// clone.js is imported dynamically inside the invite flow to avoid circular deps
|
|
14
14
|
import { WorkspaceConfig } from '../workspace-config.js';
|
|
15
15
|
import { generateAgentFiles } from '../agent-files.js';
|
|
16
|
+
// The canonical KB-sync surface is owned by retired-kb-sync.js. Consume the constant: a literal
|
|
17
|
+
// spelled here is a second derivation of the same fact, and the removed verb this replaced
|
|
18
|
+
// reached a developer who had done everything right.
|
|
19
|
+
import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
|
|
16
20
|
|
|
17
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
22
|
const __dirname = dirname(__filename);
|
|
@@ -208,8 +212,9 @@ export async function runInit(apiClient, options = {}) {
|
|
|
208
212
|
|
|
209
213
|
console.log(chalk.cyan('\n─── Next Steps ───\n'));
|
|
210
214
|
console.log(chalk.white(` 1. descix app init -a ${appId} -c ${communityId} # register app on platform`));
|
|
211
|
-
console.log(chalk.
|
|
212
|
-
console.log(chalk.white(` 3.
|
|
215
|
+
console.log(chalk.gray(` 2. Create a corpus manifest at .descix/manifests/General.json`));
|
|
216
|
+
console.log(chalk.white(` 3. ${CANONICAL_KB_SYNC} -a ${appId} # sync KB to Pinecone`));
|
|
217
|
+
console.log(chalk.white(` 4. descix chat -c ${communityId} -a ${appId} -q "test" # verify RAG\n`));
|
|
213
218
|
console.log(chalk.green('✅ Workspace initialized.\n'));
|
|
214
219
|
rl.close();
|
|
215
220
|
return { created: ['.descix/workspace.json'], skipped: [], warnings: [] };
|
|
@@ -250,7 +255,7 @@ export async function initWorkspace(options) {
|
|
|
250
255
|
created,
|
|
251
256
|
skipped: [],
|
|
252
257
|
warnings: [],
|
|
253
|
-
nextSteps: [`descix app init -a ${appId} -c ${communityId}`,
|
|
258
|
+
nextSteps: [`descix app init -a ${appId} -c ${communityId}`, `${CANONICAL_KB_SYNC} -a ${appId}`]
|
|
254
259
|
};
|
|
255
260
|
}
|
|
256
261
|
|
package/lib/commands/kb.js
CHANGED
|
@@ -27,6 +27,10 @@ import { hydrateKb, pushStaging, checkStagingFiles } from '../core/Hydrator.js';
|
|
|
27
27
|
import { processKb } from '../core/Chunker.js';
|
|
28
28
|
import { syncKb, getSyncStatus } from '../core/Syncer.js';
|
|
29
29
|
import * as driveADC from '../google-storage-adc.js';
|
|
30
|
+
// The one canonical KB-sync surface, from its owner. Never spell it as a literal here: these
|
|
31
|
+
// commands' next steps are read by a developer as instructions, so they must name the live verb
|
|
32
|
+
// and nothing else.
|
|
33
|
+
import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
|
|
30
34
|
|
|
31
35
|
// ============ Pull Command ============
|
|
32
36
|
|
|
@@ -110,8 +114,7 @@ export async function runKbPull(apiClient, options) {
|
|
|
110
114
|
if (!options.quiet) {
|
|
111
115
|
console.log(chalk.cyan('\n📋 Next steps:'));
|
|
112
116
|
console.log(chalk.gray(' 1. Review files in kb/' + kbId + '/'));
|
|
113
|
-
console.log(chalk.gray(
|
|
114
|
-
console.log(chalk.gray(' 3. Run "descix kb sync" to push to Pinecone\n'));
|
|
117
|
+
console.log(chalk.gray(` 2. Run "${CANONICAL_KB_SYNC} -a <app_id>" to push to Pinecone\n`));
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
return result;
|
|
@@ -220,8 +223,8 @@ export async function runKbPush(apiClient, options) {
|
|
|
220
223
|
// Show next steps
|
|
221
224
|
if (!options.quiet) {
|
|
222
225
|
console.log(chalk.cyan('\n📋 Next steps:'));
|
|
223
|
-
console.log(chalk.gray(' 1. Run "descix
|
|
224
|
-
console.log(chalk.gray(
|
|
226
|
+
console.log(chalk.gray(' 1. Run "descix drive pull" to sync all Drive content'));
|
|
227
|
+
console.log(chalk.gray(` 2. Run "${CANONICAL_KB_SYNC} -a <app_id>" to push to Pinecone\n`));
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
return result;
|
package/lib/commands/status.js
CHANGED
|
@@ -19,6 +19,7 @@ import { promisify } from 'util';
|
|
|
19
19
|
import { DeSciXApiClient } from '../api-client.js';
|
|
20
20
|
import { GlobalConfig } from '../global-config.js';
|
|
21
21
|
import { isAuthenticated } from '../auth-guard.js';
|
|
22
|
+
import { environmentNameFor } from '../environment-report.js';
|
|
22
23
|
|
|
23
24
|
const execAsync = promisify(exec);
|
|
24
25
|
|
|
@@ -89,7 +90,24 @@ export async function runStatus(options = {}) {
|
|
|
89
90
|
console.log(`OS: ${os.type()} ${os.release()}`);
|
|
90
91
|
|
|
91
92
|
const globalConfig = await GlobalConfig.load();
|
|
92
|
-
|
|
93
|
+
|
|
94
|
+
// THE ORIGIN COMES FROM THE ONE OWNER, VIA ITS CONSUMER. This line used to read
|
|
95
|
+
// `${globalConfig.environment} (${globalConfig.api_url})` — a SECOND, independent derivation
|
|
96
|
+
// of the origin that consulted only ~/.descix/config.json. That file legitimately names no
|
|
97
|
+
// origin, so on an unconfigured workspace status printed "production (null)": an environment
|
|
98
|
+
// NAME it had not resolved, beside a null origin, while `descix app list` seconds later
|
|
99
|
+
// correctly reported prod at https://descix.net from the actual owner. `environment` here is
|
|
100
|
+
// GlobalConfig's own unrelated default string, not a resolution — it was never an answer to
|
|
101
|
+
// "which origin will this CLI talk to".
|
|
102
|
+
//
|
|
103
|
+
// Constructing the client with NO baseUrl is what routes through the owner: initialize()
|
|
104
|
+
// resolves via resolveOrigin() and prints the `env:` line (I1 — every network-bound command,
|
|
105
|
+
// always). status and doctor were the two network-bound commands missing that line, which is
|
|
106
|
+
// exactly why the three-way disagreement stayed invisible.
|
|
107
|
+
const apiClient = new DeSciXApiClient();
|
|
108
|
+
await apiClient.ensureInitialized();
|
|
109
|
+
console.log(`Environment: ${environmentNameFor(apiClient.baseUrl)} (${apiClient.originSource})`);
|
|
110
|
+
console.log(`API Origin: ${chalk.green(apiClient.baseUrl)}`);
|
|
93
111
|
console.log();
|
|
94
112
|
|
|
95
113
|
// Load workspace context via WorkspaceConfig
|
|
@@ -110,7 +128,6 @@ export async function runStatus(options = {}) {
|
|
|
110
128
|
console.log(chalk.white('Authentication'));
|
|
111
129
|
console.log(chalk.white('--------------'));
|
|
112
130
|
|
|
113
|
-
const apiClient = new DeSciXApiClient({ baseUrl: globalConfig.api_url });
|
|
114
131
|
const isAuth = await isAuthenticated(apiClient);
|
|
115
132
|
|
|
116
133
|
if (isAuth) {
|
|
@@ -150,8 +167,15 @@ export async function runStatus(options = {}) {
|
|
|
150
167
|
console.log(`Local Apps: ${allApps.join(', ')}`);
|
|
151
168
|
}
|
|
152
169
|
|
|
153
|
-
|
|
154
|
-
|
|
170
|
+
// WHAT THE WORKSPACE FILE ITSELF SAYS — deliberately NOT a resolved origin. The resolved
|
|
171
|
+
// origin and its source are reported once, above, by the owner's consumer. This line used
|
|
172
|
+
// to call getApiUrl() with a `|| '(not configured …)'` fallback; getApiUrl() now consumes
|
|
173
|
+
// the origin owner and so NEVER returns null, making that branch unreachable — a dead
|
|
174
|
+
// fallback that could only ever misreport. Printing the raw key answers the different and
|
|
175
|
+
// genuinely useful question "is this origin coming from my workspace, or from elsewhere?"
|
|
176
|
+
const workspaceApiUrl = workspaceConfig.env?.apiUrl;
|
|
177
|
+
console.log(`API (this file): ${workspaceApiUrl
|
|
178
|
+
|| '(unset — origin resolves from the sources printed above)'}`);
|
|
155
179
|
console.log(`Local Root: ${workspaceRoot}`);
|
|
156
180
|
} catch (error) {
|
|
157
181
|
console.log(`Mode: ${chalk.red('Unknown/Invalid')} (${error.message})`);
|
package/lib/commands/update.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Commands for updating resources based on workspace.json context detection:
|
|
5
5
|
* descix update # Auto-detect what to update
|
|
6
6
|
* descix update app # Sync app assets to Drive
|
|
7
|
-
* descix update kb # Sync KB + vectorize
|
|
8
7
|
* descix update site # Deploy site to GCS
|
|
9
8
|
* descix update all # Update everything
|
|
10
9
|
*
|
package/lib/core/Hydrator.js
CHANGED
|
@@ -726,11 +726,15 @@ export async function checkStagingFiles(stagingDir) {
|
|
|
726
726
|
*
|
|
727
727
|
* @param {string} scaffoldType - 'site' or 'microservice'
|
|
728
728
|
* @param {string} appPath - Path to the app directory
|
|
729
|
-
* @param {Object} options - { verbose, force }
|
|
729
|
+
* @param {Object} options - { verbose, force, substitute }
|
|
730
|
+
* @param {{appId: string, communityId: string, appName?: string}} [options.substitute] - the
|
|
731
|
+
* caller's real identity. Supply it for any scaffold a developer will actually use: without it
|
|
732
|
+
* the copy is verbatim and every {{TOKEN}} ships unresolved. Omitting it is only appropriate
|
|
733
|
+
* when materializing the raw template itself.
|
|
730
734
|
* @returns {Promise<{copied: number}>}
|
|
731
735
|
*/
|
|
732
736
|
export async function copyScaffold(scaffoldType, appPath, options = {}) {
|
|
733
|
-
const { verbose = false, force = false } = options;
|
|
737
|
+
const { verbose = false, force = false, substitute = null } = options;
|
|
734
738
|
|
|
735
739
|
const scaffoldDir = SCAFFOLD_DIRS[scaffoldType];
|
|
736
740
|
if (!scaffoldDir) {
|
|
@@ -767,8 +771,36 @@ export async function copyScaffold(scaffoldType, appPath, options = {}) {
|
|
|
767
771
|
|
|
768
772
|
// Copy scaffold recursively
|
|
769
773
|
const stats = { copied: 0 };
|
|
770
|
-
|
|
771
|
-
|
|
774
|
+
let values = null;
|
|
775
|
+
if (substitute) {
|
|
776
|
+
if (!substitute.appId || !substitute.communityId) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
'copyScaffold: substitute requires both appId and communityId. Refusing to resolve a ' +
|
|
779
|
+
'scaffold against a partial identity — a half-substituted scaffold is harder to spot ' +
|
|
780
|
+
'than an unsubstituted one.'
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
values = {
|
|
784
|
+
APP_ID: substitute.appId,
|
|
785
|
+
COMMUNITY_ID: substitute.communityId,
|
|
786
|
+
APP_NAME: substitute.appName || substitute.appId,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const unresolved = [];
|
|
791
|
+
await copyDir(scaffoldDir, targetDir, stats, verbose, values, unresolved);
|
|
792
|
+
|
|
793
|
+
// FAIL LOUD. A placeholder that survives the copy reaches the developer as literal braces, and
|
|
794
|
+
// that is exactly the class of defect this substitution exists to end — so it must never be a
|
|
795
|
+
// warning. Name every offender so a new token added to a template cannot ship unnoticed.
|
|
796
|
+
if (unresolved.length > 0) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`Scaffold "${scaffoldType}" has ${unresolved.length} unresolved placeholder(s): ` +
|
|
799
|
+
`${unresolved.join(', ')}. Every {{TOKEN}} in a scaffold needs a value in copyScaffold's ` +
|
|
800
|
+
`substitute map (see templates/scaffolds/README.md for the documented set).`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
772
804
|
return stats;
|
|
773
805
|
}
|
|
774
806
|
|
|
@@ -785,21 +817,66 @@ async function hasContentInDir(dirPath) {
|
|
|
785
817
|
}
|
|
786
818
|
|
|
787
819
|
/**
|
|
788
|
-
*
|
|
820
|
+
* THE ONE OWNER of scaffold placeholder substitution.
|
|
821
|
+
*
|
|
822
|
+
* templates/scaffolds/README.md has documented this contract all along — "Scaffolds use these
|
|
823
|
+
* placeholders (replaced during copy): {{APP_NAME}}, {{APP_ID}}, {{COMMUNITY_ID}}" — but nothing
|
|
824
|
+
* ever performed the replacement. Both scaffolds shipped their placeholders and their sample
|
|
825
|
+
* literals verbatim, so a developer's site was titled `{{APP_NAME}}` and the sample handler
|
|
826
|
+
* queried a community that is not theirs.
|
|
827
|
+
*
|
|
828
|
+
* SUBSTITUTION, NOT A SECOND LITERAL. The fix resolves each token to the caller's real values;
|
|
829
|
+
* it does not swap one hardcoded community for another. That is what lets the same scaffold serve
|
|
830
|
+
* every community without the CLI knowing any of their names.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} text - file contents
|
|
833
|
+
* @param {Record<string,string>} values - token name (without braces) -> replacement
|
|
834
|
+
* @returns {string}
|
|
789
835
|
*/
|
|
790
|
-
|
|
836
|
+
function applyScaffoldTokens(text, values) {
|
|
837
|
+
return text.replace(/\{\{([A-Z_]+)\}\}/g, (whole, token) =>
|
|
838
|
+
Object.prototype.hasOwnProperty.call(values, token) ? values[token] : whole
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** A NUL byte is the cheap, dependency-free binary tell. Never rewrite bytes we cannot read. */
|
|
843
|
+
function looksBinary(buf) {
|
|
844
|
+
return buf.includes(0);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Recursively copy a directory, optionally substituting scaffold tokens on the way.
|
|
849
|
+
*
|
|
850
|
+
* @param {Record<string,string>|null} values - when null, the copy is byte-for-byte verbatim
|
|
851
|
+
* @param {string[]} unresolved - collects `file:{{TOKEN}}` for tokens no value was supplied for
|
|
852
|
+
*/
|
|
853
|
+
async function copyDir(src, dest, stats, verbose, values = null, unresolved = [], relBase = '') {
|
|
791
854
|
await fs.mkdir(dest, { recursive: true });
|
|
792
|
-
|
|
855
|
+
|
|
793
856
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
794
|
-
|
|
857
|
+
|
|
795
858
|
for (const entry of entries) {
|
|
796
859
|
const srcPath = path.join(src, entry.name);
|
|
797
860
|
const destPath = path.join(dest, entry.name);
|
|
798
|
-
|
|
861
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
862
|
+
|
|
799
863
|
if (entry.isDirectory()) {
|
|
800
|
-
await copyDir(srcPath, destPath, stats, verbose);
|
|
864
|
+
await copyDir(srcPath, destPath, stats, verbose, values, unresolved, rel);
|
|
801
865
|
} else {
|
|
802
|
-
|
|
866
|
+
if (values) {
|
|
867
|
+
const buf = await fs.readFile(srcPath);
|
|
868
|
+
if (looksBinary(buf)) {
|
|
869
|
+
await fs.copyFile(srcPath, destPath);
|
|
870
|
+
} else {
|
|
871
|
+
const out = applyScaffoldTokens(buf.toString('utf8'), values);
|
|
872
|
+
// Anything still wearing braces had no value supplied. Collect it rather than shipping
|
|
873
|
+
// it: an unresolved placeholder reaching a developer is the defect this exists to end.
|
|
874
|
+
for (const m of out.matchAll(/\{\{([A-Z_]+)\}\}/g)) unresolved.push(`${rel}:{{${m[1]}}}`);
|
|
875
|
+
await fs.writeFile(destPath, out);
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
await fs.copyFile(srcPath, destPath);
|
|
879
|
+
}
|
|
803
880
|
stats.copied++;
|
|
804
881
|
if (verbose) console.log(` Copied: ${entry.name}`);
|
|
805
882
|
}
|
package/lib/origin.js
CHANGED
|
@@ -60,15 +60,87 @@ export class OriginInvalidError extends Error {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* The
|
|
64
|
-
*
|
|
63
|
+
* The SOURCE label for each thing that can name an origin. One key, one spelling.
|
|
64
|
+
*
|
|
65
|
+
* WHY EACH LABEL NAMES EXACTLY ONE THING (measured 2026-09-09 on published 1.0.4): the envVar
|
|
66
|
+
* label used to read `DESCIX_API_URL (or --api-url / --env)` — a STATIC DISJUNCTION printed
|
|
67
|
+
* whichever of the three had actually chosen the origin, and printed verbatim even with
|
|
68
|
+
* DESCIX_API_URL provably unset. `--env dev`, `--api-url https://dev.descix.net` and a real
|
|
69
|
+
* `DESCIX_API_URL=...` produced three byte-identical source strings. A source that lists the
|
|
70
|
+
* possibilities is not a source: it is the same "who chose this origin" silence this module
|
|
71
|
+
* exists to end, one level in from the null it already fixed.
|
|
72
|
+
*
|
|
73
|
+
* The flag labels are reachable because `bin/descix.js` RECORDS which flag set the variable
|
|
74
|
+
* (see `recordInvocationOrigin`) instead of only folding it into `process.env` and discarding
|
|
75
|
+
* the provenance.
|
|
76
|
+
*/
|
|
77
|
+
export const ORIGIN_SOURCE_LABELS = {
|
|
78
|
+
flagApiUrl: '--api-url flag',
|
|
79
|
+
flagEnv: '--env flag',
|
|
80
|
+
envVar: 'DESCIX_API_URL environment variable',
|
|
81
|
+
workspaceEnvApiUrl: '.descix/workspace.json env.apiUrl',
|
|
82
|
+
legacyApiUrl: '.descix/workspace.json apiUrl (legacy)',
|
|
83
|
+
globalApiUrl: '~/.descix/config.json api_url',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The resolution order, most explicit first. The ORDER is the contract; this array is the only
|
|
88
|
+
* place it is written down. Labels come from ORIGIN_SOURCE_LABELS, never from a second literal.
|
|
89
|
+
*/
|
|
90
|
+
const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', 'legacyApiUrl', 'globalApiUrl'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What this invocation's FLAGS named, if anything: `{ origin, sourceKey }`.
|
|
94
|
+
*
|
|
95
|
+
* `--api-url` and `--env` are folded into `process.env.DESCIX_API_URL` by the CLI's preAction
|
|
96
|
+
* hook so that every consumer sees one variable. That fold is what destroys the provenance, so
|
|
97
|
+
* the hook records it here in the same breath. Module-level because there is exactly ONE
|
|
98
|
+
* invocation per process, and because this module is imported by every consumer of the origin —
|
|
99
|
+
* one owner of the fact, not a copy ferried through four call signatures.
|
|
100
|
+
*/
|
|
101
|
+
let invocationOrigin = null;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Record that a COMMAND-LINE FLAG chose this invocation's origin.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} origin - the origin the flag resolved to
|
|
107
|
+
* @param {'flagApiUrl'|'flagEnv'} sourceKey - which flag it was
|
|
108
|
+
* @throws when handed a key that has no published label — a source with no name is the defect.
|
|
65
109
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
110
|
+
export function recordInvocationOrigin(origin, sourceKey) {
|
|
111
|
+
if (!Object.prototype.hasOwnProperty.call(ORIGIN_SOURCE_LABELS, sourceKey)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`recordInvocationOrigin: "${sourceKey}" is not a known origin source. ` +
|
|
114
|
+
`Known: ${Object.keys(ORIGIN_SOURCE_LABELS).join(', ')}.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
invocationOrigin = { origin: normalizeOrigin(origin), sourceKey };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** TEST SEAM ONLY: forget this invocation's recorded flag provenance. */
|
|
121
|
+
export function _resetInvocationOriginForTests() {
|
|
122
|
+
invocationOrigin = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The label for the value that won, naming WHICH source supplied it.
|
|
127
|
+
*
|
|
128
|
+
* The envVar slot is the only one with more than one possible author, because the flags are
|
|
129
|
+
* folded into it. It reports a flag ONLY when the recorded flag actually accounts for the value
|
|
130
|
+
* in hand — if a flag was recorded and DESCIX_API_URL nevertheless holds something else, the
|
|
131
|
+
* honest answer is the environment variable.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} key
|
|
134
|
+
* @param {string} value
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
function sourceLabelFor(key, value) {
|
|
138
|
+
if (key === 'envVar' && invocationOrigin
|
|
139
|
+
&& invocationOrigin.origin === normalizeOrigin(value)) {
|
|
140
|
+
return ORIGIN_SOURCE_LABELS[invocationOrigin.sourceKey];
|
|
141
|
+
}
|
|
142
|
+
return ORIGIN_SOURCE_LABELS[key];
|
|
143
|
+
}
|
|
72
144
|
|
|
73
145
|
/** The remedy text every failure prints. One spelling, so every surface says the same thing. */
|
|
74
146
|
export const ORIGIN_REMEDY =
|
|
@@ -120,9 +192,10 @@ function isUsableOrigin(value) {
|
|
|
120
192
|
* same class of defect from the other direction.
|
|
121
193
|
*/
|
|
122
194
|
export function resolveOrigin(sources = {}) {
|
|
123
|
-
for (const
|
|
195
|
+
for (const key of PRECEDENCE) {
|
|
124
196
|
const value = sources[key];
|
|
125
197
|
if (typeof value === 'string' && value.trim() !== '') {
|
|
198
|
+
const label = sourceLabelFor(key, value);
|
|
126
199
|
if (!isUsableOrigin(value)) {
|
|
127
200
|
throw new OriginInvalidError(
|
|
128
201
|
`The DeSciX API origin configured in ${label} is not a usable origin: "${String(value).trim()}".\n` +
|
package/lib/workspace-config.js
CHANGED
|
@@ -2,6 +2,29 @@ import * as fs from 'fs/promises';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { ENV_ORIGINS } from '@descix/app-sdk/dev';
|
|
4
4
|
import { resolveOrigin } from './origin.js';
|
|
5
|
+
// The one canonical KB-sync surface, from its owner. The example in requireContext() is a remedy
|
|
6
|
+
// a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
|
|
7
|
+
// that can go stale where nobody is watching.
|
|
8
|
+
import { CANONICAL_KB_SYNC } from './commands/retired-kb-sync.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* THE ONE OWNER of "this app is not mapped in workspace.json, here is how to fix it".
|
|
12
|
+
*
|
|
13
|
+
* This text existed as three byte-identical copies in this file, and TWO OTHER call sites in
|
|
14
|
+
* bin/descix.js answered the same condition with a DIFFERENT and wrong remedy — `npx descix init`,
|
|
15
|
+
* which initialises a WORKSPACE and cannot map an app, so a developer who followed it stayed
|
|
16
|
+
* exactly as stuck. That is the mirror-drift tell: the same condition explained two ways, and the
|
|
17
|
+
* wrong one was the one a developer actually hit on the onboarding path.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} appId - the app that is not mapped
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function unmappedAppMessage(appId) {
|
|
23
|
+
return (
|
|
24
|
+
`App "${appId}" is not mapped in workspace.json. ` +
|
|
25
|
+
'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
5
28
|
|
|
6
29
|
/**
|
|
7
30
|
* ONE OWNER for turning a workspace-relative localPath into an absolute path.
|
|
@@ -248,6 +271,17 @@ export class WorkspaceConfig {
|
|
|
248
271
|
/**
|
|
249
272
|
* Save workspace configuration in v2.1 format.
|
|
250
273
|
* Always writes env.platform / env.products structure.
|
|
274
|
+
*
|
|
275
|
+
* PRESERVES KEYS IT DOES NOT UNDERSTAND. This method used to serialize a fixed field list from
|
|
276
|
+
* memory over the whole file, so ANY top-level key it did not itself enumerate was silently
|
|
277
|
+
* destroyed by the next `descix app set-port` — a writer re-serializing a known schema and
|
|
278
|
+
* discarding the rest. That is the same defect class as a record writer dropping a field it was
|
|
279
|
+
* never taught about, and it is invisible: the write succeeds, the file looks well-formed, and
|
|
280
|
+
* the loss is only discovered by whoever needed the missing key.
|
|
281
|
+
*
|
|
282
|
+
* The on-disk file is re-read at save time rather than trusted from construction, because the
|
|
283
|
+
* instance may have been held across another writer's save.
|
|
284
|
+
*
|
|
251
285
|
* @param {string} [workspaceRoot] - Workspace root directory (uses stored root if not provided)
|
|
252
286
|
* @returns {Promise<string>} Path to saved config
|
|
253
287
|
*/
|
|
@@ -266,7 +300,32 @@ export class WorkspaceConfig {
|
|
|
266
300
|
// Build env block for v2.1 output
|
|
267
301
|
const envBlock = (this.env && Object.keys(this.env).length > 0) ? this.env : null;
|
|
268
302
|
|
|
303
|
+
// The keys this writer OWNS. Everything else on disk is carried through untouched.
|
|
304
|
+
const OWNED = ['version', 'workspaceRoot', 'type', 'env', 'driveConfig'];
|
|
305
|
+
|
|
306
|
+
let onDisk = {};
|
|
307
|
+
try {
|
|
308
|
+
const existing = await fs.readFile(configPath, 'utf-8');
|
|
309
|
+
const parsed = JSON.parse(existing);
|
|
310
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) onDisk = parsed;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// ENOENT is the ordinary first save. A PRESENT-BUT-UNPARSEABLE file is different: carrying
|
|
313
|
+
// on would overwrite content we could not read, so refuse and name the file.
|
|
314
|
+
if (err.code !== 'ENOENT') {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Refusing to overwrite ${configPath}: it exists but could not be read as JSON ` +
|
|
317
|
+
`(${err.message}). Fix or remove it — saving now would destroy whatever it holds.`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const carried = {};
|
|
323
|
+
for (const [k, v] of Object.entries(onDisk)) {
|
|
324
|
+
if (!OWNED.includes(k)) carried[k] = v;
|
|
325
|
+
}
|
|
326
|
+
|
|
269
327
|
const configData = {
|
|
328
|
+
...carried,
|
|
270
329
|
version: '2.1',
|
|
271
330
|
workspaceRoot: path.resolve(root),
|
|
272
331
|
type: this.type,
|
|
@@ -508,7 +567,7 @@ export class WorkspaceConfig {
|
|
|
508
567
|
'Options:\n' +
|
|
509
568
|
' 1. cd into an app directory\n' +
|
|
510
569
|
' 2. Use flag: -a <app_id>\n\n' +
|
|
511
|
-
|
|
570
|
+
`Example: npx ${CANONICAL_KB_SYNC} -a daita`
|
|
512
571
|
);
|
|
513
572
|
}
|
|
514
573
|
|
|
@@ -637,8 +696,7 @@ export class WorkspaceConfig {
|
|
|
637
696
|
|
|
638
697
|
if (!entry) {
|
|
639
698
|
throw new Error(
|
|
640
|
-
|
|
641
|
-
'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
|
|
699
|
+
unmappedAppMessage(appId)
|
|
642
700
|
);
|
|
643
701
|
}
|
|
644
702
|
|
|
@@ -687,8 +745,7 @@ export class WorkspaceConfig {
|
|
|
687
745
|
|
|
688
746
|
if (!entry) {
|
|
689
747
|
throw new Error(
|
|
690
|
-
|
|
691
|
-
'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
|
|
748
|
+
unmappedAppMessage(appId)
|
|
692
749
|
);
|
|
693
750
|
}
|
|
694
751
|
|
|
@@ -743,8 +800,7 @@ export class WorkspaceConfig {
|
|
|
743
800
|
|
|
744
801
|
if (!entry) {
|
|
745
802
|
throw new Error(
|
|
746
|
-
|
|
747
|
-
'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
|
|
803
|
+
unmappedAppMessage(appId)
|
|
748
804
|
);
|
|
749
805
|
}
|
|
750
806
|
|