@descix/cli 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,11 +15,12 @@ npm install -g @descix/cli
15
15
  descix login
16
16
  ```
17
17
 
18
- 2. **Setup your workspace**:
18
+ 2. **Initialize your workspace**:
19
19
  ```bash
20
- descix setup
20
+ descix init
21
21
  ```
22
- This will guide you through connecting your Google Drive and setting up your local environment.
22
+ This writes `.descix/workspace.json` for the current project. Pass `-c <community_id> -a <app_id>`
23
+ to pre-fill the app context, or `--from-invite <token>` to resolve an invite token.
23
24
 
24
25
  **Choosing an environment**:
25
26
  The `--dev` flag has been REMOVED. Its absence silently meant production, so the most common
@@ -58,40 +59,129 @@ Best for platform admins or agencies managing multiple communities.
58
59
  ## Core Commands
59
60
 
60
61
  - `descix login`: Authenticate with the platform.
61
- - `descix setup`: Initialize or repair your workspace configuration.
62
62
  - `descix status`: View your current environment status and workspace mode.
63
- - `descix init`: Create `.descix/workspace.json` for the current project.
64
- - `descix app create`: Show instructions; use `--quick -c <community> -a <name>` for template-only app creation, or use PWA / `descix-admin app create`.
65
- - `descix sync`: Sync content to the platform (Site, Assets).
66
- - `descix kb`: Manage Knowledge Base content (Pull -> Chunk -> Sync).
63
+ - `descix init`: Initialize workspace for DeSciX app development (Git-aware) — writes `.descix/workspace.json`.
64
+ - `descix app init`: Create (when it does not exist yet) and initialize an app — the one path to an app. See [Creating an App](#creating-an-app).
65
+ - `descix site init` / `descix site upload`: Scaffold an app's site, then build and deploy it. See [Deploying a Site](#deploying-a-site).
66
+ - `descix app sync-assets`: Sync local assets (`system_instructions.md`, `app_description.md`, `icon.png`) to the platform.
67
+ - `descix drive pull` / `descix drive push`: Drive content authoring — pull from Drive to local markdown, push staging back to Drive.
68
+ - `descix kb`: Manage Knowledge Bases (`list`, `create`, `delete`, and `corpus sync`).
67
69
 
68
70
  ## Knowledge Base Management (Git Mode)
69
71
 
70
- For apps managed via CLI (Git Mode), use the following commands to process your Knowledge Base locally:
72
+ The CLI exposes exactly ONE knowledge-base sync surface: `descix kb corpus sync`, driven by a
73
+ git-tracked corpus manifest. The older four-step local pipeline has been REMOVED — its invocations
74
+ now exit non-zero and name this replacement.
71
75
 
72
- 1. **Pull**: Fetch source documents from Drive.
76
+ 1. **Create** the knowledge base (once per KB):
73
77
  ```bash
74
- descix kb pull
78
+ descix kb create -c <community_id> -a <app_id> -k <kb_name>
75
79
  ```
76
- 2. **Chunk**: Process documents into chunks locally.
80
+ 2. **Author** a corpus manifest at `.descix/manifests/<kb_name>.json` describing the sources to sync.
81
+ 3. **Sync** the corpus to the platform (Pinecone):
77
82
  ```bash
