clawdlets 0.2.2 → 0.3.0

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 (279) hide show
  1. package/dist/main.mjs +4589 -0
  2. package/{dist → node_modules/@clawdlets/core/dist}/lib/context.d.ts +2 -2
  3. package/{dist → node_modules/@clawdlets/core/dist}/lib/context.d.ts.map +1 -1
  4. package/{dist → node_modules/@clawdlets/core/dist}/lib/context.js +2 -2
  5. package/node_modules/@clawdlets/core/dist/lib/context.js.map +1 -0
  6. package/{dist → node_modules/@clawdlets/core/dist}/lib/host-resolve.js +2 -2
  7. package/node_modules/@clawdlets/core/dist/lib/host-resolve.js.map +1 -0
  8. package/node_modules/@clawdlets/core/dist/repo-layout.d.ts +1 -0
  9. package/node_modules/@clawdlets/core/dist/repo-layout.d.ts.map +1 -1
  10. package/node_modules/@clawdlets/core/dist/repo-layout.js +2 -0
  11. package/node_modules/@clawdlets/core/dist/repo-layout.js.map +1 -1
  12. package/node_modules/@clawdlets/core/package.json +1 -3
  13. package/package.json +16 -16
  14. package/dist/commands/bootstrap.d.ts +0 -43
  15. package/dist/commands/bootstrap.d.ts.map +0 -1
  16. package/dist/commands/bootstrap.js +0 -318
  17. package/dist/commands/bootstrap.js.map +0 -1
  18. package/dist/commands/bot.d.ts +0 -2
  19. package/dist/commands/bot.d.ts.map +0 -1
  20. package/dist/commands/bot.js +0 -97
  21. package/dist/commands/bot.js.map +0 -1
  22. package/dist/commands/cattle/common.d.ts +0 -29
  23. package/dist/commands/cattle/common.d.ts.map +0 -1
  24. package/dist/commands/cattle/common.js +0 -102
  25. package/dist/commands/cattle/common.js.map +0 -1
  26. package/dist/commands/cattle/destroy.d.ts +0 -33
  27. package/dist/commands/cattle/destroy.d.ts.map +0 -1
  28. package/dist/commands/cattle/destroy.js +0 -72
  29. package/dist/commands/cattle/destroy.js.map +0 -1
  30. package/dist/commands/cattle/list.d.ts +0 -20
  31. package/dist/commands/cattle/list.d.ts.map +0 -1
  32. package/dist/commands/cattle/list.js +0 -78
  33. package/dist/commands/cattle/list.js.map +0 -1
  34. package/dist/commands/cattle/logs.d.ts +0 -34
  35. package/dist/commands/cattle/logs.d.ts.map +0 -1
  36. package/dist/commands/cattle/logs.js +0 -55
  37. package/dist/commands/cattle/logs.js.map +0 -1
  38. package/dist/commands/cattle/persona.d.ts +0 -2
  39. package/dist/commands/cattle/persona.d.ts.map +0 -1
  40. package/dist/commands/cattle/persona.js +0 -85
  41. package/dist/commands/cattle/persona.js.map +0 -1
  42. package/dist/commands/cattle/reap.d.ts +0 -20
  43. package/dist/commands/cattle/reap.d.ts.map +0 -1
  44. package/dist/commands/cattle/reap.js +0 -60
  45. package/dist/commands/cattle/reap.js.map +0 -1
  46. package/dist/commands/cattle/spawn.d.ts +0 -73
  47. package/dist/commands/cattle/spawn.d.ts.map +0 -1
  48. package/dist/commands/cattle/spawn.js +0 -147
  49. package/dist/commands/cattle/spawn.js.map +0 -1
  50. package/dist/commands/cattle/ssh.d.ts +0 -20
  51. package/dist/commands/cattle/ssh.d.ts.map +0 -1
  52. package/dist/commands/cattle/ssh.js +0 -37
  53. package/dist/commands/cattle/ssh.js.map +0 -1
  54. package/dist/commands/cattle.d.ts +0 -2
  55. package/dist/commands/cattle.d.ts.map +0 -1
  56. package/dist/commands/cattle.js +0 -21
  57. package/dist/commands/cattle.js.map +0 -1
  58. package/dist/commands/config.d.ts +0 -2
  59. package/dist/commands/config.d.ts.map +0 -1
  60. package/dist/commands/config.js +0 -163
  61. package/dist/commands/config.js.map +0 -1
  62. package/dist/commands/doctor.d.ts +0 -35
  63. package/dist/commands/doctor.d.ts.map +0 -1
  64. package/dist/commands/doctor.js +0 -65
  65. package/dist/commands/doctor.js.map +0 -1
  66. package/dist/commands/env.d.ts +0 -22
  67. package/dist/commands/env.d.ts.map +0 -1
  68. package/dist/commands/env.js +0 -132
  69. package/dist/commands/env.js.map +0 -1
  70. package/dist/commands/fleet.d.ts +0 -2
  71. package/dist/commands/fleet.d.ts.map +0 -1
  72. package/dist/commands/fleet.js +0 -61
  73. package/dist/commands/fleet.js.map +0 -1
  74. package/dist/commands/host.d.ts +0 -2
  75. package/dist/commands/host.d.ts.map +0 -1
  76. package/dist/commands/host.js +0 -277
  77. package/dist/commands/host.js.map +0 -1
  78. package/dist/commands/image.d.ts +0 -2
  79. package/dist/commands/image.d.ts.map +0 -1
  80. package/dist/commands/image.js +0 -133
  81. package/dist/commands/image.js.map +0 -1
  82. package/dist/commands/infra.d.ts +0 -2
  83. package/dist/commands/infra.d.ts.map +0 -1
  84. package/dist/commands/infra.js +0 -171
  85. package/dist/commands/infra.js.map +0 -1
  86. package/dist/commands/lockdown.d.ts +0 -25
  87. package/dist/commands/lockdown.d.ts.map +0 -1
  88. package/dist/commands/lockdown.js +0 -93
  89. package/dist/commands/lockdown.js.map +0 -1
  90. package/dist/commands/project.d.ts +0 -2
  91. package/dist/commands/project.d.ts.map +0 -1
  92. package/dist/commands/project.js +0 -264
  93. package/dist/commands/project.js.map +0 -1
  94. package/dist/commands/secrets/common.d.ts +0 -8
  95. package/dist/commands/secrets/common.d.ts.map +0 -1
  96. package/dist/commands/secrets/common.js +0 -20
  97. package/dist/commands/secrets/common.js.map +0 -1
  98. package/dist/commands/secrets/init.d.ts +0 -39
  99. package/dist/commands/secrets/init.d.ts.map +0 -1
  100. package/dist/commands/secrets/init.js +0 -455
  101. package/dist/commands/secrets/init.js.map +0 -1
  102. package/dist/commands/secrets/path.d.ts +0 -11
  103. package/dist/commands/secrets/path.d.ts.map +0 -1
  104. package/dist/commands/secrets/path.js +0 -24
  105. package/dist/commands/secrets/path.js.map +0 -1
  106. package/dist/commands/secrets/sync.d.ts +0 -25
  107. package/dist/commands/secrets/sync.d.ts.map +0 -1
  108. package/dist/commands/secrets/sync.js +0 -67
  109. package/dist/commands/secrets/sync.js.map +0 -1
  110. package/dist/commands/secrets/verify.d.ts +0 -28
  111. package/dist/commands/secrets/verify.d.ts.map +0 -1
  112. package/dist/commands/secrets/verify.js +0 -118
  113. package/dist/commands/secrets/verify.js.map +0 -1
  114. package/dist/commands/secrets.d.ts +0 -2
  115. package/dist/commands/secrets.d.ts.map +0 -1
  116. package/dist/commands/secrets.js +0 -18
  117. package/dist/commands/secrets.js.map +0 -1
  118. package/dist/commands/server/common.d.ts +0 -3
  119. package/dist/commands/server/common.d.ts.map +0 -1
  120. package/dist/commands/server/common.js +0 -3
  121. package/dist/commands/server/common.js.map +0 -1
  122. package/dist/commands/server/deploy.d.ts +0 -53
  123. package/dist/commands/server/deploy.d.ts.map +0 -1
  124. package/dist/commands/server/deploy.js +0 -177
  125. package/dist/commands/server/deploy.js.map +0 -1
  126. package/dist/commands/server/github-sync.d.ts +0 -2
  127. package/dist/commands/server/github-sync.d.ts.map +0 -1
  128. package/dist/commands/server/github-sync.js +0 -166
  129. package/dist/commands/server/github-sync.js.map +0 -1
  130. package/dist/commands/server/manifest.d.ts +0 -28
  131. package/dist/commands/server/manifest.d.ts.map +0 -1
  132. package/dist/commands/server/manifest.js +0 -82
  133. package/dist/commands/server/manifest.js.map +0 -1
  134. package/dist/commands/server.d.ts +0 -2
  135. package/dist/commands/server.d.ts.map +0 -1
  136. package/dist/commands/server.js +0 -267
  137. package/dist/commands/server.js.map +0 -1
  138. package/dist/commands/ssh-target.d.ts +0 -3
  139. package/dist/commands/ssh-target.d.ts.map +0 -1
  140. package/dist/commands/ssh-target.js +0 -15
  141. package/dist/commands/ssh-target.js.map +0 -1
  142. package/dist/lib/context.js.map +0 -1
  143. package/dist/lib/deploy-gate.d.ts +0 -9
  144. package/dist/lib/deploy-gate.d.ts.map +0 -1
  145. package/dist/lib/deploy-gate.js +0 -20
  146. package/dist/lib/deploy-gate.js.map +0 -1
  147. package/dist/lib/deploy-manifest.d.ts +0 -11
  148. package/dist/lib/deploy-manifest.d.ts.map +0 -1
  149. package/dist/lib/deploy-manifest.js +0 -46
  150. package/dist/lib/deploy-manifest.js.map +0 -1
  151. package/dist/lib/doctor-render.d.ts +0 -14
  152. package/dist/lib/doctor-render.d.ts.map +0 -1
  153. package/dist/lib/doctor-render.js +0 -131
  154. package/dist/lib/doctor-render.js.map +0 -1
  155. package/dist/lib/host-resolve.js.map +0 -1
  156. package/dist/lib/linux-build.d.ts +0 -8
  157. package/dist/lib/linux-build.d.ts.map +0 -1
  158. package/dist/lib/linux-build.js +0 -15
  159. package/dist/lib/linux-build.js.map +0 -1
  160. package/dist/lib/manifest-signature.d.ts +0 -17
  161. package/dist/lib/manifest-signature.d.ts.map +0 -1
  162. package/dist/lib/manifest-signature.js +0 -52
  163. package/dist/lib/manifest-signature.js.map +0 -1
  164. package/dist/lib/template-spec.d.ts +0 -9
  165. package/dist/lib/template-spec.d.ts.map +0 -1
  166. package/dist/lib/template-spec.js +0 -50
  167. package/dist/lib/template-spec.js.map +0 -1
  168. package/dist/lib/version.d.ts +0 -3
  169. package/dist/lib/version.d.ts.map +0 -1
  170. package/dist/lib/version.js +0 -17
  171. package/dist/lib/version.js.map +0 -1
  172. package/dist/lib/wizard.d.ts +0 -10
  173. package/dist/lib/wizard.d.ts.map +0 -1
  174. package/dist/lib/wizard.js +0 -25
  175. package/dist/lib/wizard.js.map +0 -1
  176. package/dist/main.d.ts +0 -3
  177. package/dist/main.d.ts.map +0 -1
  178. package/dist/main.js +0 -50
  179. package/dist/main.js.map +0 -1
  180. package/node_modules/@clawdlets/clf-queue/dist/client.d.ts +0 -21
  181. package/node_modules/@clawdlets/clf-queue/dist/client.d.ts.map +0 -1
  182. package/node_modules/@clawdlets/clf-queue/dist/client.js +0 -132
  183. package/node_modules/@clawdlets/clf-queue/dist/client.js.map +0 -1
  184. package/node_modules/@clawdlets/clf-queue/dist/index.d.ts +0 -9
  185. package/node_modules/@clawdlets/clf-queue/dist/index.d.ts.map +0 -1
  186. package/node_modules/@clawdlets/clf-queue/dist/index.js +0 -5
  187. package/node_modules/@clawdlets/clf-queue/dist/index.js.map +0 -1
  188. package/node_modules/@clawdlets/clf-queue/dist/jobs.d.ts +0 -32
  189. package/node_modules/@clawdlets/clf-queue/dist/jobs.d.ts.map +0 -1
  190. package/node_modules/@clawdlets/clf-queue/dist/jobs.js +0 -24
  191. package/node_modules/@clawdlets/clf-queue/dist/jobs.js.map +0 -1
  192. package/node_modules/@clawdlets/clf-queue/dist/protocol.d.ts +0 -118
  193. package/node_modules/@clawdlets/clf-queue/dist/protocol.d.ts.map +0 -1
  194. package/node_modules/@clawdlets/clf-queue/dist/protocol.js +0 -46
  195. package/node_modules/@clawdlets/clf-queue/dist/protocol.js.map +0 -1
  196. package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.d.ts +0 -3
  197. package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.d.ts.map +0 -1
  198. package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.js +0 -112
  199. package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.js.map +0 -1
  200. package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.d.ts +0 -3
  201. package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.d.ts.map +0 -1
  202. package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.js +0 -313
  203. package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.js.map +0 -1
  204. package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.d.ts +0 -2
  205. package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.d.ts.map +0 -1
  206. package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.js +0 -74
  207. package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.js.map +0 -1
  208. package/node_modules/@clawdlets/clf-queue/dist/queue/open.d.ts +0 -3
  209. package/node_modules/@clawdlets/clf-queue/dist/queue/open.d.ts.map +0 -1
  210. package/node_modules/@clawdlets/clf-queue/dist/queue/open.js +0 -27
  211. package/node_modules/@clawdlets/clf-queue/dist/queue/open.js.map +0 -1
  212. package/node_modules/@clawdlets/clf-queue/dist/queue/types.d.ts +0 -113
  213. package/node_modules/@clawdlets/clf-queue/dist/queue/types.d.ts.map +0 -1
  214. package/node_modules/@clawdlets/clf-queue/dist/queue/types.js +0 -2
  215. package/node_modules/@clawdlets/clf-queue/dist/queue/types.js.map +0 -1
  216. package/node_modules/@clawdlets/clf-queue/dist/queue/util.d.ts +0 -10
  217. package/node_modules/@clawdlets/clf-queue/dist/queue/util.d.ts.map +0 -1
  218. package/node_modules/@clawdlets/clf-queue/dist/queue/util.js +0 -30
  219. package/node_modules/@clawdlets/clf-queue/dist/queue/util.js.map +0 -1
  220. package/node_modules/@clawdlets/clf-queue/dist/queue.d.ts +0 -3
  221. package/node_modules/@clawdlets/clf-queue/dist/queue.d.ts.map +0 -1
  222. package/node_modules/@clawdlets/clf-queue/dist/queue.js +0 -2
  223. package/node_modules/@clawdlets/clf-queue/dist/queue.js.map +0 -1
  224. package/node_modules/@clawdlets/clf-queue/package.json +0 -34
  225. package/node_modules/@clawdlets/core/dist/lib/cattle-state.d.ts +0 -25
  226. package/node_modules/@clawdlets/core/dist/lib/cattle-state.d.ts.map +0 -1
  227. package/node_modules/@clawdlets/core/dist/lib/cattle-state.js +0 -136
  228. package/node_modules/@clawdlets/core/dist/lib/cattle-state.js.map +0 -1
  229. package/node_modules/better-sqlite3/LICENSE +0 -21
  230. package/node_modules/better-sqlite3/README.md +0 -99
  231. package/node_modules/better-sqlite3/binding.gyp +0 -38
  232. package/node_modules/better-sqlite3/deps/common.gypi +0 -68
  233. package/node_modules/better-sqlite3/deps/copy.js +0 -31
  234. package/node_modules/better-sqlite3/deps/defines.gypi +0 -41
  235. package/node_modules/better-sqlite3/deps/download.sh +0 -122
  236. package/node_modules/better-sqlite3/deps/patches/1208.patch +0 -15
  237. package/node_modules/better-sqlite3/deps/sqlite3/sqlite3.c +0 -265969
  238. package/node_modules/better-sqlite3/deps/sqlite3/sqlite3.h +0 -13968
  239. package/node_modules/better-sqlite3/deps/sqlite3/sqlite3ext.h +0 -730
  240. package/node_modules/better-sqlite3/deps/sqlite3.gyp +0 -80
  241. package/node_modules/better-sqlite3/deps/test_extension.c +0 -21
  242. package/node_modules/better-sqlite3/lib/database.js +0 -90
  243. package/node_modules/better-sqlite3/lib/index.js +0 -3
  244. package/node_modules/better-sqlite3/lib/methods/aggregate.js +0 -43
  245. package/node_modules/better-sqlite3/lib/methods/backup.js +0 -67
  246. package/node_modules/better-sqlite3/lib/methods/function.js +0 -31
  247. package/node_modules/better-sqlite3/lib/methods/inspect.js +0 -7
  248. package/node_modules/better-sqlite3/lib/methods/pragma.js +0 -12
  249. package/node_modules/better-sqlite3/lib/methods/serialize.js +0 -16
  250. package/node_modules/better-sqlite3/lib/methods/table.js +0 -189
  251. package/node_modules/better-sqlite3/lib/methods/transaction.js +0 -78
  252. package/node_modules/better-sqlite3/lib/methods/wrappers.js +0 -54
  253. package/node_modules/better-sqlite3/lib/sqlite-error.js +0 -20
  254. package/node_modules/better-sqlite3/lib/util.js +0 -12
  255. package/node_modules/better-sqlite3/package.json +0 -59
  256. package/node_modules/better-sqlite3/src/addon.cpp +0 -47
  257. package/node_modules/better-sqlite3/src/better_sqlite3.cpp +0 -74
  258. package/node_modules/better-sqlite3/src/objects/backup.cpp +0 -120
  259. package/node_modules/better-sqlite3/src/objects/backup.hpp +0 -36
  260. package/node_modules/better-sqlite3/src/objects/database.cpp +0 -417
  261. package/node_modules/better-sqlite3/src/objects/database.hpp +0 -103
  262. package/node_modules/better-sqlite3/src/objects/statement-iterator.cpp +0 -113
  263. package/node_modules/better-sqlite3/src/objects/statement-iterator.hpp +0 -50
  264. package/node_modules/better-sqlite3/src/objects/statement.cpp +0 -383
  265. package/node_modules/better-sqlite3/src/objects/statement.hpp +0 -58
  266. package/node_modules/better-sqlite3/src/util/bind-map.cpp +0 -73
  267. package/node_modules/better-sqlite3/src/util/binder.cpp +0 -193
  268. package/node_modules/better-sqlite3/src/util/constants.cpp +0 -172
  269. package/node_modules/better-sqlite3/src/util/custom-aggregate.cpp +0 -121
  270. package/node_modules/better-sqlite3/src/util/custom-function.cpp +0 -59
  271. package/node_modules/better-sqlite3/src/util/custom-table.cpp +0 -409
  272. package/node_modules/better-sqlite3/src/util/data-converter.cpp +0 -17
  273. package/node_modules/better-sqlite3/src/util/data.cpp +0 -194
  274. package/node_modules/better-sqlite3/src/util/helpers.cpp +0 -109
  275. package/node_modules/better-sqlite3/src/util/macros.cpp +0 -70
  276. package/node_modules/better-sqlite3/src/util/query-macros.cpp +0 -71
  277. package/node_modules/better-sqlite3/src/util/row-builder.cpp +0 -49
  278. /package/{dist → node_modules/@clawdlets/core/dist}/lib/host-resolve.d.ts +0 -0
  279. /package/{dist → node_modules/@clawdlets/core/dist}/lib/host-resolve.d.ts.map +0 -0
