@hexclave/shared 1.0.36 → 1.0.37

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 (98) hide show
  1. package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +2 -2
  2. package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
  3. package/dist/config/format.js +10 -0
  4. package/dist/config/format.js.map +1 -1
  5. package/dist/config/schema.d.ts +50 -50
  6. package/dist/config-eval.d.ts +31 -0
  7. package/dist/config-eval.d.ts.map +1 -0
  8. package/dist/config-eval.js +117 -0
  9. package/dist/config-eval.js.map +1 -0
  10. package/dist/config-rendering.d.ts +3 -6
  11. package/dist/config-rendering.d.ts.map +1 -1
  12. package/dist/config-rendering.js +21 -69
  13. package/dist/config-rendering.js.map +1 -1
  14. package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +2 -2
  15. package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
  16. package/dist/esm/config/format.js +10 -0
  17. package/dist/esm/config/format.js.map +1 -1
  18. package/dist/esm/config/schema.d.ts +50 -50
  19. package/dist/esm/config-eval.d.ts +31 -0
  20. package/dist/esm/config-eval.d.ts.map +1 -0
  21. package/dist/esm/config-eval.js +112 -0
  22. package/dist/esm/config-eval.js.map +1 -0
  23. package/dist/esm/config-rendering.d.ts +3 -6
  24. package/dist/esm/config-rendering.d.ts.map +1 -1
  25. package/dist/esm/config-rendering.js +15 -58
  26. package/dist/esm/config-rendering.js.map +1 -1
  27. package/dist/esm/interface/admin-interface.d.ts +44 -2
  28. package/dist/esm/interface/admin-interface.d.ts.map +1 -1
  29. package/dist/esm/interface/admin-interface.js +56 -1
  30. package/dist/esm/interface/admin-interface.js.map +1 -1
  31. package/dist/esm/interface/admin-metrics.d.ts +12 -12
  32. package/dist/esm/interface/client-interface.js +1 -1
  33. package/dist/esm/interface/conversations.d.ts +4 -4
  34. package/dist/esm/interface/crud/current-user.d.ts +12 -12
  35. package/dist/esm/interface/crud/email-outbox.d.ts +180 -180
  36. package/dist/esm/interface/crud/products.d.ts +15 -15
  37. package/dist/esm/interface/crud/products.d.ts.map +1 -1
  38. package/dist/esm/interface/crud/project-api-keys.d.ts +2 -2
  39. package/dist/esm/interface/crud/projects.d.ts +28 -28
  40. package/dist/esm/interface/crud/team-member-profiles.d.ts +26 -26
  41. package/dist/esm/interface/crud/transactions.d.ts +20 -20
  42. package/dist/esm/interface/crud/transactions.d.ts.map +1 -1
  43. package/dist/esm/interface/crud/users.d.ts +16 -16
  44. package/dist/esm/interface/plan-usage.d.ts +2 -2
  45. package/dist/esm/interface/server-interface.js +1 -1
  46. package/dist/esm/interface/webhooks.d.ts +2 -2
  47. package/dist/esm/known-errors.d.ts +1 -1
  48. package/dist/esm/schema-fields.d.ts +35 -2
  49. package/dist/esm/schema-fields.d.ts.map +1 -1
  50. package/dist/esm/schema-fields.js +53 -2
  51. package/dist/esm/schema-fields.js.map +1 -1
  52. package/dist/esm/sessions.d.ts +7 -7
  53. package/dist/esm/utils/jsx-editable-transpiler.js +2 -2
  54. package/dist/esm/utils/promises.js +1 -1
  55. package/dist/interface/admin-interface.d.ts +44 -2
  56. package/dist/interface/admin-interface.d.ts.map +1 -1
  57. package/dist/interface/admin-interface.js +56 -1
  58. package/dist/interface/admin-interface.js.map +1 -1
  59. package/dist/interface/admin-metrics.d.ts +12 -12
  60. package/dist/interface/client-interface.js +1 -1
  61. package/dist/interface/conversations.d.ts +4 -4
  62. package/dist/interface/crud/current-user.d.ts +12 -12
  63. package/dist/interface/crud/email-outbox.d.ts +180 -180
  64. package/dist/interface/crud/products.d.ts +15 -15
  65. package/dist/interface/crud/products.d.ts.map +1 -1
  66. package/dist/interface/crud/project-api-keys.d.ts +2 -2
  67. package/dist/interface/crud/projects.d.ts +28 -28
  68. package/dist/interface/crud/team-member-profiles.d.ts +26 -26
  69. package/dist/interface/crud/transactions.d.ts +20 -20
  70. package/dist/interface/crud/transactions.d.ts.map +1 -1
  71. package/dist/interface/crud/users.d.ts +16 -16
  72. package/dist/interface/plan-usage.d.ts +2 -2
  73. package/dist/interface/server-interface.js +1 -1
  74. package/dist/interface/webhooks.d.ts +2 -2
  75. package/dist/known-errors.d.ts +1 -1
  76. package/dist/schema-fields.d.ts +35 -2
  77. package/dist/schema-fields.d.ts.map +1 -1
  78. package/dist/schema-fields.js +55 -1
  79. package/dist/schema-fields.js.map +1 -1
  80. package/dist/sessions.d.ts +7 -7
  81. package/dist/utils/jsx-editable-transpiler.js +4 -4
  82. package/dist/utils/promises.js +1 -1
  83. package/package.json +2 -1
  84. package/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.ts +2 -2
  85. package/src/config/format.ts +18 -0
  86. package/src/config-eval.ts +140 -0
  87. package/src/config-rendering.ts +23 -79
  88. package/src/interface/admin-interface.ts +85 -5
  89. package/src/schema-fields.ts +59 -1
  90. package/dist/esm/hexclave-config-file.d.ts +0 -38
  91. package/dist/esm/hexclave-config-file.d.ts.map +0 -1
  92. package/dist/esm/hexclave-config-file.js +0 -144
  93. package/dist/esm/hexclave-config-file.js.map +0 -1
  94. package/dist/hexclave-config-file.d.ts +0 -38
  95. package/dist/hexclave-config-file.d.ts.map +0 -1
  96. package/dist/hexclave-config-file.js +0 -153
  97. package/dist/hexclave-config-file.js.map +0 -1
  98. package/src/hexclave-config-file.ts +0 -215
