@kontextmind/kxm 0.7.167 → 0.7.168

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.
@@ -74,6 +74,15 @@ export function hubBindingFile(env: NodeJS.ProcessEnv = process.env): string {
74
74
  return join(resolveUserStateRoot(env), "hub-binding.json");
75
75
  }
76
76
 
77
+ /**
78
+ * Cloud token source for a machine-wide bind. Kept out of `hub-binding.json`
79
+ * so a 0.7.159 reader, which accepts only `schema`, `url`, and `boundAt`,
80
+ * does not throw and stop syncing.
81
+ */
82
+ export function hubBindingCloudFile(env: NodeJS.ProcessEnv = process.env): string {
83
+ return join(resolveUserStateRoot(env), "hub-binding.cloud.json");
84
+ }
85
+
77
86
  export function validateHubUrl(raw: string): string {
78
87
  let parsed: URL;
79
88
  try {
@@ -247,40 +256,50 @@ function isAbortError(error: unknown): boolean {
247
256
  );
248
257
  }
249
258
 
250
- export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined {
251
- const file = hubBindingFile(env);
252
- if (!existsSync(file)) return undefined;
259
+ const LEGACY_BINDING_KEYS = ["schema", "url", "boundAt"] as const;
260
+ const BINDING_KEYS = new Set<string>([...LEGACY_BINDING_KEYS, "cloud", "tokenEnv", "tokenCommand"]);
261
+
262
+ export interface HubBindingLoad {
263
+ record?: HubBindingRecord;
264
+ /**
265
+ * Set when this build ignores the file and continues against the local hub.
266
+ * A newer schema, or a cloud record an older process cannot read, must not
267
+ * throw into the Runtime sync loop.
268
+ */
269
+ warning?: string;
270
+ }
271
+
272
+ function bindingFallbackWarning(file: string, reason: string): string {
273
+ return `hub binding at ${file} ${reason}; falling back to the local hub. Restart the Runtime supervisor, the hub, and workers after upgrading and before a cloud bind.`;
274
+ }
275
+
276
+ function writeBindingJson(file: string, value: unknown): void {
277
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
278
+ const temporary = join(dirname(file), `.${file.endsWith("cloud.json") ? "hub-binding-cloud" : "hub-binding"}-${process.pid}.tmp`);
279
+ try {
280
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
281
+ renameSync(temporary, file);
282
+ } finally {
283
+ rmSync(temporary, { force: true });
284
+ }
285
+ }
286
+
287
+ function parseBindingObject(file: string): Record<string, unknown> | HubBindingError {
288
+ if (!existsSync(file)) return new HubBindingError(`missing hub binding at ${file}`);
253
289
  let parsed: unknown;
254
290
  try {
255
291
  parsed = JSON.parse(readFileSync(file, "utf8"));
256
292
  } catch {
257
- throw new HubBindingError(`malformed hub binding at ${file}`);
293
+ return new HubBindingError(`malformed hub binding at ${file}`);
258
294
  }
259
295
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
260
- throw new HubBindingError(`malformed hub binding at ${file}`);
261
- }
262
- const row = parsed as Record<string, unknown>;
263
- const keys = Object.keys(row);
264
- const allowed = new Set(["schema", "url", "boundAt", "cloud", "tokenEnv", "tokenCommand"]);
265
- const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand");
266
- if (
267
- keys.some((key) => !allowed.has(key))
268
- || (!cloudish && keys.length !== 3)
269
- || row.schema !== HUB_BINDING_SCHEMA
270
- || typeof row.url !== "string"
271
- || typeof row.boundAt !== "string"
272
- || !isIsoTimestamp(row.boundAt)
273
- ) {
274
- throw new HubBindingError(`malformed hub binding at ${file}`);
275
- }
276
- let url: string;
277
- try {
278
- url = validateHubUrl(row.url);
279
- } catch {
280
- throw new HubBindingError(`malformed hub binding at ${file}`);
296
+ return new HubBindingError(`malformed hub binding at ${file}`);
281
297
  }
282
- if (!cloudish) return { schema: HUB_BINDING_SCHEMA, url, boundAt: row.boundAt };
283
- if (row.cloud !== true) throw new HubBindingError(`malformed hub binding at ${file}`);
298
+ return parsed as Record<string, unknown>;
299
+ }
300
+
301
+ function cloudRecordFromRow(file: string, row: Record<string, unknown>, url: string, boundAt: string): HubBindingRecord | undefined {
302
+ if (row.cloud !== true) return undefined;
284
303
  let tokenEnv: string | undefined;
285
304
  let tokenCommand: string | undefined;
286
305
  if (row.tokenEnv !== undefined) {
@@ -302,34 +321,272 @@ export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBinding
302
321
  return {
303
322
  schema: HUB_BINDING_SCHEMA,
304
323
  url,
305
- boundAt: row.boundAt,
324
+ boundAt,
306
325
  cloud: true,
307
326
  ...(tokenEnv ? { tokenEnv } : {}),
308
327
  ...(tokenCommand ? { tokenCommand } : {}),
309
328
  };
310
329
  }
311
330
 
312
- export function writeHubBinding(record: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string {
331
+ /**
332
+ * The reader shipped in 0.7.159. It accepts only `schema`, `url`, and
333
+ * `boundAt`. Extra keys throw `malformed hub binding`, which stopped sync.
334
+ * New cloud binds keep this file in that shape, or omit it, so this reader
335
+ * falls through to the local hub instead of throwing.
336
+ */
337
+ export function readLegacyV1HubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined {
313
338
  const file = hubBindingFile(env);
314
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
315
- const temporary = join(dirname(file), `.hub-binding-${process.pid}.tmp`);
339
+ if (!existsSync(file)) return undefined;
340
+ const parsed = parseBindingObject(file);
341
+ if (parsed instanceof HubBindingError) throw parsed;
342
+ const keys = Object.keys(parsed);
343
+ if (
344
+ keys.length !== 3
345
+ || keys.some((key) => !LEGACY_BINDING_KEYS.includes(key as typeof LEGACY_BINDING_KEYS[number]))
346
+ || parsed.schema !== HUB_BINDING_SCHEMA
347
+ || typeof parsed.url !== "string"
348
+ || typeof parsed.boundAt !== "string"
349
+ || !isIsoTimestamp(parsed.boundAt)
350
+ ) {
351
+ throw new HubBindingError(`malformed hub binding at ${file}`);
352
+ }
353
+ let url: string;
316
354
  try {
317
- writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
318
- renameSync(temporary, file);
319
- } finally {
320
- rmSync(temporary, { force: true });
355
+ url = validateHubUrl(parsed.url);
356
+ } catch {
357
+ throw new HubBindingError(`malformed hub binding at ${file}`);
358
+ }
359
+ return { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt };
360
+ }
361
+
362
+ function interpretBindingFile(file: string): HubBindingLoad & { legacyCloud?: boolean } {
363
+ if (!existsSync(file)) return {};
364
+ const parsed = parseBindingObject(file);
365
+ if (parsed instanceof HubBindingError) throw parsed;
366
+ const keys = Object.keys(parsed);
367
+ const unknown = keys.filter((key) => !BINDING_KEYS.has(key));
368
+ if (parsed.schema !== HUB_BINDING_SCHEMA || unknown.length > 0) {
369
+ return { warning: bindingFallbackWarning(file, `is newer than this build understands (${String(parsed.schema)})`) };
370
+ }
371
+ if (typeof parsed.url !== "string" || typeof parsed.boundAt !== "string" || !isIsoTimestamp(parsed.boundAt)) {
372
+ throw new HubBindingError(`malformed hub binding at ${file}`);
373
+ }
374
+ let url: string;
375
+ try {
376
+ url = validateHubUrl(parsed.url);
377
+ } catch {
378
+ throw new HubBindingError(`malformed hub binding at ${file}`);
379
+ }
380
+ const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand");
381
+ if (!cloudish) {
382
+ if (keys.length !== 3) throw new HubBindingError(`malformed hub binding at ${file}`);
383
+ return { record: { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt } };
384
+ }
385
+ const record = cloudRecordFromRow(file, parsed, url, parsed.boundAt);
386
+ if (!record) throw new HubBindingError(`malformed hub binding at ${file}`);
387
+ return { record, legacyCloud: true };
388
+ }
389
+
390
+ /**
391
+ * Cloud metadata wins over a local `hub-binding.json`. A legacy cloud record
392
+ * that still lives in `hub-binding.json` is moved aside so a 0.7.159 process
393
+ * no longer throws on the next read.
394
+ */
395
+ export function loadHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingLoad {
396
+ const cloudFile = hubBindingCloudFile(env);
397
+ const cloud = interpretBindingFile(cloudFile);
398
+ if (cloud.warning && !cloud.record) {
399
+ const local = existsSync(hubBindingFile(env)) ? interpretBindingFile(hubBindingFile(env)) : {};
400
+ if (local.record && !local.record.cloud) return { record: local.record, warning: cloud.warning };
401
+ return { warning: cloud.warning };
402
+ }
403
+ if (cloud.record?.cloud) return { record: cloud.record, ...(cloud.warning ? { warning: cloud.warning } : {}) };
404
+ const main = interpretBindingFile(hubBindingFile(env));
405
+ if (main.legacyCloud && main.record) {
406
+ writeBindingJson(cloudFile, main.record);
407
+ rmSync(hubBindingFile(env), { force: true });
408
+ return {
409
+ record: main.record,
410
+ warning: bindingFallbackWarning(
411
+ hubBindingFile(env),
412
+ "stored a cloud bind in the pre-0.7.160 file",
413
+ ),
414
+ };
415
+ }
416
+ return { ...(main.record ? { record: main.record } : {}), ...(main.warning ? { warning: main.warning } : {}) };
417
+ }
418
+
419
+ export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined {
420
+ return loadHubBinding(env).record;
421
+ }
422
+
423
+ export function writeHubBinding(record: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string {
424
+ if (record.cloud) {
425
+ const file = hubBindingCloudFile(env);
426
+ writeBindingJson(file, record);
427
+ // A 0.7.159 reader throws on extra keys and stops sync. Leave a local
428
+ // three-key file alone so that reader keeps the local hub. Drop a file
429
+ // that points at this cloud URL, or that already carries cloud keys.
430
+ const main = hubBindingFile(env);
431
+ if (existsSync(main)) {
432
+ try {
433
+ const legacy = readLegacyV1HubBinding(env);
434
+ if (!legacy || legacy.url === record.url) rmSync(main, { force: true });
435
+ } catch (error) {
436
+ if (!(error instanceof HubBindingError)) throw error;
437
+ rmSync(main, { force: true });
438
+ }
439
+ }
440
+ return file;
321
441
  }
442
+ rmSync(hubBindingCloudFile(env), { force: true });
443
+ const file = hubBindingFile(env);
444
+ writeBindingJson(file, {
445
+ schema: HUB_BINDING_SCHEMA,
446
+ url: record.url,
447
+ boundAt: record.boundAt,
448
+ });
322
449
  return file;
323
450
  }
324
451
 
325
452
  export function removeHubBinding(env: NodeJS.ProcessEnv = process.env): boolean {
326
- const file = hubBindingFile(env);
453
+ const files = [hubBindingFile(env), hubBindingCloudFile(env)];
454
+ let removed = false;
455
+ for (const file of files) {
456
+ try {
457
+ rmSync(file);
458
+ removed = true;
459
+ } catch (error) {
460
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
461
+ }
462
+ }
463
+ return removed;
464
+ }
465
+
466
+ export type HubProbeKind = "hub" | "auth_proxy" | "redirect" | "not_hub" | "unreachable" | "timeout";
467
+
468
+ export interface HubProbe {
469
+ health: HubHealth;
470
+ probeMs: number;
471
+ kind: HubProbeKind;
472
+ detail: string;
473
+ status: number;
474
+ }
475
+
476
+ export interface HubEndpointVerdict {
477
+ ok: boolean;
478
+ status: number;
479
+ kind: HubProbeKind;
480
+ detail: string;
481
+ /** Hub JSON when the body matches. Never the raw HTML of a login page. */
482
+ body: unknown;
483
+ }
484
+
485
+ function contentTypeBase(header: string | null): string {
486
+ return (header ?? "").split(";")[0]!.trim().toLowerCase();
487
+ }
488
+
489
+ function looksLikeHtml(contentType: string, body: string): boolean {
490
+ if (contentType === "text/html" || contentType === "application/xhtml+xml") return true;
491
+ const sample = body.slice(0, 800).trim().toLowerCase();
492
+ return sample.startsWith("<!doctype html")
493
+ || sample.startsWith("<html")
494
+ || (sample.includes("<form") && (sample.includes("login") || sample.includes("authentik") || sample.includes("password")));
495
+ }
496
+
497
+ function jsonObject(text: string): Record<string, unknown> | undefined {
498
+ try {
499
+ const parsed = JSON.parse(text) as unknown;
500
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
501
+ return parsed as Record<string, unknown>;
502
+ } catch {
503
+ return undefined;
504
+ }
505
+ }
506
+
507
+ /** `/health` is `{ ok: true, agents: <number> }`. `/ready` adds `storage`. */
508
+ export function hubEndpointShape(role: "health" | "ready", body: Record<string, unknown>): boolean {
509
+ if (role === "health") return body.ok === true && typeof body.agents === "number";
510
+ return body.ok === true && (body.storage === "sqlite" || body.storage === "memory");
511
+ }
512
+
513
+ /**
514
+ * A kxm hub answers JSON with the documented shape and `application/json`.
515
+ * An auth proxy's 30x or HTML login page is not that, even on HTTP 200.
516
+ */
517
+ export function classifyHubResponse(input: {
518
+ role: "health" | "ready";
519
+ status: number;
520
+ contentType: string | null;
521
+ bodyText: string;
522
+ }): HubEndpointVerdict {
523
+ const contentType = contentTypeBase(input.contentType);
524
+ if (input.status >= 300 && input.status < 400) {
525
+ return {
526
+ ok: false,
527
+ status: input.status,
528
+ kind: "redirect",
529
+ detail: `HTTP ${input.status} redirect; this looks like an auth proxy or a redirect, not a kxm hub`,
530
+ body: { error: "auth_proxy", detail: `HTTP ${input.status} redirect` },
531
+ };
532
+ }
533
+ if (looksLikeHtml(contentType, input.bodyText)) {
534
+ return {
535
+ ok: false,
536
+ status: input.status,
537
+ kind: "auth_proxy",
538
+ detail: `HTTP ${input.status} returned an HTML login page${contentType ? ` (${contentType})` : ""}; this looks like an auth proxy, not a kxm hub`,
539
+ body: { error: "auth_proxy", detail: "HTML login page" },
540
+ };
541
+ }
542
+ const jsonType = contentType === "application/json" || contentType === "text/plain" || contentType === "";
543
+ const body = jsonType ? jsonObject(input.bodyText) : undefined;
544
+ if (body && hubEndpointShape(input.role, body) && input.status >= 200 && input.status < 300) {
545
+ if (contentType !== "" && contentType !== "application/json" && contentType !== "text/plain") {
546
+ return {
547
+ ok: false,
548
+ status: input.status,
549
+ kind: "not_hub",
550
+ detail: `HTTP ${input.status} content-type ${contentType} is not application/json`,
551
+ body: { error: "not_hub", detail: contentType },
552
+ };
553
+ }
554
+ return { ok: true, status: input.status, kind: "hub", detail: "kxm hub", body };
555
+ }
556
+ const typeNote = contentType && contentType !== "application/json" ? ` content-type ${contentType}` : "";
557
+ return {
558
+ ok: false,
559
+ status: input.status,
560
+ kind: "not_hub",
561
+ detail: `HTTP ${input.status}${typeNote} is not a kxm hub ${input.role} response`,
562
+ body: { error: "not_hub", detail: `HTTP ${input.status}${typeNote}` },
563
+ };
564
+ }
565
+
566
+ export async function readHubEndpoint(
567
+ url: string,
568
+ role: "health" | "ready",
569
+ fetchImpl: typeof fetch,
570
+ init: { headers?: Record<string, string>; signal?: AbortSignal } = {},
571
+ ): Promise<HubEndpointVerdict> {
327
572
  try {
328
- rmSync(file);
329
- return true;
573
+ const response = await fetchImpl(`${url}/${role}`, {
574
+ ...(init.headers ? { headers: init.headers } : {}),
575
+ ...(init.signal ? { signal: init.signal } : {}),
576
+ redirect: "manual",
577
+ });
578
+ const bodyText = await response.text();
579
+ return classifyHubResponse({
580
+ role,
581
+ status: response.status,
582
+ contentType: response.headers.get("content-type"),
583
+ bodyText,
584
+ });
330
585
  } catch (error) {
331
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
332
- throw error;
586
+ if (isAbortError(error)) {
587
+ return { ok: false, status: 0, kind: "timeout", detail: "no reply within the probe budget", body: { error: "hub_unreachable" } };
588
+ }
589
+ return { ok: false, status: 0, kind: "unreachable", detail: "nothing answered", body: { error: "hub_unreachable" } };
333
590
  }
334
591
  }
335
592
 
@@ -337,25 +594,14 @@ export async function probeHubHealth(
337
594
  url: string,
338
595
  fetchImpl: typeof fetch,
339
596
  timeoutMs = HUB_HEALTH_PROBE_MS,
340
- ): Promise<{ health: HubHealth; probeMs: number }> {
597
+ ): Promise<HubProbe> {
341
598
  const started = Date.now();
342
599
  const controller = new AbortController();
343
600
  const timer = setTimeout(() => controller.abort(), timeoutMs);
344
601
  try {
345
- const response = await fetchImpl(`${url}/health`, { signal: controller.signal });
346
- if (!response.ok) return { health: "unknown", probeMs: Date.now() - started };
347
- let body: unknown;
348
- try {
349
- body = await response.json();
350
- } catch {
351
- return { health: "unknown", probeMs: Date.now() - started };
352
- }
353
- if (body && typeof body === "object" && (body as { ok?: unknown }).ok === true) {
354
- return { health: "on", probeMs: Date.now() - started };
355
- }
356
- return { health: "unknown", probeMs: Date.now() - started };
357
- } catch (error) {
358
- return { health: isAbortError(error) ? "unknown" : "off", probeMs: Date.now() - started };
602
+ const verdict = await readHubEndpoint(url, "health", fetchImpl, { signal: controller.signal });
603
+ const health: HubHealth = verdict.kind === "hub" ? "on" : verdict.kind === "unreachable" ? "off" : "unknown";
604
+ return { health, probeMs: Date.now() - started, kind: verdict.kind, detail: verdict.detail, status: verdict.status };
359
605
  } finally {
360
606
  clearTimeout(timer);
361
607
  }
@@ -210,18 +210,26 @@ function keySourceFor(input: {
210
210
  hasSavedProjectToken?: (project: string) => boolean;
211
211
  }): string {
212
212
  const ref = input.endpoint?.key;
213
- if (input.mode === "local" && input.env.KXM_AUTH_TOKEN?.trim()) return "env:KXM_AUTH_TOKEN";
214
- if (ref?.env && input.env[ref.env]?.trim()) return `env:${ref.env}`;
215
- if (ref?.op) return `op:${ref.op}`;
216
- if (ref?.env) return `env:${ref.env}`;
213
+ const envName = (name: string | undefined): string | undefined => {
214
+ if (!name) return undefined;
215
+ return input.env[name]?.trim() ? `env:${name}` : undefined;
216
+ };
217
+ // Name the source that would actually supply a token. An unset variable is
218
+ // not that source, even when config still lists it.
217
219
  if (input.mode === "cloud") {
218
- if (input.binding?.tokenEnv) return `env:${input.binding.tokenEnv}`;
219
- if (input.binding?.tokenCommand) {
220
- const op = opReferenceFromTokenCommand(input.binding.tokenCommand);
221
- return op ? `op:${op}` : "command";
222
- }
220
+ const fromEnv = envName(ref?.env);
221
+ if (fromEnv) return fromEnv;
222
+ if (ref?.op) return `op:${ref.op}`;
223
+ const fromBindingEnv = envName(input.binding?.tokenEnv);
224
+ if (fromBindingEnv) return fromBindingEnv;
225
+ if (input.binding?.tokenCommand) return "token-command";
223
226
  return "missing";
224
227
  }
228
+ const fromAuth = envName("KXM_AUTH_TOKEN");
229
+ if (fromAuth) return fromAuth;
230
+ const fromEnv = envName(ref?.env);
231
+ if (fromEnv) return fromEnv;
232
+ if (ref?.op) return `op:${ref.op}`;
225
233
  if (input.hasSavedProjectToken?.(input.project)) return "hub-env";
226
234
  return "missing";
227
235
  }
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -43,6 +44,21 @@ export function kxmReleaseAssetName(version: string): string {
43
44
  return `kxm-${version}.tgz`;
44
45
  }
45
46
 
47
+ export function kxmReleaseDownloadUrl(version: string, name = kxmReleaseAssetName(version)): string {
48
+ return `https://github.com/kontextmind/kxm/releases/download/v${version}/${name}`;
49
+ }
50
+
51
+ /** `gh` is optional. Hosts without it download the same asset with curl and still verify the digest. */
52
+ export function ghReleaseDownloadAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
53
+ const result = spawnSync("gh", ["--version"], {
54
+ encoding: "utf8",
55
+ env,
56
+ windowsHide: true,
57
+ timeout: 15_000,
58
+ });
59
+ return !result.error && result.status === 0;
60
+ }
61
+
46
62
  export function readInstalledKxmVersion(root: string): string {
47
63
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: unknown };
48
64
  if (typeof pkg.version !== "string" || !parseSemver(pkg.version)) {
@@ -213,7 +229,7 @@ export async function fetchLatestKxmVersion(
213
229
  }
214
230
 
215
231
  export type KxmPackageUpdateStep =
216
- | { kind: "download"; command: "gh"; args: string[] }
232
+ | { kind: "download"; command: "gh" | "curl"; args: string[] }
217
233
  | { kind: "verify"; path: string; sha256: string }
218
234
  | { kind: "install"; command: "npm"; args: string[] };
219
235
 
@@ -233,17 +249,24 @@ export function planKxmPackageUpdate(
233
249
  latest: string,
234
250
  releaseDir: string,
235
251
  asset?: KxmReleaseAsset,
252
+ downloader: "gh" | "curl" = "gh",
236
253
  ): KxmPackageUpdateStep[] {
237
254
  if (source === "npm") {
238
255
  return [{ kind: "install", command: "npm", args: ["install", "--global", "--omit=peer", `@kontextmind/kxm@${latest}`] }];
239
256
  }
240
257
  const name = asset?.name ?? kxmReleaseAssetName(latest);
241
258
  const steps: KxmPackageUpdateStep[] = [
242
- {
243
- kind: "download",
244
- command: "gh",
245
- args: ["release", "download", `v${latest}`, "--repo", "kontextmind/kxm", "--pattern", name, "--dir", releaseDir, "--clobber"],
246
- },
259
+ downloader === "curl"
260
+ ? {
261
+ kind: "download",
262
+ command: "curl",
263
+ args: ["--fail", "--silent", "--show-error", "--location", "--output", join(releaseDir, name), kxmReleaseDownloadUrl(latest, name)],
264
+ }
265
+ : {
266
+ kind: "download",
267
+ command: "gh",
268
+ args: ["release", "download", `v${latest}`, "--repo", "kontextmind/kxm", "--pattern", name, "--dir", releaseDir, "--clobber"],
269
+ },
247
270
  ];
248
271
  if (asset?.sha256) {
249
272
  steps.push({ kind: "verify", path: join(releaseDir, name), sha256: asset.sha256 });
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.167";
14
+ const VERSION = "0.7.168";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -31,7 +31,7 @@ import { createKxmOneShotProducer } from "./oneshot-producer.ts";
31
31
  import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
32
32
  import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
33
33
  import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
34
- import { CloudTokenError, readHubBinding } from "./hub-binding.ts";
34
+ import { CloudTokenError, HubBindingError, loadHubBinding } from "./hub-binding.ts";
35
35
  import { resolveClientHubAuthToken } from "./hub-env.ts";
36
36
  import { createLogger } from "./logger.ts";
37
37
  import type { KxmOutboxRow, KxmOutboxStatus } from "./runtime-store.ts";
@@ -572,8 +572,21 @@ export async function syncKxmOutbox(
572
572
  }
573
573
 
574
574
  /** Where this Runtime reports one project, or nothing when no hub is bound. */
575
- function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv): RuntimeHubClient | undefined {
576
- const serverUrl = env.KXM_SERVER_URL?.trim() || readHubBinding(env)?.url;
575
+ function runtimeHubClientFor(
576
+ context: KxmRuntimeContext,
577
+ env: NodeJS.ProcessEnv,
578
+ onBindingWarning?: (message: string) => void,
579
+ ): RuntimeHubClient | undefined {
580
+ let recordUrl: string | undefined;
581
+ try {
582
+ const loaded = loadHubBinding(env);
583
+ if (loaded.warning) onBindingWarning?.(loaded.warning);
584
+ recordUrl = loaded.record?.url;
585
+ } catch (error) {
586
+ if (!(error instanceof HubBindingError)) throw error;
587
+ onBindingWarning?.(`${error.message}; falling back to the local hub so sync can continue`);
588
+ }
589
+ const serverUrl = env.KXM_SERVER_URL?.trim() || recordUrl;
577
590
  if (!serverUrl) return undefined;
578
591
  // Use the context's actual projectId — the same identity sync events carry — so the
579
592
  // ops snapshot can join runs to their home Runtime. The npm package name (what an
@@ -1271,6 +1284,7 @@ async function startKxmRuntimeSupervisorInner(
1271
1284
  };
1272
1285
 
1273
1286
  let syncing = false;
1287
+ let bindingWarning: string | undefined;
1274
1288
  const syncTimer = setInterval(() => {
1275
1289
  if (syncing || stopping) return;
1276
1290
  syncing = true;
@@ -1292,7 +1306,11 @@ async function startKxmRuntimeSupervisorInner(
1292
1306
  consecutiveFailures: 0,
1293
1307
  };
1294
1308
  try {
1295
- const client = runtimeHubClientFor(context, env);
1309
+ const client = runtimeHubClientFor(context, env, (message) => {
1310
+ if (bindingWarning === message) return;
1311
+ bindingWarning = message;
1312
+ logger.warn({ event: "hub_binding_fallback", message });
1313
+ });
1296
1314
  if (!client) {
1297
1315
  // Not a failure: a box with no bound hub simply keeps its rows.
1298
1316
  recordSyncStatus(context, { ...base, state: "no_hub" });
@@ -271,6 +271,36 @@ export async function runKxmNpmPublish(input) {
271
271
  };
272
272
  }
273
273
 
274
+ /**
275
+ * npm can accept a publish minutes before `npm view <pkg>@<version>` answers.
276
+ * Callers that report success should wait until this returns visible.
277
+ */
278
+ export function npmViewVersion(version, exec = execFile) {
279
+ return new Promise((resolve) => {
280
+ exec("npm", ["view", `@kontextmind/kxm@${version}`, "version"], { encoding: "utf8", timeout: 30_000 }, (error, stdout) => {
281
+ if (error) resolve("");
282
+ else resolve(String(stdout ?? "").trim());
283
+ });
284
+ });
285
+ }
286
+
287
+ export async function waitForNpmVersion(version, options = {}) {
288
+ const waitMs = Math.max(0, options.waitMs ?? 0);
289
+ const intervalMs = Math.max(250, options.intervalMs ?? 15_000);
290
+ const exec = options.npmView ?? ((value) => npmViewVersion(value));
291
+ const deadline = Date.now() + waitMs;
292
+ let last = "";
293
+ for (;;) {
294
+ const seen = String(await exec(version) ?? "").trim();
295
+ if (seen === version) return { visible: true, version };
296
+ last = seen || last;
297
+ if (Date.now() >= deadline) return { visible: false, version, detail: last };
298
+ const pause = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
299
+ if (pause === 0) return { visible: false, version, detail: last };
300
+ await new Promise((resolve) => setTimeout(resolve, pause));
301
+ }
302
+ }
303
+
274
304
  export async function main(env = process.env, stdout = process.stdout, stderr = process.stderr) {
275
305
  const token = env.GITHUB_TOKEN || env.GH_TOKEN;
276
306
  const npmToken = env.NODE_AUTH_TOKEN || env.NPM_TOKEN;
@@ -313,7 +343,23 @@ export async function main(env = process.env, stdout = process.stdout, stderr =
313
343
  stdout.write(`release ${tag} is currently a draft; waiting for publish (${sec}s remaining)...\n`);
314
344
  },
315
345
  });
316
- stdout.write(`${JSON.stringify(result)}\n`);
346
+ const version = String(tag).replace(/^v/, "");
347
+ const visibleWaitRaw = env.KXM_NPM_VISIBLE_WAIT_MS;
348
+ const visibleWaitMs = visibleWaitRaw == null || visibleWaitRaw === ""
349
+ ? 20 * 60 * 1000
350
+ : Math.max(0, Number(visibleWaitRaw) || 0);
351
+ const intervalRaw = env.KXM_NPM_VISIBLE_INTERVAL_MS;
352
+ const intervalMs = intervalRaw == null || intervalRaw === "" ? 15_000 : Math.max(250, Number(intervalRaw) || 15_000);
353
+ if (visibleWaitMs > 0) {
354
+ stdout.write(`npm publish submitted for @kontextmind/kxm@${version}; waiting until npm view shows it\n`);
355
+ const visible = await waitForNpmVersion(version, { waitMs: visibleWaitMs, intervalMs });
356
+ if (!visible.visible) {
357
+ stderr.write(`npm_not_visible: npm view @kontextmind/kxm@${version} version did not appear within ${visibleWaitMs}ms\n`);
358
+ return 1;
359
+ }
360
+ stdout.write(`npm view @kontextmind/kxm@${version} version is visible\n`);
361
+ }
362
+ stdout.write(`${JSON.stringify({ ...result, npmVisible: visibleWaitMs > 0 })}\n`);
317
363
  return 0;
318
364
  } catch (error) {
319
365
  const code = error instanceof KxmPublishNpmError ? error.code : "publish_failed";