@descix/cli 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -34
- package/agent-assets/guides/dynamic-site-pattern.md +10 -14
- package/agent-assets/guides/knowledge-only-pattern.md +11 -15
- package/agent-assets/guides/microservice-pattern.md +12 -14
- package/agent-assets/guides/static-site-pattern.md +12 -15
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/reference/app-creation.md +36 -101
- package/agent-assets/reference/architecture.md +24 -27
- package/agent-assets/reference/local-dev.md +0 -4
- package/agent-assets/reference/mcp-integration.md +14 -22
- package/agent-assets/reference/workspace-config.md +97 -340
- package/agent-assets/rules/descix_mcp.template.mdc +1 -1
- package/bin/descix.js +159 -98
- package/lib/api-client.js +30 -22
- package/lib/commands/airdrop.js +10 -0
- package/lib/commands/auth.js +33 -3
- package/lib/commands/briefer/index.js +8 -5
- package/lib/commands/config.js +13 -31
- package/lib/commands/corpus.js +161 -54
- package/lib/commands/health.js +116 -21
- package/lib/commands/init.js +25 -48
- package/lib/commands/kb.js +14 -4
- package/lib/commands/update.js +0 -1
- package/lib/core/Chunker.js +1 -1
- package/lib/core/CorpusWalker.js +238 -47
- package/lib/core/Hydrator.js +88 -11
- package/lib/core/ManifestLoader.js +68 -0
- package/lib/core/Syncer.js +147 -46
- package/lib/interactive.js +158 -0
- package/lib/origin.js +3 -3
- package/lib/wallet-file.js +36 -37
- package/lib/wizard/setup.js +104 -22
- package/lib/workspace-config.js +325 -167
- package/lib/workspace-identity.js +0 -1
- package/package.json +3 -3
- package/templates/agent-claude.md +0 -3
- package/templates/agent-cline.md +0 -3
- package/templates/agent-copilot.md +0 -3
- package/templates/agent-cursor.md +0 -3
- package/templates/drive/app/kb/General/README.md +1 -1
- package/templates/drive_templates/agent_app_template/kb/General/README.md +1 -1
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +3 -3
- package/templates/scaffolds/microservice/defaults-config.json +3 -1
- package/templates/scaffolds/microservice/manifest.json +9 -5
- package/templates/scaffolds/microservice/package.json +1 -1
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +2 -2
package/lib/core/Syncer.js
CHANGED
|
@@ -25,6 +25,54 @@ import * as fs from 'fs/promises';
|
|
|
25
25
|
import * as path from 'path';
|
|
26
26
|
import { loadChunks } from './Chunker.js';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* THE ONE VALUE this CLI uses to mean "the store did not report a count".
|
|
30
|
+
*
|
|
31
|
+
* A receipt is derived from the STORE, never from the REQUEST. Where the store
|
|
32
|
+
* cannot report, we say so — we never substitute the number of things we ASKED
|
|
33
|
+
* to be changed, because that number is a description of our own request and
|
|
34
|
+
* carries no information about what happened.
|
|
35
|
+
*
|
|
36
|
+
* @type {number}
|
|
37
|
+
*/
|
|
38
|
+
export const UNREPORTED_COUNT = -1;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a count the store returned into either that count or UNREPORTED_COUNT.
|
|
42
|
+
* This is the ONLY place the "did the store report a number?" decision is made.
|
|
43
|
+
*
|
|
44
|
+
* @param {*} value - the raw count field as it arrived from the store
|
|
45
|
+
* @returns {number} the store's count, or UNREPORTED_COUNT if it reported none
|
|
46
|
+
*/
|
|
47
|
+
export function reportedCount(value) {
|
|
48
|
+
return Number.isFinite(value) ? value : UNREPORTED_COUNT;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `value` is a count the store actually reported. Callers use this to
|
|
53
|
+
* choose between printing a number and printing "unknown" — never to fabricate one.
|
|
54
|
+
*
|
|
55
|
+
* @param {*} value
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function isReportedCount(value) {
|
|
59
|
+
return Number.isFinite(value) && value >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sum counts across batches while preserving unknown-ness: if ANY batch could not
|
|
64
|
+
* be reported, the total is not a number we can honestly state.
|
|
65
|
+
*
|
|
66
|
+
* @param {number} runningTotal - UNREPORTED_COUNT once any batch was unreportable
|
|
67
|
+
* @param {*} batchValue - the raw count field from this batch's store response
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
export function accumulateReportedCount(runningTotal, batchValue) {
|
|
71
|
+
const next = reportedCount(batchValue);
|
|
72
|
+
if (!isReportedCount(runningTotal) || !isReportedCount(next)) return UNREPORTED_COUNT;
|
|
73
|
+
return runningTotal + next;
|
|
74
|
+
}
|
|
75
|
+
|
|
28
76
|
/**
|
|
29
77
|
* Compute delta between local chunks and remote Pinecone records
|
|
30
78
|
* Uses content_hash for efficient change detection when available.
|
|
@@ -73,39 +121,64 @@ export function computeChunkDelta(localChunks, remoteChunks) {
|
|
|
73
121
|
}
|
|
74
122
|
|
|
75
123
|
/**
|
|
76
|
-
* Get existing chunk metadata from Pinecone (with content_hash for delta)
|
|
77
|
-
* Falls back to IDs-only
|
|
78
|
-
*
|
|
124
|
+
* Get existing chunk metadata from Pinecone (with content_hash for delta).
|
|
125
|
+
* Falls back to the IDs-only endpoint when the metadata endpoint is unavailable.
|
|
126
|
+
*
|
|
127
|
+
* An empty array means the STORE REPORTED an empty KB. It never means "we could
|
|
128
|
+
* not find out" — when neither endpoint can report, this THROWS naming both
|
|
129
|
+
* endpoints and both causes, because an unknown remote set presented as an empty
|
|
130
|
+
* one makes every caller conclude that nothing is stale.
|
|
131
|
+
*
|
|
79
132
|
* @param {Object} apiClient - DeSciXApiClient instance
|
|
80
133
|
* @param {string} communityId - Community ID
|
|
81
134
|
* @param {string} appId - App ID
|
|
82
135
|
* @param {string} kbId - Knowledge base ID
|
|
83
|
-
* @returns {Promise<Array<{id: string, content_hash: string}>>} Chunk
|
|
136
|
+
* @returns {Promise<Array<{id: string, content_hash: string}>|Array<string>>} Chunk
|
|
137
|
+
* metadata, or plain chunk ids from the compatibility endpoint.
|
|
138
|
+
* @throws {Error} when neither endpoint reports the remote chunk set.
|
|
84
139
|
*/
|
|
85
140
|
export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId) {
|
|
141
|
+
// A fallback is a COMPATIBILITY PATH, not an error handler: we fall back when
|
|
142
|
+
// the newer endpoint is absent, and we FAIL LOUD when neither can report.
|
|
143
|
+
// Returning [] here would present a transport failure as a store state
|
|
144
|
+
// ("remote is empty"), and every caller would then compute that nothing is
|
|
145
|
+
// stale and re-upsert everything — silently.
|
|
146
|
+
const failures = [];
|
|
147
|
+
|
|
86
148
|
try {
|
|
87
|
-
// Try new metadata endpoint first
|
|
88
149
|
const result = await apiClient.invoke('kb_get_chunk_metadata', {
|
|
89
150
|
app_id: appId,
|
|
90
151
|
kb_id: kbId
|
|
91
152
|
});
|
|
92
153
|
// apiClient.invoke returns { status, message: { chunks, count, ... } }
|
|
93
|
-
const data = result
|
|
94
|
-
|
|
154
|
+
const data = result?.message || result;
|
|
155
|
+
// An empty ARRAY is the store reporting an empty KB — legal, and preserved.
|
|
156
|
+
// An ABSENT field is the store not reporting at all — not the same fact.
|
|
157
|
+
if (Array.isArray(data?.chunks)) return data.chunks;
|
|
158
|
+
failures.push("kb_get_chunk_metadata: response carried no 'chunks' array");
|
|
95
159
|
} catch (error) {
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
const result = await apiClient.invoke('kb_get_chunk_ids', {
|
|
99
|
-
app_id: appId,
|
|
100
|
-
kb_id: kbId
|
|
101
|
-
});
|
|
102
|
-
// apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
|
|
103
|
-
const data = result.message || result;
|
|
104
|
-
return data.chunk_ids || [];
|
|
105
|
-
} catch {
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
160
|
+
failures.push(`kb_get_chunk_metadata: ${error.message}`);
|
|
108
161
|
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const result = await apiClient.invoke('kb_get_chunk_ids', {
|
|
165
|
+
app_id: appId,
|
|
166
|
+
kb_id: kbId
|
|
167
|
+
});
|
|
168
|
+
// apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
|
|
169
|
+
const data = result?.message || result;
|
|
170
|
+
if (Array.isArray(data?.chunk_ids)) return data.chunk_ids;
|
|
171
|
+
failures.push("kb_get_chunk_ids: response carried no 'chunk_ids' array");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
failures.push(`kb_get_chunk_ids: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Cannot enumerate remote chunks for ${appId}/${kbId}: both endpoints failed to report. ` +
|
|
178
|
+
'The remote chunk set is UNKNOWN — it must NOT be treated as an empty KB, because ' +
|
|
179
|
+
'that would silently skip stale-chunk deletion and re-upsert everything. ' +
|
|
180
|
+
failures.join(' | ')
|
|
181
|
+
);
|
|
109
182
|
}
|
|
110
183
|
|
|
111
184
|
/**
|
|
@@ -129,7 +202,9 @@ export async function getRemoteChunkIds(apiClient, communityId, appId, kbId) {
|
|
|
129
202
|
* @param {string} appId - App ID
|
|
130
203
|
* @param {string} kbId - Knowledge base ID
|
|
131
204
|
* @param {Array} chunks - Chunk records to upsert
|
|
132
|
-
* @returns {Promise<{upserted: number}>}
|
|
205
|
+
* @returns {Promise<{upserted: number}>} `upserted` is the store's own count, or
|
|
206
|
+
* UNREPORTED_COUNT if ANY batch's store response carried no count.
|
|
207
|
+
* Test it with isReportedCount(); never print it unguarded.
|
|
133
208
|
*/
|
|
134
209
|
export async function upsertChunks(apiClient, communityId, appId, kbId, chunks) {
|
|
135
210
|
if (chunks.length === 0) {
|
|
@@ -147,7 +222,9 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
|
|
|
147
222
|
kb_id: kbId,
|
|
148
223
|
chunks: batch
|
|
149
224
|
});
|
|
150
|
-
|
|
225
|
+
// apiClient.invoke returns the whole { status, message: {...} } envelope.
|
|
226
|
+
const data = result?.message || result;
|
|
227
|
+
totalUpserted = accumulateReportedCount(totalUpserted, data?.upserted_count);
|
|
151
228
|
}
|
|
152
229
|
|
|
153
230
|
return { upserted: totalUpserted };
|
|
@@ -164,7 +241,9 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
|
|
|
164
241
|
* @param {string} appId - App ID
|
|
165
242
|
* @param {string} kbId - Knowledge base ID
|
|
166
243
|
* @param {Array<string>} chunkIds - Chunk IDs to delete
|
|
167
|
-
* @returns {Promise<{deleted: number}>}
|
|
244
|
+
* @returns {Promise<{deleted: number}>} `deleted` is the store's own count, or
|
|
245
|
+
* UNREPORTED_COUNT if the store reported none. It is NOT chunkIds.length.
|
|
246
|
+
* Test it with isReportedCount(); never print it unguarded.
|
|
168
247
|
*/
|
|
169
248
|
export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chunkIds) {
|
|
170
249
|
if (chunkIds.length === 0) {
|
|
@@ -179,7 +258,7 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
|
|
|
179
258
|
});
|
|
180
259
|
|
|
181
260
|
const data = result?.message || result;
|
|
182
|
-
return { deleted: data
|
|
261
|
+
return { deleted: reportedCount(data?.deleted_count) };
|
|
183
262
|
}
|
|
184
263
|
|
|
185
264
|
/**
|
|
@@ -198,7 +277,10 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
|
|
|
198
277
|
* @param {string} appId - App ID
|
|
199
278
|
* @param {string} kbId - Knowledge base ID
|
|
200
279
|
* @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}>}
|
|
280
|
+
* @returns {Promise<{deleted: number, deleted_by_file_id: number}>} Each field is the
|
|
281
|
+
* store's own count, or UNREPORTED_COUNT if the store reported that field.
|
|
282
|
+
* The two are INDEPENDENT — one may be reported and the other not. Neither
|
|
283
|
+
* is validFileIds.length. Test with isReportedCount(); never print unguarded.
|
|
202
284
|
*/
|
|
203
285
|
export async function deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, fileIds) {
|
|
204
286
|
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
|
@@ -220,8 +302,8 @@ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, k
|
|
|
220
302
|
|
|
221
303
|
const data = result?.message || result;
|
|
222
304
|
return {
|
|
223
|
-
deleted: data
|
|
224
|
-
deleted_by_file_id: data
|
|
305
|
+
deleted: reportedCount(data?.deleted_count),
|
|
306
|
+
deleted_by_file_id: reportedCount(data?.deleted_by_file_id)
|
|
225
307
|
};
|
|
226
308
|
}
|
|
227
309
|
|
|
@@ -253,7 +335,8 @@ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, k
|
|
|
253
335
|
* @param {string} kbId - Knowledge base ID
|
|
254
336
|
* @returns {Promise<{ deleted: number, purged_scope: boolean }>}
|
|
255
337
|
* `deleted` is the actual count of vectors purged (the batched path always
|
|
256
|
-
* reports it)
|
|
338
|
+
* reports it), or UNREPORTED_COUNT if the store reported no count.
|
|
339
|
+
* Test it with isReportedCount(); never print it unguarded.
|
|
257
340
|
*/
|
|
258
341
|
export async function purgeKbScope(apiClient, communityId, appId, kbId) {
|
|
259
342
|
const result = await apiClient.invoke('kb_delete_chunks', {
|
|
@@ -264,7 +347,7 @@ export async function purgeKbScope(apiClient, communityId, appId, kbId) {
|
|
|
264
347
|
});
|
|
265
348
|
const data = result?.message || result;
|
|
266
349
|
return {
|
|
267
|
-
deleted:
|
|
350
|
+
deleted: reportedCount(data?.deleted_count),
|
|
268
351
|
purged_scope: data?.purged_scope === true
|
|
269
352
|
};
|
|
270
353
|
}
|
|
@@ -286,10 +369,21 @@ export async function listRemoteFileIds(apiClient, appId, kbId) {
|
|
|
286
369
|
kb_id: kbId
|
|
287
370
|
});
|
|
288
371
|
const data = result?.message || result;
|
|
372
|
+
// Same rule as getRemoteChunkMetadata: an empty array is the store REPORTING an
|
|
373
|
+
// empty namespace; an ABSENT array is the store not reporting. This list is the
|
|
374
|
+
// drift input (stale = remote_file_ids - local), so fabricating [] here would
|
|
375
|
+
// silently mean "nothing is stale" — the failure this module exists to prevent.
|
|
376
|
+
if (!Array.isArray(data?.file_ids)) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Cannot enumerate remote file_ids for ${appId}/${kbId}: the store reported no ` +
|
|
379
|
+
`'file_ids' array. The remote file set is UNKNOWN and must NOT be treated as ` +
|
|
380
|
+
`empty — that would silently skip the stale purge.`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
289
383
|
return {
|
|
290
|
-
file_ids: data.file_ids
|
|
291
|
-
unique_count: data
|
|
292
|
-
total_chunks: data
|
|
384
|
+
file_ids: data.file_ids,
|
|
385
|
+
unique_count: reportedCount(data?.unique_count),
|
|
386
|
+
total_chunks: reportedCount(data?.total_chunks)
|
|
293
387
|
};
|
|
294
388
|
}
|
|
295
389
|
|
|
@@ -336,9 +430,9 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
336
430
|
if (onProgress) onProgress('Fetching existing chunks from Pinecone...');
|
|
337
431
|
const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
338
432
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
433
|
+
// getRemoteChunkMetadata returns an array or throws — it never reports an
|
|
434
|
+
// unknown remote set as an empty one, so there is no non-array case to default.
|
|
435
|
+
const remoteCount = remoteChunks.length;
|
|
342
436
|
|
|
343
437
|
if (verbose) {
|
|
344
438
|
console.log(` Local chunks: ${localChunks.length}`);
|
|
@@ -361,7 +455,10 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
361
455
|
deleted = deleteResult.deleted;
|
|
362
456
|
if (verbose) console.log(` Deleted ${deleted} stale chunks`);
|
|
363
457
|
} catch (error) {
|
|
364
|
-
|
|
458
|
+
// The delete ERRORED: how many were removed is unknown. Leaving `deleted` at 0
|
|
459
|
+
// would report "nothing was deleted" — a claim about the store we cannot make.
|
|
460
|
+
deleted = UNREPORTED_COUNT;
|
|
461
|
+
console.warn(` Delete failed (deleted count unknown): ${error.message}`);
|
|
365
462
|
}
|
|
366
463
|
}
|
|
367
464
|
|
|
@@ -385,12 +482,11 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
385
482
|
let deletedByFileId = 0;
|
|
386
483
|
try {
|
|
387
484
|
const localFileIds = new Set(localChunks.map(c => c.file_id).filter(Boolean));
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
const remoteFileIds = remoteData?.file_ids || [];
|
|
485
|
+
// Consume the module's own enumeration owner rather than re-invoking
|
|
486
|
+
// kb_list_file_ids and hand-unwrapping its payload: two derivations of one
|
|
487
|
+
// fact drift apart silently, and this copy would keep the `|| []` defect
|
|
488
|
+
// that the owner no longer has.
|
|
489
|
+
const { file_ids: remoteFileIds } = await listRemoteFileIds(apiClient, appId, kbId);
|
|
394
490
|
const staleFileIds = remoteFileIds.filter(fid => !localFileIds.has(fid));
|
|
395
491
|
if (staleFileIds.length > 0) {
|
|
396
492
|
if (onProgress) onProgress(`Purging ${staleFileIds.length} stale file_id(s)...`);
|
|
@@ -398,14 +494,18 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
398
494
|
deletedByFileId = purgeResult.deleted;
|
|
399
495
|
}
|
|
400
496
|
} catch (purgeErr) {
|
|
401
|
-
// Non-fatal:
|
|
402
|
-
//
|
|
403
|
-
if (verbose)
|
|
497
|
+
// Non-fatal: the chunk_id-based delete above is the primary path and this is a
|
|
498
|
+
// safety net. We do NOT silently swallow — the caller sees a warning. That claim
|
|
499
|
+
// was previously false: the warning sat behind `if (verbose)`, so a default run
|
|
500
|
+
// saw nothing. It is unconditional now, which is what makes the sentence true.
|
|
501
|
+
console.warn(` File-id purge skipped (stale chunks may remain): ${purgeErr.message}`);
|
|
404
502
|
}
|
|
405
503
|
|
|
406
504
|
return {
|
|
407
505
|
synced,
|
|
408
|
-
|
|
506
|
+
// Unknown-ness survives the sum: if either leg could not be reported, their
|
|
507
|
+
// total is not a number we can state. (-1 + 5 would silently read as 4.)
|
|
508
|
+
deleted: accumulateReportedCount(deleted, deletedByFileId),
|
|
409
509
|
deleted_chunk_ids: deleted,
|
|
410
510
|
deleted_by_file_id: deletedByFileId,
|
|
411
511
|
unchanged
|
|
@@ -433,7 +533,8 @@ export async function getSyncStatus(apiClient, config) {
|
|
|
433
533
|
|
|
434
534
|
// Get remote chunk metadata
|
|
435
535
|
const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
436
|
-
|
|
536
|
+
// Array-or-throw, as above: no fabricated zero for an unknown remote set.
|
|
537
|
+
const remoteCount = remoteChunks.length;
|
|
437
538
|
|
|
438
539
|
// Compute delta using content_hash
|
|
439
540
|
const { toUpsert, toDelete, unchanged } = computeChunkDelta(localChunks, remoteChunks);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE OWNER for the question "may I prompt?".
|
|
3
|
+
*
|
|
4
|
+
* Every prompt site in this CLI consumes this module. No site derives the answer itself, and
|
|
5
|
+
* there is no flag, env var or option that restores prompting when the environment cannot
|
|
6
|
+
* support it: the guard is a property of the ENVIRONMENT, not of the invocation. A flag would
|
|
7
|
+
* be a guard the caller must remember, and forgetting it reproduces the exact hang this module
|
|
8
|
+
* exists to remove.
|
|
9
|
+
*
|
|
10
|
+
* The rule is enforced by ENCAPSULATION, not by detection: you cannot obtain a prompt handle
|
|
11
|
+
* without passing the gate, because createPromptSession() is the only way to get one and it
|
|
12
|
+
* calls requireInteractive() before it constructs anything. A comment asking authors to check
|
|
13
|
+
* is not a guard — an author who does not read it ships the hang.
|
|
14
|
+
*
|
|
15
|
+
* Reference behaviour is gcloud: it refuses non-interactively rather than hanging or silently
|
|
16
|
+
* succeeding. Both failure modes were measured in this CLI before this module existed:
|
|
17
|
+
* - stdin an open pipe or a FIFO whose write end is held: readline waits forever. SIGKILL.
|
|
18
|
+
* - stdin /dev/null: readline closes, the question callback NEVER fires, the event loop
|
|
19
|
+
* drains and node exits 0 having written NOTHING. That is worse than the hang, because it
|
|
20
|
+
* is indistinguishable from success.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import readline from 'readline';
|
|
24
|
+
import chalk from 'chalk';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single derivation of "is there a human on the other end of stdin".
|
|
28
|
+
*
|
|
29
|
+
* stdin is the decisive stream: a prompt must READ. stdout being redirected does not stop a
|
|
30
|
+
* terminal user from answering, so it is deliberately not consulted.
|
|
31
|
+
*
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function stdinIsInteractive() {
|
|
35
|
+
return process.stdin.isTTY === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when a command needs to prompt and cannot. Carries the refusal text already formatted.
|
|
40
|
+
* Command actions in bin/descix.js catch Error, print `error.message` and exit 1, so throwing
|
|
41
|
+
* this produces a LOUD, NON-ZERO refusal — never a hang, never a silent exit 0.
|
|
42
|
+
*/
|
|
43
|
+
export class NonInteractiveError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'NonInteractiveError';
|
|
47
|
+
this.isNonInteractive = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the refusal text. Names what was being asked and how to get the job done without a
|
|
53
|
+
* prompt. It NEVER prescribes a destructive remedy: where the non-interactive form overwrites
|
|
54
|
+
* or deletes something, the caller is told so explicitly and the non-destructive option leads.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} o
|
|
57
|
+
* @param {string} o.what - the command, e.g. 'descix init'
|
|
58
|
+
* @param {string} [o.question] - the question that cannot be asked
|
|
59
|
+
* @param {string[]} [o.nonInteractiveForm] - concrete lines the caller can run instead
|
|
60
|
+
*/
|
|
61
|
+
function refusalText({ what, question, nonInteractiveForm, destructive }) {
|
|
62
|
+
// NO leading blank line and NO ❌ marker: command actions print `❌ ${error.message}`
|
|
63
|
+
// themselves. Emitting one here produced a doubled marker — the message OWNS the sentence,
|
|
64
|
+
// the caller OWNS the decoration.
|
|
65
|
+
const lines = [];
|
|
66
|
+
lines.push(`${what} cannot prompt during non-interactive execution.`);
|
|
67
|
+
lines.push(chalk.gray(' stdin is not a terminal, so there is no one to answer.'));
|
|
68
|
+
if (question) {
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push(chalk.gray(` It needed to ask: ${question}`));
|
|
71
|
+
}
|
|
72
|
+
lines.push('');
|
|
73
|
+
if (destructive) {
|
|
74
|
+
// The confirmation being refused guards an IRREVERSIBLE act. The safe reading LEADS, and we
|
|
75
|
+
// never hand back a copy-pasteable destructive command as if it were the obvious next step.
|
|
76
|
+
lines.push(chalk.yellow(' This was a confirmation for an IRREVERSIBLE operation.'));
|
|
77
|
+
lines.push(chalk.gray(' Nothing has been changed.'));
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
if (nonInteractiveForm && nonInteractiveForm.length > 0) {
|
|
81
|
+
lines.push(chalk.white(' Non-interactive form:'));
|
|
82
|
+
for (const line of nonInteractiveForm) lines.push(` ${line}`);
|
|
83
|
+
} else {
|
|
84
|
+
lines.push(chalk.white(` ${what} has no non-interactive form. Run it in a terminal.`));
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The gate. Call this before ANY prompt — readline, inquirer, or anything else.
|
|
92
|
+
* Returns silently when a human can answer; throws a loud NonInteractiveError when not.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} o - see refusalText
|
|
95
|
+
* @throws {NonInteractiveError}
|
|
96
|
+
*/
|
|
97
|
+
export function requireInteractive({ what, question, nonInteractiveForm, destructive } = {}) {
|
|
98
|
+
if (stdinIsInteractive()) return;
|
|
99
|
+
throw new NonInteractiveError(refusalText({ what, question, nonInteractiveForm, destructive }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ONLY way to obtain a prompt handle in this CLI.
|
|
104
|
+
*
|
|
105
|
+
* Gated at construction, so a site physically cannot reach readline without the check. Each
|
|
106
|
+
* ask() also re-states the question in the refusal if the session is used later, and every
|
|
107
|
+
* ask() rejects rather than hanging if stdin stops being a terminal mid-session.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} o
|
|
110
|
+
* @param {string} o.what - the command, e.g. 'descix init'
|
|
111
|
+
* @param {string[]} [o.nonInteractiveForm]
|
|
112
|
+
* @param {boolean} [o.terminal] - readline terminal option (airdrop needs true to mask input)
|
|
113
|
+
* @returns {{ask: Function, askYesNo: Function, close: Function}}
|
|
114
|
+
*/
|
|
115
|
+
export function createPromptSession({ what, nonInteractiveForm, terminal, destructive } = {}) {
|
|
116
|
+
requireInteractive({ what, nonInteractiveForm, destructive });
|
|
117
|
+
|
|
118
|
+
const rl = readline.createInterface({
|
|
119
|
+
input: process.stdin,
|
|
120
|
+
output: process.stdout,
|
|
121
|
+
...(terminal === undefined ? {} : { terminal })
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Ask for free text, with an optional default. */
|
|
125
|
+
const ask = (question, defaultValue = '') => {
|
|
126
|
+
requireInteractive({ what, question, nonInteractiveForm, destructive });
|
|
127
|
+
const suffix = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
rl.question(suffix, (answer) => resolve(answer.trim() || defaultValue));
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Ask a yes/no question with an explicit default. */
|
|
134
|
+
const askYesNo = (question, defaultYes = true) => {
|
|
135
|
+
requireInteractive({ what, question, nonInteractiveForm, destructive });
|
|
136
|
+
const suffix = defaultYes ? '(Y/n)' : '(y/N)';
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
rl.question(`${question} ${suffix}: `, (answer) => {
|
|
139
|
+
const trimmed = answer.trim().toLowerCase();
|
|
140
|
+
if (trimmed === '') return resolve(defaultYes);
|
|
141
|
+
resolve(trimmed === 'y' || trimmed === 'yes');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Ask with the prompt text EXACTLY as given — no added suffix. For sites that already spell
|
|
148
|
+
* their own "[y/N] " and must keep their wording verbatim.
|
|
149
|
+
*/
|
|
150
|
+
const askRaw = (question) => {
|
|
151
|
+
requireInteractive({ what, question, nonInteractiveForm, destructive });
|
|
152
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { ask, askYesNo, askRaw, close: () => rl.close() };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default { stdinIsInteractive, requireInteractive, createPromptSession, NonInteractiveError };
|
package/lib/origin.js
CHANGED
|
@@ -79,15 +79,16 @@ export const ORIGIN_SOURCE_LABELS = {
|
|
|
79
79
|
flagEnv: '--env flag',
|
|
80
80
|
envVar: 'DESCIX_API_URL environment variable',
|
|
81
81
|
workspaceEnvApiUrl: '.descix/workspace.json env.apiUrl',
|
|
82
|
-
legacyApiUrl: '.descix/workspace.json apiUrl (legacy)',
|
|
83
82
|
globalApiUrl: '~/.descix/config.json api_url',
|
|
84
83
|
};
|
|
85
84
|
|
|
86
85
|
/**
|
|
87
86
|
* The resolution order, most explicit first. The ORDER is the contract; this array is the only
|
|
88
87
|
* place it is written down. Labels come from ORIGIN_SOURCE_LABELS, never from a second literal.
|
|
88
|
+
* The workspace names its origin at `env.apiUrl` and nowhere else — a top-level `apiUrl` is a
|
|
89
|
+
* retired v1 key that `WorkspaceConfig.load()` refuses by name.
|
|
89
90
|
*/
|
|
90
|
-
const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', '
|
|
91
|
+
const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', 'globalApiUrl'];
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* What this invocation's FLAGS named, if anything: `{ origin, sourceKey }`.
|
|
@@ -181,7 +182,6 @@ function isUsableOrigin(value) {
|
|
|
181
182
|
* @param {object} sources
|
|
182
183
|
* @param {string|null} [sources.envVar] process.env.DESCIX_API_URL
|
|
183
184
|
* @param {string|null} [sources.workspaceEnvApiUrl] workspace.json env.apiUrl
|
|
184
|
-
* @param {string|null} [sources.legacyApiUrl] workspace.json top-level apiUrl
|
|
185
185
|
* @param {string|null} [sources.globalApiUrl] ~/.descix/config.json api_url
|
|
186
186
|
* @returns {{ origin: string, source: string, isDefault: boolean }} the origin AND which source
|
|
187
187
|
* supplied it — the source is returned because "where did this come from" is the
|
package/lib/wallet-file.js
CHANGED
|
@@ -68,30 +68,23 @@ export class WalletFileManager {
|
|
|
68
68
|
* @returns {Promise<boolean>} Success status
|
|
69
69
|
*/
|
|
70
70
|
static async saveWalletFile(walletPath, walletData) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (walletData.signature) {
|
|
80
|
-
walletData.API_KEY = walletData.signature;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Ensure directory exists
|
|
84
|
-
const dir = path.dirname(walletPath);
|
|
85
|
-
await fs.mkdir(dir, { recursive: true });
|
|
71
|
+
// THROWS on any failure, never returns false. Every caller used to await this and ignore the
|
|
72
|
+
// boolean, so `descix login` printed "Login successful!" and a "saved to" path for a file that
|
|
73
|
+
// was never written (measured on PROD 2026-09-16). A save that did not happen must stop the
|
|
74
|
+
// caller.
|
|
75
|
+
const problems = this.walletFileProblems(walletData);
|
|
76
|
+
if (problems.length > 0) {
|
|
77
|
+
throw new Error(`refusing to write ${walletPath}: ${problems.join('; ')}`);
|
|
78
|
+
}
|
|
86
79
|
|
|
87
|
-
|
|
88
|
-
|
|
80
|
+
// WS-HEADLESS-MVP-A1 §2.5 — keep the API_KEY alias mirrored to the canonical
|
|
81
|
+
// signature so the on-disk file self-documents the alias (alias-only rename).
|
|
82
|
+
walletData.API_KEY = walletData.signature;
|
|
89
83
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
84
|
+
await fs.mkdir(path.dirname(walletPath), { recursive: true });
|
|
85
|
+
// Write file with restricted permissions (600)
|
|
86
|
+
await fs.writeFile(walletPath, JSON.stringify(walletData, null, 2), { mode: 0o600 });
|
|
87
|
+
return true;
|
|
95
88
|
}
|
|
96
89
|
|
|
97
90
|
/**
|
|
@@ -100,25 +93,31 @@ export class WalletFileManager {
|
|
|
100
93
|
* @returns {boolean} True if valid
|
|
101
94
|
*/
|
|
102
95
|
static validateWalletFile(walletData) {
|
|
96
|
+
return this.walletFileProblems(walletData).length === 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every reason `walletData` is not a usable credential, named by FIELD, never by value — the
|
|
101
|
+
* messages are printed, and a signature is a credential. Empty array = valid.
|
|
102
|
+
* tokenSymbol and communityId are optional (may be absent in multi-community mode).
|
|
103
|
+
* @param {Object} walletData
|
|
104
|
+
* @returns {string[]}
|
|
105
|
+
*/
|
|
106
|
+
static walletFileProblems(walletData) {
|
|
103
107
|
if (!walletData || typeof walletData !== 'object') {
|
|
104
|
-
return
|
|
108
|
+
return ['the credential is not an object'];
|
|
105
109
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Note: tokenSymbol and communityId are optional (may be absent in multi-community mode)
|
|
109
|
-
const required = ['walletAddress', 'signature'];
|
|
110
|
-
for (const field of required) {
|
|
110
|
+
const problems = [];
|
|
111
|
+
for (const field of ['walletAddress', 'signature']) {
|
|
111
112
|
if (!walletData[field] || typeof walletData[field] !== 'string') {
|
|
112
|
-
|
|
113
|
+
problems.push(`${field} is empty`);
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return false;
|
|
116
|
+
if (typeof walletData.walletAddress === 'string' && walletData.walletAddress
|
|
117
|
+
&& !/^0x[a-fA-F0-9]{40}$/.test(walletData.walletAddress)) {
|
|
118
|
+
problems.push('walletAddress is not a 0x-prefixed 40-hex-digit address');
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
return true;
|
|
120
|
+
return problems;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
/**
|
|
@@ -237,8 +236,8 @@ export class WalletFileManager {
|
|
|
237
236
|
*/
|
|
238
237
|
static async createWalletFile(walletData, preferredPath = null) {
|
|
239
238
|
const walletPath = preferredPath || this.getDefaultWalletPath();
|
|
240
|
-
|
|
241
|
-
return
|
|
239
|
+
await this.saveWalletFile(walletPath, walletData);
|
|
240
|
+
return walletPath;
|
|
242
241
|
}
|
|
243
242
|
}
|
|
244
243
|
|