@kontextmind/kxm 0.7.165 → 0.7.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/docs/README.md +1 -0
  4. package/docs/architecture/hosted-direction.md +9 -4
  5. package/docs/architecture/inventory.md +1 -0
  6. package/docs/concepts/trust-model.md +1 -1
  7. package/docs/contributing/decisions.md +153 -0
  8. package/docs/contributing/learnings.md +54 -53
  9. package/docs/contributing/operating-rules.md +19 -6
  10. package/docs/glossary.md +2 -2
  11. package/docs/operations/troubleshooting.md +2 -2
  12. package/docs/operations.md +62 -8
  13. package/docs/reference/cli-reference.md +11 -11
  14. package/docs/reference/config-reference.md +36 -11
  15. package/docs/reference/configuration.md +2 -2
  16. package/docs/reference/tools.md +1 -1
  17. package/docs/start/quickstart-claude-code.md +2 -2
  18. package/package.json +1 -1
  19. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  20. package/plugins/kxm/README.md +3 -3
  21. package/plugins/kxm/dist/claude-hook.js +417 -66
  22. package/plugins/kxm/dist/cli.js +1399 -900
  23. package/plugins/kxm/dist/extension.js +399 -64
  24. package/plugins/kxm/dist/mcp-server.js +622 -135
  25. package/plugins/kxm/dist/runtime-supervisor.js +668 -166
  26. package/plugins/kxm/dist/runtime.js +713 -211
  27. package/plugins/kxm/dist/server.js +466 -58
  28. package/plugins/kxm/package.json +1 -1
  29. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +15 -7
  30. package/plugins/kxm/src/cli/hub.ts +80 -21
  31. package/plugins/kxm/src/cli/types.ts +8 -1
  32. package/plugins/kxm/src/cli.ts +41 -7
  33. package/plugins/kxm/src/config.ts +246 -3
  34. package/plugins/kxm/src/extension.ts +8 -7
  35. package/plugins/kxm/src/hub-autostart.ts +20 -0
  36. package/plugins/kxm/src/hub-env.ts +83 -31
  37. package/plugins/kxm/src/hub-identity.ts +337 -0
  38. package/plugins/kxm/src/mcp-server.ts +17 -15
  39. package/plugins/kxm/src/project-name.ts +6 -47
  40. package/plugins/kxm/src/server.ts +22 -4
  41. package/scripts/kxm-hub.mjs +5 -0
