@fastagent-sh/fastagent 0.29.0 → 0.30.1

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 (312) hide show
  1. package/README.md +9 -4
  2. package/dist/atomic-write.d.ts +4 -5
  3. package/dist/atomic-write.js +4 -5
  4. package/dist/authoring.d.ts +32 -15
  5. package/dist/authoring.js +147 -127
  6. package/dist/channels/agentcore-protocol.d.ts +8 -3
  7. package/dist/channels/agentcore-protocol.js +3 -1
  8. package/dist/channels/agentcore-service.d.ts +1 -0
  9. package/dist/channels/agentcore-service.js +14 -4
  10. package/dist/channels/agentcore.d.ts +9 -0
  11. package/dist/channels/agentcore.js +29 -4
  12. package/dist/channels/context.d.ts +2 -0
  13. package/dist/channels/context.js +5 -1
  14. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  15. package/dist/channels/feishu/feishu.d.ts +0 -3
  16. package/dist/channels/feishu/feishu.js +10 -10
  17. package/dist/channels/feishu/register-app.js +3 -5
  18. package/dist/channels/feishu/scaffold/channel.ts +32 -34
  19. package/dist/channels/feishu/scaffold/feishu-send.ts +5 -5
  20. package/dist/channels/feishu/scaffold/feishu-threads.ts +3 -3
  21. package/dist/channels/feishu/setup-mode.d.ts +2 -12
  22. package/dist/channels/feishu/setup-mode.js +2 -22
  23. package/dist/channels/feishu/setup.d.ts +12 -0
  24. package/dist/channels/feishu/setup.js +197 -0
  25. package/dist/channels/kit/abortable.d.ts +8 -0
  26. package/dist/channels/kit/abortable.js +22 -0
  27. package/dist/channels/lark/lark.d.ts +0 -3
  28. package/dist/channels/lark/lark.js +0 -5
  29. package/dist/channels/lark/scaffold/channel.ts +33 -35
  30. package/dist/channels/lark/scaffold/lark-send.ts +5 -5
  31. package/dist/channels/lark/scaffold/lark-threads.ts +3 -3
  32. package/dist/channels/lark/setup.d.ts +9 -0
  33. package/dist/channels/lark/setup.js +57 -0
  34. package/dist/channels/setup.d.ts +112 -0
  35. package/dist/channels/setup.js +31 -0
  36. package/dist/channels/slack/history.js +1 -1
  37. package/dist/channels/slack/manifest.d.ts +17 -7
  38. package/dist/channels/slack/manifest.js +12 -16
  39. package/dist/channels/slack/scaffold/channel.ts +44 -30
  40. package/dist/channels/slack/scaffold/slack-send.ts +8 -8
  41. package/dist/channels/slack/scaffold/slack-threads.ts +4 -4
  42. package/dist/channels/slack/setup.d.ts +7 -0
  43. package/dist/channels/slack/setup.js +119 -0
  44. package/dist/channels/slack/slack-api.d.ts +2 -0
  45. package/dist/channels/slack/slack-api.js +4 -0
  46. package/dist/channels/slack/slack.d.ts +21 -2
  47. package/dist/channels/slack/slack.js +398 -338
  48. package/dist/channels/slack/socket.d.ts +57 -0
  49. package/dist/channels/slack/socket.js +305 -0
  50. package/dist/channels/telegram/polling.d.ts +27 -0
  51. package/dist/channels/telegram/polling.js +105 -0
  52. package/dist/channels/telegram/scaffold/channel.ts +52 -32
  53. package/dist/channels/telegram/scaffold/telegram-send.ts +9 -9
  54. package/dist/channels/telegram/setup.d.ts +2 -0
  55. package/dist/channels/telegram/setup.js +42 -0
  56. package/dist/channels/telegram/telegram-api.d.ts +27 -1
  57. package/dist/channels/telegram/telegram-api.js +7 -6
  58. package/dist/channels/telegram/telegram.d.ts +11 -3
  59. package/dist/channels/telegram/telegram.js +184 -157
  60. package/dist/channels/webhook-evidence.d.ts +11 -0
  61. package/dist/channels/webhook-evidence.js +71 -0
  62. package/dist/channels/webhooks.d.ts +37 -0
  63. package/dist/channels/webhooks.js +70 -0
  64. package/dist/child-env.d.ts +16 -0
  65. package/dist/child-env.js +30 -0
  66. package/dist/cli/box-login.js +4 -1
  67. package/dist/cli/channel-app.d.ts +3 -0
  68. package/dist/cli/channel-app.js +35 -0
  69. package/dist/cli/channel-setup.d.ts +38 -0
  70. package/dist/cli/channel-setup.js +195 -0
  71. package/dist/cli/commands/add.d.ts +6 -14
  72. package/dist/cli/commands/add.js +45 -303
  73. package/dist/cli/commands/auth.js +8 -2
  74. package/dist/cli/commands/build.d.ts +5 -0
  75. package/dist/cli/commands/build.js +16 -0
  76. package/dist/cli/commands/channels.d.ts +19 -0
  77. package/dist/cli/commands/channels.js +230 -0
  78. package/dist/cli/commands/content.d.ts +7 -3
  79. package/dist/cli/commands/content.js +16 -10
  80. package/dist/cli/commands/deploy/agentcore.js +55 -23
  81. package/dist/cli/commands/deploy/channel-apps.d.ts +17 -0
  82. package/dist/cli/commands/deploy/channel-apps.js +30 -0
  83. package/dist/cli/commands/deploy/docker.js +32 -65
  84. package/dist/cli/commands/deploy/fly.js +28 -12
  85. package/dist/cli/commands/deploy/railway.js +38 -13
  86. package/dist/cli/commands/deploy/secrets-step.d.ts +19 -0
  87. package/dist/cli/commands/deploy/secrets-step.js +66 -0
  88. package/dist/cli/commands/deploy/shared.d.ts +16 -5
  89. package/dist/cli/commands/deploy/shared.js +25 -8
  90. package/dist/cli/commands/deploy.js +53 -60
  91. package/dist/cli/commands/dev.d.ts +2 -0
  92. package/dist/cli/commands/dev.js +15 -3
  93. package/dist/cli/commands/env.js +1 -1
  94. package/dist/cli/commands/info.d.ts +2 -0
  95. package/dist/cli/commands/info.js +60 -25
  96. package/dist/cli/commands/init.js +3 -1
  97. package/dist/cli/commands/invoke.d.ts +2 -0
  98. package/dist/cli/commands/invoke.js +20 -7
  99. package/dist/cli/commands/login.d.ts +0 -7
  100. package/dist/cli/commands/login.js +22 -10
  101. package/dist/cli/commands/logout.js +1 -1
  102. package/dist/cli/commands/mcp.js +14 -6
  103. package/dist/cli/commands/models.js +3 -2
  104. package/dist/cli/commands/schedules.d.ts +4 -2
  105. package/dist/cli/commands/schedules.js +14 -10
  106. package/dist/cli/commands/secrets.d.ts +14 -0
  107. package/dist/cli/commands/secrets.js +213 -0
  108. package/dist/cli/commands/start.d.ts +14 -14
  109. package/dist/cli/commands/start.js +46 -60
  110. package/dist/cli/content-view.d.ts +4 -6
  111. package/dist/cli/content-view.js +5 -11
  112. package/dist/cli/first-run-channels.d.ts +3 -0
  113. package/dist/cli/first-run-channels.js +63 -0
  114. package/dist/cli/instance.d.ts +41 -0
  115. package/dist/cli/instance.js +155 -0
  116. package/dist/cli/invoke-stream.d.ts +5 -2
  117. package/dist/cli/invoke-stream.js +7 -2
  118. package/dist/cli/kernel.js +3 -0
  119. package/dist/cli/program.js +282 -110
  120. package/dist/cli/serve.d.ts +4 -5
  121. package/dist/cli/serve.js +4 -7
  122. package/dist/cli/shared.d.ts +11 -2
  123. package/dist/cli/shared.js +32 -19
  124. package/dist/cli/ticket.d.ts +7 -0
  125. package/dist/cli/ticket.js +106 -30
  126. package/dist/content/declare.d.ts +31 -37
  127. package/dist/content/declare.js +59 -60
  128. package/dist/content/file.d.ts +23 -12
  129. package/dist/content/file.js +48 -35
  130. package/dist/content/git.d.ts +0 -10
  131. package/dist/content/git.js +5 -39
  132. package/dist/content/resolve.d.ts +18 -22
  133. package/dist/content/resolve.js +32 -66
  134. package/dist/content/source.d.ts +7 -8
  135. package/dist/content/source.js +10 -28
  136. package/dist/credentials-page.d.ts +26 -0
  137. package/dist/credentials-page.js +185 -0
  138. package/dist/declared-secrets.d.ts +10 -3
  139. package/dist/declared-secrets.js +8 -4
  140. package/dist/deploy/agentcore/destroy.d.ts +4 -1
  141. package/dist/deploy/agentcore/destroy.js +25 -1
  142. package/dist/deploy/agentcore/forwarder.js +1 -1
  143. package/dist/deploy/agentcore/plan.d.ts +7 -21
  144. package/dist/deploy/agentcore/plan.js +47 -49
  145. package/dist/deploy/agentcore/run.d.ts +18 -10
  146. package/dist/deploy/agentcore/run.js +93 -70
  147. package/dist/deploy/agentcore/runtime-secrets.d.ts +7 -0
  148. package/dist/deploy/agentcore/runtime-secrets.js +108 -0
  149. package/dist/deploy/agentcore/shell.js +12 -19
  150. package/dist/deploy/agentcore/sigv4.d.ts +7 -0
  151. package/dist/deploy/agentcore/sigv4.js +15 -0
  152. package/dist/deploy/agentcore/slr.d.ts +18 -0
  153. package/dist/deploy/agentcore/slr.js +27 -0
  154. package/dist/deploy/agentcore/store.d.ts +7 -0
  155. package/dist/deploy/agentcore/store.js +115 -0
  156. package/dist/deploy/api.d.ts +14 -14
  157. package/dist/deploy/api.js +26 -24
  158. package/dist/deploy/box-shell.d.ts +3 -1
  159. package/dist/deploy/build-context.d.ts +4 -4
  160. package/dist/deploy/build-context.js +11 -15
  161. package/dist/deploy/channel-ingress.d.ts +35 -9
  162. package/dist/deploy/channel-ingress.js +119 -22
  163. package/dist/deploy/container.d.ts +35 -10
  164. package/dist/deploy/container.js +79 -37
  165. package/dist/deploy/delivered-text.d.ts +13 -0
  166. package/dist/deploy/delivered-text.js +86 -0
  167. package/dist/deploy/docker/plan.d.ts +7 -8
  168. package/dist/deploy/docker/plan.js +31 -52
  169. package/dist/deploy/docker/run.d.ts +5 -43
  170. package/dist/deploy/docker/run.js +9 -84
  171. package/dist/deploy/file-store.d.ts +2 -0
  172. package/dist/deploy/file-store.js +17 -0
  173. package/dist/deploy/fly/plan.d.ts +3 -2
  174. package/dist/deploy/fly/plan.js +20 -9
  175. package/dist/deploy/fly/run.d.ts +18 -8
  176. package/dist/deploy/fly/run.js +24 -42
  177. package/dist/deploy/fly/store.d.ts +12 -0
  178. package/dist/deploy/fly/store.js +83 -0
  179. package/dist/deploy/hosts.d.ts +1 -2
  180. package/dist/deploy/hosts.js +1 -2
  181. package/dist/deploy/preflight.d.ts +18 -5
  182. package/dist/deploy/preflight.js +135 -63
  183. package/dist/deploy/railway/ignore.d.ts +8 -0
  184. package/dist/deploy/railway/ignore.js +57 -0
  185. package/dist/deploy/railway/plan.d.ts +1 -2
  186. package/dist/deploy/railway/plan.js +8 -6
  187. package/dist/deploy/railway/run.d.ts +21 -15
  188. package/dist/deploy/railway/run.js +32 -78
  189. package/dist/deploy/railway/store.d.ts +29 -0
  190. package/dist/deploy/railway/store.js +143 -0
  191. package/dist/deploy/runner.d.ts +5 -2
  192. package/dist/deploy/runner.js +4 -3
  193. package/dist/deploy/secret-store.d.ts +63 -0
  194. package/dist/deploy/secret-store.js +98 -0
  195. package/dist/deploy/secrets.d.ts +29 -46
  196. package/dist/deploy/secrets.js +49 -122
  197. package/dist/deploy/workspace.d.ts +6 -12
  198. package/dist/deploy/workspace.js +18 -94
  199. package/dist/dev-supervisor.js +6 -8
  200. package/dist/environment/declare.js +3 -2
  201. package/dist/environment/mise.d.ts +3 -1
  202. package/dist/environment/mise.js +4 -2
  203. package/dist/feishu.d.ts +1 -1
  204. package/dist/feishu.js +1 -1
  205. package/dist/harnesses/models/catalog.d.ts +1 -0
  206. package/dist/harnesses/models/catalog.js +1 -2
  207. package/dist/harnesses/models/environment.d.ts +7 -2
  208. package/dist/harnesses/models/environment.js +9 -5
  209. package/dist/harnesses/models/host.d.ts +0 -7
  210. package/dist/harnesses/models/host.js +10 -1
  211. package/dist/harnesses/models/login.d.ts +0 -8
  212. package/dist/harnesses/models/login.js +5 -4
  213. package/dist/harnesses/models/models.d.ts +7 -2
  214. package/dist/harnesses/models/models.js +28 -4
  215. package/dist/harnesses/pi-durable/agent.js +59 -38
  216. package/dist/harnesses/pi-durable/build.d.ts +12 -0
  217. package/dist/harnesses/pi-durable/build.js +270 -0
  218. package/dist/harnesses/pi-durable/built.d.ts +24 -0
  219. package/dist/harnesses/pi-durable/built.js +136 -0
  220. package/dist/harnesses/pi-durable/config.d.ts +81 -0
  221. package/dist/harnesses/pi-durable/config.js +266 -0
  222. package/dist/harnesses/pi-durable/define.d.ts +63 -13
  223. package/dist/harnesses/pi-durable/define.js +23 -2
  224. package/dist/harnesses/pi-durable/definition.d.ts +72 -0
  225. package/dist/harnesses/pi-durable/definition.js +143 -0
  226. package/dist/harnesses/pi-durable/events.d.ts +4 -3
  227. package/dist/harnesses/pi-durable/events.js +179 -136
  228. package/dist/harnesses/pi-durable/execution-env.d.ts +17 -0
  229. package/dist/harnesses/pi-durable/execution-env.js +11 -0
  230. package/dist/harnesses/pi-durable/mcp.js +1 -3
  231. package/dist/harnesses/pi-durable/open.d.ts +35 -37
  232. package/dist/harnesses/pi-durable/open.js +91 -153
  233. package/dist/harnesses/pi-durable/reads.d.ts +0 -1
  234. package/dist/harnesses/pi-durable/reads.js +1 -3
  235. package/dist/harnesses/pi-durable/registry.d.ts +4 -6
  236. package/dist/harnesses/pi-durable/registry.js +29 -26
  237. package/dist/harnesses/pi-durable/release.d.ts +2 -2
  238. package/dist/harnesses/pi-durable/release.js +10 -7
  239. package/dist/harnesses/pi-durable/serve.d.ts +91 -15
  240. package/dist/harnesses/pi-durable/serve.js +109 -47
  241. package/dist/harnesses/pi-durable/sessions.js +15 -12
  242. package/dist/harnesses/pi-durable/text-sources.d.ts +30 -0
  243. package/dist/harnesses/pi-durable/text-sources.js +55 -0
  244. package/dist/harnesses/pi-durable/text.d.ts +16 -4
  245. package/dist/harnesses/pi-durable/text.js +115 -59
  246. package/dist/harnesses/pi-durable/wake.js +8 -8
  247. package/dist/http/client.js +9 -3
  248. package/dist/http/contract.d.ts +3 -3
  249. package/dist/http/server.d.ts +1 -1
  250. package/dist/http/server.js +1 -1
  251. package/dist/index.d.ts +8 -5
  252. package/dist/index.js +4 -1
  253. package/dist/instance-name.d.ts +7 -0
  254. package/dist/instance-name.js +26 -0
  255. package/dist/lark.d.ts +1 -1
  256. package/dist/lark.js +1 -1
  257. package/dist/mcp/client.js +11 -2
  258. package/dist/mcp/config.d.ts +70 -18
  259. package/dist/mcp/config.js +136 -53
  260. package/dist/mcp/host.d.ts +5 -2
  261. package/dist/mcp/host.js +14 -6
  262. package/dist/mcp/sign-in.js +2 -2
  263. package/dist/mcp-api.d.ts +14 -0
  264. package/dist/paths.d.ts +22 -8
  265. package/dist/paths.js +44 -11
  266. package/dist/runtime-websocket.d.ts +14 -0
  267. package/dist/runtime-websocket.js +39 -0
  268. package/dist/scaffold/add-channel.d.ts +9 -16
  269. package/dist/scaffold/add-channel.js +13 -83
  270. package/dist/scaffold/templates/env.example +4 -5
  271. package/dist/scaffold/templates/fastagent.config.ts +20 -14
  272. package/dist/scaffold/templates/gitignore +9 -6
  273. package/dist/scaffold/templates.d.ts +6 -2
  274. package/dist/scaffold/templates.js +7 -2
  275. package/dist/scaffold/vendor-skill.js +4 -2
  276. package/dist/service.d.ts +44 -6
  277. package/dist/service.js +110 -10
  278. package/dist/serving-command.d.ts +10 -0
  279. package/dist/serving-command.js +1 -0
  280. package/dist/slack.d.ts +1 -1
  281. package/dist/slack.js +1 -1
  282. package/dist/strip-edges.d.ts +10 -0
  283. package/dist/strip-edges.js +22 -0
  284. package/dist/telegram.d.ts +1 -1
  285. package/dist/telegram.js +1 -1
  286. package/dist/tunnel.d.ts +0 -1
  287. package/dist/tunnel.js +2 -8
  288. package/dist/value-file.d.ts +11 -6
  289. package/dist/value-file.js +21 -9
  290. package/dist/version.d.ts +7 -1
  291. package/dist/version.js +9 -3
  292. package/package.json +6 -5
  293. package/dist/channels/lark/credentials-page.d.ts +0 -25
  294. package/dist/channels/lark/credentials-page.js +0 -206
  295. package/dist/channels/lark/onboard.d.ts +0 -52
  296. package/dist/channels/lark/onboard.js +0 -86
  297. package/dist/channels/slack/config-api.d.ts +0 -69
  298. package/dist/channels/slack/config-api.js +0 -162
  299. package/dist/channels/slack/onboard.d.ts +0 -40
  300. package/dist/channels/slack/onboard.js +0 -136
  301. package/dist/channels/slack/onboarding-state.d.ts +0 -31
  302. package/dist/channels/slack/onboarding-state.js +0 -64
  303. package/dist/channels/slack/register-webhook.d.ts +0 -12
  304. package/dist/channels/slack/register-webhook.js +0 -67
  305. package/dist/channels/slack/setup-server.d.ts +0 -32
  306. package/dist/channels/slack/setup-server.js +0 -246
  307. package/dist/cli/add-feishu.d.ts +0 -41
  308. package/dist/cli/add-feishu.js +0 -391
  309. package/dist/cli/add-slack.d.ts +0 -18
  310. package/dist/cli/add-slack.js +0 -216
  311. package/dist/harnesses/pi-durable/parked.d.ts +0 -18
  312. package/dist/harnesses/pi-durable/parked.js +0 -27
