@descix/cli 1.0.6 → 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.
@@ -27,7 +27,10 @@ import { WorkspaceConfig } from '../workspace-config.js';
27
27
  import { loadManifests } from '../core/ManifestLoader.js';
28
28
  import { walkCorpus } from '../core/CorpusWalker.js';
29
29
  import { chunkFile, categorizeFile } from '../core/Chunker.js';
30
- import { upsertChunks, deleteStaleChunks, deleteStaleChunksByFileId, listRemoteFileIds, purgeKbScope } from '../core/Syncer.js';
30
+ import {
31
+ upsertChunks, deleteStaleChunks, deleteStaleChunksByFileId, listRemoteFileIds, purgeKbScope,
32
+ isReportedCount, accumulateReportedCount, UNREPORTED_COUNT
33
+ } from '../core/Syncer.js';
31
34
  import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
32
35
 
33
36
  /**
@@ -151,6 +154,57 @@ async function chunkCorpusFile(fileEntry, context) {
151
154
  }).filter(Boolean);
152
155
  }
153
156
 
157
+ /**
158
+ * Read the previously-synced chunk total out of sync state, for REPORTING only.
159
+ *
160
+ * Three states must not be conflated:
161
+ * no sync state at all -> 0, a fact about US (we have never synced), not the store
162
+ * total_chunks is a number -> that number, INCLUDING a legitimate 0
163
+ * total_chunks null/absent -> null: UNKNOWN, and said so. It is never coerced to 0.
164
+ *
165
+ * The file delta is computed from blob SHAs, never from this number, so an unknown
166
+ * previous total blocks nothing. The total is re-established on every clean sync by the
167
+ * store's own live read-back (`liveTotalFromReconcile`), never by arithmetic on it.
168
+ *
169
+ * @param {Object|null} previousState - the loaded sync state, or null on first sync
170
+ * @returns {number|null}
171
+ */
172
+ export function readPreviousChunkCount(previousState) {
173
+ if (!previousState) return 0;
174
+ return Number.isFinite(previousState.total_chunks) ? previousState.total_chunks : null;
175
+ }
176
+
177
+ /**
178
+ * The chunk total to PERSIST: the store's MEASURED live count from the post-sync
179
+ * reconcile (`get_kb_rag_status {reconcile:true}` → `reconcileAfter`), or null when the
180
+ * store reported none. This is the ONE owner of `total_chunks`.
181
+ *
182
+ * Why not previous + upserted − deleted: the server's `kb_sync_chunks` answers
183
+ * `upserted_count: null` by ruling (Pinecone's upsert API returns no count) and publishes
184
+ * the measured `rag_vector_count` instead. An arithmetic total built on that null was null
185
+ * on every sync, including `--rebuild` (measured 2026-09-16: reconcile read 6882 live and
186
+ * the state still said unknown), so the "re-run with --rebuild" remedy was a loop.
187
+ *
188
+ * @param {Object|null|undefined} rec - the reconcile response's message
189
+ * @returns {number|null}
190
+ */
191
+ export function liveTotalFromReconcile(rec) {
192
+ return Number.isFinite(rec?.reconcileAfter) ? rec.reconcileAfter : null;
193
+ }
194
+
195
+ /**
196
+ * Render a count for display: the number when the store reported it, otherwise a
197
+ * phrase that says so. Consumes the module's count owner rather than re-testing
198
+ * `>= 0` at each call site.
199
+ *
200
+ * @param {number} value
201
+ * @param {string} unknownText
202
+ * @returns {string}
203
+ */
204
+ export function renderCount(value, unknownText = 'an unreported number of') {
205
+ return isReportedCount(value) ? String(value) : unknownText;
206
+ }
207
+
154
208
  /**
155
209
  * Save sync state after a successful corpus sync.
156
210
  *
@@ -268,15 +322,21 @@ function resolveRef(manifest, cliRef) {
268
322
  * @returns {Promise<boolean>}
269
323
  */