package/dist/main.mjs ADDED
@@ -0,0 +1,4589 @@
1
+ #!/usr/bin/env node
2
+ import { defineCommand, runMain } from "citty";
3
+ import process$1 from "node:process";
4
+ import * as p from "@clack/prompts";
5
+ import { findRepoRoot } from "@clawdlets/core/lib/repo";
6
+ import { ClawdletsConfigSchema, SSH_EXPOSURE_MODES, assertSafeHostName, createDefaultClawdletsConfig, getSshExposureMode, getTailnetMode, loadClawdletsConfig, loadClawdletsConfigRaw, resolveHostName, writeClawdletsConfig } from "@clawdlets/core/lib/clawdlets-config";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { applyOpenTofuVars, destroyOpenTofuVars } from "@clawdlets/core/lib/opentofu";
10
+ import { resolveGitRev } from "@clawdlets/core/lib/git";
11
+ import { capture, run } from "@clawdlets/core/lib/run";
12
+ import { checkGithubRepoVisibility, tryParseGithubFlakeUri } from "@clawdlets/core/lib/github";
13
+ import { loadDeployCreds } from "@clawdlets/core/lib/deploy-creds";
14
+ import { expandPath } from "@clawdlets/core/lib/path-expand";
15
+ import { buildFleetSecretsPlan } from "@clawdlets/core/lib/fleet-secrets";
16
+ import { withFlakesEnv } from "@clawdlets/core/lib/nix-flakes";
17
+ import { resolveBaseFlake } from "@clawdlets/core/lib/base-flake";
18
+ import { getHostEncryptedAgeKeyFile, getHostExtraFilesDir, getHostExtraFilesKeyPath, getHostExtraFilesSecretsDir, getHostOpenTofuDir, getHostRemoteSecretsDir, getHostSecretsDir, getLocalOperatorAgeKeyPath, getRepoLayout } from "@clawdlets/core/repo-layout";
19
+ import { collectDoctorChecks } from "@clawdlets/core/doctor";
20
+ import { resolveHostNameOrExit } from "@clawdlets/core/lib/host-resolve";
21
+ import { ensureDir, writeFileAtomic } from "@clawdlets/core/lib/fs-safe";
22
+ import { splitDotPath } from "@clawdlets/core/lib/dot-path";
23
+ import { formatDotenvValue, parseDotenv } from "@clawdlets/core/lib/dotenv-file";
24
+ import { looksLikeSshPrivateKey, parseSshPublicKeysFromText } from "@clawdlets/core/lib/ssh";
25
+ import { shellQuote, sshCapture, sshRun, validateTargetHost } from "@clawdlets/core/lib/ssh-remote";
26
+ import { loadHostContextOrExit } from "@clawdlets/core/lib/context";
27
+ import { tmpdir } from "node:os";
28
+ import { downloadTemplate } from "giget";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
+ import { normalizeTemplateSource } from "@clawdlets/core/lib/template-source";
31
+ import { ageKeygen } from "@clawdlets/core/lib/age-keygen";
32
+ import { parseAgeKeyFile } from "@clawdlets/core/lib/age";
33
+ import { mkpasswdYescryptHash } from "@clawdlets/core/lib/mkpasswd";
34
+ import { upsertSopsCreationRule } from "@clawdlets/core/lib/sops-config";
35
+ import { sopsDecryptYamlFile, sopsEncryptYamlToFile } from "@clawdlets/core/lib/sops";
36
+ import { getHostAgeKeySopsCreationRulePathRegex, getHostSecretsSopsCreationRulePathRegex } from "@clawdlets/core/lib/sops-rules";
37
+ import { sanitizeOperatorId } from "@clawdlets/core/lib/identifiers";
38
+ import { buildSecretsInitTemplate, isPlaceholderSecretValue, listSecretsInitPlaceholders, parseSecretsInitJson, resolveSecretsInitFromJsonArg, validateSecretsInitNonInteractive } from "@clawdlets/core/lib/secrets-init";
39
+ import { readYamlScalarFromMapping } from "@clawdlets/core/lib/yaml-scalar";
40
+ import { createSecretsTar } from "@clawdlets/core/lib/secrets-tar";
41
+ import YAML from "yaml";
42
+
43
+ //#region rolldown:runtime
44
+ var __defProp = Object.defineProperty;
45
+ var __exportAll = (all, symbols) => {
46
+ let target = {};
47
+ for (var name in all) {
48
+ __defProp(target, name, {
49
+ get: all[name],
50
+ enumerable: true
51
+ });
52
+ }
53
+ if (symbols) {
54
+ __defProp(target, Symbol.toStringTag, { value: "Module" });
55
+ }
56
+ return target;
57
+ };
58
+
59
+ //#endregion
60
+ //#region src/lib/wizard.ts
61
+ const NAV_BACK = Symbol("clawdlets.nav.back");
62
+ const NAV_EXIT = Symbol("clawdlets.nav.exit");
63
+ async function navOnCancel(params) {
64
+ const flow = params.flow.trim() || "setup";
65
+ const options = [];
66
+ if (params.canBack) options.push({
67
+ value: "back",
68
+ label: "Back"
69
+ });
70
+ options.push({
71
+ value: "exit",
72
+ label: `Exit ${flow}`
73
+ });
74
+ const choice = await p.select({
75
+ message: "Canceled. Next?",
76
+ initialValue: params.canBack ? "back" : "exit",
77
+ options
78
+ });
79
+ if (p.isCancel(choice) || choice === "exit") return NAV_EXIT;
80
+ return NAV_BACK;
81
+ }
82
+ function cancelFlow() {
83
+ p.cancel("canceled");
84
+ }
85
+
86
+ //#endregion
87
+ //#region src/commands/bot.ts
88
+ function validateBotId(value) {
89
+ const v = value.trim();
90
+ if (!v) return "bot id required";
91
+ if (!/^[a-z][a-z0-9_-]*$/.test(v)) return "use: [a-z][a-z0-9_-]*";
92
+ }
93
+ const list$1 = defineCommand({
94
+ meta: {
95
+ name: "list",
96
+ description: "List bots (from fleet/clawdlets.json)."
97
+ },
98
+ args: {},
99
+ async run({ args }) {
100
+ const { config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
101
+ console.log((config$1.fleet.botOrder || []).join("\n"));
102
+ }
103
+ });
104
+ const add$2 = defineCommand({
105
+ meta: {
106
+ name: "add",
107
+ description: "Add a bot id to fleet/clawdlets.json."
108
+ },
109
+ args: {
110
+ bot: {
111
+ type: "string",
112
+ description: "Bot id (e.g. maren)."
113
+ },
114
+ interactive: {
115
+ type: "boolean",
116
+ description: "Prompt for missing inputs (requires TTY).",
117
+ default: false
118
+ }
119
+ },
120
+ async run({ args }) {
121
+ const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
122
+ let botId = String(args.bot || "").trim();
123
+ if (!botId) {
124
+ if (!args.interactive) throw new Error("missing --bot (or pass --interactive)");
125
+ if (!process$1.stdout.isTTY) throw new Error("--interactive requires a TTY");
126
+ p.intro("clawdlets bot add");
127
+ const v = await p.text({
128
+ message: "Bot id",
129
+ placeholder: "maren",
130
+ validate: validateBotId
131
+ });
132
+ if (p.isCancel(v)) {
133
+ if (await navOnCancel({
134
+ flow: "bot add",
135
+ canBack: false
136
+ }) === NAV_EXIT) cancelFlow();
137
+ return;
138
+ }
139
+ botId = String(v).trim();
140
+ }
141
+ const err = validateBotId(botId);
142
+ if (err) throw new Error(err);
143
+ const existingBots = config$1.fleet.botOrder;
144
+ if (existingBots.includes(botId) || config$1.fleet.bots[botId]) {
145
+ console.log(`ok: already present: ${botId}`);
146
+ return;
147
+ }
148
+ const next = {
149
+ ...config$1,
150
+ fleet: {
151
+ ...config$1.fleet,
152
+ botOrder: [...existingBots, botId],
153
+ bots: {
154
+ ...config$1.fleet.bots,
155
+ [botId]: {}
156
+ }
157
+ }
158
+ };
159
+ await writeClawdletsConfig({
160
+ configPath,
161
+ config: ClawdletsConfigSchema.parse(next)
162
+ });
163
+ console.log(`ok: added bot ${botId}`);
164
+ }
165
+ });
166
+ const rm$1 = defineCommand({
167
+ meta: {
168
+ name: "rm",
169
+ description: "Remove a bot id from fleet/clawdlets.json."
170
+ },
171
+ args: { bot: {
172
+ type: "string",
173
+ description: "Bot id to remove."
174
+ } },
175
+ async run({ args }) {
176
+ const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
177
+ const botId = String(args.bot || "").trim();
178
+ if (!botId) throw new Error("missing --bot");
179
+ const existingBots = config$1.fleet.botOrder;
180
+ if (!existingBots.includes(botId) && !config$1.fleet.bots[botId]) throw new Error(`bot not found: ${botId}`);
181
+ const nextBots = existingBots.filter((b) => b !== botId);
182
+ const nextBotsRecord = { ...config$1.fleet.bots };
183
+ delete nextBotsRecord[botId];
184
+ const next = {
185
+ ...config$1,
186
+ fleet: {
187
+ ...config$1.fleet,
188
+ botOrder: nextBots,
189
+ bots: nextBotsRecord
190
+ }
191
+ };
192
+ await writeClawdletsConfig({
193
+ configPath,
194
+ config: ClawdletsConfigSchema.parse(next)
195
+ });
196
+ console.log(`ok: removed bot ${botId}`);
197
+ }
198
+ });
199
+ const bot = defineCommand({
200
+ meta: {
201
+ name: "bot",
202
+ description: "Manage fleet bots."
203
+ },
204
+ subCommands: {
205
+ add: add$2,
206
+ list: list$1,
207
+ rm: rm$1
208
+ }
209
+ });
210
+
211
+ //#endregion
212
+ //#region src/lib/doctor-render.ts
213
+ const STATUS_ORDER = {
214
+ missing: 0,
215
+ warn: 1,
216
+ ok: 2
217
+ };
218
+ const SCOPE_ORDER = {
219
+ repo: 0,
220
+ bootstrap: 1,
221
+ "server-deploy": 2,
222
+ cattle: 3
223
+ };
224
+ function supportsColor(out) {
225
+ if (!out.isTTY) return false;
226
+ if (process$1.env.NO_COLOR != null) return false;
227
+ if (process$1.env.TERM === "dumb") return false;
228
+ return true;
229
+ }
230
+ function colorize(params) {
231
+ if (!params.enabled) return params.s;
232
+ return `\x1b[${params.code}m${params.s}\x1b[0m`;
233
+ }
234
+ function formatStatusTag(status, opts) {
235
+ if (status === "ok") return colorize({
236
+ enabled: opts.color,
237
+ code: 32,
238
+ s: "[OK]"
239
+ });
240
+ if (status === "warn") return colorize({
241
+ enabled: opts.color,
242
+ code: 33,
243
+ s: "[WARN]"
244
+ });
245
+ return colorize({
246
+ enabled: opts.color,
247
+ code: 31,
248
+ s: "[MISSING]"
249
+ });
250
+ }
251
+ function bold(s, opts) {
252
+ return colorize({
253
+ enabled: opts.color,
254
+ code: 1,
255
+ s
256
+ });
257
+ }
258
+ function categoryForLabel(label) {
259
+ const l = label.toLowerCase();
260
+ if (l.includes("public repo hygiene") || l.includes("inline scripting") || l.includes("docs index") || l.includes("bundled skills")) return "repo hygiene";
261
+ if (l.includes("fleet") || l.includes("guild") || l.includes("discord") || l.includes("routing")) return "fleet / discord";
262
+ if (l.includes("sops") || l.includes("secret") || l.includes("envsecrets") || l.includes("llm api")) return "secrets";
263
+ if (l.includes("deploy env file") || l.includes("env file")) return "infra";
264
+ if (l.includes("hetzner") || l.includes("provisioning") || l.includes("opentofu") || l.includes("hcloud") || l.includes("nixos-anywhere")) return "infra";
265
+ if (l.includes("github_token") || l.includes("base flake")) return "github";
266
+ if (l.includes("ssh") || l.includes("targethost") || l.includes("authorizedkeys")) return "ssh";
267
+ if (l.startsWith("nix")) return "nix";
268
+ return "other";
269
+ }
270
+ function groupChecks(params) {
271
+ const byKey = /* @__PURE__ */ new Map();
272
+ for (const c of params.checks) {
273
+ if (!params.showOk && c.status === "ok") continue;
274
+ const category = categoryForLabel(c.label);
275
+ const key = `${c.scope}:${category}`;
276
+ const existing = byKey.get(key);
277
+ if (existing) {
278
+ existing.checks.push(c);
279
+ continue;
280
+ }
281
+ byKey.set(key, {
282
+ scope: c.scope,
283
+ category,
284
+ checks: [c]
285
+ });
286
+ }
287
+ const groups = Array.from(byKey.values()).map((g) => {
288
+ const worst = g.checks.reduce((acc, c) => STATUS_ORDER[c.status] < STATUS_ORDER[acc] ? c.status : acc, "ok");
289
+ g.checks.sort((a, b) => {
290
+ const d = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
291
+ if (d !== 0) return d;
292
+ return a.label.localeCompare(b.label);
293
+ });
294
+ return {
295
+ ...g,
296
+ worst
297
+ };
298
+ });
299
+ groups.sort((a, b) => {
300
+ const scopeOrder = SCOPE_ORDER[a.scope] - SCOPE_ORDER[b.scope];
301
+ if (scopeOrder !== 0) return scopeOrder;
302
+ const worstOrder = STATUS_ORDER[a.worst] - STATUS_ORDER[b.worst];
303
+ if (worstOrder !== 0) return worstOrder;
304
+ return a.category.localeCompare(b.category);
305
+ });
306
+ return groups;
307
+ }
308
+ function renderDoctorReport(params) {
309
+ const color = supportsColor(process$1.stdout);
310
+ const counts = params.checks.reduce((acc, c) => {
311
+ acc[c.status] += 1;
312
+ return acc;
313
+ }, {
314
+ ok: 0,
315
+ warn: 0,
316
+ missing: 0
317
+ });
318
+ const groups = groupChecks({
319
+ checks: params.checks,
320
+ showOk: params.showOk
321
+ });
322
+ const lines = [];
323
+ lines.push(`doctor: host=${params.host} scope=${params.scope}${params.strict ? " strict" : ""}`);
324
+ lines.push(`summary: ok=${counts.ok} warn=${counts.warn} missing=${counts.missing}${!params.showOk && counts.ok > 0 ? " (ok hidden; pass --show-ok)" : ""}`);
325
+ if (groups.length === 0) {
326
+ lines.push("ok: no issues found");
327
+ return lines.join("\n");
328
+ }
329
+ for (const g of groups) {
330
+ lines.push("");
331
+ lines.push(bold(`${g.scope} / ${g.category}`, { color }));
332
+ for (const c of g.checks) {
333
+ const tag = formatStatusTag(c.status, { color });
334
+ lines.push(` ${tag} ${c.label}${c.detail ? ` (${c.detail})` : ""}`);
335
+ }
336
+ }
337
+ return lines.join("\n");
338
+ }
339
+ function renderDoctorGateFailure(params) {
340
+ const missing = params.checks.filter((c) => c.status === "missing");
341
+ const warn = params.checks.filter((c) => c.status === "warn");
342
+ const groups = groupChecks({
343
+ checks: params.strict ? [...missing, ...warn] : missing,
344
+ showOk: true
345
+ });
346
+ const lines = [];
347
+ lines.push(`doctor gate failed (${params.scope}${params.strict ? ", strict" : ""})`);
348
+ lines.push(`missing=${missing.length}${params.strict ? ` warn=${warn.length}` : ""}`);
349
+ const maxLines = 60;
350
+ for (const g of groups) {
351
+ if (lines.length >= maxLines) break;
352
+ lines.push("");
353
+ lines.push(`${g.scope} / ${g.category}`);
354
+ for (const c of g.checks) {
355
+ if (lines.length >= maxLines) break;
356
+ lines.push(` ${c.status.toUpperCase()}: ${c.label}${c.detail ? ` (${c.detail})` : ""}`);
357
+ }
358
+ }
359
+ lines.push("");
360
+ lines.push(`hint: run clawdlets doctor --scope ${params.scope}${params.strict ? " --strict" : ""}`);
361
+ return lines.join("\n");
362
+ }
363
+
364
+ //#endregion
365
+ //#region src/lib/deploy-gate.ts
366
+ async function requireDeployGate(params) {
367
+ const checks = await collectDoctorChecks({
368
+ cwd: process$1.cwd(),
369
+ runtimeDir: params.runtimeDir,
370
+ envFile: params.envFile,
371
+ host: params.host,
372
+ scope: params.scope,
373
+ skipGithubTokenCheck: params.skipGithubTokenCheck
374
+ });
375
+ const missing = checks.filter((c) => c.status === "missing");
376
+ const warn = checks.filter((c) => c.status === "warn");
377
+ if (!(missing.length > 0 || params.strict && warn.length > 0)) return;
378
+ throw new Error(renderDoctorGateFailure({
379
+ checks,
380
+ scope: params.scope,
381
+ strict: params.strict
382
+ }));
383
+ }
384
+
385
+ //#endregion
386
+ //#region src/commands/bootstrap.ts
387
+ async function purgeKnownHosts(ipv4, opts) {
388
+ const rm$2 = async (host$1) => {
389
+ if (opts.dryRun) {
390
+ console.log(`ssh-keygen -R ${host$1}`);
391
+ return;
392
+ }
393
+ await run("ssh-keygen", ["-R", host$1]);
394
+ };
395
+ await rm$2(ipv4);
396
+ await rm$2(`[${ipv4}]:22`);
397
+ }
398
+ function resolveHostFromFlake(flakeBase) {
399
+ const hashIndex = flakeBase.indexOf("#");
400
+ if (hashIndex === -1) return null;
401
+ const host$1 = flakeBase.slice(hashIndex + 1).trim();
402
+ return host$1.length > 0 ? host$1 : null;
403
+ }
404
+ const bootstrap = defineCommand({
405
+ meta: {
406
+ name: "bootstrap",
407
+ description: "Provision Hetzner VM + install NixOS (nixos-anywhere or image)."
408
+ },
409
+ args: {
410
+ runtimeDir: {
411
+ type: "string",
412
+ description: "Runtime directory (default: .clawdlets)."
413
+ },
414
+ envFile: {
415
+ type: "string",
416
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
417
+ },
418
+ host: {
419
+ type: "string",
420
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
421
+ },
422
+ mode: {
423
+ type: "string",
424
+ description: "Bootstrap mode: nixos-anywhere|image.",
425
+ default: "nixos-anywhere"
426
+ },
427
+ flake: {
428
+ type: "string",
429
+ description: "Override base flake (default: clawdlets.json baseFlake or git origin)."
430
+ },
431
+ rev: {
432
+ type: "string",
433
+ description: "Git rev to pin (HEAD/sha/tag).",
434
+ default: "HEAD"
435
+ },
436
+ ref: {
437
+ type: "string",
438
+ description: "Git ref to pin (branch or tag)."
439
+ },
440
+ force: {
441
+ type: "boolean",
442
+ description: "Skip doctor gate (not recommended).",
443
+ default: false
444
+ },
445
+ dryRun: {
446
+ type: "boolean",
447
+ description: "Print commands without executing.",
448
+ default: false
449
+ }
450
+ },
451
+ async run({ args }) {
452
+ const cwd = process$1.cwd();
453
+ const repoRoot = findRepoRoot(cwd);
454
+ const hostName = resolveHostNameOrExit({
455
+ cwd,
456
+ runtimeDir: args.runtimeDir,
457
+ hostArg: args.host
458
+ });
459
+ if (!hostName) return;
460
+ const { layout, config: clawdletsConfig } = loadClawdletsConfig({
461
+ repoRoot,
462
+ runtimeDir: args.runtimeDir
463
+ });
464
+ const hostCfg = clawdletsConfig.hosts[hostName];
465
+ if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
466
+ const sshExposureMode = getSshExposureMode(hostCfg);
467
+ const tailnetMode = getTailnetMode(hostCfg);
468
+ const modeRaw = String(args.mode || "nixos-anywhere").trim();
469
+ if (modeRaw !== "nixos-anywhere" && modeRaw !== "image") throw new Error(`invalid --mode: ${modeRaw} (expected nixos-anywhere|image)`);
470
+ const mode = modeRaw;
471
+ if (Boolean(args.force)) console.error("warn: skipping doctor gate (--force)");
472
+ else if (mode === "nixos-anywhere") await requireDeployGate({
473
+ runtimeDir: args.runtimeDir,
474
+ envFile: args.envFile,
475
+ host: hostName,
476
+ scope: "bootstrap",
477
+ strict: false
478
+ });
479
+ else console.error("warn: skipping doctor gate for image bootstrap");
480
+ const deployCreds = loadDeployCreds({
481
+ cwd,
482
+ runtimeDir: args.runtimeDir,
483
+ envFile: args.envFile
484
+ });
485
+ if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
486
+ if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
487
+ const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
488
+ if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
489
+ const githubToken = String(deployCreds.values.GITHUB_TOKEN || "").trim();
490
+ const nixBin = String(deployCreds.values.NIX_BIN || "nix").trim() || "nix";
491
+ const opentofuDir = getHostOpenTofuDir(layout, hostName);
492
+ const serverType = String(hostCfg.hetzner.serverType || "").trim();
493
+ if (!serverType) throw new Error(`missing hetzner.serverType for ${hostName} (set via: clawdlets host set --server-type ...)`);
494
+ const image$1 = String(hostCfg.hetzner.image || "").trim();
495
+ const location = String(hostCfg.hetzner.location || "").trim();
496
+ if (mode === "image" && !image$1) throw new Error(`missing hetzner.image for ${hostName} (set via: clawdlets host set --hetzner-image <image_id>)`);
497
+ const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
498
+ if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
499
+ const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
500
+ if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
501
+ const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
502
+ const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
503
+ if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
504
+ if (sshExposureMode === "tailnet") throw new Error(`sshExposure.mode=tailnet; bootstrap requires public SSH. Set: clawdlets host set --host ${hostName} --ssh-exposure bootstrap`);
505
+ await applyOpenTofuVars({
506
+ opentofuDir,
507
+ vars: {
508
+ hostName,
509
+ hcloudToken,
510
+ adminCidr,
511
+ adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
512
+ sshPubkeyFile,
513
+ serverType,
514
+ image: image$1,
515
+ location,
516
+ sshExposureMode,
517
+ tailnetMode
518
+ },
519
+ nixBin,
520
+ dryRun: args.dryRun,
521
+ redact: [hcloudToken, githubToken].filter(Boolean)
522
+ });
523
+ const tofuEnvWithFlakes = withFlakesEnv({
524
+ ...process$1.env,
525
+ HCLOUD_TOKEN: hcloudToken,
526
+ ADMIN_CIDR: adminCidr,
527
+ SSH_PUBKEY_FILE: sshPubkeyFile,
528
+ SERVER_TYPE: serverType
529
+ });
530
+ const ipv4 = args.dryRun ? "<opentofu-output:ipv4>" : await capture(nixBin, [
531
+ "run",
532
+ "--impure",
533
+ "nixpkgs#opentofu",
534
+ "--",
535
+ "output",
536
+ "-raw",
537
+ "ipv4"
538
+ ], {
539
+ cwd: opentofuDir,
540
+ env: tofuEnvWithFlakes,
541
+ dryRun: args.dryRun
542
+ });
543
+ console.log(`Target IPv4: ${ipv4}`);
544
+ await purgeKnownHosts(ipv4, { dryRun: args.dryRun });
545
+ if (mode === "image") {
546
+ console.log("🎉 Bootstrap complete (image mode).");
547
+ console.log(`Host: ${hostName}`);
548
+ console.log(`IPv4: ${ipv4}`);
549
+ console.log(`SSH exposure: ${sshExposureMode}`);
550
+ console.log("");
551
+ console.log("Next:");
552
+ console.log(`1) Set targetHost for deploys:`);
553
+ console.log(` clawdlets host set --host ${hostName} --target-host admin@${ipv4}`);
554
+ console.log("2) Deploy secrets + system:");
555
+ console.log(` clawdlets server deploy --host ${hostName} --target-host admin@${ipv4} --manifest deploy-manifest.${hostName}.json`);
556
+ console.log("");
557
+ console.log("After tailnet is healthy, lock down SSH:");
558
+ console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
559
+ console.log(` clawdlets lockdown --host ${hostName}`);
560
+ return;
561
+ }
562
+ const baseResolved = await resolveBaseFlake({
563
+ repoRoot,
564
+ config: clawdletsConfig
565
+ });
566
+ const flakeBase = String(args.flake || baseResolved.flake || "").trim();
567
+ if (!flakeBase) throw new Error("missing base flake (set baseFlake in fleet/clawdlets.json, set git origin, or pass --flake)");
568
+ const rev = String(args.rev || "").trim();
569
+ const ref = String(args.ref || "").trim();
570
+ if (rev && ref) throw new Error("use either --rev or --ref (not both)");
571
+ const requestedHost = String(hostCfg.flakeHost || hostName).trim() || hostName;
572
+ const hostFromFlake = resolveHostFromFlake(flakeBase);
573
+ if (hostFromFlake && hostFromFlake !== requestedHost) throw new Error(`flake host mismatch: ${hostFromFlake} vs ${requestedHost}`);
574
+ const flakeWithHost = flakeBase.includes("#") ? flakeBase : `${flakeBase}#${requestedHost}`;
575
+ const hashIndex = flakeWithHost.indexOf("#");
576
+ const flakeBasePath = hashIndex === -1 ? flakeWithHost : flakeWithHost.slice(0, hashIndex);
577
+ const flakeFragment = hashIndex === -1 ? "" : flakeWithHost.slice(hashIndex);
578
+ if ((rev || ref) && /(^|[?&])(rev|ref)=/.test(flakeBasePath)) throw new Error("flake already includes ?rev/?ref; drop --rev/--ref");
579
+ let flakePinned = flakeWithHost;
580
+ if (rev) {
581
+ const resolved = await resolveGitRev(repoRoot, rev);
582
+ if (!resolved) throw new Error(`unable to resolve git rev: ${rev}`);
583
+ flakePinned = `${flakeBasePath}${flakeBasePath.includes("?") ? "&" : "?"}rev=${resolved}${flakeFragment}`;
584
+ } else if (ref) flakePinned = `${flakeBasePath}${flakeBasePath.includes("?") ? "&" : "?"}ref=${ref}${flakeFragment}`;
585
+ const githubRepo = tryParseGithubFlakeUri(flakeBasePath);
586
+ if (githubRepo && !args.dryRun) {
587
+ const check = await checkGithubRepoVisibility({
588
+ owner: githubRepo.owner,
589
+ repo: githubRepo.repo,
590
+ token: githubToken || void 0
591
+ });
592
+ if (check.ok && check.status === "private-or-missing" && !githubToken) throw new Error(`base flake repo appears private (404). Set GITHUB_TOKEN in your environment and retry.`);
593
+ if (check.ok && check.status === "unauthorized") throw new Error(`GITHUB_TOKEN rejected by GitHub (401).`);
594
+ }
595
+ const extraFiles = getHostExtraFilesDir(layout, hostName);
596
+ const requiredKey = getHostExtraFilesKeyPath(layout, hostName);
597
+ if (!fs.existsSync(requiredKey)) throw new Error(`missing extra-files key: ${requiredKey} (run: clawdlets secrets init)`);
598
+ const secretsPlan = buildFleetSecretsPlan({
599
+ config: clawdletsConfig,
600
+ hostName
601
+ });
602
+ const requiredSecrets = [
603
+ ...tailnetMode === "tailscale" ? ["tailscale_auth_key"] : [],
604
+ "admin_password_hash",
605
+ ...secretsPlan.secretNamesRequired
606
+ ];
607
+ const extraFilesSecretsDir = getHostExtraFilesSecretsDir(layout, hostName);
608
+ if (!fs.existsSync(extraFilesSecretsDir)) throw new Error(`missing extra-files secrets dir: ${extraFilesSecretsDir} (run: clawdlets secrets init)`);
609
+ for (const secretName of requiredSecrets) {
610
+ const f = path.join(extraFilesSecretsDir, `${secretName}.yaml`);
611
+ if (!fs.existsSync(f)) throw new Error(`missing extra-files secret: ${f} (run: clawdlets secrets init)`);
612
+ }
613
+ const nixosAnywhereArgs = [
614
+ "run",
615
+ "--option",
616
+ "max-jobs",
617
+ "1",
618
+ "--option",
619
+ "cores",
620
+ "1",
621
+ "--option",
622
+ "keep-outputs",
623
+ "false",
624
+ "--option",
625
+ "keep-derivations",
626
+ "false",
627
+ "github:nix-community/nixos-anywhere",
628
+ "--",
629
+ "--option",
630
+ "tarball-ttl",
631
+ "0",
632
+ "--option",
633
+ "accept-flake-config",
634
+ "true",
635
+ "--option",
636
+ "extra-substituters",
637
+ "https://cache.garnix.io",
638
+ "--option",
639
+ "extra-trusted-public-keys",
640
+ "cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=",
641
+ "--build-on-remote",
642
+ "--extra-files",
643
+ extraFiles,
644
+ ...githubToken ? [
645
+ "--option",
646
+ "access-tokens",
647
+ `github.com=${githubToken}`
648
+ ] : [],
649
+ "--flake",
650
+ flakePinned,
651
+ `root@${ipv4}`
652
+ ];
653
+ const nixosAnywhereBaseEnv = withFlakesEnv(process$1.env);
654
+ await run(nixBin, nixosAnywhereArgs, {
655
+ cwd: repoRoot,
656
+ env: {
657
+ ...nixosAnywhereBaseEnv,
658
+ NIX_CONFIG: [
659
+ nixosAnywhereBaseEnv.NIX_CONFIG,
660
+ "accept-flake-config = true",
661
+ "extra-substituters = https://cache.garnix.io",
662
+ "extra-trusted-public-keys = cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=",
663
+ githubToken ? `access-tokens = github.com=${githubToken}` : ""
664
+ ].filter(Boolean).join("\n")
665
+ },
666
+ dryRun: args.dryRun,
667
+ redact: [hcloudToken, githubToken].filter(Boolean)
668
+ });
669
+ await purgeKnownHosts(ipv4, { dryRun: args.dryRun });
670
+ const publicSshStatus = "OPEN";
671
+ console.log("🎉 Bootstrap complete.");
672
+ console.log(`Host: ${hostName}`);
673
+ console.log(`IPv4: ${ipv4}`);
674
+ console.log(`SSH exposure: ${sshExposureMode}`);
675
+ console.log(`Public SSH (22): ${publicSshStatus}`);
676
+ console.log("");
677
+ console.log("⚠ SSH WILL REMAIN OPEN until you switch to tailnet and run lockdown:");
678
+ console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
679
+ console.log(` clawdlets lockdown --host ${hostName}`);
680
+ if (tailnetMode === "tailscale") {
681
+ console.log("");
682
+ console.log("Next (tailscale):");
683
+ console.log(`1) Wait for the host to appear in Tailscale, then copy its 100.x IP.`);
684
+ console.log(" tailscale status # look for the 100.x address");
685
+ console.log(`2) Set future SSH target to tailnet:`);
686
+ console.log(` clawdlets host set --host ${hostName} --target-host admin@<tailscale-ip>`);
687
+ console.log("3) Verify access:");
688
+ console.log(" ssh admin@<tailscale-ip> 'hostname; uptime'");
689
+ console.log("4) Switch SSH exposure to tailnet and lock down:");
690
+ console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
691
+ console.log(` clawdlets lockdown --host ${hostName}`);
692
+ console.log("5) Optional checks:");
693
+ console.log(" clawdlets server audit --host " + hostName);
694
+ } else {
695
+ console.log("");
696
+ console.log("Notes:");
697
+ console.log(`- SSH exposure is ${sshExposureMode}.`);
698
+ console.log("- If you want tailnet-only SSH, set tailnet.mode=tailscale, verify access, then:");
699
+ console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
700
+ console.log(` clawdlets lockdown --host ${hostName}`);
701
+ }
702
+ }
703
+ });
704
+
705
+ //#endregion
706
+ //#region src/commands/config.ts
707
+ function getAtPath(obj, parts) {
708
+ let cur = obj;
709
+ for (const k of parts) {
710
+ if (cur == null || typeof cur !== "object") return void 0;
711
+ cur = cur[k];
712
+ }
713
+ return cur;
714
+ }
715
+ function setAtPath(obj, parts, value) {
716
+ let cur = obj;
717
+ for (let i = 0; i < parts.length - 1; i++) {
718
+ const k = parts[i];
719
+ if (cur[k] == null || typeof cur[k] !== "object" || Array.isArray(cur[k])) cur[k] = {};
720
+ cur = cur[k];
721
+ }
722
+ cur[parts[parts.length - 1]] = value;
723
+ }
724
+ function deleteAtPath(obj, parts) {
725
+ let cur = obj;
726
+ for (let i = 0; i < parts.length - 1; i++) {
727
+ const k = parts[i];
728
+ if (cur == null || typeof cur !== "object") return false;
729
+ cur = cur[k];
730
+ }
731
+ const last = parts[parts.length - 1];
732
+ if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, last)) {
733
+ delete cur[last];
734
+ return true;
735
+ }
736
+ return false;
737
+ }
738
+ const init = defineCommand({
739
+ meta: {
740
+ name: "init",
741
+ description: "Initialize fleet/clawdlets.json (canonical config)."
742
+ },
743
+ args: {
744
+ host: {
745
+ type: "string",
746
+ description: "Initial host name.",
747
+ default: "clawdbot-fleet-host"
748
+ },
749
+ force: {
750
+ type: "boolean",
751
+ description: "Overwrite existing clawdlets.json.",
752
+ default: false
753
+ },
754
+ "dry-run": {
755
+ type: "boolean",
756
+ description: "Print planned writes without writing.",
757
+ default: false
758
+ }
759
+ },
760
+ async run({ args }) {
761
+ const repoRoot = findRepoRoot(process$1.cwd());
762
+ const host$1 = String(args.host || "clawdbot-fleet-host").trim() || "clawdbot-fleet-host";
763
+ const configPath = getRepoLayout(repoRoot).clawdletsConfigPath;
764
+ if (fs.existsSync(configPath) && !args.force) throw new Error(`config already exists (pass --force to overwrite): ${configPath}`);
765
+ const config = createDefaultClawdletsConfig({ host: host$1 });
766
+ if (args["dry-run"]) {
767
+ console.log(`planned: write ${path.relative(repoRoot, configPath)}`);
768
+ return;
769
+ }
770
+ await ensureDir(path.dirname(configPath));
771
+ await writeClawdletsConfig({
772
+ configPath,
773
+ config
774
+ });
775
+ console.log(`ok: wrote ${path.relative(repoRoot, configPath)}`);
776
+ }
777
+ });
778
+ const show$1 = defineCommand({
779
+ meta: {
780
+ name: "show",
781
+ description: "Print fleet/clawdlets.json."
782
+ },
783
+ args: { pretty: {
784
+ type: "boolean",
785
+ description: "Pretty-print JSON.",
786
+ default: true
787
+ } },
788
+ async run({ args }) {
789
+ const { config } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
790
+ console.log(args.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
791
+ }
792
+ });
793
+ const validate = defineCommand({
794
+ meta: {
795
+ name: "validate",
796
+ description: "Validate fleet/clawdlets.json schema."
797
+ },
798
+ args: {},
799
+ async run() {
800
+ loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
801
+ console.log("ok");
802
+ }
803
+ });
804
+ const get = defineCommand({
805
+ meta: {
806
+ name: "get",
807
+ description: "Get a value from fleet/clawdlets.json (dot path)."
808
+ },
809
+ args: {
810
+ path: {
811
+ type: "string",
812
+ description: "Dot path (e.g. fleet.botOrder)."
813
+ },
814
+ json: {
815
+ type: "boolean",
816
+ description: "JSON output.",
817
+ default: false
818
+ }
819
+ },
820
+ async run({ args }) {
821
+ const { config } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
822
+ const parts = splitDotPath(String(args.path || ""));
823
+ const v = getAtPath(config, parts);
824
+ if (args.json) console.log(JSON.stringify({
825
+ path: parts.join("."),
826
+ value: v
827
+ }, null, 2));
828
+ else console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
829
+ }
830
+ });
831
+ const set$2 = defineCommand({
832
+ meta: {
833
+ name: "set",
834
+ description: "Set a value in fleet/clawdlets.json (dot path)."
835
+ },
836
+ args: {
837
+ path: {
838
+ type: "string",
839
+ description: "Dot path (e.g. fleet.botOrder)."
840
+ },
841
+ value: {
842
+ type: "string",
843
+ description: "String value."
844
+ },
845
+ "value-json": {
846
+ type: "string",
847
+ description: "JSON value (parsed)."
848
+ },
849
+ delete: {
850
+ type: "boolean",
851
+ description: "Delete the key at path.",
852
+ default: false
853
+ }
854
+ },
855
+ async run({ args }) {
856
+ const { configPath, config } = loadClawdletsConfigRaw({ repoRoot: findRepoRoot(process$1.cwd()) });
857
+ const parts = splitDotPath(String(args.path || ""));
858
+ const next = structuredClone(config);
859
+ if (args.delete) {
860
+ if (!deleteAtPath(next, parts)) throw new Error(`path not found: ${parts.join(".")}`);
861
+ } else if (args["value-json"] !== void 0) {
862
+ let parsed;
863
+ try {
864
+ parsed = JSON.parse(String(args["value-json"]));
865
+ } catch {
866
+ throw new Error("invalid --value-json (must be valid JSON)");
867
+ }
868
+ setAtPath(next, parts, parsed);
869
+ } else if (args.value !== void 0) setAtPath(next, parts, String(args.value));
870
+ else throw new Error("set requires --value or --value-json (or --delete)");
871
+ try {
872
+ await writeClawdletsConfig({
873
+ configPath,
874
+ config: ClawdletsConfigSchema.parse(next)
875
+ });
876
+ console.log("ok");
877
+ } catch (err) {
878
+ let details = "";
879
+ if (Array.isArray(err?.errors)) details = err.errors.map((e) => (Array.isArray(e.path) ? e.path.join(".") : "") || e.message).filter(Boolean).join(", ");
880
+ const msg = details ? `config update failed; revert or fix validation errors: ${details}` : "config update failed; revert or fix validation errors";
881
+ throw new Error(msg);
882
+ }
883
+ }
884
+ });
885
+ const config = defineCommand({
886
+ meta: {
887
+ name: "config",
888
+ description: "Canonical config (fleet/clawdlets.json)."
889
+ },
890
+ subCommands: {
891
+ init,
892
+ show: show$1,
893
+ validate,
894
+ get,
895
+ set: set$2
896
+ }
897
+ });
898
+
899
+ //#endregion
900
+ //#region src/commands/doctor.ts
901
+ const doctor = defineCommand({
902
+ meta: {
903
+ name: "doctor",
904
+ description: "Validate repo + runtime inputs for deploying a host."
905
+ },
906
+ args: {
907
+ runtimeDir: {
908
+ type: "string",
909
+ description: "Runtime directory (default: .clawdlets)."
910
+ },
911
+ envFile: {
912
+ type: "string",
913
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
914
+ },
915
+ host: {
916
+ type: "string",
917
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
918
+ },
919
+ scope: {
920
+ type: "string",
921
+ description: "Which checks to run: repo | bootstrap | server-deploy | cattle | all (default: all).",
922
+ default: "all"
923
+ },
924
+ json: {
925
+ type: "boolean",
926
+ description: "Output JSON.",
927
+ default: false
928
+ },
929
+ "show-ok": {
930
+ type: "boolean",
931
+ description: "Show ok checks too.",
932
+ default: false
933
+ },
934
+ strict: {
935
+ type: "boolean",
936
+ description: "Fail on warn too (deploy gating).",
937
+ default: false
938
+ }
939
+ },
940
+ async run({ args }) {
941
+ const cwd = process$1.cwd();
942
+ const scopeRaw = String(args.scope || "all").trim();
943
+ if (scopeRaw !== "repo" && scopeRaw !== "bootstrap" && scopeRaw !== "server-deploy" && scopeRaw !== "cattle" && scopeRaw !== "all") throw new Error(`invalid --scope: ${scopeRaw} (expected repo|bootstrap|server-deploy|cattle|all)`);
944
+ const scope = scopeRaw;
945
+ if (scope === "repo") {
946
+ const repoRoot = findRepoRoot(cwd);
947
+ const templateSource = path.join(repoRoot, "config", "template-source.json");
948
+ const clawdletsConfig = path.join(repoRoot, "fleet", "clawdlets.json");
949
+ if (fs.existsSync(templateSource) && !fs.existsSync(clawdletsConfig)) {
950
+ console.log("note: CLI repo detected; run doctor in a project repo or via template-e2e.");
951
+ return;
952
+ }
953
+ }
954
+ const hostName = resolveHostNameOrExit({
955
+ cwd,
956
+ runtimeDir: args.runtimeDir,
957
+ hostArg: args.host
958
+ });
959
+ if (!hostName) return;
960
+ const checks = await collectDoctorChecks({
961
+ cwd,
962
+ runtimeDir: args.runtimeDir,
963
+ envFile: args.envFile,
964
+ host: hostName,
965
+ scope
966
+ });
967
+ if (args.json) console.log(JSON.stringify({
968
+ scope: scopeRaw,
969
+ host: hostName,
970
+ checks
971
+ }, null, 2));
972
+ else console.log(renderDoctorReport({
973
+ checks,
974
+ host: hostName,
975
+ scope,
976
+ strict: args.strict,
977
+ showOk: Boolean(args["show-ok"])
978
+ }));
979
+ if (checks.some((c) => c.status === "missing")) process$1.exitCode = 1;
980
+ if (args.strict && checks.some((c) => c.status === "warn")) process$1.exitCode = 1;
981
+ }
982
+ });
983
+
984
+ //#endregion
985
+ //#region src/commands/env.ts
986
+ function resolveEnvFilePath(params) {
987
+ const repoRoot = findRepoRoot(params.cwd);
988
+ const explicit = String(params.envFileArg ?? "").trim();
989
+ if (explicit) {
990
+ const expanded = expandPath(explicit);
991
+ return {
992
+ path: path.isAbsolute(expanded) ? expanded : path.resolve(params.cwd, expanded),
993
+ origin: "explicit"
994
+ };
995
+ }
996
+ return {
997
+ path: getRepoLayout(repoRoot, params.runtimeDir).envFilePath,
998
+ origin: "default"
999
+ };
1000
+ }
1001
+ function renderEnvFile(keys) {
1002
+ return [
1003
+ "# clawdlets deploy creds (local-only; never commit)",
1004
+ "# Used by: bootstrap, infra, lockdown, doctor",
1005
+ "",
1006
+ `HCLOUD_TOKEN=${formatDotenvValue(keys.HCLOUD_TOKEN)}`,
1007
+ `GITHUB_TOKEN=${formatDotenvValue(keys.GITHUB_TOKEN)}`,
1008
+ `NIX_BIN=${formatDotenvValue(keys.NIX_BIN)}`,
1009
+ `SOPS_AGE_KEY_FILE=${formatDotenvValue(keys.SOPS_AGE_KEY_FILE)}`,
1010
+ ""
1011
+ ].join("\n");
1012
+ }
1013
+ function readEnvFileOrEmpty(filePath) {
1014
+ if (!fs.existsSync(filePath)) return {
1015
+ text: "",
1016
+ parsed: {}
1017
+ };
1018
+ const st = fs.lstatSync(filePath);
1019
+ if (st.isSymbolicLink()) throw new Error(`refusing to read env file symlink: ${filePath}`);
1020
+ if (!st.isFile()) throw new Error(`refusing to read non-file env path: ${filePath}`);
1021
+ const text = fs.readFileSync(filePath, "utf8");
1022
+ return {
1023
+ text,
1024
+ parsed: parseDotenv(text)
1025
+ };
1026
+ }
1027
+ const envInit = defineCommand({
1028
+ meta: {
1029
+ name: "init",
1030
+ description: "Create/update <runtimeDir>/env for deploy creds (gitignored)."
1031
+ },
1032
+ args: {
1033
+ runtimeDir: {
1034
+ type: "string",
1035
+ description: "Runtime directory (default: .clawdlets)."
1036
+ },
1037
+ envFile: {
1038
+ type: "string",
1039
+ description: "Env file path override (advanced; default: <runtimeDir>/env)."
1040
+ }
1041
+ },
1042
+ async run({ args }) {
1043
+ const cwd = process$1.cwd();
1044
+ const repoRoot = findRepoRoot(cwd);
1045
+ const layout = getRepoLayout(repoRoot, args.runtimeDir);
1046
+ const resolved = resolveEnvFilePath({
1047
+ cwd,
1048
+ runtimeDir: args.runtimeDir,
1049
+ envFileArg: args.envFile
1050
+ });
1051
+ if (resolved.origin === "default") try {
1052
+ fs.mkdirSync(layout.runtimeDir, { recursive: true });
1053
+ fs.chmodSync(layout.runtimeDir, 448);
1054
+ } catch {}
1055
+ const existing = readEnvFileOrEmpty(resolved.path).parsed;
1056
+ const keys = {
1057
+ HCLOUD_TOKEN: String(existing.HCLOUD_TOKEN || "").trim(),
1058
+ GITHUB_TOKEN: String(existing.GITHUB_TOKEN || "").trim(),
1059
+ NIX_BIN: String(existing.NIX_BIN || "nix").trim() || "nix",
1060
+ SOPS_AGE_KEY_FILE: String(existing.SOPS_AGE_KEY_FILE || "").trim()
1061
+ };
1062
+ await writeFileAtomic(resolved.path, renderEnvFile(keys), { mode: 384 });
1063
+ console.log(`ok: wrote ${path.relative(repoRoot, resolved.path) || resolved.path}`);
1064
+ if (resolved.origin === "explicit") console.log(`note: you must pass --env-file ${resolved.path} to deploy commands to use it`);
1065
+ else console.log("next: edit this file and set HCLOUD_TOKEN (required)");
1066
+ }
1067
+ });
1068
+ const envShow = defineCommand({
1069
+ meta: {
1070
+ name: "show",
1071
+ description: "Show resolved deploy creds (redacted) + their sources (env/file/default)."
1072
+ },
1073
+ args: {
1074
+ runtimeDir: {
1075
+ type: "string",
1076
+ description: "Runtime directory (default: .clawdlets)."
1077
+ },
1078
+ envFile: {
1079
+ type: "string",
1080
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
1081
+ }
1082
+ },
1083
+ async run({ args }) {
1084
+ const loaded = loadDeployCreds({
1085
+ cwd: process$1.cwd(),
1086
+ runtimeDir: args.runtimeDir,
1087
+ envFile: args.envFile
1088
+ });
1089
+ if (loaded.envFile) {
1090
+ const status = loaded.envFile.status;
1091
+ const detail = loaded.envFile.error ? ` (${loaded.envFile.error})` : "";
1092
+ console.log(`env file: ${status} (${loaded.envFile.origin}) ${loaded.envFile.path}${detail}`);
1093
+ } else console.log("env file: (default missing; set vars via process env or run: clawdlets env init)");
1094
+ const line = (k, redact) => {
1095
+ const v = loaded.values[k];
1096
+ const src = loaded.sources[k];
1097
+ if (!v) return `${k}: unset (${src})`;
1098
+ if (redact) return `${k}: set (${src})`;
1099
+ return `${k}: ${v} (${src})`;
1100
+ };
1101
+ console.log(line("HCLOUD_TOKEN", true));
1102
+ console.log(line("GITHUB_TOKEN", true));
1103
+ console.log(line("NIX_BIN", false));
1104
+ console.log(line("SOPS_AGE_KEY_FILE", false));
1105
+ }
1106
+ });
1107
+ const env = defineCommand({
1108
+ meta: {
1109
+ name: "env",
1110
+ description: "Local deploy credentials (.clawdlets/env)."
1111
+ },
1112
+ subCommands: {
1113
+ init: envInit,
1114
+ show: envShow
1115
+ }
1116
+ });
1117
+
1118
+ //#endregion
1119
+ //#region src/commands/host.ts
1120
+ function parseBoolOrUndefined(v) {
1121
+ if (v === void 0 || v === null) return void 0;
1122
+ const s = String(v).trim().toLowerCase();
1123
+ if (s === "") return void 0;
1124
+ if (s === "true" || s === "1" || s === "yes") return true;
1125
+ if (s === "false" || s === "0" || s === "no") return false;
1126
+ throw new Error(`invalid boolean: ${String(v)} (use true/false)`);
1127
+ }
1128
+ function readSshPublicKeysFromFile(filePath) {
1129
+ const stat = fs.statSync(filePath);
1130
+ if (!stat.isFile()) throw new Error(`not a file: ${filePath}`);
1131
+ if (stat.size > 64 * 1024) throw new Error(`ssh key file too large (>64KB): ${filePath}`);
1132
+ const raw = fs.readFileSync(filePath, "utf8");
1133
+ if (looksLikeSshPrivateKey(raw)) throw new Error(`refusing to read ssh private key (expected .pub): ${filePath}`);
1134
+ const keys = parseSshPublicKeysFromText(raw);
1135
+ if (keys.length === 0) throw new Error(`no ssh public keys found in file: ${filePath}`);
1136
+ return keys;
1137
+ }
1138
+ function readKnownHostsFromFile(filePath) {
1139
+ const stat = fs.statSync(filePath);
1140
+ if (!stat.isFile()) throw new Error(`not a file: ${filePath}`);
1141
+ if (stat.size > 256 * 1024) throw new Error(`known_hosts file too large (>256KB): ${filePath}`);
1142
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
1143
+ if (lines.length === 0) throw new Error(`no known_hosts entries found in file: ${filePath}`);
1144
+ return lines;
1145
+ }
1146
+ function toStringArray(v) {
1147
+ if (v == null) return [];
1148
+ if (Array.isArray(v)) return v.map((x) => String(x));
1149
+ return [String(v)];
1150
+ }
1151
+ const add$1 = defineCommand({
1152
+ meta: {
1153
+ name: "add",
1154
+ description: "Add a host entry to fleet/clawdlets.json."
1155
+ },
1156
+ args: { host: {
1157
+ type: "string",
1158
+ description: "Host name."
1159
+ } },
1160
+ async run({ args }) {
1161
+ const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
1162
+ const hostName = String(args.host || "").trim();
1163
+ if (!hostName) throw new Error("missing --host");
1164
+ assertSafeHostName(hostName);
1165
+ if (config$1.hosts[hostName]) throw new Error(`host already exists in clawdlets.json: ${hostName}`);
1166
+ const nextHost = {
1167
+ enable: false,
1168
+ diskDevice: "/dev/sda",
1169
+ sshAuthorizedKeys: [],
1170
+ sshKnownHosts: [],
1171
+ flakeHost: "",
1172
+ targetHost: void 0,
1173
+ hetzner: {
1174
+ serverType: "cx43",
1175
+ image: "",
1176
+ location: "nbg1"
1177
+ },
1178
+ provisioning: {
1179
+ adminCidr: "",
1180
+ adminCidrAllowWorldOpen: false,
1181
+ sshPubkeyFile: "~/.ssh/id_ed25519.pub"
1182
+ },
1183
+ sshExposure: { mode: "bootstrap" },
1184
+ tailnet: { mode: "tailscale" },
1185
+ cache: { garnix: { private: {
1186
+ enable: false,
1187
+ netrcSecret: "garnix_netrc",
1188
+ netrcPath: "/etc/nix/netrc",
1189
+ narinfoCachePositiveTtl: 3600
1190
+ } } },
1191
+ operator: { deploy: { enable: false } },
1192
+ selfUpdate: {
1193
+ enable: false,
1194
+ manifestUrl: "",
1195
+ interval: "30min",
1196
+ publicKey: "",
1197
+ signatureUrl: ""
1198
+ },
1199
+ agentModelPrimary: "zai/glm-4.7"
1200
+ };
1201
+ await writeClawdletsConfig({
1202
+ configPath,
1203
+ config: ClawdletsConfigSchema.parse({
1204
+ ...config$1,
1205
+ defaultHost: config$1.defaultHost || hostName,
1206
+ hosts: {
1207
+ ...config$1.hosts,
1208
+ [hostName]: nextHost
1209
+ }
1210
+ })
1211
+ });
1212
+ console.log(`ok: added host ${hostName}`);
1213
+ }
1214
+ });
1215
+ const setDefault = defineCommand({
1216
+ meta: {
1217
+ name: "set-default",
1218
+ description: "Set config.defaultHost (default host used when --host is omitted)."
1219
+ },
1220
+ args: { host: {
1221
+ type: "string",
1222
+ description: "Host name (defaults to current defaultHost / sole host)."
1223
+ } },
1224
+ async run({ args }) {
1225
+ const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
1226
+ const resolved = resolveHostName({
1227
+ config: config$1,
1228
+ host: args.host
1229
+ });
1230
+ if (!resolved.ok) {
1231
+ console.error(`warn: ${resolved.message}`);
1232
+ for (const t of resolved.tips) console.error(`tip: ${t}`);
1233
+ process$1.exitCode = 1;
1234
+ return;
1235
+ }
1236
+ await writeClawdletsConfig({
1237
+ configPath,
1238
+ config: ClawdletsConfigSchema.parse({
1239
+ ...config$1,
1240
+ defaultHost: resolved.host
1241
+ })
1242
+ });
1243
+ console.log(`ok: defaultHost = ${resolved.host}`);
1244
+ }
1245
+ });
1246
+ const set$1 = defineCommand({
1247
+ meta: {
1248
+ name: "set",
1249
+ description: "Set host config fields (in fleet/clawdlets.json)."
1250
+ },
1251
+ args: {
1252
+ host: {
1253
+ type: "string",
1254
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
1255
+ },
1256
+ enable: {
1257
+ type: "string",
1258
+ description: "Enable fleet services (true/false)."
1259
+ },
1260
+ "ssh-exposure": {
1261
+ type: "string",
1262
+ description: "SSH exposure mode: tailnet|bootstrap|public."
1263
+ },
1264
+ "disk-device": {
1265
+ type: "string",
1266
+ description: "Disk device (Hetzner Cloud: /dev/sda)."
1267
+ },
1268
+ "agent-model-primary": {
1269
+ type: "string",
1270
+ description: "Primary agent model (e.g. zai/glm-4.7)."
1271
+ },
1272
+ tailnet: {
1273
+ type: "string",
1274
+ description: "Tailnet mode: none|tailscale."
1275
+ },
1276
+ "garnix-private-cache": {
1277
+ type: "string",
1278
+ description: "Enable private Garnix cache access (true/false). Requires garnix netrc secret."
1279
+ },
1280
+ "garnix-netrc-secret": {
1281
+ type: "string",
1282
+ description: "Sops secret name containing /etc/nix/netrc (default: garnix_netrc)."
1283
+ },
1284
+ "garnix-netrc-path": {
1285
+ type: "string",
1286
+ description: "Filesystem path for netrc on host (default: /etc/nix/netrc)."
1287
+ },
1288
+ "garnix-narinfo-cache-positive-ttl": {
1289
+ type: "string",
1290
+ description: "narinfo-cache-positive-ttl when private cache enabled (default: 3600)."
1291
+ },
1292
+ "flake-host": {
1293
+ type: "string",
1294
+ description: "Flake output host name override (default: same as host name)."
1295
+ },
1296
+ "target-host": {
1297
+ type: "string",
1298
+ description: "SSH target (ssh config alias or user@host)."
1299
+ },
1300
+ "server-type": {
1301
+ type: "string",
1302
+ description: "Hetzner server type (e.g. cx43)."
1303
+ },
1304
+ "hetzner-image": {
1305
+ type: "string",
1306
+ description: "Hetzner image ID/name (custom image or snapshot)."
1307
+ },
1308
+ "hetzner-location": {
1309
+ type: "string",
1310
+ description: "Hetzner location (e.g. nbg1, fsn1)."
1311
+ },
1312
+ "admin-cidr": {
1313
+ type: "string",
1314
+ description: "ADMIN_CIDR (e.g. 1.2.3.4/32)."
1315
+ },
1316
+ "ssh-pubkey-file": {
1317
+ type: "string",
1318
+ description: "SSH_PUBKEY_FILE path (e.g. ~/.ssh/id_ed25519.pub)."
1319
+ },
1320
+ "clear-ssh-keys": {
1321
+ type: "boolean",
1322
+ description: "Clear sshAuthorizedKeys.",
1323
+ default: false
1324
+ },
1325
+ "add-ssh-key": {
1326
+ type: "string",
1327
+ description: "Add SSH public key contents (repeatable).",
1328
+ array: true
1329
+ },
1330
+ "add-ssh-key-file": {
1331
+ type: "string",
1332
+ description: "Add SSH public key from file (repeatable).",
1333
+ array: true
1334
+ },
1335
+ "clear-ssh-known-hosts": {
1336
+ type: "boolean",
1337
+ description: "Clear sshKnownHosts.",
1338
+ default: false
1339
+ },
1340
+ "add-ssh-known-host": {
1341
+ type: "string",
1342
+ description: "Add known_hosts entry (repeatable).",
1343
+ array: true
1344
+ },
1345
+ "add-ssh-known-host-file": {
1346
+ type: "string",
1347
+ description: "Add known_hosts entries from file (repeatable).",
1348
+ array: true
1349
+ }
1350
+ },
1351
+ async run({ args }) {
1352
+ const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
1353
+ const resolved = resolveHostName({
1354
+ config: config$1,
1355
+ host: args.host
1356
+ });
1357
+ if (!resolved.ok) {
1358
+ console.error(`warn: ${resolved.message}`);
1359
+ for (const t of resolved.tips) console.error(`tip: ${t}`);
1360
+ process$1.exitCode = 1;
1361
+ return;
1362
+ }
1363
+ const hostName = resolved.host;
1364
+ const existing = config$1.hosts[hostName];
1365
+ if (!existing) {
1366
+ console.error(`warn: unknown host in clawdlets.json: ${hostName}`);
1367
+ console.error(`tip: available hosts: ${Object.keys(config$1.hosts).join(", ")}`);
1368
+ process$1.exitCode = 1;
1369
+ return;
1370
+ }
1371
+ const next = structuredClone(existing);
1372
+ const enable = parseBoolOrUndefined(args.enable);
1373
+ if (enable !== void 0) next.enable = enable;
1374
+ if (args["ssh-exposure"] !== void 0) {
1375
+ const mode = String(args["ssh-exposure"]).trim();
1376
+ if (!SSH_EXPOSURE_MODES.includes(mode)) throw new Error("invalid --ssh-exposure (expected tailnet|bootstrap|public)");
1377
+ next.sshExposure.mode = mode;
1378
+ }
1379
+ if (args["disk-device"] !== void 0) next.diskDevice = String(args["disk-device"]).trim();
1380
+ if (args["agent-model-primary"] !== void 0) next.agentModelPrimary = String(args["agent-model-primary"]).trim();
1381
+ if (args["garnix-private-cache"] !== void 0) {
1382
+ const v = parseBoolOrUndefined(args["garnix-private-cache"]);
1383
+ if (v !== void 0) next.cache.garnix.private.enable = v;
1384
+ }
1385
+ if (args["garnix-netrc-secret"] !== void 0) next.cache.garnix.private.netrcSecret = String(args["garnix-netrc-secret"]).trim();
1386
+ if (args["garnix-netrc-path"] !== void 0) next.cache.garnix.private.netrcPath = String(args["garnix-netrc-path"]).trim();
1387
+ if (args["garnix-narinfo-cache-positive-ttl"] !== void 0) {
1388
+ const raw = String(args["garnix-narinfo-cache-positive-ttl"]).trim();
1389
+ if (raw) {
1390
+ const n = Number(raw);
1391
+ if (!Number.isInteger(n) || n <= 0) throw new Error("invalid --garnix-narinfo-cache-positive-ttl (expected positive integer)");
1392
+ next.cache.garnix.private.narinfoCachePositiveTtl = n;
1393
+ }
1394
+ }
1395
+ if (args["flake-host"] !== void 0) next.flakeHost = String(args["flake-host"]).trim();
1396
+ if (args["target-host"] !== void 0) {
1397
+ const v = String(args["target-host"]).trim();
1398
+ next.targetHost = v ? validateTargetHost(v) : void 0;
1399
+ }
1400
+ if (args["server-type"] !== void 0) next.hetzner.serverType = String(args["server-type"]).trim();
1401
+ if (args["hetzner-image"] !== void 0) next.hetzner.image = String(args["hetzner-image"]).trim();
1402
+ if (args["hetzner-location"] !== void 0) next.hetzner.location = String(args["hetzner-location"]).trim();
1403
+ if (args["admin-cidr"] !== void 0) next.provisioning.adminCidr = String(args["admin-cidr"]).trim();
1404
+ if (args["ssh-pubkey-file"] !== void 0) next.provisioning.sshPubkeyFile = String(args["ssh-pubkey-file"]).trim();
1405
+ if (args.tailnet !== void 0) {
1406
+ const mode = String(args.tailnet).trim();
1407
+ if (mode !== "none" && mode !== "tailscale") throw new Error("invalid --tailnet (expected none|tailscale)");
1408
+ next.tailnet.mode = mode;
1409
+ }
1410
+ if (args["clear-ssh-keys"]) next.sshAuthorizedKeys = [];
1411
+ {
1412
+ const keys = new Set(next.sshAuthorizedKeys || []);
1413
+ for (const file of toStringArray(args["add-ssh-key-file"])) for (const k of readSshPublicKeysFromFile(file)) keys.add(k);
1414
+ for (const raw of toStringArray(args["add-ssh-key"])) {
1415
+ if (!raw.trim()) continue;
1416
+ if (looksLikeSshPrivateKey(raw)) throw new Error("refusing to add ssh private key (expected public key contents)");
1417
+ const parsed = parseSshPublicKeysFromText(raw);
1418
+ if (parsed.length === 0) throw new Error("invalid --add-ssh-key (expected ssh public key contents)");
1419
+ for (const k of parsed) keys.add(k);
1420
+ }
1421
+ next.sshAuthorizedKeys = Array.from(keys);
1422
+ }
1423
+ if (args["clear-ssh-known-hosts"]) next.sshKnownHosts = [];
1424
+ {
1425
+ const knownHosts = new Set(next.sshKnownHosts || []);
1426
+ for (const file of toStringArray(args["add-ssh-known-host-file"])) for (const line of readKnownHostsFromFile(file)) knownHosts.add(line);
1427
+ for (const raw of toStringArray(args["add-ssh-known-host"])) {
1428
+ const trimmed = raw.trim();
1429
+ if (!trimmed) continue;
1430
+ if (trimmed.startsWith("#")) continue;
1431
+ knownHosts.add(trimmed);
1432
+ }
1433
+ next.sshKnownHosts = Array.from(knownHosts);
1434
+ }
1435
+ await writeClawdletsConfig({
1436
+ configPath,
1437
+ config: ClawdletsConfigSchema.parse({
1438
+ ...config$1,
1439
+ hosts: {
1440
+ ...config$1.hosts,
1441
+ [hostName]: next
1442
+ }
1443
+ })
1444
+ });
1445
+ console.log(`ok: updated host ${hostName}`);
1446
+ }
1447
+ });
1448
+ const host = defineCommand({
1449
+ meta: {
1450
+ name: "host",
1451
+ description: "Manage host config (fleet/clawdlets.json)."
1452
+ },
1453
+ subCommands: {
1454
+ add: add$1,
1455
+ "set-default": setDefault,
1456
+ set: set$1
1457
+ }
1458
+ });
1459
+
1460
+ //#endregion
1461
+ //#region src/commands/fleet.ts
1462
+ const show = defineCommand({
1463
+ meta: {
1464
+ name: "show",
1465
+ description: "Print fleet config (from fleet/clawdlets.json)."
1466
+ },
1467
+ args: {},
1468
+ async run() {
1469
+ const { config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
1470
+ console.log(JSON.stringify(config$1.fleet, null, 2));
1471
+ }
1472
+ });
1473
+ const set = defineCommand({
1474
+ meta: {
1475
+ name: "set",
1476
+ description: "Set fleet config fields (in fleet/clawdlets.json)."
1477
+ },
1478
+ args: {
1479
+ "codex-enable": {
1480
+ type: "string",
1481
+ description: "Enable codex (true/false)."
1482
+ },
1483
+ "guild-id": {
1484
+ type: "string",
1485
+ description: "Discord guild ID."
1486
+ },
1487
+ "restic-enable": {
1488
+ type: "string",
1489
+ description: "Enable restic backups (true/false)."
1490
+ },
1491
+ "restic-repository": {
1492
+ type: "string",
1493
+ description: "Restic repository URL/path."
1494
+ }
1495
+ },
1496
+ async run({ args }) {
1497
+ const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
1498
+ const next = structuredClone(config$1);
1499
+ const parseBool = (v) => {
1500
+ if (v === void 0 || v === null) return void 0;
1501
+ const s = String(v).trim().toLowerCase();
1502
+ if (s === "") return void 0;
1503
+ if (s === "true" || s === "1" || s === "yes") return true;
1504
+ if (s === "false" || s === "0" || s === "no") return false;
1505
+ throw new Error(`invalid boolean: ${String(v)} (use true/false)`);
1506
+ };
1507
+ {
1508
+ const v = parseBool(args["codex-enable"]);
1509
+ if (v !== void 0) next.fleet.codex.enable = v;
1510
+ }
1511
+ {
1512
+ const v = parseBool(args["restic-enable"]);
1513
+ if (v !== void 0) next.fleet.backups.restic.enable = v;
1514
+ }
1515
+ if (args["guild-id"] !== void 0) next.fleet.guildId = String(args["guild-id"]).trim();
1516
+ if (args["restic-repository"] !== void 0) next.fleet.backups.restic.repository = String(args["restic-repository"]).trim();
1517
+ await writeClawdletsConfig({
1518
+ configPath,
1519
+ config: ClawdletsConfigSchema.parse(next)
1520
+ });
1521
+ console.log("ok");
1522
+ }
1523
+ });
1524
+ const fleet = defineCommand({
1525
+ meta: {
1526
+ name: "fleet",
1527
+ description: "Manage fleet config (fleet/clawdlets.json)."
1528
+ },
1529
+ subCommands: {
1530
+ show,
1531
+ set
1532
+ }
1533
+ });
1534
+
1535
+ //#endregion
1536
+ //#region src/commands/image.ts
1537
+ async function buildRawImage(params) {
1538
+ if (process$1.platform !== "linux") throw new Error("image build requires Linux; run in CI or a Linux builder");
1539
+ const attr = `.#packages.x86_64-linux.${params.host}-image`;
1540
+ const out = await capture(params.nixBin, [
1541
+ "build",
1542
+ "--json",
1543
+ "--no-link",
1544
+ attr
1545
+ ], {
1546
+ cwd: params.repoRoot,
1547
+ env: withFlakesEnv(process$1.env)
1548
+ });
1549
+ let parsed;
1550
+ try {
1551
+ parsed = JSON.parse(out);
1552
+ } catch (e) {
1553
+ throw new Error(`nix build --json returned invalid JSON (${String(e?.message || e)})`);
1554
+ }
1555
+ const imagePath = parsed?.[0]?.outputs?.out;
1556
+ if (!imagePath || typeof imagePath !== "string") throw new Error("nix build did not return an image store path");
1557
+ if (!fs.existsSync(imagePath)) throw new Error(`image path missing: ${imagePath}`);
1558
+ return imagePath;
1559
+ }
1560
+ const imageBuild = defineCommand({
1561
+ meta: {
1562
+ name: "build",
1563
+ description: "Build a raw NixOS image for a host (nixos-generators)."
1564
+ },
1565
+ args: {
1566
+ runtimeDir: {
1567
+ type: "string",
1568
+ description: "Runtime directory (default: .clawdlets)."
1569
+ },
1570
+ host: {
1571
+ type: "string",
1572
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
1573
+ },
1574
+ rev: {
1575
+ type: "string",
1576
+ description: "Git rev to name the image (HEAD/sha/tag).",
1577
+ default: "HEAD"
1578
+ },
1579
+ out: {
1580
+ type: "string",
1581
+ description: "Output path (default: .clawdlets/images/<host>/clawdlets-<host>-<rev>.raw)."
1582
+ },
1583
+ nixBin: {
1584
+ type: "string",
1585
+ description: "Override nix binary (default: nix)."
1586
+ }
1587
+ },
1588
+ async run({ args }) {
1589
+ const cwd = process$1.cwd();
1590
+ const ctx = loadHostContextOrExit({
1591
+ cwd,
1592
+ runtimeDir: args.runtimeDir,
1593
+ hostArg: args.host
1594
+ });
1595
+ if (!ctx) return;
1596
+ const { repoRoot, layout, hostName } = ctx;
1597
+ const revRaw = String(args.rev || "").trim() || "HEAD";
1598
+ const resolved = await resolveGitRev(repoRoot, revRaw);
1599
+ if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
1600
+ const imagePath = await buildRawImage({
1601
+ repoRoot,
1602
+ nixBin: String(args.nixBin || process$1.env.NIX_BIN || "nix").trim() || "nix",
1603
+ host: hostName
1604
+ });
1605
+ const outRaw = String(args.out || "").trim();
1606
+ const outPath = outRaw ? path.isAbsolute(outRaw) ? outRaw : path.resolve(cwd, outRaw) : path.join(layout.runtimeDir, "images", hostName, `clawdlets-${hostName}-${resolved}.raw`);
1607
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1608
+ fs.copyFileSync(imagePath, outPath);
1609
+ console.log(`ok: built raw image ${outPath}`);
1610
+ }
1611
+ });
1612
+ const imageUpload = defineCommand({
1613
+ meta: {
1614
+ name: "upload",
1615
+ description: "Upload a raw image to Hetzner using hcloud-upload-image."
1616
+ },
1617
+ args: {
1618
+ runtimeDir: {
1619
+ type: "string",
1620
+ description: "Runtime directory (default: .clawdlets)."
1621
+ },
1622
+ envFile: {
1623
+ type: "string",
1624
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
1625
+ },
1626
+ host: {
1627
+ type: "string",
1628
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
1629
+ },
1630
+ "image-url": {
1631
+ type: "string",
1632
+ description: "Public URL for the raw image (Hetzner must reach it)."
1633
+ },
1634
+ compression: {
1635
+ type: "string",
1636
+ description: "Compression type (none|gz|bz2|xz).",
1637
+ default: "none"
1638
+ },
1639
+ architecture: {
1640
+ type: "string",
1641
+ description: "Architecture (x86 or arm).",
1642
+ default: "x86"
1643
+ },
1644
+ location: {
1645
+ type: "string",
1646
+ description: "Hetzner location (default: host hetzner.location)."
1647
+ },
1648
+ name: {
1649
+ type: "string",
1650
+ description: "Image name override (optional)."
1651
+ },
1652
+ dryRun: {
1653
+ type: "boolean",
1654
+ description: "Print commands without executing.",
1655
+ default: false
1656
+ },
1657
+ bin: {
1658
+ type: "string",
1659
+ description: "Override hcloud-upload-image binary (default: hcloud-upload-image)."
1660
+ }
1661
+ },
1662
+ async run({ args }) {
1663
+ const cwd = process$1.cwd();
1664
+ const ctx = loadHostContextOrExit({
1665
+ cwd,
1666
+ runtimeDir: args.runtimeDir,
1667
+ hostArg: args.host
1668
+ });
1669
+ if (!ctx) return;
1670
+ const { hostName, hostCfg } = ctx;
1671
+ const deployCreds = loadDeployCreds({
1672
+ cwd,
1673
+ runtimeDir: args.runtimeDir,
1674
+ envFile: args.envFile
1675
+ });
1676
+ const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
1677
+ if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
1678
+ const imageUrl = String(args["image-url"] || "").trim();
1679
+ if (!imageUrl) throw new Error("missing --image-url");
1680
+ const compression = String(args.compression || "").trim();
1681
+ const compressionArg = compression === "none" ? "" : compression;
1682
+ if (compressionArg && ![
1683
+ "gz",
1684
+ "bz2",
1685
+ "xz"
1686
+ ].includes(compressionArg)) throw new Error("invalid --compression (expected none|gz|bz2|xz)");
1687
+ const architecture = String(args.architecture || "").trim() || "x86";
1688
+ if (!["x86", "arm"].includes(architecture)) throw new Error("invalid --architecture (expected x86|arm)");
1689
+ const location = String(args.location || hostCfg.hetzner.location || "nbg1").trim() || "nbg1";
1690
+ const name = String(args.name || "").trim();
1691
+ const bin = String(args.bin || "hcloud-upload-image").trim() || "hcloud-upload-image";
1692
+ const cmd = [
1693
+ "upload",
1694
+ "--image-url",
1695
+ imageUrl,
1696
+ "--architecture",
1697
+ architecture,
1698
+ "--location",
1699
+ location
1700
+ ];
1701
+ if (compressionArg) cmd.push("--compression", compressionArg);
1702
+ if (name) cmd.push("--name", name);
1703
+ await run(bin, cmd, {
1704
+ env: {
1705
+ ...process$1.env,
1706
+ HCLOUD_TOKEN: hcloudToken
1707
+ },
1708
+ dryRun: args.dryRun,
1709
+ redact: [hcloudToken]
1710
+ });
1711
+ console.log(`ok: upload complete for ${hostName}`);
1712
+ console.log(`hint: set hetzner.image in fleet/clawdlets.json to the new image ID/name`);
1713
+ }
1714
+ });
1715
+ const image = defineCommand({
1716
+ meta: {
1717
+ name: "image",
1718
+ description: "Image build/upload helpers (Hetzner custom images)."
1719
+ },
1720
+ subCommands: {
1721
+ build: imageBuild,
1722
+ upload: imageUpload
1723
+ }
1724
+ });
1725
+
1726
+ //#endregion
1727
+ //#region src/commands/infra.ts
1728
+ const infraApply = defineCommand({
1729
+ meta: {
1730
+ name: "apply",
1731
+ description: "Apply Hetzner OpenTofu for a host (driven by fleet/clawdlets.json)."
1732
+ },
1733
+ args: {
1734
+ runtimeDir: {
1735
+ type: "string",
1736
+ description: "Runtime directory (default: .clawdlets)."
1737
+ },
1738
+ envFile: {
1739
+ type: "string",
1740
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
1741
+ },
1742
+ host: {
1743
+ type: "string",
1744
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
1745
+ },
1746
+ dryRun: {
1747
+ type: "boolean",
1748
+ description: "Print commands without executing.",
1749
+ default: false
1750
+ }
1751
+ },
1752
+ async run({ args }) {
1753
+ const cwd = process$1.cwd();
1754
+ const repoRoot = findRepoRoot(cwd);
1755
+ const hostName = resolveHostNameOrExit({
1756
+ cwd,
1757
+ runtimeDir: args.runtimeDir,
1758
+ hostArg: args.host
1759
+ });
1760
+ if (!hostName) return;
1761
+ const { layout, config: clawdletsConfig } = loadClawdletsConfig({
1762
+ repoRoot,
1763
+ runtimeDir: args.runtimeDir
1764
+ });
1765
+ const hostCfg = clawdletsConfig.hosts[hostName];
1766
+ if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
1767
+ const opentofuDir = getHostOpenTofuDir(layout, hostName);
1768
+ const deployCreds = loadDeployCreds({
1769
+ cwd,
1770
+ runtimeDir: args.runtimeDir,
1771
+ envFile: args.envFile
1772
+ });
1773
+ if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
1774
+ if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
1775
+ const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
1776
+ if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
1777
+ const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
1778
+ if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
1779
+ const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
1780
+ if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
1781
+ const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
1782
+ const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
1783
+ if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
1784
+ const image$1 = String(hostCfg.hetzner.image || "").trim();
1785
+ const location = String(hostCfg.hetzner.location || "").trim();
1786
+ await applyOpenTofuVars({
1787
+ opentofuDir,
1788
+ vars: {
1789
+ hostName,
1790
+ hcloudToken,
1791
+ adminCidr,
1792
+ adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
1793
+ sshPubkeyFile,
1794
+ serverType: hostCfg.hetzner.serverType,
1795
+ image: image$1,
1796
+ location,
1797
+ sshExposureMode: getSshExposureMode(hostCfg),
1798
+ tailnetMode: getTailnetMode(hostCfg)
1799
+ },
1800
+ nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
1801
+ dryRun: args.dryRun,
1802
+ redact: [hcloudToken, deployCreds.values.GITHUB_TOKEN].filter(Boolean)
1803
+ });
1804
+ console.log(`ok: provisioning applied for ${hostName}`);
1805
+ console.log(`hint: outputs in ${opentofuDir}`);
1806
+ }
1807
+ });
1808
+ const infraDestroy = defineCommand({
1809
+ meta: {
1810
+ name: "destroy",
1811
+ description: "Destroy Hetzner OpenTofu resources for a host (DANGEROUS)."
1812
+ },
1813
+ args: {
1814
+ runtimeDir: {
1815
+ type: "string",
1816
+ description: "Runtime directory (default: .clawdlets)."
1817
+ },
1818
+ envFile: {
1819
+ type: "string",
1820
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
1821
+ },
1822
+ host: {
1823
+ type: "string",
1824
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
1825
+ },
1826
+ force: {
1827
+ type: "boolean",
1828
+ description: "Skip confirmation prompt (non-interactive).",
1829
+ default: false
1830
+ },
1831
+ dryRun: {
1832
+ type: "boolean",
1833
+ description: "Print commands without executing.",
1834
+ default: false
1835
+ }
1836
+ },
1837
+ async run({ args }) {
1838
+ const cwd = process$1.cwd();
1839
+ const repoRoot = findRepoRoot(cwd);
1840
+ const hostName = resolveHostNameOrExit({
1841
+ cwd,
1842
+ runtimeDir: args.runtimeDir,
1843
+ hostArg: args.host
1844
+ });
1845
+ if (!hostName) return;
1846
+ const { layout, config: clawdletsConfig } = loadClawdletsConfig({
1847
+ repoRoot,
1848
+ runtimeDir: args.runtimeDir
1849
+ });
1850
+ const hostCfg = clawdletsConfig.hosts[hostName];
1851
+ if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
1852
+ const opentofuDir = getHostOpenTofuDir(layout, hostName);
1853
+ const deployCreds = loadDeployCreds({
1854
+ cwd,
1855
+ runtimeDir: args.runtimeDir,
1856
+ envFile: args.envFile
1857
+ });
1858
+ if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
1859
+ if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
1860
+ const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
1861
+ if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
1862
+ const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
1863
+ if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
1864
+ const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
1865
+ if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
1866
+ const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
1867
+ const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
1868
+ if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
1869
+ const image$1 = String(hostCfg.hetzner.image || "").trim();
1870
+ const location = String(hostCfg.hetzner.location || "").trim();
1871
+ const force = Boolean(args.force);
1872
+ const interactive = process$1.stdin.isTTY && process$1.stdout.isTTY;
1873
+ if (!force) {
1874
+ if (!interactive) throw new Error("refusing to destroy without --force (no TTY)");
1875
+ p.intro("clawdlets infra destroy");
1876
+ const ok = await p.confirm({
1877
+ message: `Destroy Hetzner resources for host ${hostName}?`,
1878
+ initialValue: false
1879
+ });
1880
+ if (p.isCancel(ok) || !ok) {
1881
+ p.cancel("canceled");
1882
+ return;
1883
+ }
1884
+ }
1885
+ await destroyOpenTofuVars({
1886
+ opentofuDir,
1887
+ vars: {
1888
+ hostName,
1889
+ hcloudToken,
1890
+ adminCidr,
1891
+ adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
1892
+ sshPubkeyFile,
1893
+ serverType: hostCfg.hetzner.serverType,
1894
+ image: image$1,
1895
+ location,
1896
+ sshExposureMode: getSshExposureMode(hostCfg),
1897
+ tailnetMode: getTailnetMode(hostCfg)
1898
+ },
1899
+ nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
1900
+ dryRun: args.dryRun,
1901
+ redact: [hcloudToken, deployCreds.values.GITHUB_TOKEN].filter(Boolean)
1902
+ });
1903
+ console.log(`ok: provisioning destroyed for ${hostName}`);
1904
+ console.log(`hint: state in ${opentofuDir}`);
1905
+ }
1906
+ });
1907
+ const infra = defineCommand({
1908
+ meta: {
1909
+ name: "infra",
1910
+ description: "Infrastructure operations (Hetzner OpenTofu)."
1911
+ },
1912
+ subCommands: {
1913
+ apply: infraApply,
1914
+ destroy: infraDestroy
1915
+ }
1916
+ });
1917
+
1918
+ //#endregion
1919
+ //#region src/commands/lockdown.ts
1920
+ const lockdown = defineCommand({
1921
+ meta: {
1922
+ name: "lockdown",
1923
+ description: "Remove public SSH from Hetzner firewall (OpenTofu only)."
1924
+ },
1925
+ args: {
1926
+ runtimeDir: {
1927
+ type: "string",
1928
+ description: "Runtime directory (default: .clawdlets)."
1929
+ },
1930
+ envFile: {
1931
+ type: "string",
1932
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
1933
+ },
1934
+ host: {
1935
+ type: "string",
1936
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
1937
+ },
1938
+ skipTofu: {
1939
+ type: "boolean",
1940
+ description: "Skip provisioning apply.",
1941
+ default: false
1942
+ },
1943
+ dryRun: {
1944
+ type: "boolean",
1945
+ description: "Print commands without executing.",
1946
+ default: false
1947
+ }
1948
+ },
1949
+ async run({ args }) {
1950
+ const cwd = process$1.cwd();
1951
+ const repoRoot = findRepoRoot(cwd);
1952
+ const hostName = resolveHostNameOrExit({
1953
+ cwd,
1954
+ runtimeDir: args.runtimeDir,
1955
+ hostArg: args.host
1956
+ });
1957
+ if (!hostName) return;
1958
+ const { layout, config: clawdletsConfig } = loadClawdletsConfig({
1959
+ repoRoot,
1960
+ runtimeDir: args.runtimeDir
1961
+ });
1962
+ const hostCfg = clawdletsConfig.hosts[hostName];
1963
+ if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
1964
+ const opentofuDir = getHostOpenTofuDir(layout, hostName);
1965
+ const sshExposureMode = getSshExposureMode(hostCfg);
1966
+ if (sshExposureMode !== "tailnet") throw new Error(`sshExposure.mode=${sshExposureMode}; set sshExposure.mode=tailnet before lockdown (clawdlets host set --host ${hostName} --ssh-exposure tailnet)`);
1967
+ await requireDeployGate({
1968
+ runtimeDir: args.runtimeDir,
1969
+ envFile: args.envFile,
1970
+ host: hostName,
1971
+ scope: "server-deploy",
1972
+ strict: true,
1973
+ skipGithubTokenCheck: true
1974
+ });
1975
+ const deployCreds = loadDeployCreds({
1976
+ cwd,
1977
+ runtimeDir: args.runtimeDir,
1978
+ envFile: args.envFile
1979
+ });
1980
+ if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
1981
+ if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
1982
+ const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
1983
+ const githubToken = String(deployCreds.values.GITHUB_TOKEN || "").trim();
1984
+ if (!args.skipTofu) {
1985
+ if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
1986
+ const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
1987
+ if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
1988
+ const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
1989
+ if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
1990
+ const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
1991
+ const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
1992
+ if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
1993
+ const image$1 = String(hostCfg.hetzner.image || "").trim();
1994
+ const location = String(hostCfg.hetzner.location || "").trim();
1995
+ await applyOpenTofuVars({
1996
+ opentofuDir,
1997
+ vars: {
1998
+ hostName,
1999
+ hcloudToken,
2000
+ adminCidr,
2001
+ adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
2002
+ sshPubkeyFile,
2003
+ serverType: hostCfg.hetzner.serverType,
2004
+ image: image$1,
2005
+ location,
2006
+ sshExposureMode,
2007
+ tailnetMode: getTailnetMode(hostCfg)
2008
+ },
2009
+ nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
2010
+ dryRun: args.dryRun,
2011
+ redact: [hcloudToken, githubToken].filter(Boolean)
2012
+ });
2013
+ }
2014
+ }
2015
+ });
2016
+
2017
+ //#endregion
2018
+ //#region src/commands/plugin.ts
2019
+ async function loadPlugins() {
2020
+ return await Promise.resolve().then(() => plugins_exports);
2021
+ }
2022
+ function resolveSlug(args) {
2023
+ const raw = String(args.name || args._?.[0] || "").trim();
2024
+ if (!raw) throw new Error("missing plugin name (pass --name or first arg)");
2025
+ return raw;
2026
+ }
2027
+ const list = defineCommand({
2028
+ meta: {
2029
+ name: "list",
2030
+ description: "List installed plugins."
2031
+ },
2032
+ args: {
2033
+ json: {
2034
+ type: "boolean",
2035
+ description: "Output JSON.",
2036
+ default: false
2037
+ },
2038
+ runtimeDir: {
2039
+ type: "string",
2040
+ description: "Runtime directory (default: .clawdlets)."
2041
+ }
2042
+ },
2043
+ async run({ args }) {
2044
+ const { listInstalledPlugins: listInstalledPlugins$1 } = await loadPlugins();
2045
+ const errors = [];
2046
+ const plugins = listInstalledPlugins$1({
2047
+ cwd: process$1.cwd(),
2048
+ runtimeDir: args.runtimeDir,
2049
+ onError: (err) => errors.push(err)
2050
+ });
2051
+ const payload = {
2052
+ plugins,
2053
+ errors: errors.map((e) => ({
2054
+ slug: e.slug,
2055
+ error: e.error.message
2056
+ }))
2057
+ };
2058
+ if (args.json) {
2059
+ console.log(JSON.stringify(payload, null, 2));
2060
+ return;
2061
+ }
2062
+ for (const err of errors) console.error(`warn: skipping plugin ${err.slug}: ${err.error.message}`);
2063
+ if (plugins.length === 0) {
2064
+ console.log("ok: no plugins installed");
2065
+ return;
2066
+ }
2067
+ for (const p$1 of plugins) console.log(`${p$1.command}\t${p$1.packageName}@${p$1.version}`);
2068
+ }
2069
+ });
2070
+ const add = defineCommand({
2071
+ meta: {
2072
+ name: "add",
2073
+ description: "Install a plugin into .clawdlets/plugins."
2074
+ },
2075
+ args: {
2076
+ name: {
2077
+ type: "string",
2078
+ description: "Plugin name (e.g. cattle)."
2079
+ },
2080
+ package: {
2081
+ type: "string",
2082
+ description: "Package to install (default: @clawdlets/plugin-<name>)."
2083
+ },
2084
+ version: {
2085
+ type: "string",
2086
+ description: "Package version/tag (default: latest)."
2087
+ },
2088
+ allowThirdParty: {
2089
+ type: "boolean",
2090
+ description: "Allow third-party plugins (unsafe).",
2091
+ default: false
2092
+ },
2093
+ runtimeDir: {
2094
+ type: "string",
2095
+ description: "Runtime directory (default: .clawdlets)."
2096
+ }
2097
+ },
2098
+ async run({ args }) {
2099
+ const slug = resolveSlug(args);
2100
+ const packageName = String(args.package || `@clawdlets/plugin-${slug}`).trim();
2101
+ if (!args.allowThirdParty && !packageName.startsWith("@clawdlets/")) throw new Error("third-party plugins disabled (pass --allow-third-party to override)");
2102
+ const { installPlugin: installPlugin$1 } = await loadPlugins();
2103
+ const plugin = await installPlugin$1({
2104
+ cwd: process$1.cwd(),
2105
+ runtimeDir: args.runtimeDir,
2106
+ slug,
2107
+ packageName,
2108
+ version: args.version,
2109
+ allowThirdParty: args.allowThirdParty
2110
+ });
2111
+ console.log(`ok: installed ${plugin.command} (${plugin.packageName}@${plugin.version})`);
2112
+ }
2113
+ });
2114
+ const rm = defineCommand({
2115
+ meta: {
2116
+ name: "rm",
2117
+ description: "Remove an installed plugin."
2118
+ },
2119
+ args: {
2120
+ name: {
2121
+ type: "string",
2122
+ description: "Plugin name (e.g. cattle)."
2123
+ },
2124
+ runtimeDir: {
2125
+ type: "string",
2126
+ description: "Runtime directory (default: .clawdlets)."
2127
+ }
2128
+ },
2129
+ async run({ args }) {
2130
+ const slug = resolveSlug(args);
2131
+ const { removePlugin: removePlugin$1 } = await loadPlugins();
2132
+ removePlugin$1({
2133
+ cwd: process$1.cwd(),
2134
+ runtimeDir: args.runtimeDir,
2135
+ slug
2136
+ });
2137
+ console.log(`ok: removed ${slug}`);
2138
+ }
2139
+ });
2140
+ const plugin = defineCommand({
2141
+ meta: {
2142
+ name: "plugin",
2143
+ description: "Plugin manager (install/remove/list)."
2144
+ },
2145
+ subCommands: {
2146
+ add,
2147
+ list,
2148
+ rm
2149
+ }
2150
+ });
2151
+
2152
+ //#endregion
2153
+ //#region src/lib/template-spec.ts
2154
+ function firstNonEmpty(...values) {
2155
+ for (const value of values) {
2156
+ const trimmed = String(value || "").trim();
2157
+ if (trimmed) return trimmed;
2158
+ }
2159
+ }
2160
+ function resolveTemplateSourcePath() {
2161
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
2162
+ const packagedDistPath = path.resolve(moduleDir, "config", "template-source.json");
2163
+ if (fs.existsSync(packagedDistPath)) return packagedDistPath;
2164
+ const packagedPath = path.resolve(moduleDir, "..", "config", "template-source.json");
2165
+ if (fs.existsSync(packagedPath)) return packagedPath;
2166
+ const repoRootPath = path.resolve(moduleDir, "..", "..", "..", "..", "config", "template-source.json");
2167
+ if (fs.existsSync(repoRootPath)) return repoRootPath;
2168
+ const cwdPath = path.resolve(process.cwd(), "config", "template-source.json");
2169
+ if (fs.existsSync(cwdPath)) return cwdPath;
2170
+ throw new Error("template source config missing (expected config/template-source.json)");
2171
+ }
2172
+ function loadTemplateSourceDefaults() {
2173
+ const configPath = resolveTemplateSourcePath();
2174
+ const raw = fs.readFileSync(configPath, "utf8");
2175
+ const parsed = JSON.parse(raw);
2176
+ if (!parsed || typeof parsed !== "object") throw new Error(`template source config invalid: ${configPath}`);
2177
+ return {
2178
+ repo: String(parsed.repo || ""),
2179
+ path: String(parsed.path || ""),
2180
+ ref: String(parsed.ref || "")
2181
+ };
2182
+ }
2183
+ function resolveTemplateSpec(args) {
2184
+ const defaults = loadTemplateSourceDefaults();
2185
+ const repo = firstNonEmpty(args.template, process.env["CLAWDLETS_TEMPLATE_REPO"], defaults.repo);
2186
+ const tplPath = firstNonEmpty(args.templatePath, process.env["CLAWDLETS_TEMPLATE_PATH"], defaults.path);
2187
+ const ref = firstNonEmpty(args.templateRef, process.env["CLAWDLETS_TEMPLATE_REF"], defaults.ref);
2188
+ return normalizeTemplateSource({
2189
+ repo: repo || "",
2190
+ path: tplPath || "",
2191
+ ref: ref || ""
2192
+ });
2193
+ }
2194
+
2195
+ //#endregion
2196
+ //#region src/commands/project.ts
2197
+ function wantsInteractive$1(flag) {
2198
+ if (flag) return true;
2199
+ const env$1 = String(process$1.env["CLAWDLETS_INTERACTIVE"] || "").trim();
2200
+ return env$1 === "1" || env$1.toLowerCase() === "true";
2201
+ }
2202
+ function requireTtyIfInteractive(interactive) {
2203
+ if (!interactive) return;
2204
+ if (!process$1.stdout.isTTY) throw new Error("--interactive requires a TTY");
2205
+ }
2206
+ function applySubs(s, subs) {
2207
+ let out = s;
2208
+ for (const [k, v] of Object.entries(subs)) out = out.split(k).join(v);
2209
+ return out;
2210
+ }
2211
+ function isProbablyText(file) {
2212
+ const base = path.basename(file);
2213
+ if (base === "Justfile" || base === "_gitignore") return true;
2214
+ const ext = path.extname(file).toLowerCase();
2215
+ return [
2216
+ ".md",
2217
+ ".nix",
2218
+ ".tf",
2219
+ ".hcl",
2220
+ ".json",
2221
+ ".yaml",
2222
+ ".yml",
2223
+ ".txt",
2224
+ ".lock",
2225
+ ".gitignore"
2226
+ ].includes(ext);
2227
+ }
2228
+ async function copyTree(params) {
2229
+ const entries = await fs.promises.readdir(params.srcDir, { withFileTypes: true });
2230
+ for (const ent of entries) {
2231
+ const srcName = ent.name;
2232
+ const srcPath = path.join(params.srcDir, srcName);
2233
+ const renamed = srcName === "_gitignore" ? ".gitignore" : applySubs(srcName, params.subs);
2234
+ const destPath = path.join(params.destDir, renamed);
2235
+ if (ent.isDirectory()) {
2236
+ await ensureDir(destPath);
2237
+ await copyTree({
2238
+ srcDir: srcPath,
2239
+ destDir: destPath,
2240
+ subs: params.subs
2241
+ });
2242
+ continue;
2243
+ }
2244
+ if (!ent.isFile()) continue;
2245
+ const buf = await fs.promises.readFile(srcPath);
2246
+ if (!isProbablyText(srcName)) {
2247
+ await ensureDir(path.dirname(destPath));
2248
+ await fs.promises.writeFile(destPath, buf);
2249
+ continue;
2250
+ }
2251
+ await writeFileAtomic(destPath, applySubs(buf.toString("utf8"), params.subs));
2252
+ }
2253
+ }
2254
+ async function dirHasAnyFiles(dir) {
2255
+ try {
2256
+ return (await fs.promises.readdir(dir)).length > 0;
2257
+ } catch {
2258
+ return false;
2259
+ }
2260
+ }
2261
+ async function ensureHookExecutables(repoRoot) {
2262
+ const hooksDir = path.join(repoRoot, ".githooks");
2263
+ try {
2264
+ const entries = await fs.promises.readdir(hooksDir, { withFileTypes: true });
2265
+ let hasHooks = false;
2266
+ for (const ent of entries) {
2267
+ if (!ent.isFile()) continue;
2268
+ const p$1 = path.join(hooksDir, ent.name);
2269
+ await fs.promises.chmod(p$1, 493);
2270
+ hasHooks = true;
2271
+ }
2272
+ return hasHooks;
2273
+ } catch {
2274
+ return false;
2275
+ }
2276
+ }
2277
+ async function findTemplateRoot(dir) {
2278
+ const direct = path.join(dir, "fleet", "clawdlets.json");
2279
+ if (fs.existsSync(direct)) return dir;
2280
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
2281
+ const candidates = [];
2282
+ for (const ent of entries) {
2283
+ if (!ent.isDirectory()) continue;
2284
+ const candidate = path.join(dir, ent.name);
2285
+ if (fs.existsSync(path.join(candidate, "fleet", "clawdlets.json"))) candidates.push(candidate);
2286
+ }
2287
+ if (candidates.length === 1) return candidates[0];
2288
+ throw new Error(`template root missing fleet/clawdlets.json (searched: ${dir})`);
2289
+ }
2290
+ const projectInit = defineCommand({
2291
+ meta: {
2292
+ name: "init",
2293
+ description: "Scaffold a new clawdlets infra repo (from clawdlets-template)."
2294
+ },
2295
+ args: {
2296
+ dir: {
2297
+ type: "string",
2298
+ description: "Target directory (created if missing)."
2299
+ },
2300
+ host: {
2301
+ type: "string",
2302
+ description: "Host name placeholder (default: clawdbot-fleet-host).",
2303
+ default: "clawdbot-fleet-host"
2304
+ },
2305
+ gitInit: {
2306
+ type: "boolean",
2307
+ description: "Run `git init` in the new directory.",
2308
+ default: true
2309
+ },
2310
+ interactive: {
2311
+ type: "boolean",
2312
+ description: "Prompt for confirmation (requires TTY).",
2313
+ default: false
2314
+ },
2315
+ dryRun: {
2316
+ type: "boolean",
2317
+ description: "Print planned files without writing.",
2318
+ default: false
2319
+ },
2320
+ template: {
2321
+ type: "string",
2322
+ description: "Template repo (default: config/template-source.json)."
2323
+ },
2324
+ templatePath: {
2325
+ type: "string",
2326
+ description: "Template path inside repo (default: config/template-source.json)."
2327
+ },
2328
+ templateRef: {
2329
+ type: "string",
2330
+ description: "Template git ref (default: config/template-source.json)."
2331
+ }
2332
+ },
2333
+ async run({ args }) {
2334
+ const interactive = wantsInteractive$1(Boolean(args.interactive));
2335
+ requireTtyIfInteractive(interactive);
2336
+ const dirRaw = String(args.dir || "").trim();
2337
+ if (!dirRaw) throw new Error("missing --dir");
2338
+ const destDir = path.resolve(process$1.cwd(), dirRaw);
2339
+ const host$1 = String(args.host || "clawdbot-fleet-host").trim() || "clawdbot-fleet-host";
2340
+ assertSafeHostName(host$1);
2341
+ const projectName = path.basename(destDir);
2342
+ if (interactive) {
2343
+ p.intro("clawdlets project init");
2344
+ const ok = await p.confirm({
2345
+ message: `Create project at ${destDir}?`,
2346
+ initialValue: true
2347
+ });
2348
+ if (p.isCancel(ok)) {
2349
+ if (await navOnCancel({
2350
+ flow: "project init",
2351
+ canBack: false
2352
+ }) === NAV_EXIT) cancelFlow();
2353
+ return;
2354
+ }
2355
+ if (!ok) {
2356
+ cancelFlow();
2357
+ return;
2358
+ }
2359
+ }
2360
+ const templateSpec = resolveTemplateSpec({
2361
+ template: args.template,
2362
+ templatePath: args.templatePath,
2363
+ templateRef: args.templateRef
2364
+ });
2365
+ if (fs.existsSync(destDir) && await dirHasAnyFiles(destDir)) throw new Error(`target dir not empty: ${destDir}`);
2366
+ const subs = {
2367
+ "__PROJECT_NAME__": projectName,
2368
+ "clawdbot-fleet-host": host$1,
2369
+ "clawdbot_fleet_host": host$1.replace(/-/g, "_")
2370
+ };
2371
+ const tempDir = await fs.promises.mkdtemp(path.join(tmpdir(), "clawdlets-template-"));
2372
+ let templateDir = tempDir;
2373
+ try {
2374
+ templateDir = await findTemplateRoot((await downloadTemplate(templateSpec.spec, {
2375
+ dir: tempDir,
2376
+ force: true,
2377
+ auth: String(process$1.env["GITHUB_TOKEN"] || process$1.env["CLAWDLETS_TEMPLATE_TOKEN"] || "").trim() || void 0
2378
+ })).dir || tempDir);
2379
+ } catch (e) {
2380
+ await fs.promises.rm(tempDir, {
2381
+ recursive: true,
2382
+ force: true
2383
+ });
2384
+ throw e;
2385
+ }
2386
+ const planned = [];
2387
+ const walk = async (srcDir, rel) => {
2388
+ const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
2389
+ for (const ent of entries) {
2390
+ const srcName = ent.name;
2391
+ const mapped = srcName === "_gitignore" ? ".gitignore" : applySubs(srcName, subs);
2392
+ const nextRel = path.join(rel, mapped);
2393
+ if (ent.isDirectory()) await walk(path.join(srcDir, srcName), nextRel);
2394
+ else if (ent.isFile()) planned.push(nextRel);
2395
+ }
2396
+ };
2397
+ await walk(templateDir, ".");
2398
+ if (args.dryRun) {
2399
+ p.note(planned.sort().join("\n"), "Planned files");
2400
+ p.outro("dry-run");
2401
+ await fs.promises.rm(tempDir, {
2402
+ recursive: true,
2403
+ force: true
2404
+ });
2405
+ return;
2406
+ }
2407
+ await ensureDir(destDir);
2408
+ await copyTree({
2409
+ srcDir: templateDir,
2410
+ destDir,
2411
+ subs
2412
+ });
2413
+ await fs.promises.rm(tempDir, {
2414
+ recursive: true,
2415
+ force: true
2416
+ });
2417
+ const hasHooks = await ensureHookExecutables(destDir);
2418
+ {
2419
+ const configPath = path.join(destDir, "fleet", "clawdlets.json");
2420
+ const raw = await fs.promises.readFile(configPath, "utf8");
2421
+ const parsed = JSON.parse(raw);
2422
+ const hostCfg = parsed?.hosts?.[host$1];
2423
+ if (hostCfg && typeof hostCfg === "object") {
2424
+ hostCfg.cache = hostCfg.cache && typeof hostCfg.cache === "object" ? hostCfg.cache : {};
2425
+ hostCfg.cache.garnix = hostCfg.cache.garnix && typeof hostCfg.cache.garnix === "object" ? hostCfg.cache.garnix : {};
2426
+ hostCfg.cache.garnix.private = hostCfg.cache.garnix.private && typeof hostCfg.cache.garnix.private === "object" ? hostCfg.cache.garnix.private : {};
2427
+ hostCfg.cache.garnix.private.enable = false;
2428
+ await writeFileAtomic(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
2429
+ }
2430
+ }
2431
+ if (args.gitInit) try {
2432
+ await capture("git", ["--version"], { cwd: destDir });
2433
+ await run("git", ["init"], { cwd: destDir });
2434
+ if (hasHooks) await run("git", [
2435
+ "config",
2436
+ "core.hooksPath",
2437
+ ".githooks"
2438
+ ], { cwd: destDir });
2439
+ } catch {
2440
+ if (interactive) p.note("git not available; skipped `git init`", "gitInit");
2441
+ }
2442
+ const next = [
2443
+ "next:",
2444
+ `- cd ${destDir}`,
2445
+ "- create a git repo + set origin (recommended; enables blank base flake)",
2446
+ "- clawdlets env init # set HCLOUD_TOKEN in .clawdlets/env (required for provisioning)",
2447
+ `- clawdlets host set --host ${host$1} --admin-cidr <your-ip>/32 --disk-device /dev/sda --add-ssh-key-file $HOME/.ssh/id_ed25519.pub`,
2448
+ `- clawdlets host set --host ${host$1} --ssh-exposure bootstrap`,
2449
+ `- clawdlets secrets init --host ${host$1}`,
2450
+ `- clawdlets doctor --host ${host$1}`,
2451
+ `- clawdlets bootstrap --host ${host$1}`,
2452
+ `- clawdlets host set --host ${host$1} --target-host <ssh-alias|user@host>`,
2453
+ `- clawdlets host set --host ${host$1} --ssh-exposure tailnet`,
2454
+ `- clawdlets lockdown --host ${host$1}`
2455
+ ].join("\n");
2456
+ if (interactive) p.outro(next);
2457
+ else console.log(next);
2458
+ }
2459
+ });
2460
+ const project = defineCommand({
2461
+ meta: {
2462
+ name: "project",
2463
+ description: "Project scaffolding."
2464
+ },
2465
+ subCommands: { init: projectInit }
2466
+ });
2467
+
2468
+ //#endregion
2469
+ //#region src/commands/ssh-target.ts
2470
+ function needsSudo(targetHost) {
2471
+ return !/^root@/i.test(targetHost.trim());
2472
+ }
2473
+ function requireTargetHost(targetHost, hostName) {
2474
+ const v = targetHost.trim();
2475
+ if (v) return validateTargetHost(v);
2476
+ throw new Error([
2477
+ `missing target host for ${hostName}`,
2478
+ "set it in fleet/clawdlets.json (hosts.<host>.targetHost) or pass --target-host",
2479
+ "recommended: use an SSH config alias (e.g. botsmj)"
2480
+ ].join("; "));
2481
+ }
2482
+
2483
+ //#endregion
2484
+ //#region src/commands/secrets/common.ts
2485
+ function quoteYamlString(value) {
2486
+ return `"${value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/"/g, "\\\"")}"`;
2487
+ }
2488
+ function upsertYamlScalarLine(params) {
2489
+ const { text, key, value } = params;
2490
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2491
+ const rx = new RegExp(`^\\s*${escaped}\\s*:\\s*.*$`, "m");
2492
+ const line = `${key}: ${quoteYamlString(value)}`;
2493
+ if (rx.test(text)) return text.replace(rx, line);
2494
+ return `${text.trimEnd()}\n${line}\n`;
2495
+ }
2496
+
2497
+ //#endregion
2498
+ //#region src/commands/secrets/init.ts
2499
+ function wantsInteractive(flag) {
2500
+ if (flag) return true;
2501
+ const env$1 = String(process$1.env["CLAWDLETS_INTERACTIVE"] || "").trim();
2502
+ return env$1 === "1" || env$1.toLowerCase() === "true";
2503
+ }
2504
+ function readSecretsInitJson(fromJson) {
2505
+ const src = String(fromJson || "").trim();
2506
+ if (!src) throw new Error("missing --from-json");
2507
+ let raw;
2508
+ if (src === "-") raw = fs.readFileSync(0, "utf8");
2509
+ else {
2510
+ const jsonPath = path.isAbsolute(src) ? src : path.resolve(process$1.cwd(), src);
2511
+ if (!fs.existsSync(jsonPath)) throw new Error(`missing --from-json file: ${jsonPath}`);
2512
+ raw = fs.readFileSync(jsonPath, "utf8");
2513
+ }
2514
+ return parseSecretsInitJson(raw);
2515
+ }
2516
+ const secretsInit = defineCommand({
2517
+ meta: {
2518
+ name: "init",
2519
+ description: "Create/update secrets in /secrets (sops+age) and generate .clawdlets/extra-files/<host>/..."
2520
+ },
2521
+ args: {
2522
+ runtimeDir: {
2523
+ type: "string",
2524
+ description: "Runtime directory (default: .clawdlets)."
2525
+ },
2526
+ host: {
2527
+ type: "string",
2528
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
2529
+ },
2530
+ interactive: {
2531
+ type: "boolean",
2532
+ description: "Prompt for secret values (requires TTY).",
2533
+ default: false
2534
+ },
2535
+ fromJson: {
2536
+ type: "string",
2537
+ description: "Read secret values from JSON file (or '-' for stdin) (non-interactive)."
2538
+ },
2539
+ allowPlaceholders: {
2540
+ type: "boolean",
2541
+ description: "Allow placeholders for missing tokens.",
2542
+ default: false
2543
+ },
2544
+ operator: {
2545
+ type: "string",
2546
+ description: "Operator id for local age key name (default: $USER)."
2547
+ },
2548
+ yes: {
2549
+ type: "boolean",
2550
+ description: "Overwrite without prompt.",
2551
+ default: false
2552
+ },
2553
+ dryRun: {
2554
+ type: "boolean",
2555
+ description: "Print actions without writing.",
2556
+ default: false
2557
+ }
2558
+ },
2559
+ async run({ args }) {
2560
+ const a = args;
2561
+ const ctx = loadHostContextOrExit({
2562
+ cwd: process$1.cwd(),
2563
+ runtimeDir: a.runtimeDir,
2564
+ hostArg: a.host
2565
+ });
2566
+ if (!ctx) return;
2567
+ const { layout, config: clawdletsConfig, hostName, hostCfg } = ctx;
2568
+ const hasTty = Boolean(process$1.stdin.isTTY && process$1.stdout.isTTY);
2569
+ let interactive = wantsInteractive(Boolean(a.interactive));
2570
+ if (!interactive && hasTty && !a.fromJson) interactive = true;
2571
+ if (interactive && !hasTty) throw new Error("--interactive requires a TTY");
2572
+ const operatorId = sanitizeOperatorId(String(a.operator || process$1.env.USER || "operator"));
2573
+ const sopsConfigPath = layout.sopsConfigPath;
2574
+ const operatorKeyPath = getLocalOperatorAgeKeyPath(layout, operatorId);
2575
+ const operatorPubPath = path.join(layout.localOperatorKeysDir, `${operatorId}.age.pub`);
2576
+ const hostKeyFile = getHostEncryptedAgeKeyFile(layout, hostName);
2577
+ const extraFilesKeyPath = getHostExtraFilesKeyPath(layout, hostName);
2578
+ const extraFilesSecretsDir = getHostExtraFilesSecretsDir(layout, hostName);
2579
+ const localSecretsDir = getHostSecretsDir(layout, hostName);
2580
+ const bots = clawdletsConfig.fleet.botOrder;
2581
+ if (bots.length === 0) throw new Error("fleet.botOrder is empty (set bots in fleet/clawdlets.json)");
2582
+ const requiresTailscaleAuthKey = String(hostCfg.tailnet?.mode || "none") === "tailscale";
2583
+ const garnixPrivate = hostCfg.cache?.garnix?.private;
2584
+ const garnixPrivateEnabled = Boolean(garnixPrivate?.enable);
2585
+ const garnixNetrcSecretName = garnixPrivateEnabled ? String(garnixPrivate?.netrcSecret || "garnix_netrc").trim() : "";
2586
+ const garnixNetrcPath = garnixPrivateEnabled ? String(garnixPrivate?.netrcPath || "/etc/nix/netrc").trim() : "";
2587
+ if (garnixPrivateEnabled && !garnixNetrcSecretName) throw new Error("cache.garnix.private.netrcSecret must be set when private cache is enabled");
2588
+ const secretsPlan = buildFleetSecretsPlan({
2589
+ config: clawdletsConfig,
2590
+ hostName
2591
+ });
2592
+ if (secretsPlan.missingSecretConfig.length > 0) {
2593
+ const first = secretsPlan.missingSecretConfig[0];
2594
+ if (first.kind === "discord") throw new Error(`missing discordTokenSecret for bot=${first.bot} (set fleet.bots.${first.bot}.profile.discordTokenSecret)`);
2595
+ throw new Error(`missing modelSecrets entry for provider=${first.provider} (bot=${first.bot}, model=${first.model}); set fleet.modelSecrets.${first.provider}`);
2596
+ }
2597
+ const requiredSecretNames = new Set(secretsPlan.secretNamesRequired);
2598
+ const discordSecretByName = /* @__PURE__ */ new Map();
2599
+ for (const [bot$1, secretName] of Object.entries(secretsPlan.discordSecretsByBot)) if (secretName) discordSecretByName.set(secretName, bot$1);
2600
+ const discordBotsRequired = bots.filter((b) => {
2601
+ const secretName = secretsPlan.discordSecretsByBot[b] || "";
2602
+ return secretName && requiredSecretNames.has(secretName);
2603
+ });
2604
+ const requiredExtraSecretNames = new Set([...requiredSecretNames, ...garnixPrivateEnabled ? [garnixNetrcSecretName] : []]);
2605
+ const templateExtraSecrets = {};
2606
+ for (const secretName of secretsPlan.secretNamesAll) templateExtraSecrets[secretName] = requiredSecretNames.has(secretName) ? "<REPLACE_WITH_SECRET>" : "<OPTIONAL>";
2607
+ if (garnixPrivateEnabled) templateExtraSecrets[garnixNetrcSecretName] = "<REPLACE_WITH_NETRC>";
2608
+ const defaultSecretsJsonPath = path.join(layout.runtimeDir, "secrets.json");
2609
+ const defaultSecretsJsonDisplay = path.relative(process$1.cwd(), defaultSecretsJsonPath) || defaultSecretsJsonPath;
2610
+ let fromJson = resolveSecretsInitFromJsonArg({
2611
+ fromJsonRaw: a.fromJson,
2612
+ argv: process$1.argv,
2613
+ stdinIsTTY: Boolean(process$1.stdin.isTTY)
2614
+ });
2615
+ if (!interactive && !fromJson) if (fs.existsSync(defaultSecretsJsonPath)) {
2616
+ fromJson = defaultSecretsJsonPath;
2617
+ if (!a.allowPlaceholders) {
2618
+ const placeholders = listSecretsInitPlaceholders({
2619
+ input: parseSecretsInitJson(fs.readFileSync(defaultSecretsJsonPath, "utf8")),
2620
+ bots,
2621
+ discordBots: discordBotsRequired,
2622
+ requiresTailscaleAuthKey
2623
+ });
2624
+ if (placeholders.length > 0) {
2625
+ console.error(`error: placeholders found in ${defaultSecretsJsonDisplay} (fill it or pass --allow-placeholders)`);
2626
+ for (const p0 of placeholders) console.error(`- ${p0}`);
2627
+ process$1.exitCode = 1;
2628
+ return;
2629
+ }
2630
+ }
2631
+ } else {
2632
+ const template = buildSecretsInitTemplate({
2633
+ bots,
2634
+ discordBots: discordBotsRequired,
2635
+ requiresTailscaleAuthKey,
2636
+ secrets: templateExtraSecrets
2637
+ });
2638
+ if (!a.dryRun) {
2639
+ await ensureDir(path.dirname(defaultSecretsJsonPath));
2640
+ await writeFileAtomic(defaultSecretsJsonPath, `${JSON.stringify(template, null, 2)}\n`, { mode: 384 });
2641
+ }
2642
+ console.error(`${a.dryRun ? "would write" : "wrote"} secrets template: ${defaultSecretsJsonDisplay}`);
2643
+ if (a.dryRun) console.error("run without --dry-run to write it");
2644
+ else console.error(`fill it, then run: clawdlets secrets init --from-json ${defaultSecretsJsonDisplay}`);
2645
+ process$1.exitCode = 1;
2646
+ return;
2647
+ }
2648
+ validateSecretsInitNonInteractive({
2649
+ interactive,
2650
+ fromJson,
2651
+ yes: Boolean(a.yes),
2652
+ dryRun: Boolean(a.dryRun),
2653
+ localSecretsDirExists: fs.existsSync(localSecretsDir)
2654
+ });
2655
+ if (interactive && fs.existsSync(localSecretsDir) && !a.yes) {
2656
+ const ok = await p.confirm({
2657
+ message: `Update existing secrets dir? (${localSecretsDir})`,
2658
+ initialValue: true
2659
+ });
2660
+ if (p.isCancel(ok)) {
2661
+ if (await navOnCancel({
2662
+ flow: "secrets init",
2663
+ canBack: false
2664
+ }) === NAV_EXIT) cancelFlow();
2665
+ return;
2666
+ }
2667
+ if (!ok) return;
2668
+ }
2669
+ const nix = {
2670
+ nixBin: String(process$1.env.NIX_BIN || "nix").trim() || "nix",
2671
+ cwd: layout.repoRoot,
2672
+ dryRun: Boolean(a.dryRun)
2673
+ };
2674
+ const ensureAgePair = async (keyPath, pubPath) => {
2675
+ if (fs.existsSync(keyPath) && fs.existsSync(pubPath)) {
2676
+ const parsed = parseAgeKeyFile(fs.readFileSync(keyPath, "utf8"));
2677
+ const publicKey = fs.readFileSync(pubPath, "utf8").trim();
2678
+ if (!parsed.secretKey) throw new Error(`invalid age key: ${keyPath}`);
2679
+ if (!publicKey) throw new Error(`invalid age public key: ${pubPath}`);
2680
+ return {
2681
+ secretKey: parsed.secretKey,
2682
+ publicKey
2683
+ };
2684
+ }
2685
+ const pair = await ageKeygen(nix);
2686
+ if (!a.dryRun) {
2687
+ await ensureDir(path.dirname(keyPath));
2688
+ await writeFileAtomic(keyPath, pair.fileText, { mode: 384 });
2689
+ await writeFileAtomic(pubPath, `${pair.publicKey}\n`, { mode: 420 });
2690
+ }
2691
+ return {
2692
+ secretKey: pair.secretKey,
2693
+ publicKey: pair.publicKey
2694
+ };
2695
+ };
2696
+ const operatorKeys = await ensureAgePair(operatorKeyPath, operatorPubPath);
2697
+ const withHostKeyRule = upsertSopsCreationRule({
2698
+ existingYaml: fs.existsSync(sopsConfigPath) ? fs.readFileSync(sopsConfigPath, "utf8") : void 0,
2699
+ pathRegex: getHostAgeKeySopsCreationRulePathRegex(layout, hostName),
2700
+ ageRecipients: [operatorKeys.publicKey]
2701
+ });
2702
+ let hostKeys;
2703
+ if (fs.existsSync(hostKeyFile)) if (a.dryRun) hostKeys = {
2704
+ publicKey: "age1dryrundryrundryrundryrundryrundryrundryrundryrundryrun0l9p4",
2705
+ secretKey: "AGE-SECRET-KEY-DRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUN"
2706
+ };
2707
+ else {
2708
+ const decrypted = await sopsDecryptYamlFile({
2709
+ filePath: hostKeyFile,
2710
+ ageKeyFile: operatorKeyPath,
2711
+ nix
2712
+ });
2713
+ const secretKey = readYamlScalarFromMapping({
2714
+ yamlText: decrypted,
2715
+ key: "age_secret_key"
2716
+ })?.trim() || "";
2717
+ const publicKey = readYamlScalarFromMapping({
2718
+ yamlText: decrypted,
2719
+ key: "age_public_key"
2720
+ })?.trim() || "";
2721
+ if (!secretKey || !publicKey) throw new Error(`invalid host age key file: ${hostKeyFile}`);
2722
+ hostKeys = {
2723
+ secretKey,
2724
+ publicKey
2725
+ };
2726
+ }
2727
+ else {
2728
+ const pair = await ageKeygen(nix);
2729
+ hostKeys = {
2730
+ secretKey: pair.secretKey,
2731
+ publicKey: pair.publicKey
2732
+ };
2733
+ const plaintextYaml = upsertYamlScalarLine({
2734
+ text: upsertYamlScalarLine({
2735
+ text: "\n",
2736
+ key: "age_public_key",
2737
+ value: pair.publicKey
2738
+ }),
2739
+ key: "age_secret_key",
2740
+ value: pair.secretKey
2741
+ }) + "\n";
2742
+ if (!a.dryRun) {
2743
+ await ensureDir(path.dirname(sopsConfigPath));
2744
+ await writeFileAtomic(sopsConfigPath, withHostKeyRule, { mode: 420 });
2745
+ await sopsEncryptYamlToFile({
2746
+ plaintextYaml,
2747
+ outPath: hostKeyFile,
2748
+ configPath: sopsConfigPath,
2749
+ nix
2750
+ });
2751
+ }
2752
+ }
2753
+ const nextSops = upsertSopsCreationRule({
2754
+ existingYaml: withHostKeyRule,
2755
+ pathRegex: getHostSecretsSopsCreationRulePathRegex(layout, hostName),
2756
+ ageRecipients: [hostKeys.publicKey, operatorKeys.publicKey]
2757
+ });
2758
+ if (!a.dryRun) {
2759
+ await ensureDir(path.dirname(sopsConfigPath));
2760
+ await writeFileAtomic(sopsConfigPath, nextSops, { mode: 420 });
2761
+ await ensureDir(path.dirname(extraFilesKeyPath));
2762
+ await writeFileAtomic(extraFilesKeyPath, `${hostKeys.secretKey}\n`, { mode: 384 });
2763
+ }
2764
+ const readExistingScalar = async (secretName) => {
2765
+ const p0 = path.join(localSecretsDir, `${secretName}.yaml`);
2766
+ if (!fs.existsSync(p0)) return null;
2767
+ try {
2768
+ return readYamlScalarFromMapping({
2769
+ yamlText: await sopsDecryptYamlFile({
2770
+ filePath: p0,
2771
+ ageKeyFile: operatorKeyPath,
2772
+ nix
2773
+ }),
2774
+ key: secretName
2775
+ });
2776
+ } catch {
2777
+ return null;
2778
+ }
2779
+ };
2780
+ const flowSecrets = "secrets init";
2781
+ const values = {
2782
+ adminPassword: "",
2783
+ adminPasswordHash: "",
2784
+ tailscaleAuthKey: "",
2785
+ secrets: {},
2786
+ discordTokens: {}
2787
+ };
2788
+ if (interactive) {
2789
+ const discordSecretNames = new Set(Object.values(secretsPlan.discordSecretsByBot).filter(Boolean));
2790
+ const requiredExtraSecrets = Array.from(requiredExtraSecretNames).filter((s) => !discordSecretNames.has(s)).sort();
2791
+ const discordTokenBots = [];
2792
+ const seenDiscordSecrets = /* @__PURE__ */ new Set();
2793
+ for (const bot$1 of bots) {
2794
+ const secretName = secretsPlan.discordSecretsByBot[bot$1] || "";
2795
+ if (!secretName) continue;
2796
+ if (!requiredSecretNames.has(secretName)) continue;
2797
+ if (seenDiscordSecrets.has(secretName)) continue;
2798
+ seenDiscordSecrets.add(secretName);
2799
+ discordTokenBots.push({
2800
+ bot: bot$1,
2801
+ secretName
2802
+ });
2803
+ }
2804
+ const allSteps = [
2805
+ { kind: "adminPassword" },
2806
+ ...requiresTailscaleAuthKey ? [{ kind: "tailscaleAuthKey" }] : [],
2807
+ ...requiredExtraSecrets.map((secretName) => garnixPrivateEnabled && secretName === garnixNetrcSecretName ? {
2808
+ kind: "garnixNetrcFile",
2809
+ secretName,
2810
+ netrcPath: garnixNetrcPath || "/etc/nix/netrc"
2811
+ } : {
2812
+ kind: "secret",
2813
+ secretName
2814
+ }),
2815
+ ...discordTokenBots.map((b) => ({
2816
+ kind: "discordToken",
2817
+ bot: b.bot,
2818
+ secretName: b.secretName
2819
+ }))
2820
+ ];
2821
+ for (let i = 0; i < allSteps.length;) {
2822
+ const step = allSteps[i];
2823
+ let v;
2824
+ if (step.kind === "adminPassword") v = await p.password({ message: "Admin password (used to generate admin_password_hash; leave blank to keep existing/placeholder)" });
2825
+ else if (step.kind === "tailscaleAuthKey") v = await p.password({ message: "Tailscale auth key (tailscale_auth_key) (required for non-interactive tailnet bootstrap)" });
2826
+ else if (step.kind === "garnixNetrcFile") v = await p.text({
2827
+ message: `Path to netrc file for private Garnix cache (${step.secretName} → ${step.netrcPath}) (required)`,
2828
+ placeholder: `${layout.runtimeDir}/garnix.netrc`
2829
+ });
2830
+ else if (step.kind === "secret") v = await p.password({ message: `Secret value (${step.secretName}) (required)` });
2831
+ else v = await p.password({ message: `Discord token for ${step.bot} (${step.secretName}) (required)` });
2832
+ if (p.isCancel(v)) {
2833
+ if (await navOnCancel({
2834
+ flow: flowSecrets,
2835
+ canBack: i > 0
2836
+ }) === NAV_EXIT) {
2837
+ cancelFlow();
2838
+ return;
2839
+ }
2840
+ i = Math.max(0, i - 1);
2841
+ continue;
2842
+ }
2843
+ const s = String(v ?? "");
2844
+ if (step.kind === "adminPassword") values.adminPassword = s;
2845
+ else if (step.kind === "tailscaleAuthKey") values.tailscaleAuthKey = s;
2846
+ else if (step.kind === "garnixNetrcFile") {
2847
+ const rawPath = s.trim();
2848
+ if (!rawPath) values.secrets[step.secretName] = "";
2849
+ else {
2850
+ const expanded = expandPath(rawPath);
2851
+ const abs = path.isAbsolute(expanded) ? expanded : path.resolve(layout.repoRoot, expanded);
2852
+ const stat = fs.statSync(abs);
2853
+ if (!stat.isFile()) throw new Error(`not a file: ${abs}`);
2854
+ if (stat.size > 64 * 1024) throw new Error(`netrc file too large (>64KB): ${abs}`);
2855
+ const netrc = fs.readFileSync(abs, "utf8").trimEnd();
2856
+ if (!netrc) throw new Error(`netrc file is empty: ${abs}`);
2857
+ values.secrets[step.secretName] = netrc;
2858
+ }
2859
+ } else if (step.kind === "secret") values.secrets[step.secretName] = s;
2860
+ else {
2861
+ values.discordTokens[step.bot] = s;
2862
+ values.secrets[step.secretName] = s;
2863
+ }
2864
+ i += 1;
2865
+ }
2866
+ } else {
2867
+ const input = readSecretsInitJson(String(fromJson));
2868
+ values.adminPasswordHash = input.adminPasswordHash;
2869
+ values.tailscaleAuthKey = input.tailscaleAuthKey || "";
2870
+ values.secrets = input.secrets || {};
2871
+ values.discordTokens = input.discordTokens || {};
2872
+ }
2873
+ const secretsToWrite = secretsPlan.secretNamesAll;
2874
+ const requiredSecrets = Array.from(new Set([
2875
+ ...requiresTailscaleAuthKey ? ["tailscale_auth_key"] : [],
2876
+ "admin_password_hash",
2877
+ ...garnixPrivateEnabled ? [garnixNetrcSecretName] : [],
2878
+ ...secretsToWrite
2879
+ ]));
2880
+ const isOptionalMarker = (v) => String(v || "").trim() === "<OPTIONAL>";
2881
+ const resolvedValues = {};
2882
+ for (const secretName of requiredSecrets) {
2883
+ const existing = await readExistingScalar(secretName);
2884
+ if (secretName === "tailscale_auth_key") {
2885
+ if (values.tailscaleAuthKey.trim()) resolvedValues[secretName] = values.tailscaleAuthKey.trim();
2886
+ else if (existing && !isPlaceholderSecretValue(existing)) resolvedValues[secretName] = existing;
2887
+ else if (a.allowPlaceholders) resolvedValues[secretName] = "<FILL_ME>";
2888
+ else throw new Error("missing tailscale auth key (tailscale_auth_key); pass --allow-placeholders only if you intend to set it later");
2889
+ continue;
2890
+ }
2891
+ if (secretName === "admin_password_hash") {
2892
+ if (values.adminPasswordHash.trim()) resolvedValues[secretName] = values.adminPasswordHash.trim();
2893
+ else if (values.adminPassword.trim()) resolvedValues[secretName] = a.dryRun ? "<admin_password_hash>" : await mkpasswdYescryptHash(String(values.adminPassword), nix);
2894
+ else resolvedValues[secretName] = existing ?? "<FILL_ME>";
2895
+ continue;
2896
+ }
2897
+ if (discordSecretByName.has(secretName)) {
2898
+ const bot$1 = discordSecretByName.get(secretName) || "";
2899
+ const required$1 = requiredSecretNames.has(secretName);
2900
+ const vv$1 = (bot$1 ? values.discordTokens[bot$1]?.trim() : "") || values.secrets?.[secretName]?.trim() || "";
2901
+ if (vv$1) resolvedValues[secretName] = vv$1;
2902
+ else if (existing) resolvedValues[secretName] = existing;
2903
+ else if (!required$1) resolvedValues[secretName] = "<OPTIONAL>";
2904
+ else if (a.allowPlaceholders) resolvedValues[secretName] = "<FILL_ME>";
2905
+ else throw new Error(`missing discord token for ${bot$1 || secretName} (provide it in --from-json.discordTokens or pass --allow-placeholders)`);
2906
+ continue;
2907
+ }
2908
+ const vv = values.secrets?.[secretName]?.trim() || "";
2909
+ const required = requiredExtraSecretNames.has(secretName);
2910
+ if (vv && !(required && isOptionalMarker(vv))) {
2911
+ resolvedValues[secretName] = vv;
2912
+ continue;
2913
+ }
2914
+ if (existing && (!required || !isPlaceholderSecretValue(existing) && !isOptionalMarker(existing) && existing.trim())) {
2915
+ resolvedValues[secretName] = existing;
2916
+ continue;
2917
+ }
2918
+ if (required) {
2919
+ if (a.allowPlaceholders) resolvedValues[secretName] = "<FILL_ME>";
2920
+ else throw new Error(`missing required secret: ${secretName} (set it in --from-json.secrets or via interactive prompts)`);
2921
+ continue;
2922
+ }
2923
+ resolvedValues[secretName] = "<OPTIONAL>";
2924
+ }
2925
+ if (!a.dryRun) {
2926
+ await ensureDir(localSecretsDir);
2927
+ await ensureDir(extraFilesSecretsDir);
2928
+ for (const secretName of requiredSecrets) {
2929
+ const outPath = path.join(localSecretsDir, `${secretName}.yaml`);
2930
+ await sopsEncryptYamlToFile({
2931
+ plaintextYaml: upsertYamlScalarLine({
2932
+ text: "\n",
2933
+ key: secretName,
2934
+ value: resolvedValues[secretName] ?? ""
2935
+ }),
2936
+ outPath,
2937
+ configPath: sopsConfigPath,
2938
+ nix
2939
+ });
2940
+ const encrypted = fs.readFileSync(outPath, "utf8");
2941
+ await writeFileAtomic(path.join(extraFilesSecretsDir, `${secretName}.yaml`), encrypted, { mode: 256 });
2942
+ }
2943
+ }
2944
+ console.log(`ok: secrets ready at ${localSecretsDir}`);
2945
+ console.log(`ok: sops config at ${sopsConfigPath}`);
2946
+ console.log(`ok: operator age key at ${operatorKeyPath}`);
2947
+ console.log(`ok: host age key (encrypted) at ${hostKeyFile}`);
2948
+ console.log(`ok: extra-files key at ${extraFilesKeyPath}`);
2949
+ console.log(`ok: extra-files secrets at ${extraFilesSecretsDir}`);
2950
+ }
2951
+ });
2952
+
2953
+ //#endregion
2954
+ //#region src/commands/secrets/path.ts
2955
+ const secretsPath = defineCommand({
2956
+ meta: {
2957
+ name: "path",
2958
+ description: "Print local + remote secrets paths for a host."
2959
+ },
2960
+ args: {
2961
+ runtimeDir: {
2962
+ type: "string",
2963
+ description: "Runtime directory (default: .clawdlets)."
2964
+ },
2965
+ host: {
2966
+ type: "string",
2967
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
2968
+ }
2969
+ },
2970
+ async run({ args }) {
2971
+ const ctx = loadHostContextOrExit({
2972
+ cwd: process$1.cwd(),
2973
+ runtimeDir: args.runtimeDir,
2974
+ hostArg: args.host
2975
+ });
2976
+ if (!ctx) return;
2977
+ const { layout, hostName } = ctx;
2978
+ console.log(`local: ${getHostSecretsDir(layout, hostName)}`);
2979
+ console.log(`remote: ${getHostRemoteSecretsDir(hostName)}`);
2980
+ }
2981
+ });
2982
+
2983
+ //#endregion
2984
+ //#region src/commands/secrets/sync.ts
2985
+ const secretsSync = defineCommand({
2986
+ meta: {
2987
+ name: "sync",
2988
+ description: "Copy local secrets to the server via the install-secrets allowlist."
2989
+ },
2990
+ args: {
2991
+ runtimeDir: {
2992
+ type: "string",
2993
+ description: "Runtime directory (default: .clawdlets)."
2994
+ },
2995
+ host: {
2996
+ type: "string",
2997
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
2998
+ },
2999
+ targetHost: {
3000
+ type: "string",
3001
+ description: "SSH target override (default: from clawdlets.json)."
3002
+ },
3003
+ rev: {
3004
+ type: "string",
3005
+ description: "Git rev for secrets metadata (HEAD/sha/tag).",
3006
+ default: "HEAD"
3007
+ },
3008
+ sshTty: {
3009
+ type: "boolean",
3010
+ description: "Allocate TTY for sudo prompts.",
3011
+ default: true
3012
+ }
3013
+ },
3014
+ async run({ args }) {
3015
+ const ctx = loadHostContextOrExit({
3016
+ cwd: process$1.cwd(),
3017
+ runtimeDir: args.runtimeDir,
3018
+ hostArg: args.host
3019
+ });
3020
+ if (!ctx) return;
3021
+ const { layout, hostName, hostCfg } = ctx;
3022
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3023
+ const localDir = getHostSecretsDir(layout, hostName);
3024
+ const remoteDir = getHostRemoteSecretsDir(hostName);
3025
+ const revRaw = String(args.rev || "").trim() || "HEAD";
3026
+ const resolved = await resolveGitRev(layout.repoRoot, revRaw);
3027
+ if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
3028
+ const { tarPath: tarLocal, digest } = await createSecretsTar({
3029
+ hostName,
3030
+ localDir
3031
+ });
3032
+ const tarRemote = `/tmp/clawdlets-secrets.${hostName}.${process$1.pid}.tgz`;
3033
+ try {
3034
+ await run("scp", [tarLocal, `${targetHost}:${tarRemote}`], { redact: [] });
3035
+ } finally {
3036
+ try {
3037
+ if (fs.existsSync(tarLocal)) fs.unlinkSync(tarLocal);
3038
+ } catch {}
3039
+ }
3040
+ const sudo = needsSudo(targetHost);
3041
+ await sshRun(targetHost, [
3042
+ ...sudo ? ["sudo"] : [],
3043
+ "/etc/clawdlets/bin/install-secrets",
3044
+ "--host",
3045
+ hostName,
3046
+ "--tar",
3047
+ tarRemote,
3048
+ "--rev",
3049
+ resolved,
3050
+ "--digest",
3051
+ digest
3052
+ ].map(shellQuote).join(" "), { tty: sudo && args.sshTty });
3053
+ console.log(`ok: synced secrets to ${remoteDir}`);
3054
+ }
3055
+ });
3056
+
3057
+ //#endregion
3058
+ //#region src/commands/secrets/verify.ts
3059
+ const secretsVerify = defineCommand({
3060
+ meta: {
3061
+ name: "verify",
3062
+ description: "Verify secrets decrypt correctly and contain no placeholders."
3063
+ },
3064
+ args: {
3065
+ runtimeDir: {
3066
+ type: "string",
3067
+ description: "Runtime directory (default: .clawdlets)."
3068
+ },
3069
+ envFile: {
3070
+ type: "string",
3071
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
3072
+ },
3073
+ host: {
3074
+ type: "string",
3075
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3076
+ },
3077
+ operator: {
3078
+ type: "string",
3079
+ description: "Operator id for age key name (default: $USER). Used if SOPS_AGE_KEY_FILE is not set."
3080
+ },
3081
+ ageKeyFile: {
3082
+ type: "string",
3083
+ description: "Override SOPS_AGE_KEY_FILE path."
3084
+ },
3085
+ json: {
3086
+ type: "boolean",
3087
+ description: "Output JSON.",
3088
+ default: false
3089
+ }
3090
+ },
3091
+ async run({ args }) {
3092
+ const cwd = process$1.cwd();
3093
+ const ctx = loadHostContextOrExit({
3094
+ cwd,
3095
+ runtimeDir: args.runtimeDir,
3096
+ hostArg: args.host
3097
+ });
3098
+ if (!ctx) return;
3099
+ const { layout, config: config$1, hostName, hostCfg } = ctx;
3100
+ const deployCreds = loadDeployCreds({
3101
+ cwd,
3102
+ runtimeDir: args.runtimeDir,
3103
+ envFile: args.envFile
3104
+ });
3105
+ if (deployCreds.envFile?.origin === "explicit" && deployCreds.envFile.status !== "ok") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || deployCreds.envFile.status})`);
3106
+ const operatorId = sanitizeOperatorId(String(args.operator || process$1.env.USER || "operator"));
3107
+ const operatorKeyPath = (args.ageKeyFile ? String(args.ageKeyFile).trim() : "") || (deployCreds.values.SOPS_AGE_KEY_FILE ? String(deployCreds.values.SOPS_AGE_KEY_FILE).trim() : "") || getLocalOperatorAgeKeyPath(layout, operatorId);
3108
+ const nix = {
3109
+ nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
3110
+ cwd: layout.repoRoot,
3111
+ dryRun: false
3112
+ };
3113
+ const localDir = getHostSecretsDir(layout, hostName);
3114
+ const secretsPlan = buildFleetSecretsPlan({
3115
+ config: config$1,
3116
+ hostName
3117
+ });
3118
+ const requiredSecretNames = new Set(secretsPlan.secretNamesRequired);
3119
+ const tailnetMode = String(hostCfg.tailnet?.mode || "none");
3120
+ const requiredSecrets = Array.from(new Set([...tailnetMode === "tailscale" ? ["tailscale_auth_key"] : [], "admin_password_hash"]));
3121
+ const secretNames = secretsPlan.secretNamesAll;
3122
+ const optionalSecrets = ["root_password_hash"];
3123
+ const results = [];
3124
+ if (!fs.existsSync(operatorKeyPath)) results.push({
3125
+ secret: "SOPS_AGE_KEY_FILE",
3126
+ status: "missing",
3127
+ detail: operatorKeyPath
3128
+ });
3129
+ const verifyOne = async (secretName, optional, allowOptionalMarker) => {
3130
+ const filePath = path.join(localDir, `${secretName}.yaml`);
3131
+ if (!fs.existsSync(filePath)) {
3132
+ results.push({
3133
+ secret: secretName,
3134
+ status: optional ? "warn" : "missing",
3135
+ detail: `(missing: ${filePath})`
3136
+ });
3137
+ return;
3138
+ }
3139
+ try {
3140
+ const decrypted = await sopsDecryptYamlFile({
3141
+ filePath,
3142
+ ageKeyFile: operatorKeyPath,
3143
+ nix
3144
+ });
3145
+ const parsed = YAML.parse(decrypted) || {};
3146
+ const keys = Object.keys(parsed).filter((k) => k !== "sops");
3147
+ if (keys.length !== 1 || keys[0] !== secretName) {
3148
+ results.push({
3149
+ secret: secretName,
3150
+ status: "missing",
3151
+ detail: "(invalid: expected exactly 1 key matching filename)"
3152
+ });
3153
+ return;
3154
+ }
3155
+ const v = parsed[secretName];
3156
+ const value = typeof v === "string" ? v : v == null ? "" : String(v);
3157
+ if (!allowOptionalMarker && value.trim() === "<OPTIONAL>") {
3158
+ results.push({
3159
+ secret: secretName,
3160
+ status: "missing",
3161
+ detail: "(placeholder: <OPTIONAL>)"
3162
+ });
3163
+ return;
3164
+ }
3165
+ if (!optional && isPlaceholderSecretValue(value)) {
3166
+ results.push({
3167
+ secret: secretName,
3168
+ status: "missing",
3169
+ detail: `(placeholder: ${value.trim()})`
3170
+ });
3171
+ return;
3172
+ }
3173
+ if (optional && isPlaceholderSecretValue(value)) {
3174
+ results.push({
3175
+ secret: secretName,
3176
+ status: "missing",
3177
+ detail: `(placeholder: ${value.trim()})`
3178
+ });
3179
+ return;
3180
+ }
3181
+ if (!optional && !value.trim()) {
3182
+ results.push({
3183
+ secret: secretName,
3184
+ status: "missing",
3185
+ detail: "(empty)"
3186
+ });
3187
+ return;
3188
+ }
3189
+ results.push({
3190
+ secret: secretName,
3191
+ status: "ok"
3192
+ });
3193
+ } catch (e) {
3194
+ results.push({
3195
+ secret: secretName,
3196
+ status: "missing",
3197
+ detail: String(e?.message || e)
3198
+ });
3199
+ }
3200
+ };
3201
+ if (!fs.existsSync(localDir)) results.push({
3202
+ secret: "secrets.localDir",
3203
+ status: "missing",
3204
+ detail: localDir
3205
+ });
3206
+ else {
3207
+ for (const s of requiredSecrets) await verifyOne(s, false, false);
3208
+ for (const s of secretNames) await verifyOne(s, false, !requiredSecretNames.has(s));
3209
+ for (const s of optionalSecrets) await verifyOne(s, true, true);
3210
+ }
3211
+ if (args.json) console.log(JSON.stringify({
3212
+ host: hostName,
3213
+ localDir,
3214
+ results
3215
+ }, null, 2));
3216
+ else for (const r of results) console.log(`${r.status}: ${r.secret}${r.detail ? ` (${r.detail})` : ""}`);
3217
+ if (results.some((r) => r.status === "missing")) process$1.exitCode = 1;
3218
+ }
3219
+ });
3220
+
3221
+ //#endregion
3222
+ //#region src/commands/secrets.ts
3223
+ const secrets = defineCommand({
3224
+ meta: {
3225
+ name: "secrets",
3226
+ description: "Secrets workflow (/secrets + extra-files + sync)."
3227
+ },
3228
+ subCommands: {
3229
+ init: secretsInit,
3230
+ verify: secretsVerify,
3231
+ sync: secretsSync,
3232
+ path: secretsPath
3233
+ }
3234
+ });
3235
+
3236
+ //#endregion
3237
+ //#region src/commands/server/github-sync.ts
3238
+ function normalizeKind(raw) {
3239
+ const v = raw.trim();
3240
+ if (v === "prs" || v === "issues") return v;
3241
+ throw new Error(`invalid --kind: ${raw} (expected prs|issues)`);
3242
+ }
3243
+ const serverGithubSyncStatus = defineCommand({
3244
+ meta: {
3245
+ name: "status",
3246
+ description: "Show GitHub sync timers (clawdbot-gh-sync-*.timer)."
3247
+ },
3248
+ args: {
3249
+ runtimeDir: {
3250
+ type: "string",
3251
+ description: "Runtime directory (default: .clawdlets)."
3252
+ },
3253
+ host: {
3254
+ type: "string",
3255
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3256
+ },
3257
+ targetHost: {
3258
+ type: "string",
3259
+ description: "SSH target override (default: from clawdlets.json)."
3260
+ },
3261
+ sshTty: {
3262
+ type: "boolean",
3263
+ description: "Allocate TTY for sudo prompts.",
3264
+ default: true
3265
+ }
3266
+ },
3267
+ async run({ args }) {
3268
+ const ctx = loadHostContextOrExit({
3269
+ cwd: process$1.cwd(),
3270
+ runtimeDir: args.runtimeDir,
3271
+ hostArg: args.host
3272
+ });
3273
+ if (!ctx) return;
3274
+ const { hostName, hostCfg } = ctx;
3275
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3276
+ const sudo = needsSudo(targetHost);
3277
+ await sshRun(targetHost, [
3278
+ ...sudo ? ["sudo"] : [],
3279
+ "systemctl",
3280
+ "list-timers",
3281
+ "--all",
3282
+ "--no-pager",
3283
+ shellQuote("clawdbot-gh-sync-*.timer")
3284
+ ].join(" "), { tty: sudo && args.sshTty });
3285
+ }
3286
+ });
3287
+ const serverGithubSyncRun = defineCommand({
3288
+ meta: {
3289
+ name: "run",
3290
+ description: "Run a GitHub sync now (oneshot)."
3291
+ },
3292
+ args: {
3293
+ runtimeDir: {
3294
+ type: "string",
3295
+ description: "Runtime directory (default: .clawdlets)."
3296
+ },
3297
+ host: {
3298
+ type: "string",
3299
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3300
+ },
3301
+ targetHost: {
3302
+ type: "string",
3303
+ description: "SSH target override (default: from clawdlets.json)."
3304
+ },
3305
+ bot: {
3306
+ type: "string",
3307
+ description: "Bot id (default: all bots with sync enabled)."
3308
+ },
3309
+ sshTty: {
3310
+ type: "boolean",
3311
+ description: "Allocate TTY for sudo prompts.",
3312
+ default: true
3313
+ }
3314
+ },
3315
+ async run({ args }) {
3316
+ const ctx = loadHostContextOrExit({
3317
+ cwd: process$1.cwd(),
3318
+ runtimeDir: args.runtimeDir,
3319
+ hostArg: args.host
3320
+ });
3321
+ if (!ctx) return;
3322
+ const { hostName, hostCfg } = ctx;
3323
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3324
+ const bot$1 = String(args.bot || "").trim();
3325
+ const unit = bot$1 ? `clawdbot-gh-sync-${bot$1}.service` : "clawdbot-gh-sync-*.service";
3326
+ const sudo = needsSudo(targetHost);
3327
+ await sshRun(targetHost, [
3328
+ ...sudo ? ["sudo"] : [],
3329
+ "systemctl",
3330
+ "start",
3331
+ shellQuote(unit)
3332
+ ].join(" "), { tty: sudo && args.sshTty });
3333
+ }
3334
+ });
3335
+ const serverGithubSyncLogs = defineCommand({
3336
+ meta: {
3337
+ name: "logs",
3338
+ description: "Show GitHub sync logs (journalctl)."
3339
+ },
3340
+ args: {
3341
+ runtimeDir: {
3342
+ type: "string",
3343
+ description: "Runtime directory (default: .clawdlets)."
3344
+ },
3345
+ host: {
3346
+ type: "string",
3347
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3348
+ },
3349
+ targetHost: {
3350
+ type: "string",
3351
+ description: "SSH target override (default: from clawdlets.json)."
3352
+ },
3353
+ bot: {
3354
+ type: "string",
3355
+ description: "Bot id (required)."
3356
+ },
3357
+ follow: {
3358
+ type: "boolean",
3359
+ description: "Follow logs.",
3360
+ default: false
3361
+ },
3362
+ lines: {
3363
+ type: "string",
3364
+ description: "Number of lines (default: 200).",
3365
+ default: "200"
3366
+ },
3367
+ sshTty: {
3368
+ type: "boolean",
3369
+ description: "Allocate TTY for sudo prompts.",
3370
+ default: true
3371
+ }
3372
+ },
3373
+ async run({ args }) {
3374
+ const ctx = loadHostContextOrExit({
3375
+ cwd: process$1.cwd(),
3376
+ runtimeDir: args.runtimeDir,
3377
+ hostArg: args.host
3378
+ });
3379
+ if (!ctx) return;
3380
+ const { hostName, hostCfg } = ctx;
3381
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3382
+ const bot$1 = String(args.bot || "").trim();
3383
+ if (!bot$1) throw new Error("missing --bot (example: --bot maren)");
3384
+ const sudo = needsSudo(targetHost);
3385
+ const unit = `clawdbot-gh-sync-${bot$1}.service`;
3386
+ const n = String(args.lines || "200").trim() || "200";
3387
+ if (!/^\d+$/.test(n) || Number(n) <= 0) throw new Error(`invalid --lines: ${n}`);
3388
+ await sshRun(targetHost, [
3389
+ ...sudo ? ["sudo"] : [],
3390
+ "journalctl",
3391
+ "-u",
3392
+ shellQuote(unit),
3393
+ "-n",
3394
+ shellQuote(n),
3395
+ ...args.follow ? ["-f"] : [],
3396
+ "--no-pager"
3397
+ ].join(" "), { tty: sudo && args.sshTty });
3398
+ }
3399
+ });
3400
+ const serverGithubSyncShow = defineCommand({
3401
+ meta: {
3402
+ name: "show",
3403
+ description: "Show the last synced snapshot (prs|issues) from bot workspace memory."
3404
+ },
3405
+ args: {
3406
+ runtimeDir: {
3407
+ type: "string",
3408
+ description: "Runtime directory (default: .clawdlets)."
3409
+ },
3410
+ host: {
3411
+ type: "string",
3412
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3413
+ },
3414
+ targetHost: {
3415
+ type: "string",
3416
+ description: "SSH target override (default: from clawdlets.json)."
3417
+ },
3418
+ bot: {
3419
+ type: "string",
3420
+ description: "Bot id (required)."
3421
+ },
3422
+ kind: {
3423
+ type: "string",
3424
+ description: "Snapshot kind: prs|issues.",
3425
+ default: "prs"
3426
+ },
3427
+ lines: {
3428
+ type: "string",
3429
+ description: "Max lines to print (default: 200).",
3430
+ default: "200"
3431
+ },
3432
+ sshTty: {
3433
+ type: "boolean",
3434
+ description: "Allocate TTY for sudo prompts.",
3435
+ default: true
3436
+ }
3437
+ },
3438
+ async run({ args }) {
3439
+ const ctx = loadHostContextOrExit({
3440
+ cwd: process$1.cwd(),
3441
+ runtimeDir: args.runtimeDir,
3442
+ hostArg: args.host
3443
+ });
3444
+ if (!ctx) return;
3445
+ const { hostName, hostCfg } = ctx;
3446
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3447
+ const bot$1 = String(args.bot || "").trim();
3448
+ if (!bot$1) throw new Error("missing --bot (example: --bot maren)");
3449
+ const kind = normalizeKind(String(args.kind || "prs"));
3450
+ const n = String(args.lines || "200").trim() || "200";
3451
+ if (!/^\d+$/.test(n) || Number(n) <= 0) throw new Error(`invalid --lines: ${n}`);
3452
+ const sudo = needsSudo(targetHost);
3453
+ await sshRun(targetHost, [
3454
+ ...sudo ? ["sudo"] : [],
3455
+ "/etc/clawdlets/bin/gh-sync-read",
3456
+ shellQuote(bot$1),
3457
+ shellQuote(kind),
3458
+ shellQuote(n)
3459
+ ].join(" "), { tty: sudo && args.sshTty });
3460
+ }
3461
+ });
3462
+ const serverGithubSync = defineCommand({
3463
+ meta: {
3464
+ name: "github-sync",
3465
+ description: "GitHub inventory sync (systemd timers + logs + snapshots)."
3466
+ },
3467
+ subCommands: {
3468
+ status: serverGithubSyncStatus,
3469
+ run: serverGithubSyncRun,
3470
+ logs: serverGithubSyncLogs,
3471
+ show: serverGithubSyncShow
3472
+ }
3473
+ });
3474
+
3475
+ //#endregion
3476
+ //#region src/lib/deploy-manifest.ts
3477
+ const REV_RE = /^[0-9a-f]{40}$/;
3478
+ const DIGEST_RE = /^[0-9a-f]{64}$/;
3479
+ function requireRev(value) {
3480
+ const v = value.trim();
3481
+ if (!REV_RE.test(v)) throw new Error(`invalid rev (expected 40-char sha): ${v || "<empty>"}`);
3482
+ return v;
3483
+ }
3484
+ function requireToplevel(value) {
3485
+ const v = value.trim();
3486
+ if (!v) throw new Error("missing toplevel store path");
3487
+ if (/\s/.test(v)) throw new Error(`invalid toplevel (contains whitespace): ${v}`);
3488
+ if (!v.startsWith("/nix/store/")) throw new Error(`invalid toplevel (expected /nix/store/...): ${v}`);
3489
+ return v;
3490
+ }
3491
+ function parseDeployManifest(manifestPath) {
3492
+ const raw = fs.readFileSync(manifestPath, "utf8");
3493
+ let parsed;
3494
+ try {
3495
+ parsed = JSON.parse(raw);
3496
+ } catch (e) {
3497
+ throw new Error(`invalid deploy manifest JSON: ${manifestPath} (${String(e?.message || e)})`);
3498
+ }
3499
+ if (!parsed || typeof parsed !== "object") throw new Error(`invalid deploy manifest: ${manifestPath}`);
3500
+ const rev = requireRev(String(parsed.rev ?? ""));
3501
+ const host$1 = String(parsed.host ?? "").trim();
3502
+ if (!host$1) throw new Error(`invalid deploy manifest host: ${manifestPath}`);
3503
+ const toplevel = requireToplevel(String(parsed.toplevel ?? ""));
3504
+ const secretsDigestRaw = String(parsed.secretsDigest ?? "").trim();
3505
+ const secretsDigest = secretsDigestRaw ? secretsDigestRaw : void 0;
3506
+ if (secretsDigest && !DIGEST_RE.test(secretsDigest)) throw new Error(`invalid deploy manifest secretsDigest (expected sha256 hex): ${manifestPath}`);
3507
+ return {
3508
+ rev,
3509
+ host: host$1,
3510
+ toplevel,
3511
+ secretsDigest
3512
+ };
3513
+ }
3514
+ function formatDeployManifest(manifest) {
3515
+ return `${JSON.stringify(manifest, null, 2)}\n`;
3516
+ }
3517
+
3518
+ //#endregion
3519
+ //#region src/lib/manifest-signature.ts
3520
+ function resolveManifestSignaturePath(params) {
3521
+ const sigArg = String(params.signaturePathArg || "").trim();
3522
+ if (sigArg) return path.isAbsolute(sigArg) ? sigArg : path.resolve(params.cwd, sigArg);
3523
+ const fallback = `${params.manifestPath}.minisig`;
3524
+ if (fs.existsSync(fallback)) return fallback;
3525
+ throw new Error("manifest signature missing (provide --manifest-signature or <manifest>.minisig)");
3526
+ }
3527
+ function resolveManifestPublicKey(params) {
3528
+ const direct = String(params.publicKeyArg || "").trim();
3529
+ if (direct) return direct;
3530
+ const fileArg = String(params.publicKeyFileArg || "").trim();
3531
+ if (fileArg) {
3532
+ const content = fs.readFileSync(fileArg, "utf8").trim();
3533
+ if (!content) throw new Error(`manifest public key file empty: ${fileArg}`);
3534
+ return content;
3535
+ }
3536
+ const fallbackPath = String(params.defaultKeyPath || "").trim();
3537
+ if (fallbackPath && fs.existsSync(fallbackPath)) {
3538
+ const content = fs.readFileSync(fallbackPath, "utf8").trim();
3539
+ if (!content) throw new Error(`manifest public key file empty: ${fallbackPath}`);
3540
+ return content;
3541
+ }
3542
+ const fromHost = String(params.hostPublicKey || "").trim();
3543
+ if (fromHost) return fromHost;
3544
+ throw new Error("manifest public key missing (set hosts.<host>.selfUpdate.publicKey or --manifest-public-key)");
3545
+ }
3546
+ async function verifyManifestSignature(params) {
3547
+ try {
3548
+ await run("minisign", [
3549
+ "-Vm",
3550
+ params.manifestPath,
3551
+ "-P",
3552
+ params.publicKey,
3553
+ "-x",
3554
+ params.signaturePath
3555
+ ], { redact: [] });
3556
+ } catch (e) {
3557
+ const err = e;
3558
+ const msg = String(err?.message || e);
3559
+ if (err?.code === "ENOENT" || msg.includes("spawn minisign ENOENT")) throw new Error(`minisign not found (${msg}). Install minisign and retry.`);
3560
+ throw new Error(`manifest signature invalid (${msg}). See minisign output above.`);
3561
+ }
3562
+ }
3563
+
3564
+ //#endregion
3565
+ //#region src/lib/linux-build.ts
3566
+ function linuxBuildRequiredError(params) {
3567
+ const cmd = params.command.trim() || "this command";
3568
+ return new Error([
3569
+ `${cmd}: local NixOS builds require Linux.`,
3570
+ "Use one of:",
3571
+ "- CI: deploy-manifest.yml publishes signed deploy manifests, then deploy.yml (or selfUpdate) deploys by manifest over tailnet",
3572
+ "- Linux builder: build the system on Linux and deploy with --manifest or --toplevel"
3573
+ ].join("\n"));
3574
+ }
3575
+ function requireLinuxForLocalNixosBuild(params) {
3576
+ if (params.platform === "linux") return;
3577
+ throw linuxBuildRequiredError({ command: params.command });
3578
+ }
3579
+
3580
+ //#endregion
3581
+ //#region src/commands/server/deploy.ts
3582
+ async function buildLocalToplevel(params) {
3583
+ requireLinuxForLocalNixosBuild({
3584
+ platform: process$1.platform,
3585
+ command: "clawdlets server deploy"
3586
+ });
3587
+ const attr = `.#nixosConfigurations.${params.host}.config.system.build.toplevel`;
3588
+ const out = await capture(params.nixBin, [
3589
+ "build",
3590
+ "--json",
3591
+ "--no-link",
3592
+ attr
3593
+ ], {
3594
+ cwd: params.repoRoot,
3595
+ env: withFlakesEnv(process$1.env)
3596
+ });
3597
+ let parsed;
3598
+ try {
3599
+ parsed = JSON.parse(out);
3600
+ } catch (e) {
3601
+ throw new Error(`nix build --json returned invalid JSON (${String(e?.message || e)})`);
3602
+ }
3603
+ const toplevel = parsed?.[0]?.outputs?.out;
3604
+ if (!toplevel || typeof toplevel !== "string") throw new Error("nix build did not return a toplevel store path");
3605
+ return requireToplevel(toplevel);
3606
+ }
3607
+ const serverDeploy = defineCommand({
3608
+ meta: {
3609
+ name: "deploy",
3610
+ description: "Deploy a prebuilt NixOS system + secrets by store path."
3611
+ },
3612
+ args: {
3613
+ runtimeDir: {
3614
+ type: "string",
3615
+ description: "Runtime directory (default: .clawdlets)."
3616
+ },
3617
+ envFile: {
3618
+ type: "string",
3619
+ description: "Env file for deploy creds (default: <runtimeDir>/env)."
3620
+ },
3621
+ host: {
3622
+ type: "string",
3623
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3624
+ },
3625
+ targetHost: {
3626
+ type: "string",
3627
+ description: "SSH target override (default: from clawdlets.json)."
3628
+ },
3629
+ rev: {
3630
+ type: "string",
3631
+ description: "Git rev to pin (HEAD/sha/tag).",
3632
+ default: "HEAD"
3633
+ },
3634
+ toplevel: {
3635
+ type: "string",
3636
+ description: "NixOS system toplevel store path (CI mode)."
3637
+ },
3638
+ manifest: {
3639
+ type: "string",
3640
+ description: "Path to deploy manifest JSON (CI mode)."
3641
+ },
3642
+ manifestSignature: {
3643
+ type: "string",
3644
+ description: "Path to manifest minisign signature (.minisig)."
3645
+ },
3646
+ manifestPublicKey: {
3647
+ type: "string",
3648
+ description: "Minisign public key string (verify manifest)."
3649
+ },
3650
+ manifestPublicKeyFile: {
3651
+ type: "string",
3652
+ description: "Path to minisign public key (verify manifest)."
3653
+ },
3654
+ manifestOut: {
3655
+ type: "string",
3656
+ description: "Write deploy manifest JSON to this path."
3657
+ },
3658
+ sshTty: {
3659
+ type: "boolean",
3660
+ description: "Allocate TTY for sudo prompts.",
3661
+ default: true
3662
+ }
3663
+ },
3664
+ async run({ args }) {
3665
+ const cwd = process$1.cwd();
3666
+ const ctx = loadHostContextOrExit({
3667
+ cwd,
3668
+ runtimeDir: args.runtimeDir,
3669
+ hostArg: args.host
3670
+ });
3671
+ if (!ctx) return;
3672
+ const { repoRoot, layout, hostName, hostCfg } = ctx;
3673
+ await requireDeployGate({
3674
+ runtimeDir: args.runtimeDir,
3675
+ envFile: args.envFile,
3676
+ host: hostName,
3677
+ scope: "server-deploy",
3678
+ strict: false,
3679
+ skipGithubTokenCheck: true
3680
+ });
3681
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3682
+ const sudo = needsSudo(targetHost);
3683
+ const deployCreds = loadDeployCreds({
3684
+ cwd,
3685
+ runtimeDir: args.runtimeDir,
3686
+ envFile: args.envFile
3687
+ });
3688
+ if (deployCreds.envFile?.origin === "explicit" && deployCreds.envFile.status !== "ok") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || deployCreds.envFile.status})`);
3689
+ const nixBin = String(deployCreds.values.NIX_BIN || "nix").trim() || "nix";
3690
+ const manifestPath = String(args.manifest || "").trim();
3691
+ const toplevelArg = String(args.toplevel || "").trim();
3692
+ if (manifestPath && toplevelArg) throw new Error("use either --manifest or --toplevel (not both)");
3693
+ let resolvedRev = "";
3694
+ let toplevel = "";
3695
+ let manifestDigest;
3696
+ if (manifestPath) {
3697
+ await verifyManifestSignature({
3698
+ manifestPath,
3699
+ signaturePath: resolveManifestSignaturePath({
3700
+ cwd,
3701
+ manifestPath,
3702
+ signaturePathArg: args.manifestSignature
3703
+ }),
3704
+ publicKey: resolveManifestPublicKey({
3705
+ publicKeyArg: args.manifestPublicKey,
3706
+ publicKeyFileArg: args.manifestPublicKeyFile,
3707
+ defaultKeyPath: path.join(repoRoot, "config", "manifest.minisign.pub"),
3708
+ hostPublicKey: hostCfg?.selfUpdate?.publicKey
3709
+ })
3710
+ });
3711
+ const manifest = parseDeployManifest(manifestPath);
3712
+ if (manifest.host !== hostName) throw new Error(`manifest host mismatch: ${manifest.host} vs ${hostName}`);
3713
+ const revArg = String(args.rev || "").trim();
3714
+ if (revArg && revArg !== "HEAD" && revArg !== manifest.rev) throw new Error(`manifest rev mismatch: ${manifest.rev} vs ${revArg}`);
3715
+ resolvedRev = manifest.rev;
3716
+ toplevel = manifest.toplevel;
3717
+ manifestDigest = manifest.secretsDigest;
3718
+ } else {
3719
+ const revRaw = String(args.rev || "").trim() || "HEAD";
3720
+ const resolved = await resolveGitRev(layout.repoRoot, revRaw);
3721
+ if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
3722
+ resolvedRev = resolved;
3723
+ if (toplevelArg) toplevel = requireToplevel(toplevelArg);
3724
+ else toplevel = await buildLocalToplevel({
3725
+ repoRoot,
3726
+ nixBin,
3727
+ host: String(hostCfg.flakeHost || hostName).trim() || hostName
3728
+ });
3729
+ }
3730
+ const { tarPath: tarLocal, digest } = await createSecretsTar({
3731
+ hostName,
3732
+ localDir: getHostSecretsDir(layout, hostName)
3733
+ });
3734
+ const tarRemote = `/tmp/clawdlets-secrets.${hostName}.${process$1.pid}.tgz`;
3735
+ if (manifestDigest && manifestDigest !== digest) throw new Error(`secrets digest mismatch (manifest ${manifestDigest}, local ${digest}); regenerate or omit secretsDigest`);
3736
+ try {
3737
+ await run("scp", [tarLocal, `${targetHost}:${tarRemote}`], { redact: [] });
3738
+ } finally {
3739
+ try {
3740
+ if (fs.existsSync(tarLocal)) fs.unlinkSync(tarLocal);
3741
+ } catch {}
3742
+ }
3743
+ await sshRun(targetHost, [
3744
+ ...sudo ? ["sudo"] : [],
3745
+ "/etc/clawdlets/bin/install-secrets",
3746
+ "--host",
3747
+ hostName,
3748
+ "--tar",
3749
+ tarRemote,
3750
+ "--rev",
3751
+ resolvedRev,
3752
+ "--digest",
3753
+ digest
3754
+ ].map(shellQuote).join(" "), { tty: sudo && args.sshTty });
3755
+ await sshRun(targetHost, [
3756
+ ...sudo ? ["sudo"] : [],
3757
+ "/etc/clawdlets/bin/switch-system",
3758
+ "--toplevel",
3759
+ toplevel,
3760
+ "--rev",
3761
+ resolvedRev
3762
+ ].map(shellQuote).join(" "), { tty: sudo && args.sshTty });
3763
+ const manifestOutRaw = String(args.manifestOut || "").trim();
3764
+ const manifestOut = manifestOutRaw ? path.isAbsolute(manifestOutRaw) ? manifestOutRaw : path.resolve(cwd, manifestOutRaw) : manifestPath ? "" : path.join(layout.runtimeDir, "deploy.json");
3765
+ if (manifestOut) {
3766
+ fs.mkdirSync(path.dirname(manifestOut), { recursive: true });
3767
+ const manifest = {
3768
+ rev: resolvedRev,
3769
+ host: hostName,
3770
+ toplevel,
3771
+ secretsDigest: digest
3772
+ };
3773
+ fs.writeFileSync(manifestOut, formatDeployManifest(manifest), "utf8");
3774
+ console.log(`ok: wrote deploy manifest ${manifestOut}`);
3775
+ }
3776
+ console.log(`ok: deployed ${hostName} (${resolvedRev})`);
3777
+ }
3778
+ });
3779
+
3780
+ //#endregion
3781
+ //#region src/commands/server/manifest.ts
3782
+ async function buildToplevel(params) {
3783
+ const attr = `.#packages.x86_64-linux.${params.host}-system`;
3784
+ const out = await capture(params.nixBin, [
3785
+ "build",
3786
+ "--json",
3787
+ "--no-link",
3788
+ attr
3789
+ ], {
3790
+ cwd: params.repoRoot,
3791
+ env: withFlakesEnv(process$1.env)
3792
+ });
3793
+ let parsed;
3794
+ try {
3795
+ parsed = JSON.parse(out);
3796
+ } catch (e) {
3797
+ throw new Error(`nix build --json returned invalid JSON (${String(e?.message || e)})`);
3798
+ }
3799
+ const toplevel = parsed?.[0]?.outputs?.out;
3800
+ if (!toplevel || typeof toplevel !== "string") throw new Error("nix build did not return a toplevel store path");
3801
+ return requireToplevel(toplevel);
3802
+ }
3803
+ const serverManifest = defineCommand({
3804
+ meta: {
3805
+ name: "manifest",
3806
+ description: "Build a deploy manifest (rev + toplevel + secrets digest)."
3807
+ },
3808
+ args: {
3809
+ runtimeDir: {
3810
+ type: "string",
3811
+ description: "Runtime directory (default: .clawdlets)."
3812
+ },
3813
+ host: {
3814
+ type: "string",
3815
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3816
+ },
3817
+ rev: {
3818
+ type: "string",
3819
+ description: "Git rev to pin (HEAD/sha/tag).",
3820
+ default: "HEAD"
3821
+ },
3822
+ toplevel: {
3823
+ type: "string",
3824
+ description: "NixOS system toplevel store path (skip build)."
3825
+ },
3826
+ out: {
3827
+ type: "string",
3828
+ description: "Output manifest path (default: deploy-manifest.<host>.json)."
3829
+ },
3830
+ nixBin: {
3831
+ type: "string",
3832
+ description: "Override nix binary (default: nix)."
3833
+ }
3834
+ },
3835
+ async run({ args }) {
3836
+ const cwd = process$1.cwd();
3837
+ const ctx = loadHostContextOrExit({
3838
+ cwd,
3839
+ runtimeDir: args.runtimeDir,
3840
+ hostArg: args.host
3841
+ });
3842
+ if (!ctx) return;
3843
+ const { repoRoot, layout, hostName } = ctx;
3844
+ const revRaw = String(args.rev || "").trim() || "HEAD";
3845
+ const resolved = await resolveGitRev(layout.repoRoot, revRaw);
3846
+ if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
3847
+ const rev = requireRev(resolved);
3848
+ const nixBin = String(args.nixBin || process$1.env.NIX_BIN || "nix").trim() || "nix";
3849
+ const toplevelArg = String(args.toplevel || "").trim();
3850
+ if (!toplevelArg) requireLinuxForLocalNixosBuild({
3851
+ platform: process$1.platform,
3852
+ command: "clawdlets server manifest"
3853
+ });
3854
+ const toplevel = toplevelArg ? requireToplevel(toplevelArg) : await buildToplevel({
3855
+ repoRoot,
3856
+ nixBin,
3857
+ host: hostName
3858
+ });
3859
+ const { tarPath: tarLocal, digest } = await createSecretsTar({
3860
+ hostName,
3861
+ localDir: getHostSecretsDir(layout, hostName)
3862
+ });
3863
+ try {
3864
+ const manifest = {
3865
+ rev,
3866
+ host: hostName,
3867
+ toplevel,
3868
+ secretsDigest: digest
3869
+ };
3870
+ const outRaw = String(args.out || "").trim();
3871
+ const outPath = outRaw ? path.isAbsolute(outRaw) ? outRaw : path.resolve(cwd, outRaw) : path.join(cwd, `deploy-manifest.${hostName}.json`);
3872
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
3873
+ fs.writeFileSync(outPath, formatDeployManifest(manifest), "utf8");
3874
+ console.log(`ok: wrote deploy manifest ${outPath}`);
3875
+ } finally {
3876
+ try {
3877
+ if (fs.existsSync(tarLocal)) fs.unlinkSync(tarLocal);
3878
+ } catch {}
3879
+ }
3880
+ }
3881
+ });
3882
+
3883
+ //#endregion
3884
+ //#region src/commands/server.ts
3885
+ function normalizeSince(value) {
3886
+ const v = value.trim();
3887
+ const m = v.match(/^(\d+)\s*([smhd])$/i);
3888
+ if (!m) return v;
3889
+ const n = Number(m[1]);
3890
+ const unit = String(m[2]).toLowerCase();
3891
+ if (!Number.isFinite(n) || n <= 0) return v;
3892
+ if (unit === "s") return `${n} sec ago`;
3893
+ if (unit === "m") return `${n} min ago`;
3894
+ if (unit === "h") return `${n} hour ago`;
3895
+ if (unit === "d") return `${n} day ago`;
3896
+ return v;
3897
+ }
3898
+ function normalizeClawdbotUnit(value) {
3899
+ const v = value.trim();
3900
+ if (v === "clawdbot-*.service") return v;
3901
+ if (/^clawdbot-[A-Za-z0-9._-]+$/.test(v)) return `${v}.service`;
3902
+ if (/^clawdbot-[A-Za-z0-9._-]+\.service$/.test(v)) return v;
3903
+ throw new Error(`invalid --unit: ${v} (expected clawdbot-<id>[.service] or clawdbot-*.service)`);
3904
+ }
3905
+ function parseSystemctlShow(output) {
3906
+ const out = {};
3907
+ for (const line of output.split("\n")) {
3908
+ const idx = line.indexOf("=");
3909
+ if (idx <= 0) continue;
3910
+ const key = line.slice(0, idx);
3911
+ if (key in out) continue;
3912
+ out[key] = line.slice(idx + 1);
3913
+ }
3914
+ return out;
3915
+ }
3916
+ async function trySshCapture(targetHost, remoteCmd, opts = {}) {
3917
+ try {
3918
+ return {
3919
+ ok: true,
3920
+ out: await sshCapture(targetHost, remoteCmd, opts)
3921
+ };
3922
+ } catch (e) {
3923
+ return {
3924
+ ok: false,
3925
+ out: String(e?.message || e)
3926
+ };
3927
+ }
3928
+ }
3929
+ const serverAudit = defineCommand({
3930
+ meta: {
3931
+ name: "audit",
3932
+ description: "Audit host invariants over SSH (tailscale, clawdbot services)."
3933
+ },
3934
+ args: {
3935
+ runtimeDir: {
3936
+ type: "string",
3937
+ description: "Runtime directory (default: .clawdlets)."
3938
+ },
3939
+ host: {
3940
+ type: "string",
3941
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
3942
+ },
3943
+ targetHost: {
3944
+ type: "string",
3945
+ description: "SSH target override (default: from clawdlets.json)."
3946
+ },
3947
+ sshTty: {
3948
+ type: "boolean",
3949
+ description: "Allocate TTY for sudo prompts.",
3950
+ default: true
3951
+ },
3952
+ json: {
3953
+ type: "boolean",
3954
+ description: "Output JSON.",
3955
+ default: false
3956
+ }
3957
+ },
3958
+ async run({ args }) {
3959
+ const ctx = loadHostContextOrExit({
3960
+ cwd: process$1.cwd(),
3961
+ runtimeDir: args.runtimeDir,
3962
+ hostArg: args.host
3963
+ });
3964
+ if (!ctx) return;
3965
+ const { config: config$1, hostName, hostCfg } = ctx;
3966
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
3967
+ const sudo = needsSudo(targetHost);
3968
+ const bots = config$1.fleet.botOrder ?? [];
3969
+ const checks = [];
3970
+ const add$3 = (c) => checks.push(c);
3971
+ const must = async (label, cmd) => {
3972
+ const out = await trySshCapture(targetHost, cmd, { tty: sudo && args.sshTty });
3973
+ if (!out.ok) {
3974
+ add$3({
3975
+ status: "missing",
3976
+ label,
3977
+ detail: out.out
3978
+ });
3979
+ return null;
3980
+ }
3981
+ return out.out;
3982
+ };
3983
+ if (hostCfg.tailnet?.mode === "tailscale") {
3984
+ const tailscaled = await must("tailscale service", [
3985
+ ...sudo ? ["sudo"] : [],
3986
+ "systemctl",
3987
+ "show",
3988
+ "tailscaled.service"
3989
+ ].join(" "));
3990
+ if (tailscaled) {
3991
+ const parsed = parseSystemctlShow(tailscaled);
3992
+ add$3({
3993
+ status: parsed.ActiveState === "active" ? "ok" : "missing",
3994
+ label: "tailscale service state",
3995
+ detail: `${parsed.ActiveState || "?"}/${parsed.SubState || "?"}`
3996
+ });
3997
+ }
3998
+ const autoconnect = await must("tailscale autoconnect", [
3999
+ ...sudo ? ["sudo"] : [],
4000
+ "systemctl",
4001
+ "show",
4002
+ "tailscaled-autoconnect.service"
4003
+ ].join(" "));
4004
+ if (autoconnect) {
4005
+ const parsed = parseSystemctlShow(autoconnect);
4006
+ add$3({
4007
+ status: parsed.ActiveState === "active" ? "ok" : "missing",
4008
+ label: "tailscale autoconnect state",
4009
+ detail: `${parsed.ActiveState || "?"}/${parsed.SubState || "?"}`
4010
+ });
4011
+ }
4012
+ }
4013
+ if (Array.isArray(bots) && bots.length > 0) add$3({
4014
+ status: "ok",
4015
+ label: "fleet bots list",
4016
+ detail: bots.join(", ")
4017
+ });
4018
+ else add$3({
4019
+ status: "warn",
4020
+ label: "fleet bots list",
4021
+ detail: "(empty)"
4022
+ });
4023
+ for (const bot$1 of bots) {
4024
+ const unit = normalizeClawdbotUnit(`clawdbot-${String(bot$1).trim()}`);
4025
+ const show$2 = await must(`systemctl show ${unit}`, [
4026
+ ...sudo ? ["sudo"] : [],
4027
+ "systemctl",
4028
+ "show",
4029
+ shellQuote(unit)
4030
+ ].join(" "));
4031
+ if (!show$2) continue;
4032
+ const parsed = parseSystemctlShow(show$2);
4033
+ const loadState = parsed.LoadState || "";
4034
+ const activeState = parsed.ActiveState || "";
4035
+ const subState = parsed.SubState || "";
4036
+ if (loadState && loadState !== "loaded") add$3({
4037
+ status: "missing",
4038
+ label: `${unit} load state`,
4039
+ detail: `LoadState=${loadState}`
4040
+ });
4041
+ else if (activeState === "active" && subState === "running") add$3({
4042
+ status: "ok",
4043
+ label: `${unit} state`,
4044
+ detail: `${activeState}/${subState}`
4045
+ });
4046
+ else add$3({
4047
+ status: "missing",
4048
+ label: `${unit} state`,
4049
+ detail: `${activeState || "?"}/${subState || "?"}`
4050
+ });
4051
+ }
4052
+ if (args.json) console.log(JSON.stringify({
4053
+ host: hostName,
4054
+ targetHost,
4055
+ checks
4056
+ }, null, 2));
4057
+ else for (const c of checks) console.log(`${c.status}: ${c.label}${c.detail ? ` (${c.detail})` : ""}`);
4058
+ if (checks.some((c) => c.status === "missing")) process$1.exitCode = 1;
4059
+ }
4060
+ });
4061
+ const serverStatus = defineCommand({
4062
+ meta: {
4063
+ name: "status",
4064
+ description: "Show systemd status for Clawdbot services."
4065
+ },
4066
+ args: {
4067
+ runtimeDir: {
4068
+ type: "string",
4069
+ description: "Runtime directory (default: .clawdlets)."
4070
+ },
4071
+ host: {
4072
+ type: "string",
4073
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
4074
+ },
4075
+ targetHost: {
4076
+ type: "string",
4077
+ description: "SSH target override (default: from clawdlets.json)."
4078
+ },
4079
+ sshTty: {
4080
+ type: "boolean",
4081
+ description: "Allocate TTY for sudo prompts.",
4082
+ default: true
4083
+ }
4084
+ },
4085
+ async run({ args }) {
4086
+ const ctx = loadHostContextOrExit({
4087
+ cwd: process$1.cwd(),
4088
+ runtimeDir: args.runtimeDir,
4089
+ hostArg: args.host
4090
+ });
4091
+ if (!ctx) return;
4092
+ const { hostName, hostCfg } = ctx;
4093
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
4094
+ const sudo = needsSudo(targetHost);
4095
+ const out = await sshCapture(targetHost, [
4096
+ ...sudo ? ["sudo"] : [],
4097
+ "systemctl",
4098
+ "list-units",
4099
+ "clawdbot-*.service",
4100
+ "--no-pager"
4101
+ ].join(" "), { tty: sudo && args.sshTty });
4102
+ console.log(out);
4103
+ }
4104
+ });
4105
+ const serverLogs = defineCommand({
4106
+ meta: {
4107
+ name: "logs",
4108
+ description: "Stream or print logs via journalctl."
4109
+ },
4110
+ args: {
4111
+ runtimeDir: {
4112
+ type: "string",
4113
+ description: "Runtime directory (default: .clawdlets)."
4114
+ },
4115
+ host: {
4116
+ type: "string",
4117
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
4118
+ },
4119
+ targetHost: {
4120
+ type: "string",
4121
+ description: "SSH target override (default: from clawdlets.json)."
4122
+ },
4123
+ unit: {
4124
+ type: "string",
4125
+ description: "systemd unit (default: clawdbot-*.service).",
4126
+ default: "clawdbot-*.service"
4127
+ },
4128
+ lines: {
4129
+ type: "string",
4130
+ description: "Number of lines (default: 200).",
4131
+ default: "200"
4132
+ },
4133
+ since: {
4134
+ type: "string",
4135
+ description: "Time window (supports 5m/1h/2d or journalctl syntax)."
4136
+ },
4137
+ follow: {
4138
+ type: "boolean",
4139
+ description: "Follow logs.",
4140
+ default: false
4141
+ },
4142
+ sshTty: {
4143
+ type: "boolean",
4144
+ description: "Allocate TTY for sudo prompts.",
4145
+ default: true
4146
+ }
4147
+ },
4148
+ async run({ args }) {
4149
+ const ctx = loadHostContextOrExit({
4150
+ cwd: process$1.cwd(),
4151
+ runtimeDir: args.runtimeDir,
4152
+ hostArg: args.host
4153
+ });
4154
+ if (!ctx) return;
4155
+ const { hostName, hostCfg } = ctx;
4156
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
4157
+ const sudo = needsSudo(targetHost);
4158
+ const unit = normalizeClawdbotUnit(String(args.unit || "clawdbot-*.service"));
4159
+ const since = args.since ? normalizeSince(String(args.since)) : "";
4160
+ const n = String(args.lines || "200").trim() || "200";
4161
+ if (!/^\d+$/.test(n) || Number(n) <= 0) throw new Error(`invalid --lines: ${n}`);
4162
+ await sshRun(targetHost, [
4163
+ ...sudo ? ["sudo"] : [],
4164
+ "journalctl",
4165
+ "-u",
4166
+ shellQuote(unit),
4167
+ "-n",
4168
+ shellQuote(n),
4169
+ ...since ? ["--since", shellQuote(since)] : [],
4170
+ ...args.follow ? ["-f"] : [],
4171
+ "--no-pager"
4172
+ ].join(" "), { tty: sudo && args.sshTty });
4173
+ }
4174
+ });
4175
+ const serverRestart = defineCommand({
4176
+ meta: {
4177
+ name: "restart",
4178
+ description: "Restart a systemd unit (default: clawdbot-*.service)."
4179
+ },
4180
+ args: {
4181
+ runtimeDir: {
4182
+ type: "string",
4183
+ description: "Runtime directory (default: .clawdlets)."
4184
+ },
4185
+ host: {
4186
+ type: "string",
4187
+ description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
4188
+ },
4189
+ targetHost: {
4190
+ type: "string",
4191
+ description: "SSH target override (default: from clawdlets.json)."
4192
+ },
4193
+ unit: {
4194
+ type: "string",
4195
+ description: "systemd unit (default: clawdbot-*.service).",
4196
+ default: "clawdbot-*.service"
4197
+ },
4198
+ sshTty: {
4199
+ type: "boolean",
4200
+ description: "Allocate TTY for sudo prompts.",
4201
+ default: true
4202
+ }
4203
+ },
4204
+ async run({ args }) {
4205
+ const ctx = loadHostContextOrExit({
4206
+ cwd: process$1.cwd(),
4207
+ runtimeDir: args.runtimeDir,
4208
+ hostArg: args.host
4209
+ });
4210
+ if (!ctx) return;
4211
+ const { hostName, hostCfg } = ctx;
4212
+ const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
4213
+ const unit = String(args.unit || "clawdbot-*.service").trim() || "clawdbot-*.service";
4214
+ const sudo = needsSudo(targetHost);
4215
+ await sshRun(targetHost, [
4216
+ ...sudo ? ["sudo"] : [],
4217
+ "systemctl",
4218
+ "restart",
4219
+ shellQuote(unit)
4220
+ ].join(" "), { tty: sudo && args.sshTty });
4221
+ }
4222
+ });
4223
+ const server = defineCommand({
4224
+ meta: {
4225
+ name: "server",
4226
+ description: "Server operations via SSH (deploy/logs/status)."
4227
+ },
4228
+ subCommands: {
4229
+ audit: serverAudit,
4230
+ deploy: serverDeploy,
4231
+ manifest: serverManifest,
4232
+ status: serverStatus,
4233
+ logs: serverLogs,
4234
+ "github-sync": serverGithubSync,
4235
+ restart: serverRestart
4236
+ }
4237
+ });
4238
+
4239
+ //#endregion
4240
+ //#region src/commands/registry.ts
4241
+ const baseCommands = {
4242
+ bot,
4243
+ bootstrap,
4244
+ config,
4245
+ doctor,
4246
+ env,
4247
+ host,
4248
+ fleet,
4249
+ image,
4250
+ infra,
4251
+ lockdown,
4252
+ plugin,
4253
+ project,
4254
+ secrets,
4255
+ server
4256
+ };
4257
+ const baseCommandNames = Object.freeze(Object.keys(baseCommands));
4258
+
4259
+ //#endregion
4260
+ //#region src/lib/plugins.ts
4261
+ var plugins_exports = /* @__PURE__ */ __exportAll({
4262
+ findPluginByCommand: () => findPluginByCommand,
4263
+ installPlugin: () => installPlugin,
4264
+ listInstalledPlugins: () => listInstalledPlugins,
4265
+ listReservedCommands: () => listReservedCommands,
4266
+ loadPluginCommand: () => loadPluginCommand,
4267
+ removePlugin: () => removePlugin
4268
+ });
4269
+ const PLUGIN_MANIFEST = "clawdlets-plugin.json";
4270
+ const RESERVED_COMMANDS = new Set(baseCommandNames);
4271
+ const SAFE_SLUG_RE = /^[a-z][a-z0-9_-]*$/;
4272
+ const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/;
4273
+ function readJsonFile(filePath) {
4274
+ const raw = fs.readFileSync(filePath, "utf8");
4275
+ return JSON.parse(raw);
4276
+ }
4277
+ function writeJsonFile(filePath, data) {
4278
+ const dir = path.dirname(filePath);
4279
+ fs.mkdirSync(dir, { recursive: true });
4280
+ const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}`);
4281
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
4282
+ fs.renameSync(tmp, filePath);
4283
+ }
4284
+ function assertSafeSlug(value) {
4285
+ if (!SAFE_SLUG_RE.test(value)) throw new Error(`invalid plugin command: ${value} (expected [a-z][a-z0-9_-]*)`);
4286
+ }
4287
+ function isReservedCommand(value) {
4288
+ return RESERVED_COMMANDS.has(value);
4289
+ }
4290
+ function assertCommandName(value) {
4291
+ assertSafeSlug(value);
4292
+ if (isReservedCommand(value)) throw new Error(`plugin command reserved: ${value}`);
4293
+ }
4294
+ function resolvePluginsDir(params) {
4295
+ return getRepoLayout(findRepoRoot(params.cwd), params.runtimeDir).pluginsDir;
4296
+ }
4297
+ function resolveInstallDir(params) {
4298
+ return path.join(params.pluginsDir, params.slug);
4299
+ }
4300
+ function resolvePackageDir(params) {
4301
+ return path.join(params.installDir, "node_modules", ...params.packageName.split("/"));
4302
+ }
4303
+ function readPluginPackageMeta(packageDir) {
4304
+ const pkgPath = path.join(packageDir, "package.json");
4305
+ if (!fs.existsSync(pkgPath)) throw new Error(`plugin package.json missing: ${pkgPath}`);
4306
+ const pkg = readJsonFile(pkgPath);
4307
+ const command = String(pkg?.clawdlets?.command || "").trim();
4308
+ const entry = String(pkg?.clawdlets?.entry || "").trim();
4309
+ if (!command) throw new Error(`plugin missing clawdlets.command in ${pkgPath}`);
4310
+ if (!entry) throw new Error(`plugin missing clawdlets.entry in ${pkgPath}`);
4311
+ assertCommandName(command);
4312
+ return {
4313
+ command,
4314
+ entry
4315
+ };
4316
+ }
4317
+ function assertPackageName(value) {
4318
+ if (!PACKAGE_NAME_RE.test(value)) throw new Error(`invalid plugin package name: ${value}`);
4319
+ }
4320
+ function resolveManifestPath(installDir) {
4321
+ return path.join(installDir, PLUGIN_MANIFEST);
4322
+ }
4323
+ function normalizeManifest(slug, manifest) {
4324
+ if (!manifest || typeof manifest !== "object") throw new Error("plugin manifest invalid");
4325
+ const packageName = String(manifest.packageName || "").trim();
4326
+ const version = String(manifest.version || "").trim();
4327
+ const command = String(manifest.command || "").trim();
4328
+ const entry = String(manifest.entry || "").trim();
4329
+ if (!packageName) throw new Error("plugin manifest missing packageName");
4330
+ assertPackageName(packageName);
4331
+ if (!version) throw new Error("plugin manifest missing version");
4332
+ if (!command) throw new Error("plugin manifest missing command");
4333
+ if (!entry) throw new Error("plugin manifest missing entry");
4334
+ assertCommandName(command);
4335
+ if (command !== slug) throw new Error(`plugin manifest command mismatch (expected ${slug}, got ${command})`);
4336
+ return {
4337
+ slug,
4338
+ packageName,
4339
+ version,
4340
+ command,
4341
+ entry
4342
+ };
4343
+ }
4344
+ function readPluginManifest(installDir, slug) {
4345
+ return normalizeManifest(slug, readJsonFile(resolveManifestPath(installDir)));
4346
+ }
4347
+ function writePluginManifest(installDir, manifest) {
4348
+ writeJsonFile(resolveManifestPath(installDir), manifest);
4349
+ }
4350
+ function deriveManifestFromInstall(installDir, slug) {
4351
+ const pkgPath = path.join(installDir, "package.json");
4352
+ if (!fs.existsSync(pkgPath)) throw new Error(`plugin install missing package.json: ${pkgPath}`);
4353
+ const pkg = readJsonFile(pkgPath);
4354
+ const deps = Object.keys(pkg.dependencies || {});
4355
+ if (deps.length !== 1) throw new Error(`plugin install must declare exactly one dependency (found ${deps.length})`);
4356
+ const packageName = deps[0] || "";
4357
+ if (!packageName) throw new Error("plugin dependency missing");
4358
+ assertPackageName(packageName);
4359
+ const packageDir = resolvePackageDir({
4360
+ installDir,
4361
+ packageName
4362
+ });
4363
+ const meta = readPluginPackageMeta(packageDir);
4364
+ if (meta.command !== slug) throw new Error(`plugin command mismatch: expected ${slug} got ${meta.command}`);
4365
+ const pluginPkgPath = path.join(packageDir, "package.json");
4366
+ const pluginPkg = readJsonFile(pluginPkgPath);
4367
+ const version = String(pluginPkg.version || "").trim();
4368
+ if (!version) throw new Error(`plugin version missing in ${pluginPkgPath}`);
4369
+ return {
4370
+ slug,
4371
+ packageName,
4372
+ version,
4373
+ command: meta.command,
4374
+ entry: meta.entry
4375
+ };
4376
+ }
4377
+ function listInstalledPlugins(params) {
4378
+ const pluginsDir = resolvePluginsDir(params);
4379
+ if (!fs.existsSync(pluginsDir)) return [];
4380
+ const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
4381
+ const out = [];
4382
+ for (const ent of entries) {
4383
+ if (!ent.isDirectory()) continue;
4384
+ const slug = ent.name;
4385
+ if (slug.startsWith(".")) continue;
4386
+ try {
4387
+ assertSafeSlug(slug);
4388
+ const installDir = resolveInstallDir({
4389
+ pluginsDir,
4390
+ slug
4391
+ });
4392
+ let manifest;
4393
+ try {
4394
+ manifest = readPluginManifest(installDir, slug);
4395
+ } catch {
4396
+ manifest = deriveManifestFromInstall(installDir, slug);
4397
+ writePluginManifest(installDir, manifest);
4398
+ }
4399
+ const packageDir = resolvePackageDir({
4400
+ installDir,
4401
+ packageName: manifest.packageName
4402
+ });
4403
+ out.push({
4404
+ ...manifest,
4405
+ installDir,
4406
+ packageDir
4407
+ });
4408
+ } catch (error) {
4409
+ params.onError?.({
4410
+ slug,
4411
+ error: error instanceof Error ? error : new Error(String(error))
4412
+ });
4413
+ continue;
4414
+ }
4415
+ }
4416
+ return out.sort((a, b) => a.command.localeCompare(b.command));
4417
+ }
4418
+ function findPluginByCommand(params) {
4419
+ const cmd = params.command.trim();
4420
+ if (!cmd || cmd.startsWith("-") || isReservedCommand(cmd)) return null;
4421
+ return listInstalledPlugins(params).find((p$1) => p$1.command === cmd) || null;
4422
+ }
4423
+ async function loadPluginCommand(plugin$1) {
4424
+ const entryRel = plugin$1.entry.trim();
4425
+ if (!entryRel) throw new Error(`plugin entry empty for ${plugin$1.command}`);
4426
+ if (path.isAbsolute(entryRel)) throw new Error(`plugin entry must be relative: ${entryRel}`);
4427
+ if (entryRel.split(/[/\\\\]+/).includes("..")) throw new Error(`plugin entry must not contain .. segments: ${entryRel}`);
4428
+ const entryPath = path.resolve(plugin$1.packageDir, entryRel);
4429
+ const entryRelPath = path.relative(plugin$1.packageDir, entryPath);
4430
+ if (entryRelPath.startsWith("..") || path.isAbsolute(entryRelPath)) throw new Error(`plugin entry escapes package: ${entryRel}`);
4431
+ if (!fs.existsSync(entryPath)) throw new Error(`plugin entry missing: ${entryPath}`);
4432
+ const mod = await import(pathToFileURL(entryPath).href);
4433
+ const command = mod.command || mod.plugin?.command || mod.default?.command || mod.default;
4434
+ if (!command) throw new Error(`plugin entry ${entryPath} does not export a command`);
4435
+ return command;
4436
+ }
4437
+ async function installPlugin(params) {
4438
+ const pluginsDir = resolvePluginsDir(params);
4439
+ const slug = params.slug.trim();
4440
+ if (!slug) throw new Error("plugin name required");
4441
+ assertCommandName(slug);
4442
+ const packageName = params.packageName.trim();
4443
+ if (!packageName) throw new Error("package name required");
4444
+ assertPackageName(packageName);
4445
+ if (!params.allowThirdParty && !packageName.startsWith("@clawdlets/")) throw new Error("third-party plugins disabled (pass --allow-third-party to override)");
4446
+ const installDir = resolveInstallDir({
4447
+ pluginsDir,
4448
+ slug
4449
+ });
4450
+ if (fs.existsSync(installDir) && fs.readdirSync(installDir).length > 0) throw new Error(`plugin already installed: ${slug} (${installDir})`);
4451
+ fs.mkdirSync(installDir, { recursive: true });
4452
+ const depVersion = params.version?.trim() || "latest";
4453
+ const pkgJson = {
4454
+ name: `clawdlets-plugin-${slug}`,
4455
+ private: true,
4456
+ type: "module",
4457
+ description: `clawdlets plugin install (${slug})`,
4458
+ dependencies: { [packageName]: depVersion }
4459
+ };
4460
+ writeJsonFile(path.join(installDir, "package.json"), pkgJson);
4461
+ await run("npm", [
4462
+ "install",
4463
+ "--omit=dev",
4464
+ "--no-audit",
4465
+ "--no-fund"
4466
+ ], { cwd: installDir });
4467
+ const manifest = deriveManifestFromInstall(installDir, slug);
4468
+ writePluginManifest(installDir, manifest);
4469
+ const packageDir = resolvePackageDir({
4470
+ installDir,
4471
+ packageName: manifest.packageName
4472
+ });
4473
+ return {
4474
+ ...manifest,
4475
+ installDir,
4476
+ packageDir
4477
+ };
4478
+ }
4479
+ function removePlugin(params) {
4480
+ const pluginsDir = resolvePluginsDir(params);
4481
+ const slug = params.slug.trim();
4482
+ if (!slug) throw new Error("plugin name required");
4483
+ assertSafeSlug(slug);
4484
+ const installDir = resolveInstallDir({
4485
+ pluginsDir,
4486
+ slug
4487
+ });
4488
+ const rel = path.relative(pluginsDir, installDir);
4489
+ if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`plugin path escapes plugins dir: ${installDir}`);
4490
+ if (!fs.existsSync(installDir)) throw new Error(`plugin not installed: ${slug}`);
4491
+ fs.rmSync(installDir, {
4492
+ recursive: true,
4493
+ force: true
4494
+ });
4495
+ }
4496
+ function listReservedCommands() {
4497
+ return [...RESERVED_COMMANDS].sort();
4498
+ }
4499
+
4500
+ //#endregion
4501
+ //#region src/lib/version.ts
4502
+ function resolvePackageRoot(fromUrl = import.meta.url) {
4503
+ let dir = path.dirname(fileURLToPath(fromUrl));
4504
+ for (let i = 0; i < 5; i += 1) {
4505
+ const candidate = path.join(dir, "package.json");
4506
+ if (fs.existsSync(candidate)) return dir;
4507
+ const parent = path.dirname(dir);
4508
+ if (parent === dir) break;
4509
+ dir = parent;
4510
+ }
4511
+ return path.dirname(fileURLToPath(fromUrl));
4512
+ }
4513
+ function readCliVersion(rootDir = resolvePackageRoot()) {
4514
+ const pkgPath = path.join(rootDir, "package.json");
4515
+ const raw = fs.readFileSync(pkgPath, "utf8");
4516
+ const parsed = JSON.parse(raw);
4517
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) throw new Error("missing version in package.json");
4518
+ return parsed.version;
4519
+ }
4520
+
4521
+ //#endregion
4522
+ //#region src/main.ts
4523
+ const main = defineCommand({
4524
+ meta: {
4525
+ name: "clawdlets",
4526
+ description: "Clawdbot fleet helper (CLI-first; runtime state in .clawdlets/; secrets in /secrets)."
4527
+ },
4528
+ subCommands: baseCommands
4529
+ });
4530
+ function resolveRuntimeDir(rawArgs) {
4531
+ for (let i = 0; i < rawArgs.length; i += 1) {
4532
+ const arg = rawArgs[i] || "";
4533
+ if (arg === "--runtime-dir" || arg === "--runtimeDir") {
4534
+ const next = rawArgs[i + 1];
4535
+ if (next) return String(next);
4536
+ }
4537
+ if (arg.startsWith("--runtime-dir=")) return arg.slice(14);
4538
+ if (arg.startsWith("--runtimeDir=")) return arg.slice(13);
4539
+ }
4540
+ }
4541
+ function findCommandToken(rawArgs) {
4542
+ for (let i = 0; i < rawArgs.length; i += 1) {
4543
+ const arg = rawArgs[i];
4544
+ if (!arg) continue;
4545
+ if (arg === "--") continue;
4546
+ if (arg === "--runtime-dir" || arg === "--runtimeDir") {
4547
+ i += 1;
4548
+ continue;
4549
+ }
4550
+ if (arg.startsWith("--runtime-dir=") || arg.startsWith("--runtimeDir=")) continue;
4551
+ if (arg.startsWith("-")) continue;
4552
+ return {
4553
+ index: i,
4554
+ command: arg
4555
+ };
4556
+ }
4557
+ return null;
4558
+ }
4559
+ async function mainEntry() {
4560
+ const [nodeBin, script, ...rest] = process.argv;
4561
+ const normalized = rest.filter((a) => a !== "--");
4562
+ if (normalized.includes("--version") || normalized.includes("-v")) {
4563
+ console.log(readCliVersion());
4564
+ process.exit(0);
4565
+ return;
4566
+ }
4567
+ process.argv = [
4568
+ nodeBin,
4569
+ script,
4570
+ ...normalized
4571
+ ];
4572
+ const runtimeDir = resolveRuntimeDir(normalized);
4573
+ const commandToken = findCommandToken(normalized);
4574
+ const command = commandToken?.command ?? "";
4575
+ const pluginMatch = findPluginByCommand({
4576
+ cwd: process.cwd(),
4577
+ runtimeDir,
4578
+ command
4579
+ });
4580
+ if (pluginMatch) {
4581
+ await runMain(await loadPluginCommand(pluginMatch), { rawArgs: commandToken ? normalized.slice(commandToken.index + 1) : [] });
4582
+ return;
4583
+ }
4584
+ await runMain(main);
4585
+ }
4586
+ mainEntry();
4587
+
4588
+ //#endregion
4589
+ export { };