@descix/cli 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/descix.js CHANGED
@@ -13,6 +13,7 @@ import { DeSciXApiClient } from '../lib/api-client.js';
13
13
  import { requireAuth } from '../lib/auth-guard.js';
14
14
  import { WorkspaceConfig } from '../lib/workspace-config.js';
15
15
  import { CLI_VERSION } from '../lib/cli-version.js';
16
+ import { recordInvocationOrigin } from '../lib/origin.js';
16
17
  // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
17
18
  import {
18
19
  getSessionInteractionId,
@@ -107,12 +108,24 @@ const ENV_URL_MAP = Object.fromEntries(
107
108
  Object.entries(WorkspaceConfig.ENV_MAP).map(([k, v]) => [k, v.url])
108
109
  );
109
110
 
111
+ // The fold into DESCIX_API_URL is what makes every consumer see ONE variable — and it is also
112
+ // what used to DESTROY the answer to "who chose this origin", so the origin owner printed a
113
+ // static disjunction ("DESCIX_API_URL (or --api-url / --env)") for all three cases. The fold
114
+ // stays; the provenance is now RECORDED alongside it, in the same breath, so the owner can name
115
+ // the actual source. Recording it here rather than deriving it in the owner keeps one owner of
116
+ // the fact: the only code that knows a flag was passed is the code that read the flag.
110
117
  program.hook('preAction', (thisCommand) => {
111
- const opts = thisCommand.opts();
118
+ // Read from the ROOT program, not from the action command. `--env` and `--api-url` are
119
+ // declared ONCE, globally, and Commander binds them to the root — an identically-named option
120
+ // redeclared on a subcommand is shadowed and silently never populated (measured on 1.0.4:
121
+ // `descix config init --env dev` reached its action with `{}` and rejected its own advertised
122
+ // flag). One declaration, read from where Commander actually puts it.
123
+ const opts = program.opts();
112
124
 
113
125
  // --api-url takes highest priority
114
126
  if (opts.apiUrl) {
115
127
  process.env.DESCIX_API_URL = opts.apiUrl;
128
+ recordInvocationOrigin(opts.apiUrl, 'flagApiUrl');
116
129
  return;
117
130
  }
118
131
 
@@ -120,12 +133,23 @@ program.hook('preAction', (thisCommand) => {
120
133
  if (opts.env) {
121
134
  const envName = opts.env.toLowerCase();
122
135
  if (!(envName in ENV_URL_MAP)) {
123
- console.error(chalk.red(`Unknown environment: ${opts.env}. Use: dev, demo, prod`));
136
+ // THE REFUSAL CARRIES THE CUSTOM-ENVIRONMENT PATH. This validator runs BEFORE any command
137
+ // action, so it is now the only thing a developer typing `--env staging` ever sees — the
138
+ // richer text inside `config init` that names `config set-env` for exactly this case can
139
+ // no longer be reached through the flag. A refusal that names only the three built-in
140
+ // names would strand every self-hosted or port-forwarded gateway with no next step, which
141
+ // is the same dead end as a remedy string for a command that does not run.
142
+ console.error(chalk.red(
143
+ `Unknown environment: ${opts.env}. Known environments: ${Object.keys(ENV_URL_MAP).join(', ')}.\n\n` +
144
+ 'For a self-hosted or port-forwarded gateway, name it with its origin instead:\n' +
145
+ ` descix config set-env ${envName} --url https://...`
146
+ ));
124
147
  process.exit(1);
125
148
  }
126
149
  // Every named environment resolves to a URL (including dev → cloud DEV).
127
150
  // A local backend is a URL you name, not an environment.
128
151
  process.env.DESCIX_API_URL = ENV_URL_MAP[envName];
152
+ recordInvocationOrigin(ENV_URL_MAP[envName], 'flagEnv');
129
153
  }
130
154
  });
131
155
 
@@ -4334,15 +4358,24 @@ configCommand
4334
4358
 
4335
4359
  configCommand
4336
4360
  .command('init')
4337
- .description('Initialize configuration for an environment (requires --env)')
4361
+ .description('Pin this workspace to an environment (requires the global --env dev|demo|prod)')
4338
4362
  // `--dev` DELETED, not deprecated: its absence meant PRODUCTION, so the most common
4339
4363
  // invocation (`descix config init`) wrote a prod origin nobody named. There is no default.
4340
- .option('--env <name>', 'Environment to initialize: dev, demo or prod')
4364
+ //
4365
+ // `--env` IS NOT REDECLARED HERE, and that is the fix, not an omission. It was declared both
4366
+ // globally (program.option, above) and on this subcommand; Commander 14 binds `--env dev` to
4367
+ // the ROOT program, so this action received `{}` and threw "requires an environment" — the
4368
+ // command REJECTED THE FLAG ITS OWN --help ADVERTISED. Measured on published 1.0.4, and worse
4369
+ // than a missing command: the CLI printed `descix config init --env dev` as the remedy for an
4370
+ // unconfigured origin (origin.js DEFAULT_ORIGIN_SOURCE) and that exact copy-pasted string
4371
+ // exited 1. Two declarations of one flag is the mirror-drift class; one owner is the cure.
4341
4372
  .option('--url <url>', 'Explicit origin (for a self-hosted or port-forwarded gateway)')
4342
4373
  .option('-g, --global', 'Save to global config (~/.descixrc)')
4374
+ .addHelpText('after', '\nEnvironment comes from the global flag:\n' +
4375
+ ' descix config init --env dev|demo|prod\n')
4343
4376
  .action(async (options) => {
4344
4377
  try {
4345
- await configCommands.init(options.env, options);
4378
+ await configCommands.init(program.opts().env, options);
4346
4379
  } catch (error) {
4347
4380
  fail(error);
4348
4381
  }
package/lib/api-client.js CHANGED
@@ -66,6 +66,15 @@ export class DeSciXApiClient {
66
66
  this.baseUrl = String(this.baseUrl).trim().replace(/\/+$/, '');
67
67
  originSource = EXPLICIT_ORIGIN_SOURCE;
68
68
  }
69
+ // KEPT ON THE CLIENT, not just printed and dropped. `status` and `doctor` have to SHOW the
70
+ // resolved origin and its source in their own report bodies; when this was a local variable
71
+ // the only way for them to state an origin was to derive one themselves, and both did —
72
+ // from GlobalConfig.api_url, which is legitimately null. That produced three different
73
+ // answers for one state seconds apart (status "production (null)", doctor "API URL: null",
74
+ // app list the real origin). A consumer can only ferry the owner's answer if the owner's
75
+ // answer is still reachable.
76
+ this.originSource = originSource;
77
+
69
78
  // I1 (A', rev 2): ALWAYS, not default-only. Silence was the defect; PROD was not.
70
79
  reportEnvironment({ origin: this.baseUrl, source: originSource });
71
80
 
@@ -101,13 +101,29 @@ export async function init(env, options = {}) {
101
101
  // Write through the CANONICAL writer. This function used to assign the LEGACY top-level
102
102
  // `workspaceConfig.apiUrl`, while `config set-env` writes `env.apiUrl` — two writers of one
103
103
  // fact, writing two different keys, one of which `getApiUrl()` only consults as a fallback.
104
- const workspaceConfig = await WorkspaceConfig.load();
104
+ //
105
+ // `tryLoad`, NOT `load`, AND IT CREATES ONE WHEN THERE IS NONE. This is what closes the
106
+ // PROD WINDOW at the start of every onboarding (measured on published 1.0.4): in a fresh
107
+ // directory `config init --env dev` threw "Workspace not configured", `config set-env dev`
108
+ // threw the same, and so there was NO WORKING WAY TO PIN THE ENVIRONMENT BEFORE the first
109
+ // command ran — every one of them silently targeted PROD while printing a remedy that could
110
+ // not be executed. Pinning the environment must not require a workspace to already exist:
111
+ // choosing the origin is the FIRST thing a developer does, not something they may only do
112
+ // after they have already talked to the wrong one.
113
+ const existing = await WorkspaceConfig.tryLoad();
114
+ const workspaceConfig = existing
115
+ || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, process.cwd());
116
+ const created = !existing;
105
117
  const result = await workspaceConfig.setEnvironment(normalized, options.url || null);
106
118
 
107
119
  console.log(chalk.green('\n✅ Configuration initialized!\n'));
108
120
  console.log(chalk.white(` Environment: ${result.environment}`));
109
121
  console.log(chalk.white(` API URL: ${result.apiUrl}`));
110
- console.log(chalk.gray(` Saved to: ${result.configPath}\n`));
122
+ console.log(chalk.gray(` Saved to: ${result.configPath}`));
123
+ if (created) {
124
+ console.log(chalk.gray(' (new workspace created here — this directory is now the workspace root)'));
125
+ }
126
+ console.log('');
111
127
 
112
128
  } catch (error) {
113
129
  console.error(chalk.red('Error initializing config:', error.message));
@@ -124,7 +140,32 @@ export async function init(env, options = {}) {
124
140
  */
125
141
  export async function setEnv(envName, options = {}) {
126
142
  try {
127
- const workspaceConfig = await WorkspaceConfig.load();
143
+ // ONE SURFACE PER CAPABILITY. `config init` and `config set-env` both write through the same
144
+ // canonical writer (setEnvironment) — that shared owner is correct — but they must not both
145
+ // accept the SAME input, or there are two commands for one act and the CLI's own remedy
146
+ // string can name the loser. `config init` is canonical for the three KNOWN environments:
147
+ // the origin owner prints `descix config init --env dev` as the remedy, so that command must
148
+ // be the one that does it. `set-env` keeps the capability `init` genuinely does not have —
149
+ // a CUSTOM environment name with an explicit --url — and REFUSES the known names outright,
150
+ // naming its replacement. A refusal, not a warning: a warning would leave two working paths.
151
+ const knownName = WorkspaceConfig.ENV_MAP[String(envName).toLowerCase()];
152
+ if (knownName) {
153
+ throw new Error(
154
+ `"${envName}" is a known environment — use the canonical command:\n` +
155
+ ` descix config init --env ${String(envName).toLowerCase()}\n\n` +
156
+ 'descix config set-env is for a CUSTOM environment name with an explicit origin, e.g.\n' +
157
+ ' descix config set-env staging --url https://staging.example.com'
158
+ );
159
+ }
160
+
161
+ // Same tryLoad-or-create as `config init`, for the same reason: `config init`'s own error
162
+ // text sends a developer HERE for the self-hosted / port-forwarded case, so this command has
163
+ // to work where that developer actually is. If it still required a pre-existing workspace,
164
+ // init's remedy would point at a command that refuses — the identical closed loop, moved one
165
+ // hop along. A remedy string is only true if the command it names runs.
166
+ const existing = await WorkspaceConfig.tryLoad();
167
+ const workspaceConfig = existing
168
+ || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, process.cwd());
128
169
 
129
170
  const result = await workspaceConfig.setEnvironment(envName, options.url || null);
130
171
 
@@ -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
  }
@@ -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})`);
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` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",