@fastagent-sh/fastagent 0.12.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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
3
+ * FASTAGENT_AUTH_SEED materialization. ABSENT-ONLY by design: a present file (a refreshed volume copy)
4
+ * is never overwritten by the stale seed, so a box that ran its own OAuth refresh is not rolled back.
5
+ */
6
+ export function authSeedBytes(seed, fileExists) {
7
+ return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
8
+ }
9
+ /** Whether a `fly … list --json` array contains an object named `name` (Fly capitalizes `Name`; accept both). */
10
+ function listHasName(stdout, name) {
11
+ try {
12
+ const arr = JSON.parse(stdout);
13
+ return (Array.isArray(arr) &&
14
+ arr.some((o) => o.Name === name || o.name === name));
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * Run the deploy through `fly`. `log` reports progress; `registerTelegram(baseUrl)` performs the
22
+ * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
23
+ */
24
+ export async function deployFlyRun(plan, fly, log, registerTelegram) {
25
+ const gate = (g) => ({ ok: false, gate: g });
26
+ // 1. Auth is the one gate a coding agent can't clear itself (browser OAuth). `whoami` succeeds with
27
+ // either an interactive login or FLY_API_TOKEN, so one check covers both.
28
+ if ((await fly(["auth", "whoami"], { capture: true })).code !== 0) {
29
+ return gate("not logged in to Fly — run `fly auth login` (opens a browser), or set FLY_API_TOKEN, then re-run");
30
+ }
31
+ // 2. Gate missing required secret VALUES before any side effect (no half-created infra).
32
+ if (plan.missingSecrets.length > 0) {
33
+ return gate(`no local value for: ${plan.missingSecrets.join(", ")} — set them in .env (or the environment) and re-run`);
34
+ }
35
+ // 3. App — idempotent (create only if absent; a taken global name is a gate). A FAILED list is its own
36
+ // gate: inferring "absent" from an errored query would then misreport the create as a name clash.
37
+ const appsList = await fly(["apps", "list", "--json"], { capture: true });
38
+ if (appsList.code !== 0)
39
+ return gate("`fly apps list` failed — see the flyctl output above; fix and re-run");
40
+ if (listHasName(appsList.stdout, plan.appName)) {
41
+ log(`app ${plan.appName} exists — skipping create`);
42
+ }
43
+ else {
44
+ log(`creating app ${plan.appName}…`);
45
+ if ((await fly(["apps", "create", plan.appName])).code !== 0) {
46
+ return gate(`\`fly apps create ${plan.appName}\` failed — Fly app names are globally unique and it may be taken. ` +
47
+ `Set a unique \`app\` in fly.toml and re-run.`);
48
+ }
49
+ }
50
+ // 4. Volume — idempotent; region comes from fly.toml (must match the machine's region). A failed list
51
+ // gates for the same reason as the app list above.
52
+ const volList = await fly(["volumes", "list", "-a", plan.appName, "--json"], { capture: true });
53
+ if (volList.code !== 0)
54
+ return gate("`fly volumes list` failed — see the flyctl output above; fix and re-run");
55
+ if (listHasName(volList.stdout, "data")) {
56
+ log(`volume data exists — skipping create`);
57
+ }
58
+ else {
59
+ log(`creating volume data in ${plan.region}…`);
60
+ if ((await fly(["volumes", "create", "data", "-a", plan.appName, "--region", plan.region, "--size", "1", "--yes"]))
61
+ .code !== 0) {
62
+ return gate("`fly volumes create` failed — see the flyctl output above");
63
+ }
64
+ }
65
+ // 5. Secrets — staged (no deploy yet; we deploy with fly.toml next). Values over stdin, not argv.
66
+ const keys = Object.keys(plan.secrets);
67
+ if (keys.length > 0) {
68
+ log(`setting ${keys.length} secret(s): ${keys.join(", ")}`);
69
+ const input = `${keys.map((k) => `${k}=${plan.secrets[k]}`).join("\n")}\n`;
70
+ if ((await fly(["secrets", "import", "--stage", "-a", plan.appName], { input })).code !== 0) {
71
+ return gate("`fly secrets import` failed — see the flyctl output above");
72
+ }
73
+ }
74
+ // 6. Deploy — remote builder (no local Docker), one machine.
75
+ log("deploying (remote build)…");
76
+ if ((await fly(["deploy", "-a", plan.appName, "-c", plan.flyConfig, "--remote-only", "--yes", "--ha=false"])).code !== 0) {
77
+ return gate("`fly deploy` failed — see the flyctl output above; fix and re-run");
78
+ }
79
+ // 7. Post-deploy webhook — telegram end-to-end (fastagent has the token + the live URL); github is a
80
+ // repo-settings step only a human can do.
81
+ if (plan.channels.includes("telegram")) {
82
+ log("registering telegram webhook…");
83
+ await registerTelegram(`https://${plan.appName}.fly.dev`);
84
+ }
85
+ if (plan.channels.includes("github")) {
86
+ log(`github: set the webhook in the repo (Settings → Webhooks) → https://${plan.appName}.fly.dev/webhook`);
87
+ }
88
+ return { ok: true };
89
+ }
@@ -0,0 +1,59 @@
1
+ import type { FastagentConfig } from "../engines/pi/config.ts";
2
+ import { type ChannelKind } from "../scaffold/add-channel.ts";
3
+ import { type ContainerInput } from "./container.ts";
4
+ /** A stderr line the CLI prints (`[fastagent] warn: …` / `[fastagent] note: …`). Host-neutral advisories. */
5
+ export interface DeployMessage {
6
+ level: "warn" | "note";
7
+ text: string;
8
+ }
9
+ /** The resolved facts every host plan needs (the container shape, channels, model auth, ports/secrets). */
10
+ export interface DeployFacts {
11
+ messages: DeployMessage[];
12
+ channels: ChannelKind[];
13
+ /** Whether the agent has TIME triggers — `schedules/` files or `selfSchedule` (the wake tool). Cron/wake
14
+ * has no external wake-up, so the deployment must keep one machine running: the fly plan forces
15
+ * `min_machines_running=1`, the railway runbook forbids App Sleeping. */
16
+ hasTimeTriggers: boolean;
17
+ /** What satisfies model auth locally ({@link probeAuthSource}) — an env-var name, an OAuth/stored label,
18
+ * or undefined. Drives the runbook's secret guidance and `--run`'s credential carry. */
19
+ modelAuth: string | undefined;
20
+ /** The project-level auth file `--run` reads to carry the credential (probed with the same path). */
21
+ authPath: string;
22
+ /** Container facts shared by the plan and the generated Dockerfile — ONE source, so they can't drift. */
23
+ container: ContainerInput;
24
+ port: number;
25
+ extraSecrets: string[];
26
+ }
27
+ /** Done (facts for the host branch), or a hard gate the CLI stops on (a model that won't reach the box). */
28
+ export type DeployPreflight = {
29
+ ok: false;
30
+ gate: string;
31
+ } | ({
32
+ ok: true;
33
+ } & DeployFacts);
34
+ /**
35
+ * Run the host-neutral pre-flight. Throws on a real fault (an unreadable channels/ dir, a throwing
36
+ * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
37
+ */
38
+ export declare function preflightDeploy(input: {
39
+ target: string;
40
+ /** The agent-definition dir (config.agentDir resolved against target; = target when unset) — where
41
+ * channels are discovered. Container facts (package.json/lockfile) still read `target`, the run root. */
42
+ agentDir: string;
43
+ config: FastagentConfig;
44
+ modelSpec: string | undefined;
45
+ /** `--run` fully deploys, so a model that won't travel is a GATE (a known crash-loop); else it warns. */
46
+ run: boolean;
47
+ /** `--force` regenerates artifacts, so the kept-hand-written-Dockerfile apt warning does not apply. */
48
+ force: boolean;
49
+ /** `--auth-path` / `FASTAGENT_AUTH_PATH`; falls back to the project default `<state root>/auth.json`. */
50
+ authPathOverride: string | undefined;
51
+ }): Promise<DeployPreflight>;
52
+ /**
53
+ * Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
54
+ * is the model's committed home (config's charter: model / tools / http) and the only source deploy ships:
55
+ * a `--model`/`FASTAGENT_MODEL`/`.env` value is builder-local and doesn't travel (`.env` is dockerignored),
56
+ * so a model NOT in config crash-loops the box with "missing model". The pre-flight warns (runbook) or gates
57
+ * (`--run`). Single source on purpose — a host env block (fly.toml `[env]`) is NOT advertised as a second home.
58
+ */
59
+ export declare function modelTravelIssue(configModel: string | undefined, modelSpec: string | undefined): string | undefined;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The host-NEUTRAL deploy pre-flight: everything `fastagent deploy <host>` computes and checks BEFORE
3
+ * the host branch (fly.ts / railway.ts). Model-travel gate, channel discovery, model-auth probe, the
4
+ * container facts + their warnings, and the hand-written-Dockerfile apt warning are identical on every
5
+ * host — so they live here, out of the CLI dispatcher, testable in isolation (call it against a temp dir
6
+ * and assert the gate / messages / facts). The CLI stays thin: run this, print the messages, branch by host.
7
+ *
8
+ * It returns messages rather than printing them (the CLI owns stderr) and a `{ ok }` outcome mirroring
9
+ * the run modules' {@link import("./fly/run.ts").FlyRunOutcome}: a model that won't travel is a GATE the
10
+ * CLI stops on, distinct from the advisory warnings/notes it prints and proceeds past.
11
+ */
12
+ import { readFile } from "node:fs/promises";
13
+ import { join, relative, sep } from "node:path";
14
+ import { defaultAuthPath, resolveStateRoot } from "../engines/pi/config.js";
15
+ import { discoverChannelFiles } from "../engines/pi/channel.js";
16
+ import { discoverScheduleFiles } from "../schedule/discover.js";
17
+ import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
18
+ import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
19
+ import { exists } from "../scaffold/init.js";
20
+ import { detectRuntime, readPackageJson } from "../runtime.js";
21
+ import { fastagentVersion } from "../version.js";
22
+ import { isGeneratedDockerfile } from "./container.js";
23
+ /**
24
+ * Run the host-neutral pre-flight. Throws on a real fault (an unreadable channels/ dir, a throwing
25
+ * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
26
+ */
27
+ export async function preflightDeploy(input) {
28
+ const { target, agentDir, config, modelSpec, run, force, authPathOverride } = input;
29
+ const messages = [];
30
+ // The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
31
+ // env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
32
+ const modelIssue = modelTravelIssue(config.model, modelSpec);
33
+ if (modelIssue) {
34
+ if (run)
35
+ return { ok: false, gate: modelIssue };
36
+ messages.push({ level: "warn", text: modelIssue });
37
+ }
38
+ // Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
39
+ // author wire them.
40
+ const discovered = await discoverChannelFiles(agentDir);
41
+ const channels = discovered.filter((c) => CHANNEL_KINDS.includes(c));
42
+ for (const c of discovered) {
43
+ if (!channels.includes(c)) {
44
+ messages.push({ level: "note", text: `channel "${c}" is custom — set its secrets and webhook yourself` });
45
+ }
46
+ }
47
+ // Time triggers (static schedules or self-scheduling) need a machine kept running — unlike a webhook,
48
+ // nothing external wakes a scale-to-zero box for a cron instant or a wake-up. The note is CONDITIONAL
49
+ // ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
50
+ // when a kept fly.toml still scales to zero.
51
+ const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
52
+ if (hasTimeTriggers) {
53
+ messages.push({
54
+ level: "note",
55
+ text: `schedules/self-scheduling present — a GENERATED plan keeps one machine running (cron/wake has ` +
56
+ `no external wake-up; scale-to-zero would sleep through them).`,
57
+ });
58
+ }
59
+ // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
60
+ // miss a `fastagent login` credential and falsely report "none configured".
61
+ const authPath = authPathOverride ?? defaultAuthPath(resolveStateRoot(target));
62
+ const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
63
+ // Container facts (shared by every host) + the warnings that follow. Repo-as-workspace layout
64
+ // (agentDir ≠ target): the facts describe the KIT — its package.json/runtime/lockfile drive the
65
+ // image's install step — never the host repo's (whose manifest belongs to the host's own deploy).
66
+ // POSIX-normalized: kitDir lands verbatim in Dockerfile COPY/CMD lines and fly/railway commands,
67
+ // which all require forward slashes (a Windows `relative()` would emit backslashes).
68
+ const kitDir = agentDir === target ? undefined : relative(target, agentDir).split(sep).join("/");
69
+ const factsDir = kitDir ? agentDir : target;
70
+ if (kitDir && run) {
71
+ // Generate + runbook fully support the layout; the fly/railway --run drivers do not yet (their
72
+ // command sequences assume root artifacts). Fail at the gate, not mid-drive.
73
+ return {
74
+ ok: false,
75
+ gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
76
+ };
77
+ }
78
+ const hasPackageJson = await exists(join(factsDir, "package.json"));
79
+ const pkg = await readPackageJson(factsDir);
80
+ const { runtime, bunVersion, hasLockfile } = detectRuntime(factsDir, pkg);
81
+ const install = runtime === "bun" ? "bun install" : "npm install";
82
+ const runner = runtime === "bun" ? "bun run fastagent" : "./node_modules/.bin/fastagent";
83
+ const hasOtherLock = runtime === "node" &&
84
+ ((await exists(join(factsDir, "pnpm-lock.yaml"))) || (await exists(join(factsDir, "yarn.lock"))));
85
+ if (kitDir) {
86
+ // After the facts: the deps sentence must match the kit's actual shape (a markdown-only kit has no
87
+ // package.json and installs nothing — the note must not point at a file that doesn't exist).
88
+ const deps = hasPackageJson
89
+ ? `only the kit's deps (${kitDir}/package.json) are installed — the host repo's own deps are the agent's runtime concern`
90
+ : `the kit has no package.json, so no deps are installed (the pinned global CLI serves the repo)`;
91
+ messages.push({
92
+ level: "note",
93
+ text: `repo-as-workspace image (EXPERIMENTAL — not yet verified end-to-end on a real host): the whole ` +
94
+ `repo is baked as the agent's cwd; ${deps}. Un-pushed changes on the box do not survive a redeploy ` +
95
+ `(the image is a snapshot); write-back goes through git (persona owns the policy; GH_TOKEN etc. go ` +
96
+ `in config.deploy.secrets — see the runbook's caveat on .git surviving the host's upload).`,
97
+ });
98
+ }
99
+ // A code workspace with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
100
+ // reproducible. A pnpm/yarn user gets an accurate message (their lockfile is ignored by the npm Dockerfile).
101
+ if (hasPackageJson && !hasLockfile) {
102
+ const lock = runtime === "bun" ? "bun.lock" : "package-lock.json";
103
+ messages.push({
104
+ level: "warn",
105
+ text: hasOtherLock
106
+ ? `the generated Dockerfile is npm-based — your pnpm/yarn lockfile is NOT used (build runs ` +
107
+ `\`npm install\`, not reproducible). Edit the Dockerfile for your package manager, or vendor a package-lock.json.`
108
+ : `no ${lock} — the image build resolves deps at build time (not reproducible). ` +
109
+ `Run \`${install}\` and commit the lockfile for pinned redeploys.`,
110
+ });
111
+ }
112
+ // The code-path Dockerfile runs `${runner}` — the workspace's OWN local dependency, never the
113
+ // registry — so a package.json missing it means the container fails at start (no bin to run).
114
+ if (hasPackageJson && !("@fastagent-sh/fastagent" in { ...pkg.dependencies, ...pkg.devDependencies })) {
115
+ messages.push({
116
+ level: "warn",
117
+ text: `package.json does not list @fastagent-sh/fastagent — the image's \`${runner}\` has no local bin to run, ` +
118
+ `so the container fails at start. Add it to dependencies and re-run \`${install}\`.`,
119
+ });
120
+ }
121
+ // A kept host root .dockerignore silently replaces KIT_DOCKERIGNORE's two protections — read it and
122
+ // warn SPECIFICALLY (the generic "kept" line suggests --force, which would clobber the host's file):
123
+ // (a) a .git exclude kills the baked write-back (the runtime-clone fallback applies); (b) without a
124
+ // recursive **/node_modules the build machine's kit deps (native binaries) clobber the image's.
125
+ // Not force-gated: the host's root .dockerignore is kept even under --force (never ours to clobber),
126
+ // so these warnings apply regardless.
127
+ if (kitDir && (await exists(join(target, ".dockerignore")))) {
128
+ const lines = (await readFile(join(target, ".dockerignore"), "utf8")).split("\n").map((l) => l.trim());
129
+ if (lines.some((l) => l === ".git" || l === "/.git" || l === ".git/" || l === "**/.git")) {
130
+ messages.push({
131
+ level: "warn",
132
+ text: `your .dockerignore excludes .git — the baked repo ships WITHOUT history/remote, so the agent ` +
133
+ `cannot commit/push the baked copy; it must \`git clone\` its repo in the workspace instead ` +
134
+ `(or remove the .git line).`,
135
+ });
136
+ }
137
+ if (!lines.some((l) => l === "**/node_modules" || l === "**/node_modules/")) {
138
+ messages.push({
139
+ level: "warn",
140
+ text: `your .dockerignore lacks \`**/node_modules\` — the build machine's ${kitDir}/node_modules ` +
141
+ `(native binaries for YOUR OS) would be uploaded and clobber the image's freshly-installed ones. ` +
142
+ `Add \`**/node_modules\` to it.`,
143
+ });
144
+ }
145
+ }
146
+ // Write-back mechanics are fastagent's (the policy is the persona's): a kit-layout image always
147
+ // carries git, so commit/push can work at all. Merged with (never duplicating) config.deploy.apt.
148
+ const apt = kitDir ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
149
+ const container = {
150
+ hasPackageJson,
151
+ runtime,
152
+ bunVersion,
153
+ hasLockfile,
154
+ version: await fastagentVersion(),
155
+ apt,
156
+ kitDir,
157
+ };
158
+ const port = config.http?.port ?? 8787;
159
+ // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
160
+ // secrets: listed in the runbook, set from the local env under --run, gated if a value is missing.
161
+ const extraSecrets = config.deploy?.secrets ?? [];
162
+ // deploy.apt only shapes the GENERATED Dockerfile. Warn ONLY when the kept Dockerfile is HAND-WRITTEN
163
+ // (its apt won't include these) — a fastagent-generated one is handled by writeArtifacts. Don't suggest
164
+ // --force here: it would overwrite the user's hand-written file.
165
+ const dockerfileHome = kitDir ? join(agentDir, "Dockerfile") : join(target, "Dockerfile");
166
+ if (config.deploy?.apt?.length && !force && (await exists(dockerfileHome))) {
167
+ if (!isGeneratedDockerfile(await readFile(dockerfileHome, "utf8"))) {
168
+ messages.push({
169
+ level: "warn",
170
+ text: `kept your hand-written Dockerfile — deploy.apt (${config.deploy.apt.join(", ")}) is ` +
171
+ `NOT applied; install those packages in your Dockerfile.`,
172
+ });
173
+ }
174
+ }
175
+ return { ok: true, messages, channels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets };
176
+ }
177
+ /**
178
+ * Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
179
+ * is the model's committed home (config's charter: model / tools / http) and the only source deploy ships:
180
+ * a `--model`/`FASTAGENT_MODEL`/`.env` value is builder-local and doesn't travel (`.env` is dockerignored),
181
+ * so a model NOT in config crash-loops the box with "missing model". The pre-flight warns (runbook) or gates
182
+ * (`--run`). Single source on purpose — a host env block (fly.toml `[env]`) is NOT advertised as a second home.
183
+ */
184
+ export function modelTravelIssue(configModel, modelSpec) {
185
+ if (configModel)
186
+ return undefined;
187
+ return modelSpec
188
+ ? `model "${modelSpec}" is set via --model/FASTAGENT_MODEL, not fastagent.config.ts — it won't reach ` +
189
+ `the deployed box. Add \`model: "${modelSpec}"\` to fastagent.config.ts.`
190
+ : `no model in fastagent.config.ts — the deployed box can't resolve one. Add \`model: "provider/id"\`.`;
191
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `fastagent deploy railway` — the Railway deploy PLAN, computed from the resolved definition. Pure:
3
+ * facts in, artifact contents + an ordered runbook out; the CLI writes the files and prints the runbook.
4
+ *
5
+ * Railway is the second target, and it is NOT a copy of Fly — three asymmetries drive this module:
6
+ *
7
+ * 1. The config file is thin. `railway.json` (config-as-code) holds ONLY build/deploy settings; the
8
+ * volume, the variables (state root + secrets), and App Sleeping are Railway service settings applied
9
+ * by CLI/dashboard, not the file. So Fly's "one committed file is the single source" does not carry:
10
+ * Railway's source of truth is the linked project's platform state, not a file we generate.
11
+ *
12
+ * 2. Scale-to-zero is not scriptable. Railway's App Sleeping is a dashboard-only toggle (no CLI/API),
13
+ * so unlike Fly's `auto_stop_machines`, we cannot generate it — the runbook states the manual step.
14
+ * This is a real capability downgrade vs Fly, named rather than hidden.
15
+ *
16
+ * 3. The public URL is minted, not deterministic. Fly gives `<app>.fly.dev` up front; Railway mints a
17
+ * `*.up.railway.app` domain that must be read back (`railway domain`), so the webhook step points at
18
+ * "the domain from `railway domain`" rather than a precomputed URL.
19
+ *
20
+ * What IS shared with Fly comes from the neutral modules: the container (Dockerfile + .dockerignore) and
21
+ * the required-secret list. `railway.json`'s `healthcheckPath=/health` also fixes the "routed before the
22
+ * server is listening" boot race Fly's deploy hit — Railway only routes once /health passes.
23
+ */
24
+ import type { ChannelKind } from "../../scaffold/add-channel.ts";
25
+ import { type Artifact, type ContainerInput } from "../container.ts";
26
+ export interface RailwayPlanInput extends ContainerInput {
27
+ /** The service name to create (`railway add --service`). Railway service names are project-scoped, not
28
+ * globally unique (unlike a Fly app), so the CLI derives it from the dir basename — any value works. */
29
+ serviceName: string;
30
+ /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
31
+ modelAuth: string | undefined;
32
+ /** Channels discovered in the workspace — each contributes its required secrets + webhook step. */
33
+ channels: ChannelKind[];
34
+ /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
35
+ extraSecrets?: string[];
36
+ /** Time triggers present (schedules/ or selfSchedule) — the runbook forbids App Sleeping: cron/wake has
37
+ * no external wake-up, so a sleeping service sleeps through them. Required (like FlyPlanInput's) so a
38
+ * caller can't silently omit it and degrade the sleeping guidance to "optional". */
39
+ hasTimeTriggers: boolean;
40
+ }
41
+ export interface RailwayPlan {
42
+ /** railway.json / Dockerfile / .dockerignore — written by the CLI (skipped if present unless --force). */
43
+ artifacts: Artifact[];
44
+ /** The ordered, values-resolved deploy runbook — printed to stdout for the coding agent to execute. */
45
+ runbook: string[];
46
+ }
47
+ /** Compute the Railway deploy plan from the resolved definition. */
48
+ export declare function planRailwayDeploy(input: RailwayPlanInput): RailwayPlan;
@@ -0,0 +1,91 @@
1
+ import { containerArtifacts } from "../container.js";
2
+ import { isEnvKey, requiredSecrets } from "../secrets.js";
3
+ /** State root = the volume mount path, kept in lockstep. `/data` matches the Fly recipe. */
4
+ const MOUNT = "/data";
5
+ /** railway.json — build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
6
+ * are service settings the runbook applies via CLI. healthcheckPath gates routing on a live server. */
7
+ function railwayJson(kitDir) {
8
+ return `${JSON.stringify({
9
+ $schema: "https://railway.com/railway.schema.json",
10
+ // dockerfilePath is relative to the repo root (`railway up`'s upload context) in BOTH layouts.
11
+ build: { builder: "DOCKERFILE", dockerfilePath: kitDir ? `${kitDir}/Dockerfile` : "Dockerfile" },
12
+ deploy: { healthcheckPath: "/health", restartPolicyType: "ON_FAILURE" },
13
+ }, null, 2)}\n`;
14
+ }
15
+ /** Compute the Railway deploy plan from the resolved definition. */
16
+ export function planRailwayDeploy(input) {
17
+ const { serviceName, modelAuth, channels } = input;
18
+ // Kit layout: railway.json is namespaced under the kit too (the host repo may carry its own
19
+ // railway.toml/json for the product). Railway reads config-as-code from the repo root by default,
20
+ // so the runbook adds the dashboard step that points the service at the kit's file (no CLI flag exists).
21
+ const configPath = input.kitDir ? `${input.kitDir}/railway.json` : "railway.json";
22
+ const artifacts = [
23
+ { path: configPath, content: railwayJson(input.kitDir) },
24
+ ...containerArtifacts(input),
25
+ ];
26
+ const secrets = requiredSecrets(modelAuth, channels, input.extraSecrets);
27
+ // Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
28
+ // variables are service-scoped and `railway up` deploys THE service — so the service must exist first
29
+ // (`railway add --service`), and variables must be set BEFORE the first `up` or the box boots without a
30
+ // model key / FASTAGENT_STATE_DIR and crash-loops against restartPolicy ON_FAILURE + the healthcheck.
31
+ const runbook = [
32
+ `# Deploy to Railway. ${configPath} / Dockerfile(.dockerignore) are generated above.`,
33
+ `# Prereqs: the Railway CLI (https://docs.railway.com/guides/cli) and \`railway login\`.`,
34
+ ``,
35
+ `# One-time setup (init → service → volume → variables). SKIP all of it on a redeploy — a redeploy is`,
36
+ `# just \`railway up\` (below). Re-running these makes a second project, a DUPLICATE service (Railway`,
37
+ `# service names are project-scoped, NOT unique — no error), and another volume, splitting state.`,
38
+ ``,
39
+ `# Create + link a project (writes .railway link state in this dir; the project — not a committed`,
40
+ `# file — is Railway's source of truth for identity, variables, and the volume).`,
41
+ `railway init # or \`railway link\` to attach an existing project`,
42
+ ``,
43
+ `# Create the service. \`railway init\` makes only a project; the volume/variables below are`,
44
+ `# service-scoped and \`railway up\` deploys THIS service. \`add\` auto-links it to this directory, so`,
45
+ `# the later commands resolve it without --service (--run passes --service to stay non-interactive).`,
46
+ `railway add --service ${serviceName}`,
47
+ ``,
48
+ `# Persistent volume at ${MOUNT} — sessions, auth, channel state. FASTAGENT_STATE_DIR is set to match.`,
49
+ `railway volume add --mount-path ${MOUNT}`,
50
+ ``,
51
+ `# Variables — set BEFORE the first deploy so the box boots with them. Railway injects PORT itself.`,
52
+ `railway variables set FASTAGENT_STATE_DIR=${MOUNT}`,
53
+ ];
54
+ if (secrets.length > 0) {
55
+ runbook.push(`# ${secrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `railway variables set ${secrets.map((s) => `${s.name}=<value>`).join(" ")}`);
56
+ }
57
+ // Model-auth guidance: an env key becomes a variable above. Otherwise the plan can't read the local
58
+ // credential's value (OAuth or a stored key) to set it — same wording discipline as the Fly plan.
59
+ if (!isEnvKey(modelAuth)) {
60
+ runbook.push(modelAuth === undefined
61
+ ? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/auth.json), or \`--run\` carries it automatically.`
62
+ : `# Model auth: your local auth is "${modelAuth}" — the plan can't read its value to set as a variable.`, `# Set your provider API key as a variable (railway variables set KEY=...), OR place auth.json on the ${MOUNT} volume.`);
63
+ }
64
+ if (input.kitDir) {
65
+ runbook.push(``, `# Repo-as-workspace: point the service at the kit's config file BEFORE the first deploy —`, `# dashboard-only, like App Sleeping (no CLI flag): Service → Settings → Config-as-code →`, `# set the file path to ${configPath}. Without it Railway would read the repo root's own config.`);
66
+ }
67
+ runbook.push(``, `# Deploy — uploads this dir and builds the Dockerfile on Railway (no local Docker needed). This is`, `# also the ENTIRE redeploy: re-run \`railway up\` alone (the one-time setup above is not repeated).`, `railway up`);
68
+ if (input.kitDir) {
69
+ runbook.push(``, `# Write-back mechanics: git ships in the image and GH_TOKEN-style creds ride config.deploy.secrets;`, `# the POLICY (push vs PR, identity, remote) lives in persona.md. CAVEAT — \`railway up\` is known to`, `# strip .git from its upload, so expect NO baked history on the box: the agent should \`git clone\``, `# its repo in the workspace (same token) before making changes. Un-pushed changes never survive a`, `# redeploy — the image is a snapshot; durability lives in git.`);
70
+ }
71
+ // The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
72
+ // channel's webhook uses that domain (mint once even when both channels are present).
73
+ if (channels.includes("telegram") || channels.includes("github")) {
74
+ runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`);
75
+ }
76
+ if (channels.includes("telegram")) {
77
+ runbook.push(`# Register the Telegram webhook (default route POST /telegram; if you remapped it in`, `# channels/telegram.ts, use your path). secret_token MUST equal TELEGRAM_SECRET_TOKEN:`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=https://<your-domain>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
78
+ }
79
+ if (channels.includes("github")) {
80
+ runbook.push(`# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped it`, `# in channels/github.ts, use your path:`, `# Payload URL = https://<your-domain>/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
81
+ }
82
+ // Scale-to-zero: App Sleeping is dashboard-only (no CLI/API) — a manual step, not a generated setting.
83
+ // A github channel should NOT enable it: fire-and-forget reviews have no replay (unlike Telegram's L1
84
+ // turn store), so a sleep mid-review would drop it — the same floor the Fly plan enforces via config.
85
+ runbook.push(``, channels.includes("github")
86
+ ? `# Scale-to-zero: do NOT enable App Sleeping — github turns have no replay, a sleep mid-review is lost.`
87
+ : input.hasTimeTriggers
88
+ ? `# Scale-to-zero: do NOT enable App Sleeping — schedules/wake-ups have no external wake-up; a sleeping service sleeps through them.`
89
+ : `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
90
+ return { artifacts, runbook };
91
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `fastagent deploy railway --run` — drive the Railway CLI to completion. The middle of the deploy the
3
+ * plain runbook hands to the operator; `--run` executes it so a coding agent runs ONE command.
4
+ *
5
+ * Railway's model forces differences from the Fly runner (fly/run.ts), all validated against CLI 5.15.0:
6
+ *
7
+ * - **`--run` PROVISIONS a project and only runs on an UNLINKED directory** — so it can never deploy
8
+ * into a project it didn't create. `railway init` isn't check-then-act (it ALWAYS makes a new project)
9
+ * and `railway status` exits 0 even when unlinked (linkedness is read from stdout: JSON when linked,
10
+ * empty when not). We do NOT track ownership: a pre-existing link is refused (Railway has no globally
11
+ * unique name to give free identity like Fly's app name, and synthesizing one — a machine-local marker
12
+ * — was a large, bug-prone premature optimization). The only "yes, this project" signal is the operator's
13
+ * explicit `--into-linked`, which provisions INTO the linked project; a routine redeploy is just
14
+ * `railway up`. The volume is check-then-act, so `--into-linked` (or a create that failed at volume-add) still gets it.
15
+ * - **Every command needs `--service` explicitly** to stay non-interactive (a bare command prompts to
16
+ * pick a service). `railway volume add` is the exception — it has NO `--service` flag and rides the
17
+ * linked service, so the service must be created (and thus linked) first.
18
+ * - **The public URL is minted, not deterministic** — bare `railway domain --json` returns the service
19
+ * domain (minting one if absent), read for the webhook. We avoid the newer `domain list` subcommand:
20
+ * on a CLI without it, `railway domain list` registers a bogus custom domain named "list" (destructive).
21
+ *
22
+ * Secrets go in one-per-`variable set --stdin` (value on stdin, never argv/process listing — Railway has
23
+ * no bulk stdin import like Fly's `secrets import`). Auth needs an ACCOUNT credential (login or
24
+ * `RAILWAY_API_KEY`), not a project token: `init` creates a project that a project token can't predate.
25
+ */
26
+ import type { ChannelKind } from "../../scaffold/add-channel.ts";
27
+ import type { CliRunner } from "../runner.ts";
28
+ export interface RailwayRunPlan {
29
+ /** Names both the project (`railway init --name`) and the service (`railway add --service`). Railway
30
+ * names are project-scoped, not globally unique — the CLI derives it from the dir basename. */
31
+ name: string;
32
+ /** The volume mount path AND `FASTAGENT_STATE_DIR` (kept in lockstep). `/data`, matching the Fly recipe. */
33
+ mountPath: string;
34
+ /** `KEY=value` secrets set one-per-`variable set --stdin`: model key (env auth) or `FASTAGENT_AUTH_SEED`
35
+ * (file auth) + channel secrets. Never on argv. */
36
+ secrets: Record<string, string>;
37
+ /** Required secret names with NO local value — the run gates on these before any side effect. */
38
+ missingSecrets: string[];
39
+ channels: ChannelKind[];
40
+ /** Opt-in (CLI `--into-linked`) to provision INTO the project this directory is already linked to. Off
41
+ * by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
42
+ * unrelated/production) project; the flag is the operator's explicit "yes, this project". */
43
+ intoLinked: boolean;
44
+ }
45
+ /** Done (with the live URL), or a gate the operator must clear before re-running (printed + non-zero
46
+ * exit by the CLI). */
47
+ export type RailwayRunOutcome = {
48
+ ok: true;
49
+ url: string;
50
+ } | {
51
+ ok: false;
52
+ gate: string;
53
+ };
54
+ /**
55
+ * Whether `railway status --json` shows a linked project: non-empty stdout. Unlinked prints its message
56
+ * to stderr and leaves stdout EMPTY (the exit code is 0 either way, so it can't be the signal). ANY
57
+ * non-empty output — parseable or not — counts as linked, so an unreadable shape is refused, never
58
+ * mistaken for unlinked (which would `init` a duplicate). Verified against CLI 5.15.0.
59
+ */
60
+ export declare function isLinked(stdout: string): boolean;
61
+ /** The linked project's name for the gate message, or undefined if it can't be read (still linked —
62
+ * `railway status --json` puts `name` at the top level on 5.15.0). */
63
+ export declare function linkedName(stdout: string): string | undefined;
64
+ /**
65
+ * The first Railway-provided domain as an https URL, or undefined if none is present — reads `railway
66
+ * domain --json` without assuming exact field names: any string value carrying a `*.railway.app` host
67
+ * wins (unanchored, so a scheme/port/path around it still yields the bare host). Undefined → the CLI
68
+ * gates (register manually).
69
+ */
70
+ export declare function parseDomainUrl(stdout: string): string | undefined;
71
+ /**
72
+ * Whether `railway volume list --json` shows a volume at `mountPath` — shape-agnostic, like
73
+ * {@link parseDomainUrl}. Drives check-then-act so a half-provisioned project (service created, volume
74
+ * add failed) self-heals on re-run instead of silently deploying with no volume.
75
+ */
76
+ export declare function parseHasVolume(stdout: string, mountPath: string): boolean;
77
+ /**
78
+ * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` performs the
79
+ * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
80
+ */
81
+ export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<void>): Promise<RailwayRunOutcome>;