78
- descix kb chunk
79
- ```
80
- 3. **Sync**: Push chunks to the platform (Pinecone).
81
- ```bash
82
- descix kb sync
83
- ```
84
- 4. **Build**: Run the full pipeline (Pull -> Chunk -> Sync).
85
- ```bash
86
- descix kb build
83
+ descix kb corpus sync -a <app_id> [-k <kb_name>]
87
84
  ```
85
+ It walks the git blobs at the manifest's ref, so only changed files are re-vectorized. Useful
86
+ flags: `--dry-run` (report drift, write nothing), `--show-walk` (print the resolved ref and the
87
+ files it would walk), `--ref <ref>` (override the git ref for all manifest sources).
88
+
89
+ To fetch source documents from Google Drive into local markdown, use `descix drive pull`.
90
+
91
+ ## Creating an App
92
+
93
+ `descix app init` is the one path to an app. It creates the app on the platform when it does not
94
+ exist yet, then registers and scaffolds it locally — one command, not two.
95
+
96
+ ```bash
97
+ descix app init -a <name> -c <community>
98
+ ```
99
+
100
+ Pass `-c` to create a new app in that community. **Omit `-c` to initialize an app that already
101
+ exists** — the CLI resolves the app from the Products registry and only does the local half.
102
+
103
+ The command is idempotent, so re-running it on a half-made app finishes the job.
104
+
105
+ **Options:**
106
+
107
+ - `-a, --app <app_id>` (required): the app ID. With `-c`, this is the app NAME to create.
108
+ - `-c, --community <id>`: the community to create the app in. It must already be materialized in
109
+ this environment. Required only for creation.
110
+ - `-s, --short <short_name>`: the short id segment (no hyphens). The `app_id` is composed
111
+ **server-side** as `{community}-{short}`; defaults to `--app`.
112
+ - `--overwrite`: when creating, overwrite an existing app record intentionally.
113
+ - `--kb <name>`: knowledge base name (default: `General`).
114
+ - `-p, --path <dir>`: local app directory (default: auto-detected, else the current directory).
115
+
116
+ **What it does, in order:**
117
+
118
+ 1. **Creates the app** (only when `-c` is given and the app does not exist): the server composes
119
+ the unique `app_id` and writes `Products/{app_id}` and `Community/{community}/Apps/{app_id}`.
120
+ Every app is guaranteed a default knowledge base at creation.
121
+ 2. **Registers it in `.descix/workspace.json`** if it is not already mapped.
122
+ 3. **Scaffolds the app folder**: creates `site/`, `microservice/` and `assets/`, and writes
123
+ `assets/system_instructions.md` and `assets/app_description.md` when they do not exist.
124
+ 4. **Creates the knowledge base record** for Git-mode sync.
125
+
126
+ It fails loud rather than guessing: initializing an app that does not exist without `-c` names the
127
+ `-c` invocation that would create it; a `-c` that disagrees with the app's real community is
128
+ refused; and `-p` against an already-mapped app names `descix app set-localpath` instead.
88
129
 
89
- ## Provisioning
130
+ **Next step** — author a corpus manifest at `.descix/manifests/<kb_name>.json`, then
131
+ `descix kb corpus sync -a <app_id>`.
90
132
 
91
- **Communities and apps are created in the PWA** (Workspace Config / Device Setup / App Manager) or via **Admin CLI** (`descix-admin community create`, `descix-admin app create`) for platform admins. The user CLI does not create communities or full app structures; use `descix init` to write `.descix/workspace.json` and PWA or Admin CLI to create entities.
133
+ Creating a **community** is a separate, admin-only operation: `descix community create` requires
134
+ platform-admin membership and deploys a real ERC-20 token contract to live Polygon. App developers
135
+ work inside a community that already exists.
92
136
 
93
- 1. Create your App in the PWA (Workspace Builder).
94
- 2. Run `descix setup` to hydrate your local folder structure.
137
+ ## Deploying a Site
138
+
139
+ An app's site is scaffolded, then uploaded. `descix site upload` is the deploy verb — there is no
140
+ `descix site deploy`.
141
+
142
+ ```bash
143
+ descix site init # scaffold site/ (optional — upload works on any site directory)
144
+ descix site upload --env dev
145
+ ```
146
+
147
+ ### `descix site init`
148
+
149
+ Copies the site scaffold — `index.html`, `app.js`, `styles.css`, `DeSciXAppSDK.js`, `README.md` —
150
+ into the app's `site/` directory. The app must already be mapped in `.descix/workspace.json`.
151
+
152
+ - `-f, --force`: overwrite an existing `site/` folder.
153
+ - `-c, --community <id>` / `-a, --app <id>`: auto-detected from context when omitted.
154
+
155
+ It scaffolds site content only; it writes no manifest. Skip it entirely if you already have a site.
156
+
157
+ ### `descix site upload`
158
+
159
+ Builds (when a manifest declares a build) and uploads the site to GCS, then updates the app's
160
+ metadata. Files are uploaded straight to GCS with short-lived **signed URLs** issued by
161
+ `get_site_deploy_token`; `confirm_site_deploy` then records the deploy and returns the site URL.
162
+
163
+ - `-p, --path <localPath>`: directory to deploy (default `./site`). **Ignored when a site manifest
164
+ exists.**
165
+ - `--preview`: deploy to the preview path.
166
+ - `--full`: force a full upload, ignoring the unchanged-file delta.
167
+ - `--dry-run`: show what would be deployed.
168
+ - `--no-cache`: set `Cache-Control: no-cache`.
169
+ - `-c, --community <id>` / `-a, --app <id>`: auto-detected from context when omitted.
170
+
171
+ **Site deploys target a cloud environment.** An upload that resolves to a local backend without
172
+ this invocation naming its target is refused — pass `--env <dev|demo|prod>`.
173
+
174
+ **Manifest-driven builds.** When `{app_root}/.descix/manifests/site.json` exists it takes over from
175
+ `-p`. Author it yourself; nothing generates it for you. It declares the sources to walk and,
176
+ optionally, a `buildCommand`, which runs in the app root with the environment-specific `VITE_*`
177
+ build variables the server returns injected into its environment:
178
+
179
+ ```json
180
+ {
181
+ "sources": [{ "path": "site/dist", "include": ["**/*"] }],
182
+ "buildCommand": "npm run build --prefix site"
183
+ }
184
+ ```
95
185
 
96
186
  ## Airdrop Admin Operations (WS-ADMIN-B1)
97
187
 
@@ -103,25 +193,31 @@ Admin-only commands for triggering airdrop migration batch runs. Per CEO-D-MANUA
103
193
 
104
194
  Manually trigger the server-side `airdrop_execute_queue` command on the target environment. Assembles `pending_migrations` rows into per-community batches and (when `BATCH_UPDATE_BALANCES_BROADCAST_ENABLED=true`) broadcasts to the Powch HD wallet. When the broadcast flag is off (default for DEV), batches are assembled but the downstream Powch handler returns `stopped_at_broadcast_boundary` — useful for verifying queue shape without spending MATIC.
105
195
 
196
+ **Read-only is the default.** Without `--apply`, the command runs as a dry run.
197
+
106
198
  **Options:**
107
199
 
200
+ - `--dry-run`: Read-only preview — encode calldata, estimate gas, validate the net-zero invariant. No PK, no transaction, no state mutation. Mutually exclusive with `--apply`.
201
+ - `--apply`: Live execution. Requires `--signer-pk-file` or the interactive prompt, and requires `--community`.
202
+ - `--community <slug>`: Community slug for per-community batch scoping. **Required for `--apply`**; optional in dry-run mode, where omitting it returns an aggregate preview across communities.
203
+ - `--signer-pk-file <path>`: Path to a file containing the admin signer PK (`0x` + 64 hex). Used for `--apply`; when omitted, the PK is requested at an interactive no-echo prompt. Inline PK flags and environment variables are rejected on security grounds (process-listing leak).
108
204
  - `--batch-size <n>`: Cap on users processed this run (server caps at `AIRDROP_MAX_RUN_USERS`; a larger `--batch-size` is clamped).
109
- - `--dry-run`: Assemble batches but skip the broadcast call entirely. Emits an `on_chain_log` row with `status: dry_run` for audit.
110
205
 
111
206
  **Examples:**
112
207
 
113
208
  ```bash
