@kontextmind/kxm 0.7.0 → 0.7.4

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/roles/writer.yaml +2 -0
  3. package/docs/README.md +2 -0
  4. package/docs/agent-skills.md +5 -2
  5. package/docs/configuration.md +10 -1
  6. package/docs/getting-started.md +21 -0
  7. package/docs/operations.md +24 -0
  8. package/docs/skills/repo-work-delivery.md +5 -0
  9. package/docs/skills.md +2 -0
  10. package/docs/troubleshooting.md +22 -1
  11. package/package.json +1 -1
  12. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  13. package/plugins/kxm/dist/cli.js +2257 -439
  14. package/plugins/kxm/dist/core.js +57 -0
  15. package/plugins/kxm/dist/extension.js +40 -3
  16. package/plugins/kxm/dist/mcp-server.js +1 -1
  17. package/plugins/kxm/dist/runtime.js +140 -17
  18. package/plugins/kxm/dist/server.js +129 -4
  19. package/plugins/kxm/dist/vnext-runtime-supervisor.js +136 -13
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -0
  22. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +9 -2
  23. package/plugins/kxm/src/autocomplete.ts +1 -1
  24. package/plugins/kxm/src/cli.ts +507 -9
  25. package/plugins/kxm/src/completion-install.ts +223 -0
  26. package/plugins/kxm/src/database.ts +1 -1
  27. package/plugins/kxm/src/external-effects.ts +1 -1
  28. package/plugins/kxm/src/hub-env.ts +193 -0
  29. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  30. package/plugins/kxm/src/local-snapshot.ts +1 -1
  31. package/plugins/kxm/src/mcp-server.ts +1 -1
  32. package/plugins/kxm/src/protocol.ts +111 -0
  33. package/plugins/kxm/src/role.ts +335 -0
  34. package/plugins/kxm/src/safety-integrity.ts +76 -0
  35. package/plugins/kxm/src/sqlite.ts +76 -0
  36. package/plugins/kxm/src/store.ts +1 -1
  37. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  38. package/plugins/kxm/src/vnext-config.ts +38 -1
  39. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  40. package/plugins/kxm/src/vnext-engine.ts +16 -0
  41. package/plugins/kxm/src/vnext-harness.ts +4 -2
  42. package/plugins/kxm/src/vnext-oneshot-process.ts +21 -4
  43. package/plugins/kxm/src/vnext-oneshot-producer.ts +18 -0
  44. package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
  45. package/plugins/kxm/src/workflow-tui.ts +1 -1
  46. package/plugins/kxm/src/workflow.ts +144 -0
  47. package/scripts/kxm-bump-version.mjs +146 -0
  48. package/scripts/kxm-hub.mjs +145 -3
  49. package/scripts/kxm-publish-npm.mjs +3 -1
  50. package/scripts/kxm-release-github.mjs +3 -1
  51. package/scripts/kxm.mjs +0 -0