@@ -0,0 +1,337 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import {
6
+ type HubEndpointConfig,
7
+ type HubKeyRef,
8
+ type KxmHubMode,
9
+ isHubOpReference,
10
+ loadHubSettings,
11
+ } from "./config.ts";
12
+ import {
13
+ CloudTokenError,
14
+ type HubBindingRecord,
15
+ type HubBindingScope,
16
+ HubBindingError,
17
+ hubBindingScope,
18
+ readHubBinding,
19
+ splitTokenCommand,
20
+ validateHubUrl,
21
+ } from "./hub-binding.ts";
22
+
23
+ /** Where the project id presented to the hub came from. */
24
+ export type ProjectIdentitySource = "flag" | "env" | "config" | "project.yaml" | "package.json" | "directory";
25
+
26
+ export interface ProjectIdentity {
27
+ project: string;
28
+ source: ProjectIdentitySource;
29
+ /** Safe to print. Names the file or variable, never a secret. */
30
+ sourceLabel: string;
31
+ }
32
+
33
+ export type OpReader = (reference: string, env: NodeJS.ProcessEnv) => string;
34
+
35
+ export interface ResolvedKey {
36
+ value: string;
37
+ /** `env:<name>` or `op:<op://...>`. Never the token. */
38
+ source: string;
39
+ }
40
+
41
+ export interface HubConnection {
42
+ mode: KxmHubMode;
43
+ modeSource: "config" | "binding" | "default";
44
+ url: string;
45
+ urlSource: "env" | "config" | "binding" | "default";
46
+ scope: HubBindingScope;
47
+ endpoint?: HubEndpointConfig;
48
+ binding?: HubBindingRecord;
49
+ /** Source that would supply the key. Never the key. */
50
+ keySource: string;
51
+ }
52
+
53
+ const OP_TIMEOUT_MS = 15_000;
54
+ const OP_MAX_BUFFER = 4096;
55
+
56
+ function normalizeUrl(raw: string): string | undefined {
57
+ try {
58
+ return validateHubUrl(raw);
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function projectYamlId(start: string): string | undefined {
65
+ let dir = resolve(start);
66
+ for (;;) {
67
+ const file = join(dir, ".kxm", "project.yaml");
68
+ if (existsSync(file)) {
69
+ try {
70
+ const doc = parseYaml(readFileSync(file, "utf8")) as { id?: unknown } | null;
71
+ if (doc && typeof doc === "object" && !Array.isArray(doc) && typeof doc.id === "string" && doc.id.trim()) {
72
+ return doc.id.trim();
73
+ }
74
+ } catch {
75
+ // Malformed YAML falls through to package.json of the original directory.
76
+ }
77
+ return undefined;
78
+ }
79
+ const parent = dirname(dir);
80
+ if (parent === dir) return undefined;
81
+ dir = parent;
82
+ }
83
+ }
84
+
85
+ function packageName(cwd: string): string | undefined {
86
+ try {
87
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
88
+ if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
89
+ } catch {
90
+ // No readable package.json: fall through to the directory name.
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function activeBinding(env: NodeJS.ProcessEnv): HubBindingRecord | undefined {
96
+ try {
97
+ return readHubBinding(env);
98
+ } catch (error) {
99
+ if (error instanceof HubBindingError) return undefined;
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ function endpointFor(mode: KxmHubMode, settings: ReturnType<typeof loadHubSettings>): HubEndpointConfig | undefined {
105
+ return mode === "cloud" ? settings.cloud : settings.local;
106
+ }
107
+
108
+ /**
109
+ * Project id presented to the hub.
110
+ *
111
+ * Order: flag, then `KXM_PROJECT`, then the active mode's `project` in
112
+ * `.kxm/config.yaml`, then the `id` in the nearest `.kxm/project.yaml`, then
113
+ * the workspace `package.json` name, then the directory name.
114
+ */
115
+ export function resolveProjectIdentity(
116
+ cwd: string,
117
+ env: NodeJS.ProcessEnv = process.env,
118
+ explicit?: string,
119
+ ): ProjectIdentity {
120
+ const fromExplicit = explicit?.trim();
121
+ if (fromExplicit) return { project: fromExplicit, source: "flag", sourceLabel: "--project" };
122
+ const fromEnv = env.KXM_PROJECT?.trim();
123
+ if (fromEnv) return { project: fromEnv, source: "env", sourceLabel: "KXM_PROJECT" };
124
+ const settings = loadHubSettings(cwd, env);
125
+ const mode = settings.mode ?? (activeBinding(env)?.cloud ? "cloud" : "local");
126
+ const fromConfig = endpointFor(mode, settings)?.project?.trim();
127
+ if (fromConfig) {
128
+ return { project: fromConfig, source: "config", sourceLabel: `.kxm/config.yaml hub.${mode}.project` };
129
+ }
130
+ const fromProject = projectYamlId(cwd);
131
+ if (fromProject) return { project: fromProject, source: "project.yaml", sourceLabel: ".kxm/project.yaml id" };
132
+ const fromPackage = packageName(cwd);
133
+ if (fromPackage) return { project: fromPackage, source: "package.json", sourceLabel: "package.json name" };
134
+ return { project: basename(cwd), source: "directory", sourceLabel: "directory name" };
135
+ }
136
+
137
+ /** Same order as {@link resolveProjectIdentity}. One function, string form. */
138
+ export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env, explicit?: string): string {
139
+ return resolveProjectIdentity(cwd, env, explicit).project;
140
+ }
141
+
142
+ export function opReferenceFromTokenCommand(command: string): string | undefined {
143
+ try {
144
+ const args = splitTokenCommand(command);
145
+ if (args[0] === "op" && args[1] === "read" && args[2] && isHubOpReference(args[2])) return args[2];
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ function defaultOpRead(reference: string, env: NodeJS.ProcessEnv): string {
153
+ if (!isHubOpReference(reference)) {
154
+ throw new CloudTokenError("cloud_token_command_invalid", `refusing to resolve ${reference}; an op:// reference is required`);
155
+ }
156
+ const result = spawnSync("op", ["read", reference], {
157
+ encoding: "utf8",
158
+ shell: false,
159
+ timeout: OP_TIMEOUT_MS,
160
+ maxBuffer: OP_MAX_BUFFER,
161
+ env,
162
+ windowsHide: true,
163
+ });
164
+ if (result.error || result.status !== 0) {
165
+ throw new CloudTokenError(
166
+ "cloud_token_command_failed",
167
+ `op read failed for ${reference}; the token was not saved`,
168
+ );
169
+ }
170
+ const line = (result.stdout ?? "").trim();
171
+ if (!line || line.split(/\r?\n/).length !== 1) {
172
+ throw new CloudTokenError(
173
+ "cloud_token_command_failed",
174
+ `op read did not print a single token for ${reference}; the token was not saved`,
175
+ );
176
+ }
177
+ return line;
178
+ }
179
+
180
+ /**
181
+ * Resolve a key reference. A non-empty env var wins; otherwise `op read` runs.
182
+ * The value is returned to the caller and never written.
183
+ */
184
+ export function resolveKeyReference(
185
+ ref: HubKeyRef | undefined,
186
+ env: NodeJS.ProcessEnv,
187
+ opRead: OpReader = defaultOpRead,
188
+ ): ResolvedKey | undefined {
189
+ if (!ref) return undefined;
190
+ if (ref.env) {
191
+ const value = env[ref.env]?.trim();
192
+ if (value) return { value, source: `env:${ref.env}` };
193
+ }
194
+ if (ref.op) {
195
+ const value = opRead(ref.op, env).trim();
196
+ if (!value || value.split(/\r?\n/).length !== 1) {
197
+ throw new CloudTokenError("cloud_token_command_failed", `op read did not print a single token for ${ref.op}`);
198
+ }
199
+ return { value, source: `op:${ref.op}` };
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ function keySourceFor(input: {
205
+ mode: KxmHubMode;
206
+ env: NodeJS.ProcessEnv;
207
+ endpoint?: HubEndpointConfig;
208
+ binding?: HubBindingRecord;
209
+ project: string;
210
+ hasSavedProjectToken?: (project: string) => boolean;
211
+ }): string {
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}`;
217
+ 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
+ }
223
+ return "missing";
224
+ }
225
+ if (input.hasSavedProjectToken?.(input.project)) return "hub-env";
226
+ return "missing";
227
+ }
228
+
229
+ /**
230
+ * Mode, URL, and key source for this checkout. The key source names an env var
231
+ * or an `op://` reference. It is never the token.
232
+ */
233
+ export function describeHubConnection(
234
+ cwd: string,
235
+ env: NodeJS.ProcessEnv = process.env,
236
+ hooks: { hasSavedProjectToken?: (project: string) => boolean; explicitProject?: string } = {},
237
+ ): HubConnection & ProjectIdentity {
238
+ const settings = loadHubSettings(cwd, env);
239
+ const binding = activeBinding(env);
240
+ const mode: KxmHubMode = settings.mode ?? (binding?.cloud ? "cloud" : "local");
241
+ const modeSource: HubConnection["modeSource"] = settings.mode ? "config" : binding?.cloud ? "binding" : "default";
242
+ const endpoint = endpointFor(mode, settings);
243
+ const fromEnv = env.KXM_SERVER_URL?.trim();
244
+ const fromConfig = endpoint?.url?.trim();
245
+ const fromBinding = binding?.url;
246
+ let url = "http://127.0.0.1:7331";
247
+ let urlSource: HubConnection["urlSource"] = "default";
248
+ if (fromEnv) {
249
+ url = fromEnv.replace(/\/$/, "");
250
+ urlSource = "env";
251
+ } else if (fromConfig) {
252
+ url = fromConfig.replace(/\/$/, "");
253
+ urlSource = "config";
254
+ } else if (fromBinding) {
255
+ url = fromBinding;
256
+ urlSource = "binding";
257
+ }
258
+ const identity = resolveProjectIdentity(cwd, env, hooks.explicitProject);
259
+ const configuredCloudUrl = settings.cloud?.url ?? (binding?.cloud ? binding.url : undefined);
260
+ let scope: HubBindingScope;
261
+ if (mode === "cloud") {
262
+ const override = fromEnv ? normalizeUrl(fromEnv) : undefined;
263
+ const named = configuredCloudUrl ? normalizeUrl(configuredCloudUrl) : undefined;
264
+ scope = override && named && override !== named ? hubBindingScope(url) : "remote";
265
+ } else {
266
+ scope = hubBindingScope(url);
267
+ }
268
+ return {
269
+ ...identity,
270
+ mode,
271
+ modeSource,
272
+ url,
273
+ urlSource,
274
+ scope,
275
+ ...(endpoint ? { endpoint } : {}),
276
+ ...(binding ? { binding } : {}),
277
+ keySource: keySourceFor({
278
+ mode,
279
+ env,
280
+ ...(endpoint ? { endpoint } : {}),
281
+ ...(binding ? { binding } : {}),
282
+ project: identity.project,
283
+ ...(hooks.hasSavedProjectToken ? { hasSavedProjectToken: hooks.hasSavedProjectToken } : {}),
284
+ }),
285
+ };
286
+ }
287
+
288
+ export function projectTokenMissingMessage(identity: ProjectIdentity): string {
289
+ return [
290
+ `kxm has no project token for project ${identity.project} (from ${identity.sourceLabel}).`,
291
+ `Ask the user to set this project's token. Set KXM_AUTH_TOKEN, or set hub.local.project or hub.cloud.project in .kxm/config.yaml to the project key the hub already knows, or add a key reference under hub.projects.${identity.project} (key.op is an op:// reference, key.env is an environment variable name, never the token) and restart the hub.`,
292
+ "KXM_PROJECT or --project overrides the id for one command.",
293
+ "An agent never uses the hub admin token.",
294
+ ].join(" ");
295
+ }
296
+
297
+ export interface HubProjectTokenSelection {
298
+ tokens: Record<string, string> | undefined;
299
+ source: "env" | "config" | "file" | "none";
300
+ }
301
+
302
+ /** `KXM_PROJECT_TOKENS` replaces config, which replaces the saved file map. */
303
+ export function selectHubProjectTokenMap(input: {
304
+ explicit: Record<string, string> | undefined;
305
+ configured: Record<string, string> | undefined;
306
+ file: Record<string, string> | undefined;
307
+ }): HubProjectTokenSelection {
308
+ if (input.explicit) return { tokens: input.explicit, source: "env" };
309
+ if (input.configured && Object.keys(input.configured).length > 0) return { tokens: input.configured, source: "config" };
310
+ if (input.file && Object.keys(input.file).length > 0) return { tokens: input.file, source: "file" };
311
+ return { tokens: undefined, source: "none" };
312
+ }
313
+
314
+ /**
315
+ * Resolve `hub.projects` key references. Returns undefined when the map is
316
+ * absent. Does not write the resolved tokens.
317
+ */
318
+ export function resolveConfiguredHubProjectTokens(
319
+ cwd: string,
320
+ env: NodeJS.ProcessEnv = process.env,
321
+ opRead: OpReader = defaultOpRead,
322
+ ): Record<string, string> | undefined {
323
+ const projects = loadHubSettings(cwd, env).projects;
324
+ if (!projects || Object.keys(projects).length === 0) return undefined;
325
+ const tokens: Record<string, string> = {};
326
+ for (const [project, ref] of Object.entries(projects)) {
327
+ const resolved = resolveKeyReference(ref, env, opRead);
328
+ if (!resolved?.value) {
329
+ throw new CloudTokenError(
330
+ "cloud_token_missing",
331
+ `hub.projects.${project} did not resolve; set ${ref.env ?? "the named variable"} or fix the op:// reference. The token was not saved`,
332
+ );
333
+ }
334
+ tokens[project] = resolved.value;
335
+ }
336
+ return tokens;
337
+ }
@@ -4,15 +4,14 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { HubClient, HubHttpError } from "./client.ts";
7
- import { resolveAgentHubAuthToken } from "./hub-env.ts";
8
- import { resolveHubServerUrl } from "./hub-binding.ts";
9
- import { defaultProjectName } from "./project-name.ts";
7
+ import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
8
+ import { describeHubConnection, resolveProjectIdentity } from "./hub-identity.ts";
10
9
  import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
11
10
  import { deliverInboxNotification } from "./inbox.ts";
12
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
13
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
14
13
 
15
- const VERSION = "0.7.165";
14
+ const VERSION = "0.7.167";
16
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
17
16
  const inbox = new Map<string, MessageRecord>();
18
17
  const notifiedInbox = new Set<string>();
@@ -47,12 +46,15 @@ function asRecord(value: unknown): Record<string, unknown> {
47
46
  }
48
47
 
49
48
  /** Where this session works and which hub it talks to, read from the launch environment. */
50
- function sessionIdentity(): { projectDir: string; project: string; serverUrl: string } {
49
+ function sessionIdentity(): { projectDir: string; project: string; projectSource: string; sourceLabel: string; serverUrl: string } {
51
50
  const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
51
+ const connection = describeHubConnection(projectDir, process.env);
52
52
  return {
53
53
  projectDir,
54
- project: defaultProjectName(projectDir, process.env),
55
- serverUrl: resolveHubServerUrl(process.env),
54
+ project: connection.project,
55
+ projectSource: connection.source,
56
+ sourceLabel: connection.sourceLabel,
57
+ serverUrl: connection.url,
56
58
  };
57
59
  }
58
60
 
@@ -136,15 +138,13 @@ async function ensureClient(): Promise<HubClient> {
136
138
  if (meshClient?.agent) return meshClient;
137
139
  if (starting) return starting;
138
140
  starting = (async () => {
139
- const { project, serverUrl } = sessionIdentity();
141
+ const { project, projectDir, serverUrl } = sessionIdentity();
140
142
  // An agent session registers with a project token only. The hub admits its admin token
141
143
  // to any project missing from its token map, so no project token fails here, before the
142
144
  // hub is contacted.
143
- const authToken = resolveAgentHubAuthToken(process.env, project);
145
+ const authToken = resolveAgentHubAuthToken(process.env, project, projectDir);
144
146
  if (!authToken) {
145
- throw new Error(
146
- `KXM has no project token for project ${project} on this machine. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) or to add ${project} to the hub KXM_PROJECT_TOKENS, listing every existing project too because that variable replaces the saved map.`,
147
- );
147
+ throw new AgentProjectTokenMissingError(resolveProjectIdentity(projectDir, process.env));
148
148
  }
149
149
  const name = process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`;
150
150
  try {
@@ -192,8 +192,10 @@ async function connectedClient(): Promise<HubClient> {
192
192
 
193
193
  /** Tool errors are read by the model, so each names the next step and who takes it. */
194
194
  function toolErrorText(error: unknown): string {
195
- if (error instanceof HubHttpError && error.code === "invalid_auth") {
196
- return `KXM hub rejected the project token for project ${sessionIdentity().project}. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) to that project's token from the hub KXM_PROJECT_TOKENS.`;
195
+ if (error instanceof AgentProjectTokenMissingError) return error.message;
196
+ if (error instanceof HubHttpError && (error.code === "invalid_auth" || error.code === "project_token_missing")) {
197
+ const identity = sessionIdentity();
198
+ return `KXM hub rejected the project token for project ${identity.project} (from ${identity.sourceLabel}). Set hub.local.project or hub.cloud.project in .kxm/config.yaml to the key the hub already knows, and set hub.local.key or hub.cloud.key to an op:// reference or an environment variable name. Ask the user before changing the kxm plugin auth_token (${CONFIGURE_PLUGIN}). Never put the token in config.`;
197
199
  }
198
200
  return error instanceof Error ? error.message : String(error);
199
201
  }
@@ -238,7 +240,7 @@ function registersAtStartup(): boolean {
238
240
  const { projectDir, project } = sessionIdentity();
239
241
  try {
240
242
  if (!statSync(join(projectDir, ".kxm")).isDirectory()) return false;
241
- if (!resolveAgentHubAuthToken(process.env, project)) return false;
243
+ if (!resolveAgentHubAuthToken(process.env, project, projectDir)) return false;
242
244
  } catch {
243
245
  return false;
244
246
  }
@@ -1,47 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { basename, dirname, join, resolve } from "node:path";
3
- import { parse as parseYaml } from "yaml";
4
-
5
- /** Default project identity for a workspace.
6
- *
7
- * Precedence: an explicit flag, then `KXM_PROJECT`, then the `id` in the
8
- * nearest `.kxm/project.yaml` walking upward from `cwd`, then the workspace
9
- * `package.json` `name` (that read never walks upward), then the directory
10
- * name. A missing, malformed, or id-less project file falls through. A missing,
11
- * malformed, or name-less package.json falls through to the directory name. */
12
- export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env, explicit?: string): string {
13
- const fromExplicit = explicit?.trim();
14
- if (fromExplicit) return fromExplicit;
15
- const fromEnv = env.KXM_PROJECT?.trim();
16
- if (fromEnv) return fromEnv;
17
- const fromProject = readProjectYamlId(cwd);
18
- if (fromProject) return fromProject;
19
- try {
20
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
21
- if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
22
- } catch {
23
- // No readable package.json: fall through to the directory name.
24
- }
25
- return basename(cwd);
26
- }
27
-
28
- function readProjectYamlId(start: string): string | undefined {
29
- let dir = resolve(start);
30
- for (;;) {
31
- const file = join(dir, ".kxm", "project.yaml");
32
- if (existsSync(file)) {
33
- try {
34
- const doc = parseYaml(readFileSync(file, "utf8")) as { id?: unknown } | null;
35
- if (doc && typeof doc === "object" && !Array.isArray(doc) && typeof doc.id === "string" && doc.id.trim()) {
36
- return doc.id.trim();
37
- }
38
- } catch {
39
- // Malformed YAML falls through to package.json of the original directory.
40
- }
41
- return undefined;
42
- }
43
- const parent = dirname(dir);
44
- if (parent === dir) return undefined;
45
- dir = parent;
46
- }
47
- }
1
+ export {
2
+ defaultProjectName,
3
+ resolveProjectIdentity,
4
+ type ProjectIdentity,
5
+ type ProjectIdentitySource,
6
+ } from "./hub-identity.ts";
@@ -1,6 +1,7 @@
1
1
  import { createMeshHub } from "./hub.ts";
2
2
  import { createWriteStream, mkdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { resolveConfiguredHubProjectTokens, selectHubProjectTokenMap } from "./hub-identity.ts";
4
5
  import {
5
6
  DEFAULT_MESSAGE_RETENTION_MS,
6
7
  DEFAULT_MESSAGE_TTL_MS,
@@ -42,10 +43,10 @@ const structuredLog = createLogger({
42
43
  path: logPath,
43
44
  });
44
45
 
45
- function projectTokens(): Record<string, string> | undefined {
46
- const raw = process.env.KXM_PROJECT_TOKENS?.trim();
47
- if (!raw) return undefined;
48
- const value = JSON.parse(raw) as unknown;
46
+ function parseProjectTokenEnv(raw: string | undefined): Record<string, string> | undefined {
47
+ const trimmed = raw?.trim();
48
+ if (!trimmed) return undefined;
49
+ const value = JSON.parse(trimmed) as unknown;
49
50
  if (!value || Array.isArray(value) || typeof value !== "object") {
50
51
  throw new Error("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens");
51
52
  }
@@ -56,6 +57,23 @@ function projectTokens(): Record<string, string> | undefined {
56
57
  return Object.fromEntries(entries) as Record<string, string>;
57
58
  }
58
59
 
60
+ function projectTokens(): Record<string, string> | undefined {
61
+ const explicit = process.env.KXM_PROJECT_TOKENS_EXPLICIT === "1";
62
+ const fromEnv = parseProjectTokenEnv(process.env.KXM_PROJECT_TOKENS);
63
+ const workdir = resolve(process.env.KXM_WORKDIR?.trim() || process.cwd());
64
+ const configured = explicit ? undefined : resolveConfiguredHubProjectTokens(workdir, process.env);
65
+ const selected = selectHubProjectTokenMap({
66
+ explicit: explicit ? fromEnv : undefined,
67
+ configured,
68
+ file: explicit ? undefined : fromEnv,
69
+ });
70
+ if (selected.source === "config") {
71
+ const count = Object.keys(selected.tokens ?? {}).length;
72
+ process.stderr.write(`kxm hub: project tokens from .kxm/config.yaml (${count} projects, key references resolved, values not saved)\n`);
73
+ }
74
+ return selected.tokens;
75
+ }
76
+
59
77
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
60
78
  throw new Error("KXM_PORT must be an integer between 0 and 65535");
61
79
  }
@@ -165,9 +165,14 @@ try {
165
165
  process.stderr.write(`kxm hub: ${error.message}\n`);
166
166
  process.exit(1);
167
167
  }
168
+ const operatorSetProjectTokens = Boolean(process.env.KXM_PROJECT_TOKENS?.trim());
168
169
  const launchEnv = { ...process.env };
169
170
  if (credentials.authToken) launchEnv.KXM_AUTH_TOKEN = credentials.authToken;
170
171
  if (credentials.projectTokens) launchEnv.KXM_PROJECT_TOKENS = JSON.stringify(credentials.projectTokens);
172
+ // The operator's KXM_PROJECT_TOKENS replaces config. A map copied from
173
+ // hub-env.json does not, so the server can prefer hub.projects references
174
+ // and keep the resolved tokens out of the credential file.
175
+ if (operatorSetProjectTokens) launchEnv.KXM_PROJECT_TOKENS_EXPLICIT = "1";
171
176
  if (credentials.authTokenSource !== "env") {
172
177
  process.stdout.write(`kxm hub: using ${credentials.authTokenSource === "generated" ? "newly generated" : "persisted"} KXM_AUTH_TOKEN from ${credentials.file}\n`);
173
178
  }