@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.
@@ -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
- message: 'Not installed or not in PATH',
154
- remediation: `Install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install`
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
- message: 'Env Var set but file missing',
172
- remediation: `Check GOOGLE_APPLICATION_CREDENTIALS path: ${process.env.GOOGLE_APPLICATION_CREDENTIALS}`
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
- message: 'Missing Credentials',
197
- remediation: `Run: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/drive.file,https://www.googleapis.com/auth/drive`
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
- const globalConfig = await GlobalConfig.load();
213
- const apiClient = new DeSciXApiClient({ baseUrl: globalConfig.api_url });
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.exit(1);
342
+ process.exitCode = 1;
276
343
  }
277
344
  }
@@ -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.white(` 2. descix update kb -c ${communityId} -a ${appId} # sync KB to Pinecone`));
212
- console.log(chalk.white(` 3. descix chat -c ${communityId} -a ${appId} -q "test" # verify RAG\n`));
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}`, `descix update kb -c ${communityId} -a ${appId}`]
258
+ nextSteps: [`descix app init -a ${appId} -c ${communityId}`, `${CANONICAL_KB_SYNC} -a ${appId}`]
254
259
  };
255
260
  }
256
261
 
@@ -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(' 2. Run "descix kb chunk" to generate chunks'));
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 kb pull" to sync all Drive content'));
224
- console.log(chalk.gray(' 2. Run "descix kb chunk" to generate chunks\n'));
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;
@@ -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
- console.log(`Environment: ${globalConfig.environment} (${globalConfig.api_url})`);
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
- console.log(`API: ${workspaceConfig.getApiUrl() ||
154
- '(not configured — run `descix config set-env dev|demo|prod`)'}`);
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})`);
@@ -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
  *
@@ -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
- await copyDir(scaffoldDir, targetDir, stats, verbose);
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
- * Recursively copy a directory
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
- async function copyDir(src, dest, stats, verbose) {
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
- await fs.copyFile(srcPath, destPath);
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 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.
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
- 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
- ];
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 [label, key] of PRECEDENCE) {
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` +
@@ -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
- 'Example: npx descix update kb -a daita'
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
- `App "${appId}" is not mapped in workspace.json. ` +
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
- `App "${appId}" is not mapped in workspace.json. ` +
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
- `App "${appId}" is not mapped in workspace.json. ` +
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