270
324
  async function confirmYesNo(prompt) {
271
- const readline = await import('readline');
272
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
273
- return new Promise(resolve => {
274
- rl.question(`${prompt} [y/N] `, answer => {
275
- rl.close();
276
- const a = (answer || '').trim().toLowerCase();
277
- resolve(a === 'y' || a === 'yes');
278
- });
325
+ // Gated at construction by the ONE owner of "may I prompt". Under a non-TTY this throws a
326
+ // loud NonInteractiveError instead of hanging (open pipe/FIFO) or resolving nothing (/dev/null).
327
+ const { createPromptSession } = await import('../interactive.js');
328
+ const rl = createPromptSession({
329
+ what: 'descix kb corpus',
330
+ destructive: true,
331
+ nonInteractiveForm: ['descix kb corpus ... --yes # skips this confirmation']
279
332
  });
333
+ try {
334
+ const answer = await rl.askRaw(`${prompt} [y/N] `);
335
+ const a = (answer || '').trim().toLowerCase();
336
+ return a === 'y' || a === 'yes';
337
+ } finally {
338
+ rl.close();
339
+ }
280
340
  }
281
341
 
282
342
  /**
@@ -455,9 +515,18 @@ export async function runCorpusSync(apiClient, options) {
455
515
 
456
516
  // 3a. Walk corpus
457
517
  const walkSpinner = ora(' Walking source directories...').start();
458
- const { files, commitSha, provenance } = await walkCorpus(manifest, workspaceRoot);
518
+ const { files, commitSha, provenance, gateBoundary } = await walkCorpus(manifest, workspaceRoot);
459
519
  walkSpinner.succeed(` Found ${files.length} files (commit: ${commitSha.substring(0, 8)})`);
460
520
 
521
+ // A gate prints its own COVERAGE BOUNDARY with its verdict — on GREEN as well as
522
+ // RED — so the reader of a pass can see where the pass stops without hunting for a
523
+ // report elsewhere. Every file above cleared the source gate.
524
+ console.log(chalk.gray(` [source-gate] PASS — all ${files.length} file(s) tracked by git at their manifest ref.`));
525
+ console.log(chalk.gray(` [source-gate] compares: ${gateBoundary.compares}`));
526
+ console.log(chalk.gray(` [source-gate] catches: ${gateBoundary.catches}`));
527
+ console.log(chalk.gray(` [source-gate] does NOT read: ${gateBoundary.does_not_read}`));
528
+ console.log(chalk.gray(` [source-gate] runs automatically: ${gateBoundary.runs_automatically}`));
529
+
461
530
  // ── Deliverable A: --show-walk prints the resolved ref + first 50 files
462
531
  // BEFORE any Pinecone read/write so the operator can sanity-check the
463
532
  // walk results without trusting downstream output.
@@ -507,9 +576,13 @@ export async function runCorpusSync(apiClient, options) {
507
576
  const remote = await listRemoteFileIds(apiClient, appId, kbName);
508
577
  rebuildRemoteUniqueCount = remote.unique_count;
509
578
  rebuildRemoteTotalChunks = remote.total_chunks;
579
+ // `Pinecone reports N` is an ATTRIBUTION to the store, so it may only carry
580
+ // numbers the store actually reported. The file_ids list is always real
581
+ // (listRemoteFileIds throws rather than fabricating it); the counts may not be.
510
582
  rebuildSpinner.succeed(
511
- ` [rebuild] Pinecone reports ${remote.unique_count} corpus-scheme file_id(s) / ` +
512
- `${remote.total_chunks} enumerable chunk(s) (legacy orphans may exceed this)`
583
+ ` [rebuild] Pinecone enumerated ${remote.file_ids.length} corpus-scheme file_id(s); ` +
584
+ `reports ${renderCount(remote.unique_count)} unique / ` +
585
+ `${renderCount(remote.total_chunks)} enumerable chunk(s) (legacy orphans may exceed this)`
513
586
  );
514
587
 
515
588
  if (options.dryRun) {
@@ -521,9 +594,13 @@ export async function runCorpusSync(apiClient, options) {
521
594
  ));
522
595
  } else {
523
596
  // Interactive confirmation guard. --yes skips for scripting/CI.
597
+ // A destructive-action prompt must not overstate its own certainty: if the
598
+ // store reported no count, say so rather than printing "≥-1".
524
599
  console.log(chalk.yellow(
525
600
  `\n About to FULL-SCOPE PURGE every vector in ${communityId}/${appId}/${kbName} ` +
526
- `(≥${rebuildRemoteTotalChunks} enumerable chunk(s); legacy orphans included), ` +
601
+ (isReportedCount(rebuildRemoteTotalChunks)
602
+ ? `(≥${rebuildRemoteTotalChunks} enumerable chunk(s); legacy orphans included), `
603
+ : `(the store reported no chunk count; ALL vectors in scope will go), `) +
527
604
  `then re-upsert the full local walk (${files.length} file(s)).`
528
605
  ));
529
606
 
@@ -544,9 +621,9 @@ export async function runCorpusSync(apiClient, options) {
544
621
  rebuildScopePurged = purgeResult.purged_scope;
545
622
  // deleteRAG via filter-based deleteMany returns -1 (count not knowable
546
623
  // from the SDK). Surface honestly rather than fabricating a number.
547
- rebuildPurged = purgeResult.deleted >= 0 ? purgeResult.deleted : 0;
624
+ rebuildPurged = purgeResult.deleted;
548
625
  purgeSpinner.succeed(
549
- purgeResult.deleted >= 0
626
+ isReportedCount(purgeResult.deleted)
550
627
  ? ` [rebuild] Purged ${purgeResult.deleted} vector(s) (full scope) and reset KB doc rag fields`
551
628
  : ` [rebuild] Purged full scope (filter-based delete; SDK reports no count) and reset KB doc rag fields`
552
629
  );
@@ -574,10 +651,10 @@ export async function runCorpusSync(apiClient, options) {
574
651
  previousState = null;
575
652
  }
576
653
  const previousBlobShas = new Set(previousState?.synced_blob_shas || []);
577
- const previousChunkCount = previousState?.total_chunks || 0;
654
+ const previousChunkCount = readPreviousChunkCount(previousState);
578
655
 
579
656
  if (previousBlobShas.size > 0) {
580
- console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount} chunks`));
657
+ console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount ?? 'an unknown number of'} chunks`));
581
658
  } else {
582
659
  console.log(chalk.gray(' First sync (no previous state)'));
583
660
  }
@@ -640,7 +717,12 @@ export async function runCorpusSync(apiClient, options) {
640
717
  console.log(chalk.white(` Would upsert: ${allChunks.length} chunks from ${newOrChangedFiles.length} file(s)`));
641
718
  console.log(chalk.white(` Would delete (stale-file): ${fileIdsToDelete.length} blob SHA(s)`));
642
719
  if (options.rebuild) {
643
- console.log(chalk.white(` Would purge (rebuild): FULL scope ${communityId}/${appId}/${kbName} (≥${rebuildRemoteTotalChunks} enumerable chunks; legacy orphans included)`));
720
+ console.log(chalk.white(
721
+ ` Would purge (rebuild): FULL scope ${communityId}/${appId}/${kbName} ` +
722
+ (isReportedCount(rebuildRemoteTotalChunks)
723
+ ? `(≥${rebuildRemoteTotalChunks} enumerable chunks; legacy orphans included)`
724
+ : `(the store reported no chunk count; all vectors in scope)`)
725
+ ));
644
726
  }
645
727
  // Track for the summary exit-code computation at the end of the loop.
646
728
  totalFilesProcessed += newOrChangedFiles.length;
@@ -648,7 +730,10 @@ export async function runCorpusSync(apiClient, options) {
648
730
  totalFilesSkipped += unchangedCount;
649
731
  // Full-scope rebuild would purge ≥ the enumerable chunk count. Use it as the
650
732
  // would-delete tally so dry-run reports drift whenever a purge would run.
651
- totalFilesDeleted += fileIdsToDelete.length + (options.rebuild ? rebuildRemoteTotalChunks : 0);
733
+ // Only add a rebuild figure the store actually reported; an unknown count
734
+ // contributes nothing rather than silently subtracting one from the total.
735
+ totalFilesDeleted += fileIdsToDelete.length +
736
+ ((options.rebuild && isReportedCount(rebuildRemoteTotalChunks)) ? rebuildRemoteTotalChunks : 0);
652
737
  // Do NOT call upsertChunks, deleteStaleChunksByFileId, deleteStaleChunks,
653
738
  // or saveSyncState in dry-run. Continue to next manifest.
654
739
  continue;
@@ -675,7 +760,9 @@ export async function runCorpusSync(apiClient, options) {
675
760
  while (!success && retries < MAX_RETRIES) {
676
761
  try {
677
762
  const result = await upsertChunks(apiClient, communityId, appId, kbName, batch);
678
- upserted += result.upserted;
763
+ // Unknown-ness survives the running total: one unreportable batch makes
764
+ // the whole figure unstateable rather than quietly summing around it.
765
+ upserted = accumulateReportedCount(upserted, result.upserted);
679
766
  success = true;
680
767
  syncSpinner.text = ` Upserting chunks... ${i + batch.length}/${allChunks.length}`;
681
768
  } catch (err) {
@@ -715,7 +802,7 @@ export async function runCorpusSync(apiClient, options) {
715
802
  if (!success) {
716
803
  syncSpinner.warn(
717
804
  ` Batch ${i}-${i + batch.length} did not complete after ${MAX_RETRIES} retries. ` +
718
- `Synced ${upserted} chunks so far. This run is BOUNDED (it did not hang) — ` +
805
+ `Synced ${renderCount(upserted)} chunks so far. This run is BOUNDED (it did not hang) — ` +
719
806
  `re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' to RESUME: id-keyed upserts ` +
720
807
  `are idempotent so already-synced batches are cheap and the remainder continues.`
721
808
  );
@@ -728,7 +815,7 @@ export async function runCorpusSync(apiClient, options) {
728
815
  }
729
816
  }
730
817
 
731
- syncSpinner.succeed(` Upserted: ${upserted} chunks`);
818
+ syncSpinner.succeed(` Upserted: ${renderCount(upserted)} chunks`);
732
819
 
733
820
  // Write failure log to .descix if any batches failed
734
821
  if (syncFailures.length > 0) {
@@ -752,59 +839,76 @@ export async function runCorpusSync(apiClient, options) {
752
839
  // (community_id, app_id, knowledgebase_name) — guarantees namespace isolation.
753
840
  const result = await deleteStaleChunksByFileId(apiClient, communityId, appId, kbName, fileIdsToDelete);
754
841
  deleted = result.deleted;
755
- deleteSpinner.succeed(` Purged: ${deleted} chunk(s) across ${fileIdsToDelete.length} stale blob SHA(s)`);
842
+ deleteSpinner.succeed(
843
+ ` Purged: ${renderCount(deleted)} chunk(s) across ${fileIdsToDelete.length} stale blob SHA(s)`
844
+ );
756
845
  } catch (err) {
757
- deleteSpinner.warn(` Delete failed: ${err.message}`);
846
+ // The purge ERRORED: how many were removed is unknown. Leaving `deleted` at 0
847
+ // would persist "nothing was deleted" into sync state as though it were a fact.
848
+ deleted = UNREPORTED_COUNT;
849
+ deleteSpinner.warn(` Delete failed (deleted count unknown): ${err.message}`);
758
850
  }
759
851
  }
760
852
 
761
- // 3f. Save sync state with blob SHAs for future delta computation
762
- const allSyncedBlobShas = [...localBlobShas]; // All current files
763
- await saveSyncState(appRoot, kbName, {
764
- last_sync_commit: commitSha,
765
- // Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
766
- // synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
767
- // synced — the resolved sha is, and it stays exact after the branch moves.
768
- sources_provenance: provenance,
769
- synced_files_count: files.length,
770
- total_chunks: previousChunkCount + upserted - deleted,
771
- timestamp: new Date().toISOString(),
772
- files_upserted: newOrChangedFiles.length,
773
- files_unchanged: unchangedCount,
774
- chunks_upserted: upserted,
775
- chunks_deleted: deleted,
776
- synced_blob_shas: allSyncedBlobShas
777
- });
778
-
779
- // 3g. AUTO-RECONCILE the cached rag_vector_count to the TRUE live Pinecone
853
+ // 3f. AUTO-RECONCILE the cached rag_vector_count to the TRUE live Pinecone
780
854
  // count after a COMPLETED, clean sync (no batch failures). This keeps the
781
855
  // doctor's fast cached read truthful after every clean run, so drift only
782
856
  // ever appears mid-flight (on interruption). Skipped if any batch failed
783
857
  // (the run is partial — reconciling would bake in the partial state and the
784
858
  // re-run will reconcile once complete). Non-fatal: a reconcile hiccup must
785
- // not fail an otherwise-successful sync.
859
+ // not fail an otherwise-successful sync — but then the persisted total is
860
+ // null, honestly, because nothing measured it.
861
+ let liveTotal = null;
786
862
  if (syncFailures.length === 0) {
787
863
  try {
788
864
  const recRes = await apiClient.invoke('get_kb_rag_status', {
789
865
  app_id: appId, kb_id: kbName, reconcile: true
790
866
  }, { allowGuest: false });
791
867
  const rec = recRes.message || recRes;
792
- if (rec && typeof rec.reconcileAfter === 'number') {
868
+ liveTotal = liveTotalFromReconcile(rec);
869
+ if (liveTotal !== null) {
793
870
  const d = rec.reconcileAfter - rec.reconcileBefore;
794
871
  console.log(chalk.gray(
795
872
  ` ⟳ Reconciled live count: cached ${rec.reconcileBefore} → live ${rec.reconcileAfter}` +
796
873
  (d === 0 ? ' (already truthful)' : ` (${d >= 0 ? '+' : ''}${d})`)
797
874
  ));
875
+ } else {
876
+ console.log(chalk.yellow(' ⚠ Reconcile reported no live count; total_chunks stays unknown'));
798
877
  }
799
878
  } catch (recErr) {
800
879
  console.log(chalk.yellow(` ⚠ Auto-reconcile skipped (non-fatal): ${recErr.message}`));
801
880
  }
802
881
  }
803
882
 
883
+ // 3g. Save sync state with blob SHAs for future delta computation
884
+ const allSyncedBlobShas = [...localBlobShas]; // All current files
885
+ await saveSyncState(appRoot, kbName, {
886
+ last_sync_commit: commitSha,
887
+ // Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
888
+ // synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
889
+ // synced — the resolved sha is, and it stays exact after the branch moves.
890
+ sources_provenance: provenance,
891
+ synced_files_count: files.length,
892
+ // The store's measured live count, or null — never a fabricated integer: this file
893
+ // is read by later runs, so a wrong number here outlives the console line.
894
+ total_chunks: liveTotal,
895
+ timestamp: new Date().toISOString(),
896
+ files_upserted: newOrChangedFiles.length,
897
+ files_unchanged: unchangedCount,
898
+ chunks_upserted: isReportedCount(upserted) ? upserted : null,
899
+ chunks_deleted: isReportedCount(deleted) ? deleted : null,
900
+ synced_blob_shas: allSyncedBlobShas
901
+ });
902
+
804
903
  totalFilesProcessed += newOrChangedFiles.length;
805
- totalChunksCreated += upserted;
806
904
  totalFilesSkipped += unchangedCount;
807
- totalFilesDeleted += deleted + rebuildPurged;
905
+ // A run total is summed by the SAME owner the per-batch total uses (:754), so an
906
+ // unreported leg leaves the total unstateable instead of quietly summing around it.
907
+ // Contributing 0 keeps -1 out of the printed total by fabricating a number in its
908
+ // place — the same untruth, told where nobody can see it.
909
+ totalChunksCreated = accumulateReportedCount(totalChunksCreated, upserted);
910
+ totalFilesDeleted = accumulateReportedCount(totalFilesDeleted, deleted);
911
+ totalFilesDeleted = accumulateReportedCount(totalFilesDeleted, rebuildPurged);
808
912
  }
809
913
 
810
914
  // Summary
@@ -827,13 +931,12 @@ export async function runCorpusSync(apiClient, options) {
827
931
 
828
932
  console.log(chalk.green('\nCorpus sync complete:'));
829
933
  console.log(chalk.white(` Files synced: ${totalFilesProcessed}`));
830
- console.log(chalk.white(` Chunks created: ${totalChunksCreated}`));
934
+ console.log(chalk.white(` Chunks created: ${renderCount(totalChunksCreated)}`));
831
935
  console.log(chalk.white(` Files unchanged: ${totalFilesSkipped}`));
832
- if (totalFilesDeleted > 0) {
833
- console.log(chalk.white(` Chunks deleted: ${totalFilesDeleted}`));
834
- } else {
835
- console.log(chalk.gray(` Chunks deleted: 0`));
836
- }
936
+ // Only a zero the store actually REPORTED is dimmed as a no-op; an unreported total
937
+ // is stated as unknown in full white, never collapsed into the "nothing happened" line.
938
+ const deletedLine = ` Chunks deleted: ${renderCount(totalFilesDeleted)}`;
939
+ console.log(totalFilesDeleted === 0 ? chalk.gray(deletedLine) : chalk.white(deletedLine));
837
940
  console.log('');
838
941
  return { dryRun: false };
839
942
 
@@ -917,7 +1020,11 @@ export async function runCorpusStatus(apiClient, options) {
917
1020
  console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
918
1021
  console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
919
1022
  console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
920
- console.log(chalk.white(` Chunks in store: ${syncState.total_chunks}`));
1023
+ console.log(chalk.white(
1024
+ ` Chunks in store: ${Number.isFinite(syncState.total_chunks)
1025
+ ? syncState.total_chunks
1026
+ : 'unknown (the last sync reported no live count)'}`
1027
+ ));
921
1028
 
922
1029
  // Quick change detection: walk and compare blob SHAs
923
1030
  if (options.verbose) {
@@ -3,7 +3,12 @@
3
3
  *
4
4
  * WS-CLI-HEALTH-ENV-PIVOT (2026-05-26):
5
5
  * `descix health --env <env>` pivots the probe surface based on env:
6
- * - dev (default): existing local-port checks (UNCHANGED behavior)
6
+ * - dev (default): BOUND (lsof) AND SERVES (a route probe) per service, plus a gateway
7
+ * row from env.gateway.port. A bound port is not a health check: measured
8
+ * 2026-09-16, a port answering 500 to every request reported PASS, and the
9
+ * gateway had no row. A microservice's health path is read from its OWN served
10
+ * manifest (`GET /manifest` → service.healthEndpoint — cloud declares
11
+ * /api/health, powch /health), never assumed.
7
12
  * - demo: gcloud probes against demo cloud resources + synthetic HTTPS
8
13
  * probes against *.demo.descix.net hosts
9
14
  * - prod: gcloud probes against prod cloud resources + synthetic HTTPS
@@ -28,6 +33,7 @@
28
33
  import chalk from 'chalk';
29
34
  import { exec } from 'child_process';
30
35
  import { promisify } from 'util';
36
+ import { localUpstreamOrigin, resolveGatewayPort } from '@descix/app-sdk/dev';
31
37
  import https from 'https';
32
38
  import http from 'http';
33
39
  import { WorkspaceConfig } from '../workspace-config.js';
@@ -67,17 +73,22 @@ async function defaultExec(cmd) {
67
73
  }
68
74
 
69
75
  /**
70
- * Default HTTPS probe. Resolves with { status, error? } — never rejects.
71
- * Tests replace this with a mock.
76
+ * Default HTTP(S) probe. Resolves with { status, body, error? } — never rejects.
77
+ * `insecure` tolerates the workspace's self-signed dev certs and is set ONLY by the DEV
78
+ * path; cloud probes keep TLS validation. Tests replace this with a mock.
72
79
  */
73
- function defaultHttpsProbe(url, timeoutMs = 8000) {
80
+ function defaultHttpsProbe(url, timeoutMs = 8000, { insecure = false } = {}) {
74
81
  return new Promise((resolve) => {
75
82
  try {
76
83
  const parsed = new URL(url);
77
84
  const client = parsed.protocol === 'http:' ? http : https;
78
- const req = client.get(url, { timeout: timeoutMs }, (res) => {
79
- res.resume();
80
- resolve({ status: res.statusCode });
85
+ const opts = { timeout: timeoutMs };
86
+ if (insecure && parsed.protocol === 'https:') opts.rejectUnauthorized = false;
87
+ const req = client.get(url, opts, (res) => {
88
+ let body = '';
89
+ res.setEncoding('utf8');
90
+ res.on('data', (chunk) => { body += chunk; });
91
+ res.on('end', () => resolve({ status: res.statusCode, body }));
81
92
  });
82
93
  req.on('error', (err) => resolve({ status: 0, error: err.message }));
83
94
  req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });
@@ -90,29 +101,34 @@ function defaultHttpsProbe(url, timeoutMs = 8000) {
90
101
  // ─── DEV path (UNCHANGED behavior) ───────────────────────────────────────────
91
102
 
92
103
  /**
93
- * Build the service registry from workspace.json for DEV port checks.
94
- * Returns an array of { name, appId, port, type, protocol? }.
104
+ * Build the service registry from workspace.json for DEV checks.
105
+ * Returns an array of { name, appId, port, type, origin }. `origin` — scheme included — comes
106
+ * from the ONE owner of a local upstream's origin (@descix/app-sdk localOrigin), never a
107
+ * protocol default re-derived here. The gateway is a row too: it is the process a local platform
108
+ * developer most needs to see, and it used to be structurally invisible to this command.
95
109
  */
96
110
  function buildServiceRegistry(wsConfig) {
97
111
  const services = [];
98
112
  const env = wsConfig.env || {};
99
113
 
100
114
  if (env.platform) {
115
+ const name = env.platform.appId || 'platform';
101
116
  if (env.platform.microservice?.port) {
102
117
  services.push({
103
- name: env.platform.appId || 'platform',
118
+ name,
104
119
  appId: env.platform.appId,
105
120
  port: env.platform.microservice.port,
106
121
  type: 'microservice',
122
+ origin: localUpstreamOrigin(env.platform.microservice, 'env.platform.microservice'),
107
123
  });
108
124
  }
109
125
  if (env.platform.site?.port) {
110
126
  services.push({
111
- name: `${env.platform.appId || 'platform'}-site`,
127
+ name: `${name}-site`,
112
128
  appId: env.platform.appId,
113
129
  port: env.platform.site.port,
114
130
  type: 'site',
115
- protocol: env.platform.site.protocol || 'https',
131
+ origin: localUpstreamOrigin(env.platform.site, 'env.platform.site'),
116
132
  });
117
133
  }
118
134
  }
@@ -124,6 +140,7 @@ function buildServiceRegistry(wsConfig) {
124
140
  appId: product.appId,
125
141
  port: product.microservice.port,
126
142
  type: 'microservice',
143
+ origin: localUpstreamOrigin(product.microservice, `env.products[${product.appId}].microservice`),
127
144
  });
128
145
  }
129
146
  if (product.site?.port) {
@@ -132,14 +149,82 @@ function buildServiceRegistry(wsConfig) {
132
149
  appId: product.appId,
133
150
  port: product.site.port,
134
151
  type: 'site',
135
- protocol: product.site.protocol || 'https',
152
+ origin: localUpstreamOrigin(product.site, `env.products[${product.appId}].site`),
136
153
  });
137
154
  }
138
155
  }
139
156
 
157
+ // The gateway — port from its ONE owner (resolveGatewayPort: --port > env.gateway.port >
158
+ // the SDK default), origin from the same owner as every other row.
159
+ const { port: gatewayPort } = resolveGatewayPort(wsConfig);
160
+ services.push({
161
+ name: 'gateway',
162
+ appId: null,
163
+ port: gatewayPort,
164
+ type: 'gateway',
165
+ origin: localUpstreamOrigin({ port: gatewayPort }, 'env.gateway'),
166
+ });
167
+
140
168
  return services;
141
169
  }
142
170
 
171
+ /** What a DEV run reads, and what it does not — printed on green AND red, and carried in JSON. */
172
+ const DEV_PROBE_BOUNDARY =
173
+ 'Probes: bound = lsof on the port; serves = microservices: GET /manifest then GET its ' +
174
+ 'service.healthEndpoint (2xx); sites: GET / (2xx); gateway: GET /__descix/app-binding.json ' +
175
+ '(a JSON binding, not HTML). Not probed: authentication, any command, credits, the mesh ' +
176
+ 'registration, or the deployed cloud — use --env demo|prod for that.';
177
+
178
+ /** A JSON body, or null when it is not one (a vite dev server answers 200 HTML for any path). */
179
+ function parseJsonBody(body) {
180
+ try {
181
+ const v = JSON.parse(body);
182
+ return v && typeof v === 'object' ? v : null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ const is2xx = (status) => typeof status === 'number' && status >= 200 && status < 300;
189
+
190
+ /**
191
+ * DEV mode: does the bound process actually SERVE? Resolves { serves, probe } where `probe` says
192
+ * exactly what was asked and what came back, so a FAIL line names its own reason.
193
+ */
194
+ async function checkServesDev(svc, httpsProbeFn) {
195
+ const opts = { insecure: true };
196
+ try {
197
+ if (svc.type === 'microservice') {
198
+ const m = await httpsProbeFn(`${svc.origin}/manifest`, 8000, opts);
199
+ if (!is2xx(m.status)) {
200
+ return { serves: false, probe: `GET /manifest -> ${m.status || m.error || 'unreachable'}` };
201
+ }
202
+ const manifest = parseJsonBody(m.body);
203
+ const healthEndpoint = manifest?.service?.healthEndpoint;
204
+ if (typeof healthEndpoint !== 'string' || healthEndpoint === '') {
205
+ return { serves: false, probe: 'GET /manifest -> 200 but it names no service.healthEndpoint' };
206
+ }
207
+ const h = await httpsProbeFn(`${svc.origin}${healthEndpoint}`, 8000, opts);
208
+ return { serves: is2xx(h.status), probe: `GET ${healthEndpoint} -> ${h.status || h.error || 'unreachable'}` };
209
+ }
210
+ if (svc.type === 'gateway') {
211
+ const b = await httpsProbeFn(`${svc.origin}/__descix/app-binding.json`, 8000, opts);
212
+ const binding = is2xx(b.status) ? parseJsonBody(b.body) : null;
213
+ const isBinding = !!binding && typeof binding.mode === 'string';
214
+ return {
215
+ serves: isBinding,
216
+ probe: `GET /__descix/app-binding.json -> ${b.status || b.error || 'unreachable'}` +
217
+ (is2xx(b.status) && !isBinding ? ' (not a binding: something else is on this port)' : ''),
218
+ };
219
+ }
220
+ // site
221
+ const s = await httpsProbeFn(`${svc.origin}/`, 8000, opts);
222
+ return { serves: is2xx(s.status) && !!s.body, probe: `GET / -> ${s.status || s.error || 'unreachable'}` };
223
+ } catch (err) {
224
+ return { serves: false, probe: `probe threw: ${err.message}` };
225
+ }
226
+ }
227
+
143
228
  /**
144
229
  * DEV mode: check if a local port is listening (lsof).
145
230
  */
@@ -402,16 +487,16 @@ export async function runHealth(options = {}) {
402
487
  }
403
488
 
404
489
  if (env === 'dev') {
405
- return runHealthDev(wsConfig, filterName, jsonOutput, execFn);
490
+ return runHealthDev(wsConfig, filterName, jsonOutput, execFn, httpsProbeFn);
406
491
  }
407
492
 
408
493
  return runHealthCloud(env, wsConfig, filterName, jsonOutput, execFn, httpsProbeFn);
409
494
  }
410
495
 
411
496
  /**
412
- * DEV path — unchanged from pre-pivot behavior.
497
+ * DEV path — bound AND serves, per service, plus the gateway.
413
498
  */
414
- async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
499
+ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn, httpsProbeFn) {
415
500
  let services = buildServiceRegistry(wsConfig);
416
501
 
417
502
  if (filterName) {
@@ -432,13 +517,20 @@ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
432
517
  const results = [];
433
518
  const startTime = Date.now();
434
519
  for (const svc of services) {
435
- const check = await checkPortDev(svc.port, execFn);
520
+ const bound = (await checkPortDev(svc.port, execFn)).healthy;
521
+ const { serves, probe } = bound
522
+ ? await checkServesDev(svc, httpsProbeFn)
523
+ : { serves: false, probe: 'not bound' };
436
524
  results.push({
437
525
  service: svc.name,
438
526
  appId: svc.appId,
439
527
  type: svc.type,
440
528
  port: svc.port,
441
- ...check,
529
+ bound,
530
+ serves,
531
+ serves_probe: probe,
532
+ healthy: bound && serves,
533
+ method: 'port+route',
442
534
  });
443
535
  }
444
536
  const totalTime = Date.now() - startTime;
@@ -447,7 +539,8 @@ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
447
539
  environment: 'dev',
448
540
  timestamp: new Date().toISOString(),
449
541
  check_duration_ms: totalTime,
450
- probe_surface: 'local-port',
542
+ probe_surface: 'local-port+route',
543
+ probe_boundary: DEV_PROBE_BOUNDARY,
451
544
  services: results,
452
545
  all_healthy: results.every(r => r.healthy),
453
546
  summary: {
@@ -466,10 +559,12 @@ async function runHealthDev(wsConfig, filterName, jsonOutput, execFn) {
466
559
  console.log(chalk.cyan('='.repeat(50)));
467
560
  console.log(chalk.gray(` Timestamp: ${output.timestamp}`));
468
561
  console.log(chalk.gray(` Check duration: ${output.check_duration_ms}ms`));
469
- console.log(chalk.gray(` Probe surface: local-port (lsof)\n`));
562
+ console.log(chalk.gray(` Probe surface: local-port+route`));
563
+ console.log(chalk.gray(` ${DEV_PROBE_BOUNDARY}\n`));
470
564
 
471
565
  for (const r of results) {
472
- const probeDetail = `${r.type}, port:${r.port}`;
566
+ const yn = (v) => (v ? 'yes' : 'no');
567
+ const probeDetail = `${r.type}, port:${r.port}, bound:${yn(r.bound)}, serves:${yn(r.serves)}, ${r.serves_probe}`;
473
568
  console.log(formatProbeLine(r.service, 'dev', r.healthy, probeDetail));
474
569
  }
475
570