@dharta/cli 0.6.77

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 (259) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/dist/agent.d.ts +17 -0
  4. package/dist/agent.js +134 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/api-error.d.ts +3 -0
  7. package/dist/api-error.js +80 -0
  8. package/dist/api-error.js.map +1 -0
  9. package/dist/browser.d.ts +1 -0
  10. package/dist/browser.js +19 -0
  11. package/dist/browser.js.map +1 -0
  12. package/dist/cli.d.ts +5 -0
  13. package/dist/cli.js +1978 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/commands/agents.d.ts +131 -0
  16. package/dist/commands/agents.js +439 -0
  17. package/dist/commands/agents.js.map +1 -0
  18. package/dist/commands/analytics.d.ts +25 -0
  19. package/dist/commands/analytics.js +166 -0
  20. package/dist/commands/analytics.js.map +1 -0
  21. package/dist/commands/api.d.ts +19 -0
  22. package/dist/commands/api.js +278 -0
  23. package/dist/commands/api.js.map +1 -0
  24. package/dist/commands/appearance.d.ts +19 -0
  25. package/dist/commands/appearance.js +63 -0
  26. package/dist/commands/appearance.js.map +1 -0
  27. package/dist/commands/artifact.d.ts +33 -0
  28. package/dist/commands/artifact.js +235 -0
  29. package/dist/commands/artifact.js.map +1 -0
  30. package/dist/commands/audit-log.d.ts +32 -0
  31. package/dist/commands/audit-log.js +102 -0
  32. package/dist/commands/audit-log.js.map +1 -0
  33. package/dist/commands/auth.d.ts +47 -0
  34. package/dist/commands/auth.js +117 -0
  35. package/dist/commands/auth.js.map +1 -0
  36. package/dist/commands/caps.d.ts +82 -0
  37. package/dist/commands/caps.js +213 -0
  38. package/dist/commands/caps.js.map +1 -0
  39. package/dist/commands/create.d.ts +22 -0
  40. package/dist/commands/create.js +76 -0
  41. package/dist/commands/create.js.map +1 -0
  42. package/dist/commands/deploy.d.ts +45 -0
  43. package/dist/commands/deploy.js +892 -0
  44. package/dist/commands/deploy.js.map +1 -0
  45. package/dist/commands/dev.d.ts +33 -0
  46. package/dist/commands/dev.js +655 -0
  47. package/dist/commands/dev.js.map +1 -0
  48. package/dist/commands/directories.d.ts +60 -0
  49. package/dist/commands/directories.js +83 -0
  50. package/dist/commands/directories.js.map +1 -0
  51. package/dist/commands/doctor.d.ts +13 -0
  52. package/dist/commands/doctor.js +123 -0
  53. package/dist/commands/doctor.js.map +1 -0
  54. package/dist/commands/embed-keys.d.ts +61 -0
  55. package/dist/commands/embed-keys.js +165 -0
  56. package/dist/commands/embed-keys.js.map +1 -0
  57. package/dist/commands/env.d.ts +59 -0
  58. package/dist/commands/env.js +300 -0
  59. package/dist/commands/env.js.map +1 -0
  60. package/dist/commands/files.d.ts +14 -0
  61. package/dist/commands/files.js +84 -0
  62. package/dist/commands/files.js.map +1 -0
  63. package/dist/commands/git-credential.d.ts +1 -0
  64. package/dist/commands/git-credential.js +70 -0
  65. package/dist/commands/git-credential.js.map +1 -0
  66. package/dist/commands/kartas.d.ts +101 -0
  67. package/dist/commands/kartas.js +289 -0
  68. package/dist/commands/kartas.js.map +1 -0
  69. package/dist/commands/keys.d.ts +33 -0
  70. package/dist/commands/keys.js +141 -0
  71. package/dist/commands/keys.js.map +1 -0
  72. package/dist/commands/login.d.ts +22 -0
  73. package/dist/commands/login.js +78 -0
  74. package/dist/commands/login.js.map +1 -0
  75. package/dist/commands/logs.d.ts +14 -0
  76. package/dist/commands/logs.js +89 -0
  77. package/dist/commands/logs.js.map +1 -0
  78. package/dist/commands/members.d.ts +32 -0
  79. package/dist/commands/members.js +77 -0
  80. package/dist/commands/members.js.map +1 -0
  81. package/dist/commands/model-keys.d.ts +22 -0
  82. package/dist/commands/model-keys.js +222 -0
  83. package/dist/commands/model-keys.js.map +1 -0
  84. package/dist/commands/open.d.ts +29 -0
  85. package/dist/commands/open.js +119 -0
  86. package/dist/commands/open.js.map +1 -0
  87. package/dist/commands/parity.d.ts +15 -0
  88. package/dist/commands/parity.js +73 -0
  89. package/dist/commands/parity.js.map +1 -0
  90. package/dist/commands/rollback.d.ts +21 -0
  91. package/dist/commands/rollback.js +26 -0
  92. package/dist/commands/rollback.js.map +1 -0
  93. package/dist/commands/schedules.d.ts +52 -0
  94. package/dist/commands/schedules.js +231 -0
  95. package/dist/commands/schedules.js.map +1 -0
  96. package/dist/commands/scopes.d.ts +3 -0
  97. package/dist/commands/scopes.js +21 -0
  98. package/dist/commands/scopes.js.map +1 -0
  99. package/dist/commands/sessions.d.ts +53 -0
  100. package/dist/commands/sessions.js +142 -0
  101. package/dist/commands/sessions.js.map +1 -0
  102. package/dist/commands/setup.d.ts +28 -0
  103. package/dist/commands/setup.js +949 -0
  104. package/dist/commands/setup.js.map +1 -0
  105. package/dist/commands/status.d.ts +19 -0
  106. package/dist/commands/status.js +63 -0
  107. package/dist/commands/status.js.map +1 -0
  108. package/dist/commands/update.d.ts +14 -0
  109. package/dist/commands/update.js +120 -0
  110. package/dist/commands/update.js.map +1 -0
  111. package/dist/commands/usage.d.ts +3 -0
  112. package/dist/commands/usage.js +37 -0
  113. package/dist/commands/usage.js.map +1 -0
  114. package/dist/commands/webhooks.d.ts +47 -0
  115. package/dist/commands/webhooks.js +156 -0
  116. package/dist/commands/webhooks.js.map +1 -0
  117. package/dist/commands/whoami.d.ts +7 -0
  118. package/dist/commands/whoami.js +17 -0
  119. package/dist/commands/whoami.js.map +1 -0
  120. package/dist/completions.d.ts +13 -0
  121. package/dist/completions.js +120 -0
  122. package/dist/completions.js.map +1 -0
  123. package/dist/config.d.ts +20 -0
  124. package/dist/config.js +98 -0
  125. package/dist/config.js.map +1 -0
  126. package/dist/context.d.ts +14 -0
  127. package/dist/context.js +38 -0
  128. package/dist/context.js.map +1 -0
  129. package/dist/device-flow.d.ts +9 -0
  130. package/dist/device-flow.js +127 -0
  131. package/dist/device-flow.js.map +1 -0
  132. package/dist/harness-preflight.d.ts +24 -0
  133. package/dist/harness-preflight.js +252 -0
  134. package/dist/harness-preflight.js.map +1 -0
  135. package/dist/harnesses.d.ts +11 -0
  136. package/dist/harnesses.js +112 -0
  137. package/dist/harnesses.js.map +1 -0
  138. package/dist/index.d.ts +3 -0
  139. package/dist/index.js +3 -0
  140. package/dist/index.js.map +1 -0
  141. package/dist/login-offer.d.ts +9 -0
  142. package/dist/login-offer.js +36 -0
  143. package/dist/login-offer.js.map +1 -0
  144. package/dist/manifest.d.ts +15 -0
  145. package/dist/manifest.js +142 -0
  146. package/dist/manifest.js.map +1 -0
  147. package/dist/money.d.ts +1 -0
  148. package/dist/money.js +16 -0
  149. package/dist/money.js.map +1 -0
  150. package/dist/output.d.ts +2 -0
  151. package/dist/output.js +20 -0
  152. package/dist/output.js.map +1 -0
  153. package/dist/runtime.d.ts +19 -0
  154. package/dist/runtime.js +95 -0
  155. package/dist/runtime.js.map +1 -0
  156. package/dist/scaffold.d.ts +16 -0
  157. package/dist/scaffold.js +74 -0
  158. package/dist/scaffold.js.map +1 -0
  159. package/dist/setup-scan.d.ts +21 -0
  160. package/dist/setup-scan.js +127 -0
  161. package/dist/setup-scan.js.map +1 -0
  162. package/dist/templates.d.ts +14 -0
  163. package/dist/templates.js +65 -0
  164. package/dist/templates.js.map +1 -0
  165. package/dist/validators.d.ts +10 -0
  166. package/dist/validators.js +116 -0
  167. package/dist/validators.js.map +1 -0
  168. package/dist/version-check.d.ts +25 -0
  169. package/dist/version-check.js +68 -0
  170. package/dist/version-check.js.map +1 -0
  171. package/node_modules/@dharta/sdk/LICENSE +21 -0
  172. package/node_modules/@dharta/sdk/README.md +87 -0
  173. package/node_modules/@dharta/sdk/dist/errors.d.ts +20 -0
  174. package/node_modules/@dharta/sdk/dist/errors.d.ts.map +1 -0
  175. package/node_modules/@dharta/sdk/dist/errors.js +51 -0
  176. package/node_modules/@dharta/sdk/dist/errors.js.map +1 -0
  177. package/node_modules/@dharta/sdk/dist/http.d.ts +26 -0
  178. package/node_modules/@dharta/sdk/dist/http.d.ts.map +1 -0
  179. package/node_modules/@dharta/sdk/dist/http.js +136 -0
  180. package/node_modules/@dharta/sdk/dist/http.js.map +1 -0
  181. package/node_modules/@dharta/sdk/dist/index.d.ts +15 -0
  182. package/node_modules/@dharta/sdk/dist/index.d.ts.map +1 -0
  183. package/node_modules/@dharta/sdk/dist/index.js +21 -0
  184. package/node_modules/@dharta/sdk/dist/index.js.map +1 -0
  185. package/node_modules/@dharta/sdk/dist/resources.d.ts +48 -0
  186. package/node_modules/@dharta/sdk/dist/resources.d.ts.map +1 -0
  187. package/node_modules/@dharta/sdk/dist/resources.js +150 -0
  188. package/node_modules/@dharta/sdk/dist/resources.js.map +1 -0
  189. package/node_modules/@dharta/sdk/dist/types.d.ts +94 -0
  190. package/node_modules/@dharta/sdk/dist/types.d.ts.map +1 -0
  191. package/node_modules/@dharta/sdk/dist/types.js +59 -0
  192. package/node_modules/@dharta/sdk/dist/types.js.map +1 -0
  193. package/node_modules/@dharta/sdk/package.json +32 -0
  194. package/node_modules/@dharta/sdk/src/errors.ts +58 -0
  195. package/node_modules/@dharta/sdk/src/http.ts +166 -0
  196. package/node_modules/@dharta/sdk/src/index.ts +36 -0
  197. package/node_modules/@dharta/sdk/src/resources.ts +197 -0
  198. package/node_modules/@dharta/sdk/src/types.ts +179 -0
  199. package/package.json +38 -0
  200. package/src/agent.ts +147 -0
  201. package/src/api-error.ts +88 -0
  202. package/src/browser.ts +20 -0
  203. package/src/cli.ts +2339 -0
  204. package/src/commands/agents.ts +635 -0
  205. package/src/commands/analytics.ts +181 -0
  206. package/src/commands/api.ts +297 -0
  207. package/src/commands/appearance.ts +79 -0
  208. package/src/commands/artifact.ts +331 -0
  209. package/src/commands/audit-log.ts +125 -0
  210. package/src/commands/auth.ts +186 -0
  211. package/src/commands/caps.ts +302 -0
  212. package/src/commands/create.ts +114 -0
  213. package/src/commands/deploy.ts +1122 -0
  214. package/src/commands/dev.ts +792 -0
  215. package/src/commands/directories.ts +136 -0
  216. package/src/commands/doctor.ts +138 -0
  217. package/src/commands/embed-keys.ts +237 -0
  218. package/src/commands/env.ts +369 -0
  219. package/src/commands/files.ts +124 -0
  220. package/src/commands/git-credential.ts +78 -0
  221. package/src/commands/kartas.ts +439 -0
  222. package/src/commands/keys.ts +206 -0
  223. package/src/commands/login.ts +115 -0
  224. package/src/commands/logs.ts +111 -0
  225. package/src/commands/members.ts +109 -0
  226. package/src/commands/model-keys.ts +278 -0
  227. package/src/commands/open.ts +173 -0
  228. package/src/commands/parity.ts +96 -0
  229. package/src/commands/rollback.ts +45 -0
  230. package/src/commands/schedules.ts +290 -0
  231. package/src/commands/scopes.ts +22 -0
  232. package/src/commands/sessions.ts +225 -0
  233. package/src/commands/setup.ts +1176 -0
  234. package/src/commands/status.ts +94 -0
  235. package/src/commands/update.ts +157 -0
  236. package/src/commands/usage.ts +41 -0
  237. package/src/commands/webhooks.ts +200 -0
  238. package/src/commands/whoami.ts +24 -0
  239. package/src/completions.ts +168 -0
  240. package/src/config.ts +143 -0
  241. package/src/context.ts +59 -0
  242. package/src/device-flow.ts +174 -0
  243. package/src/harness-preflight.ts +293 -0
  244. package/src/harnesses.ts +117 -0
  245. package/src/index.ts +3 -0
  246. package/src/login-offer.ts +49 -0
  247. package/src/manifest.ts +153 -0
  248. package/src/money.ts +15 -0
  249. package/src/output.ts +22 -0
  250. package/src/runtime.ts +127 -0
  251. package/src/scaffold.ts +102 -0
  252. package/src/setup-scan.ts +154 -0
  253. package/src/templates.ts +85 -0
  254. package/src/validators.ts +124 -0
  255. package/src/version-check.ts +75 -0
  256. package/templates/blank/CLAUDE.md +21 -0
  257. package/templates/blank/dharta.toml +11 -0
  258. package/templates/faq-agent/CLAUDE.md +51 -0
  259. package/templates/faq-agent/dharta.toml +11 -0
