@descix/cli 1.0.6 → 1.0.7

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
@@ -11,7 +11,7 @@ import { Command } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DeSciXApiClient } from '../lib/api-client.js';
13
13
  import { requireAuth } from '../lib/auth-guard.js';
14
- import { WorkspaceConfig, unmappedAppMessage } from '../lib/workspace-config.js';
14
+ import { WorkspaceConfig, unmappedAppMessage, resolveWorkspacePath, TOP_LEVEL_API_URL_REMEDY } from '../lib/workspace-config.js';
15
15
  import { CLI_VERSION } from '../lib/cli-version.js';
16
16
  import { recordInvocationOrigin } from '../lib/origin.js';
17
17
  // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
@@ -29,6 +29,8 @@ import * as buyCommands from '../lib/commands/buy.js';
29
29
  import * as creditsCommands from '../lib/commands/credits.js';
30
30
  import * as airdropCommands from '../lib/commands/airdrop.js';
31
31
  import { runInit } from '../lib/commands/init.js';
32
+ // "May I prompt?" has ONE OWNER. No command in this file derives it.
33
+ import { createPromptSession } from '../lib/interactive.js';
32
34
  import * as updateCommands from '../lib/commands/update.js';
33
35
  import { registerAllRetiredKbSync, refuseRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
34
36
  import { runStatus } from '../lib/commands/status.js';
@@ -291,7 +293,9 @@ program
291
293
  // --env=demo is supplied (because the subcommand owns its own flag space).
292
294
  const parentEnv = program.opts().env || null;
293
295
  const env = options.env || parentEnv || 'dev';
294
- await runHealth({ ...options, env });
296
+ const result = await runHealth({ ...options, env });
297
+ // A health check that reports an unhealthy service and exits 0 is not a gate.
298
+ if (result && result.all_healthy === false) process.exitCode = 1;
295
299
  } catch (error) {
296
300
  console.error(chalk.red(`\nHealth check error: ${error.message}\n`));
297
301
  process.exit(1);
@@ -612,11 +616,12 @@ communityCommand
612
616
  console.log(chalk.white(` Network: Polygon (live)\n`));
613
617
 
614
618
  if (!options.yes) {
615
- const readline = await import('readline');
616
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
617
- const answer = await new Promise(resolve => {
618
- rl.question(chalk.yellow(` Create community "${options.name}" with token ${tokenSymbol} on live Polygon? [y/N] `), resolve);
619
+ const rl = createPromptSession({
620
+ what: 'descix community create',
621
+ destructive: true,
622
+ nonInteractiveForm: ['descix community create ... --yes # deploys a REAL token contract on live Polygon']
619
623
  });
624
+ const answer = await rl.askRaw(chalk.yellow(` Create community "${options.name}" with token ${tokenSymbol} on live Polygon? [y/N] `));
620
625
  rl.close();
621
626
  if (answer.toLowerCase() !== 'y') {
622
627
  console.log(chalk.gray('\n Aborted.\n'));
@@ -713,11 +718,15 @@ communityCommand
713
718
 
714
719
  // Confirmation prompt (unless --yes)
715
720
  if (!options.yes && hardDelete) {
716
- const readline = await import('readline');
717
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
718
- const answer = await new Promise(resolve => {
719
- rl.question(chalk.yellow(`\n Permanently delete community "${manifest.community_name}" and all ${manifest.app_count} app(s)? This cannot be undone. [y/N] `), resolve);
721
+ const rl = createPromptSession({
722
+ what: 'descix community delete',
723
+ destructive: true,
724
+ nonInteractiveForm: [
725
+ 'Re-run with --dry-run to see the plan without changing anything.',
726
+ 'descix community delete ... --yes # PERMANENTLY deletes the community and every app in it'
727
+ ]
720
728
  });
729
+ const answer = await rl.askRaw(chalk.yellow(`\n Permanently delete community "${manifest.community_name}" and all ${manifest.app_count} app(s)? This cannot be undone. [y/N] `));
721
730
  rl.close();
722
731
  if (answer.toLowerCase() !== 'y') {
723
732
  console.log(chalk.gray('\n Aborted.\n'));
@@ -803,12 +812,15 @@ communityCommand
803
812
  }
804
813
 
805
814
  if (!options.yes) {
806
- const readline = await import('readline');
807
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
808
- const answer = await new Promise(resolve => rl.question(
809
- chalk.yellow(`\n Execute rename ${oldId} -> ${newId}? Re-tags ${plan.pinecone.matched} vector(s) and deletes Community/${oldId} after verifying Community/${newId}. [y/N] `),
810
- resolve
811
- ));
815
+ const rl = createPromptSession({
816
+ what: 'descix community rename',
817
+ destructive: true,
818
+ nonInteractiveForm: [
819
+ 'Re-run with --dry-run to see the plan without changing anything.',
820
+ 'descix community rename ... --yes # re-tags vectors and DELETES the old community doc'
821
+ ]
822
+ });
823
+ const answer = await rl.askRaw(chalk.yellow(`\n Execute rename ${oldId} -> ${newId}? Re-tags ${plan.pinecone.matched} vector(s) and deletes Community/${oldId} after verifying Community/${newId}. [y/N] `));
812
824
  rl.close();
813
825
  if (answer.toLowerCase() !== 'y') {
814
826
  console.log(chalk.gray('\n Aborted.\n'));
@@ -1608,11 +1620,15 @@ appCommand
1608
1620
 
1609
1621
  // Confirmation prompt (unless --yes)
1610
1622
  if (!options.yes && hardDelete) {
1611
- const readline = await import('readline');
1612
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1613
- const answer = await new Promise(resolve => {
1614
- rl.question(chalk.yellow(`\n Permanently delete ${appId}? This cannot be undone. [y/N] `), resolve);
1623
+ const rl = createPromptSession({
1624
+ what: 'descix app delete',
1625
+ destructive: true,
1626
+ nonInteractiveForm: [
1627
+ 'Re-run with --dry-run to see the plan without changing anything.',
1628
+ `descix app delete -a ${appId} --yes # PERMANENTLY deletes the app (hard delete is the default; --soft only hides it)`
1629
+ ]
1615
1630
  });
1631
+ const answer = await rl.askRaw(chalk.yellow(`\n Permanently delete ${appId}? This cannot be undone. [y/N] `));
1616
1632
  rl.close();
1617
1633
  if (answer.toLowerCase() !== 'y') {
1618
1634
  console.log(chalk.gray('\n Aborted.\n'));
@@ -1742,36 +1758,49 @@ appCommand
1742
1758
  const newPath = options.path;
1743
1759
 
1744
1760
  const workspaceConfig = await WorkspaceConfig.load();
1745
- const appConfig = workspaceConfig.getAppByAppId(appId);
1746
- if (!appConfig) {
1747
- throw new Error(`App '${appId}' is not mapped. Run 'descix app init -a ${appId}' first.`);
1748
- }
1761
+ const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1749
1762
 
1750
- // Hard-fail if path doesn't exist or is not a directory
1763
+ // Read the RAW registry entry, never the RESOLVED one. getAppByAppId() resolves the stored
1764
+ // localPath, so on a workspace already carrying a rejected value it throws here — before
1765
+ // any write is attempted. That is what made this verb refuse exactly when it was needed,
1766
+ // while the rejection told the user to fix it with the config verb and never by hand.
1767
+ // This command is about to REPLACE that value; it has no business resolving it.
1768
+ //
1769
+ // Display value only. setLocalPath() is the SINGLE authority on whether the app is mapped
1770
+ // and refuses if it is not — deciding that here as well would be two derivations of one
1771
+ // fact, and they would not even agree: _buildAppIdMap (behind getAppEntry) requires BOTH
1772
+ // appId and localPath, while the live-entry walk matches on appId alone.
1773
+ const oldPath = workspaceConfig.getAppEntry(appId)?.localPath;
1774
+
1775
+ // VALIDATE AT THE WRITE, through the LOADER'S OWN resolver — not a second copy of its rules.
1776
+ // If resolveWorkspacePath would reject this value on the next read, it is rejected now, and
1777
+ // nothing reaches disk. Writing a value the loader will refuse is what bricks a workspace.
1778
+ const absPath = resolveWorkspacePath(wsRoot, newPath, appId);
1779
+
1780
+ // Hard-fail if path doesn't exist or is not a directory — checked at the path the LOADER
1781
+ // will resolve to, not at a CWD-relative path that only exists from where you happened to
1782
+ // be standing when you typed it.
1751
1783
  let stat;
1752
1784
  try {
1753
- stat = await fs.stat(newPath);
1785
+ stat = await fs.stat(absPath);
1754
1786
  } catch {
1755
- throw new Error(`Path does not exist: ${newPath}`);
1787
+ throw new Error(
1788
+ `Path does not exist: ${absPath}\n` +
1789
+ ` (localPath '${newPath}' resolved against workspace root ${wsRoot})`
1790
+ );
1756
1791
  }
1757
1792
  if (!stat.isDirectory()) {
1758
- throw new Error(`Path is not a directory: ${newPath}`);
1793
+ throw new Error(
1794
+ `Path is not a directory: ${absPath}\n` +
1795
+ ` (localPath '${newPath}' resolved against workspace root ${wsRoot})`
1796
+ );
1759
1797
  }
1760
1798
 
1761
- // Update the localPath in workspace.json
1762
- const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
1763
- const oldPath = appConfig.localPath;
1764
- appConfig.localPath = newPath;
1765
-
1766
- // Update env.products entry
1767
- const products = workspaceConfig.env?.products || [];
1768
- for (const product of products) {
1769
- if (product.appId === appId || product.app_id === appId) {
1770
- product.localPath = newPath;
1771
- break;
1772
- }
1773
- }
1774
- await workspaceConfig.save(wsRoot);
1799
+ // Write through the canonical setter, which covers env.platform AND env.products[]. The
1800
+ // loop this replaces walked env.products ONLY: on the platform app it matched nothing,
1801
+ // wrote nothing, and still printed success — a loud failure turned into a silent lie at
1802
+ // the one entry that matters most.
1803
+ await workspaceConfig.setLocalPath(appId, newPath);
1775
1804
 
1776
1805
  console.log(chalk.green(`\nāœ“ ${appId} local path updated`));
1777
1806
  console.log(chalk.gray(` ${oldPath} → ${newPath}\n`));
@@ -3049,15 +3078,13 @@ siteCommand
3049
3078
  }
3050
3079
 
3051
3080
  if (!options.confirm) {
3052
- const readline = await import('readline');
3053
- const rl = readline.createInterface({
3054
- input: process.stdin,
3055
- output: process.stdout
3056
- });
3057
-
3058
- const answer = await new Promise(resolve => {
3059
- rl.question(chalk.yellow(`\nāš ļø Delete site for ${communityId}/${appId}${options.preview ? ' (preview)' : ''}? (y/N) `), resolve);
3081
+ const rl = createPromptSession({
3082
+ what: 'descix site delete',
3083
+ destructive: true,
3084
+ nonInteractiveForm: [`descix site delete -c ${communityId} -a ${appId} --confirm # deletes the uploaded site files`]
3060
3085
  });
3086
+
3087
+ const answer = await rl.askRaw(chalk.yellow(`\nāš ļø Delete site for ${communityId}/${appId}${options.preview ? ' (preview)' : ''}? (y/N) `));
3061
3088
  rl.close();
3062
3089
 
3063
3090
  if (answer.toLowerCase() !== 'y') {
@@ -4342,17 +4369,22 @@ configCommand
4342
4369
  }
4343
4370
  });
4344
4371
 
4372
+ // `config set-url` is RETIRED, and it is retired LOUDLY rather than deleted outright: a deleted
4373
+ // subcommand only earns commander's "unknown command", which names no replacement. It used to
4374
+ // assign a top-level `apiUrl` that save() never serialized, then print a success banner over a
4375
+ // value that landed nowhere — the file was re-stamped and the origin never moved. Hidden from
4376
+ // --help; any invocation exits non-zero naming the three surfaces that actually set the origin.
4345
4377
  configCommand
4346
- .command('set-url')
4347
- .description('Set API URL')
4348
- .argument('<url>', 'API URL (e.g., https://localhost:4000)')
4349
- .option('-g, --global', 'Save to global config (~/.descixrc)')
4350
- .action(async (url, options) => {
4351
- try {
4352
- await configCommands.setUrl(url, options);
4353
- } catch (error) {
4354
- fail(error);
4355
- }
4378
+ .command('set-url', { hidden: true })
4379
+ .description('retired — see error text')
4380
+ .argument('[url]')
4381
+ .allowUnknownOption()
4382
+ .action(() => {
4383
+ fail(new Error(
4384
+ '"descix config set-url" is retired: it never wrote the origin it reported (a retired top-level\n' +
4385
+ 'key that nothing reads). Set the API origin where it is read, env.apiUrl, with one of:\n' +
4386
+ ` ${TOP_LEVEL_API_URL_REMEDY}`
4387
+ ));
4356
4388
  });
4357
4389
 
4358
4390
  configCommand
@@ -4825,8 +4857,12 @@ program
4825
4857
  const { WorkspaceConfig } = await import('../lib/workspace-config.js');
4826
4858
  wsConfig = await WorkspaceConfig.load(process.cwd());
4827
4859
  } catch (e) {
4828
- console.error(chalk.yellow(`āš ļø Could not read .descix/workspace.json for project scope`));
4829
- console.error(chalk.gray(` Run 'descix init' first, or use --scope entitlements`));
4860
+ // RELAY the loader's own diagnosis; do not re-derive a remedy here. This site used to
4861
+ // print "Run 'descix init' first" for EVERY failure, including a corrupt-but-present
4862
+ // workspace.json — prescribing an overwrite of the file it had just failed to read,
4863
+ // and discarding the loader's message that said exactly what was wrong.
4864
+ console.error(chalk.red(e.message));
4865
+ console.error(chalk.gray(` Or use --scope entitlements, which needs no workspace.`));
4830
4866
  process.exit(1);
4831
4867
  }
4832
4868
  const appIds = new Set();
@@ -5019,29 +5055,55 @@ program
5019
5055
  await authCommands.loginDevice(loginOptions);
5020
5056
  }
5021
5057
 
5022
- // Step 2: Workspace init — create workspace.json if missing
5023
- const wsConfigPath = path.join(workspaceRoot, '.descix', 'workspace.json');
5024
- let hasWorkspace = false;
5025
- try {
5026
- await fs.access(wsConfigPath);
5027
- hasWorkspace = true;
5058
+ // Step 2: Workspace init — create workspace.json only when there is none ANYWHERE UP THE TREE.
5059
+ //
5060
+ // WHERE THE WORKSPACE ROOT IS has ONE OWNER: WorkspaceConfig.findWorkspaceRoot, which walks UP
5061
+ // exactly as load() does. This step used to derive it a THIRD time with fs.access on the target
5062
+ // path only (the wizard guard was the second). Run from a SUBDIRECTORY of an existing
5063
+ // workspace, that check saw nothing, runInit created a NESTED workspace.json whose
5064
+ // workspaceRoot pointed at the subdirectory, and it SHADOWED the parent for every later
5065
+ // resolution — silently, with "Quickstart complete!" and exit 0.
5066
+ //
5067
+ // quickstart is an ONBOARDING flow: a user who asked to be set up and already IS set up has
5068
+ // SUCCEEDED, so this SKIPS and REPORTS and continues. `descix mcp quickstart` refuses non-zero
5069
+ // on the same fact because it was asked specifically to CREATE a workspace and cannot honour
5070
+ // that. Different verbs, different contracts, ONE OWNER for the underlying fact.
5071
+ const existingRoot = await WorkspaceConfig.findWorkspaceRoot(workspaceRoot);
5072
+ if (existingRoot) {
5028
5073
  console.log(chalk.green('āœ“ Workspace already initialized'));
5029
- } catch { /* missing */ }
5030
-
5031
- if (!hasWorkspace) {
5074
+ if (path.resolve(existingRoot) !== path.resolve(workspaceRoot)) {
5075
+ console.log(chalk.gray(` Workspace root: ${existingRoot}`));
5076
+ console.log(chalk.gray(` You are in a subdirectory of it. Not creating a second workspace`));
5077
+ console.log(chalk.gray(` here — a nested one would shadow the root for every command run`));
5078
+ console.log(chalk.gray(` from this directory.`));
5079
+ }
5080
+ } else {
5032
5081
  console.log(chalk.cyan('\nšŸ“‹ Initialize Workspace\n'));
5033
- await runInit({ path: workspaceRoot });
5082
+ // runInit's signature is (apiClient, options). Passing a single object put it in the
5083
+ // apiClient slot and left options defaulting to {}, so `path` was silently discarded and
5084
+ // runInit fell back to process.cwd(). It was MASKED here only because workspaceRoot IS
5085
+ // process.cwd() in this action — the argument was in the wrong slot regardless, and would
5086
+ // have written the workspace to the wrong directory the moment that stopped being true.
5087
+ // This action has no apiClient of its own; null is passed explicitly rather than implied.
5088
+ await runInit(null, { path: workspaceRoot });
5034
5089
  }
5035
5090
 
5091
+ // EVERY REMAINING STEP TARGETS THE RESOLVED ROOT, not the directory the command was typed in.
5092
+ // They read the workspace to learn the app, community and origin they must state, so pointing
5093
+ // them at a subdirectory that deliberately has no workspace.json makes them hard-fail on the
5094
+ // absence this command just chose to preserve. Resolving the root once and ferrying it is the
5095
+ // same one-owner rule that fixed the check above.
5096
+ const targetRoot = existingRoot || workspaceRoot;
5097
+
5036
5098
  // Step 3: Generate agent instruction files
5037
5099
  console.log(chalk.cyan('\nšŸ“‹ Generating Agent Instructions\n'));
5038
- const written = await generateAgentFiles(workspaceRoot);
5100
+ const written = await generateAgentFiles(targetRoot);
5039
5101
  for (const f of written) {
5040
5102
  console.log(chalk.green(` āœ“ ${f}`));
5041
5103
  }
5042
5104
 
5043
5105
  // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
5044
- const mcpWritten = await generateMcpConfig(workspaceRoot);
5106
+ const mcpWritten = await generateMcpConfig(targetRoot);
5045
5107
  if (mcpWritten) {
5046
5108
  console.log(chalk.green(' āœ“ .vscode/mcp.json'));
5047
5109
  } else {
@@ -5051,7 +5113,7 @@ program
5051
5113
  // Step 5: Copy SDK assets
5052
5114
  try {
5053
5115
  const { pullSdkAssets } = await import('../lib/wizard/setup.js');
5054
- const pulled = await pullSdkAssets(workspaceRoot);
5116
+ const pulled = await pullSdkAssets(targetRoot);
5055
5117
  if (pulled) console.log(chalk.green(' āœ“ .descix/sdk-assets/'));
5056
5118
  } catch { /* setup.js pullSdkAssets may not be exported — skip */ }
5057
5119
 
package/lib/api-client.js CHANGED
@@ -13,6 +13,35 @@ import { resolveOrigin } from './origin.js';
13
13
  import { reportEnvironment, EXPLICIT_ORIGIN_SOURCE } from './environment-report.js';
14
14
  import { diag } from './diagnostic-log.js';
15
15
 
16
+ /**
17
+ * THE effective API origin for this invocation, with the source that chose it: the environment
18
+ * variable (which the --api-url / --env flags fold into), then the workspace's env.apiUrl, then
19
+ * ~/.descix/config.json, then the declared default. Every surface that SHOWS or USES the origin
20
+ * consumes this — `config show` used to print the workspace file's value while every command
21
+ * used the environment variable's, so an inherited DESCIX_API_URL silently pointed commands at
22
+ * production while `config show` said dev (JARVIS-FRAQTL, 2026-09-16).
23
+ *
24
+ * Never returns null and never throws for an ABSENT configuration (nothing configured is the
25
+ * declared default, carrying `source: default`, which the caller prints). Throws
26
+ * OriginInvalidError when a source named an origin that cannot be used.
27
+ *
28
+ * @param {string|null|undefined} workspaceEnvApiUrl - the loaded workspace's env.apiUrl
29
+ * @returns {Promise<{origin: string, source: string, isDefault: boolean}>}
30
+ */
31
+ export async function resolveEffectiveOrigin(workspaceEnvApiUrl) {
32
+ let globalApiUrl = null;
33
+ try {
34
+ const { GlobalConfig } = await import('./global-config.js');
35
+ const gc = await GlobalConfig.load();
36
+ globalApiUrl = gc.api_url || null;
37
+ } catch {}
38
+ return resolveOrigin({
39
+ envVar: process.env.DESCIX_API_URL,
40
+ workspaceEnvApiUrl,
41
+ globalApiUrl,
42
+ });
43
+ }
44
+
16
45
  export class DeSciXApiClient {
17
46
  constructor(options = {}) {
18
47
  this.baseUrl = options.baseUrl || null; // Will be loaded async
@@ -93,28 +122,7 @@ export class DeSciXApiClient {
93
122
  * @returns {Promise<{origin: string, source: string, isDefault: boolean}>}
94
123
  */
95
124
  async detectApiUrl() {
96
- // Every branch of this function used to be able to yield the production origin without the
97
- // developer having chosen it, and the `!== PRODUCTION_URL` comparisons made it WORSE than a
98
- // plain fallback: a workspace that had deliberately set prod was treated as if it had set
99
- // nothing and skipped, so "chosen" and "unconfigured" were provably indistinguishable. The
100
- // precedence and the fail-loud now live in the one origin owner.
101
- let globalApiUrl = null;
102
- try {
103
- const { GlobalConfig } = await import('./global-config.js');
104
- const gc = await GlobalConfig.load();
105
- globalApiUrl = gc.api_url || null;
106
- } catch {}
107
-
108
- // Never returns null and never throws for an ABSENT configuration: nothing configured is
109
- // the declared default (PROD), carrying `source: default`, which the caller prints. It DOES
110
- // throw OriginInvalidError when a source named an origin that cannot be used — silently
111
- // discarding what the developer typed is the same defect from the other direction.
112
- return resolveOrigin({
113
- envVar: process.env.DESCIX_API_URL,
114
- workspaceEnvApiUrl: this._workspaceConfig?.env?.apiUrl,
115
- legacyApiUrl: this._workspaceConfig?.apiUrl,
116
- globalApiUrl,
117
- });
125
+ return resolveEffectiveOrigin(this._workspaceConfig?.env?.apiUrl);
118
126
  }
119
127
 
120
128
  /**
@@ -28,6 +28,8 @@
28
28
  */
29
29
 
30
30
  import fs from 'fs';
31
+ // "May I prompt?" has ONE OWNER. This site holds no TTY check of its own.
32
+ import { requireInteractive } from '../interactive.js';
31
33
  import readline from 'readline';
32
34
  import chalk from 'chalk';
33
35
  import { DeSciXApiClient } from '../api-client.js';
@@ -65,6 +67,14 @@ export function readSignerPkFromFile(path) {
65
67
  * @returns {Promise<string>} Validated PK in `0x` + 64 hex form.
66
68
  */
67
69
  export async function promptSignerPkInteractive({ stdin = process.stdin, stdout = process.stdout } = {}) {
70
+ // Gate FIRST: a non-TTY here previously hung forever holding a secret prompt open. This site
71
+ // keeps its own readline because it must disable terminal echo so the PK is never displayed;
72
+ // the DECISION of whether prompting is allowed is still the owner's alone.
73
+ requireInteractive({
74
+ what: 'descix airdrop',
75
+ question: 'signer private key',
76
+ nonInteractiveForm: ['descix airdrop --signer-pk-file <path> # reads the key from a file, never echoed']
77
+ });
68
78
  return await new Promise((resolve, reject) => {
69
79
  const rl = readline.createInterface({ input: stdin, output: stdout, terminal: true });
70
80
  // Disable echo so the PK isn't visible while typing.
@@ -264,7 +264,19 @@ export async function loginDevice(options = {}) {
264
264
  }
265
265
 
266
266
  const walletPath = WalletFileManager.getProjectWalletPath(workspaceRoot);
267
- await WalletFileManager.saveWalletFile(walletPath, walletData);
267
+ try {
268
+ await WalletFileManager.saveWalletFile(walletPath, walletData);
269
+ } catch (saveErr) {
270
+ spinner.fail(chalk.red('Login did not complete'));
271
+ // The browser ceremony succeeded, but the platform returned no usable CLI credential for this
272
+ // account (e.g. an empty wallet signature). Say so plainly: nothing was written, and this
273
+ // workspace is NOT logged in on this environment.
274
+ throw Object.assign(new Error(
275
+ `The platform completed the browser sign-in but returned an unusable CLI credential (${saveErr.message}). ` +
276
+ `Nothing was saved and you are NOT logged in on ${apiClient.baseUrl}. ` +
277
+ `This is a platform defect, not something you can fix locally; report it with this message.`
278
+ ), { code: 'UNUSABLE_CLI_CREDENTIAL' });
279
+ }
268
280
 
269
281
  spinner.succeed(chalk.green('Login successful!'));
270
282
  if (walletData.oauth) {
@@ -319,7 +331,9 @@ export async function loginDevice(options = {}) {
319
331
  spinner.fail(chalk.red('Login failed'));
320
332
  console.error(chalk.red(error.message));
321
333
 
322
- if (error.message.includes('expired')) {
334
+ if (error.code === 'UNUSABLE_CLI_CREDENTIAL') {
335
+ // The message above is the whole diagnosis; connectivity tips would misdirect.
336
+ } else if (error.message.includes('expired')) {
323
337
  diag(chalk.yellow('šŸ’” Tip: Device codes expire after 15 minutes.'));
324
338
  diag(chalk.yellow(' Run the login command again to get a new code.\n'));
325
339
  } else {
@@ -218,16 +218,19 @@ export async function runBriefer(options = {}) {
218
218
  let workspaceConfig = null;
219
219
  try {
220
220
  workspaceConfig = await WorkspaceConfig.tryLoad(cwd);
221
- } catch {
222
- // tryLoad returns null on missing workspace; an exception here means the
223
- // workspace.json file exists but is invalid. Hard-fail per philosophy —
224
- // do NOT proceed with a guessed default workspace.
221
+ } catch (error) {
222
+ // tryLoad returns null on a MISSING workspace; it throws only when workspace.json EXISTS
223
+ // and cannot be read. Hard-fail per philosophy — do NOT proceed with a guessed default.
224
+ //
225
+ // The recovery text is the loader's own, not a second opinion invented here: it used to say
226
+ // "run `descix init` to regenerate", which prescribes overwriting the very file that could
227
+ // not be read, and which also contradicts the standing rule against hand-editing it.
225
228
  throw new BrieferExtractorError({
226
229
  code: BRIEFER_ERROR_CODES.PARSE_FAIL,
227
230
  section: 'briefer entry',
228
231
  source: '.descix/workspace.json',
229
232
  expected: 'valid v2.1 workspace JSON',
230
- recovery: 'Fix the workspace.json or run `descix init` to regenerate.'
233
+ recovery: error.message
231
234
  });
232
235
  }
233
236
 
@@ -9,6 +9,8 @@ import chalk from 'chalk';
9
9
  import * as path from 'path';
10
10
  import { WorkspaceConfig } from '../workspace-config.js';
11
11
  import { environmentNameFor } from '../environment-report.js';
12
+ import { resolveEffectiveOrigin } from '../api-client.js';
13
+ import { normalizeOrigin } from '../origin.js';
12
14
 
13
15
  /**
14
16
  * Show current configuration
@@ -20,15 +22,17 @@ export async function show() {
20
22
 
21
23
  console.log(chalk.cyan('\nšŸ“‹ DeSciX Workspace Configuration:\n'));
22
24
  console.log(chalk.white(` Workspace: ${workspaceRoot}`));
23
- // Under (A', contract rev 2) getApiUrl() ALWAYS yields an origin — a workspace that named
24
- // none resolves to the declared default. The "(not configured)" branch is therefore
25
- // unreachable and was DELETED, not fenced.
26
- const configuredOrigin = workspaceConfig.getApiUrl();
27
- console.log(chalk.white(` API URL: ${configuredOrigin}`));
28
- // The environment NAME comes from the ONE owner that derives it from the origin. It used to
29
- // read a separate `env.environment` key and fall back to the literal 'production' — a second
30
- // derivation of the same fact, which could disagree with the origin printed one line above.
31
- console.log(chalk.white(` Environment: ${environmentNameFor(configuredOrigin)}`));
25
+ // The origin every command in this shell will USE, and who chose it — not the file's value
26
+ // alone. An inherited DESCIX_API_URL outranks the workspace pin; showing only the pin made
27
+ // `config show` say dev while commands ran against production.
28
+ const effective = await resolveEffectiveOrigin(workspaceConfig.env?.apiUrl);
29
+ console.log(chalk.white(` API URL: ${effective.origin}`));
30
+ console.log(chalk.white(` Environment: ${environmentNameFor(effective.origin)}`));
31
+ console.log(chalk.white(` Chosen by: ${effective.source}`));
32
+ const pinned = workspaceConfig.env?.apiUrl;
33
+ if (pinned && normalizeOrigin(pinned) !== effective.origin) {
34
+ console.log(chalk.yellow(` ⚠ This workspace pins ${normalizeOrigin(pinned)} (${environmentNameFor(pinned)}), but ${effective.source} overrides it for this shell.`));
35
+ }
32
36
 
33
37
  // Show mapped apps (v2.1 env.products)
34
38
  const platform = workspaceConfig.env?.platform;
@@ -49,28 +53,6 @@ export async function show() {
49
53
  }
50
54
  }
51
55
 
52
- /**
53
- * Set API URL
54
- */
55
- export async function setUrl(url, options = {}) {
56
- try {
57
- const workspaceConfig = await WorkspaceConfig.load();
58
- const workspaceRoot = workspaceConfig.getWorkspaceRoot();
59
-
60
- workspaceConfig.apiUrl = url;
61
- workspaceConfig.environment = url.includes('localhost') ? 'development' : 'production';
62
- const configPath = await workspaceConfig.save(workspaceRoot);
63
-
64
- console.log(chalk.green('\nāœ… Configuration updated!\n'));
65
- console.log(chalk.white(` API URL: ${url}`));
66
- console.log(chalk.gray(` Saved to: ${configPath}\n`));
67
-
68
- } catch (error) {
69
- console.error(chalk.red('Error updating config:', error.message));
70
- throw error;
71
- }
72
- }
73
-
74
56
  /**
75
57
  * Initialize config for environment
76
58
  */