114
- # Dry run against DEV — safe to run repeatedly; no broadcast, no MATIC
209
+ # Dry run against DEV — safe to run repeatedly; no PK, no tx, no state mutation
115
210
  descix airdrop execute-queue --env dev --dry-run
116
211
 
117
- # Execute a capped real run (10 users max this invocation)
118
- descix airdrop execute-queue --env dev --batch-size 10
212
+ # Dry run scoped to one community
213
+ descix airdrop execute-queue --env dev --dry-run --community smile
119
214
 
120
- # Execute a full queue run (still gated by BATCH_UPDATE_BALANCES_BROADCAST_ENABLED)
121
- descix airdrop execute-queue --env dev
122
- ```
215
+ # Live execution, capped at 10 users this invocation (prompts for the signer PK)
216
+ descix airdrop execute-queue --env dev --apply --community smile --batch-size 10
123
217
 
124
- **Audit trail:** every invocation (dry-run or real) writes one `on_chain_log` row with `op.kind = "airdrop_execute_queue_trigger"` and `caller.operator_email = <invoking admin email>`.
218
+ # Live execution reading the signer PK from a file
219
+ descix airdrop execute-queue --env dev --apply --community smile --signer-pk-file ./signer.key
220
+ ```
125
221
 
126
222
  **Testing:**
