@descix/cli 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +21 -15
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +220 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +19 -6
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. package/templates/scaffolds/site/styles.css +0 -123
@@ -0,0 +1,218 @@
1
+ /**
2
+ * SiteWalker - Manifest-driven file walker for site deployment
3
+ *
4
+ * Given a site manifest (.descix/manifests/site.json), walks source entries,
5
+ * applies include/exclude glob patterns, and computes file hashes for
6
+ * delta-based deployment to GCS.
7
+ *
8
+ * Architecture:
9
+ * - Files stay in place — no copying or staging
10
+ * - File hashes (SHA-256) enable delta uploads (skip unchanged files)
11
+ * - Multiple source entries are flattened into a deploy tree preserving
12
+ * relative paths from each source root
13
+ * - Unlike CorpusWalker, accepts any file type (HTML, CSS, JS, images, fonts, etc.)
14
+ * and uses include/exclude globs instead of syncignore + extension filtering
15
+ */
16
+
17
+ import * as fs from 'fs/promises';
18
+ import * as path from 'path';
19
+ import { createHash } from 'crypto';
20
+ import { minimatch } from 'minimatch';
21
+
22
+ /**
23
+ * Check if a relative path matches any of the given glob patterns.
24
+ *
25
+ * @param {string} relativePath - Path relative to the source entry root
26
+ * @param {string[]} patterns - Array of glob patterns
27
+ * @returns {boolean} True if the path matches at least one pattern
28
+ */
29
+ function matchesAny(relativePath, patterns) {
30
+ for (const pattern of patterns) {
31
+ if (minimatch(relativePath, pattern, { dot: true })) {
32
+ return true;
33
+ }
34
+ // Also check just the filename for patterns like "*.css"
35
+ const basename = path.basename(relativePath);
36
+ if (minimatch(basename, pattern, { dot: true })) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * Check if a directory name matches any exclude patterns that look like directory names.
45
+ * Patterns ending with "/" are directory excludes (e.g., "node_modules/").
46
+ *
47
+ * @param {string} dirName - Directory name (not full path)
48
+ * @param {string[]} excludePatterns - Exclude patterns
49
+ * @returns {boolean} True if the directory should be skipped entirely
50
+ */
51
+ function isDirExcluded(dirName, excludePatterns) {
52
+ for (const pattern of excludePatterns) {
53
+ // Pattern ending with / targets directories
54
+ if (pattern.endsWith('/') && dirName === pattern.slice(0, -1)) {
55
+ return true;
56
+ }
57
+ // Also match directory-style patterns without trailing slash
58
+ if (dirName === pattern) {
59
+ return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ /**
66
+ * Compute SHA-256 hash of a file's contents.
67
+ *
68
+ * @param {string} absolutePath - Absolute path to the file
69
+ * @returns {Promise<string>} Hex-encoded SHA-256 hash
70
+ */
71
+ async function computeFileHash(absolutePath) {
72
+ const content = await fs.readFile(absolutePath);
73
+ return createHash('sha256').update(content).digest('hex');
74
+ }
75
+
76
+ /**
77
+ * Get file size in bytes.
78
+ *
79
+ * @param {string} absolutePath - Absolute path to the file
80
+ * @returns {Promise<number>} File size
81
+ */
82
+ async function getFileSize(absolutePath) {
83
+ const stat = await fs.stat(absolutePath);
84
+ return stat.size;
85
+ }
86
+
87
+ /**
88
+ * Recursively walk a directory and collect file paths,
89
+ * applying include and exclude glob filters.
90
+ *
91
+ * @param {string} dir - Directory to walk
92
+ * @param {string} baseDir - Base directory for computing relative paths
93
+ * @param {string[]} include - Include glob patterns (empty = include all)
94
+ * @param {string[]} exclude - Exclude glob patterns
95
+ * @returns {Promise<string[]>} Array of absolute file paths that pass filters
96
+ */
97
+ async function walkDir(dir, baseDir, include, exclude) {
98
+ const results = [];
99
+
100
+ let entries;
101
+ try {
102
+ entries = await fs.readdir(dir, { withFileTypes: true });
103
+ } catch {
104
+ return results; // Directory doesn't exist or unreadable
105
+ }
106
+
107
+ for (const entry of entries) {
108
+ const fullPath = path.join(dir, entry.name);
109
+ const relativePath = path.relative(baseDir, fullPath);
110
+
111
+ if (entry.isDirectory()) {
112
+ // Skip excluded directories early (avoids walking into node_modules, etc.)
113
+ if (isDirExcluded(entry.name, exclude)) {
114
+ continue;
115
+ }
116
+ // Also check full relative path against exclude patterns
117
+ if (matchesAny(relativePath + '/', exclude) || matchesAny(relativePath, exclude)) {
118
+ continue;
119
+ }
120
+ const subFiles = await walkDir(fullPath, baseDir, include, exclude);
121
+ results.push(...subFiles);
122
+ } else if (entry.isFile()) {
123
+ // Check exclude first
124
+ if (exclude.length > 0 && matchesAny(relativePath, exclude)) {
125
+ continue;
126
+ }
127
+ // Check include (empty include = match all)
128
+ if (include.length === 0 || matchesAny(relativePath, include)) {
129
+ results.push(fullPath);
130
+ }
131
+ }
132
+ }
133
+
134
+ return results;
135
+ }
136
+
137
+ /**
138
+ * Walk a site manifest and return file entries with hashes for deployment.
139
+ *
140
+ * For each source entry in the manifest:
141
+ * 1. Resolve the local path (relative to app root / workspace root)
142
+ * 2. Walk the directory, applying include/exclude globs
143
+ * 3. Compute SHA-256 hash for each file
144
+ * 4. Return file list with deploy paths (relative to deploy root)
145
+ *
146
+ * @param {Object} manifest - Validated site manifest with _resolvedSources
147
+ * @param {string} appRoot - Absolute path to the app root directory
148
+ * @returns {Promise<Object>} { files: Array<SiteFileEntry>, totalSize: number }
149
+ *
150
+ * SiteFileEntry: {
151
+ * absolutePath: string, // Local filesystem path
152
+ * deployPath: string, // Path in the deploy tree (relative)
153
+ * hash: string, // SHA-256 hex hash
154
+ * size: number, // File size in bytes
155
+ * sourceEntry: string // Source manifest path for traceability
156
+ * }
157
+ */
158
+ export async function walkSite(manifest, appRoot) {
159
+ const files = [];
160
+ let totalSize = 0;
161
+
162
+ for (const source of manifest._resolvedSources) {
163
+ const { absolutePath, include, exclude, sourcePath } = source;
164
+
165
+ // Check if source is a single file
166
+ let stat;
167
+ try {
168
+ stat = await fs.stat(absolutePath);
169
+ } catch (err) {
170
+ if (err.code === 'ENOENT') {
171
+ // Source doesn't exist — skip with warning (caller can log)
172
+ continue;
173
+ }
174
+ throw err;
175
+ }
176
+
177
+ if (stat.isFile()) {
178
+ // Single file source entry — deploy at the filename level
179
+ const hash = await computeFileHash(absolutePath);
180
+ const size = stat.size;
181
+ totalSize += size;
182
+
183
+ files.push({
184
+ absolutePath,
185
+ deployPath: path.basename(absolutePath),
186
+ hash,
187
+ size,
188
+ sourceEntry: sourcePath
189
+ });
190
+ } else if (stat.isDirectory()) {
191
+ // Directory: walk with include/exclude
192
+ const filePaths = await walkDir(absolutePath, absolutePath, include, exclude);
193
+
194
+ for (const filePath of filePaths) {
195
+ const hash = await computeFileHash(filePath);
196
+ const size = await getFileSize(filePath);
197
+ totalSize += size;
198
+
199
+ // Deploy path is relative to the source directory root (not the source entry label).
200
+ // e.g. source.path = "site/dist" → dist/index.html under {env}/{app}/site/
201
+ const relativeToSource = path.relative(absolutePath, filePath);
202
+ const deployPath = relativeToSource.split(path.sep).join('/');
203
+
204
+ files.push({
205
+ absolutePath: filePath,
206
+ deployPath,
207
+ hash,
208
+ size,
209
+ sourceEntry: sourcePath
210
+ });
211
+ }
212
+ }
213
+ }
214
+
215
+ return { files, totalSize };
216
+ }
217
+
218
+ export default { walkSite };
@@ -89,7 +89,9 @@ export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId
89
89
  app_id: appId,
90
90
  kb_id: kbId
91
91
  });
92
- return result.chunks || [];
92
+ // apiClient.invoke returns { status, message: { chunks, count, ... } }
93
+ const data = result.message || result;
94
+ return data.chunks || [];
93
95
  } catch (error) {
94
96
  // Fall back to IDs-only endpoint
95
97
  try {
@@ -97,8 +99,9 @@ export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId
97
99
  app_id: appId,
98
100
  kb_id: kbId
99
101
  });
100
- // Return as array of IDs (old format) for backward compatibility
101
- return result.chunk_ids || [];
102
+ // apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
103
+ const data = result.message || result;
104
+ return data.chunk_ids || [];
102
105
  } catch {
103
106
  return [];
104
107
  }
@@ -151,8 +154,11 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
151
154
  }