@@ -1,67 +1,27 @@
1
1
  import { webhookPaths } from "../channel-ingress.js";
2
+ import { manualWebhookStep, webhookKinds } from "../../channels/webhooks.js";
2
3
  import { containerArtifacts } from "../container.js";
3
4
  import { deploymentLoginCommand } from "../box-shell.js";
4
5
  import { SECRETS_DIRNAME } from "../../paths.js";
6
+ import { stripEdges, stripEnd } from "../../strip-edges.js";
5
7
  const MOUNT = "/data";
6
8
  export const DOCKER_COMPOSE_FILE = "fastagent.compose.yml";
7
9
  export const DOCKER_VALUE_FILE = `${SECRETS_DIRNAME}/production/.env`;
8
10
  const GENERATED_COMPOSE_MARKER = "# Generated by `fastagent deploy docker`";
9
11
  export const MIN_DOCKER_COMPOSE_VERSION = "2.3.3";
10
- /** Pinned: generated deployment artifacts must not silently move when Cloudflare publishes `latest`. */
11
- export const CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2026.7.1";
12
12
  /** Whether a Compose file still opts into generated-drift warnings (drop the marker after taking ownership). */
13
13
  export function isGeneratedCompose(content) {
14
14
  return content.startsWith(GENERATED_COMPOSE_MARKER);
15
15
  }