127
223
 
@@ -131,4 +227,4 @@ cd DeSciX_Core/descix-cli && npm test
131
227
 
132
228
  ## Documentation
133
229
 
134
- For full documentation, please refer to the [DeSciX SDK Documentation](../../SERVICE_README_sdk.md).
230
+ For full documentation, please refer to the [DeSciX SDK Documentation](./SERVICE_README_sdk.md).
@@ -143,10 +143,6 @@ KB querying will surface. If you fix something there, the fix reaches readers of
143
143
  nobody asking the KB — so do not assume KB coverage for a surface that has none. This is a known
144
144
  boundary, not a defect.
145
145
 
146
- `descix kb chunk`, `descix kb sync` and `descix update kb` still run but are **superseded
147
- duplicates** — each names `kb corpus sync` as its replacement in its own `--help` and is slated
148
- for removal. Do not put them in new instructions.
149
-
150
146
  #### What you put in a KB changes what comes out of it
151
147
 
152
148
  Retrieval is **vocabulary-dominated**, and when it goes wrong it does so silently. Measured against
package/bin/descix.js CHANGED
@@ -11,8 +11,9 @@ 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 } from '../lib/workspace-config.js';
15
15
  import { CLI_VERSION } from '../lib/cli-version.js';
16
+ import { recordInvocationOrigin } from '../lib/origin.js';
16
17
  // Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
