@fastagent-sh/fastagent 0.19.0 → 0.21.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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,63 +1,97 @@
1
- /**
2
- * `fastagent deploy <host> [dir]`: generate host artifacts from the resolved definition and print an
3
- * ordered deploy runbook. Host-scoped (`docker` | `fly` | `railway` — the extension seam). It does NOT
4
- * run the host CLI by default: fastagent owns the definition-aware artifacts and precise runbook;
5
- * Docker may opt into a generated ephemeral tunnel, while durable ingress stays operator-owned. The pre-flight
6
- * (config/model/channels/container facts) is host-neutral; the host branch adds its config + run drive.
7
- * Read-only on the definition; the only writes are generated artifacts (never clobbered without
8
- * --force). `--run` drives the target CLI instead of printing.
9
- */
10
- import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
11
- import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
- import { tmpdir } from "node:os";
13
- import { basename, dirname, join, resolve } from "node:path";
14
- import { registerFeishuWebhook } from "../../channels/feishu/register-webhook.js";
15
- import { readSlackBotAuthEnv } from "../../channels/slack/bot-auth.js";
16
- import { registerSlackWebhook } from "../../channels/slack/register-webhook.js";
17
- import { registerTelegramWebhook } from "../../channels/telegram/register-webhook.js";
18
- import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
19
- import { deployAgentcoreRun } from "../../deploy/agentcore/run.js";
20
- import { isGeneratedDockerfile, isGeneratedDockerignore } from "../../deploy/container.js";
21
- import { composeHasTunnelService, dockerWebhookPaths, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
22
- import { deployDockerRun } from "../../deploy/docker/run.js";
23
- import { isGeneratedFlyToml, parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "../../deploy/fly/plan.js";
24
- import { deployFlyRun } from "../../deploy/fly/run.js";
25
1
  import { preflightDeploy } from "../../deploy/preflight.js";
26
- import { dockerfilePathVar, isGeneratedRailwayJson, planRailwayDeploy } from "../../deploy/railway/plan.js";
27
- import { deployRailwayRun } from "../../deploy/railway/run.js";
28
- import { spawnRunner } from "../../deploy/runner.js";
29
- import { assembleSecrets } from "../../deploy/secrets.js";
30
- import { loadDotEnv } from "../../env.js";
31
2
  import { loadConfig, resolveModelSpec } from "../../engines/pi/config.js";
32
- import { resolveStateRoot } from "../../paths.js";
33
- import { loadSchedules } from "../../schedule/discover.js";
34
- import { installProxyFetch } from "../../proxy.js";
35
- import { openExternalUrl } from "../../open-url.js";
36
- import { exists } from "../../paths.js";
37
- import { announceWebhooks } from "../../tunnel.js";
38
- import { failStartup, failUsage, placementOrExit } from "../fail.js";
39
- import { resolveFirstRunModel } from "../shared.js";
40
- /** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
41
- function shellArg(value) {
42
- return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
3
+ import { failStartup, failUsage } from "../fail.js";
4
+ import { enterAgentCommand } from "../shared.js";
5
+ import { agentcoreHost } from "./deploy/agentcore.js";
6
+ import { dockerHost } from "./deploy/docker.js";
7
+ import { flyHost } from "./deploy/fly.js";
8
+ import { railwayHost } from "./deploy/railway.js";
9
+ import { writeArtifacts } from "./deploy/shared.js";
10
+ export { writeArtifacts };
11
+ /** Every host, by the name the CLI takes. Typed over {@link DeployHost} so a host added to the list
12
+ * without a module here fails to compile. */
13
+ export const HOSTS = {
14
+ docker: dockerHost,
15
+ fly: flyHost,
16
+ railway: railwayHost,
17
+ agentcore: agentcoreHost,
18
+ };
19
+ /** Infers `Owner` from the literal's own `owner`, so each row carries its exhaustiveness itself. The
20
+ * alternative — annotating the array as a tuple of `HostOnlyFlag<"fly"> | …` — put the whole guarantee
21
+ * in one annotation that a third rule invites relaxing to `HostOnlyFlag<DeployHost>[]`, where
22
+ * `Exclude<DeployHost, DeployHost>` collapses to `never` and every `instead` type-checks empty. */
23
+ const hostOnlyFlag = (rule) => rule;
24
+ /**
25
+ * ONE table for "this flag belongs to that host", because the fact is symmetric and was not stored
26
+ * that way: each host branch stated the OTHER hosts' flags in its own words, so the same sentence
27
+ * existed three times per flag and had already drifted (`Railway-only` twice, `railway-only` once).
28
+ *
29
+ * Every row is a flag that only WARNS elsewhere. `--tunnel` is host-only too and is deliberately not
30
+ * here: it is a usage GATE (`failUsage`, exit 2) raised in `runDeploy`, not a warning — moving it in
31
+ * would downgrade a refusal to a line of advice.
32
+ *
33
+ * Exported for the exhaustiveness test: the type stops a missing `instead` line, and the test stops it
34
+ * from being typed away.
35
+ */
36
+ export const HOST_ONLY_FLAGS = [
37
+ hostOnlyFlag({
38
+ flag: "--stop/--no-scale-to-zero",
39
+ owner: "fly",
40
+ passed: (opts) => opts.stop === true || opts.scaleToZero === false,
41
+ instead: {
42
+ docker: "local Compose stays running",
43
+ railway: "Railway's App Sleeping is a dashboard toggle (the runbook states the manual step)",
44
+ agentcore: "AgentCore's idle/lifetime policy lives in the template's LifecycleConfiguration",
45
+ },
46
+ }),
47
+ hostOnlyFlag({
48
+ flag: "--into-linked",
49
+ owner: "railway",
50
+ passed: (opts) => opts.intoLinked === true,
51
+ instead: {
52
+ docker: "ignored for local Docker",
53
+ agentcore: "ignored for AgentCore",
54
+ fly: "fly --run is idempotent — it reuses an existing app/volume",
55
+ },
56
+ }),
57
+ ];
58
+ /** Exported for its own test: nothing covered these six sentences, which is how three of them came to
59
+ * disagree about the spelling of a host name. */
60
+ export function warnHostOnlyFlags(host, opts) {
61
+ for (const rule of HOST_ONLY_FLAGS) {
62
+ if (host === rule.owner || !rule.passed(opts))
63
+ continue;
64
+ // The `continue` above is exactly the key set `instead` is typed over, but TS cannot narrow a
65
+ // union member out through a comparison against a per-rule literal, so the read needs a cast —
66
+ // and a cast is how a hole would reach the operator as the word "undefined". Unreachable while
67
+ // the rows stay exhaustive, which is what the table's own test asserts.
68
+ const instead = rule.instead[host];
69
+ // THROWN, not `failStartup`ed, though every other failure in this file exits through that: an
70
+ // incomplete table is a bug, and fail.ts prints a plain Error's message WITHOUT its stack (it
71
+ // reserves that shape for user-fixable problems) while exiting the process — which in the test
72
+ // that covers this table would kill the worker instead of reporting which row is short.
73
+ if (instead === undefined) {
74
+ throw new Error(`deploy: HOST_ONLY_FLAGS has no "instead" line for ${host} on ${rule.flag}`);
75
+ }
76
+ // A colon, not "is": one row names a single flag and the other names a pair, and no verb agrees
77
+ // with both (the hand-written copies this replaced said "is" and "are" respectively).
78
+ console.error(`[fastagent] warn: ${rule.flag}: ${rule.owner}-only — ${instead}`);
79
+ }
43
80
  }
44
81
  export async function runDeploy(host, dirArg, opts) {
45
- // ONE deploy semantic: bake the WORKSPACE (WYSIWYG). Artifacts land under the agent dir
46
- // (`fastagent/`) plus the one workspace-root `.dockerignore` the packers require; host CLIs run
47
- // from the workspace, which is the build context.
48
- const placement = placementOrExit(resolve(dirArg));
49
- const { agentDir, workspace } = placement;
50
82
  if (opts.tunnel && host !== "docker") {
51
83
  // A flag/host combination the parser cannot see (host is an argument) — usage class, exit 2.
52
84
  failUsage(`deploy stopped: --tunnel is supported only by the local Docker target`);
53
85
  }
54
- loadDotEnv(agentDir); // a custom provider/tool may read a key at config load
55
- installProxyFetch(); // post-deploy channel API calls must honor HTTP(S)_PROXY under Node
56
- // First-run funnel, FULL picker: the write-back lands the model in fastagent.config.* — exactly what
57
- // the model-travel gate below requires (--model/env don't reach the deployed box) — and an inline
58
- // login stores the credential `--run` then carries. Runs BEFORE loadConfig; the read-back sees the
59
- // rewritten file because loadConfig cache-busts on mtime (a failed write-back still gates, correctly).
60
- await resolveFirstRunModel(agentDir, { model: opts.model, authPath: opts.authPath, input: opts.input });
86
+ // The picker's write-back lands the model in fastagent.config.* — exactly what the model-travel
87
+ // gate below requires (--model/env don't reach the deployed box) and an inline login stores the
88
+ // credential `--run` then carries. Runs BEFORE loadConfig; the read-back sees the rewritten file
89
+ // because loadConfig cache-busts on mtime (a failed write-back still gates, correctly).
90
+ const placement = await enterAgentCommand(dirArg, opts);
91
+ // ONE deploy semantic: bake the WORKSPACE (WYSIWYG). Artifacts land under the agent dir
92
+ // (`fastagent/`) plus the one workspace-root `.dockerignore` the packers require; host CLIs run
93
+ // from the workspace, which is the build context.
94
+ const { agentDir, workspace } = placement;
61
95
  const { config } = await loadConfig(agentDir).catch(failStartup);
62
96
  const modelSpec = resolveModelSpec(opts.model, config);
63
97
  // The host-neutral pre-flight (model-travel gate, channel discovery, model-auth probe, container facts +
@@ -76,585 +110,21 @@ export async function runDeploy(host, dirArg, opts) {
76
110
  failStartup(new Error(`deploy stopped: ${pre.gate}`));
77
111
  for (const m of pre.messages)
78
112
  console.error(`[fastagent] ${m.level}: ${m.text}`);
79
- const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets, } = pre;
80
- const hasDeclaredChannels = routeChannels.length + longConnectionChannels.length > 0;
81
- // Docker: one app service + loopback port + state volume. `--tunnel` shapes the generated topology
82
- // with an optional Quick Tunnel service; `--run` alone decides whether Docker receives side effects.
83
- if (host === "docker") {
84
- if (opts.stop || opts.scaleToZero === false) {
85
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — local Compose stays running`);
86
- }
87
- if (opts.intoLinked) {
88
- console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for local Docker`);
89
- }
90
- const projectName = toDockerProjectName(basename(workspace));
91
- const dockerPlan = (tunnel) => planDockerDeploy({
92
- projectName,
93
- port,
94
- modelAuth,
95
- channels,
96
- longConnectionChannels,
97
- tunnel,
98
- extraSecrets,
99
- ...container,
100
- });
101
- const requestedTunnel = !!opts.tunnel && (!hasDeclaredChannels || routeChannels.length > 0);
102
- if (opts.tunnel && hasDeclaredChannels && routeChannels.length === 0) {
103
- console.error(`[fastagent] note: --tunnel skipped — every channel uses a long connection`);
104
- }
105
- let plan = dockerPlan(requestedTunnel);
106
- // An existing Compose file is authoritative: shape its comparison/runbook from the topology on disk,
107
- // regardless of the current flag. `--force` is the explicit reset to the requested generated shape.
108
- const composeFile = join(workspace, plan.composePath);
109
- let keptWithoutRequestedTunnel = false;
110
- // Same ownership question as fly.toml: a hand-owned compose file survives --force, so the plan must
111
- // describe the topology that will actually be there.
112
- const composeText = await readFile(composeFile, "utf8").catch(() => undefined);
113
- if (composeText !== undefined && (!opts.force || !isGeneratedCompose(composeText))) {
114
- const existingHasTunnel = composeHasTunnelService(composeText);
115
- plan = dockerPlan(existingHasTunnel);
116
- keptWithoutRequestedTunnel = requestedTunnel && !existingHasTunnel;
117
- }
118
- await writeArtifacts(workspace, plan.artifacts, {
119
- force: !!opts.force,
120
- });
121
- if (opts.run) {
122
- return runDeployDocker({
123
- agentDir,
124
- workspace,
125
- composeFile: plan.composePath,
126
- port,
127
- requireTunnel: requestedTunnel,
128
- modelAuth,
129
- modelKeyInDefinition,
130
- authPath,
131
- channels,
132
- longConnectionChannels,
133
- extraSecrets,
134
- });
135
- }
136
- if (keptWithoutRequestedTunnel) {
137
- console.error(`[fastagent] warn: --tunnel was requested but kept ${plan.composePath} has no "tunnel" service — ` +
138
- `edit it, delete it and regenerate, or pass --force`);
139
- }
140
- console.log(plan.runbook.join("\n"));
141
- return;
142
- }
143
- // Railway: thin config file, scale-to-zero is a manual dashboard step, the URL is minted (see
144
- // planRailwayDeploy). --run drives the railway CLI to completion; otherwise print the runbook.
145
- if (host === "railway") {
146
- if (opts.stop || opts.scaleToZero === false) {
147
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — Railway's App Sleeping is a dashboard toggle ` +
148
- `(the runbook states the manual step).`);
149
- }
150
- // Railway service names are project-scoped (not globally unique like a Fly app); slug the dir
151
- // basename so a name with spaces/odd chars can't break the `railway add --service <name>` command.
152
- const serviceName = basename(workspace)
153
- .replace(/[^a-zA-Z0-9-]+/g, "-")
154
- .replace(/^-+|-+$/g, "") || "agent";
155
- const plan = planRailwayDeploy({
156
- serviceName,
157
- modelAuth,
158
- channels,
159
- longConnectionChannels,
160
- extraSecrets,
161
- hasTimeTriggers,
162
- ...container,
163
- });
164
- await writeArtifacts(workspace, plan.artifacts, {
165
- force: !!opts.force,
166
- });
167
- if (opts.run) {
168
- // The BUILD entry is guaranteed by the RAILWAY_DOCKERFILE_PATH service variable the runner sets
169
- // (Railway's documented non-root-Dockerfile route), and Railway's default restart policy already
170
- // equals the file's ON_FAILURE — the dashboard-only Config-as-code pointer only adds the /health
171
- // deploy gate (boot-crash visibility), so it is an OPTIONAL note, not a gate.
172
- console.error(`[fastagent] note: optional — point the service at fastagent/railway.json (Service → Settings → ` +
173
- `Config-as-code, dashboard-only) so the /health healthcheck marks a boot-crashing deploy as FAILED; ` +
174
- `the build already uses fastagent/Dockerfile via the RAILWAY_DOCKERFILE_PATH variable`);
175
- return runDeployRailway({
176
- agentDir,
177
- workspace,
178
- name: serviceName,
179
- modelAuth,
180
- modelKeyInDefinition,
181
- authPath,
182
- channels,
183
- longConnectionChannels,
184
- extraSecrets,
185
- intoLinked: !!opts.intoLinked,
186
- dockerfilePath: dockerfilePathVar(pre.container.agentPrefix),
187
- });
188
- }
189
- console.log(plan.runbook.join("\n"));
190
- return;
191
- }
192
- // AgentCore: one CloudFormation stack (runtime + forwarder Lambda + EventBridge schedules); no
193
- // public URL and no resident process — see deploy/agentcore/plan.ts for the topology decisions.
194
- if (host === "agentcore") {
195
- if (opts.stop || opts.scaleToZero === false) {
196
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — AgentCore's idle/lifetime policy lives in ` +
197
- `the template's LifecycleConfiguration.`);
198
- }
199
- if (opts.intoLinked) {
200
- console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for AgentCore`);
201
- }
202
- // Long-connection channels are STRUCTURALLY unsupported: the connection is the ingress, and a
203
- // reclaimed session has nothing to wake it — events in the gap are silently lost. `--run` gates
204
- // (deploying a channel that can't connect); generate-only warns and prints the runbook.
205
- if (longConnectionChannels.length > 0) {
206
- const msg = `long-connection channel (${longConnectionChannels.join(", ")}) cannot run on AgentCore — there is no ` +
207
- `resident process to hold the connection, and nothing wakes a reclaimed session. Switch the channel ` +
208
- `to webhook mode (its events then ride the forwarder like every other channel).`;
209
- if (opts.run)
210
- failStartup(new Error(`deploy stopped: ${msg}`));
211
- console.error(`[fastagent] warn: ${msg}`);
212
- }
213
- // selfSchedule is fully supported: pending wake-ups are mirrored into one-shot EventBridge
214
- // schedules via the forwarder (the wake-alarm mechanism — see deploy/agentcore/plan.ts).
215
- // Schedules feed EventBridge rules — parsed facts (cron/tz), not just file names, so a bad file
216
- // must surface here (a schedule silently missing its rule would never fire).
217
- const loaded = await loadSchedules(agentDir).catch(failStartup);
218
- if (loaded.failures.length > 0) {
219
- failStartup(new Error(`deploy stopped: cannot load schedules: ${loaded.failures.map((x) => `${x.label}: ${x.message}`).join("; ")}`));
220
- }
221
- const acName = agentcoreName(basename(workspace));
222
- // Every derived AWS name embeds acName; the tightest ceiling is the Lambda function name
223
- // (`fastagent-<name>-forwarder` ≤ 64 chars). Gate the base instead of silently truncating —
224
- // truncation would break the redeploy identity (a renamed stack starts blank state).
225
- if (acName.length > 40) {
226
- failStartup(new Error(`deploy stopped: the directory name maps to "${acName}" (${acName.length} chars) — AWS resource ` +
227
- `names derived from it exceed their limits past 40 chars. Deploy from a shorter directory name.`));
228
- }
229
- const plan = planAgentcoreDeploy({
230
- name: acName,
231
- modelAuth,
232
- channels,
233
- routeChannels,
234
- extraSecrets,
235
- schedules: loaded.schedules.map((s) => ({ name: s.name, cron: s.cron, tz: s.tz })),
236
- selfSchedule: !!config.selfSchedule,
237
- ...container,
238
- });
239
- for (const u of plan.untranslatableSchedules) {
240
- // Same discipline as Fly's kept-toml time-trigger gate: a deploy whose schedule silently never
241
- // fires is worse than a stopped deploy — nothing fails visibly when the instant passes.
242
- const msg = `schedule "${u.name}" cannot be expressed as an EventBridge rule — ${u.reason}`;
243
- if (opts.run)
244
- failStartup(new Error(`deploy stopped: ${msg}`));
245
- console.error(`[fastagent] warn: ${msg} — it will NOT fire on this deployment`);
246
- }
247
- // Host capability limit, stated at plan time. GitHub's own webhook contract is 25 MiB, but a
248
- // Lambda Function URL request caps at 6 MB — so on this host a large payload cannot arrive at
249
- // all. Better a sentence here than an opaque 502 the first time someone pushes a big diff.
250
- if (channels.includes("github")) {
251
- console.error(`[fastagent] note: on AgentCore a webhook body is capped at ~${Math.round(MAX_WEBHOOK_BODY_BYTES / (1 << 20))} MiB ` +
252
- `(the forwarder's Function URL limit); the GitHub channel accepts 25 MiB on a resident host, so the largest ` +
253
- `payloads are rejected here rather than delivered`);
254
- }
255
- // The template IS the topology (EventBridge rules, wake wiring, secrets) — a kept generated
256
- // template that no longer matches the definition would deploy a stack silently missing the
257
- // difference (a new schedule with no rule never fires: the exact miss the gate above stops).
258
- // A hand-written template (marker removed) is the operator's own — kept, never gated.
259
- const templateArtifact = plan.artifacts.find((a) => a.path.endsWith(TEMPLATE_FILE));
260
- const templateHome = join(workspace, templateArtifact?.path ?? TEMPLATE_FILE);
261
- if (!opts.force && templateArtifact && (await exists(templateHome))) {
262
- const existing = await readFile(templateHome, "utf8");
263
- if (isGeneratedAgentcoreTemplate(existing) && existing !== templateArtifact.content) {
264
- const msg = `${templateArtifact.path} no longer matches this definition (channels/schedules/selfSchedule changed) — ` +
265
- `the kept template would silently drop the difference. Pass --force to regenerate (hand edits are lost), ` +
266
- `or delete the file.`;
267
- if (opts.run)
268
- failStartup(new Error(`deploy stopped: ${msg}`));
269
- console.error(`[fastagent] warn: ${msg}`);
270
- }
271
- }
272
- await writeArtifacts(workspace, plan.artifacts, {
273
- force: !!opts.force,
274
- alwaysWrite: [`${container.agentPrefix}${FORWARDER_FILE}`],
275
- });
276
- if (opts.run) {
277
- return runDeployAgentcore({
278
- agentDir,
279
- workspace,
280
- agentPrefix: container.agentPrefix,
281
- name: acName,
282
- modelAuth,
283
- modelKeyInDefinition,
284
- authPath,
285
- channels,
286
- extraSecrets,
287
- selfSchedule: !!config.selfSchedule,
288
- // Same predicate the template uses for the forwarder resource — kept in one expression so
289
- // the run path and the topology cannot disagree about whether a forwarder exists.
290
- needsForwarder: routeChannels.length > 0 || loaded.schedules.length > 0 || !!config.selfSchedule,
291
- });
292
- }
293
- console.log(plan.runbook.join("\n"));
294
- return;
295
- }
296
- // host === "fly".
297
- if (opts.intoLinked) {
298
- console.error(`[fastagent] warn: --into-linked is railway-only (fly --run is idempotent — it reuses an existing app/volume)`);
299
- }
300
- // The replay floor that makes scale-to-zero safe is Telegram-only (its L1 turn store). GitHub turns
301
- // are fire-and-forget (no replay), so the generated fly.toml keeps one machine running for them —
302
- // a note, not a warn, since the plan already did the safe thing (definition-aware autostop).
303
- if (channels.includes("github")) {
304
- console.error(`[fastagent] note: github turns have no replay — the generated fly.toml uses min_machines_running=1 ` +
305
- `(no scale-to-zero) so autostop can't drop an in-flight review. Set it to 0 to accept that trade.`);
306
- }
307
- // Two consistent modes. KEEP (no --force): an existing fly.toml is authoritative — not rewritten,
308
- // and the runbook reads its `app=` (Fly app names are globally unique, so the basename guess may be
309
- // taken and the user renamed it). --force: the template is authoritative — the WHOLE fly.toml resets
310
- // (app→basename, region→iad, vm→defaults), so we do NOT round-trip `app` and warn that hand edits go.
311
- // fly.toml lives in the agent dir (fastagent/fly.toml) — the workspace's own
312
- // fly.toml (if any) belongs to the host's product deploy and is never read or written here.
313
- const flyTomlPath = join(agentDir, "fly.toml");
314
- const flyToml = await readFile(flyTomlPath, "utf8").catch(() => undefined);
315
- // Every decision below turns on ONE question — will `writeArtifacts` keep this file? — and the answer is
316
- // OWNERSHIP, not the flag: `--force` resets a fly.toml we generated and leaves a hand-written one alone.
317
- // Keying these on `--force` alone meant a forced deploy of a hand-owned fly.toml took the app name from
318
- // the directory basename (shipping that file at a DIFFERENT app than it declares) and skipped the
319
- // scale-to-zero gate below (a schedules deploy that silently sleeps). Read once, decide once.
320
- const flyTomlKept = flyToml !== undefined && (!opts.force || !isGeneratedFlyToml(flyToml));
321
- const keptApp = flyTomlKept ? parseFlyAppName(flyToml) : undefined;
322
- const appName = keptApp ?? toFlyAppName(basename(workspace));
323
- if (keptApp)
324
- console.error(`[fastagent] app: ${keptApp} (from fly.toml)`);
325
- if (flyToml !== undefined && !flyTomlKept) {
326
- console.error(`[fastagent] warn: --force resets fly.toml to defaults (app, region, vm) — re-apply any hand edits`);
327
- }
328
- // Autostop flags shape the GENERATED fly.toml only. In KEEP mode (fly.toml exists, no --force) it is
329
- // not rewritten, so the flags would silently do nothing — surface that instead of a confusing no-op.
330
- if (flyTomlKept && (opts.stop || opts.scaleToZero === false)) {
331
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero only shape a freshly generated fly.toml — yours exists and ` +
332
- `was kept. Edit auto_stop_machines/min_machines_running in fly.toml, or pass --force to regenerate.`);
333
- }
334
- // KEEP mode + time triggers: the kept fly.toml may still scale to zero — which would sleep through every
335
- // cron instant / wake-up. The generated plan can't fix a kept file, so surface it instead of the preflight
336
- // note silently not applying (the author who deployed FIRST and added schedules LATER hits exactly this).
337
- // Under `--run` this is a GATE (same discipline as the model-travel gate): a full deploy whose schedules
338
- // silently never fire is worse than a crash-loop — nothing fails visibly when a cron instant passes on a
339
- // sleeping machine, and unlike github's min=0 there is no legitimate trade to accept here.
340
- if (flyTomlKept && (hasTimeTriggers || longConnectionChannels.length > 0)) {
341
- const min = parseFlyMinMachines(flyToml);
342
- if ((min ?? 0) === 0) {
343
- // undefined = the line is absent — Fly's platform default for min_machines_running is 0, so a
344
- // hand-written fly.toml without the line scales to zero exactly like an explicit 0.
345
- const reason = hasTimeTriggers
346
- ? `schedules/self-scheduling need a running machine (no external wake-up)`
347
- : `long-connection channel (${longConnectionChannels.join(", ")}) needs an always-on outbound connection`;
348
- const msg = `your kept fly.toml scales to zero (min_machines_running = ${min ?? "absent → platform default 0"}), but ` +
349
- `${reason}. Set min_machines_running = 1, or pass --force to regenerate.`;
350
- if (opts.run)
351
- failStartup(new Error(`deploy stopped: ${msg}`));
352
- console.error(`[fastagent] warn: ${msg}`);
353
- }
354
- }
355
- const plan = planFlyDeploy({
356
- appName,
357
- port,
358
- modelAuth,
359
- channels,
360
- longConnectionChannels,
361
- extraSecrets,
362
- hasTimeTriggers,
363
- ...container,
364
- autostop: opts.stop ? "stop" : "suspend",
365
- scaleToZero: opts.scaleToZero !== false,
366
- });
367
- await writeArtifacts(workspace, plan.artifacts, {
368
- force: !!opts.force,
369
- });
370
- if (opts.run) {
371
- return runDeployFly({
372
- agentDir,
373
- workspace,
374
- agentPrefix: container.agentPrefix,
375
- appName,
376
- modelAuth,
377
- modelKeyInDefinition,
378
- authPath,
379
- channels,
380
- longConnectionChannels,
381
- flyTomlPath,
382
- extraSecrets,
383
- });
384
- }
385
- console.log(plan.runbook.join("\n"));
386
- }
387
- /**
388
- * Write each generated artifact, under ONE ownership rule: **deploy only ever overwrites its own
389
- * output.** Each generated file opens with a marker, so a file on disk is either OURS (a previous
390
- * `deploy` wrote it) or the author's — and `--force` means "my generated artifact is authoritative",
391
- * which never licenses clobbering a file we did not write. To hand a path back to fastagent, delete it.
392
- *
393
- * That single rule replaced a second mechanism (a per-host list of paths exempt from `--force`), which
394
- * only described one shape: with the agent inside the workspace, the root `.dockerignore` is the
395
- * WORKSPACE's file and was listed; with the agent flat, the list was empty — so `--force` would have
396
- * overwritten a hand-written root `Dockerfile` in a repository `init --flat` had explicitly adopted.
397
- * Ownership is a property of the file, not of where the agent sits.
398
- *
399
- * A KEPT file of OURS that no longer matches what deploy would generate now (config/channel/lockfile/
400
- * version drift — or the author's edits, which we cannot tell apart) is flagged stale; `--force`
401
- * regenerates that one.
402
- */
403
- /** Did fastagent generate the file at `path`? ONE table for all five artifact kinds: a kind missing from
404
- * it is permanently classified as the author's, which silently turns `--force` into a no-op for that
405
- * file (`fly.toml` and `railway.json` were, until the marker predicates below existed). */
406
- function isOurArtifact(path, content) {
407
- if (path.endsWith("Dockerfile"))
408
- return isGeneratedDockerfile(content);
409
- if (path.endsWith(".dockerignore"))
410
- return isGeneratedDockerignore(content);
411
- if (path.endsWith("fastagent.compose.yml"))
412
- return isGeneratedCompose(content);
413
- if (path.endsWith("fly.toml"))
414
- return isGeneratedFlyToml(content);
415
- if (path.endsWith("railway.json"))
416
- return isGeneratedRailwayJson(content);
417
- if (path.endsWith(TEMPLATE_FILE))
418
- return isGeneratedAgentcoreTemplate(content);
419
- return false;
420
- }
421
- /**
422
- * Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
423
- * never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
424
- * machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
425
- * command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
426
- */
427
- export async function writeArtifacts(target, artifacts, options) {
428
- for (const a of artifacts) {
429
- const abs = join(target, a.path);
430
- // Pure build output, not operator-owned configuration. It must track the generated template/runbook.
431
- if (options.alwaysWrite?.includes(a.path)) {
432
- await mkdir(dirname(abs), { recursive: true });
433
- await writeFile(abs, a.content);
434
- console.error(`[fastagent] wrote ${a.path}`);
435
- continue;
436
- }
437
- const existing = (await exists(abs)) ? await readFile(abs, "utf8") : undefined;
438
- const ours = existing !== undefined && isOurArtifact(a.path, existing);
439
- if (existing !== undefined && !ours) {
440
- // Only the `.dockerignore` has content checks in preflight — pointing at "the preflight warnings"
441
- // for the other kinds would send the reader looking for output that is never printed.
442
- console.error(`[fastagent] kept ${a.path} — not generated by fastagent, so --force does not touch it ` +
443
- `(delete it to let deploy own the path)` +
444
- (a.path.endsWith(".dockerignore")
445
- ? `; see the preflight warnings for what it must exclude`
446
- : a.path.endsWith("Dockerfile")
447
- ? `; deploy still assumes it listens on $PORT and runs \`fastagent start /app\``
448
- : ``));
449
- continue;
450
- }
451
- if (existing !== undefined && !options.force) {
452
- console.error(existing !== a.content
453
- ? `[fastagent] kept ${a.path} — it no longer matches what deploy would generate (config changed, or ` +
454
- `you edited it); pass --force to regenerate.`
455
- : `[fastagent] kept ${a.path} (unchanged)`);
456
- continue;
457
- }
458
- await mkdir(dirname(abs), { recursive: true }); // artifacts live under fastagent/
459
- await writeFile(abs, a.content);
460
- console.error(`[fastagent] wrote ${a.path}`);
461
- }
462
- }
463
- function deployEnvironment(agentDir, channels) {
464
- if (!channels.includes("slack"))
465
- return process.env;
466
- const latest = readSlackBotAuthEnv(join(resolveStateRoot(agentDir), "channels", "slack", "bot-auth.json"));
467
- return { ...process.env, ...latest };
468
- }
469
- /**
470
- * `deploy docker --run`: carry local credentials into Compose's child environment, then reconcile the
471
- * user-owned local topology. Docker owns container/network/volume lifecycle. A Compose tunnel service,
472
- * when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
473
- */
474
- async function runDeployDocker(params) {
475
- const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, } = params;
476
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
477
- modelAuth,
478
- modelKeyInDefinition,
479
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
480
- channels,
481
- longConnectionChannels,
482
- extraSecrets,
483
- env: deployEnvironment(agentDir, channels),
484
- });
485
- const outcome = await deployDockerRun({ composeFile, port, secrets, missingSecrets, needsModelCredential, requireTunnel }, spawnRunner("docker", workspace), (message) => console.error(`[fastagent] ${message}`));
486
- if (!outcome.ok)
487
- failStartup(new Error(`deploy stopped: ${outcome.gate}`));
488
- const compose = `docker compose -f ${composeFile}`;
489
- console.error(`[fastagent] running${outcome.url ? ` → ${outcome.url}` : ""}`);
490
- console.error(`[fastagent] logs: ${compose} logs -f agent`);
491
- console.error(`[fastagent] stop: ${compose} down (state volume is kept)`);
492
- if (outcome.tunnelUrl) {
493
- // Docker Desktop commonly injects a host proxy. The Quick Tunnel hostname may be resolvable only
494
- // through it, exactly like provider/channel APIs; use the same Node dispatcher as dev/start/login.
495
- installProxyFetch();
496
- await announceWebhooks(agentDir, outcome.tunnelUrl, {
497
- openUrl: openExternalUrl,
498
- routeChannels: channels.filter((kind) => !longConnectionChannels.includes(kind)),
499
- stateRoot: resolveStateRoot(agentDir),
500
- });
501
- console.error(`[fastagent] note: Quick Tunnel URLs are ephemeral — after the tunnel container/Docker daemon ` +
502
- `restarts, re-run this deploy so webhooks receive the new URL`);
503
- return;
504
- }
505
- const paths = dockerWebhookPaths(channels.filter((kind) => !longConnectionChannels.includes(kind)));
506
- if (paths.length > 0) {
507
- console.error(`[fastagent] note: public ingress is operator-owned — configure your tunnel/proxy, then wire the ` +
508
- `default webhook path(s): ${paths.join(", ")} (or your remapped channel routes)`);
509
- }
510
- }
511
- /**
512
- * `deploy fly --run`: drive flyctl to completion (idempotent, resumable). Gathers the secret VALUES
513
- * from the local env — the model key (env auth) or the whole auth.json as a `FASTAGENT_AUTH_SEED` seed
514
- * (OAuth/stored auth: the deployed box materializes it onto the /data volume on first boot, so a
515
- * personal deploy runs on the SAME subscription) plus channel secrets — then runs the flyctl steps
516
- * behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the whole workspace).
517
- */
518
- async function runDeployFly(params) {
519
- const { agentDir, workspace, agentPrefix, appName, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
520
- const fly = spawnRunner("fly", workspace);
521
- // Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
522
- if ((await fly(["version"], { capture: true })).code === 127) {
523
- failStartup(new Error(`flyctl not found — install it: https://fly.io/docs/flyctl/install, then re-run`));
524
- }
525
- const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
526
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
527
- modelAuth,
528
- modelKeyInDefinition,
529
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
530
- channels,
531
- longConnectionChannels,
532
- extraSecrets,
533
- env: deployEnvironment(agentDir, channels),
534
- });
535
- // Model credential has its OWN remediation (login), distinct from a missing secret's (.env) — gate it
536
- // here, not through missingSecrets, so the message isn't a contradictory mash of both.
537
- if (needsModelCredential) {
538
- failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
539
- }
540
- const outcome = await deployFlyRun({
541
- appName,
542
- region,
543
- secrets,
544
- missingSecrets,
545
- channels,
546
- longConnectionChannels,
547
- flyConfig: `${agentPrefix}fly.toml`,
548
- dockerfile: `${agentPrefix}Dockerfile`,
549
- }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
550
- if (!outcome.ok)
551
- failStartup(new Error(`deploy stopped: ${outcome.gate}`));
552
- console.error(`[fastagent] deployed → https://${appName}.fly.dev`);
553
- }
554
- /**
555
- * `deploy agentcore --run`: drive aws + docker to completion. Mirrors {@link runDeployFly} — same
556
- * credential carry via {@link assembleSecrets}, same runner seam (spawned `aws` + `docker`, cwd = the
557
- * workspace so the build context is the agent). The AgentCore-specific sequence (identity → buildx →
558
- * ECR → CloudFormation → outputs → webhooks) lives in {@link deployAgentcoreRun}; the params temp
559
- * file (secret values off argv) is created here — 0600, removed after the run either way.
560
- */
561
- async function runDeployAgentcore(params) {
562
- const { agentDir, workspace, agentPrefix, name, modelAuth, modelKeyInDefinition, authPath, channels, extraSecrets, selfSchedule, } = params;
563
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
564
- modelAuth,
565
- modelKeyInDefinition,
566
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
567
- channels,
568
- extraSecrets,
569
- env: deployEnvironment(agentDir, channels),
570
- });
571
- // The wake-alarm shared secret (container ↔ forwarder). Minted fresh each run — both sides receive
572
- // the SAME parameter, so rotation is atomic; it never needs to be remembered locally.
573
- if (selfSchedule)
574
- secrets.FASTAGENT_WAKE_SECRET = crypto.randomUUID();
575
- // The forwarder→runtime ingress secret: what makes an envelope the forwarder's rather than any IAM
576
- // principal's. Minted fresh each run — both sides receive the SAME parameter, so rotation is atomic.
577
- if (params.needsForwarder)
578
- secrets.FASTAGENT_INGRESS_SECRET = crypto.randomUUID();
579
- if (needsModelCredential) {
580
- failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
581
- }
582
- // The params temp dir holds the ONE file carrying secret values (file:// parameter-overrides —
583
- // never argv); 0700/0600 and removed after the run, success or gate.
584
- const paramsDir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-"));
585
- try {
586
- const outcome = await deployAgentcoreRun({
587
- name,
588
- templatePath: `${agentPrefix}${TEMPLATE_FILE}`,
589
- dockerfilePath: agentPrefix ? `${agentPrefix}Dockerfile` : undefined,
590
- tag: new Date()
591
- .toISOString()
592
- .replace(/[-:.TZ]/g, "")
593
- .slice(0, 14),
594
- region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION,
595
- secrets,
596
- missingSecrets,
597
- channels,
598
- // Same predicate the template uses: the forwarder exists for route channels, cron schedules
599
- // or selfSchedule — and it is what mints the state snapshot's presigned URLs.
600
- needsForwarder: params.needsForwarder,
601
- }, spawnRunner("aws", workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
602
- const path = join(paramsDir, "params.json");
603
- await writeFile(path, content, { mode: 0o600 });
604
- await chmod(path, 0o600);
605
- return path;
606
- }, async (bytes) => {
607
- const path = join(paramsDir, "forwarder.zip");
608
- await writeFile(path, bytes);
609
- return path;
610
- }, (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
611
- if (!outcome.ok)
612
- failStartup(new Error(`deploy stopped: ${outcome.gate}`));
613
- console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
614
- if (outcome.url)
615
- console.error(`[fastagent] webhook ingress → ${outcome.url}`);
616
- const logsDir = shellArg(workspace);
617
- console.error(`[fastagent] runtime logs → fastagent logs agentcore ${logsDir} --follow`);
618
- if (params.needsForwarder) {
619
- console.error(`[fastagent] forwarder logs → fastagent logs agentcore ${logsDir} --source forwarder --follow`);
620
- }
621
- console.error(`[fastagent] invoke: aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ${outcome.runtimeArn} \\\n` +
622
- ` --runtime-session-id "my-conversation-000000000000000000" \\\n` +
623
- ` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
624
- }
625
- finally {
626
- await rm(paramsDir, { recursive: true, force: true });
627
- }
628
- }
629
- /**
630
- * `deploy railway --run`: drive the railway CLI to completion. Mirrors {@link runDeployFly} — same
631
- * credential carry (env key OR the OAuth auth.json as `FASTAGENT_AUTH_SEED`) via {@link assembleSecrets},
632
- * same runner seam (spawned `railway`, cwd = the workspace so `railway up`'s upload is the whole workspace). The
633
- * Railway-specific sequence (linked-check → init/add/volume when fresh → variables → up → domain →
634
- * webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
635
- */
636
- async function runDeployRailway(params) {
637
- const { agentDir, workspace, name, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
638
- const railway = spawnRunner("railway", workspace);
639
- // Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
640
- if ((await railway(["--version"], { capture: true })).code === 127) {
641
- failStartup(new Error(`railway CLI not found — install it: https://docs.railway.com/guides/cli, then re-run`));
642
- }
643
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
644
- modelAuth,
645
- modelKeyInDefinition,
646
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
113
+ const { channels } = pre;
114
+ warnHostOnlyFlags(host, opts);
115
+ const target = HOSTS[host];
116
+ await target.deploy({
117
+ opts,
118
+ agentDir,
119
+ workspace,
120
+ config,
121
+ pre,
647
122
  channels,
648
- longConnectionChannels,
649
- extraSecrets,
650
- env: deployEnvironment(agentDir, channels),
123
+ webhookChannels: channels.filter((channel) => channel.ingress === "webhook"),
124
+ longConnectionChannels: channels.filter((channel) => channel.ingress === "long-connection"),
125
+ // The ownership predicate is bound HERE, from the same lookup that chose the host, so no host
126
+ // module can pass one — the four that did each stated theirs twice, in a position where a
127
+ // neighbour's type-checks and silently makes `--force` a no-op for the host's own artifact.
128
+ write: (artifacts, options) => writeArtifacts(workspace, artifacts, { ...options, isOurs: target.isOurs }),
651
129
  });
652
- // Model credential has its OWN remediation (login), distinct from a missing secret's (.env).
653
- if (needsModelCredential) {
654
- failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
655
- }
656
- const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, longConnectionChannels, intoLinked, dockerfilePath }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
657
- if (!outcome.ok)
658
- failStartup(new Error(`deploy stopped: ${outcome.gate}`));
659
- console.error(`[fastagent] deployed → ${outcome.url}`);
660
130
  }