@descix/cli 1.0.5 → 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.
Files changed (46) hide show
  1. package/README.md +130 -34
  2. package/agent-assets/guides/dynamic-site-pattern.md +10 -14
  3. package/agent-assets/guides/knowledge-only-pattern.md +11 -15
  4. package/agent-assets/guides/microservice-pattern.md +12 -14
  5. package/agent-assets/guides/static-site-pattern.md +12 -15
  6. package/agent-assets/instructions/onboarding-flow.md +4 -4
  7. package/agent-assets/reference/app-creation.md +36 -101
  8. package/agent-assets/reference/architecture.md +24 -27
  9. package/agent-assets/reference/local-dev.md +0 -4
  10. package/agent-assets/reference/mcp-integration.md +14 -22
  11. package/agent-assets/reference/workspace-config.md +97 -340
  12. package/agent-assets/rules/descix_mcp.template.mdc +1 -1
  13. package/bin/descix.js +159 -98
  14. package/lib/api-client.js +30 -22
  15. package/lib/commands/airdrop.js +10 -0
  16. package/lib/commands/auth.js +33 -3
  17. package/lib/commands/briefer/index.js +8 -5
  18. package/lib/commands/config.js +13 -31
  19. package/lib/commands/corpus.js +161 -54
  20. package/lib/commands/health.js +116 -21
  21. package/lib/commands/init.js +25 -48
  22. package/lib/commands/kb.js +14 -4
  23. package/lib/commands/update.js +0 -1
  24. package/lib/core/Chunker.js +1 -1
  25. package/lib/core/CorpusWalker.js +238 -47
  26. package/lib/core/Hydrator.js +88 -11
  27. package/lib/core/ManifestLoader.js +68 -0
  28. package/lib/core/Syncer.js +147 -46
  29. package/lib/interactive.js +158 -0
  30. package/lib/origin.js +3 -3
  31. package/lib/wallet-file.js +36 -37
  32. package/lib/wizard/setup.js +104 -22
  33. package/lib/workspace-config.js +325 -167
  34. package/lib/workspace-identity.js +0 -1
  35. package/package.json +3 -3
  36. package/templates/agent-claude.md +0 -3
  37. package/templates/agent-cline.md +0 -3
  38. package/templates/agent-copilot.md +0 -3
  39. package/templates/agent-cursor.md +0 -3
  40. package/templates/drive/app/kb/General/README.md +1 -1
  41. package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
  42. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
  43. package/templates/scaffolds/microservice/defaults-config.json +3 -1
  44. package/templates/scaffolds/microservice/manifest.json +9 -5
  45. package/templates/scaffolds/microservice/package.json +1 -1
  46. package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
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 } 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`));
@@ -2507,8 +2536,7 @@ siteCommand
2507
2536
 
2508
2537
  const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
2509
2538
  if (!appConfig) {
2510
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
2511
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2539
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(ctx.appId)}\n`));
2512
2540
  process.exit(1);
2513
2541
  }
2514
2542
 
@@ -2518,9 +2546,10 @@ siteCommand
2518
2546
  console.log(chalk.cyan('\n📁 Adding site scaffold...\n'));
2519
2547
 
2520
2548
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
2521
- const stats = await copyScaffold('site', appPath, {
2522
- verbose: true,
2523
- force: options.force
2549
+ const stats = await copyScaffold('site', appPath, {
2550
+ verbose: true,
2551
+ force: options.force,
2552
+ substitute: { appId: ctx.appId, communityId: ctx.communityId }
2524
2553
  });
2525
2554
 
2526
2555
  console.log(chalk.green(`\n✅ Site scaffold added (${stats.copied} files)\n`));
@@ -2874,8 +2903,7 @@ siteCommand
2874
2903
  if (!port) {
2875
2904
  const appConfig = workspaceConfig.getAppByAppId(appId);
2876
2905
  if (!appConfig) {
2877
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
2878
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2906
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(appId)}\n`));
2879
2907
  process.exit(1);
2880
2908
  }
2881
2909
  // Read site.port from the live env entry (not from the constructed copy)
@@ -3050,15 +3078,13 @@ siteCommand
3050
3078
  }
3051
3079
 
3052
3080
  if (!options.confirm) {
3053
- const readline = await import('readline');
3054
- const rl = readline.createInterface({
3055
- input: process.stdin,
3056
- output: process.stdout
3057
- });
3058
-
3059
- const answer = await new Promise(resolve => {
3060
- 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`]
3061
3085
  });
3086
+
3087
+ const answer = await rl.askRaw(chalk.yellow(`\n⚠️ Delete site for ${communityId}/${appId}${options.preview ? ' (preview)' : ''}? (y/N) `));
3062
3088
  rl.close();
3063
3089
 