package/src/config.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import * as path from "node:path";
4
+ import TOML from "@iarna/toml";
5
+
6
+ // One host schema (RFC 0013 CLI-7, executing RFC 0011 HOST-5's CLI
7
+ // note). D11's coordinate system has three hosts, each with one auth
8
+ // model; the CLI talks to two of them:
9
+ // base_url - the control API (Bearer dh_live_): login, projects,
10
+ // templates, rollback. The Rails app - dharta.ai today,
11
+ // console.dharta.ai after the HOST-3 cutover.
12
+ // git_base_url - the hosted git server (Basic + API key): where
13
+ // `dharta deploy` points the remote and which host the
14
+ // credential helper answers for.
15
+ // The agent host (agent.dharta.ai, the consumer endpoint) is never
16
+ // configured here: `dharta open` reads the project's agent_url from the
17
+ // control plane, and `dharta dev` serves the same protocol locally.
18
+ export interface Profile {
19
+ api_key: string;
20
+ base_url: string;
21
+ git_base_url?: string;
22
+ // RFC 0043 D8: the data-plane git host this profile clones karta workspaces from
23
+ // (learned from /me at login). Distinct from git_base_url (the RFC 0011 push host) -
24
+ // the clone bytes flow straight from the data plane, never through the control plane.
25
+ workspace_git_base_url?: string;
26
+ }
27
+
28
+ export interface Config {
29
+ default_profile: string;
30
+ profiles: Record<string, Profile>;
31
+ }
32
+
33
+ // Allow tests to redirect HOME without setting process.env.HOME globally.
34
+ let homeOverride: string | undefined;
35
+
36
+ export function setHomeForTesting(home: string | undefined): void {
37
+ homeOverride = home;
38
+ }
39
+
40
+ export function configPath(): string {
41
+ const home = homeOverride ?? process.env.HOME ?? homedir();
42
+ return path.join(home, ".dharta", "config.toml");
43
+ }
44
+
45
+ // NOT api.dharta.ai: that name resolves to the data plane (the RFC 0011
46
+ // as-built base-URL confusion this constant fixes). The control API
47
+ // lives on the Rails app.
48
+ const DEFAULT_BASE_URL = "https://dharta.ai";
49
+ const DEFAULT_GIT_BASE_URL = "https://git.dharta.ai";
50
+
51
+ export async function loadConfig(): Promise<Config | null> {
52
+ try {
53
+ const raw = await fs.readFile(configPath(), "utf8");
54
+ const parsed = TOML.parse(raw) as unknown as Partial<Config>;
55
+ if (!parsed.default_profile || !parsed.profiles) return null;
56
+ return {
57
+ default_profile: parsed.default_profile,
58
+ profiles: parsed.profiles,
59
+ };
60
+ } catch (err: unknown) {
61
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
62
+ throw err;
63
+ }
64
+ }
65
+
66
+ export async function saveProfile(
67
+ name: string,
68
+ profile: Profile,
69
+ ): Promise<string> {
70
+ const target = configPath();
71
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
72
+ const existing = (await loadConfig()) ?? {
73
+ default_profile: name,
74
+ profiles: {},
75
+ };
76
+ existing.profiles[name] = profile;
77
+ existing.default_profile = name;
78
+ await fs.writeFile(
79
+ target,
80
+ TOML.stringify(existing as unknown as TOML.JsonMap),
81
+ {
82
+ mode: 0o600,
83
+ },
84
+ );
85
+ return target;
86
+ }
87
+
88
+ // Remove a stored profile (auth logout). Returns false if it didn't
89
+ // exist. If the default profile was removed, the first remaining one
90
+ // becomes the default; the file is deleted when no profiles remain.
91
+ export async function removeProfile(name: string): Promise<boolean> {
92
+ const cfg = await loadConfig();
93
+ if (!cfg || !cfg.profiles[name]) return false;
94
+ delete cfg.profiles[name];
95
+
96
+ const remaining = Object.keys(cfg.profiles);
97
+ if (remaining.length === 0) {
98
+ await fs.rm(configPath(), { force: true });
99
+ return true;
100
+ }
101
+ if (cfg.default_profile === name) cfg.default_profile = remaining[0];
102
+ await fs.writeFile(
103
+ configPath(),
104
+ TOML.stringify(cfg as unknown as TOML.JsonMap),
105
+ { mode: 0o600 },
106
+ );
107
+ return true;
108
+ }
109
+
110
+ export async function activeProfile(): Promise<Profile> {
111
+ const cfg = await loadConfig();
112
+ if (!cfg) {
113
+ throw new Error(
114
+ "Not logged in. Run `dharta login` to store an API key in ~/.dharta/config.toml.",
115
+ );
116
+ }
117
+ const profile = cfg.profiles[cfg.default_profile];
118
+ if (!profile) {
119
+ throw new Error(
120
+ `Default profile '${cfg.default_profile}' not found in ${configPath()}. ` +
121
+ "Run `dharta login` to recreate it.",
122
+ );
123
+ }
124
+ return {
125
+ api_key: profile.api_key,
126
+ base_url: profile.base_url || DEFAULT_BASE_URL,
127
+ git_base_url: profile.git_base_url || DEFAULT_GIT_BASE_URL,
128
+ workspace_git_base_url: profile.workspace_git_base_url,
129
+ };
130
+ }
131
+
132
+ // Base URL for commands that work without a credential (init's template
133
+ // fetch): env override, then the stored profile, then the default.
134
+ export async function resolveBaseUrl(): Promise<string> {
135
+ if (process.env.DHARTA_BASE_URL) return process.env.DHARTA_BASE_URL;
136
+ try {
137
+ return (await activeProfile()).base_url;
138
+ } catch {
139
+ return DEFAULT_BASE_URL;
140
+ }
141
+ }
142
+
143
+ export { DEFAULT_BASE_URL, DEFAULT_GIT_BASE_URL };
package/src/context.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { Dharta, HttpClient } from "@dharta/sdk";
2
+ import { activeProfile, DEFAULT_BASE_URL } from "./config.js";
3
+ import { cliFormatError, cliFormatNetworkError } from "./api-error.js";
4
+
5
+ export interface CliContext {
6
+ sdk: Dharta;
7
+ http: HttpClient;
8
+ baseUrl: string;
9
+ // Exposed for `dharta deploy`, which hands the key to git's
10
+ // credential helper via the child process env.
11
+ apiKey: string;
12
+ // The resolved fetch impl (a test stub, or undefined = global fetch). Exposed so
13
+ // `dharta files` can build a second HttpClient for the DATA-PLANE files host (a
14
+ // different origin than the control-plane base_url) while staying testable.
15
+ fetch?: typeof fetch;
16
+ }
17
+
18
+ // Allow tests to inject a stub fetch + base config without touching disk.
19
+ export interface CtxOverrides {
20
+ apiKey?: string;
21
+ baseUrl?: string;
22
+ fetch?: typeof fetch;
23
+ }
24
+
25
+ export async function buildContext(
26
+ over: CtxOverrides = {},
27
+ ): Promise<CliContext> {
28
+ let apiKey = over.apiKey;
29
+ let baseUrl = over.baseUrl;
30
+ // Env overrides config everywhere (the GH_TOKEN pattern, RFC 0013
31
+ // §2); explicit per-call overrides beat both. DHARTA_BASE_URL applies
32
+ // regardless of where the key came from, matching resolveBaseUrl.
33
+ baseUrl = baseUrl ?? process.env.DHARTA_BASE_URL;
34
+ if (!apiKey && process.env.DHARTA_API_KEY) {
35
+ apiKey = process.env.DHARTA_API_KEY;
36
+ }
37
+ if (!apiKey) {
38
+ const p = await activeProfile();
39
+ apiKey = p.api_key;
40
+ baseUrl = baseUrl ?? p.base_url;
41
+ }
42
+ if (!baseUrl) baseUrl = DEFAULT_BASE_URL;
43
+ const fetchImpl = over.fetch;
44
+ const sdk = new Dharta({
45
+ apiKey,
46
+ baseUrl,
47
+ fetch: fetchImpl,
48
+ formatError: cliFormatError,
49
+ formatNetworkError: cliFormatNetworkError,
50
+ });
51
+ const http = new HttpClient({
52
+ apiKey,
53
+ baseUrl,
54
+ fetch: fetchImpl,
55
+ formatError: cliFormatError,
56
+ formatNetworkError: cliFormatNetworkError,
57
+ });
58
+ return { sdk, http, baseUrl, apiKey, fetch: fetchImpl };
59
+ }
@@ -0,0 +1,174 @@
1
+ // RFC 8628 device-flow client for `dharta login` (RFC 0013 CLI-1).
2
+ // Mints a code pair on the control plane, shows the user code, opens the
3
+ // approve page, and polls the token endpoint until the browser approval
4
+ // mints a named CLI credential.
5
+
6
+ import { openBrowser } from "./browser.js";
7
+
8
+ export interface DeviceFlowOptions {
9
+ baseUrl: string;
10
+ clientName?: string;
11
+ fetchImpl?: typeof fetch;
12
+ log?: (line: string) => void;
13
+ openBrowser?: (url: string) => Promise<boolean>;
14
+ sleep?: (ms: number) => Promise<void>;
15
+ }
16
+
17
+ interface MintResponse {
18
+ device_code: string;
19
+ user_code: string;
20
+ verification_uri: string;
21
+ verification_uri_complete?: string;
22
+ expires_in: number;
23
+ interval?: number;
24
+ }
25
+
26
+ const EXPIRED_MESSAGE =
27
+ "The login code expired before it was approved. Run `dharta login` again.";
28
+ // Consecutive network failures tolerated before giving up — the
29
+ // approval window is minutes long, so one wifi blip must not abort it.
30
+ const MAX_CONSECUTIVE_NETWORK_FAILURES = 5;
31
+
32
+ // Resolves to the freshly minted API key (dh_live_…).
33
+ export async function deviceFlowLogin(opts: DeviceFlowOptions): Promise<string> {
34
+ const fetchImpl = (opts.fetchImpl ?? globalThis.fetch).bind(globalThis);
35
+ const log = opts.log ?? ((line: string) => console.log(line));
36
+ const sleep =
37
+ opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
38
+ const base = opts.baseUrl.replace(/\/+$/, "");
39
+
40
+ let mintRes: Response;
41
+ try {
42
+ mintRes = await fetchImpl(`${base}/cli/device_authorizations`, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
45
+ body: JSON.stringify({ client_name: opts.clientName }),
46
+ });
47
+ } catch (error) {
48
+ throw new Error(
49
+ `Could not start the login: could not reach ${base} (${formatCause(error)}). ` +
50
+ "Check your network and try `dharta login` again.",
51
+ );
52
+ }
53
+ if (!mintRes.ok) {
54
+ throw new Error(
55
+ `Could not start the login (${mintRes.status} from ${base}). ` +
56
+ "Check your network and try `dharta login` again.",
57
+ );
58
+ }
59
+ let mint: MintResponse;
60
+ try {
61
+ mint = (await mintRes.json()) as MintResponse;
62
+ } catch (error) {
63
+ throw new Error(
64
+ `Could not start the login: ${base} returned invalid JSON (${formatCause(error)}). ` +
65
+ "Check DHARTA_BASE_URL or try `dharta login` again.",
66
+ );
67
+ }
68
+ if (
69
+ typeof mint.device_code !== "string" ||
70
+ typeof mint.user_code !== "string" ||
71
+ typeof mint.verification_uri !== "string" ||
72
+ typeof mint.expires_in !== "number"
73
+ ) {
74
+ throw new Error(
75
+ `Could not start the login: ${base} returned an incomplete device login response. ` +
76
+ "Check DHARTA_BASE_URL or try `dharta login` again.",
77
+ );
78
+ }
79
+
80
+ const approveUrl = mint.verification_uri_complete ?? mint.verification_uri;
81
+ log(`! First, verify this code in your browser: ${mint.user_code}`);
82
+
83
+ const opened = await (opts.openBrowser ?? openBrowser)(approveUrl);
84
+ log(
85
+ opened
86
+ ? `Opening ${approveUrl} in your browser…`
87
+ : `Open this URL to approve: ${approveUrl}`,
88
+ );
89
+ log("Waiting for approval…");
90
+
91
+ // RFC 8628: interval is optional, default 5s. Guard against a
92
+ // missing/garbage value — setTimeout(NaN) fires immediately and
93
+ // would hot-loop the poll.
94
+ let intervalMs = Math.max(Number(mint.interval) || 5, 1) * 1000;
95
+ const deadline = Date.now() + mint.expires_in * 1000;
96
+ let networkFailures = 0;
97
+
98
+ while (Date.now() < deadline) {
99
+ await sleep(intervalMs);
100
+
101
+ let res: Response;
102
+ try {
103
+ res = await fetchImpl(`${base}/cli/device_authorizations/token`, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
106
+ body: JSON.stringify({ device_code: mint.device_code }),
107
+ });
108
+ } catch {
109
+ // Transient network failure mid-poll: keep waiting, the
110
+ // deadline bounds the loop.
111
+ networkFailures += 1;
112
+ if (networkFailures >= MAX_CONSECUTIVE_NETWORK_FAILURES) {
113
+ throw new Error(
114
+ `Lost the connection to ${base} while waiting for approval. ` +
115
+ "Check your network and run `dharta login` again.",
116
+ );
117
+ }
118
+ continue;
119
+ }
120
+ networkFailures = 0;
121
+
122
+ if (res.ok) {
123
+ let token: string | undefined;
124
+ try {
125
+ token = ((await res.json()) as { access_token?: string }).access_token;
126
+ } catch {
127
+ // fall through to the throw below
128
+ }
129
+ if (!token) {
130
+ throw new Error(
131
+ "The server approved the login but returned no token. Run `dharta login` again.",
132
+ );
133
+ }
134
+ return token;
135
+ }
136
+
137
+ if (res.status === 429) {
138
+ // Rate-limited (Rack::Attack): back off like slow_down rather
139
+ // than keeping the cadence that tripped the limit.
140
+ await res.text().catch(() => {});
141
+ intervalMs += 5000;
142
+ continue;
143
+ }
144
+
145
+ let error = "";
146
+ try {
147
+ error = ((await res.json()) as { error?: string }).error ?? "";
148
+ } catch {
149
+ // non-JSON error body: fall through to the unknown-error throw
150
+ }
151
+
152
+ switch (error) {
153
+ case "authorization_pending":
154
+ continue;
155
+ case "slow_down":
156
+ intervalMs += 5000; // RFC 8628 §3.5: back off by 5s on slow_down
157
+ continue;
158
+ case "expired_token":
159
+ throw new Error(EXPIRED_MESSAGE);
160
+ case "access_denied":
161
+ throw new Error("The login request was denied in the browser.");
162
+ default:
163
+ throw new Error(
164
+ `Login failed (${error || `HTTP ${res.status}`}). Run \`dharta login\` again.`,
165
+ );
166
+ }
167
+ }
168
+
169
+ throw new Error(EXPIRED_MESSAGE);
170
+ }
171
+
172
+ function formatCause(error: unknown): string {
173
+ return error instanceof Error && error.message ? error.message : String(error);
174
+ }
@@ -0,0 +1,293 @@
1
+ // RFC 0035 D7: an advisory, client-side pre-flight for a dharta.toml [harness]
2
+ // version declaration. Release acceptance on the control plane is authoritative;
3
+ // this check is stale-able (the catalog is CDN-cached) and bypassable (the API
4
+ // and old CLIs exist), so a green pre-flight is not a guarantee. Its only job is
5
+ // fast LOCAL feedback before a deploy round-trips:
6
+ //
7
+ // - block a version pin the platform can't honour (exit non-zero, like the
8
+ // server would, but instantly and with the supported list), and
9
+ // - surface a local-vs-cloud version gap so the developer deploys knowingly.
10
+ //
11
+ // Resolution rule A (locked): a range like "2" resolves to the LATEST matching
12
+ // supported version for everyone and for CI - deterministic, the same on every
13
+ // machine. The locally-installed build never changes what deploys; it only
14
+ // *guides* an optional parity pin. So this pre-flight warns on a gap and offers
15
+ // the closest version to pin, but the deploy target itself stays deterministic.
16
+ //
17
+ // It must never block a deploy on its own failure: any error reaching the
18
+ // catalog, parsing, or probing the local binary degrades to "proceed" and lets
19
+ // the authoritative server have the final word. The two deliberate aborts are an
20
+ // unsatisfiable pin and a developer answering "no" to the gap prompt.
21
+
22
+ import { existsSync } from "node:fs";
23
+ import { readFile } from "node:fs/promises";
24
+ import { join } from "node:path";
25
+ import { spawn } from "node:child_process";
26
+ import * as readline from "node:readline";
27
+ import TOML from "@iarna/toml";
28
+ import { sniffHarnessType } from "./harnesses.js";
29
+ import { whichAll } from "./runtime.js";
30
+
31
+ export interface CatalogVersion {
32
+ version: string;
33
+ status: string;
34
+ }
35
+ export interface CatalogHarness {
36
+ id: string;
37
+ name: string;
38
+ default_version?: string | null;
39
+ versions: CatalogVersion[];
40
+ }
41
+
42
+ // The binary whose `--version` reports the locally-installed harness build. Only
43
+ // types we can probe are listed; an unlisted type yields a null local version
44
+ // (the pin is still validated against the catalog, just no gap warning).
45
+ const LOCAL_BIN: Record<string, string> = {
46
+ "claude-code": "claude",
47
+ opencode: "opencode",
48
+ };
49
+
50
+ export interface PreflightDeps {
51
+ dir: string;
52
+ baseUrl: string;
53
+ log: (line: string) => void;
54
+ // Injectable seams so unit tests never touch the network, a real binary, or a
55
+ // TTY. Defaults wire the real implementations.
56
+ fetchImpl?: typeof fetch;
57
+ detectLocalVersion?: (type: string) => Promise<string | null>;
58
+ confirm?: (question: string) => Promise<boolean>;
59
+ }
60
+
61
+ // `dharta.toml`'s [harness] table, shape-mirrored from the data plane's
62
+ // parse_harness_table (spec.py): an optional type + optional string version.
63
+ interface HarnessTable {
64
+ type?: string;
65
+ version?: string;
66
+ }
67
+
68
+ // Read [harness] from dir/dharta.toml. Returns null when there's no manifest, no
69
+ // [harness] table, or the file can't be parsed - all "nothing to check" cases.
70
+ async function readHarnessTable(dir: string): Promise<HarnessTable | null> {
71
+ const path = join(dir, "dharta.toml");
72
+ if (!existsSync(path)) return null;
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = TOML.parse(await readFile(path, "utf8"));
76
+ } catch {
77
+ return null; // a malformed manifest fails loudly at the server; not our job
78
+ }
79
+ const table = (parsed as Record<string, unknown>).harness;
80
+ if (table === null || typeof table !== "object") return null;
81
+ const t = table as Record<string, unknown>;
82
+ return {
83
+ type: typeof t.type === "string" && t.type.trim() ? t.type.trim() : undefined,
84
+ version: typeof t.version === "string" && t.version.trim() ? t.version.trim() : undefined,
85
+ };
86
+ }
87
+
88
+ // Back-compat type sniff, mirroring the data plane's sniff_harness_type: a tree
89
+ // is claude-code only when it carries Claude config and NOT OpenCode config;
90
+ // anything else (incl. a bare folder) is the opencode default. Used only when
91
+ // [harness] declares a version but omits the type.
92
+ export function sniffType(dir: string): string {
93
+ return sniffHarnessType(dir);
94
+ }
95
+
96
+ // Compare two dotted numeric versions; positive when a > b. Pads the shorter
97
+ // with zeros and compares segment-by-segment (Gem::Version-style, the server's
98
+ // ordering). Non-numeric segments compare as 0 - claude-code/opencode versions
99
+ // are plain numerics, and a malformed entry shouldn't crash the advisory path.
100
+ export function compareVersions(a: string, b: string): number {
101
+ const pa = a.split(".");
102
+ const pb = b.split(".");
103
+ const n = Math.max(pa.length, pb.length);
104
+ for (let i = 0; i < n; i++) {
105
+ const da = Number.parseInt(pa[i] ?? "0", 10) || 0;
106
+ const db = Number.parseInt(pb[i] ?? "0", 10) || 0;
107
+ if (da !== db) return da - db;
108
+ }
109
+ return 0;
110
+ }
111
+
112
+ // True when `version`'s dot segments start with all of `prefix`'s segments
113
+ // ("2" matches 2.x.x; "2.1" matches 2.1.x; an exact string also matches). Mirrors
114
+ // HarnessVersion.version_has_prefix? - a plain string prefix would wrongly match
115
+ // "20.x" for "2"; segment compare won't.
116
+ export function versionHasPrefix(version: string, prefix: string): boolean {
117
+ const v = version.split(".");
118
+ const p = prefix.split(".");
119
+ if (p.length === 0 || p.length > v.length) return false;
120
+ return p.every((seg, i) => v[i] === seg);
121
+ }
122
+
123
+ // Advisory mirror of Harness#resolve_version (rule A): blank -> the default (if
124
+ // supported), exact -> that version, prefix -> the LATEST matching. Returns null
125
+ // when nothing matches, exactly when the server would fail acceptance.
126
+ export function resolvePreview(
127
+ constraint: string | undefined,
128
+ supported: string[],
129
+ defaultVersion?: string | null,
130
+ ): string | null {
131
+ if (!constraint) {
132
+ return defaultVersion && supported.includes(defaultVersion) ? defaultVersion : null;
133
+ }
134
+ if (supported.includes(constraint)) return constraint;
135
+ const matching = supported.filter((v) => versionHasPrefix(v, constraint));
136
+ if (matching.length === 0) return null;
137
+ return matching.slice().sort((a, b) => compareVersions(b, a))[0];
138
+ }
139
+
140
+ // The supported version closest to the local build, to offer as a parity pin:
141
+ // the longest shared leading-segment prefix with `local` (stay in the same
142
+ // major.minor line), tie-broken by the latest within that line. Null when
143
+ // there's no local build or no supported versions.
144
+ export function closestForParity(local: string | null, supported: string[]): string | null {
145
+ if (!local || supported.length === 0) return null;
146
+ const lp = local.split(".");
147
+ const shared = (v: string): number => {
148
+ const vp = v.split(".");
149
+ let n = 0;
150
+ while (n < lp.length && n < vp.length && lp[n] === vp[n]) n++;
151
+ return n;
152
+ };
153
+ const maxShared = Math.max(...supported.map(shared));
154
+ return supported
155
+ .filter((v) => shared(v) === maxShared)
156
+ .sort((a, b) => compareVersions(b, a))[0];
157
+ }
158
+
159
+ // Fetch the public harness catalog (GET /harnesses.json). Returns null on any
160
+ // failure - the pre-flight then proceeds and the server decides.
161
+ async function fetchCatalog(
162
+ baseUrl: string,
163
+ fetchImpl: typeof fetch,
164
+ ): Promise<CatalogHarness[] | null> {
165
+ try {
166
+ const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/harnesses.json`);
167
+ if (!res.ok) return null;
168
+ const body = (await res.json()) as { harnesses?: CatalogHarness[] };
169
+ return Array.isArray(body.harnesses) ? body.harnesses : null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ // Probe the locally-installed harness build via `<bin> --version`, returning the
176
+ // first dotted-numeric token (e.g. "2.1.190" from "2.1.190 (Claude Code)") or
177
+ // null when the binary isn't on PATH or doesn't answer. Best-effort: any failure
178
+ // is a null, never a throw.
179
+ async function detectLocalVersionDefault(type: string): Promise<string | null> {
180
+ const bin = LOCAL_BIN[type];
181
+ if (!bin) return null;
182
+ if ((await whichAll(bin)).length === 0) return null;
183
+ const out = await runVersion(bin);
184
+ const match = out?.match(/\d+(?:\.\d+)+/);
185
+ return match ? match[0] : null;
186
+ }
187
+
188
+ function runVersion(bin: string): Promise<string | null> {
189
+ return new Promise((resolve) => {
190
+ let settled = false;
191
+ const done = (v: string | null) => {
192
+ if (!settled) {
193
+ settled = true;
194
+ resolve(v);
195
+ }
196
+ };
197
+ try {
198
+ const proc = spawn(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
199
+ const out: Buffer[] = [];
200
+ proc.stdout?.on("data", (c) => out.push(c));
201
+ proc.on("error", () => done(null));
202
+ proc.on("close", () => done(Buffer.concat(out).toString().trim() || null));
203
+ // A hung binary must not hang the deploy.
204
+ const timer = setTimeout(() => {
205
+ proc.kill();
206
+ done(null);
207
+ }, 4000);
208
+ timer.unref?.();
209
+ } catch {
210
+ done(null);
211
+ }
212
+ });
213
+ }
214
+
215
+ // TTY-aware [Y/n] confirm, default yes. Non-interactive (no TTY, or piped stdin
216
+ // in CI) auto-confirms so a deploy pipeline never hangs waiting on input - the
217
+ // gap was already printed, so it's surfaced either way.
218
+ function confirmTTYDefault(question: string): Promise<boolean> {
219
+ if (!process.stdin.isTTY) return Promise.resolve(true);
220
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
221
+ return new Promise((resolve) => {
222
+ rl.question(question, (answer) => {
223
+ rl.close();
224
+ const a = answer.trim().toLowerCase();
225
+ resolve(a === "" || a === "y" || a === "yes");
226
+ });
227
+ });
228
+ }
229
+
230
+ // Right-pad a label so the values line up in the printed table.
231
+ function row(label: string, value: string): string {
232
+ return ` ${label.padEnd(24)}${value}`;
233
+ }
234
+
235
+ // Run the pre-flight. Resolves normally to let the deploy proceed; throws to
236
+ // abort it (an unsatisfiable pin, or a declined gap prompt). Activates only when
237
+ // [harness] declares a version - an omitted version is always the curated
238
+ // default, so there's nothing to validate and the post-deploy echo shows what
239
+ // was frozen.
240
+ export async function harnessPreflight(deps: PreflightDeps): Promise<void> {
241
+ const table = await readHarnessTable(deps.dir);
242
+ if (!table?.version) return; // no declared constraint -> nothing to check
243
+
244
+ const type = table.type ?? sniffType(deps.dir);
245
+ const fetchImpl = deps.fetchImpl ?? fetch;
246
+ const catalog = await fetchCatalog(deps.baseUrl, fetchImpl);
247
+ if (!catalog) return; // catalog unreachable -> defer to the authoritative server
248
+
249
+ const harness = catalog.find((h) => h.id === type);
250
+ if (!harness) return; // unknown to the advisory catalog -> let the server judge
251
+ const supported = harness.versions.filter((v) => v.status === "supported").map((v) => v.version);
252
+
253
+ const detect = deps.detectLocalVersion ?? detectLocalVersionDefault;
254
+ const local = await detect(type).catch(() => null);
255
+ const localLabel = local ?? "not detected";
256
+
257
+ const resolved = resolvePreview(table.version, supported, harness.default_version);
258
+
259
+ if (resolved === null) {
260
+ // The pin matches no supported version. Block - the platform can't run a
261
+ // version it doesn't have - with the supported list so the fix is obvious.
262
+ const supportedLabel = supported.length ? supported.join(", ") : "(none published)";
263
+ throw new Error(
264
+ `Harness version pre-flight failed.\n` +
265
+ row(`Local ${type}:`, localLabel) + "\n" +
266
+ row("dharta.toml version:", table.version) + "\n" +
267
+ row("Supported on Dharta:", supportedLabel) + "\n\n" +
268
+ ` No Dharta-supported ${type} version matches "${table.version}". ` +
269
+ `Update or clear the version in [harness] in dharta.toml, then re-deploy.`,
270
+ );
271
+ }
272
+
273
+ if (local && local !== resolved) {
274
+ // The deploy target is deterministic (rule A: latest matching); the local
275
+ // build just differs. Surface the gap and the closest version to pin for
276
+ // parity, then let the developer decide.
277
+ const closest = closestForParity(local, supported);
278
+ deps.log(`⚠ Your local ${type} build differs from the deploy target.`);
279
+ deps.log(row(`Local ${type}:`, local));
280
+ deps.log(row("dharta.toml version:", `${table.version} (deploys as ${resolved})`));
281
+ if (closest && closest !== resolved) {
282
+ deps.log(row("Closest to your local:", `${closest} (pin it in [harness] for parity)`));
283
+ }
284
+ const confirm = deps.confirm ?? confirmTTYDefault;
285
+ const proceed = await confirm(` Deploy on ${type} ${resolved} anyway? [Y/n] `);
286
+ if (!proceed) {
287
+ throw new Error(
288
+ `Deploy cancelled - local ${type} ${local} differs from the deploy target ${resolved}. ` +
289
+ `Pin a version in [harness] in dharta.toml, or re-run to accept the difference.`,
290
+ );
291
+ }
292
+ }
293
+ }