@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
@@ -0,0 +1,1176 @@
1
+ import { constants, existsSync, promises as fs, readFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import * as path from "node:path";
4
+ import { findGitRoot, scanAgentFolders, renderTable, describeFolder, type AgentFolder } from "../setup-scan.js";
5
+ import { renderManifest, patchManifest, type DeployMethod } from "../manifest.js";
6
+ import { deriveSlug, validateAgentName } from "../agent.js";
7
+ import { defaultGitRunner, rejectOverlappingAgentRoots, type GitRunner } from "./deploy.js";
8
+ import { closestChoice, normalizeNonBlank } from "../validators.js";
9
+ import { DEFAULT_CREATE_HARNESS, hasAgentMarker } from "../harnesses.js";
10
+
11
+ // `dharta setup [path]` (RFC 0024 DEP-4): the one front door. It scans the
12
+ // repo for agent folders, lets you pick one, and writes its (self-
13
+ // documenting) dharta.toml - name, deploy gate, deployment transport.
14
+ //
15
+ // Destination-agnostic by construction: setup writes LOCAL config only and
16
+ // never contacts a Dharta environment. The selected transport is persisted on
17
+ // the agent itself as deploy_method, so the same folder behaves consistently
18
+ // from a laptop or git push. GitHub Actions is separate automation, not a
19
+ // third transport; its generated least-privilege workflow pins the transport
20
+ // selected when setup renders it.
21
+
22
+ export type Method = DeployMethod;
23
+ export type CiProvider = "github";
24
+
25
+ export interface SetupOptions {
26
+ cwd?: string;
27
+ name?: string;
28
+ enable?: boolean;
29
+ disable?: boolean;
30
+ method?: string;
31
+ ci?: string;
32
+ yes?: boolean;
33
+ // seams (tests / cli wiring)
34
+ ask?: (question: string) => Promise<string>;
35
+ gitRunner?: GitRunner;
36
+ log?: (line: string) => void;
37
+ }
38
+
39
+ export interface SetupResult {
40
+ folder: string; // configured folder, relative to the scan root
41
+ name: string;
42
+ deploy: boolean;
43
+ method: Method;
44
+ ci?: CiProvider;
45
+ manifestPath: string; // absolute
46
+ scaffolded: boolean; // true when dharta.toml was newly created (vs patched)
47
+ workflowScaffolded: boolean; // true when --ci github wrote this agent's workflow
48
+ gitInitialized: boolean;
49
+ }
50
+
51
+ const METHODS: Method[] = ["git", "folder"];
52
+ const METHOD_INPUTS = [...METHODS, "github"] as const;
53
+ const CI_PROVIDERS: CiProvider[] = ["github"];
54
+ const MANAGED_WORKFLOW_MARKER = "# Dharta managed workflow. Generated by dharta setup (format v1).";
55
+ const CLI_VERSION = (
56
+ JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }
57
+ ).version;
58
+
59
+ export async function setup(pathArg: string | undefined, opts: SetupOptions = {}): Promise<SetupResult> {
60
+ const cwd = path.resolve(opts.cwd ?? ".");
61
+ const git = opts.gitRunner ?? defaultGitRunner;
62
+ const log = opts.log ?? ((line: string) => console.error(line));
63
+ const ask = opts.yes ? undefined : opts.ask;
64
+ const pathInput = pathArg === undefined ? undefined : normalizeNonBlank(pathArg, "path");
65
+ const parsedMethod = parseMethod(opts.method); // validate configuration up front
66
+ const forcedMethod = parsedMethod.method;
67
+ const ci = parseCi(opts.ci) ?? parsedMethod.ci;
68
+ if (opts.enable && opts.disable) throw new Error("Use either --enable or --disable, not both.");
69
+ if (opts.name !== undefined) validateAgentName(opts.name, "--name");
70
+ // Resolve and validate an explicit target before setup can initialize git or
71
+ // write anything. Besides avoiding a stray .git on typos, this establishes a
72
+ // lexical + realpath boundary that a symlink cannot redirect later writes
73
+ // through.
74
+ let gitRoot = findGitRoot(cwd);
75
+ await validateExplicitSetupPath(cwd, pathInput, gitRoot ?? cwd);
76
+ if (parsedMethod.legacyGithub) {
77
+ log(
78
+ "Compatibility: --method github now means --method folder plus --ci github. " +
79
+ "Prefer the two explicit options in scripts.",
80
+ );
81
+ }
82
+
83
+ // --- 1. git context (needed by git transport and GitHub automation) -----
84
+ const containingPlainAgent = !gitRoot && !pathInput ? nearestAgentMarkerDir(cwd) : undefined;
85
+ const localSetupRoot = containingPlainAgent ?? cwd;
86
+ let gitInitialized = false;
87
+ const existingMethod = !gitRoot && !forcedMethod
88
+ ? await inferExistingMethodWithoutGit(localSetupRoot, pathInput)
89
+ : undefined;
90
+ const needsGit = ci === "github" || (forcedMethod ?? existingMethod) !== "folder";
91
+ if (!gitRoot && needsGit) {
92
+ const rel = relLabel(localSetupRoot);
93
+ const create = ask
94
+ ? yesNoCancel(await ask(`No git here. Create git in ${rel} ? [Yes] / [no] / [cancel] `))
95
+ : "yes"; // non-interactive default takes the [Yes] (or pass --method folder)
96
+ if (create === "cancel") throw new Error("setup canceled.");
97
+ if (create === "no" && ci === "github") {
98
+ throw new Error("--ci github requires a git repo. Initialize git, then run setup again.");
99
+ }
100
+ if (create === "yes") {
101
+ await run(git, ["-C", localSetupRoot, "init", "-b", "main"], "git init");
102
+ gitRoot = localSetupRoot;
103
+ gitInitialized = true;
104
+ log(`Initialized a git repo in ${rel}.`);
105
+ }
106
+ }
107
+ const scanRoot = gitRoot ?? containingPlainAgent ?? cwd;
108
+ const hasGit = !!gitRoot;
109
+
110
+ // --- 2. scan for agent folders -----------------------------------------
111
+ const folders = await scanAgentFolders(scanRoot, { isGitRepo: hasGit });
112
+ const shared: Shared = { cwd, git, hasGit, scanRoot, gitInitialized, forcedMethod, ci, ask, log };
113
+
114
+ // The nearest containing agent always wins, even if it is deep or ignored
115
+ // and therefore absent from the repo-wide discovery list. Setup from inside
116
+ // an agent must never fall through to a sibling selection.
117
+ if (!pathInput) {
118
+ const containingDir = nearestAgentMarkerDir(cwd, gitRoot ?? undefined);
119
+ if (containingDir) {
120
+ const target = folders.find((folder) => folder.dir === containingDir) ??
121
+ await describeFolder(containingDir, path.relative(scanRoot, containingDir));
122
+ requireAgent(target, pathInput);
123
+ return configureAgent(target, shared, opts);
124
+ }
125
+ }
126
+
127
+ // --- 3. interactive + several agents: select exactly one ----------------
128
+ // Setup is agent-scoped, just like deploy. Standing inside an agent selects
129
+ // it immediately. At an ambiguous repo root, show one menu and configure the
130
+ // single choice; configuring siblings requires explicit repeat invocations.
131
+ if (ask && !pathInput && folders.length >= 2) {
132
+ const target = await pickOneAgent(folders, shared);
133
+ if (!target) return snapshotResult(folders[0], shared);
134
+ return configureAgent(target, shared, opts);
135
+ }
136
+
137
+ // --- single agent / explicit path / non-interactive --------------------
138
+ if (ask && folders.length > 0) {
139
+ log("");
140
+ log(renderTable(folders));
141
+ log("");
142
+ }
143
+ const target = await pickFolder(pathInput, folders, scanRoot, cwd, ask);
144
+ requireAgent(target, pathInput);
145
+ return configureAgent(target, shared, opts);
146
+ }
147
+
148
+ function nearestAgentMarkerDir(start: string, boundary?: string): string | undefined {
149
+ let dir = path.resolve(start);
150
+ const stop = boundary === undefined ? undefined : path.resolve(boundary);
151
+ for (;;) {
152
+ if (hasAgentMarker(dir)) return dir;
153
+ if (stop && dir === stop) return undefined;
154
+ const parent = path.dirname(dir);
155
+ if (parent === dir) return undefined;
156
+ dir = parent;
157
+ }
158
+ }
159
+
160
+ // Everything one setup invocation shares across the agent(s) it configures: the
161
+ // git context (resolved once) and the IO seams.
162
+ interface Shared {
163
+ cwd: string;
164
+ git: GitRunner;
165
+ hasGit: boolean;
166
+ scanRoot: string;
167
+ gitInitialized: boolean;
168
+ forcedMethod?: Method; // --method, if passed
169
+ ci?: CiProvider; // --ci, if passed
170
+ ask?: (question: string) => Promise<string>;
171
+ log: (line: string) => void;
172
+ }
173
+
174
+ // setup configures an EXISTING agent; scaffolding a brand-new folder is
175
+ // `dharta create`'s job (RFC 0024). A folder with no harness or karta marker
176
+ // has no agent to configure - send them to create.
177
+ function requireAgent(target: AgentFolder, pathArg: string | undefined): void {
178
+ if (hasAgentMarker(target.dir)) return;
179
+ const here = target.rel === "." ? "this folder" : target.rel;
180
+ throw new Error(
181
+ `No agent to configure in ${here}. Create one first with \`dharta create${pathArg ? ` ${pathArg}` : ""}\`.`,
182
+ );
183
+ }
184
+
185
+ // Configure ONE agent: name, deploy gate, delivery method, then write the
186
+ // self-documenting dharta.toml and apply the method action.
187
+ async function configureAgent(target: AgentFolder, s: Shared, opts: SetupOptions): Promise<SetupResult> {
188
+ const { cwd, hasGit, scanRoot, gitInitialized, ask, log } = s;
189
+
190
+ // Discovery is read-only and may encounter paths supplied by the working
191
+ // tree. Re-establish the selected agent boundary before prompts or writes so
192
+ // an explicit or discovered symlink can never become a write target.
193
+ await validateSelectedAgentRoot(scanRoot, target.dir, target.rel);
194
+
195
+ const defaultName = target.name ?? deriveSlug(path.basename(target.dir));
196
+ const name = validateAgentName(opts.name === undefined
197
+ ? ((ask ? (await ask(`Agent name [${defaultName}]: `)).trim() : "") || defaultName)
198
+ : opts.name, "agent name");
199
+
200
+ // Default to the EXISTING gate so re-running setup on an enabled agent does
201
+ // not silently disable it; a brand-new agent has deployEnabled = false, so
202
+ // D6's fail-safe default still holds.
203
+ let deploy: boolean;
204
+ if (opts.enable) deploy = true;
205
+ else if (opts.disable) deploy = false;
206
+ else if (ask) {
207
+ const defaultDeploy = target.configured ? target.deployEnabled : false;
208
+ const hint = defaultDeploy ? "Y/n" : "y/N";
209
+ deploy = yesNo(await ask(`Enable deploy for this agent? [${hint}] `), defaultDeploy);
210
+ }
211
+ else deploy = target.deployEnabled;
212
+
213
+ // An explicit option changes the transport; otherwise preserve the method
214
+ // already configured for this agent. Only an unconfigured agent falls back
215
+ // to the repo-shaped default / interactive choice.
216
+ let method = s.forcedMethod ?? target.deployMethod;
217
+ if (!method) method = ask ? await askMethod(ask, hasGit) : hasGit ? "git" : "folder";
218
+ if (!hasGit) method = "folder"; // folder is forced without a git repo
219
+
220
+ // Enforce the same one-manifest-per-release-root invariant as deploy before
221
+ // either local artifact is written. A parent and child dharta.toml would make
222
+ // ownership of the child's files ambiguous regardless of transport or CI.
223
+ await rejectOverlappingAgentRoots(target.dir, s.git);
224
+ await rejectDuplicateAgentSlug(scanRoot, target.dir, name, hasGit);
225
+
226
+ const workflow = s.ci === "github"
227
+ ? githubWorkflowTarget(
228
+ scanRoot,
229
+ target.dir,
230
+ name,
231
+ method,
232
+ await currentGitBranch(s.git, scanRoot),
233
+ )
234
+ : undefined;
235
+ let workflowState: WorkflowState | undefined;
236
+ if (workflow) {
237
+ if (!hasGit) throw new Error("--ci github requires a git repo.");
238
+ await validateWorkflowTarget(workflow);
239
+ workflowState = await existingWorkflowState(workflow, log);
240
+ if (workflowState !== "custom") {
241
+ await rejectGitignoredSetupFile(s.git, scanRoot, workflow.dest, workflow.rel);
242
+ }
243
+ }
244
+
245
+ const manifestPath = path.join(target.dir, "dharta.toml");
246
+ await validateSafeWriteTarget(target.dir, manifestPath, "dharta.toml", false);
247
+ if (hasGit) {
248
+ await rejectGitignoredSetupFile(s.git, scanRoot, manifestPath, path.relative(scanRoot, manifestPath));
249
+ }
250
+ const existingManifest = await lstatIfPresent(manifestPath);
251
+ const scaffolded = existingManifest === undefined;
252
+ if (existingManifest) {
253
+ const patched = patchManifest(await fs.readFile(manifestPath, "utf8"), {
254
+ name,
255
+ deploy,
256
+ deployMethod: method,
257
+ });
258
+ await safeAtomicWriteFile(target.dir, manifestPath, "dharta.toml", patched);
259
+ } else {
260
+ await safeAtomicWriteFile(
261
+ target.dir,
262
+ manifestPath,
263
+ "dharta.toml",
264
+ renderManifest({
265
+ name,
266
+ deploy,
267
+ deployMethod: method,
268
+ harness: target.harnessType ?? DEFAULT_CREATE_HARNESS,
269
+ }),
270
+ );
271
+ }
272
+ log(
273
+ `${scaffolded ? "Wrote" : "Updated"} ${path.relative(cwd, manifestPath) || "dharta.toml"} ` +
274
+ `(name = ${name}, deploy = ${deploy}, deploy_method = ${method}).`,
275
+ );
276
+
277
+ applyMethod(method, { deploy, log });
278
+ const workflowScaffolded = workflow
279
+ ? await scaffoldGithubWorkflow(workflow, workflowState as WorkflowState, log)
280
+ : false;
281
+
282
+ return {
283
+ folder: target.rel,
284
+ name,
285
+ deploy,
286
+ method,
287
+ ci: s.ci,
288
+ manifestPath,
289
+ scaffolded,
290
+ workflowScaffolded,
291
+ gitInitialized,
292
+ };
293
+ }
294
+
295
+ async function pickOneAgent(initial: AgentFolder[], s: Shared): Promise<AgentFolder | null> {
296
+ const { scanRoot, hasGit, ask, log } = s;
297
+ const view = await Promise.all(initial.map((folder) => describeFolder(folder.dir, folder.rel)));
298
+ log("");
299
+ log(`Agents in ${hasGit ? "this repo" : "this folder"} (${scanRoot}):`);
300
+ log("");
301
+ log(renderMenu(view, null));
302
+ log("");
303
+ return selectAgent(ask as (q: string) => Promise<string>, view, log);
304
+ }
305
+
306
+ // The agent folder the user is standing in: an exact cwd match, else the
307
+ // deepest agent that contains cwd. null when cwd is outside every agent.
308
+ function currentAgentDir(folders: AgentFolder[], cwd: string): string | null {
309
+ const containing = folders
310
+ .filter((f) => f.dir === cwd || cwd.startsWith(f.dir + path.sep))
311
+ .sort((a, b) => b.dir.length - a.dir.length);
312
+ return containing[0]?.dir ?? null;
313
+ }
314
+
315
+ // Numbered agent menu: the current folder is prefixed [current], and a final
316
+ // `q` row cancels this invocation.
317
+ function renderMenu(folders: AgentFolder[], currentDir: string | null): string {
318
+ const headers = ["#", "AGENT FOLDER", "NAME", "HARNESS", "CONFIGURED", "DEPLOY", "METHOD"];
319
+ const rows = folders.map((f, i) => [
320
+ String(i + 1),
321
+ `${f.dir === currentDir ? "[current] " : ""}${f.rel === "." ? "./ (repo root)" : `./${f.rel}`}`,
322
+ f.name ?? "-",
323
+ f.harnessType ?? "-",
324
+ f.configured ? "yes" : "no",
325
+ f.configured ? (f.deployEnabled ? "enabled" : "disabled") : "-",
326
+ f.deployMethod ?? "-",
327
+ ]);
328
+ const quit = ["q", "quit", "", "", "", "", ""];
329
+ const widths = headers.map((_, i) => Math.max(...[headers, ...rows, quit].map((r) => r[i].length)));
330
+ const fmt = (cols: string[]) => cols.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
331
+ return [fmt(headers), ...rows.map(fmt), fmt(quit)].join("\n");
332
+ }
333
+
334
+ // Read one menu choice: a number, or 'q' to quit. An empty answer quits because
335
+ // an ambiguous repository root has no safe implicit agent selection.
336
+ async function selectAgent(
337
+ ask: (q: string) => Promise<string>,
338
+ folders: AgentFolder[],
339
+ log: (line: string) => void,
340
+ ): Promise<AgentFolder | null> {
341
+ for (;;) {
342
+ const choice = (await ask("Select one agent to set up, or q to quit [q]: ")).trim().toLowerCase() || "q";
343
+ if (choice === "q" || choice === "quit") return null;
344
+ const n = Number(choice);
345
+ if (Number.isInteger(n) && n >= 1 && n <= folders.length) return folders[n - 1];
346
+ log(`Enter a number 1-${folders.length}, or q to quit.`);
347
+ }
348
+ }
349
+
350
+ // A read-only SetupResult for "quit before configuring anything" - only used to
351
+ // satisfy the return type; the interactive caller never reads it.
352
+ function snapshotResult(f: AgentFolder, s: Shared): SetupResult {
353
+ return {
354
+ folder: f.rel,
355
+ name: f.name ?? deriveSlug(path.basename(f.dir)),
356
+ deploy: f.deployEnabled,
357
+ method: f.deployMethod ?? (s.hasGit ? "git" : "folder"),
358
+ ci: s.ci,
359
+ manifestPath: path.join(f.dir, "dharta.toml"),
360
+ scaffolded: false,
361
+ workflowScaffolded: false,
362
+ gitInitialized: s.gitInitialized,
363
+ };
364
+ }
365
+
366
+ interface ParsedMethodInput {
367
+ method?: Method;
368
+ ci?: CiProvider;
369
+ legacyGithub: boolean;
370
+ }
371
+
372
+ function parseMethod(raw: string | undefined): ParsedMethodInput {
373
+ if (raw === undefined) return { legacyGithub: false };
374
+ const normalized = normalizeNonBlank(raw, "--method");
375
+ // Backward compatibility for the old three-way transport switch. GitHub is
376
+ // automation, and the workflow it historically generated used folder
377
+ // upload, so preserve that behavior while making the two decisions explicit.
378
+ if (normalized === "github") {
379
+ return { method: "folder", ci: "github", legacyGithub: true };
380
+ }
381
+ if (!METHODS.includes(normalized as Method)) {
382
+ const suggestion = closestChoice(normalized, METHOD_INPUTS);
383
+ throw new Error(
384
+ `Unknown --method '${normalized}'. Use git or folder.` +
385
+ (suggestion ? ` Did you mean ${suggestion}?` : ""),
386
+ );
387
+ }
388
+ return { method: normalized as Method, legacyGithub: false };
389
+ }
390
+
391
+ function parseCi(raw: string | undefined): CiProvider | undefined {
392
+ if (raw === undefined) return undefined;
393
+ const normalized = normalizeNonBlank(raw, "--ci");
394
+ if (!CI_PROVIDERS.includes(normalized as CiProvider)) {
395
+ const suggestion = closestChoice(normalized, CI_PROVIDERS);
396
+ throw new Error(
397
+ `Unknown --ci '${normalized}'. Use github.` +
398
+ (suggestion ? ` Did you mean ${suggestion}?` : ""),
399
+ );
400
+ }
401
+ return normalized as CiProvider;
402
+ }
403
+
404
+ // Before setup decides whether to initialize git, honor a persisted folder
405
+ // method for the obvious single-agent / explicit-path cases. This prevents a
406
+ // routine setup re-run from changing a plain-folder project's repo shape.
407
+ async function inferExistingMethodWithoutGit(
408
+ cwd: string,
409
+ pathArg: string | undefined,
410
+ ): Promise<Method | undefined> {
411
+ if (pathArg) {
412
+ return (await describeFolder(path.resolve(cwd, pathArg), pathArg)).deployMethod;
413
+ }
414
+ const folders = await scanAgentFolders(cwd, { isGitRepo: false });
415
+ if (folders.length === 1) return folders[0].deployMethod;
416
+ if (folders.length > 1 && folders.every((folder) => folder.deployMethod === "folder")) {
417
+ return "folder";
418
+ }
419
+ return undefined;
420
+ }
421
+
422
+ async function validateExplicitSetupPath(
423
+ cwd: string,
424
+ pathArg: string | undefined,
425
+ boundary: string,
426
+ ): Promise<void> {
427
+ if (!pathArg) return;
428
+ const target = path.resolve(cwd, pathArg);
429
+ try {
430
+ await validateExistingDirectoryTree(boundary, target, `Cannot set up ${pathArg}`);
431
+ } catch (error) {
432
+ const code = (error as NodeJS.ErrnoException).code;
433
+ if (code === "ENOENT") {
434
+ throw new Error(
435
+ `Cannot set up ${pathArg}: that directory does not exist. ` +
436
+ `Create an agent first with \`dharta create ${pathArg}\`.`,
437
+ );
438
+ }
439
+ if (code === "ENOTDIR") {
440
+ throw new Error(`Cannot set up ${pathArg}: a parent path is not a directory.`);
441
+ }
442
+ throw error;
443
+ }
444
+ if (!hasAgentMarker(target)) {
445
+ throw new Error(
446
+ `Cannot set up ${pathArg}: no agent was found there. ` +
447
+ `Create one first with \`dharta create ${pathArg}\`.`,
448
+ );
449
+ }
450
+ }
451
+
452
+ async function validateSelectedAgentRoot(scanRoot: string, agentDir: string, rel: string): Promise<void> {
453
+ const label = rel === "." ? "selected agent root" : `selected agent root '${rel}'`;
454
+ await validateExistingDirectoryTree(scanRoot, agentDir, `Cannot use ${label}`);
455
+ }
456
+
457
+ // Resolve the agent folder: an explicit path, the sole candidate, an
458
+ // interactive pick, or (non-interactive + ambiguous) a loud error.
459
+ async function pickFolder(
460
+ pathArg: string | undefined,
461
+ folders: AgentFolder[],
462
+ scanRoot: string,
463
+ cwd: string,
464
+ ask: ((q: string) => Promise<string>) | undefined,
465
+ ): Promise<AgentFolder> {
466
+ if (pathArg) {
467
+ const requested = path.resolve(cwd, pathArg);
468
+ const [scanRootReal, requestedReal] = await Promise.all([fs.realpath(scanRoot), fs.realpath(requested)]);
469
+ const realRelative = containedRelativePath(scanRootReal, requestedReal);
470
+ // Use the scan root's spelling for all later reads/writes. This preserves
471
+ // harmless platform aliases such as macOS /var -> /private/var while
472
+ // removing any caller-supplied symlink component from the writable path.
473
+ const dir = realRelative === undefined ? requested : path.resolve(scanRoot, realRelative);
474
+ const match = folders.find((f) => f.dir === dir);
475
+ if (match) return match;
476
+ // Not surfaced by the scan (deep / untracked): describe it directly so an
477
+ // existing dharta.toml's name + deploy are read, not silently reset to the
478
+ // folder slug - which would rename the agent and change its deploy slug.
479
+ return describeFolder(dir, path.relative(scanRoot, dir));
480
+ }
481
+ const current = currentAgentDir(folders, cwd);
482
+ if (current) return folders.find((folder) => folder.dir === current) as AgentFolder;
483
+ if (folders.length === 1) return folders[0];
484
+ if (folders.length === 0) {
485
+ // No marker folder found - configure the scan root itself.
486
+ return { dir: scanRoot, rel: ".", configured: false, deployEnabled: false };
487
+ }
488
+ if (!ask) {
489
+ throw new Error(
490
+ "Multiple agent folders here. Pass a [path] to pick one, e.g. `dharta setup ./support-bot`.",
491
+ );
492
+ }
493
+ const answer = (await ask("Configure which folder? (path) ")).trim();
494
+ const dir = path.resolve(scanRoot, answer);
495
+ const match = folders.find((f) => f.dir === dir || f.rel === answer);
496
+ if (!match) throw new Error(`No agent folder matching '${answer}'.`);
497
+ return match;
498
+ }
499
+
500
+ async function askMethod(ask: (q: string) => Promise<string>, hasGit: boolean): Promise<Method> {
501
+ const def: Method = hasGit ? "git" : "folder";
502
+ const a = (await ask(`Deploy method - git (push) / folder (upload) [${def}]: `)).trim().toLowerCase();
503
+ return METHODS.includes(a as Method) ? (a as Method) : def;
504
+ }
505
+
506
+ function applyMethod(
507
+ method: Method,
508
+ o: { deploy: boolean; log: (l: string) => void },
509
+ ): void {
510
+ const deployHint = o.deploy ? "`dharta deploy`" : "enable it (deploy = true), then `dharta deploy`";
511
+ switch (method) {
512
+ case "git":
513
+ // The hosted repo + the `dharta` remote are provisioned at deploy time
514
+ // (dharta deploy self-provisions), keeping setup destination-agnostic.
515
+ o.log(
516
+ `Method: git. Deploy with ${deployHint} - it provisions the hosted repo and wires the 'karta' remote on first run. ` +
517
+ "After that, 'dharta deploy' remains the agent-scoped path. A direct deploy-branch push (for example, 'git push dharta main') is a compatibility shortcut only for repos with one enabled agent.",
518
+ );
519
+ return;
520
+ case "folder":
521
+ o.log(`Method: folder. Deploy with ${deployHint} - it uploads only this agent folder as a tarball.`);
522
+ return;
523
+ }
524
+ }
525
+
526
+ interface GithubWorkflowTarget {
527
+ repoRoot: string;
528
+ slug: string;
529
+ method: Method;
530
+ branch: string;
531
+ secretName: string;
532
+ agentDir: string; // POSIX-style path relative to the checkout root
533
+ rel: string;
534
+ dest: string;
535
+ }
536
+
537
+ function githubWorkflowTarget(
538
+ repoRoot: string,
539
+ agentDirectory: string,
540
+ name: string,
541
+ method: Method,
542
+ branch: string,
543
+ ): GithubWorkflowTarget {
544
+ const relative = path.relative(repoRoot, agentDirectory);
545
+ if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
546
+ throw new Error("--ci github requires the agent folder to be inside the git repo.");
547
+ }
548
+ const agentDir = relative === "" ? "." : relative.split(path.sep).join("/");
549
+ if (agentDir.includes("${{")) {
550
+ throw new Error(
551
+ "--ci github cannot generate a safe workflow for an agent path containing '${{'. " +
552
+ "Rename the folder, then run setup again.",
553
+ );
554
+ }
555
+ const slug = deriveSlug(name);
556
+ const rel = `.github/workflows/dharta-${slug}.yml`;
557
+ const secretName = `DHARTA_API_KEY_${slug.replaceAll("-", "_").toUpperCase()}`;
558
+ return { repoRoot, slug, method, branch, secretName, agentDir, rel, dest: path.join(repoRoot, rel) };
559
+ }
560
+
561
+ async function currentGitBranch(git: GitRunner, repoRoot: string): Promise<string> {
562
+ const result = await git(["-C", repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD"]);
563
+ const branch = result.stdout.trim();
564
+ if (result.code !== 0 || branch.length === 0 || branch.match(/[\0\r\n]/)) {
565
+ throw new Error(
566
+ "--ci github needs a checked-out branch so setup can generate the correct push trigger. " +
567
+ "Check out the deploy branch, then run setup again.",
568
+ );
569
+ }
570
+ if (branch.includes("${{")) {
571
+ throw new Error(
572
+ "--ci github cannot generate a safe workflow for a branch containing '${{'. " +
573
+ "Check out a different deploy branch, then run setup again.",
574
+ );
575
+ }
576
+ return branch;
577
+ }
578
+
579
+ async function rejectDuplicateAgentSlug(
580
+ scanRoot: string,
581
+ targetDir: string,
582
+ name: string,
583
+ isGitRepo: boolean,
584
+ ): Promise<void> {
585
+ const slug = deriveSlug(name);
586
+ const folders = isGitRepo
587
+ ? await scanAgentFolders(scanRoot, { isGitRepo: true })
588
+ : await recursiveConfiguredFolders(scanRoot);
589
+ const conflict = folders.find(
590
+ (folder) => path.resolve(folder.dir) !== path.resolve(targetDir) &&
591
+ folder.name !== undefined && deriveSlug(folder.name) === slug,
592
+ );
593
+ if (!conflict) return;
594
+
595
+ throw new Error(
596
+ `Agent name '${name}' resolves to slug '${slug}', already used by ${conflict.rel}. ` +
597
+ "Choose unique agent names before setup; Dharta deploy identity is the derived slug.",
598
+ );
599
+ }
600
+
601
+ async function recursiveConfiguredFolders(root: string): Promise<AgentFolder[]> {
602
+ const folders: AgentFolder[] = [];
603
+ const walk = async (dir: string): Promise<void> => {
604
+ if (existsSync(path.join(dir, "dharta.toml"))) {
605
+ folders.push(await describeFolder(dir, path.relative(root, dir) || "."));
606
+ }
607
+ let entries: import("node:fs").Dirent[];
608
+ try {
609
+ entries = await fs.readdir(dir, { withFileTypes: true });
610
+ } catch {
611
+ return;
612
+ }
613
+ for (const entry of entries) {
614
+ if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith(".")) continue;
615
+ await walk(path.join(dir, entry.name));
616
+ }
617
+ };
618
+ await walk(path.resolve(root));
619
+ return folders;
620
+ }
621
+
622
+ // GitHub is an automation layer, not a third persisted transport. Each agent
623
+ // owns one path-filtered workflow and one named secret, so changing support
624
+ // cannot trigger billing and concurrent runs for the same agent stay ordered.
625
+ // The job pins the method rendered by setup so its checkout, concurrency, and
626
+ // credential scope cannot drift from the actual command. Setup does not mint
627
+ // credentials; it explains the scope required by that method.
628
+ async function scaffoldGithubWorkflow(
629
+ target: GithubWorkflowTarget,
630
+ state: WorkflowState,
631
+ log: (l: string) => void,
632
+ ): Promise<boolean> {
633
+ if (state === "custom") return false;
634
+
635
+ await safeAtomicWriteFile(
636
+ target.repoRoot,
637
+ target.dest,
638
+ target.rel,
639
+ renderDeployWorkflow(target),
640
+ );
641
+ log(`CI: github. ${state === "generated" ? "Updated" : "Wrote"} ${target.rel} for ${target.agentDir}.`);
642
+ log(
643
+ ` This workflow is pinned to --mode ${target.method}. To change methods, rerun setup with ` +
644
+ "--ci github and rotate the agent's CI secret for the new scope.",
645
+ );
646
+ const scope = target.method === "folder" ? "releases:write" : "source_repos:write";
647
+ log(` To let CI deploy, add a ${scope} key as the ${target.secretName} repo secret:`);
648
+ log(` dharta keys create ci-${target.slug} --scope ${scope}`);
649
+ log(" (requires an active key with keys:write; dashboard creation also works)");
650
+ log(` gh secret set ${target.secretName} # or add it in repo Settings > Secrets`);
651
+ log(" Then push a change to this agent folder, or run the workflow manually.");
652
+ return true;
653
+ }
654
+
655
+ async function validateWorkflowTarget(target: GithubWorkflowTarget): Promise<void> {
656
+ // `target.dest` is always repo/.github/workflows/file. Derive the workflow
657
+ // directory without trusting a caller-supplied relative path.
658
+ const workflowsDir = path.dirname(target.dest);
659
+
660
+ await validateSafeWriteTarget(target.repoRoot, target.dest, target.rel, false);
661
+ await rejectLegacyRepoWorkflow(workflowsDir, target.dest);
662
+ await rejectConflictingGeneratedWorkflow(workflowsDir, target);
663
+ }
664
+
665
+ async function rejectConflictingGeneratedWorkflow(
666
+ workflowsDir: string,
667
+ target: GithubWorkflowTarget,
668
+ ): Promise<void> {
669
+ let entries: import("node:fs").Dirent[];
670
+ try {
671
+ entries = await fs.readdir(workflowsDir, { withFileTypes: true });
672
+ } catch (error) {
673
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
674
+ throw error;
675
+ }
676
+
677
+ const agentMarker = `# Agent directory: ${JSON.stringify(target.agentDir)}.`;
678
+ const unpinnedCommand = `dharta deploy --dir ${shellQuote(target.agentDir)}`;
679
+ for (const entry of entries) {
680
+ if (!entry.isFile() || !(entry.name.endsWith(".yml") || entry.name.endsWith(".yaml"))) continue;
681
+ const dest = path.join(workflowsDir, entry.name);
682
+ const content = await fs.readFile(dest, "utf8");
683
+ if (!isManagedWorkflow(content)) continue;
684
+
685
+ const deploysTarget = content.includes(agentMarker) || content.includes(unpinnedCommand);
686
+ if (path.resolve(dest) === path.resolve(target.dest) && !deploysTarget) {
687
+ throw new Error(
688
+ `${target.rel} is a generated workflow for a different agent folder. ` +
689
+ `Agent slug '${target.slug}' is already in use; choose unique dharta.toml names before setup.`,
690
+ );
691
+ }
692
+ if (path.resolve(dest) !== path.resolve(target.dest) && deploysTarget) {
693
+ const rel = `.github/workflows/${entry.name}`;
694
+ throw new Error(
695
+ `${rel} already deploys ${target.agentDir}. Remove or rename that generated workflow ` +
696
+ "before changing this agent's name, or both workflows could deploy the same folder.",
697
+ );
698
+ }
699
+ }
700
+ }
701
+
702
+ async function rejectLegacyRepoWorkflow(workflowsDir: string, targetDest: string): Promise<void> {
703
+ const rel = ".github/workflows/dharta-deploy.yml";
704
+ const dest = path.join(workflowsDir, "dharta-deploy.yml");
705
+ let content: string;
706
+ try {
707
+ content = await fs.readFile(dest, "utf8");
708
+ } catch (error) {
709
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" ||
710
+ (error as NodeJS.ErrnoException).code === "EISDIR") return;
711
+ throw error;
712
+ }
713
+
714
+ const generatedLegacy = content.includes("# Deploy this repo's Dharta agents on every push (RFC 0024).") &&
715
+ content.includes("DHARTA_API_KEY: ${{ secrets.DHARTA_API_KEY }}") &&
716
+ content.includes("run: dharta deploy --mode folder");
717
+ if (generatedLegacy) {
718
+ throw new Error(
719
+ `${rel} is Dharta's legacy repo-wide deploy workflow and can duplicate or fan out deploys. ` +
720
+ `Remove it, then rerun setup to create one path-scoped workflow per agent. No files were changed.`,
721
+ );
722
+ }
723
+ if (!isManagedWorkflow(content) && path.resolve(dest) !== path.resolve(targetDest)) {
724
+ throw new Error(
725
+ `${rel} already exists but is not a current per-agent Dharta workflow. ` +
726
+ "Inspect, remove, or rename it before enabling per-agent CI; setup left every file unchanged.",
727
+ );
728
+ }
729
+ }
730
+
731
+ type WorkflowState = "missing" | "generated" | "custom";
732
+
733
+ async function existingWorkflowState(
734
+ target: GithubWorkflowTarget,
735
+ log: (l: string) => void,
736
+ ): Promise<WorkflowState> {
737
+ const { dest, rel } = target;
738
+ try {
739
+ const stat = await fs.lstat(dest);
740
+ if (stat.isSymbolicLink()) {
741
+ throw new Error(`Cannot write ${rel}: the workflow file is a symbolic link.`);
742
+ }
743
+ if (stat.isDirectory()) {
744
+ throw new Error(`Cannot write ${rel}: expected a file, got a directory.`);
745
+ }
746
+ const content = await fs.readFile(dest, "utf8");
747
+ if (isManagedWorkflow(content)) return "generated";
748
+ log(
749
+ `CI: github requested, but ${rel} is a custom workflow. Left it unchanged; ` +
750
+ "CI was not configured. Verify and maintain that workflow yourself.",
751
+ );
752
+ return "custom";
753
+ } catch (error) {
754
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
755
+ throw error;
756
+ }
757
+ }
758
+
759
+ function isManagedWorkflow(content: string): boolean {
760
+ const lines = content.split(/\r?\n/);
761
+ if (lines[0] === MANAGED_WORKFLOW_MARKER) {
762
+ return /^# Deploy only the [a-z0-9-]+ Dharta agent\.$/.test(lines[1] ?? "") &&
763
+ /^# Agent directory: ".*"\.$/.test(lines[2] ?? "");
764
+ }
765
+
766
+ // Compatibility for the per-agent workflow format emitted immediately
767
+ // before the explicit ownership marker existed. Its exact first two lines,
768
+ // pinned-method comment, and deploy command form a narrow signature; a
769
+ // custom workflow that merely mentions the old generator phrase is not
770
+ // adopted or overwritten.
771
+ return /^# Deploy only the [a-z0-9-]+ Dharta agent\. Generated by dharta setup\.$/.test(lines[0] ?? "") &&
772
+ /^# Agent directory: ".*"\.$/.test(lines[1] ?? "") &&
773
+ content.includes("# The command is pinned to the ") &&
774
+ /dharta deploy --mode (git|folder) --dir /.test(content);
775
+ }
776
+
777
+ interface SafeWriteTarget {
778
+ rootReal: string;
779
+ existing?: import("node:fs").Stats;
780
+ }
781
+
782
+ // Validate an existing directory without following any repo-relative symlink.
783
+ // We deliberately start at the setup/repo boundary instead of walking system
784
+ // ancestors: platform paths such as /var may themselves be legitimate OS
785
+ // links, while every component controlled by the working tree must be real.
786
+ async function validateExistingDirectoryTree(root: string, target: string, label: string): Promise<void> {
787
+ const absoluteRoot = path.resolve(root);
788
+ const absoluteTarget = path.resolve(target);
789
+ const rootStat = await fs.lstat(absoluteRoot);
790
+ if (rootStat.isSymbolicLink()) {
791
+ throw new Error(
792
+ `${label}: '.' is a symbolic link. ` +
793
+ "Dharta setup does not follow links when selecting an agent folder.",
794
+ );
795
+ }
796
+ if (!rootStat.isDirectory()) throw new Error(`${label}: a parent path is not a directory.`);
797
+
798
+ // Check the caller-visible leaf before realpath so a selected agent symlink
799
+ // is rejected even when it resolves back inside the repo.
800
+ const targetLeaf = await fs.lstat(absoluteTarget);
801
+ if (targetLeaf.isSymbolicLink()) {
802
+ throw new Error(
803
+ `${label}: '${path.basename(absoluteTarget)}' is a symbolic link. ` +
804
+ "Dharta setup does not follow links when selecting an agent folder.",
805
+ );
806
+ }
807
+ if (!targetLeaf.isDirectory()) throw new Error(`${label}: expected a directory, got a file.`);
808
+
809
+ const [rootReal, targetReal] = await Promise.all([fs.realpath(absoluteRoot), fs.realpath(absoluteTarget)]);
810
+ const realRelative = containedRelativePath(rootReal, targetReal);
811
+ if (realRelative === undefined) {
812
+ throw new Error(`${label}: the resolved path escapes the current setup root.`);
813
+ }
814
+
815
+ // Prefer the caller-visible path when it is lexically below the boundary so
816
+ // repo-controlled intermediate symlinks are observable. macOS commonly
817
+ // exposes the same safe temp directory through /var and /private/var; when
818
+ // those lexical aliases differ, walk the already-contained canonical path.
819
+ const lexicalRelative = containedRelativePath(absoluteRoot, absoluteTarget);
820
+ const walkRoot = lexicalRelative === undefined ? rootReal : absoluteRoot;
821
+ const relativeTarget = lexicalRelative ?? realRelative;
822
+ const components = relativeTarget === "" ? [] : relativeTarget.split(path.sep);
823
+ let current = walkRoot;
824
+ for (const component of components) {
825
+ current = path.join(current, component);
826
+ const stat = await fs.lstat(current);
827
+ const display = path.relative(walkRoot, current) || ".";
828
+ if (stat.isSymbolicLink()) {
829
+ throw new Error(
830
+ `${label}: '${display}' is a symbolic link. ` +
831
+ "Dharta setup does not follow links when selecting an agent folder.",
832
+ );
833
+ }
834
+ if (!stat.isDirectory()) {
835
+ throw new Error(`${label}: a parent path is not a directory.`);
836
+ }
837
+ }
838
+ }
839
+
840
+ // Validate (and optionally create) every parent below a trusted root. lstat is
841
+ // intentional: stat/readFile/writeFile would follow a final symlink. A realpath
842
+ // containment check provides a second invariant for platforms with links in
843
+ // unusual directory layouts.
844
+ async function validateSafeWriteTarget(
845
+ root: string,
846
+ destination: string,
847
+ label: string,
848
+ createParents: boolean,
849
+ ): Promise<SafeWriteTarget> {
850
+ const absoluteRoot = path.resolve(root);
851
+ const absoluteDestination = path.resolve(destination);
852
+ const relativeDestination = containedRelativePath(absoluteRoot, absoluteDestination);
853
+ if (relativeDestination === undefined || relativeDestination === "") {
854
+ throw new Error(`Cannot write ${label}: destination is outside its intended root.`);
855
+ }
856
+
857
+ await validateExistingDirectoryTree(absoluteRoot, absoluteRoot, `Cannot write ${label}`);
858
+ const rootReal = await fs.realpath(absoluteRoot);
859
+ const parent = path.dirname(absoluteDestination);
860
+ const relativeParent = path.relative(absoluteRoot, parent);
861
+ const components = relativeParent === "" ? [] : relativeParent.split(path.sep);
862
+ let current = absoluteRoot;
863
+ let parentMissing = false;
864
+
865
+ for (const component of components) {
866
+ current = path.join(current, component);
867
+ let stat: import("node:fs").Stats;
868
+ try {
869
+ stat = await fs.lstat(current);
870
+ } catch (error) {
871
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
872
+ if (!createParents) {
873
+ parentMissing = true;
874
+ break;
875
+ }
876
+ try {
877
+ await fs.mkdir(current);
878
+ } catch (mkdirError) {
879
+ if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError;
880
+ }
881
+ stat = await fs.lstat(current);
882
+ }
883
+
884
+ const display = path.relative(absoluteRoot, current) || ".";
885
+ if (stat.isSymbolicLink()) {
886
+ throw new Error(
887
+ `Cannot write ${label}: parent '${display}' is a symbolic link. ` +
888
+ "Dharta setup refuses to follow links for generated files.",
889
+ );
890
+ }
891
+ if (!stat.isDirectory()) {
892
+ throw new Error(`Cannot write ${label}: a parent path is not a directory.`);
893
+ }
894
+ const currentReal = await fs.realpath(current);
895
+ if (containedRelativePath(rootReal, currentReal) === undefined) {
896
+ throw new Error(`Cannot write ${label}: parent '${display}' resolves outside its intended root.`);
897
+ }
898
+ }
899
+
900
+ if (parentMissing) return { rootReal };
901
+
902
+ let existing: import("node:fs").Stats | undefined;
903
+ try {
904
+ existing = await fs.lstat(absoluteDestination);
905
+ } catch (error) {
906
+ const code = (error as NodeJS.ErrnoException).code;
907
+ if (code === "ENOENT") return { rootReal };
908
+ if (code === "ENOTDIR") {
909
+ throw new Error(`Cannot write ${label}: a parent path is not a directory.`);
910
+ }
911
+ throw error;
912
+ }
913
+ if (existing.isSymbolicLink()) {
914
+ throw new Error(
915
+ `Cannot write ${label}: the destination is a symbolic link. ` +
916
+ "Dharta setup refuses to follow links for generated files.",
917
+ );
918
+ }
919
+ if (existing.isDirectory()) throw new Error(`Cannot write ${label}: expected a file, got a directory.`);
920
+ if (!existing.isFile()) throw new Error(`Cannot write ${label}: expected a regular file.`);
921
+ const destinationReal = await fs.realpath(absoluteDestination);
922
+ if (containedRelativePath(rootReal, destinationReal) === undefined) {
923
+ throw new Error(`Cannot write ${label}: destination resolves outside its intended root.`);
924
+ }
925
+ return { rootReal, existing };
926
+ }
927
+
928
+ // Write through a fresh same-directory file and atomically replace the final
929
+ // path. O_NOFOLLOW protects the temporary leaf; lstat rejects a linked final
930
+ // leaf; rename replaces rather than dereferences a link introduced after the
931
+ // check. This also avoids leaving half a manifest/workflow on interruption.
932
+ async function safeAtomicWriteFile(
933
+ root: string,
934
+ destination: string,
935
+ label: string,
936
+ content: string,
937
+ ): Promise<void> {
938
+ const prepared = await validateSafeWriteTarget(root, destination, label, true);
939
+ const parent = path.dirname(destination);
940
+ const temporary = path.join(
941
+ parent,
942
+ `.${path.basename(destination)}.karta-${process.pid}-${randomBytes(12).toString("hex")}.tmp`,
943
+ );
944
+ const noFollow = constants.O_NOFOLLOW ?? 0;
945
+ const flags = constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow;
946
+ const finalMode = prepared.existing ? prepared.existing.mode & 0o777 : 0o644;
947
+ let handle: import("node:fs/promises").FileHandle | undefined;
948
+ let renamed = false;
949
+
950
+ try {
951
+ handle = await fs.open(temporary, flags, 0o600);
952
+ const opened = await handle.stat();
953
+ const temporaryReal = await fs.realpath(temporary);
954
+ if (containedRelativePath(prepared.rootReal, temporaryReal) === undefined) {
955
+ throw new Error(`Cannot write ${label}: temporary file escaped its intended root.`);
956
+ }
957
+ await handle.writeFile(content, "utf8");
958
+ await handle.sync();
959
+ await handle.chmod(finalMode);
960
+ await handle.close();
961
+ handle = undefined;
962
+
963
+ // Re-check after writing, immediately before the atomic replacement. The
964
+ // inode comparison ensures the lexical temporary path still names the file
965
+ // descriptor we created rather than a swapped link/file.
966
+ await validateSafeWriteTarget(root, destination, label, true);
967
+ const tempStat = await fs.lstat(temporary);
968
+ if (
969
+ !tempStat.isFile() || tempStat.isSymbolicLink() ||
970
+ tempStat.dev !== opened.dev || tempStat.ino !== opened.ino
971
+ ) {
972
+ throw new Error(`Cannot write ${label}: temporary file changed during setup.`);
973
+ }
974
+ const checkedTemporaryReal = await fs.realpath(temporary);
975
+ if (containedRelativePath(prepared.rootReal, checkedTemporaryReal) === undefined) {
976
+ throw new Error(`Cannot write ${label}: temporary file escaped its intended root.`);
977
+ }
978
+
979
+ await fs.rename(temporary, destination);
980
+ renamed = true;
981
+ } catch (error) {
982
+ if (error instanceof Error && error.message.startsWith(`Cannot write ${label}:`)) throw error;
983
+ throw new Error(`Cannot write ${label}: ${(error as Error).message}`);
984
+ } finally {
985
+ await handle?.close().catch(() => {});
986
+ if (!renamed) await fs.unlink(temporary).catch(() => {});
987
+ }
988
+ }
989
+
990
+ async function lstatIfPresent(file: string): Promise<import("node:fs").Stats | undefined> {
991
+ try {
992
+ return await fs.lstat(file);
993
+ } catch (error) {
994
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
995
+ throw error;
996
+ }
997
+ }
998
+
999
+ function containedRelativePath(root: string, candidate: string): string | undefined {
1000
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
1001
+ if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
1002
+ return undefined;
1003
+ }
1004
+ return relative;
1005
+ }
1006
+
1007
+ async function rejectGitignoredSetupFile(
1008
+ git: GitRunner,
1009
+ repoRoot: string,
1010
+ destination: string,
1011
+ label: string,
1012
+ ): Promise<void> {
1013
+ const relative = containedRelativePath(repoRoot, destination);
1014
+ if (relative === undefined || relative === "") {
1015
+ throw new Error(`Cannot check ${label}: path is outside the git repo.`);
1016
+ }
1017
+ const gitPath = relative.split(path.sep).join("/");
1018
+ // Do not pass --no-index: an already tracked generated file remains part of
1019
+ // the repo even if a later ignore rule matches it. New/untracked generated
1020
+ // files are the ones setup must reject as impossible to push.
1021
+ const result = await git(["-C", repoRoot, "check-ignore", "-q", "--", gitPath]);
1022
+ if (result.code === 1) return;
1023
+ if (result.code !== 0) {
1024
+ throw new Error(
1025
+ `git could not check ignore rules for ${gitPath}: ${result.stderr.trim() || `exit ${result.code}`}`,
1026
+ );
1027
+ }
1028
+ throw new Error(
1029
+ `Cannot configure ${label}: ${gitPath} is excluded by .gitignore. ` +
1030
+ "Unignore it, then rerun setup; Dharta did not write the generated file or report it ready to push.",
1031
+ );
1032
+ }
1033
+
1034
+ function renderDeployWorkflow(target: GithubWorkflowTarget): string {
1035
+ const pushPaths = githubPushPaths(target);
1036
+ const scope = target.method === "folder" ? "releases:write" : "source_repos:write";
1037
+ const reconcileSource = target.method === "git" ? " --reconcile-source" : "";
1038
+ const command =
1039
+ `dharta deploy --mode ${target.method}${reconcileSource} --dir ${shellQuote(target.agentDir)}`;
1040
+ const concurrency = target.method === "git"
1041
+ ? "dharta-git-deploy-${{ github.repository }}"
1042
+ : `dharta-deploy-${target.slug}`;
1043
+ return [
1044
+ MANAGED_WORKFLOW_MARKER,
1045
+ `# Deploy only the ${target.slug} Dharta agent.`,
1046
+ `# Agent directory: ${JSON.stringify(target.agentDir)}.`,
1047
+ "#",
1048
+ `# Authentication comes from this agent's ${target.secretName} repo secret.`,
1049
+ `# The command is pinned to the ${target.method} method used to render this workflow.`,
1050
+ ...(target.method === "git" ? [
1051
+ "# --reconcile-source is safe here only because the branch-tip guard below rejects stale runs.",
1052
+ ] : []),
1053
+ "# To change deploy_method, update dharta.toml by rerunning setup, regenerate this workflow,",
1054
+ "# and rotate the secret so its operation scope matches the new method:",
1055
+ `# rerun: dharta setup ${shellQuote(target.agentDir)} --ci github`,
1056
+ "#",
1057
+ "# Required repo secret:",
1058
+ `# ${target.secretName} a dh_live_... key with \`${scope}\`:`,
1059
+ `# dharta keys create ci-${target.slug} --scope ${scope}`,
1060
+ "# (requires an active key with keys:write; dashboard creation also works)",
1061
+ "# Optional repo variable:",
1062
+ "# DHARTA_BASE_URL control-plane URL (defaults to https://dharta.ai).",
1063
+ "",
1064
+ `name: Deploy ${target.slug} to Dharta`,
1065
+ "",
1066
+ "on:",
1067
+ " push:",
1068
+ ` branches: [${JSON.stringify(githubLiteralPath(target.branch))}]`,
1069
+ " paths:",
1070
+ ...pushPaths.map((triggerPath) => ` - ${JSON.stringify(triggerPath)}`),
1071
+ " workflow_dispatch:",
1072
+ "",
1073
+ "permissions:",
1074
+ " contents: read",
1075
+ "",
1076
+ "concurrency:",
1077
+ ` group: ${concurrency}`,
1078
+ " queue: max",
1079
+ " cancel-in-progress: false",
1080
+ "",
1081
+ "jobs:",
1082
+ " deploy:",
1083
+ " runs-on: ubuntu-latest",
1084
+ " steps:",
1085
+ " - uses: actions/checkout@v4",
1086
+ ...(target.method === "git" ? [ " with:", " fetch-depth: 0" ] : []),
1087
+ " - uses: actions/setup-node@v4",
1088
+ ' with:',
1089
+ ' node-version: "20"',
1090
+ " - name: Install Dharta CLI",
1091
+ ` run: npm install --global @dharta/cli@${CLI_VERSION}`,
1092
+ " - name: Check current branch tip",
1093
+ " id: freshness",
1094
+ " env:",
1095
+ ` DHARTA_DEPLOY_BRANCH: ${JSON.stringify(target.branch)}`,
1096
+ " run: |",
1097
+ " git fetch --no-tags --force origin \"refs/heads/${DHARTA_DEPLOY_BRANCH}:refs/remotes/origin/dharta-deploy-tip\"",
1098
+ " tip=\"$(git rev-parse refs/remotes/origin/dharta-deploy-tip)\"",
1099
+ " if [ \"$GITHUB_SHA\" = \"$tip\" ]; then",
1100
+ " echo \"deploy=true\" >> \"$GITHUB_OUTPUT\"",
1101
+ " else",
1102
+ ` echo "Skipping stale ${target.method} deploy: workflow SHA $GITHUB_SHA is not current branch tip $tip."`,
1103
+ " echo \"deploy=false\" >> \"$GITHUB_OUTPUT\"",
1104
+ " fi",
1105
+ " - name: Deploy",
1106
+ " if: steps.freshness.outputs.deploy == 'true'",
1107
+ " env:",
1108
+ ` DHARTA_API_KEY: \${{ secrets.${target.secretName} }}`,
1109
+ " DHARTA_BASE_URL: ${{ vars.DHARTA_BASE_URL || 'https://dharta.ai' }}",
1110
+ ` run: ${JSON.stringify(command)}`,
1111
+ "",
1112
+ ].join("\n");
1113
+ }
1114
+
1115
+ function githubPushPaths(target: GithubWorkflowTarget): string[] {
1116
+ // A root agent's `**` covers its own .gitignore and generated workflow.
1117
+ if (target.agentDir === ".") return ["**"];
1118
+
1119
+ const paths = new Set<string>([`${githubLiteralPath(target.agentDir)}/**`]);
1120
+ const parts = target.agentDir.split("/");
1121
+
1122
+ // Folder packaging applies ignore rules from the checkout root through the
1123
+ // selected folder. Include every *potential* ancestor .gitignore, even when
1124
+ // it does not exist yet, so adding one cannot silently change the package
1125
+ // without triggering CI. The selected folder's own .gitignore is already
1126
+ // covered by the subtree glob, hence the strict `< parts.length` bound.
1127
+ for (let depth = 0; depth < parts.length; depth += 1) {
1128
+ const prefix = parts.slice(0, depth).join("/");
1129
+ const ignorePath = prefix ? `${prefix}/.gitignore` : ".gitignore";
1130
+ paths.add(githubLiteralPath(ignorePath));
1131
+ }
1132
+
1133
+ // Ordinarily a generated-workflow edit should trigger its deploy. Avoid a
1134
+ // duplicate when an unusually placed agent subtree already covers it.
1135
+ if (!target.rel.startsWith(`${target.agentDir}/`)) {
1136
+ paths.add(githubLiteralPath(target.rel));
1137
+ }
1138
+ return [...paths];
1139
+ }
1140
+
1141
+ function githubLiteralPath(value: string): string {
1142
+ // GitHub `paths` values are glob patterns even when YAML-quoted. Escape
1143
+ // metacharacters that are legal in git directory names so a literal agent
1144
+ // path cannot broaden, negate, or otherwise change its workflow trigger.
1145
+ return value.replace(/[\\*?+!\[\]]/g, "\\$&");
1146
+ }
1147
+
1148
+ function shellQuote(value: string): string {
1149
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
1150
+ }
1151
+
1152
+ function relLabel(dir: string): string {
1153
+ const rel = path.relative(process.cwd(), dir);
1154
+ return rel === "" ? "./" : `./${rel}`;
1155
+ }
1156
+
1157
+ // (ensureRemote lives on the deploy command; setup stays destination-
1158
+ // agnostic and lets `dharta deploy` provision + wire the remote.)
1159
+
1160
+ function yesNo(answer: string, fallback: boolean): boolean {
1161
+ const a = answer.trim().toLowerCase();
1162
+ if (a === "") return fallback;
1163
+ return a === "y" || a === "yes";
1164
+ }
1165
+
1166
+ function yesNoCancel(answer: string): "yes" | "no" | "cancel" {
1167
+ const a = answer.trim().toLowerCase();
1168
+ if (a === "n" || a === "no") return "no";
1169
+ if (a === "c" || a === "cancel") return "cancel";
1170
+ return "yes"; // empty (the [Yes] default) or y/yes
1171
+ }
1172
+
1173
+ async function run(git: GitRunner, args: string[], label: string): Promise<void> {
1174
+ const r = await git(args);
1175
+ if (r.code !== 0) throw new Error(`${label} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
1176
+ }