@descix/cli 1.0.5 → 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,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 } 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.
@@ -2507,8 +2507,7 @@ siteCommand
2507
2507
 
2508
2508
  const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
2509
2509
  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'));
2510
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(ctx.appId)}\n`));
2512
2511
  process.exit(1);
2513
2512
  }
2514
2513
 
@@ -2518,9 +2517,10 @@ siteCommand
2518
2517
  console.log(chalk.cyan('\n📁 Adding site scaffold...\n'));
2519
2518
 
2520
2519
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
2521
- const stats = await copyScaffold('site', appPath, {
2522
- verbose: true,
2523
- 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 }
2524
2524
  });
2525
2525
 
2526
2526
  console.log(chalk.green(`\n✅ Site scaffold added (${stats.copied} files)\n`));
@@ -2874,8 +2874,7 @@ siteCommand
2874
2874
  if (!port) {
2875
2875
  const appConfig = workspaceConfig.getAppByAppId(appId);
2876
2876
  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'));
2877
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(appId)}\n`));
2879
2878
  process.exit(1);
2880
2879
  }
2881
2880
  // Read site.port from the live env entry (not from the constructed copy)
@@ -3116,8 +3115,7 @@ microserviceCommand
3116
3115
 
3117
3116
  const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
3118
3117
  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'));
3118
+ console.error(chalk.red(`\n❌ ${unmappedAppMessage(ctx.appId)}\n`));
3121
3119
  process.exit(1);
3122
3120
  }
3123
3121
 
@@ -3149,7 +3147,8 @@ microserviceCommand
3149
3147
  const { copyScaffold } = await import('../lib/core/Hydrator.js');
3150
3148
  const stats = await copyScaffold('microservice', appPath, {
3151
3149
  verbose: true,
3152
- force: options.force
3150
+ force: options.force,
3151
+ substitute: { appId: ctx.appId, communityId: ctx.communityId }
3153
3152
  });
3154
3153
 
3155
3154
  // Configuration Injection
@@ -3158,15 +3157,16 @@ microserviceCommand
3158
3157
  const manifestPath = path.join(microserviceDir, 'manifest.json');
3159
3158
  const overridesPath = path.join(microserviceDir, 'dev-overrides.json');
3160
3159
 
3161
- // 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.
3162
3164
  try {
3163
3165
  const defaultsContent = await fs.readFile(defaultsPath, 'utf-8');
3164
3166
  const defaults = JSON.parse(defaultsContent);
3165
- defaults.community_id = ctx.communityId;
3166
- defaults.app_id = ctx.appId;
3167
3167
  defaults.LOCAL_PORT = microservicePort;
3168
3168
  await fs.writeFile(defaultsPath, JSON.stringify(defaults, null, 2));
3169
- console.log(chalk.gray(` ✓ Injected context + port into defaults-config.json`));
3169
+ console.log(chalk.gray(` ✓ Injected port into defaults-config.json`));
3170
3170
  } catch (err) {
3171
3171
  console.warn(chalk.yellow(` ⚠ Could not update defaults-config.json: ${err.message}`));
3172
3172
  }
@@ -3188,20 +3188,19 @@ microserviceCommand
3188
3188
  console.warn(chalk.yellow(` ⚠ Could not claim package.json: ${err.message}`));
3189
3189
  }
3190
3190
 
3191
- // 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.
3192
3198
  try {
3193
3199
  const manifestContent = await fs.readFile(manifestPath, 'utf-8');
3194
3200
  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
3201
  manifest.service.debugPort = microservicePort;
3203
3202
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
3204
- console.log(chalk.gray(` ✓ Injected context + port into manifest.json`));
3203
+ console.log(chalk.gray(` ✓ Injected port into manifest.json`));
3205
3204
  } catch (err) {
3206
3205
  console.warn(chalk.yellow(` ⚠ Could not update manifest.json: ${err.message}`));
3207
3206
  }