17
18
  import {
18
19
  getSessionInteractionId,
@@ -107,12 +108,24 @@ const ENV_URL_MAP = Object.fromEntries(
107
108
  Object.entries(WorkspaceConfig.ENV_MAP).map(([k, v]) => [k, v.url])
108
109
  );
109
110
 
111
+ // The fold into DESCIX_API_URL is what makes every consumer see ONE variable — and it is also
112
+ // what used to DESTROY the answer to "who chose this origin", so the origin owner printed a
113
+ // static disjunction ("DESCIX_API_URL (or --api-url / --env)") for all three cases. The fold
114
+ // stays; the provenance is now RECORDED alongside it, in the same breath, so the owner can name
115
+ // the actual source. Recording it here rather than deriving it in the owner keeps one owner of
116
+ // the fact: the only code that knows a flag was passed is the code that read the flag.
110
117
  program.hook('preAction', (thisCommand) => {
111
- const opts = thisCommand.opts();
118
+ // Read from the ROOT program, not from the action command. `--env` and `--api-url` are
119
+ // declared ONCE, globally, and Commander binds them to the root — an identically-named option
120
+ // redeclared on a subcommand is shadowed and silently never populated (measured on 1.0.4:
121
+ // `descix config init --env dev` reached its action with `{}` and rejected its own advertised
122
+ // flag). One declaration, read from where Commander actually puts it.
123
+ const opts = program.opts();
112
124
 
113
125
  // --api-url takes highest priority
114
126
  if (opts.apiUrl) {
115
127
  process.env.DESCIX_API_URL = opts.apiUrl;
128
+ recordInvocationOrigin(opts.apiUrl, 'flagApiUrl');
116
129
  return;
117
130
  }
118
131
 
@@ -120,12 +133,23 @@ program.hook('preAction', (thisCommand) => {
120
133
  if (opts.env) {
121
134
  const envName = opts.env.toLowerCase();
122
135
  if (!(envName in ENV_URL_MAP)) {
123
- console.error(chalk.red(`Unknown environment: ${opts.env}. Use: dev, demo, prod`));
136
+ // THE REFUSAL CARRIES THE CUSTOM-ENVIRONMENT PATH. This validator runs BEFORE any command
137
+ // action, so it is now the only thing a developer typing `--env staging` ever sees — the
138
+ // richer text inside `config init` that names `config set-env` for exactly this case can
139
+ // no longer be reached through the flag. A refusal that names only the three built-in
140
+ // names would strand every self-hosted or port-forwarded gateway with no next step, which
141
+ // is the same dead end as a remedy string for a command that does not run.
142
+ console.error(chalk.red(
143
+ `Unknown environment: ${opts.env}. Known environments: ${Object.keys(ENV_URL_MAP).join(', ')}.\n\n` +
144
+ 'For a self-hosted or port-forwarded gateway, name it with its origin instead:\n' +
145
+ ` descix config set-env ${envName} --url https://...`
146
+ ));
124
147
  process.exit(1);
125
148
  }
126
149
  // Every named environment resolves to a URL (including dev → cloud DEV).
127
150
  // A local backend is a URL you name, not an environment.
128
151
  process.env.DESCIX_API_URL = ENV_URL_MAP[envName];
152
+ recordInvocationOrigin(ENV_URL_MAP[envName], 'flagEnv');
129
153
  }
130
154
  });
131
155
 
@@ -2483,8 +2507,7 @@ siteCommand
2483
2507
 
2484
2508
  const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
2485
2509
  if (!appConfig) {
2486
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
2487
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2510
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(ctx.appId)}\n`));
2488
2511
  process.exit(1);
2489
2512
  }
2490
2513
 
@@ -2494,9 +2517,10 @@ siteCommand
2494
2517
  console.log(chalk.cyan('\n📁 Adding site scaffold...\n'));
2495
2518
 
2496
2519
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
2497
- const stats = await copyScaffold('site', appPath, {
2498
- verbose: true,
2499
- force: options.force
2520
+ const stats = await copyScaffold('site', appPath, {
2521
+ verbose: true,
2522
+ force: options.force,
2523
+ substitute: { appId: ctx.appId, communityId: ctx.communityId }
2500
2524
  });
2501
2525
 
2502
2526
  console.log(chalk.green(`\n✅ Site scaffold added (${stats.copied} files)\n`));
@@ -2850,8 +2874,7 @@ siteCommand
2850
2874
  if (!port) {
2851
2875
  const appConfig = workspaceConfig.getAppByAppId(appId);
2852
2876
  if (!appConfig) {
2853
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
2854
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
2877
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(appId)}\n`));
2855
2878
  process.exit(1);
2856
2879
  }
2857
2880
  // Read site.port from the live env entry (not from the constructed copy)
@@ -3092,8 +3115,7 @@ microserviceCommand
3092
3115
 
3093
3116
  const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