16
- /** Whether an authoritative Compose file already carries the generated tunnel service contract. */
17
- export function composeHasTunnelService(content) {
18
- return /^[ \t]+tunnel:\s*(?:#.*)?$/m.test(content);
19
- }
20
16
  /** Compose project names are lowercase [a-z0-9_-] and must start with an alphanumeric character. */
21
17
  export function toDockerProjectName(directoryName) {
22
- const slug = directoryName
23
- .toLowerCase()
24
- .replace(/[^a-z0-9_-]+/g, "-")
25
- .replace(/^[-_]+|[-_]+$/g, "")
26
- .slice(0, 54)
27
- .replace(/[-_]+$/g, "");
18
+ const separator = (char) => char === "-" || char === "_";
19
+ const slug = stripEnd(stripEdges(directoryName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-"), separator).slice(0, 54), separator);
28
20
  return `fastagent-${slug || "agent"}`;
29
21
  }
30
- /** `${NAME:-}` without making JavaScript treat it as interpolation. */
31
- function composeInterpolation(name) {
32
- return `\${${name}:-}`;
33
- }
34
22
  function composeYaml(input) {
35
- // Compose sits beside the Dockerfile, in the agent directory, which is the build context.
36
- const tunnelService = input.tunnel
37
- ? `
38
- # Cloudflare Quick Tunnel: ephemeral URL, generated only with \`deploy docker --tunnel\`.
39
- # No restart policy on purpose: a new process gets a new URL and needs webhook re-registration.
40
- tunnel:
41
- image: ${CLOUDFLARED_IMAGE}
42
- command:
43
- - tunnel
44
- - --no-autoupdate
45
- - --url
46
- - http://agent:${input.port}
47
- depends_on:
48
- - agent
49
- environment:
50
- # Docker Desktop may inject HTTP(S)_PROXY into containers. The origin is Docker-internal and must
51
- # bypass it; preserve any operator NO_PROXY entries after the required service/loopback names.
52
- NO_PROXY: "agent,localhost,127.0.0.1,${composeInterpolation("NO_PROXY")}"
53
- no_proxy: "agent,localhost,127.0.0.1,${composeInterpolation("no_proxy")}"
54
- # Quieter than the agent, but bounded for the same reason: nothing else on this host bounds it.
55
- logging:
56
- driver: json-file
57
- options:
58
- max-size: "10m"
59
- max-file: "3"
60
- restart: "no"
61
- `
62
- : "";
63
23
  return `${GENERATED_COMPOSE_MARKER}. Edit freely — it is kept unless you pass --force.
64
- # Durable/public ingress remains yours; --tunnel optionally adds an ephemeral Quick Tunnel service.
24
+ # Public ingress remains yours: add your own tunnel/proxy/DNS/TLS, and name its URL as http.publicUrl.
65
25
  name: ${input.projectName}
66
26
 
67
27
  services:
@@ -98,11 +58,33 @@ services:
98
58
  max-size: "10m"
99
59
  max-file: "3"
100
60
  restart: unless-stopped
101
- ${tunnelService}
61
+
102
62
  volumes:
103
63
  state:
104
64
  `;
105
65
  }
66
+ /**
67
+ * What `--run` says of the webhook channels once Compose is up. Docker has no URL of its own: with `http.publicUrl`
68
+ * named in the delivered configuration the agent points its own channels there (its `/health` says how each went) and
69
+ * only Slack's Request URL is the operator's; without one, nothing can reach those paths yet.
70
+ */
71
+ export function ingressNotes(channels, publicUrl, healthUrl) {
72
+ const paths = webhookPaths(channels);
73
+ if (paths.length === 0)
74
+ return [];
75
+ if (publicUrl === undefined) {
76
+ return [
77
+ `${paths.join(", ")} receive nothing until a public URL reaches this container — configure your own ` +
78
+ `tunnel/proxy, point those webhook path(s) (or your remapped channel routes) at it and name its URL as ` +
79
+ `http.publicUrl in the configuration --config delivers, or set the channel's ingress to "connection" ` +
80
+ `where it has one`,
81
+ ];
82
+ }
83
+ return [
84
+ `the agent points its webhook channels at ${publicUrl} on every start; ${healthUrl ?? "its /health"} says how each went`,
85
+ ...(webhookKinds(channels).includes("slack") ? [manualWebhookStep("slack", publicUrl)] : []),
86
+ ];
87
+ }
106
88
  /** Compute local-Docker artifacts + the runbook; no Docker process is touched here. */
107
89
  export function planDockerDeploy(input) {
108
90
  const composePath = DOCKER_COMPOSE_FILE;
@@ -121,17 +103,14 @@ export function planDockerDeploy(input) {
121
103
  runbook.push(`# Environment: the container reads ${input.valueFile} whole (Compose \`env_file\`) — that file declares the`, `# deployed environment (in CI, write it before running the command):`, ...secrets.map((secret) => `# ${secret.name}: ${secret.hint}`));
122
104
  }
123
105
  runbook.push(`# Run from the agent directory. The build bakes it as the definition and installs its`, `# package.json dependencies.`);
124
- runbook.push(``, `# Before building a new definition release, run \`fastagent deploy docker\` to refresh its manifest.`, `# The Compose volume at ${MOUNT} retains the deployed definition, state and credentials.`, `# --pull: the base image is a floating tag, and a copy cached here may be older than the runtime floor.`, `${compose} build --pull`, `${compose} up -d`, `curl --fail http://127.0.0.1:${input.port}/health`, ...(input.boxLogin
106
+ runbook.push(``, `# Before building a new definition release, run \`fastagent deploy docker\` to refresh its manifest.`, `# The Compose volume at ${MOUNT} is the agent's working directory, and retains its state and credentials.`, `# --pull: the base image is a floating tag, and a copy cached here may be older than the runtime floor.`, `${compose} build --pull`, `${compose} up -d`, `curl --fail http://127.0.0.1:${input.port}/health`, ...(input.boxLogin
125
107
  ? [
126
108
  `# Model auth: once it is up, the deployment logs in to ${input.boxLogin} itself:`,
127
109
  `${deploymentLoginCommand("docker", input.boxLogin)}`,
128
110
  ]
129
111
  : []), ``, `# Operate it:`, `${compose} logs -f agent`, `${compose} ps`, `${compose} down # stops containers; keeps the state volume`, `# ${compose} down -v # DESTRUCTIVE: deletes credentials and all agent state`);
130
- if (input.tunnel) {
131
- runbook.push(``, `# Quick Tunnel: \`--run\` starts the Compose tunnel service and reads its ephemeral public URL.`, `# Telegram/Feishu/Lark and locally onboarded Slack auto-register; manual Slack prints its console URL. Re-run after the`, `# tunnel container/Docker daemon restarts: a new Quick Tunnel URL must replace the old webhook URL.`, `${compose} logs -f tunnel`);
132
- }
133
- else if (paths.length > 0) {
134
- runbook.push(``, `# Public ingress is operator-owned: add your tunnel/proxy/DNS/TLS, then configure the`, `# detected default endpoint(s): ${paths.map((path) => `https://<your-domain>${path}`).join(", ")}.`, `# If your channels/*.ts glue remaps a route, use that path instead.`);
112
+ if (paths.length > 0) {
113
+ runbook.push(``, `# Public ingress is operator-owned: add your tunnel/proxy/DNS/TLS, then route the detected default`, `# endpoint(s) to the agent: ${paths.map((path) => `https://<your-domain>${path}`).join(", ")}.`, `# If your channels/*.ts glue remaps a route, use that path instead. Name that URL as http.publicUrl in the`, `# configuration \`--config\` delivers, and the agent points Telegram and Feishu/Lark at it on every start`, `# (Slack's Request URL stays yours to set).`);
135
114
  }
136
115
  runbook.push(`# Keep one agent replica: the local state volume is single-process; multiple replicas split/corrupt state.`);
137
116
  return { artifacts, composePath, runbook };
@@ -1,6 +1,4 @@
1
1
  import type { BoxLoginStep } from "../box-shell.ts";
2
- import type { DeclaredChannel } from "../../channels/discover.ts";
3
- import type { RegistrationOutcome } from "../../channels/registration.ts";
4
2
  import { type CliRunner } from "../runner.ts";
5
3
  export interface DockerRunPlan {
6
4
  /** Compose file relative to the runner cwd (the agent directory). */
@@ -8,20 +6,10 @@ export interface DockerRunPlan {
8
6
  /** Container port from config; used to ask Compose for the effective published host port. */
9
7
  port: number;
10
8
  /**
11
- * What `--run` assembled. The container reads the value file itself through the generated `env_file`, so nothing
12
- * travels through this process: the names are reported to the operator and otherwise unused.
9
+ * Make sure the value file Compose reads by `env_file:` holds a value for every name the deployment needs, asking for
10
+ * the ones it lacks: resolves a gate line, or undefined. Nothing travels through this process.
13
11
  */
14
- secrets: Record<string, string>;
15
- /** Declared names the value file supplies no value for — the run gates on these before any side effect. */
16
- missingSecrets: string[];
17
- /** That value file, agent-dir-relative, so the gate names the file this deploy actually read. */
18
- valueFile: string;
19
- /** Register the deployment's webhooks against the tunnel URL, reporting what each registrar answered. */
20
- announce: DockerAnnounce;
21
- /** Every declared channel and its ingress: which of them a failed login leaves unregistered. */
22
- channels: readonly DeclaredChannel[];
23
- /** `--tunnel` was requested for this run; a kept Compose file must actually contain that service. */
24
- requireTunnel: boolean;
12
+ provideSecrets: () => Promise<string | undefined>;
25
13
  /**
26
14
  * Log the box in (`fastagent login --deployment`) once it is up and before any webhook is pointed at it: a channel pointed at
27
15
  * a box with no model credential answers every message with a failure. Resolves a gate line, or undefined.
@@ -31,23 +19,13 @@ export interface DockerRunPlan {
31
19
  export type DockerRunOutcome = {
32
20
  ok: true;
33
21
  url?: string;
34
- tunnelUrl?: string;
35
22
  }
36
- /**
37
- * `url`/`tunnelUrl` travel with a gate too: Compose is up, so the operator still needs to know where it is and what
38
- * to re-run.
39
- */
23
+ /** `url` travels with a gate too: Compose is up, so the operator still needs to know where. */
40
24
  | {
41
25
  ok: false;
42
26
  gate: string;
43
27
  url?: string;
44
- tunnelUrl?: string;
45
28
  };
46
- /** Register the deployment's webhooks against its public URL, reporting what each registrar answered. */
47
- type DockerAnnounce = (baseUrl: string) => Promise<{
48
- kind: string;
49
- outcome: RegistrationOutcome;
50
- }[]>;
51
29
  /**
52
30
  * `stillStarting` answers whether the agent container is still up; a probe that ignores it simply waits out its whole
53
31
  * budget.
@@ -58,12 +36,6 @@ export type DockerHealthProbe = (healthUrl: string, stillStarting: () => Promise
58
36
  * publish free", asked before `up`, because afterwards a `/health` that answers cannot say whose it is.
59
37
  */
60
38
  export type HostPortProbe = (host: string, port: number) => Promise<boolean>;
61
- /** A published Quick Tunnel URL, and whether its tunnel ever reported an edge connection. */
62
- export interface ComposeTunnel {
63
- url: string;
64
- connected: boolean;
65
- }
66
- export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string) => Promise<ComposeTunnel | undefined>;
67
39
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
68
40
  export declare function localUrlFromComposePort(stdout: string): string | undefined;
69
41
  /**
@@ -75,14 +47,4 @@ export declare function agentPublishedPorts(stdout: string): {
75
47
  host: string;
76
48
  port: number;
77
49
  }[];
78
- /**
79
- * Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
80
- * connection.
81
- */
82
- export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile: string, options?: {
83
- attempts?: number;
84
- intervalMs?: number;
85
- sleep?: (ms: number) => Promise<void>;
86
- }): Promise<ComposeTunnel | undefined>;
87
- export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe, hostPortBusy?: HostPortProbe): Promise<DockerRunOutcome>;
88
- export {};
50
+ export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, hostPortBusy?: HostPortProbe): Promise<DockerRunOutcome>;
@@ -1,12 +1,8 @@
1
1
  /** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
2
2
  import { connect } from "node:net";
3
3
  import { waitForHealth } from "../../channels/wait-health.js";
4
- import { loginGate } from "../channel-ingress.js";
5
- import { TUNNEL_DNS_LAG_MS, hasTunnelConnection, parseTunnelUrl } from "../../tunnel.js";
6
- import { registrationGate } from "../registration-gate.js";
7
4
  import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
8
5
  import { readOutput } from "../runner.js";
9
- import { missingValuesGate } from "../secrets.js";
10
6
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
11
7
  export function localUrlFromComposePort(stdout) {
12
8
  const line = stdout
@@ -52,29 +48,6 @@ export function agentPublishedPorts(stdout) {
52
48
  });
53
49
  }
54
50
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
55
- /**
56
- * Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
57
- * connection.
58
- */
59
- export async function waitForComposeTunnelUrl(docker, composeFile, options = {}) {
60
- const compose = ["compose", "-f", composeFile];
61
- const attempts = options.attempts ?? 60;
62
- let assigned;
63
- for (let attempt = 0; attempt < attempts; attempt++) {
64
- const logs = await docker([...compose, "logs", "--no-color", "tunnel"], { capture: true });
65
- if (logs.code === 0) {
66
- assigned ??= parseTunnelUrl(logs.stdout);
67
- if (assigned && hasTunnelConnection(logs.stdout)) {
68
- await (options.sleep ?? sleep)(TUNNEL_DNS_LAG_MS);
69
- return { url: assigned, connected: true };
70
- }
71
- }
72
- if (attempt + 1 < attempts)
73
- await (options.sleep ?? sleep)(options.intervalMs ?? 500);
74
- }
75
- return assigned ? { url: assigned, connected: false } : undefined;
76
- }
77
- const defaultTunnelUrlProbe = (docker, composeFile) => waitForComposeTunnelUrl(docker, composeFile);
78
51
  /**
79
52
  * `/health` asked from inside the agent container, for a topology that publishes no host port. The image always has
80
53
  * node or bun (the generated Dockerfile's base), and both have `fetch`; nothing else is assumed installed.
@@ -92,7 +65,7 @@ async function containerHealthy(docker, compose, port, stillStarting) {
92
65
  }
93
66
  return false;
94
67
  }
95
- export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe, hostPortBusy = defaultHostPortBusy) {
68
+ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, hostPortBusy = defaultHostPortBusy) {
96
69
  const gate = (message) => ({ ok: false, gate: message });
97
70
  const compose = ["compose", "-f", plan.composeFile];
98
71
  // CLI/plugin gate first: unlike a daemon error, spawn ENOENT becomes 127 at the shared runner seam.
@@ -103,17 +76,10 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
103
76
  if (version.code !== 0) {
104
77
  return gate("Docker Compose plugin is unavailable — install/enable `docker compose`, then re-run");
105
78
  }
106
- // The values gate precedes the first side effect (build/create).
107
- const missingValues = missingValuesGate(plan.missingSecrets, plan.valueFile);
108
- if (missingValues)
109
- return gate(missingValues);
110
- // Name what the container must find, and WHERE — the generated Compose reads the value file itself, so this run
111
- // hands Compose nothing. Saying "passing N secrets to Compose" would be false, and doubly so on a hand-owned
112
- // Compose file that has no `env_file` entry at all.
113
- const secretNames = Object.keys(plan.secrets);
114
- if (secretNames.length > 0) {
115
- log(`${secretNames.length} value(s) the container reads from ${plan.valueFile}: ${secretNames.join(", ")}`);
116
- }
79
+ // The values come before the first side effect (build/create).
80
+ const secretsGate = await plan.provideSecrets();
81
+ if (secretsGate !== undefined)
82
+ return gate(secretsGate);
117
83
  if ((await docker(["info"], { capture: true })).code !== 0) {
118
84
  return gate("Docker daemon is unavailable — start Docker Engine/Desktop, then re-run");
119
85
  }
@@ -127,11 +93,6 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
127
93
  if (!services.includes("agent")) {
128
94
  return gate(`Compose file must keep the "agent" service for \`fastagent deploy docker --run\``);
129
95
  }
130
- const hasTunnel = services.includes("tunnel");
131
- if (plan.requireTunnel && !hasTunnel) {
132
- return gate(`--tunnel was requested but the kept ${plan.composeFile} has no "tunnel" service — ` +
133
- `edit it, delete it and regenerate, or pass --force`);
134
- }
135
96
  // A port something else already serves would answer the `/health` this run waits for, and the run would report that
136
97
  // process's answer as the agent's. A container this project already runs holds its own port, so only a first start
137
98
  // (or a start after `down`) asks. Before the first side effect: a refusal leaves nothing to undo.
@@ -146,19 +107,12 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
146
107
  if (await hostPortBusy(host, port)) {
147
108
  return gate(`${host}:${port} is already in use by another process, so the agent's published port is not free and a ` +
148
109
  `health check there would answer for that process, not for the agent — stop it, or move the agent: ` +
149
- `edit the published port in ${plan.composeFile}, or change http.port in fastagent.config.ts and re-run ` +
110
+ `edit the published port in ${plan.composeFile}, or change defaults.http.port in fastagent.config.ts and re-run ` +
150
111
  `with --force (a generated Compose file is kept otherwise)`);
151
112
  }
152
113
  }
153
114
  }
154
115
  }
155
- // Quick Tunnel logs are the control-plane output (the assigned URL).
156
- if (hasTunnel) {
157
- log("recreating the ephemeral tunnel service…");
158
- if ((await docker([...compose, "rm", "-s", "-f", "tunnel"])).code !== 0) {
159
- return gate(`could not recreate the tunnel service — inspect \`docker compose -f ${plan.composeFile} ps\``);
160
- }
161
- }
162
116
  log(`building and reconciling ${plan.composeFile}…`);
163
117
  // `--pull`: the base image is a floating tag, and a copy cached here may be older than the runtime floor.
164
118
  if ((await docker([...compose, "build", "--pull"])).code !== 0) {
@@ -173,9 +127,6 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
173
127
  if (running.code !== 0 || !runningServices.includes("agent")) {
174
128
  return gate(`the Compose service "agent" is not running — inspect with \`docker compose -f ${plan.composeFile} logs agent\``);
175
129
  }
176
- if (hasTunnel && !runningServices.includes("tunnel")) {
177
- return gate(`the Compose service "tunnel" is not running — inspect with \`docker compose -f ${plan.composeFile} logs tunnel\``);
178
- }
179
130
  // A user-owned topology may deliberately remove the host port and expose only through its own ingress.
180
131
  const published = await docker([...compose, "port", "agent", String(plan.port)], { capture: true });
181
132
  const url = published.code === 0 ? localUrlFromComposePort(published.stdout) : undefined;
@@ -208,33 +159,7 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
208
159
  }
209
160
  // `url` travels with this gate: Compose is up, and the operator needs to know where.
210
161
  const notLoggedIn = await plan.boxLogin?.run();
211
- if (notLoggedIn) {
212
- // Only a tunnel is ours to announce; without one, the webhooks were never this run's to register.
213
- const afterLogin = "re-run `fastagent deploy docker --run` (it keeps the login and announces the tunnel)";
214
- const channels = hasTunnel ? plan.channels : [];
215
- return { ok: false, gate: loginGate({ notLoggedIn, channels, log, afterLogin }), url };
216
- }
217
- if (!hasTunnel)
218
- return { ok: true, url };
219
- log("waiting for the Compose tunnel service to publish its Quick Tunnel URL…");
220
- const tunnel = await tunnelUrlProbe(docker, plan.composeFile);
221
- if (!tunnel) {
222
- return gate(`tunnel did not publish a Quick Tunnel URL — inspect \`docker compose -f ${plan.composeFile} logs tunnel\``);
223
- }
224
- // The same sentence `startCloudflareTunnel` prints for the same state, and needed MORE here.
225
- if (!tunnel.connected) {
226
- log(`warn: the tunnel service never reported an edge connection for ${tunnel.url} — announcing it anyway. ` +
227
- `Nothing reaches a tunnel that has not connected, so a webhook registration that cannot resolve the ` +
228
- `host is this, not the platform. Inspect \`docker compose -f ${plan.composeFile} logs tunnel\`.`);
229
- }
230
- const tunnelUrl = tunnel.url;
231
- // Registration lives HERE, like every other host's driver, and not at the CLI: this is the layer that owns the
232
- // outcome, so it is the layer that can gate on one.
233
- const reg = registrationGate(log, `re-run this deploy to retry registration (Compose is already up)`);
234
- for (const { kind, outcome } of await plan.announce(tunnelUrl))
235
- reg.track(kind, outcome);
236
- const blocked = reg.gate();
237
- if (blocked)
238
- return { ok: false, gate: blocked, url, tunnelUrl };
239
- return { ok: true, url, tunnelUrl };
162
+ if (notLoggedIn)
163
+ return { ok: false, gate: notLoggedIn, url };
164
+ return { ok: true, url };
240
165
  }
@@ -0,0 +1,2 @@
1
+ import type { SecretStore } from "./secret-store.ts";
2
+ export declare function fileStore(agentDir: string, file: string): SecretStore;
@@ -0,0 +1,17 @@
1
+ /** A value file as a deployment's store: Docker's (Compose reads it by `env_file:`), and what `start` reads. */
2
+ import { loadEnvValues, writeValues } from "../value-file.js";
3
+ export function fileStore(agentDir, file) {
4
+ return {
5
+ async exists() {
6
+ return true;
7
+ },
8
+ async ensure() { },
9
+ async names() {
10
+ return new Set([...loadEnvValues(file)].filter(([, value]) => value.trim() !== "").map(([name]) => name));
11
+ },
12
+ async write(values) {
13
+ const names = Object.keys(values);
14
+ await writeValues(agentDir, "deployment", names.map((name) => ({ name, value: values[name] })), names, file);
15
+ },
16
+ };
17
+ }
@@ -18,12 +18,13 @@ export interface FlyPlanInput extends ContainerInput {
18
18
  */
19
19
  channels: readonly DeclaredChannel[];
20
20
  /**
21
- * The runbook's variable list (`deploymentSecrets`): what must have a value, then everything else the value file
22
- * carries.
21
+ * The runbook's variable list (`deploymentSecrets`): the declared names, each hinted by the file that declared it.
23
22
  */
24
23
  secrets?: readonly DeploymentSecret[];
25
24
  /** `schedules/` declares a schedule — one of the things that forces a machine up (deploy/residency.ts). */
26
25
  hasCron: boolean;
26
+ /** The `http.publicUrl` the image delivers, where the platforms reach the box; default the app's own URL. */
27
+ publicUrl?: string;
27
28
  }
28
29
  export interface FlyPlan {
29
30
  /** fly.toml / Dockerfile / .dockerignore — written by the CLI (skipped if present unless --force). */
@@ -1,7 +1,9 @@
1
+ import { webhookKinds } from "../../channels/webhooks.js";
1
2
  import { webhookRunbook } from "../channel-ingress.js";
2
3
  import { containerArtifacts, imageHasGit } from "../container.js";
3
4
  import { deploymentLoginCommand } from "../box-shell.js";
4
5
  import { WAKEUPS_WHEN_ASLEEP, residencyFor } from "../residency.js";
6
+ import { stripEdges } from "../../strip-edges.js";
5
7
  function flyToml(appName, port, residency) {
6
8
  // WHY is residency.ts's to decide; the SETTING and its wording are Fly's. Anything else is an edit to the
7
9
  // generated file, which is the artifact's job.
@@ -65,7 +67,7 @@ export function planFlyDeploy(input) {
65
67
  `# Fly app names are GLOBALLY unique: if this fails as taken, set a unique "app" in fly.toml and`,
66
68
  `# re-run \`fastagent deploy fly\` — the runbook follows fly.toml's app name.`,
67
69
  `fly apps create ${appName}`,
68
- `# The volume (definition, state, credentials — kept across stop/suspend/redeploy) is created by the deploy`,
70
+ `# The volume (the agent's working directory, state, credentials — kept across stop/suspend/redeploy) is created by the deploy`,
69
71
  `# below, in primary_region and sized by [mounts].initial_size. Creating it yourself first pins it to a host`,
70
72
  `# picked without the machine, which can fail the deploy with "insufficient resources … existing volume".`,
71
73
  ``,
@@ -77,18 +79,30 @@ export function planFlyDeploy(input) {
77
79
  `fly ips allocate-v6 --app ${appName}`,
78
80
  ];
79
81
  if (secrets.length > 0) {
80
- runbook.push(``, `# Secrets (replace each <value>):`, ...secrets.map((s) => `# ${s.name}: ${s.hint}`), `fly secrets set --app ${appName} ${secrets.map((s) => `${s.name}=<value>`).join(" ")}`);
82
+ runbook.push(``, `# Secrets the app needs a value for. \`fastagent secrets push fly\` sets the ones a value file holds and`, `# \`fastagent secrets set fly <NAME>\` one typed without echo; or with flyctl (replace each <value>):`, ...secrets.map((s) => `# ${s.name}: ${s.hint}`), `fly secrets set --app ${appName} ${secrets.map((s) => `${s.name}=<value>`).join(" ")}`);
81
83
  }
82
84
  runbook.push(``, `# The build context is the agent directory, baked as the definition.`, `# Before a new definition release, run \`fastagent deploy fly\` to refresh the release manifest.`, `# Run this from the agent directory:`, deployCmd);
83
- runbook.push(``, `# The volume keeps .state and .secrets across restarts and deploys; each release replaces /data/definition.`, imageHasGit(input)
85
+ runbook.push(``, `# The volume is the agent's working directory: it keeps its work, .state and .secrets across restarts and deploys.`, imageHasGit(input)
84
86
  ? `# Git is installed (the definition ships its .git, github content is cloned here, or context.json declares it in aptPackages).`
85
87
  : `# To give the agent git, declare it: fastagent env add apt:git`);
86
88
  // The credential is created on the box, never carried: the box is then the only holder of its grant.
87
89
  if (input.boxLogin) {
88
90
  runbook.push(``, `# Model auth: once it is up, the deployment logs in to ${input.boxLogin} itself (from the agent directory):`, `${deploymentLoginCommand("fly", input.boxLogin)}`);
89
91
  }
90
- // The fastagent-only post-step: point each channel at the live URL.
91
- const steps = webhookRunbook(`https://${appName}.fly.dev`, channels);
92
+ // The box points the channels its own credentials can at its `http.publicUrl`; only Slack's is a person's step.
93
+ const appUrl = `https://${appName}.fly.dev`;
94
+ const publicUrl = input.publicUrl ?? appUrl;
95
+ const own = webhookKinds(channels).filter((kind) => kind !== "slack");
96
+ const steps = [
97
+ ...(own.length > 0
98
+ ? [
99
+ `# The box points ${own.join(", ")} at ${publicUrl} itself, on every start; its /health says how each`,
100
+ `# one went (a step only a person can take, Lark's console where its config API answers 404, is in its log):`,
101
+ `curl -fsS ${appUrl}/health`,
102
+ ]
103
+ : []),
104
+ ...webhookRunbook(publicUrl, channels.filter((channel) => channel.name === "slack")),
105
+ ];
92
106
  const post = steps.length > 0 ? [`# After deploy:`, ...steps] : [];
93
107
  if (post.length > 0)
94
108
  runbook.push(``, ...post);
@@ -107,9 +121,6 @@ export function parseFlyMinMachines(toml) {
107
121
  }
108
122
  /** Sanitize a directory basename into a Fly app name: lowercase, [a-z0-9-], must start with a letter. */
109
123
  export function toFlyAppName(basename) {
110
- const slug = basename
111
- .toLowerCase()
112
- .replace(/[^a-z0-9]+/g, "-")
113
- .replace(/^-+|-+$/g, "");
124
+ const slug = stripEdges(basename.toLowerCase().replace(/[^a-z0-9]+/g, "-"), (char) => char === "-");
114
125
  return /^[a-z]/.test(slug) ? slug : `app-${slug || "agent"}`;
115
126
  }
@@ -1,16 +1,21 @@
1
1
  /** `fastagent deploy fly --run` — drive flyctl to completion. */
2
2
  import type { BoxLoginStep } from "../box-shell.ts";
3
- import { type PublicHealthProbe, type Registrars } from "../channel-ingress.ts";
3
+ import { type BoxWebhooksProbe, type UrlRecorder, type PublicHealthProbe, type Registrars } from "../channel-ingress.ts";
4
4
  import type { DeclaredChannel } from "../../channels/discover.ts";
5
5
  import { type CliRunner } from "../runner.ts";
6
+ import type { SecretStore } from "../secret-store.ts";
6
7
  export interface FlyRunPlan {
8
+ /** At a terminal with a `--config` file: record each URL a person set once the box confirms it. */
9
+ recordUrl?: UrlRecorder;
10
+ /** The `http.publicUrl` the image delivers, where the platforms reach the box; default the app's own URL. */
11
+ publicUrl?: string;
7
12
  appName: string;
8
- /** `KEY=value` secrets to set on Fly: the value file's variables. */
9
- secrets: Record<string, string>;
10
- /** Declared names the value file supplies no value for — the run gates on these before any side effect. */
11
- missingSecrets: string[];
12
- /** That value file, agent-dir-relative, so the gate names the file this deploy actually read. */
13
- valueFile: string;
13
+ /**
14
+ * Make sure the app holds a value for every name the deployment needs, through whoever can supply one
15
+ * (secret-store.ts): resolves a gate line, or undefined. It runs once the app exists, since only then can Fly say
16
+ * which names it has.
17
+ */
18
+ provideSecrets: (store: SecretStore) => Promise<string | undefined>;
14
19
  /** Every declared channel and its ingress — the driver asks which of them have a webhook. */
15
20
  channels: readonly DeclaredChannel[];
16
21
  /** fly.toml path passed to `fly deploy -c` (relative to the run cwd = the agent directory). */
@@ -37,4 +42,9 @@ export type FlyRunOutcome = {
37
42
  ok: false;
38
43
  gate: string;
39
44
  };
40
- export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registrars: Registrars, healthProbe?: PublicHealthProbe): Promise<FlyRunOutcome>;
45
+ export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void,
46
+ /** Slack's, which this machine still sets (its Request URL no credential on the box can change). */
47
+ registrars: Pick<Registrars, "leave">, healthProbe?: PublicHealthProbe, boxWebhooks?: {
48
+ probe: BoxWebhooksProbe;
49
+ intervalMs?: number;
50
+ }): Promise<FlyRunOutcome>;
@@ -1,6 +1,7 @@
1
- import { loginGate, publicHealthGate, registerWebhooks, } from "../channel-ingress.js";
1
+ import { confirmWebhooks, loginGate, publicHealthGate, } from "../channel-ingress.js";
2
2
  import { readOutput } from "../runner.js";
3
- import { hostSignedOutGate, missingValuesGate } from "../secrets.js";
3
+ import { hostSignedOutGate } from "../secrets.js";
4
+ import { ensureFlyApp, flyStore } from "./store.js";
4
5
  /**
5
6
  * The `Type` values that put an app on `https://<app>.fly.dev`, BY FAMILY — one entry per allocate command, because
6
7
  * that is the granularity the answer is acted on at.
@@ -17,23 +18,10 @@ function ingressAddresses(stdout) {
17
18
  const has = (types) => entries.some((entry) => typeof entry?.Address === "string" && entry.Address !== "" && types.includes(entry.Type));
18
19
  return { v4: has(INGRESS_TYPES.v4), v6: has(INGRESS_TYPES.v6) };
19
20
  }
20
- /**
21
- * Whether `fly apps list --json` lists an app called `name`. flyctl capitalizes the field, and an
22
- * offline fixture can only repeat what we already believe, so a change in flyctl's output is not caught here.
23
- */
24
- function listHasName(stdout, name) {
25
- const entries = JSON.parse(stdout);
26
- if (!Array.isArray(entries))
27
- throw new Error(`expected a JSON array, got ${typeof entries}`);
28
- return entries.some((o) => o.Name === name);
29
- }
30
- export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
21
+ export async function deployFlyRun(plan, fly, log,
22
+ /** Slack's, which this machine still sets (its Request URL no credential on the box can change). */
23
+ registrars, healthProbe, boxWebhooks) {
31
24
  const gate = (g) => ({ ok: false, gate: g });
32
- // 1. Gate missing required secret VALUES before any side effect (no half-created infra), and before asking a
33
- // person to sign in for a deploy that would stop here anyway.
34
- const missingValues = missingValuesGate(plan.missingSecrets, plan.valueFile);
35
- if (missingValues)
36
- return gate(missingValues);
37
25
  // 2.
38
26
  if ((await fly(["auth", "whoami"], { capture: true })).code !== 0) {
39
27
  const failed = plan.hostLogin
@@ -43,19 +31,14 @@ export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
43
31
  return gate(failed);
44
32
  }
45
33
  // 3.
46
- const appExists = await readOutput(fly, "fly", ["apps", "list", "--json"], (out) => listHasName(out, plan.appName));
47
- if ("gate" in appExists)
48
- return gate(appExists.gate);
49
- if (appExists.value) {
50
- log(`app ${plan.appName} exists — skipping create`);
51
- }
52
- else {
53
- log(`creating app ${plan.appName}…`);
54
- if ((await fly(["apps", "create", plan.appName])).code !== 0) {
55
- return gate(`\`fly apps create ${plan.appName}\` failed — Fly app names are globally unique and it may be taken. ` +
56
- `Set a unique \`app\` in fly.toml and re-run.`);
57
- }
58
- }
34
+ const appGate = await ensureFlyApp(fly, plan.appName, log);
35
+ if (appGate !== undefined)
36
+ return gate(appGate);
37
+ // 3b. Secrets, before anything else is set up for an app that cannot run without them: the names the app holds, the
38
+ // gaps filled (staged: the deploy below applies them).
39
+ const secretsGate = await plan.provideSecrets(flyStore(fly, plan.appName, log, { stage: true }));
40
+ if (secretsGate !== undefined)
41
+ return gate(secretsGate);
59
42
  // 4. NO volume step: `fly deploy` creates it from [mounts] on a first deploy, and only it can tell the scheduler
60
43
  // which machine (guest size + image) must fit on the host the volume is pinned to. Pre-creating one here placed it
61
44
  // blind, and a host with disk but no compute failed the deploy with `insufficient resources … with existing volume`.
@@ -78,15 +61,6 @@ export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
78
61
  return gate(`\`fly ${allocate.join(" ")}\` failed — see the flyctl output above`);
79
62
  }
80
63
  }
81
- // 6. Secrets — staged (no deploy yet; we deploy with fly.toml next). Values over stdin, not argv.
82
- const keys = Object.keys(plan.secrets);
83
- if (keys.length > 0) {
84
- log(`setting ${keys.length} secret(s): ${keys.join(", ")}`);
85
- const input = `${keys.map((k) => `${k}=${plan.secrets[k]}`).join("\n")}\n`;
86
- if ((await fly(["secrets", "import", "--stage", "-a", plan.appName], { input })).code !== 0) {
87
- return gate("`fly secrets import` failed — see the flyctl output above");
88
- }
89
- }
90
64
  // 7.
91
65
  log("deploying (remote build)…");
92
66
  const deployArgs = [
@@ -128,13 +102,21 @@ export async function deployFlyRun(plan, fly, log, registrars, healthProbe) {
128
102
  afterLogin: "re-run `fastagent deploy fly --run` (it keeps the login)",
129
103
  }));
130
104
  }
131
- // 9.
132
- const registrationGateMsg = await registerWebhooks({
105
+ // 9. The box points its own channels at the `http.publicUrl` this deploy delivered; this reads what it made of them.
106
+ const registrationGateMsg = await confirmWebhooks({
133
107
  baseUrl,
108
+ publicUrl: plan.publicUrl ?? baseUrl,
134
109
  channels: plan.channels,
135
110
  registrars,
136
111
  log,
112
+ ...(plan.recordUrl === undefined ? {} : { record: plan.recordUrl }),
137
113
  retryHint: "re-run to retry registration (steps already done are skipped)",
114
+ ...(boxWebhooks === undefined
115
+ ? {}
116
+ : {
117
+ probe: boxWebhooks.probe,
118
+ ...(boxWebhooks.intervalMs === undefined ? {} : { intervalMs: boxWebhooks.intervalMs }),
119
+ }),
138
120
  });
139
121
  if (registrationGateMsg)
140
122
  return gate(registrationGateMsg);
@@ -0,0 +1,12 @@
1
+ /** Fly's secrets as a deployment's store: `fly secrets list` for the names, `fly secrets set NAME=-` to set each. */
2
+ import type { SecretStore } from "../secret-store.ts";
3
+ import { type CliRunner } from "../runner.ts";
4
+ /** Create the app when `fly apps list` does not show it. Resolves the gate line when it cannot be made. */
5
+ export declare function ensureFlyApp(fly: CliRunner, app: string, log: (message: string) => void): Promise<string | undefined>;
6
+ /**
7
+ * `stage`: set the secrets without deploying them, for a deploy that follows (`fly deploy` applies staged ones); off, a
8
+ * running app restarts on them at once.
9
+ */
10
+ export declare function flyStore(fly: CliRunner, app: string, log: (message: string) => void, options: {
11
+ stage: boolean;
12
+ }): SecretStore;