@@ -0,0 +1,140 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { createJiti } from "jiti";
3
+ import path from "path";
4
+ import { showOnboardingHexclaveConfigValue } from "./config-authoring";
5
+ import { detectConfigImportPackage } from "./config-rendering";
6
+
7
+ const jiti = createJiti(import.meta.url, { moduleCache: false });
8
+
9
+ /**
10
+ * Thrown when a config file evaluates successfully but its exported `config`
11
+ * isn't a usable shape (missing, or not an object / "show-onboarding" string).
12
+ * Distinct from the underlying loader errors jiti throws, so callers can tell a
13
+ * malformed config apart from a file that simply failed to load.
14
+ */
15
+ export class ConfigFileEvalError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "ConfigFileEvalError";
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Walks up from `dir` to find the nearest `package.json` and returns the
24
+ * best SDK package to use for the `HexclaveConfig` type import.
25
+ */
26
+ export function detectImportPackageFromDir(dir: string): string | undefined {
27
+ let current = dir;
28
+ while (true) {
29
+ const pkgPath = path.join(current, "package.json");
30
+ if (existsSync(pkgPath)) {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
33
+ const deps = [
34
+ ...Object.keys(pkg.dependencies ?? {}),
35
+ ...Object.keys(pkg.devDependencies ?? {}),
36
+ ];
37
+ return detectConfigImportPackage(deps);
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ const parent = path.dirname(current);
43
+ if (parent === current) break;
44
+ current = parent;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return value != null && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
53
+ /** A config object, or the `"show-onboarding"` sentinel that stands in for one. */
54
+ export type ParsedConfigValue = Record<string, unknown> | typeof showOnboardingHexclaveConfigValue;
55
+
56
+ function invalidConfigShape(filePath: string): ConfigFileEvalError {
57
+ return new ConfigFileEvalError(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "${showOnboardingHexclaveConfigValue}".`);
58
+ }
59
+
60
+ /**
61
+ * Evaluates config file content using jiti and returns the exported `config`
62
+ * value.
63
+ *
64
+ * WARNING: This executes arbitrary code via `jiti.evalModule` — only use on
65
+ * content that is fully operator-controlled (local filesystem). Never call
66
+ * this on untrusted input (e.g. content fetched from a remote repository).
67
+ */
68
+ export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
69
+ if (content.trim() === "") return {};
70
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
71
+ const mod: unknown = jiti.evalModule(content, { filename: resolvedPath });
72
+ if (!isRecord(mod)) {
73
+ throw invalidConfigShape(filePath);
74
+ }
75
+ const config = mod.config;
76
+ if (config === undefined) {
77
+ throw invalidConfigShape(filePath);
78
+ }
79
+ if (typeof config === "string") {
80
+ if (config !== showOnboardingHexclaveConfigValue) {
81
+ throw new ConfigFileEvalError(`Invalid config in ${filePath}. String config values must be "${showOnboardingHexclaveConfigValue}", got "${config}".`);
82
+ }
83
+ return config;
84
+ }
85
+ if (isRecord(config)) return config;
86
+ throw invalidConfigShape(filePath);
87
+ }
88
+
89
+ // --- inline vitest tests ---
90
+
91
+ import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => {
92
+ expect(evalConfigFileContent(`
93
+ import type { StackConfig } from "@hexclave/js";
94
+ export const config: StackConfig = {
95
+ auth: { allowSignUp: true },
96
+ payments: { testMode: false },
97
+ };
98
+ `, "stack.config.ts")).toMatchInlineSnapshot(`
99
+ {
100
+ "auth": {
101
+ "allowSignUp": true,
102
+ },
103
+ "payments": {
104
+ "testMode": false,
105
+ },
106
+ }
107
+ `);
108
+ });
109
+
110
+ import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => {
111
+ expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
112
+ });
113
+
114
+ import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => {
115
+ expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
116
+ });
117
+
118
+ import.meta.vitest?.test("evalConfigFileContent rejects arbitrary string config values", ({ expect }) => {
119
+ expect(() => evalConfigFileContent('export const config = "arbitrary-string";', "stack.config.ts")).toThrow(/must be "show-onboarding"/);
120
+ });
121
+
122
+ import.meta.vitest?.test("evalConfigFileContent rejects unresolvable config factories", ({ expect }) => {
123
+ expect(() => evalConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow();
124
+ });
125
+
126
+ import.meta.vitest?.test("evalConfigFileContent rejects missing config import targets", ({ expect }) => {
127
+ expect(() => evalConfigFileContent(`
128
+ import missingConfigPart from "./missing-config-part";
129
+ export const config = { auth: missingConfigPart };
130
+ `, "/tmp/hexclave-missing-import-config.ts")).toThrow();
131
+ });
132
+
133
+ import.meta.vitest?.test("evalConfigFileContent surfaces invalid syntax as a loader error, not ConfigFileEvalError", ({ expect }) => {
134
+ // A malformed file fails inside jiti's parser, so the thrown error is a loader
135
+ // error — NOT a ConfigFileEvalError. Callers depend on that distinction to route
136
+ // it to "Failed to load config file" rather than "Invalid config".
137
+ const evalInvalid = () => evalConfigFileContent("export const config = {", "stack.config.ts");
138
+ expect(evalInvalid).toThrow();
139
+ expect(evalInvalid).not.toThrow(ConfigFileEvalError);
140
+ });
@@ -1,7 +1,6 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import path from "path";
3
- import { hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, tryParseHexclaveConfigFileContent } from "./hexclave-config-file";
4
- export { hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, tryParseHexclaveConfigFileContent };
1
+ import { isValidConfig, normalize } from "./config/format";
2
+
3
+ const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
5
4
 
6
5
  /**
7
6
  * Packages that export the `HexclaveConfig` type, in priority order.
@@ -37,32 +36,30 @@ export function detectConfigImportPackage(dependencies: string[]): string | unde
37
36
  }
38
37
 
39
38
  /**
40
- * Walks up from `dir` to find the nearest `package.json` and returns the
41
- * best SDK package to use for the `HexclaveConfig` type import.
39
+ * Renders a config object into the source text of a `stack.config.ts` file.
42
40
  */
43
- export function detectImportPackageFromDir(dir: string): string | undefined {
44
- let current = dir;
45
- while (true) {
46
- const pkgPath = path.join(current, "package.json");
47
- if (existsSync(pkgPath)) {
48
- try {
49
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
50
- const deps = [
51
- ...Object.keys(pkg.dependencies ?? {}),
52
- ...Object.keys(pkg.devDependencies ?? {}),
53
- ];
54
- return detectConfigImportPackage(deps);
55
- } catch {
56
- return undefined;
57
- }
58
- }
59
- const parent = path.dirname(current);
60
- if (parent === current) break;
61
- current = parent;
41
+ export function renderConfigFileContent(config: unknown, importPackage?: string): string {
42
+ if (!isValidConfig(config)) {
43
+ throw new Error("Invalid config: expected a plain object.");
62
44
  }
63
- return undefined;
45
+
46
+ const droppedKeys: string[] = [];
47
+ const normalizedConfig = normalize(config, {
48
+ onDotIntoNonObject: "ignore",
49
+ onDotIntoNull: "empty-object",
50
+ droppedKeys,
51
+ });
52
+ if (droppedKeys.length > 0) {
53
+ throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
54
+ }
55
+ const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
56
+ const importSpecifier = pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
57
+ const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
58
+ return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
64
59
  }
65
60
 
61
+ // --- inline vitest tests ---
62
+
66
63
  import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
67
64
  expect(renderConfigFileContent({
68
65
  "payments.items.todos.displayName": "Todo Slots",
@@ -79,55 +76,6 @@ import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({
79
76
  };`);
80
77
  });
81
78
 
82
- import.meta.vitest?.test("parseHexclaveConfigFileContent parses static config exports", ({ expect }) => {
83
- expect(parseHexclaveConfigFileContent(`
84
- import type { StackConfig } from "@hexclave/js";
85
- export const config: StackConfig = {
86
- auth: { allowSignUp: true },
87
- payments: { testMode: false },
88
- };
89
- `, "stack.config.ts")).toMatchInlineSnapshot(`
90
- {
91
- "auth": {
92
- "allowSignUp": true,
93
- },
94
- "payments": {
95
- "testMode": false,
96
- },
97
- }
98
- `);
99
- });
100
-
101
- import.meta.vitest?.test("parseHexclaveConfigFileContent parses show-onboarding", ({ expect }) => {
102
- expect(parseHexclaveConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
103
- });
104
-
105
- import.meta.vitest?.test("parseHexclaveConfigFileContent rejects dynamic config exports", ({ expect }) => {
106
- expect(() => parseHexclaveConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(/Unsupported config expression/);
107
- });
108
-
109
- import.meta.vitest?.test("tryParseHexclaveConfigFileContent returns the config for static exports", ({ expect }) => {
110
- expect(tryParseHexclaveConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
111
- auth: { allowSignUp: true },
112
- });
113
- });
114
-
115
- import.meta.vitest?.test("tryParseHexclaveConfigFileContent returns null for non-static exports", ({ expect }) => {
116
- // Wrapped in a helper call (e.g. defineStackConfig) -> not a plain literal.
117
- expect(tryParseHexclaveConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toBeNull();
118
- // References an imported value -> has structure to preserve.
119
- expect(tryParseHexclaveConfigFileContent('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
120
- // Syntax error.
121
- expect(tryParseHexclaveConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
122
- });
123
-
124
- import.meta.vitest?.test("hexclaveConfigFileExportsConfig detects a config export", ({ expect }) => {
125
- expect(hexclaveConfigFileExportsConfig("export const config = { a: 1 };", "stack.config.ts")).toBe(true);
126
- expect(hexclaveConfigFileExportsConfig('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBe(true);
127
- expect(hexclaveConfigFileExportsConfig("export const notConfig = { a: 1 };", "stack.config.ts")).toBe(false);
128
- expect(hexclaveConfigFileExportsConfig("export const config = {", "stack.config.ts")).toBe(false);
129
- });
130
-
131
79
  import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
132
80
  expect(() => renderConfigFileContent({
133
81
  "a.b": 1,
@@ -152,8 +100,6 @@ import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({
152
100
  });
153
101
 
154
102
  import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
155
- // The lightweight `/config` subpath only exists on Hexclave-branded packages;
156
- // already-published @stackframe/* releases predate it.
157
103
  const content = renderConfigFileContent({}, "@stackframe/next");
158
104
  expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
159
105
  });
@@ -163,9 +109,7 @@ import.meta.vitest?.test("detectConfigImportPackage picks first matching package
163
109
  expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react");
164
110
  expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js");
165
111
  expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start");
166
- // Hexclave names take priority over legacy stackframe names when both appear.
167
112
  expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next");
168
- // Legacy fallback still works for projects pinned to the last @stackframe/* release.
169
113
  expect(detectConfigImportPackage(["@stackframe/stack"])).toBe("@stackframe/stack");
170
114
  expect(detectConfigImportPackage(["@stackframe/template"])).toBe("@stackframe/template");
171
115
  expect(detectConfigImportPackage(["lodash", "express"])).toBeUndefined();
@@ -1,12 +1,11 @@
1
1
  import * as yup from "yup";
2
+ import type { EnvironmentConfigOverrideOverride } from "../config/schema";
2
3
  import { KnownErrors } from "../known-errors";
3
- import { branchConfigSourceSchema, type RestrictedReason } from "../schema-fields";
4
+ import { branchConfigSourceSchema, type ConfigAgentRunApi, type RestrictedReason } from "../schema-fields";
4
5
  import { AccessToken, InternalSession, RefreshToken } from "../sessions";
5
6
  import type { MoneyAmount } from "../utils/currency-constants";
6
- import type { Json } from "../utils/json";
7
7
  import { Result } from "../utils/results";
8
8
  import { urlString } from "../utils/urls";
9
- import type { PlanUsageResponse } from "./plan-usage";
10
9
  import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
11
10
  import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics";
12
11
  import { EmailOutboxCrud } from "./crud/email-outbox";
@@ -15,9 +14,9 @@ import { InternalApiKeysCrud } from "./crud/internal-api-keys";
15
14
  import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions";
16
15
  import { ProjectsCrud } from "./crud/projects";
17
16
  import type {
18
- AdminGetSessionReplayResponse,
19
17
  AdminGetSessionReplayAllEventsResponse,
20
18
  AdminGetSessionReplayChunkEventsResponse,
19
+ AdminGetSessionReplayResponse,
21
20
  AdminListSessionReplayChunksOptions,
22
21
  AdminListSessionReplayChunksResponse,
23
22
  AdminListSessionReplaysOptions,
@@ -26,7 +25,8 @@ import type {
26
25
  import { SvixTokenCrud } from "./crud/svix-token";
27
26
  import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions";
28
27
  import type { Transaction, TransactionType } from "./crud/transactions";
29
- import { ServerAuthApplicationOptions, HexclaveServerInterface } from "./server-interface";
28
+ import type { PlanUsageResponse } from "./plan-usage";
29
+ import { HexclaveServerInterface, ServerAuthApplicationOptions } from "./server-interface";
30
30
 
31
31
  export type { PlanUsageResponse } from "./plan-usage";
32
32
 
@@ -834,6 +834,86 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
834
834
  );
835
835
  }
836
836
 
837
+ /**
838
+ * Reads a specific config-agent run's state (or `null`) for the linked GitHub
839
+ * repo. Polled by the dashboard — using the id returned by `applyConfigViaAgent`
840
+ * — for live progress and the review diff. Runs are independent, so each is
841
+ * addressed by its own id rather than "the" run on the branch.
842
+ */
843
+ async getConfigAgentRun(runId: string): Promise<ConfigAgentRunApi | null> {
844
+ const response = await this.sendAdminRequest(
845
+ `/internal/config/github/run?run_id=${encodeURIComponent(runId)}`,
846
+ { method: "GET" },
847
+ null,
848
+ );
849
+ const data = await response.json();
850
+ return data.agent_run ?? null;
851
+ }
852
+
853
+ /**
854
+ * Applies a dashboard config change to the linked GitHub repo by running the
855
+ * config agent in a sandbox (server-side). Returns immediately with the new run's
856
+ * `id`; poll `getConfigAgentRun(id)` for progress. The GitHub access token is the
857
+ * caller's own OAuth token and is used transiently server-side.
858
+ */
859
+ async applyConfigViaAgent(options: { configUpdate: EnvironmentConfigOverrideOverride, githubAccessToken: string }): Promise<{ status: "started", id: string }> {
860
+ const response = await this.sendAdminRequest(
861
+ `/internal/config/github/apply`,
862
+ {
863
+ method: "POST",
864
+ headers: { "content-type": "application/json" },
865
+ body: JSON.stringify({
866
+ github_access_token: options.githubAccessToken,
867
+ config_update_string: JSON.stringify(options.configUpdate),
868
+ }),
869
+ },
870
+ null,
871
+ );
872
+ return await response.json();
873
+ }
874
+
875
+ /**
876
+ * Cancels a specific in-flight agent-driven config write: hard-stops the sandbox
877
+ * so the agent stops mid-work. Also cancels runs in `awaiting_review`. No revert
878
+ * — if the agent already pushed, the commit stays. Returns `not-running` if the
879
+ * run is gone or already terminal.
880
+ */
881
+ async cancelConfigAgentRun(runId: string): Promise<{ status: "cancelling" | "not-running" }> {
882
+ const response = await this.sendAdminRequest(
883
+ `/internal/config/github/cancel`,
884
+ {
885
+ method: "POST",
886
+ headers: { "content-type": "application/json" },
887
+ body: JSON.stringify({ run_id: runId }),
888
+ },
889
+ null,
890
+ );
891
+ return await response.json();
892
+ }
893
+
894
+ /**
895
+ * Commits a specific run's reviewed change to GitHub. Only valid when that run is in
896
+ * `awaiting_review` status; the change (diff + base commit) was captured at apply time
897
+ * and is rebuilt + pushed via the GitHub API here, so no live sandbox is involved.
898
+ * Returns `not-awaiting-review` if the run isn't in a committable state.
899
+ */
900
+ async commitConfigAgentRun(runId: string, options: { githubAccessToken: string, commitMessage?: string }): Promise<{ status: "committing" | "not-awaiting-review" }> {
901
+ const response = await this.sendAdminRequest(
902
+ `/internal/config/github/commit`,
903
+ {
904
+ method: "POST",
905
+ headers: { "content-type": "application/json" },
906
+ body: JSON.stringify({
907
+ run_id: runId,
908
+ github_access_token: options.githubAccessToken,
909
+ ...(options.commitMessage ? { commit_message: options.commitMessage } : {}),
910
+ }),
911
+ },
912
+ null,
913
+ );
914
+ return await response.json();
915
+ }
916
+
837
917
  async resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]): Promise<void> {
838
918
  await this.sendAdminRequest(
839
919
  `/internal/config/override/${level}/reset-keys`,
@@ -1,6 +1,7 @@
1
1
  import * as yup from "yup";
2
2
  import { KnownErrors } from "./known-errors";
3
3
  import { isBase64 } from "./utils/bytes";
4
+ import { isValidCountryCode, normalizeCountryCode } from "./utils/country-codes";
4
5
  import { SUPPORTED_CURRENCIES, type Currency, type MoneyAmount } from "./utils/currency-constants";
5
6
  import type { DayInterval, Interval } from "./utils/dates";
6
7
  import { getProcessEnv } from "./utils/env";
@@ -9,7 +10,6 @@ import { decodeBasicAuthorizationHeader } from "./utils/http";
9
10
  import { allProviders } from "./utils/oauth";
10
11
  import { deepPlainClone, omit, typedFromEntries } from "./utils/objects";
11
12
  import { deindent } from "./utils/strings";
12
- import { isValidCountryCode, normalizeCountryCode } from "./utils/country-codes";
13
13
  import { isValidHostnameWithWildcards, isValidUrl } from "./utils/urls";
14
14
  import { isUuid } from "./utils/uuids";
15
15
 
@@ -884,6 +884,15 @@ export const oauthProviderAllowConnectedAccountsSchema = yupBoolean().meta({ ope
884
884
  export const oauthProviderAccountIdSchema = yupString().meta({ openapiField: { description: 'Account ID of the OAuth provider. This uniquely identifies the account on the provider side.', exampleValue: 'google-account-id-12345' } });
885
885
  export const oauthProviderProviderConfigIdSchema = yupString().meta({ openapiField: { description: 'Provider config ID of the OAuth provider. This uniquely identifies the provider config on config.json file', exampleValue: 'google' } });
886
886
 
887
+ export const configAgentSafeErrorMessages = [
888
+ "The config agent failed to apply the change.",
889
+ "Sandbox session expired. Please retry the update.",
890
+ "Failed to commit and push the config changes.",
891
+ "The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch.",
892
+ ] as const;
893
+ export type ConfigAgentSafeErrorMessage = typeof configAgentSafeErrorMessages[number];
894
+ export const configAgentSafeErrorMessageSchema = yupString().oneOf(configAgentSafeErrorMessages);
895
+
887
896
  // Headers
888
897
  export const basicAuthorizationHeaderSchema = yupString().test('is-basic-authorization-header', 'Authorization header must be in the format "Basic <base64>"', (value) => {
889
898
  if (!value) return true;
@@ -930,6 +939,9 @@ export function yupDefinedAndNonEmptyWhen<S extends yup.StringSchema>(
930
939
  });
931
940
  }
932
941
 
942
+ // `source` describes only WHERE a branch's config came from. The ephemeral state
943
+ // of the dashboard→GitHub config agent lives separately in `configAgentRunSchema`
944
+ // (its own DB column), so it doesn't pollute the source descriptor.
933
945
  export const branchConfigSourceSchema = yupUnion(
934
946
  yupObject({
935
947
  type: yupString().oneOf(["pushed-from-github"]).defined(),
@@ -947,3 +959,49 @@ export const branchConfigSourceSchema = yupUnion(
947
959
  type: yupString().oneOf(["unlinked"]).defined(),
948
960
  }),
949
961
  );
962
+
963
+ /**
964
+ * State of a single dashboard→GitHub config agent run, so the dashboard can poll for
965
+ * progress and surface the resulting commit (or error). Each run is one row in the
966
+ * `ConfigAgentRun` table, addressed by `id`; runs are NOT serialized, so many can
967
+ * target the same branch at once and GitHub catches conflicts at push time.
968
+ */
969
+ export const configAgentRunSchema = yupObject({
970
+ // The run's id (the `ConfigAgentRun` row id). The dashboard polls/cancels/commits this specific run by id.
971
+ id: yupString().uuid().defined(),
972
+ // "running": agent is working; "awaiting_review": agent done, diff ready, waiting for the user to commit;
973
+ // "success" | "no-change" | "error" | "cancelled": terminal.
974
+ status: yupString().oneOf(["running", "awaiting_review", "success", "no-change", "error", "cancelled"]).defined(),
975
+ started_at: yupNumber().defined(),
976
+ finished_at: yupNumber().optional(),
977
+ commit_url: urlSchema.optional(),
978
+ error: configAgentSafeErrorMessageSchema.optional(),
979
+ // Vercel Sandbox id of the in-flight run, recorded only while `status` is "running"
980
+ // so a cancel request (a different invocation) can hard-stop the sandbox. Cleared once
981
+ // the change set is captured ("awaiting_review") or the run goes terminal.
982
+ sandbox_id: yupString().optional(),
983
+ // A short, SANITIZED live activity feed (recent agent actions, e.g. "Editing
984
+ // hexclave.config.ts", "Running: git push"). Never file contents, tool inputs, or tokens.
985
+ progress: yupString().optional(),
986
+ // Current stage, for the dashboard progress bar. Absent once terminal.
987
+ stage: yupString().oneOf(["initializing_sandbox", "cloning_repo", "agent_making_changes", "awaiting_review"]).optional(),
988
+ // The git unified diff produced by the agent, set when status becomes "awaiting_review".
989
+ diff: yupString().optional(),
990
+ });
991
+ export type ConfigAgentRunApi = yup.InferType<typeof configAgentRunSchema>;
992
+
993
+ import.meta.vitest?.test("configAgentRunSchema only allows safe config-agent error messages", async ({ expect }) => {
994
+ expect(await configAgentRunSchema.isValid({
995
+ id: "00000000-0000-0000-0000-000000000000",
996
+ status: "error",
997
+ started_at: 1,
998
+ error: "The config agent failed to apply the change.",
999
+ })).toMatchInlineSnapshot(`true`);
1000
+
1001
+ expect(await configAgentRunSchema.isValid({
1002
+ id: "00000000-0000-0000-0000-000000000000",
1003
+ status: "error",
1004
+ started_at: 1,
1005
+ error: "ENOENT: tokenized internal failure",
1006
+ })).toMatchInlineSnapshot(`false`);
1007
+ });
@@ -1,38 +0,0 @@
1
- //#region src/hexclave-config-file.d.ts
2
- declare const showOnboardingHexclaveConfigValue = "show-onboarding";
3
- /**
4
- * Renders a config object into the source text of a `stack.config.ts` file.
5
- *
6
- * Browser-safe: kept here (next to `parseHexclaveConfigFileContent`) instead of in
7
- * `config-rendering.ts` so dashboard client code can render config files
8
- * without pulling in `fs` / `path`.
9
- */
10
- declare function renderConfigFileContent(config: unknown, importPackage?: string): string;
11
- type ParsedStackConfig = Record<string, unknown> | typeof showOnboardingHexclaveConfigValue;
12
- /**
13
- * Like {@link parseHexclaveConfigFileContent}, but returns `null` instead of
14
- * throwing when the file is not a plain static config (e.g. it wraps the config
15
- * in a helper call, references imported values, or has a syntax error). Useful
16
- * for deciding whether a config file can be safely regenerated deterministically
17
- * or whether it has custom structure that must be preserved.
18
- */
19
- declare function tryParseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig | null;
20
- /**
21
- * Returns whether `content` parses as a module that exports a `config` binding.
22
- * Used as a lightweight structural sanity check after editing config files whose
23
- * values can't be evaluated by our loader (e.g. they import external text
24
- * files), where a full semantic comparison isn't possible.
25
- */
26
- declare function hexclaveConfigFileExportsConfig(content: string, filePath: string): boolean;
27
- /**
28
- * Returns the relative import sources (those starting with `./` or `../`)
29
- * declared in `content`. Used to discover the external files a config update may
30
- * touch — e.g. `import x from "./welcome-email.tsx" with { type: "text" }` — so
31
- * they can be snapshotted and rolled back if an in-place update fails. Returns
32
- * an empty array if the file can't be parsed.
33
- */
34
- declare function getRelativeImportSpecifiers(content: string): string[];
35
- declare function parseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig;
36
- //#endregion
37
- export { getRelativeImportSpecifiers, hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, showOnboardingHexclaveConfigValue, tryParseHexclaveConfigFileContent };
38
- //# sourceMappingURL=hexclave-config-file.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"hexclave-config-file.d.ts","names":[],"sources":["../../src/hexclave-config-file.ts"],"mappings":";cAIa,iCAAA;AAAb;;;;;AAWA;;AAXA,iBAWgB,uBAAA,CAAwB,MAAA,WAAiB,aAAA;AAAA,KAyBpD,iBAAA,GAAoB,MAAA,2BAAiC,iCAAA;;AAFzD;;;;;AAuED;iBAAgB,iCAAA,CAAkC,OAAA,UAAiB,QAAA,WAAmB,iBAAA;;;;;;;iBActE,+BAAA,CAAgC,OAAA,UAAiB,QAAA;AAAjE;;;;;AA8CA;;AA9CA,iBA8CgB,2BAAA,CAA4B,OAAA;AAAA,iBAsB5B,8BAAA,CAA+B,OAAA,UAAiB,QAAA,WAAmB,iBAAA"}