3094
3117
  if (!appConfig) {
3095
- console.error(chalk.red('\n❌ App not found in workspace.json.'));
3096
- console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
3118
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(ctx.appId)}\n`));
3097
3119
  process.exit(1);
3098
3120
  }
3099
3121
 
@@ -3125,7 +3147,8 @@ microserviceCommand
3125
3147
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
3126
3148
  const stats = await copyScaffold('microservice', appPath, {
3127
3149
  verbose: true,
3128
- force: options.force
3150
+ force: options.force,
3151
+ substitute: { appId: ctx.appId, communityId: ctx.communityId }
3129
3152
  });
3130
3153
 
3131
3154
  // Configuration Injection
@@ -3134,15 +3157,16 @@ microserviceCommand
3134
3157
  const manifestPath = path.join(microserviceDir, 'manifest.json');
3135
3158
  const overridesPath = path.join(microserviceDir, 'dev-overrides.json');
3136
3159
 
3137
- // 1. Inject Context + Port into defaults-config.json
3160
+ // 1. Inject Port into defaults-config.json.
3161
+ // IDENTITY IS NOT SET HERE. copyScaffold resolves {{APP_ID}}/{{COMMUNITY_ID}} during the
3162
+ // copy, so re-deriving them here would be a second derivation of one fact. The port is a
3163
+ // workspace-derived RUNTIME value with no scaffold token, so it stays.
3138
3164
  try {
3139
3165
  const defaultsContent = await fs.readFile(defaultsPath, 'utf-8');
3140
3166
  const defaults = JSON.parse(defaultsContent);
3141
- defaults.community_id = ctx.communityId;
3142
- defaults.app_id = ctx.appId;
3143
3167
  defaults.LOCAL_PORT = microservicePort;
3144
3168
  await fs.writeFile(defaultsPath, JSON.stringify(defaults, null, 2));
3145
- console.log(chalk.gray(` ✓ Injected context + port into defaults-config.json`));
3169
+ console.log(chalk.gray(` ✓ Injected port into defaults-config.json`));
3146
3170
  } catch (err) {
3147
3171
  console.warn(chalk.yellow(` ⚠ Could not update defaults-config.json: ${err.message}`));
3148
3172
  }
@@ -3164,20 +3188,19 @@ microserviceCommand
3164
3188
  console.warn(chalk.yellow(` ⚠ Could not claim package.json: ${err.message}`));
3165
3189
  }
3166
3190
 
3167
- // 2. Inject Context + Port into manifest.json
3191
+ // 2. Inject Port into manifest.json.
3192
+ // IDENTITY IS NOT SET HERE. copyScaffold resolved {{APP_ID}}/{{COMMUNITY_ID}} during the
3193
+ // copy, so setting app_id/community_id/name again would be a second derivation of one fact —
3194
+ // the exact mirror-drift shape that let a scaffold ship `community_id: 'descix'` while this
3195
+ // block quietly wrote the right value into a different file.
3196
+ // No domain is injected. A service does not declare its own domain — the platform derives
3197
+ // it ({app_id}.{SITE_DOMAIN}) at registration, and refuses a manifest that declares one.
3168
3198
  try {
3169
3199
  const manifestContent = await fs.readFile(manifestPath, 'utf-8');
3170
3200
  const manifest = JSON.parse(manifestContent);
3171
- manifest.service.app_id = ctx.appId;
3172
- manifest.service.community_id = ctx.communityId;
3173
- manifest.service.name = ctx.appId;
3174
- // No domain is injected. A service does not declare its own domain — the platform derives
3175
- // it ({app_id}.{SITE_DOMAIN}) at registration, and refuses a manifest that declares one.
3176
- // The literal this replaced was `${ctx.appId}.descix.net`: the PROD host, scaffolded into
3177
- // every service regardless of the env it would be registered against.
3178
3201
  manifest.service.debugPort = microservicePort;
3179
3202
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
3180
- console.log(chalk.gray(` ✓ Injected context + port into manifest.json`));
3203
+ console.log(chalk.gray(` ✓ Injected port into manifest.json`));
3181
3204
  } catch (err) {
3182
3205
  console.warn(chalk.yellow(` ⚠ Could not update manifest.json: ${err.message}`));
3183
3206
  }
@@ -4334,15 +4357,24 @@ configCommand
4334
4357
 
4335
4358
  configCommand
4336
4359
  .command('init')
4337
- .description('Initialize configuration for an environment (requires --env)')
4360
+ .description('Pin this workspace to an environment (requires the global --env dev|demo|prod)')
4338
4361
  // `--dev` DELETED, not deprecated: its absence meant PRODUCTION, so the most common
4339
4362
  // invocation (`descix config init`) wrote a prod origin nobody named. There is no default.
4340
- .option('--env <name>', 'Environment to initialize: dev, demo or prod')
4363
+ //
4364
+ // `--env` IS NOT REDECLARED HERE, and that is the fix, not an omission. It was declared both
4365
+ // globally (program.option, above) and on this subcommand; Commander 14 binds `--env dev` to
4366
+ // the ROOT program, so this action received `{}` and threw "requires an environment" — the
4367
+ // command REJECTED THE FLAG ITS OWN --help ADVERTISED. Measured on published 1.0.4, and worse
4368
+ // than a missing command: the CLI printed `descix config init --env dev` as the remedy for an
4369
+ // unconfigured origin (origin.js DEFAULT_ORIGIN_SOURCE) and that exact copy-pasted string
4370
+ // exited 1. Two declarations of one flag is the mirror-drift class; one owner is the cure.
4341
4371
  .option('--url <url>', 'Explicit origin (for a self-hosted or port-forwarded gateway)')
4342
4372
  .option('-g, --global', 'Save to global config (~/.descixrc)')
4373
+ .addHelpText('after', '\nEnvironment comes from the global flag:\n' +
4374
+ ' descix config init --env dev|demo|prod\n')
4343
4375
  .action(async (options) => {
4344
4376
  try {
4345
- await configCommands.init(options.env, options);
4377
+ await configCommands.init(program.opts().env, options);
4346
4378
  } catch (error) {
4347
4379
  fail(error);
4348
4380
  }
package/lib/api-client.js CHANGED
@@ -66,6 +66,15 @@ export class DeSciXApiClient {
66
66
  this.baseUrl = String(this.baseUrl).trim().replace(/\/+$/, '');
67
67
  originSource = EXPLICIT_ORIGIN_SOURCE;
68
68
  }
69
+ // KEPT ON THE CLIENT, not just printed and dropped. `status` and `doctor` have to SHOW the
70
+ // resolved origin and its source in their own report bodies; when this was a local variable
71
+ // the only way for them to state an origin was to derive one themselves, and both did —
72
+ // from GlobalConfig.api_url, which is legitimately null. That produced three different
73
+ // answers for one state seconds apart (status "production (null)", doctor "API URL: null",
74
+ // app list the real origin). A consumer can only ferry the owner's answer if the owner's
75
+ // answer is still reachable.
76
+ this.originSource = originSource;
77
+
69
78
  // I1 (A', rev 2): ALWAYS, not default-only. Silence was the defect; PROD was not.
70
79
  reportEnvironment({ origin: this.baseUrl, source: originSource });
71
80
 
@@ -275,6 +275,7 @@ export async function loginDevice(options = {}) {
275
275
  diag(chalk.gray(` ${walletPath}\n`));
276
276
 
277
277
  // Sync auto-purchase apps
278
+ let entitlementsSynced = true;
278
279
  spinner.start('Syncing app entitlements...');
279
280
  try {
280
281
  apiClient.setCredentials({
@@ -293,10 +294,25 @@ export async function loginDevice(options = {}) {
293
294
  spinner.succeed(chalk.gray('App entitlements up to date'));
294
295
  }
295
296
  } catch (error) {
297
+ entitlementsSynced = false;
296
298
  spinner.warn(chalk.yellow('Could not sync app entitlements'));
297
299
  diag(chalk.gray(` ${error.message}\n`));
298
300
  }
299
-
301
+
302
+ // DO NOT CLAIM SUCCESS ON A PARTIAL LOGIN, AND DO NOT EXIT 0.
303
+ // Credentials are saved either way, so this is genuinely partial rather than a failed login —
304
+ // but entitlements are what grant app access, so "you can now use all DeSciX CLI commands" is
305
+ // false when the sync failed. A script branching on the exit code is the caller least able to
306
+ // read the yellow warning above, which is why the code is the sharper half of this fix.
307
+ if (!entitlementsSynced) {
308
+ diag(chalk.yellow(
309
+ 'Logged in, but app entitlements did NOT sync. Credentials are saved; commands needing an ' +
310
+ 'entitlement may fail until you re-run `descix login`.\n'
311
+ ));
312
+ process.exitCode = 1;
313
+ return;
314
+ }
315
+
300
316
  diag(chalk.white('You can now use all DeSciX CLI commands!\n'));
301
317
 
302
318
  } catch (error) {
@@ -101,13 +101,29 @@ export async function init(env, options = {}) {
101
101
  // Write through the CANONICAL writer. This function used to assign the LEGACY top-level
102
102
  // `workspaceConfig.apiUrl`, while `config set-env` writes `env.apiUrl` — two writers of one
103
103
  // fact, writing two different keys, one of which `getApiUrl()` only consults as a fallback.
104
- const workspaceConfig = await WorkspaceConfig.load();
104
+ //
105
+ // `tryLoad`, NOT `load`, AND IT CREATES ONE WHEN THERE IS NONE. This is what closes the
106
+ // PROD WINDOW at the start of every onboarding (measured on published 1.0.4): in a fresh
107
+ // directory `config init --env dev` threw "Workspace not configured", `config set-env dev`
108
+ // threw the same, and so there was NO WORKING WAY TO PIN THE ENVIRONMENT BEFORE the first
109
+ // command ran — every one of them silently targeted PROD while printing a remedy that could
110
+ // not be executed. Pinning the environment must not require a workspace to already exist:
111
+ // choosing the origin is the FIRST thing a developer does, not something they may only do
112
+ // after they have already talked to the wrong one.
113
+ const existing = await WorkspaceConfig.tryLoad();
114
+ const workspaceConfig = existing
115
+ || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, process.cwd());
116
+ const created = !existing;
105
117
  const result = await workspaceConfig.setEnvironment(normalized, options.url || null);
106
118
 
107
119
  console.log(chalk.green('\n✅ Configuration initialized!\n'));
108
120
  console.log(chalk.white(` Environment: ${result.environment}`));
109
121
  console.log(chalk.white(` API URL: ${result.apiUrl}`));
110
- console.log(chalk.gray(` Saved to: ${result.configPath}\n`));
122
+ console.log(chalk.gray(` Saved to: ${result.configPath}`));
123
+ if (created) {
124
+ console.log(chalk.gray(' (new workspace created here — this directory is now the workspace root)'));
125
+ }
126
+ console.log('');
111
127
 
112
128
  } catch (error) {
113
129
  console.error(chalk.red('Error initializing config:', error.message));
@@ -124,7 +140,32 @@ export async function init(env, options = {}) {
124
140
  */
125
141
  export async function setEnv(envName, options = {}) {
126
142
  try {
127
- const workspaceConfig = await WorkspaceConfig.load();
143
+ // ONE SURFACE PER CAPABILITY. `config init` and `config set-env` both write through the same
144
+ // canonical writer (setEnvironment) — that shared owner is correct — but they must not both
145
+ // accept the SAME input, or there are two commands for one act and the CLI's own remedy
146
+ // string can name the loser. `config init` is canonical for the three KNOWN environments:
147
+ // the origin owner prints `descix config init --env dev` as the remedy, so that command must
148
+ // be the one that does it. `set-env` keeps the capability `init` genuinely does not have —
149
+ // a CUSTOM environment name with an explicit --url — and REFUSES the known names outright,
150
+ // naming its replacement. A refusal, not a warning: a warning would leave two working paths.
151
+ const knownName = WorkspaceConfig.ENV_MAP[String(envName).toLowerCase()];
152
+ if (knownName) {
153
+ throw new Error(
154
+ `"${envName}" is a known environment — use the canonical command:\n` +
155
+ ` descix config init --env ${String(envName).toLowerCase()}\n\n` +
156
+ 'descix config set-env is for a CUSTOM environment name with an explicit origin, e.g.\n' +
157
+ ' descix config set-env staging --url https://staging.example.com'
158
+ );
159
+ }
160
+
161
+ // Same tryLoad-or-create as `config init`, for the same reason: `config init`'s own error
162
+ // text sends a developer HERE for the self-hosted / port-forwarded case, so this command has
163
+ // to work where that developer actually is. If it still required a pre-existing workspace,
164
+ // init's remedy would point at a command that refuses — the identical closed loop, moved one
165
+ // hop along. A remedy string is only true if the command it names runs.
166
+ const existing = await WorkspaceConfig.tryLoad();
167
+ const workspaceConfig = existing
168
+ || new WorkspaceConfig({ version: '2.1', type: 'workspace', env: {} }, process.cwd());
128
169
 
129
170
  const result = await workspaceConfig.setEnvironment(envName, options.url || null);
130
171