@@ -0,0 +1,223 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { generateShellCompletion, type SupportedShell } from "./autocomplete.ts";
5
+
6
+ export const COMPLETION_MARKER = "# kxm completion";
7
+ export const PATH_MARKER = "# kxm path";
8
+ export interface CompletionInstallOptions {
9
+ env?: NodeJS.ProcessEnv | undefined;
10
+ homeDir?: string | undefined;
11
+ configDir?: string | undefined;
12
+ platform?: NodeJS.Platform | undefined;
13
+ isTty?: boolean | undefined;
14
+ shell?: string | undefined;
15
+ dryRun?: boolean | undefined;
16
+ overwrite?: boolean | undefined;
17
+ }
18
+
19
+ export interface CompletionInstallReport {
20
+ ok: boolean;
21
+ shell: SupportedShell | "unknown";
22
+ scriptPath: string;
23
+ rcFile: string | undefined;
24
+ rcModified: boolean;
25
+ alreadyInstalled: boolean;
26
+ reason?: string | undefined;
27
+ }
28
+
29
+ export function detectShell(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): SupportedShell | "unknown" {
30
+ const shellPath = env.SHELL?.trim() || (process.platform === "win32" ? undefined : env.SHELL?.trim());
31
+ const name = shellPath ? basename(shellPath).toLowerCase() : "";
32
+ if (name === "bash" || name === "zsh" || name === "fish") return name;
33
+ if (name.endsWith("bash") || name.includes("bash")) return "bash";
34
+ if (name.endsWith("zsh")) return "zsh";
35
+ if (name.endsWith("fish")) return "fish";
36
+ return "unknown";
37
+ }
38
+
39
+ function effectiveHome(options: Pick<CompletionInstallOptions, "env" | "homeDir">): string {
40
+ const env = options.env ?? process.env;
41
+ if (options.homeDir) return options.homeDir;
42
+ const envHome = env.HOME?.trim() ?? env.USERPROFILE?.trim();
43
+ return envHome && envHome.length > 0 ? resolve(envHome) : homedir();
44
+ }
45
+
46
+ export function completionScriptPath(shell: SupportedShell, options: Pick<CompletionInstallOptions, "env" | "homeDir" | "configDir"> = {}): string {
47
+ const env = options.env ?? process.env;
48
+ const explicit = options.configDir ?? env.KXM_USER_CONFIG_DIR?.trim();
49
+ const base = explicit && explicit.length > 0 ? resolve(explicit) : resolve(effectiveHome(options), ".config", "kxm");
50
+ return join(base, "completions", `kxm.${shell}`);
51
+ }
52
+
53
+ function bashRcCandidate(options: Pick<CompletionInstallOptions, "env" | "homeDir">): string | undefined {
54
+ const home = effectiveHome(options);
55
+ const candidates = [join(home, ".bashrc"), join(home, ".bash_profile")];
56
+ const existing = candidates.find((candidate) => existsSync(candidate));
57
+ return existing ?? candidates[0];
58
+ }
59
+
60
+ function zshRcCandidate(options: Pick<CompletionInstallOptions, "env" | "homeDir">): string | undefined {
61
+ const env = options.env ?? process.env;
62
+ const home = effectiveHome(options);
63
+ if (env.ZDOTDIR?.trim()) return join(resolve(env.ZDOTDIR.trim()), ".zshrc");
64
+ return join(home, ".zshrc");
65
+ }
66
+
67
+ function fishCompletionTarget(shell: SupportedShell, options: Pick<CompletionInstallOptions, "env" | "homeDir">): string {
68
+ const env = options.env ?? process.env;
69
+ const home = effectiveHome(options);
70
+ if (env.XDG_CONFIG_HOME?.trim()) return join(resolve(env.XDG_CONFIG_HOME.trim()), "fish", "completions", "kxm.fish");
71
+ return join(home, ".config", "fish", "completions", "kxm.fish");
72
+ }
73
+
74
+ export function completionRcTarget(shell: SupportedShell, options: Pick<CompletionInstallOptions, "env" | "homeDir"> = {}): { rcFile?: string | undefined } {
75
+ if (shell === "fish") {
76
+ return { rcFile: undefined }; // fish auto-loads ~/.config/fish/completions
77
+ }
78
+ if (shell === "zsh") {
79
+ return { rcFile: zshRcCandidate(options) };
80
+ }
81
+ return { rcFile: bashRcCandidate(options) };
82
+ }
83
+
84
+ function sourceLine(shell: SupportedShell, scriptPath: string): string {
85
+ if (shell === "fish") return `source ${scriptPath}`;
86
+ if (shell === "zsh") return `[[ -f ${scriptPath} ]] && source ${scriptPath}`;
87
+ return `[[ -f ${scriptPath} ]] && source ${scriptPath}`;
88
+ }
89
+
90
+ export function installShellCompletion(
91
+ shellInput: string | undefined,
92
+ options: CompletionInstallOptions = {},
93
+ ): CompletionInstallReport {
94
+ const shell = shellInput?.trim() && shellInput !== "auto" ? shellInput.trim() as SupportedShell : detectShell(options.env ?? process.env, options.platform ?? process.platform);
95
+ if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
96
+ return {
97
+ ok: false,
98
+ shell: "unknown",
99
+ scriptPath: "",
100
+ rcFile: undefined,
101
+ rcModified: false,
102
+ alreadyInstalled: false,
103
+ reason: "shell_not_detected",
104
+ };
105
+ }
106
+
107
+ const scriptPath = completionScriptPath(shell, options);
108
+ const { rcFile } = completionRcTarget(shell, options);
109
+ const script = generateShellCompletion(shell);
110
+
111
+ const existingScript = existsSync(scriptPath);
112
+ const existingScriptMatches = existingScript && readFileSync(scriptPath, "utf8") === script;
113
+
114
+ let rcModified = false;
115
+ let alreadyInstalled = false;
116
+
117
+ if (shell === "fish") {
118
+ // fish auto-loads files in ~/.config/fish/completions by filename; no rc edit needed
119
+ const fishTarget = fishCompletionTarget(shell, options);
120
+ const fishInstalled = existsSync(fishTarget) && readFileSync(fishTarget, "utf8") === script;
121
+ alreadyInstalled = fishInstalled;
122
+ if (!options.dryRun && (!fishInstalled || options.overwrite)) {
123
+ mkdirSync(resolve(fishTarget, ".."), { recursive: true });
124
+ writeFileSync(fishTarget, script, { encoding: "utf8" });
125
+ }
126
+ return {
127
+ ok: true,
128
+ shell,
129
+ scriptPath: fishTarget,
130
+ rcFile: undefined,
131
+ rcModified: !fishInstalled,
132
+ alreadyInstalled,
133
+ };
134
+ }
135
+
136
+ if (rcFile) {
137
+ const line = sourceLine(shell, scriptPath);
138
+ const rcContent = existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "";
139
+ alreadyInstalled = rcContent.includes(line) || rcContent.includes(`${COMPLETION_MARKER}`);
140
+ }
141
+
142
+ if (!options.dryRun) {
143
+ if (!existingScriptMatches || options.overwrite) {
144
+ mkdirSync(resolve(scriptPath, ".."), { recursive: true });
145
+ writeFileSync(scriptPath, script, { encoding: "utf8" });
146
+ }
147
+ if (rcFile && !alreadyInstalled) {
148
+ const line = sourceLine(shell, scriptPath);
149
+ const marker = `${COMPLETION_MARKER} (${new Date().toISOString()})`;
150
+ const stanza = `\n${marker}\n${line}\n`;
151
+ mkdirSync(resolve(rcFile, ".."), { recursive: true });
152
+ writeFileSync(rcFile, (existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "") + stanza, { encoding: "utf8" });
153
+ rcModified = true;
154
+ }
155
+ }
156
+
157
+ return {
158
+ ok: true,
159
+ shell,
160
+ scriptPath,
161
+ rcFile,
162
+ rcModified,
163
+ alreadyInstalled,
164
+ };
165
+ }
166
+
167
+ export interface PathInstallReport {
168
+ ok: boolean;
169
+ binDir: string | undefined;
170
+ rcFile: string | undefined;
171
+ rcModified: boolean;
172
+ alreadyInstalled: boolean;
173
+ reason?: string | undefined;
174
+ }
175
+
176
+ /** Locate the directory containing the kxm entrypoint (global npm bin or clone scripts dir). */
177
+ export function kxmBinDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
178
+ const argv1 = env.KXM_ENTRY ?? process.argv[1];
179
+ if (argv1 && isAbsolute(argv1)) {
180
+ const dir = dirname(argv1);
181
+ if (existsSync(join(dir, process.platform === "win32" ? "kxm.cmd" : "kxm"))) return dir;
182
+ }
183
+ // fall back to PATH lookup
184
+ const pathEnv = env.PATH ?? "";
185
+ for (const part of pathEnv.split(delimiter)) {
186
+ if (!part) continue;
187
+ const candidate = resolve(part, process.platform === "win32" ? "kxm.cmd" : "kxm");
188
+ if (existsSync(candidate)) return resolve(part);
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ export function kxmOnPath(env: NodeJS.ProcessEnv = process.env): boolean {
194
+ return kxmBinDir(env) !== undefined;
195
+ }
196
+
197
+ function pathLine(binDir: string): string {
198
+ return `export PATH="${binDir}:$PATH" # kxm`;
199
+ }
200
+
201
+ /** Append a PATH export for the kxm bin dir to the shell rc (bash/zsh). */
202
+ export function installPathEntry(
203
+ shellInput: string | undefined,
204
+ options: CompletionInstallOptions & { binDir?: string } = {},
205
+ ): PathInstallReport {
206
+ const shell = shellInput?.trim() && shellInput !== "auto" ? shellInput.trim() as SupportedShell : detectShell(options.env ?? process.env, options.platform ?? process.platform);
207
+ if (shell !== "bash" && shell !== "zsh") {
208
+ return { ok: false, binDir: undefined, rcFile: undefined, rcModified: false, alreadyInstalled: false, reason: "shell_not_supported" };
209
+ }
210
+ const binDir = options.binDir ?? kxmBinDir(options.env ?? process.env);
211
+ if (!binDir) {
212
+ return { ok: false, binDir: undefined, rcFile: undefined, rcModified: false, alreadyInstalled: false, reason: "kxm_not_found" };
213
+ }
214
+ const { rcFile } = completionRcTarget(shell, options);
215
+ const line = pathLine(binDir);
216
+ const rcContent = existsSync(rcFile ?? "") ? readFileSync(rcFile!, "utf8") : "";
217
+ const alreadyInstalled = rcContent.includes(binDir) || (options.env?.PATH ?? process.env.PATH ?? "").split(delimiter).includes(binDir);
218
+ if (!options.dryRun && rcFile && !alreadyInstalled) {
219
+ mkdirSync(dirname(rcFile), { recursive: true });
220
+ writeFileSync(rcFile, rcContent + `\n${PATH_MARKER}\n${line}\n`, { encoding: "utf8" });
221
+ }
222
+ return { ok: true, binDir, rcFile, rcModified: !alreadyInstalled && !options.dryRun, alreadyInstalled };
223
+ }
@@ -11,7 +11,7 @@ import {
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
13
  import { basename, dirname, join, resolve } from "node:path";
14
- import { DatabaseSync } from "node:sqlite";
14
+ import { DatabaseSync } from "./sqlite.ts";
15
15
  import { VnextConfigError, type VnextConfigIssue } from "./vnext-config.ts";
16
16
 
17
17
  export interface DatabaseMigrationStep {
@@ -3,7 +3,7 @@
3
3
  * Ensures deterministic branching, preflight CAS checks, and immutable receipts for external mutations.
4
4
  */
5
5
 
6
- import { DatabaseSync } from "node:sqlite";
6
+ import { DatabaseSync } from "./sqlite.ts";
7
7
  import { createHash } from "node:crypto";
8
8
  import {
9
9
  mkdirSync,
@@ -0,0 +1,193 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { randomBytes } from "node:crypto";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+
6
+ export const HUB_ENV_SCHEMA = "kxm.hub-env.v1" as const;
7
+
8
+ export interface HubEnvRecord {
9
+ schema: typeof HUB_ENV_SCHEMA;
10
+ /** ISO-8601 creation time of the persisted record. */
11
+ createdAt: string;
12
+ authToken?: string | undefined;
13
+ projectTokens?: Record<string, string> | undefined;
14
+ }
15
+
16
+ export class HubEnvError extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = "HubEnvError";
20
+ }
21
+ }
22
+
23
+ function resolveUserStateRoot(env: NodeJS.ProcessEnv): string {
24
+ const explicit = env.KXM_STATE_HOME?.trim();
25
+ if (explicit) {
26
+ if (!isAbsolute(explicit)) throw new HubEnvError("local_state_root_not_absolute");
27
+ return resolve(explicit);
28
+ }
29
+ if (process.platform === "win32") {
30
+ const localAppData = env.LOCALAPPDATA?.trim();
31
+ const base = localAppData && isAbsolute(localAppData) ? localAppData : join(homedir(), "AppData", "Local");
32
+ return resolve(base, "KXM");
33
+ }
34
+ if (process.platform === "darwin") return resolve(homedir(), "Library", "Application Support", "KXM");
35
+ const xdgState = env.XDG_STATE_HOME?.trim();
36
+ const base = xdgState && isAbsolute(xdgState) ? xdgState : join(homedir(), ".local", "state");
37
+ return resolve(base, "kxm");
38
+ }
39
+
40
+ export function hubEnvFile(env: NodeJS.ProcessEnv = process.env): string {
41
+ return join(resolveUserStateRoot(env), "hub-env.json");
42
+ }
43
+
44
+ export function generateHubAuthToken(): string {
45
+ return `kxm_admin_${randomBytes(24).toString("base64url")}`;
46
+ }
47
+
48
+ export function readHubEnvRecord(env: NodeJS.ProcessEnv = process.env): HubEnvRecord | undefined {
49
+ const file = hubEnvFile(env);
50
+ if (!existsSync(file)) return undefined;
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(readFileSync(file, "utf8"));
54
+ } catch (error) {
55
+ throw new HubEnvError(`hub env file is malformed at ${file}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
58
+ throw new HubEnvError(`hub env file is malformed at ${file}: expected a JSON object`);
59
+ }
60
+ const record = parsed as Partial<HubEnvRecord>;
61
+ if (record.schema !== HUB_ENV_SCHEMA) {
62
+ throw new HubEnvError(`hub env file at ${file} has unsupported schema ${String(record.schema)}`);
63
+ }
64
+ if (record.authToken !== undefined && (typeof record.authToken !== "string" || record.authToken.trim().length === 0)) {
65
+ throw new HubEnvError(`hub env file at ${file} has an invalid authToken`);
66
+ }
67
+ if (record.projectTokens !== undefined) {
68
+ const tokens = record.projectTokens;
69
+ if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) {
70
+ throw new HubEnvError(`hub env file at ${file} has invalid projectTokens`);
71
+ }
72
+ for (const [project, token] of Object.entries(tokens)) {
73
+ if (!project.trim() || typeof token !== "string" || !token.trim()) {
74
+ throw new HubEnvError(`hub env file at ${file} has an empty project name or token`);
75
+ }
76
+ }
77
+ }
78
+ return {
79
+ schema: HUB_ENV_SCHEMA,
80
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : "",
81
+ ...(record.authToken !== undefined ? { authToken: record.authToken } : {}),
82
+ ...(record.projectTokens !== undefined ? { projectTokens: record.projectTokens } : {}),
83
+ };
84
+ }
85
+
86
+ export function writeHubEnvRecord(record: HubEnvRecord, env: NodeJS.ProcessEnv = process.env): string {
87
+ const file = hubEnvFile(env);
88
+ const directory = dirname(file);
89
+ mkdirSync(directory, { recursive: true });
90
+ const temporary = join(directory, `.hub-env-${process.pid}.tmp`);
91
+ writeFileSync(temporary, `${JSON.stringify(record, undefined, 2)}\n`, { encoding: "utf8", mode: 0o600 });
92
+ try {
93
+ renameSync(temporary, file);
94
+ } catch (error) {
95
+ rmSync(temporary, { force: true });
96
+ throw error;
97
+ }
98
+ return file;
99
+ }
100
+
101
+ export interface ResolveHubCredentialsOptions {
102
+ env?: NodeJS.ProcessEnv | undefined;
103
+ /** Injectable token generator for tests. */
104
+ generateToken?: () => string | undefined;
105
+ /** When false, never create or update the persisted file. */
106
+ persist?: boolean | undefined;
107
+ now?: () => string | undefined;
108
+ }
109
+
110
+ export interface ResolvedHubCredentials {
111
+ authToken: string | undefined;
112
+ projectTokens: Record<string, string> | undefined;
113
+ /** Where each value came from; `generated` values were persisted when allowed. */
114
+ authTokenSource: "env" | "file" | "generated" | "none";
115
+ projectTokensSource: "env" | "file" | "generated" | "none";
116
+ envFile: string | undefined;
117
+ written: boolean;
118
+ }
119
+
120
+ function parseProjectTokens(raw: string | undefined): Record<string, string> | undefined | "invalid" {
121
+ const trimmed = raw?.trim();
122
+ if (!trimmed) return undefined;
123
+ let parsed: unknown;
124
+ try {
125
+ parsed = JSON.parse(trimmed);
126
+ } catch {
127
+ return "invalid";
128
+ }
129
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "invalid";
130
+ const entries = Object.entries(parsed as Record<string, unknown>);
131
+ if (entries.some(([project, token]) => !project.trim() || typeof token !== "string" || !token.trim())) return "invalid";
132
+ return Object.fromEntries(entries) as Record<string, string>;
133
+ }
134
+
135
+ /** Resolve hub credentials with persistence across restarts.
136
+ *
137
+ * Precedence: explicit environment variable, then the persisted user-state
138
+ * file, then (for the admin token only) a freshly generated token that is
139
+ * persisted so later restarts and workers reuse the same credential. */
140
+ export function resolveHubCredentials(options: ResolveHubCredentialsOptions = {}): ResolvedHubCredentials {
141
+ const env = options.env ?? process.env;
142
+ const persist = options.persist !== false;
143
+ const existing = readHubEnvRecord(env);
144
+ const envFile = hubEnvFile(env);
145
+
146
+ const envAuthToken = env.KXM_AUTH_TOKEN?.trim();
147
+ const envProjectTokens = parseProjectTokens(env.KXM_PROJECT_TOKENS);
148
+ if (envProjectTokens === "invalid") {
149
+ throw new HubEnvError("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens");
150
+ }
151
+
152
+ let authTokenSource: ResolvedHubCredentials["authTokenSource"] = "none";
153
+ let authToken = envAuthToken || existing?.authToken;
154
+ if (envAuthToken) authTokenSource = "env";
155
+ else if (authToken) authTokenSource = "file";
156
+
157
+ if (!authToken) {
158
+ const generated = options.generateToken?.() ?? generateHubAuthToken();
159
+ if (generated) {
160
+ authToken = generated;
161
+ authTokenSource = "generated";
162
+ }
163
+ }
164
+
165
+ let projectTokensSource: ResolvedHubCredentials["projectTokensSource"] = "none";
166
+ let projectTokens = envProjectTokens ?? existing?.projectTokens;
167
+ if (envProjectTokens) projectTokensSource = "env";
168
+ else if (projectTokens) projectTokensSource = "file";
169
+
170
+ const needsPersist = persist
171
+ && (authTokenSource === "generated"
172
+ || (authTokenSource === "env" && existing?.authToken !== authToken)
173
+ || (projectTokensSource === "env" && JSON.stringify(existing?.projectTokens ?? undefined) !== JSON.stringify(projectTokens ?? undefined)));
174
+
175
+ if (needsPersist) {
176
+ const record: HubEnvRecord = {
177
+ schema: HUB_ENV_SCHEMA,
178
+ createdAt: existing?.createdAt || (options.now?.() ?? new Date().toISOString()),
179
+ ...(authToken ? { authToken } : {}),
180
+ ...(projectTokens ? { projectTokens } : {}),
181
+ };
182
+ writeHubEnvRecord(record, env);
183
+ }
184
+
185
+ return {
186
+ authToken,
187
+ projectTokens,
188
+ authTokenSource,
189
+ projectTokensSource,
190
+ envFile: needsPersist || existing ? envFile : undefined,
191
+ written: needsPersist === true,
192
+ };
193
+ }