@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.
- package/README.md +130 -34
- package/agent-assets/guides/dynamic-site-pattern.md +10 -14
- package/agent-assets/guides/knowledge-only-pattern.md +11 -15
- package/agent-assets/guides/microservice-pattern.md +12 -14
- package/agent-assets/guides/static-site-pattern.md +12 -15
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/reference/app-creation.md +36 -101
- package/agent-assets/reference/architecture.md +24 -27
- package/agent-assets/reference/local-dev.md +0 -4
- package/agent-assets/reference/mcp-integration.md +14 -22
- package/agent-assets/reference/workspace-config.md +97 -340
- package/agent-assets/rules/descix_mcp.template.mdc +1 -1
- package/bin/descix.js +159 -98
- package/lib/api-client.js +30 -22
- package/lib/commands/airdrop.js +10 -0
- package/lib/commands/auth.js +33 -3
- package/lib/commands/briefer/index.js +8 -5
- package/lib/commands/config.js +13 -31
- package/lib/commands/corpus.js +161 -54
- package/lib/commands/health.js +116 -21
- package/lib/commands/init.js +25 -48
- package/lib/commands/kb.js +14 -4
- package/lib/commands/update.js +0 -1
- package/lib/core/Chunker.js +1 -1
- package/lib/core/CorpusWalker.js +238 -47
- package/lib/core/Hydrator.js +88 -11
- package/lib/core/ManifestLoader.js +68 -0
- package/lib/core/Syncer.js +147 -46
- package/lib/interactive.js +158 -0
- package/lib/origin.js +3 -3
- package/lib/wallet-file.js +36 -37
- package/lib/wizard/setup.js +104 -22
- package/lib/workspace-config.js +325 -167
- package/lib/workspace-identity.js +0 -1
- package/package.json +3 -3
- package/templates/agent-claude.md +0 -3
- package/templates/agent-cline.md +0 -3
- package/templates/agent-copilot.md +0 -3
- package/templates/agent-cursor.md +0 -3
- package/templates/drive/app/kb/General/README.md +1 -1
- package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
- package/templates/scaffolds/microservice/defaults-config.json +3 -1
- package/templates/scaffolds/microservice/manifest.json +9 -5
- package/templates/scaffolds/microservice/package.json +1 -1
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
package/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
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
1785
|
+
stat = await fs.stat(absPath);
|
|
1754
1786
|
} catch {
|
|
1755
|
-
throw new Error(
|
|
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(
|
|
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
|
-
//
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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(
|
|
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(
|
|
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
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
4349
|
-
.argument('
|
|
4350
|
-
.
|
|
4351
|
-
.action(
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
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
|
-
|
|
4830
|
-
|
|
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
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
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
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|
package/lib/commands/airdrop.js
CHANGED
|
@@ -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.
|
package/lib/commands/auth.js
CHANGED
|
@@ -264,7 +264,19 @@ export async function loginDevice(options = {}) {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
const walletPath = WalletFileManager.getProjectWalletPath(workspaceRoot);
|
|
267
|
-
|
|
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.
|
|
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
|
|
223
|
-
//
|
|
224
|
-
//
|
|
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:
|
|
233
|
+
recovery: error.message
|
|
231
234
|
});
|
|
232
235
|
}
|
|
233
236
|
|