152
155
 
153
156
  /**
154
- * Delete stale chunks from Pinecone
155
- *
157
+ * Delete stale chunks from Pinecone by explicit chunk IDs.
158
+ *
159
+ * Use this when you have the exact chunk_ids to purge (e.g., shrinking file
160
+ * where chunks N..M no longer exist locally but linger in Pinecone).
161
+ *
156
162
  * @param {Object} apiClient - DeSciXApiClient instance
157
163
  * @param {string} communityId - Community ID
158
164
  * @param {string} appId - App ID
@@ -164,14 +170,127 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
164
170
  if (chunkIds.length === 0) {
165
171
  return { deleted: 0 };
166
172
  }
167
-
168
- await apiClient.invoke('kb_delete_chunks', {
173
+
174
+ const result = await apiClient.invoke('kb_delete_chunks', {
175
+ community_id: communityId,
169
176
  app_id: appId,
170
177
  kb_id: kbId,
171
178
  chunk_ids: chunkIds
172
179
  });
173
-
174
- return { deleted: chunkIds.length };
180
+
181
+ const data = result?.message || result;
182
+ return { deleted: data.deleted_count ?? chunkIds.length };
183
+ }
184
+
185
+ /**
186
+ * Delete stale chunks from Pinecone by file_id (metadata filter).
187
+ *
188
+ * This is the canonical stale-purge path for `kb corpus sync`: when a file's
189
+ * blob_sha changes, the prior blob_sha is a stale file_id. Passing it here
190
+ * deletes every chunk where metadata.file_id matches, scoped by the full
191
+ * multi-tenancy filter (community_id, app_id, knowledgebase_name) on the
192
+ * server side via pineconeService.deleteVectorsByFileId.
193
+ *
194
+ * Bulk: pass multiple file_ids in one call to amortize the round-trip.
195
+ *
196
+ * @param {Object} apiClient - DeSciXApiClient instance
197
+ * @param {string} communityId - Community ID
198
+ * @param {string} appId - App ID
199
+ * @param {string} kbId - Knowledge base ID
200
+ * @param {Array<string>} fileIds - file_id values to delete (e.g., ['corpus:abc...', 'local:def...'])
201
+ * @returns {Promise<{deleted: number, deleted_by_file_id: number}>}
202
+ */
203
+ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, fileIds) {
204
+ if (!Array.isArray(fileIds) || fileIds.length === 0) {
205
+ return { deleted: 0, deleted_by_file_id: 0 };
206
+ }
207
+
208
+ // Filter out null/undefined/empty entries — pineconeService treats empty fileId as 0-count no-op
209
+ const validFileIds = fileIds.filter(Boolean);
210
+ if (validFileIds.length === 0) {
211
+ return { deleted: 0, deleted_by_file_id: 0 };
212
+ }
213
+
214
+ const result = await apiClient.invoke('kb_delete_chunks', {
215
+ community_id: communityId,
216
+ app_id: appId,
217
+ kb_id: kbId,
218
+ file_ids: validFileIds
219
+ });
220
+
221
+ const data = result?.message || result;
222
+ return {
223
+ deleted: data.deleted_count ?? validFileIds.length,
224
+ deleted_by_file_id: data.deleted_by_file_id ?? validFileIds.length
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Purge EVERY vector in a KB's {community_id, app_id, knowledgebase_name} scope.
230
+ *
231
+ * This is the FULL metadata-scoped purge — it clears all vectors regardless of
232
+ * their file_id scheme, including legacy/orphan vectors that carry no `corpus:`
233
+ * file_id and are therefore INVISIBLE to listRemoteFileIds (the corpus-scheme
234
+ * enumeration). It is the only primitive that fully clears accumulated orphan
235
+ * pollution before a `--rebuild` re-upsert.
236
+ *
237
+ * Server-side it routes to kb_delete_chunks(purge_scope: true), which delegates
238
+ * to the canonical KnowledgeBase.deleteRAG(). As of WS-KB-CORPUS-SCOPEPURGE-SCALE
239
+ * that purge is ROBUST AT SCALE: it enumerates the KB's vectors by their canonical
240
+ * id-prefix `${app_id}:${kb_id}:` (deterministic listPaginated) and deletes them in
241
+ * bounded batches of 1000, so an arbitrarily large scope (~20k+ vectors) is cleared
242
+ * without exceeding the Pinecone client's max-retries ceiling (the prior single
243
+ * giant metadata deleteMany failed at that size). It still clears ALL orphans
244
+ * (they share the prefix) AND resets the KB doc's rag fields (rag_vector_count→0)
245
+ * so `kb doctor` reports clean drift after the subsequent re-sync. The rag-field
246
+ * reset happens ONLY after the live purge completes. Touches Pinecone vectors +
247
+ * the KB doc's rag fields ONLY — no Firestore/GCS/Products cascade. On a partial
248
+ * batch failure it FAILS LOUD (recoverable by re-running — idempotent).
249
+ *
250
+ * @param {Object} apiClient - DeSciXApiClient instance
251
+ * @param {string} communityId - Community ID (required server-side; full scope)
252
+ * @param {string} appId - App ID
253
+ * @param {string} kbId - Knowledge base ID
254
+ * @returns {Promise<{ deleted: number, purged_scope: boolean }>}
255
+ * `deleted` is the actual count of vectors purged (the batched path always
256
+ * reports it); -1 only if a future primitive cannot report a count.
257
+ */
258
+ export async function purgeKbScope(apiClient, communityId, appId, kbId) {
259
+ const result = await apiClient.invoke('kb_delete_chunks', {
260
+ community_id: communityId,
261
+ app_id: appId,
262
+ kb_id: kbId,
263
+ purge_scope: true
264
+ });
265
+ const data = result?.message || result;
266
+ return {
267
+ deleted: typeof data?.deleted_count === 'number' ? data.deleted_count : -1,
268
+ purged_scope: data?.purged_scope === true
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Enumerate all file_id values currently present in a KB's Pinecone namespace.
274
+ *
275
+ * Used by `descix kb corpus sync --rebuild` to compute drift:
276
+ * stale = pinecone_file_ids − current_local_file_ids
277
+ *
278
+ * @param {Object} apiClient - DeSciXApiClient instance
279
+ * @param {string} appId - App ID
280
+ * @param {string} kbId - Knowledge base ID
281
+ * @returns {Promise<{ file_ids: string[], unique_count: number, total_chunks: number }>}
282
+ */
283
+ export async function listRemoteFileIds(apiClient, appId, kbId) {
284
+ const result = await apiClient.invoke('kb_list_file_ids', {
285
+ app_id: appId,
286
+ kb_id: kbId
287
+ });
288
+ const data = result?.message || result;
289
+ return {
290
+ file_ids: data.file_ids || [],
291
+ unique_count: data.unique_count || 0,
292
+ total_chunks: data.total_chunks || 0
293
+ };
175
294
  }
176
295
 
177
296
  /**
@@ -255,10 +374,40 @@ export async function syncKb(apiClient, config, options = {}) {
255
374
  } else {
256
375
  if (onProgress) onProgress('All chunks already in sync');
257
376
  }
258
-
377
+
378
+ // 6. File-level stale purge (belt-and-suspenders).
379
+ // The chunk_id-based delete in step 4 handles the common shrinking/deletion case
380
+ // but only sees chunk_ids that were returned by getRemoteChunkMetadata. For
381
+ // robustness — and to close DN-2 at the lower-level kb sync surface too — we
382
+ // also enumerate remote file_ids and delete any whose file_id is not present
383
+ // in the current local chunk set. This catches file deletions and modifications
384
+ // that produced new chunk_id shapes (e.g., content-hash-based ids).
385
+ let deletedByFileId = 0;
386
+ try {
387
+ const localFileIds = new Set(localChunks.map(c => c.file_id).filter(Boolean));
388
+ const remoteFileIdsResp = await apiClient.invoke('kb_list_file_ids', {
389
+ app_id: appId,
390
+ kb_id: kbId
391
+ });
392
+ const remoteData = remoteFileIdsResp?.message || remoteFileIdsResp;
393
+ const remoteFileIds = remoteData?.file_ids || [];
394
+ const staleFileIds = remoteFileIds.filter(fid => !localFileIds.has(fid));
395
+ if (staleFileIds.length > 0) {
396
+ if (onProgress) onProgress(`Purging ${staleFileIds.length} stale file_id(s)...`);
397
+ const purgeResult = await deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, staleFileIds);
398
+ deletedByFileId = purgeResult.deleted;
399
+ }
400
+ } catch (purgeErr) {
401
+ // Non-fatal: surface via verbose log. The chunk_id-based delete above is the primary
402
+ // path; this is a safety net. We do NOT silently swallow — caller sees a warning.
403
+ if (verbose) console.log(` File-id purge skipped: ${purgeErr.message}`);
404
+ }
405
+
259
406
  return {
260
407
  synced,
261
- deleted,
408
+ deleted: deleted + deletedByFileId,
409
+ deleted_chunk_ids: deleted,
410
+ deleted_by_file_id: deletedByFileId,
262
411
  unchanged
263
412
  };
264
413
  }
@@ -306,5 +455,8 @@ export default {
306
455
  getRemoteChunkMetadata,
307
456
  upsertChunks,
308
457
  deleteStaleChunks,
458
+ deleteStaleChunksByFileId,
459
+ purgeKbScope,
460
+ listRemoteFileIds,
309
461
  getSyncStatus
310
462
  };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * THE ONE OWNER of every write the CLI makes ABOUT ITS OWN EXECUTION.
3
+ *
4
+ * WHY THIS EXISTS (measured 2026-08-30). `bin/mcp-server.js` speaks JSON-RPC over stdio, so its
5
+ * stdout IS the protocol stream. Driving one `tools/call` put 130 bytes of non-JSON-RPC text into
6
+ * that stream, from `console.log` calls in `lib/api-client.js`, on the credential-refresh path —
7
+ * which fires exactly when a token expires, the moment a long-running MCP session is least able
8
+ * to survive a corrupt frame.
9
+ *
10
+ * THE SCOPE IS THE MEASURED IMPORT GRAPH, NOT A GREP. Walking the real import graph from
11
+ * `bin/mcp-server.js` (static and dynamic), 12 files are reachable carrying 74 `console.log`
12
+ * sites — `lib/api-client.js` 9 and `lib/commands/auth.js` 65. The package-wide count is 1108;
13
+ * that number is not the target and never was. Everything on the reachable graph writes through
14
+ * here.
15
+ *
16
+ * WHY A WRITER AND NOT A WRAPPER. There WAS already a fix, and its shape is the lesson:
17
+ * `mcpListTools()` monkey-patched `console.log = console.error` for the duration of ONE call and
18
+ * restored it in a `finally`. A call-site workaround for a write-site defect — it protected the
19
+ * one path its author was looking at and left every other path through `invoke()`/`invokeRaw()`
20
+ * corrupting the stream. It is DELETED in the same change that makes it unnecessary; a fix that
21
+ * does not sweep the workarounds it invalidates leaves a time bomb primed toward silence.
22
+ *
23
+ * STDERR, ALWAYS, WITH NO MODE SWITCH. A stream decision that can be configured is one that will
24
+ * eventually be configured wrongly, and the caller who gets it wrong is a protocol transport that
25
+ * cannot report its own corruption. On a terminal stderr is still visible to the human, so the
26
+ * interactive login flow reads exactly as before; what changes is that `descix login > file` no
27
+ * longer captures the prompts, because prompts were never the command's OUTPUT.
28
+ *
29
+ * WHAT DOES NOT BELONG HERE: anything the user ASKED FOR — a command's JSON document, a report
30
+ * like `config show`, a value meant to be piped. Those are OUTPUT and they belong on stdout.
31
+ * `bin/descix.js::progress()` owns the narrower question of where human progress text goes when
32
+ * `--json` makes stdout a data channel; this owns the general one.
33
+ */
34
+
35
+ /**
36
+ * Write a diagnostic / human-progress line. Same call shape as console.log; always stderr.
37
+ * @param {...any} args
38
+ */
39
+ export function diag(...args) {
40
+ console.error(...args);
41
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * THE ONE OWNER OF "which environment is this origin, and how does the CLI SAY SO".
3
+ *
4
+ * WHY THIS FILE EXISTS: contract I1 (A', rev 2) requires that every network-bound command
5
+ * PRINT the resolved environment, origin and source — always, not only when it landed on the
6
+ * default. That print needs two facts: the env NAME for an origin, and one spelling of the
7
+ * line. Both were about to be derived in a second place.
8
+ *
9
+ * `wallet-environment.js` already derived origin -> env name privately, for the wallet stamp.
10
+ * Two derivations of one fact is the mirror-drift bug class this contract exists to end, so
11
+ * the derivation moved HERE and the wallet stamp became a consumer of it. There is exactly one
12
+ * answer to "what environment is https://dev.descix.net", and both surfaces read it from here.
13
+ *
14
+ * The origin TABLE is still owned upstream (`@descix/app-sdk/dev` ENV_ORIGINS, surfaced as
15
+ * `WorkspaceConfig.ENV_MAP`). This module holds no copy of it.
16
+ */
17
+ import { WorkspaceConfig } from './workspace-config.js';
18
+ import { normalizeOrigin } from './origin.js';
19
+
20
+ /**
21
+ * The environment NAME for an origin.
22
+ *
23
+ * @param {string} origin
24
+ * @returns {string} the ENV_MAP name whose url matches (`dev` | `demo` | `prod`), or `'custom'`
25
+ * when the origin is a real one that simply is not in the map — a self-hosted or
26
+ * port-forwarded gateway. 'custom' is an HONEST answer ("a real origin, not one of the
27
+ * three named ones"), not a fallback standing in for a missing fact: the origin itself
28
+ * is always printed beside it.
29
+ * @throws when called with no origin. There is no default: guessing which environment an
30
+ * origin belongs to is exactly the misreport this contract exists to end.
31
+ */
32
+ export function environmentNameFor(origin) {
33
+ if (!origin || String(origin).trim() === '') {
34
+ throw new Error(
35
+ 'environmentNameFor: refusing to name the environment of an empty origin — ' +
36
+ 'it cannot be guessed.',
37
+ );
38
+ }
39
+ const normalized = normalizeOrigin(origin);
40
+ const match = Object.entries(WorkspaceConfig.ENV_MAP)
41
+ .find(([, entry]) => entry && normalizeOrigin(entry.url) === normalized);
42
+ return match ? match[0] : 'custom';
43
+ }
44
+
45
+ /**
46
+ * The ONE spelling of the env line every network-bound command prints.
47
+ *
48
+ * Shape is the contract's own (I1, rev 2): `env: <name> (<source>) <origin>`. The SOURCE is the
49
+ * whole point — it is the fact whose absence made "the developer chose production" and "the
50
+ * developer chose nothing" one observable state on 1.0.1.
51
+ *
52
+ * @param {{origin: string, source: string}} resolved - as returned by `resolveOrigin`
53
+ * @returns {string} the line, WITHOUT a trailing newline
54
+ */
55
+ export function formatEnvLine({ origin, source }) {
56
+ return `env: ${environmentNameFor(origin)} (${source}) ${origin}`;
57
+ }
58
+
59
+ /**
60
+ * The SOURCE label for an origin handed in by the caller rather than resolved from config —
61
+ * `--api-url`, `descix auth login -u`, a microservice constructing a client against a known
62
+ * origin. Exported so the label has one spelling.
63
+ */
64
+ export const EXPLICIT_ORIGIN_SOURCE = 'explicit --api-url / caller-supplied baseUrl';
65
+
66
+ /**
67
+ * Distinct (origin, source) pairs already reported in this process.
68
+ *
69
+ * WHY DEDUPE AND NOT A ONE-SHOT FLAG: a single command constructs many clients (87 sites), so
70
+ * a per-client print would emit the same line a dozen times and train the reader to skim past
71
+ * it. But a one-shot flag would HIDE a second, DIFFERENT origin being contacted in the same
72
+ * invocation — which is precisely the kind of thing this contract exists to surface. Keying on
73
+ * the pair prints each distinct target exactly once.
74
+ */
75
+ const reported = new Set();
76
+
77
+ /**
78
+ * PRINT the resolved environment for a network-bound invocation. Always — not only when the
79
+ * origin came from the declared default.
80
+ *
81
+ * STDERR, DELIBERATELY. stdout is a DATA CHANNEL under `--json` and is the protocol channel for
82
+ * the MCP stdio server; a status line on stdout would corrupt both. stderr is where a human
83
+ * piping stdout still sees it.
84
+ *
85
+ * @param {{origin: string, source: string}} resolved
86
+ * @returns {string|null} the line printed, or null when this pair was already reported
87
+ */
88
+ export function reportEnvironment({ origin, source }) {
89
+ const key = `${origin}\u0000${source}`;
90
+ if (reported.has(key)) return null;
91
+ reported.add(key);
92
+ const line = formatEnvLine({ origin, source });
93
+ process.stderr.write(line + '\n');
94
+ return line;
95
+ }
96
+
97
+ /** TEST SEAM ONLY: forget what has been reported, so a test can observe the print again. */
98
+ export function _resetEnvironmentReportForTests() {
99
+ reported.clear();
100
+ }
@@ -20,7 +20,10 @@ export class GlobalConfig {
20
20
  constructor(config = {}) {
21
21
  this.user_base_folder_id = config.user_base_folder_id || null;
22
22
  this.user_base_folder_name = config.user_base_folder_name || null;
23
- this.api_url = config.api_url || 'https://descix.net';
23
+ // NO default. An absent api_url means the global config names no origin, which is a fact
24
+ // the origin owner knows how to report; substituting the production literal here made
25
+ // ~/.descix/config.json claim an origin the user never set.
26
+ this.api_url = config.api_url || null;
24
27
  this.environment = config.environment || 'production';
25
28
  this.registered_at = config.registered_at || null;
26
29
  }