3064
3090
  if (answer.toLowerCase() !== 'y') {
@@ -3116,8 +3142,7 @@ microserviceCommand
3116
3142
 
3117
3143
  const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
3118
3144
  if (!appConfig) {
3119
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
3120
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
3145
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(ctx.appId)}\n`));
3121
3146
  process.exit(1);
3122
3147
  }
3123
3148
 
@@ -3149,7 +3174,8 @@ microserviceCommand
3149
3174
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
3150
3175
  const stats = await copyScaffold('microservice', appPath, {
3151
3176
  verbose: true,
3152
- force: options.force
3177
+ force: options.force,
3178
+ substitute: { appId: ctx.appId, communityId: ctx.communityId }
3153
3179
  });
3154
3180
 
3155
3181
  // Configuration Injection
@@ -3158,15 +3184,16 @@ microserviceCommand
3158
3184
  const manifestPath = path.join(microserviceDir, 'manifest.json');
3159
3185
  const overridesPath = path.join(microserviceDir, 'dev-overrides.json');
3160
3186
 
3161
- // 1. Inject Context + Port into defaults-config.json
3187
+ // 1. Inject Port into defaults-config.json.
3188
+ // IDENTITY IS NOT SET HERE. copyScaffold resolves {{APP_ID}}/{{COMMUNITY_ID}} during the
3189
+ // copy, so re-deriving them here would be a second derivation of one fact. The port is a
3190
+ // workspace-derived RUNTIME value with no scaffold token, so it stays.
3162
3191
  try {
3163
3192
  const defaultsContent = await fs.readFile(defaultsPath, 'utf-8');
3164
3193
  const defaults = JSON.parse(defaultsContent);
3165
- defaults.community_id = ctx.communityId;
3166
- defaults.app_id = ctx.appId;
3167
3194
  defaults.LOCAL_PORT = microservicePort;
3168
3195
  await fs.writeFile(defaultsPath, JSON.stringify(defaults, null, 2));
3169
- console.log(chalk.gray(` ✓ Injected context + port into defaults-config.json`));
3196
+ console.log(chalk.gray(` ✓ Injected port into defaults-config.json`));
3170
3197
  } catch (err) {
3171
3198
  console.warn(chalk.yellow(` ⚠ Could not update defaults-config.json: ${err.message}`));
3172
3199
  }
@@ -3188,20 +3215,19 @@ microserviceCommand
3188
3215
  console.warn(chalk.yellow(` ⚠ Could not claim package.json: ${err.message}`));
3189
3216
  }
3190
3217
 
3191
- // 2. Inject Context + Port into manifest.json
3218
+ // 2. Inject Port into manifest.json.
3219
+ // IDENTITY IS NOT SET HERE. copyScaffold resolved {{APP_ID}}/{{COMMUNITY_ID}} during the
3220
+ // copy, so setting app_id/community_id/name again would be a second derivation of one fact —
3221
+ // the exact mirror-drift shape that let a scaffold ship `community_id: 'descix'` while this
3222
+ // block quietly wrote the right value into a different file.
3223
+ // No domain is injected. A service does not declare its own domain — the platform derives
3224
+ // it ({app_id}.{SITE_DOMAIN}) at registration, and refuses a manifest that declares one.
3192
3225
  try {
3193
3226
  const manifestContent = await fs.readFile(manifestPath, 'utf-8');
3194
3227
  const manifest = JSON.parse(manifestContent);
3195
- manifest.service.app_id = ctx.appId;
3196
- manifest.service.community_id = ctx.communityId;
3197
- manifest.service.name = ctx.appId;
3198
- // No domain is injected. A service does not declare its own domain — the platform derives
3199
- // it ({app_id}.{SITE_DOMAIN}) at registration, and refuses a manifest that declares one.
3200
- // The literal this replaced was `${ctx.appId}.descix.net`: the PROD host, scaffolded into
3201
- // every service regardless of the env it would be registered against.
3202
3228
  manifest.service.debugPort = microservicePort;
3203
3229
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
3204
- console.log(chalk.gray(` ✓ Injected context + port into manifest.json`));
3230
+ console.log(chalk.gray(` ✓ Injected port into manifest.json`));
3205
3231
  } catch (err) {
3206
3232
  console.warn(chalk.yellow(` ⚠ Could not update manifest.json: ${err.message}`));
3207
3233
  }
@@ -4343,17 +4369,22 @@ configCommand
4343
4369
  }
4344
4370
  });
4345
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.
4346
4377
  configCommand
4347
- .command('set-url')
4348
- .description('Set API URL')
4349
- .argument('<url>', 'API URL (e.g., https://localhost:4000)')
4350
- .option('-g, --global', 'Save to global config (~/.descixrc)')
4351
- .action(async (url, options) => {
4352
- try {
4353
- await configCommands.setUrl(url, options);
4354
- } catch (error) {
4355
- fail(error);
4356
- }
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
+ ));
4357
4388
  });
4358
4389
 
4359
4390
  configCommand
@@ -4826,8 +4857,12 @@ program
4826
4857
  const { WorkspaceConfig } = await import('../lib/workspace-config.js');
4827
4858
  wsConfig = await WorkspaceConfig.load(process.cwd());
4828
4859
  } catch (e) {
4829
- console.error(chalk.yellow(`⚠️ Could not read .descix/workspace.json for project scope`));
4830
- 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.`));
4831
4866
  process.exit(1);