@@ -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) {
@@ -13,6 +13,10 @@ import { dirname, join } from 'path';
13
13
  // clone.js is imported dynamically inside the invite flow to avoid circular deps
14
14
  import { WorkspaceConfig } from '../workspace-config.js';
15
15
  import { generateAgentFiles } from '../agent-files.js';
16
+ // The canonical KB-sync surface is owned by retired-kb-sync.js. Consume the constant: a literal
17
+ // spelled here is a second derivation of the same fact, and the removed verb this replaced
18
+ // reached a developer who had done everything right.
19
+ import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
16
20
 
17
21
  const __filename = fileURLToPath(import.meta.url);
18
22
  const __dirname = dirname(__filename);
@@ -208,8 +212,9 @@ export async function runInit(apiClient, options = {}) {
208
212
 
209
213
  console.log(chalk.cyan('\n─── Next Steps ───\n'));
210
214
  console.log(chalk.white(` 1. descix app init -a ${appId} -c ${communityId} # register app on platform`));
211
- console.log(chalk.white(` 2. descix update kb -c ${communityId} -a ${appId} # sync KB to Pinecone`));
212
- console.log(chalk.white(` 3. descix chat -c ${communityId} -a ${appId} -q "test" # verify RAG\n`));
215
+ console.log(chalk.gray(` 2. Create a corpus manifest at .descix/manifests/General.json`));
216
+ console.log(chalk.white(` 3. ${CANONICAL_KB_SYNC} -a ${appId} # sync KB to Pinecone`));
217
+ console.log(chalk.white(` 4. descix chat -c ${communityId} -a ${appId} -q "test" # verify RAG\n`));
213
218
  console.log(chalk.green('✅ Workspace initialized.\n'));
214
219
  rl.close();
215
220
  return { created: ['.descix/workspace.json'], skipped: [], warnings: [] };
@@ -250,7 +255,7 @@ export async function initWorkspace(options) {
250
255
  created,
251
256
  skipped: [],
252
257
  warnings: [],
253
- nextSteps: [`descix app init -a ${appId} -c ${communityId}`, `descix update kb -c ${communityId} -a ${appId}`]
258
+ nextSteps: [`descix app init -a ${appId} -c ${communityId}`, `${CANONICAL_KB_SYNC} -a ${appId}`]
254
259
  };
255
260
  }
256
261
 
@@ -27,6 +27,10 @@ import { hydrateKb, pushStaging, checkStagingFiles } from '../core/Hydrator.js';
27
27
  import { processKb } from '../core/Chunker.js';
28
28
  import { syncKb, getSyncStatus } from '../core/Syncer.js';
29
29
  import * as driveADC from '../google-storage-adc.js';
30
+ // The one canonical KB-sync surface, from its owner. Never spell it as a literal here: these
31
+ // commands' next steps are read by a developer as instructions, so they must name the live verb
32
+ // and nothing else.
33
+ import { CANONICAL_KB_SYNC } from './retired-kb-sync.js';
30
34
 
31
35
  // ============ Pull Command ============
32
36
 
@@ -110,8 +114,7 @@ export async function runKbPull(apiClient, options) {
110
114
  if (!options.quiet) {
111
115
  console.log(chalk.cyan('\n📋 Next steps:'));
112
116
  console.log(chalk.gray(' 1. Review files in kb/' + kbId + '/'));
113
- console.log(chalk.gray(' 2. Run "descix kb chunk" to generate chunks'));
114
- console.log(chalk.gray(' 3. Run "descix kb sync" to push to Pinecone\n'));
117
+ console.log(chalk.gray(` 2. Run "${CANONICAL_KB_SYNC} -a <app_id>" to push to Pinecone\n`));
115
118
  }
116
119
 
117
120
  return result;
@@ -220,8 +223,8 @@ export async function runKbPush(apiClient, options) {
220
223
  // Show next steps
221
224
  if (!options.quiet) {
222
225
  console.log(chalk.cyan('\n📋 Next steps:'));
223
- console.log(chalk.gray(' 1. Run "descix kb pull" to sync all Drive content'));
224
- console.log(chalk.gray(' 2. Run "descix kb chunk" to generate chunks\n'));
226
+ console.log(chalk.gray(' 1. Run "descix drive pull" to sync all Drive content'));
227
+ console.log(chalk.gray(` 2. Run "${CANONICAL_KB_SYNC} -a <app_id>" to push to Pinecone\n`));
225
228
  }
226
229
 
227
230
  return result;
@@ -4,7 +4,6 @@
4
4
  * Commands for updating resources based on workspace.json context detection:
5
5
  * descix update # Auto-detect what to update
6
6
  * descix update app # Sync app assets to Drive
7
- * descix update kb # Sync KB + vectorize
8
7
  * descix update site # Deploy site to GCS
9
8
  * descix update all # Update everything
10
9
  *
@@ -726,11 +726,15 @@ export async function checkStagingFiles(stagingDir) {
726
726
  *
727
727
  * @param {string} scaffoldType - 'site' or 'microservice'
728
728
  * @param {string} appPath - Path to the app directory
729
- * @param {Object} options - { verbose, force }
729
+ * @param {Object} options - { verbose, force, substitute }
730
+ * @param {{appId: string, communityId: string, appName?: string}} [options.substitute] - the
731
+ * caller's real identity. Supply it for any scaffold a developer will actually use: without it
732
+ * the copy is verbatim and every {{TOKEN}} ships unresolved. Omitting it is only appropriate
733
+ * when materializing the raw template itself.
730
734
  * @returns {Promise<{copied: number}>}
731
735
  */
732
736
  export async function copyScaffold(scaffoldType, appPath, options = {}) {
733
- const { verbose = false, force = false } = options;
737
+ const { verbose = false, force = false, substitute = null } = options;
734
738
 
735
739
  const scaffoldDir = SCAFFOLD_DIRS[scaffoldType];
736
740
  if (!scaffoldDir) {
@@ -767,8 +771,36 @@ export async function copyScaffold(scaffoldType, appPath, options = {}) {
767
771
 
768
772
  // Copy scaffold recursively
769
773
  const stats = { copied: 0 };
770
- await copyDir(scaffoldDir, targetDir, stats, verbose);
771
-
774
+ let values = null;
775
+ if (substitute) {
776
+ if (!substitute.appId || !substitute.communityId) {
777
+ throw new Error(
778
+ 'copyScaffold: substitute requires both appId and communityId. Refusing to resolve a ' +
779
+ 'scaffold against a partial identity — a half-substituted scaffold is harder to spot ' +
780
+ 'than an unsubstituted one.'
781
+ );
782
+ }
783
+ values = {
784
+ APP_ID: substitute.appId,
785
+ COMMUNITY_ID: substitute.communityId,
786
+ APP_NAME: substitute.appName || substitute.appId,
787
+ };
788
+ }
789
+
790
+ const unresolved = [];
791
+ await copyDir(scaffoldDir, targetDir, stats, verbose, values, unresolved);
792
+
793
+ // FAIL LOUD. A placeholder that survives the copy reaches the developer as literal braces, and
794
+ // that is exactly the class of defect this substitution exists to end — so it must never be a
795
+ // warning. Name every offender so a new token added to a template cannot ship unnoticed.
796
+ if (unresolved.length > 0) {
797
+ throw new Error(
798
+ `Scaffold "${scaffoldType}" has ${unresolved.length} unresolved placeholder(s): ` +
799
+ `${unresolved.join(', ')}. Every {{TOKEN}} in a scaffold needs a value in copyScaffold's ` +
800
+ `substitute map (see templates/scaffolds/README.md for the documented set).`
801
+ );
802
+ }
803
+
772
804
  return stats;
773
805
  }
774
806
 
@@ -785,21 +817,66 @@ async function hasContentInDir(dirPath) {
785
817
  }
786
818
 
787
819
  /**
788
- * Recursively copy a directory
820
+ * THE ONE OWNER of scaffold placeholder substitution.
821
+ *
822
+ * templates/scaffolds/README.md has documented this contract all along — "Scaffolds use these
823
+ * placeholders (replaced during copy): {{APP_NAME}}, {{APP_ID}}, {{COMMUNITY_ID}}" — but nothing
824
+ * ever performed the replacement. Both scaffolds shipped their placeholders and their sample
825
+ * literals verbatim, so a developer's site was titled `{{APP_NAME}}` and the sample handler
826
+ * queried a community that is not theirs.
827
+ *
828
+ * SUBSTITUTION, NOT A SECOND LITERAL. The fix resolves each token to the caller's real values;
829
+ * it does not swap one hardcoded community for another. That is what lets the same scaffold serve
830
+ * every community without the CLI knowing any of their names.
831
+ *
832
+ * @param {string} text - file contents
833
+ * @param {Record<string,string>} values - token name (without braces) -> replacement
834
+ * @returns {string}
789
835
  */
790
- async function copyDir(src, dest, stats, verbose) {
836
+ function applyScaffoldTokens(text, values) {
837
+ return text.replace(/\{\{([A-Z_]+)\}\}/g, (whole, token) =>
838
+ Object.prototype.hasOwnProperty.call(values, token) ? values[token] : whole
839
+ );
840
+ }
841
+
842
+ /** A NUL byte is the cheap, dependency-free binary tell. Never rewrite bytes we cannot read. */
843
+ function looksBinary(buf) {
844
+ return buf.includes(0);
845
+ }
846
+
847
+ /**
848
+ * Recursively copy a directory, optionally substituting scaffold tokens on the way.
849
+ *
850
+ * @param {Record<string,string>|null} values - when null, the copy is byte-for-byte verbatim
851
+ * @param {string[]} unresolved - collects `file:{{TOKEN}}` for tokens no value was supplied for
852
+ */
853
+ async function copyDir(src, dest, stats, verbose, values = null, unresolved = [], relBase = '') {
791
854
  await fs.mkdir(dest, { recursive: true });
792
-
855
+
793
856
  const entries = await fs.readdir(src, { withFileTypes: true });
794
-
857
+
795
858
  for (const entry of entries) {
796
859
  const srcPath = path.join(src, entry.name);
797
860
  const destPath = path.join(dest, entry.name);
798
-
861
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
862
+
799
863
  if (entry.isDirectory()) {
800
- await copyDir(srcPath, destPath, stats, verbose);
864
+ await copyDir(srcPath, destPath, stats, verbose, values, unresolved, rel);
801
865
  } else {
802
- await fs.copyFile(srcPath, destPath);
866
+ if (values) {
867
+ const buf = await fs.readFile(srcPath);
868
+ if (looksBinary(buf)) {
869
+ await fs.copyFile(srcPath, destPath);
870
+ } else {
871
+ const out = applyScaffoldTokens(buf.toString('utf8'), values);
872
+ // Anything still wearing braces had no value supplied. Collect it rather than shipping
873
+ // it: an unresolved placeholder reaching a developer is the defect this exists to end.
874
+ for (const m of out.matchAll(/\{\{([A-Z_]+)\}\}/g)) unresolved.push(`${rel}:{{${m[1]}}}`);
875
+ await fs.writeFile(destPath, out);
876
+ }
877
+ } else {
878
+ await fs.copyFile(srcPath, destPath);
879
+ }
803
880
  stats.copied++;
804
881
  if (verbose) console.log(` Copied: ${entry.name}`);
805
882
  }
@@ -2,6 +2,29 @@ import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import { ENV_ORIGINS } from '@descix/app-sdk/dev';
4
4
  import { resolveOrigin } from './origin.js';
5
+ // The one canonical KB-sync surface, from its owner. The example in requireContext() is a remedy
6
+ // a stuck developer will copy verbatim, so it must resolve to the live verb rather than a literal
7
+ // that can go stale where nobody is watching.
8
+ import { CANONICAL_KB_SYNC } from './commands/retired-kb-sync.js';
9
+
10
+ /**
11
+ * THE ONE OWNER of "this app is not mapped in workspace.json, here is how to fix it".
12
+ *
13
+ * This text existed as three byte-identical copies in this file, and TWO OTHER call sites in
14
+ * bin/descix.js answered the same condition with a DIFFERENT and wrong remedy — `npx descix init`,
15
+ * which initialises a WORKSPACE and cannot map an app, so a developer who followed it stayed
16
+ * exactly as stuck. That is the mirror-drift tell: the same condition explained two ways, and the
17
+ * wrong one was the one a developer actually hit on the onboarding path.
18
+ *
19
+ * @param {string} appId - the app that is not mapped
20
+ * @returns {string}
21
+ */
22
+ export function unmappedAppMessage(appId) {
23
+ return (
24
+ `App "${appId}" is not mapped in workspace.json. ` +
25
+ 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
26
+ );
27
+ }
5
28
 
6
29
  /**
7
30
  * ONE OWNER for turning a workspace-relative localPath into an absolute path.
@@ -248,6 +271,17 @@ export class WorkspaceConfig {
248
271
  /**
249
272
  * Save workspace configuration in v2.1 format.
250
273
  * Always writes env.platform / env.products structure.
274
+ *
275
+ * PRESERVES KEYS IT DOES NOT UNDERSTAND. This method used to serialize a fixed field list from
276
+ * memory over the whole file, so ANY top-level key it did not itself enumerate was silently
277
+ * destroyed by the next `descix app set-port` — a writer re-serializing a known schema and
278
+ * discarding the rest. That is the same defect class as a record writer dropping a field it was
279
+ * never taught about, and it is invisible: the write succeeds, the file looks well-formed, and
280
+ * the loss is only discovered by whoever needed the missing key.
281
+ *
282
+ * The on-disk file is re-read at save time rather than trusted from construction, because the
283
+ * instance may have been held across another writer's save.
284
+ *
251
285
  * @param {string} [workspaceRoot] - Workspace root directory (uses stored root if not provided)
252
286
  * @returns {Promise<string>} Path to saved config
253
287
  */
@@ -266,7 +300,32 @@ export class WorkspaceConfig {
266
300
  // Build env block for v2.1 output
267
301
  const envBlock = (this.env && Object.keys(this.env).length > 0) ? this.env : null;
268
302
 
303
+ // The keys this writer OWNS. Everything else on disk is carried through untouched.
304
+ const OWNED = ['version', 'workspaceRoot', 'type', 'env', 'driveConfig'];
305
+
306
+ let onDisk = {};
307
+ try {
308
+ const existing = await fs.readFile(configPath, 'utf-8');
309
+ const parsed = JSON.parse(existing);
310
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) onDisk = parsed;
311
+ } catch (err) {
312
+ // ENOENT is the ordinary first save. A PRESENT-BUT-UNPARSEABLE file is different: carrying
313
+ // on would overwrite content we could not read, so refuse and name the file.
314
+ if (err.code !== 'ENOENT') {
315
+ throw new Error(
316
+ `Refusing to overwrite ${configPath}: it exists but could not be read as JSON ` +
317
+ `(${err.message}). Fix or remove it — saving now would destroy whatever it holds.`
318
+ );
319
+ }
320
+ }
321
+
322
+ const carried = {};
323
+ for (const [k, v] of Object.entries(onDisk)) {
324
+ if (!OWNED.includes(k)) carried[k] = v;
325
+ }
326
+
269
327
  const configData = {
328
+ ...carried,
270
329
  version: '2.1',
271
330
  workspaceRoot: path.resolve(root),
272
331
  type: this.type,
@@ -508,7 +567,7 @@ export class WorkspaceConfig {
508
567
  'Options:\n' +
509
568
  ' 1. cd into an app directory\n' +
510
569
  ' 2. Use flag: -a <app_id>\n\n' +
511
- 'Example: npx descix update kb -a daita'
570
+ `Example: npx ${CANONICAL_KB_SYNC} -a daita`
512
571
  );
513
572
  }
514
573
 
@@ -637,8 +696,7 @@ export class WorkspaceConfig {
637
696
 
638
697
  if (!entry) {
639
698
  throw new Error(
640
- `App "${appId}" is not mapped in workspace.json. ` +
641
- 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
699
+ unmappedAppMessage(appId)
642
700
  );
643
701
  }
644
702
 
@@ -687,8 +745,7 @@ export class WorkspaceConfig {
687
745
 
688
746
  if (!entry) {
689
747
  throw new Error(
690
- `App "${appId}" is not mapped in workspace.json. ` +
691
- 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
748
+ unmappedAppMessage(appId)
692
749
  );
693
750
  }
694
751
 
@@ -743,8 +800,7 @@ export class WorkspaceConfig {
743
800
 
744
801
  if (!entry) {
745
802
  throw new Error(
746
- `App "${appId}" is not mapped in workspace.json. ` +
747
- 'Use `descix app init` to register, or `descix app set-localpath -a <id> -p <path>` to repoint.'
803
+ unmappedAppMessage(appId)
748
804
  );
749
805
  }
750
806
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "license": "SEE LICENSE IN LICENSE.md",
31
31
  "dependencies": {
32
32
  "@descix/app-sdk": "^0.1.2",
33
- "@descix/sdk": "^1.0.0",
33
+ "@descix/sdk": "^2.0.0",
34
34
  "@modelcontextprotocol/sdk": "^1.25.1",
35
35
  "axios": "^1.7.9",
36
36
  "chalk": "^5.6.2",
@@ -151,9 +151,6 @@ descix kb corpus sync -c <community> -a <app> -k <KB>
151
151
  descix kb corpus status -c <community> -a <app>
152
152
  ```
153
153
 
154
- `descix update kb`, `descix kb chunk` and `descix kb sync` still run, but each names
155
- `kb corpus sync` as its replacement in its own `--help` and is slated for removal. Do not put
156
- them in new instructions.
157
154
 
158
155
  ## Local Development
159
156
 
@@ -115,9 +115,6 @@ descix kb corpus sync -c <community> -a <app> -k <KB>
115
115
  descix kb corpus status -c <community> -a <app>
116
116
  ```
117
117
 
118
- `descix update kb`, `descix kb chunk` and `descix kb sync` still run, but each names
119
- `kb corpus sync` as its replacement in its own `--help` and is slated for removal. Do not put
120
- them in new instructions.
121
118
 
122
119
  ## Local Development
123
120
 
@@ -115,9 +115,6 @@ descix kb corpus sync -c <community> -a <app> -k <KB>
115
115
  descix kb corpus status -c <community> -a <app>
116
116
  ```
117
117
 
118
- `descix update kb`, `descix kb chunk` and `descix kb sync` still run, but each names
119
- `kb corpus sync` as its replacement in its own `--help` and is slated for removal. Do not put
120
- them in new instructions.
121
118
 
122
119
  ## Local Development
123
120
 
@@ -115,9 +115,6 @@ descix kb corpus sync -c <community> -a <app> -k <KB>
115
115
  descix kb corpus status -c <community> -a <app>
116
116
  ```
117
117
 
118
- `descix update kb`, `descix kb chunk` and `descix kb sync` still run, but each names
119
- `kb corpus sync` as its replacement in its own `--help` and is slated for removal. Do not put
120
- them in new instructions.
121
118
 
122
119
  ## Local Development
123
120
 
@@ -9,7 +9,7 @@ This folder contains the documents that power the AI assistant. Add your content
9
9
  ## How It Works
10
10
 
11
11
  1. **Add Documents**: Upload markdown, text, or PDF files to this folder
12
- 2. **Sync**: Use `descix kb sync` to process and vectorize your documents
12
+ 2. **Sync**: Use `descix kb corpus sync -a <app_id>` to process and vectorize your documents
13
13
  3. **Query**: Users can now ask questions and get answers based on your content
14
14
 
15
15
  ## Folder Structure
@@ -9,7 +9,7 @@ This folder contains the documents that power the AI assistant. Add your content
9
9
  ## How It Works
10
10
 
11
11
  1. **Add Documents**: Upload markdown, text, or PDF files to this folder
12
- 2. **Sync**: Use `descix kb sync` to process and vectorize your documents
12
+ 2. **Sync**: Use `descix kb corpus sync -a <app_id>` to process and vectorize your documents
13
13
  3. **Query**: Users can now ask questions and get answers based on your content
14
14
 
15
15
  ## Folder Structure
@@ -237,7 +237,7 @@ tell_me_how({
237
237
  execute_remote_command({
238
238
  command: "create_app_for_community",
239
239
  params: {
240
- community_id: "descix",
240
+ community_id: "{{COMMUNITY_ID}}",
241
241
  app_name: "My New App"
242
242
  }
243
243
  })
@@ -285,9 +285,9 @@ ask_question_to_app({
285
285
  {
286
286
  "version": "2.1",
287
287
  "env": {
288
- "platform": { "appId": "daita", "communityId": "descix", "localPath": ".", "kbId": "General" },
288
+ "platform": { "appId": "daita", "communityId": "daita", "localPath": ".", "kbId": "General" },
289
289
  "products": [
290
- { "appId": "docs", "communityId": "descix", "kbId": "sdk", "localPath": "descix-docs" }
290
+ { "appId": "docs", "communityId": "daita", "kbId": "sdk", "localPath": "descix-docs" }
291
291
  ]
292
292
  }
293
293
  }
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "DEBUG_LOCAL": true,
3
3
  "LOCAL_PORT": "auto",
4
- "CORE_API_URL": "https://localhost:4000/apifront"
4
+ "CORE_API_URL": "https://localhost:4000/apifront",
5
+ "community_id": "{{COMMUNITY_ID}}",
6
+ "app_id": "{{APP_ID}}"
5
7
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "service": {
3
- "name": "sample-service",
3
+ "name": "{{APP_ID}}",
4
4
  "version": "1.0.0",
5
5
  "description": "Sample DeSciX Add-on Service",
6
6
  "healthEndpoint": "/health",
7
7
  "debugPort": 3003,
8
- "app_id": "sample-app",
9
- "community_id": "descix"
8
+ "app_id": "{{APP_ID}}",
9
+ "community_id": "{{COMMUNITY_ID}}"
10
10
  },
11
11
  "commands": {
12
12
  "sample_hello": {
@@ -21,7 +21,9 @@
21
21
  "description": "Message to echo"
22
22
  }
23
23
  },
24
- "required": ["message"]
24
+ "required": [
25
+ "message"
26
+ ]
25
27
  }
26
28
  },
27
29
  "sample_analyze": {
@@ -36,7 +38,9 @@
36
38
  "description": "Topic to analyze"
37
39
  }
38
40
  },
39
- "required": ["query"]
41
+ "required": [
42
+ "query"
43
+ ]
40
44
  }
41
45
  }
42
46
  },
@@ -19,7 +19,7 @@
19
19
  "cors": "^2.8.5",
20
20
  "dotenv": "^16.3.1",
21
21
  "express": "^4.18.2",
22
- "@descix/sdk": "^1.0.0",
22
+ "@descix/sdk": "^2.0.0",
23
23
  "@descix/cli": "^1.0.0"
24
24
  },
25
25
  "devDependencies": {
@@ -33,8 +33,8 @@ export const commands = {
33
33
  // CALL CORE TOOL: query_knowledge_base
34
34
  // This goes Service -> Cloud -> Vector DB -> Cloud -> Service
35
35
  const ragResult = await mcpClient.callTool('query_knowledge_base', {
36
- community_id: 'descix',
37
- app_id: 'docs',
36
+ community_id: '{{COMMUNITY_ID}}',
37
+ app_id: '{{APP_ID}}',
38
38
  kb_id: 'sdk',
39
39
  query: query
40
40
  }, _descix);