@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +18 -0
- package/docs/contributing/ci-and-release.md +6 -1
- package/docs/operations/runtime-sync.md +2 -1
- package/docs/operations/troubleshooting.md +4 -0
- package/docs/operations/upgrade.md +18 -3
- package/docs/operations.md +70 -7
- package/docs/reference/cli-reference.md +21 -6
- package/docs/reference/config-reference.md +4 -1
- package/docs/start/install.md +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +170 -36
- package/plugins/kxm/dist/cli.js +332 -129
- package/plugins/kxm/dist/extension.js +184 -53
- package/plugins/kxm/dist/mcp-server.js +97 -40
- package/plugins/kxm/dist/runtime-supervisor.js +113 -42
- package/plugins/kxm/dist/runtime.js +113 -42
- package/plugins/kxm/dist/server.js +2 -0
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +4 -2
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -4
- package/plugins/kxm/src/cli/hub.ts +42 -7
- package/plugins/kxm/src/cli/system.ts +3 -1
- package/plugins/kxm/src/cli/types.ts +5 -3
- package/plugins/kxm/src/cli.ts +2 -1
- package/plugins/kxm/src/hub-binding.ts +301 -55
- package/plugins/kxm/src/hub-identity.ts +17 -9
- package/plugins/kxm/src/kxm-update.ts +29 -6
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-supervisor.ts +22 -4
- package/scripts/kxm-publish-npm.mjs +47 -1
|
@@ -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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
293
|
+
return new HubBindingError(`malformed hub binding at ${file}`);
|
|
258
294
|
}
|
|
259
295
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
260
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
324
|
+
boundAt,
|
|
306
325
|
cloud: true,
|
|
307
326
|
...(tokenEnv ? { tokenEnv } : {}),
|
|
308
327
|
...(tokenCommand ? { tokenCommand } : {}),
|
|
309
328
|
};
|
|
310
329
|
}
|
|
311
330
|
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
const
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
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
|
|
332
|
-
|
|
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<
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
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,
|
|
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(
|
|
576
|
-
|
|
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
|
-
|
|
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";
|