4832
4867
  }
4833
4868
  const appIds = new Set();
@@ -5020,29 +5055,55 @@ program
5020
5055
  await authCommands.loginDevice(loginOptions);
5021
5056
  }
5022
5057
 
5023
- // Step 2: Workspace init — create workspace.json if missing
5024
- const wsConfigPath = path.join(workspaceRoot, '.descix', 'workspace.json');
5025
- let hasWorkspace = false;
5026
- try {
5027
- await fs.access(wsConfigPath);
5028
- 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) {
5029
5073
  console.log(chalk.green('✓ Workspace already initialized'));
5030
- } catch { /* missing */ }
5031
-
5032
- 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 {
5033
5081
  console.log(chalk.cyan('\n📋 Initialize Workspace\n'));
5034
- 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 });
5035
5089
  }
5036
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
+
5037
5098
  // Step 3: Generate agent instruction files
5038
5099
  console.log(chalk.cyan('\n📋 Generating Agent Instructions\n'));
5039
- const written = await generateAgentFiles(workspaceRoot);
5100
+ const written = await generateAgentFiles(targetRoot);
5040
5101
  for (const f of written) {
5041
5102
  console.log(chalk.green(` ✓ ${f}`));
5042
5103
  }
5043
5104
 
5044
5105
  // Step 4: Generate .vscode/mcp.json (skipped if DeSciX extension handles MCP)
5045
- const mcpWritten = await generateMcpConfig(workspaceRoot);
5106
+ const mcpWritten = await generateMcpConfig(targetRoot);
5046
5107
  if (mcpWritten) {
5047
5108
  console.log(chalk.green(' ✓ .vscode/mcp.json'));
5048
5109
  } else {
@@ -5052,7 +5113,7 @@ program
5052
5113
  // Step 5: Copy SDK assets
5053
5114
  try {
5054
5115
  const { pullSdkAssets } = await import('../lib/wizard/setup.js');
5055
- const pulled = await pullSdkAssets(workspaceRoot);
5116
+ const pulled = await pullSdkAssets(targetRoot);
5056
5117
  if (pulled) console.log(chalk.green(' ✓ .descix/sdk-assets/'));
5057
5118
  } catch { /* setup.js pullSdkAssets may not be exported — skip */ }
5058
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) {
@@ -275,6 +287,7 @@ export async function loginDevice(options = {}) {
275
287
  diag(chalk.gray(` ${walletPath}\n`));
276
288
 
277
289
  // Sync auto-purchase apps
290
+ let entitlementsSynced = true;
278
291
  spinner.start('Syncing app entitlements...');
279
292
  try {
280
293
  apiClient.setCredentials({
@@ -293,17 +306,34 @@ export async function loginDevice(options = {}) {
293
306
  spinner.succeed(chalk.gray('App entitlements up to date'));
294
307
  }
295
308
  } catch (error) {
309
+ entitlementsSynced = false;
296
310
  spinner.warn(chalk.yellow('Could not sync app entitlements'));
297
311
  diag(chalk.gray(` ${error.message}\n`));
298
312
  }
299
-
313
+
314
+ // DO NOT CLAIM SUCCESS ON A PARTIAL LOGIN, AND DO NOT EXIT 0.
315
+ // Credentials are saved either way, so this is genuinely partial rather than a failed login —
316
+ // but entitlements are what grant app access, so "you can now use all DeSciX CLI commands" is
317
+ // false when the sync failed. A script branching on the exit code is the caller least able to
318
+ // read the yellow warning above, which is why the code is the sharper half of this fix.
319
+ if (!entitlementsSynced) {
320
+ diag(chalk.yellow(
321
+ 'Logged in, but app entitlements did NOT sync. Credentials are saved; commands needing an ' +
322
+ 'entitlement may fail until you re-run `descix login`.\n'
323
+ ));
324
+ process.exitCode = 1;
325
+ return;
326
+ }
327
+
300
328
  diag(chalk.white('You can now use all DeSciX CLI commands!\n'));
301
329
 
302
330
  } catch (error) {
303
331
  spinner.fail(chalk.red('Login failed'));
304
332
  console.error(chalk.red(error.message));
305
333
 
306
- 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')) {
307
337
  diag(chalk.yellow('💡 Tip: Device codes expire after 15 minutes.'));
308
338
  diag(chalk.yellow(' Run the login command again to get a new code.\n'));
309
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