@fastagent-sh/fastagent 0.13.0 → 0.14.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 (90) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  3. package/dist/channels/feishu/context-buffer.js +133 -0
  4. package/dist/channels/feishu/crypto.d.ts +4 -2
  5. package/dist/channels/feishu/crypto.js +4 -2
  6. package/dist/channels/feishu/feishu-api.d.ts +4 -4
  7. package/dist/channels/feishu/feishu-api.js +2 -2
  8. package/dist/channels/feishu/feishu.d.ts +11 -1
  9. package/dist/channels/feishu/feishu.js +143 -30
  10. package/dist/channels/feishu/invoke-turn.d.ts +19 -13
  11. package/dist/channels/feishu/invoke-turn.js +70 -19
  12. package/dist/channels/feishu/model.d.ts +97 -0
  13. package/dist/channels/feishu/model.js +9 -0
  14. package/dist/channels/feishu/normalize.d.ts +22 -0
  15. package/dist/channels/feishu/normalize.js +132 -0
  16. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  17. package/dist/channels/feishu/owned-threads.js +47 -0
  18. package/dist/channels/feishu/parse.d.ts +20 -102
  19. package/dist/channels/feishu/parse.js +35 -145
  20. package/dist/channels/feishu/preview.js +2 -2
  21. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  22. package/dist/channels/feishu/seen.js +12 -12
  23. package/dist/channels/lark/scaffold/channel.ts +10 -3
  24. package/dist/cli/commands/add.d.ts +9 -0
  25. package/dist/cli/commands/add.js +142 -0
  26. package/dist/cli/commands/chat.d.ts +3 -0
  27. package/dist/cli/commands/chat.js +16 -0
  28. package/dist/cli/commands/deploy.d.ts +13 -0
  29. package/dist/cli/commands/deploy.js +338 -0
  30. package/dist/cli/commands/dev.d.ts +11 -0
  31. package/dist/cli/commands/dev.js +76 -0
  32. package/dist/cli/commands/fire.d.ts +7 -0
  33. package/dist/cli/commands/fire.js +45 -0
  34. package/dist/cli/commands/info.d.ts +7 -0
  35. package/dist/cli/commands/info.js +108 -0
  36. package/dist/cli/commands/init.d.ts +8 -0
  37. package/dist/cli/commands/init.js +81 -0
  38. package/dist/cli/commands/invoke.d.ts +7 -0
  39. package/dist/cli/commands/invoke.js +29 -0
  40. package/dist/cli/commands/login.d.ts +6 -0
  41. package/dist/cli/commands/login.js +63 -0
  42. package/dist/cli/commands/models.d.ts +1 -0
  43. package/dist/cli/commands/models.js +15 -0
  44. package/dist/cli/commands/schedule.d.ts +12 -0
  45. package/dist/cli/commands/schedule.js +89 -0
  46. package/dist/cli/commands/start.d.ts +10 -0
  47. package/dist/cli/commands/start.js +90 -0
  48. package/dist/cli/commands/tool.d.ts +1 -0
  49. package/dist/cli/commands/tool.js +37 -0
  50. package/dist/cli/fail.d.ts +19 -0
  51. package/dist/cli/fail.js +32 -0
  52. package/dist/cli/kernel.d.ts +89 -0
  53. package/dist/cli/kernel.js +190 -0
  54. package/dist/cli/program.d.ts +11 -0
  55. package/dist/cli/program.js +421 -0
  56. package/dist/cli/serve.d.ts +28 -0
  57. package/dist/cli/serve.js +90 -0
  58. package/dist/cli/shared.d.ts +24 -0
  59. package/dist/cli/shared.js +116 -0
  60. package/dist/cli.js +8 -1329
  61. package/dist/deploy/docker/plan.d.ts +45 -0
  62. package/dist/deploy/docker/plan.js +139 -0
  63. package/dist/deploy/docker/run.d.ts +40 -0
  64. package/dist/deploy/docker/run.js +126 -0
  65. package/dist/deploy/preflight.js +4 -3
  66. package/dist/deploy/runner.d.ts +4 -1
  67. package/dist/deploy/runner.js +1 -0
  68. package/dist/engines/pi/auth.js +160 -46
  69. package/dist/engines/pi/chat.js +77 -4
  70. package/dist/engines/pi/config.d.ts +12 -3
  71. package/dist/engines/pi/config.js +16 -1
  72. package/dist/engines/pi/create.d.ts +14 -5
  73. package/dist/engines/pi/create.js +44 -9
  74. package/dist/engines/pi/harness.d.ts +16 -1
  75. package/dist/engines/pi/harness.js +77 -1
  76. package/dist/engines/pi/invoke.d.ts +1 -1
  77. package/dist/engines/pi/invoke.js +37 -2
  78. package/dist/engines/pi/login.js +1 -1
  79. package/dist/engines/pi/search-tools.d.ts +10 -0
  80. package/dist/engines/pi/search-tools.js +138 -0
  81. package/dist/engines/pi/tool-context.d.ts +28 -0
  82. package/dist/engines/pi/tool-context.js +8 -0
  83. package/dist/engines/pi/tool.d.ts +32 -1
  84. package/dist/engines/pi/tool.js +42 -1
  85. package/dist/engines/pi/workspace.d.ts +4 -1
  86. package/dist/engines/pi/workspace.js +3 -1
  87. package/dist/pi.d.ts +2 -1
  88. package/dist/scaffold/add-channel.js +3 -1
  89. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  90. package/package.json +7 -4
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `fastagent deploy docker` — the local-Docker plan. The application image stays ingress-neutral:
3
+ * this target generates only the shared Dockerfile plus a small Compose service for the FastAgent
4
+ * process, its loopback port, exact environment-variable names, and one persistent state volume.
5
+ * `--tunnel` can add an ephemeral Cloudflare Quick Tunnel service; durable ingress remains operator-owned.
6
+ */
7
+ import type { ChannelKind } from "../../scaffold/add-channel.ts";
8
+ import { type Artifact, type ContainerInput } from "../container.ts";
9
+ export interface DockerPlanInput extends ContainerInput {
10
+ /** Stable Compose project name, sanitized by {@link toDockerProjectName}. */
11
+ projectName: string;
12
+ /** Container + default host port. The host binding is loopback-only by default. */
13
+ port: number;
14
+ /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
15
+ modelAuth: string | undefined;
16
+ /** Known channels contribute their environment-variable names and webhook registration. */
17
+ channels: ChannelKind[];
18
+ /** Generate an optional Cloudflare Quick Tunnel service in Compose. Generation only; `--run` starts it. */
19
+ tunnel: boolean;
20
+ /** Extra environment-variable names declared in config.deploy.secrets. */
21
+ extraSecrets?: string[];
22
+ }
23
+ export interface DockerPlan {
24
+ /** fastagent.compose.yml + the shared Dockerfile/ignore artifacts. */
25
+ artifacts: Artifact[];
26
+ /** Compose file path relative to the workspace root (namespaced for agentDir layouts). */
27
+ composePath: string;
28
+ /** Ordered local build/run/operate instructions. */
29
+ runbook: string[];
30
+ }
31
+ export declare const DOCKER_COMPOSE_FILE = "fastagent.compose.yml";
32
+ export declare const GENERATED_COMPOSE_MARKER = "# Generated by `fastagent deploy docker`";
33
+ export declare const MIN_DOCKER_COMPOSE_VERSION = "2.3.3";
34
+ /** Pinned: generated deployment artifacts must not silently move when Cloudflare publishes `latest`. */
35
+ export declare const CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2026.7.1";
36
+ /** Whether a Compose file still opts into generated-drift warnings (drop the marker after taking ownership). */
37
+ export declare function isGeneratedCompose(content: string): boolean;
38
+ /** Whether an authoritative Compose file already carries the generated tunnel service contract. */
39
+ export declare function composeHasTunnelService(content: string): boolean;
40
+ /** Compose project names are lowercase [a-z0-9_-] and must start with an alphanumeric character. */
41
+ export declare function toDockerProjectName(directoryName: string): string;
42
+ /** Default first-party webhook paths. Workspace glue may remap them, so guidance labels them defaults. */
43
+ export declare function dockerWebhookPaths(channels: ChannelKind[]): string[];
44
+ /** Compute local-Docker artifacts + the runbook; no Docker process is touched here. */
45
+ export declare function planDockerDeploy(input: DockerPlanInput): DockerPlan;
@@ -0,0 +1,139 @@
1
+ import { containerArtifacts } from "../container.js";
2
+ import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
+ const MOUNT = "/data";
4
+ export const DOCKER_COMPOSE_FILE = "fastagent.compose.yml";
5
+ export const GENERATED_COMPOSE_MARKER = "# Generated by `fastagent deploy docker`";
6
+ export const MIN_DOCKER_COMPOSE_VERSION = "2.3.3";
7
+ /** Pinned: generated deployment artifacts must not silently move when Cloudflare publishes `latest`. */
8
+ export const CLOUDFLARED_IMAGE = "cloudflare/cloudflared:2026.7.1";
9
+ /** Whether a Compose file still opts into generated-drift warnings (drop the marker after taking ownership). */
10
+ export function isGeneratedCompose(content) {
11
+ return content.startsWith(GENERATED_COMPOSE_MARKER);
12
+ }
13
+ /** Whether an authoritative Compose file already carries the generated tunnel service contract. */
14
+ export function composeHasTunnelService(content) {
15
+ return /^[ \t]+tunnel:\s*(?:#.*)?$/m.test(content);
16
+ }
17
+ /** Compose project names are lowercase [a-z0-9_-] and must start with an alphanumeric character. */
18
+ export function toDockerProjectName(directoryName) {
19
+ const slug = directoryName
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9_-]+/g, "-")
22
+ .replace(/^[-_]+|[-_]+$/g, "")
23
+ .slice(0, 54)
24
+ .replace(/[-_]+$/g, "");
25
+ return `fastagent-${slug || "agent"}`;
26
+ }
27
+ /** Default first-party webhook paths. Workspace glue may remap them, so guidance labels them defaults. */
28
+ export function dockerWebhookPaths(channels) {
29
+ const path = {
30
+ github: "/webhook",
31
+ telegram: "/telegram",
32
+ feishu: "/feishu",
33
+ lark: "/lark",
34
+ };
35
+ return channels.map((kind) => path[kind]);
36
+ }
37
+ /** `${NAME:-}` without making JavaScript treat it as interpolation. */
38
+ function composeInterpolation(name) {
39
+ return `\${${name}:-}`;
40
+ }
41
+ /** Relative path from the namespaced Compose file's directory back to the workspace/build root. */
42
+ function buildContext(kitDir) {
43
+ if (!kitDir)
44
+ return ".";
45
+ return kitDir
46
+ .split("/")
47
+ .map(() => "..")
48
+ .join("/");
49
+ }
50
+ function composeYaml(input) {
51
+ const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
52
+ // Always leave the auth-seed seam in the committed topology. `--run` uses it for OAuth/stored auth;
53
+ // it is empty otherwise. Values never land in this file — Compose interpolates them at invocation.
54
+ const envNames = [...new Set([...secrets.map((secret) => secret.name), "FASTAGENT_AUTH_SEED"])];
55
+ const secretEnv = envNames.map((name) => ` ${name}: "${composeInterpolation(name)}"`).join("\n");
56
+ const context = buildContext(input.kitDir);
57
+ const dockerfile = input.kitDir ? `${input.kitDir}/Dockerfile` : "Dockerfile";
58
+ const tunnelService = input.tunnel
59
+ ? `
60
+ # Cloudflare Quick Tunnel: ephemeral URL, generated only with \`deploy docker --tunnel\`.
61
+ # No restart policy on purpose: a new process gets a new URL and needs webhook re-registration.
62
+ tunnel:
63
+ image: ${CLOUDFLARED_IMAGE}
64
+ command:
65
+ - tunnel
66
+ - --no-autoupdate
67
+ - --url
68
+ - http://agent:${input.port}
69
+ depends_on:
70
+ - agent
71
+ environment:
72
+ # Docker Desktop may inject HTTP(S)_PROXY into containers. The origin is Docker-internal and must
73
+ # bypass it; preserve any operator NO_PROXY entries after the required service/loopback names.
74
+ NO_PROXY: "agent,localhost,127.0.0.1,${composeInterpolation("NO_PROXY")}"
75
+ no_proxy: "agent,localhost,127.0.0.1,${composeInterpolation("no_proxy")}"
76
+ restart: "no"
77
+ `
78
+ : "";
79
+ return `${GENERATED_COMPOSE_MARKER}. Edit freely — it is kept unless you pass --force.
80
+ # Durable/public ingress remains yours; --tunnel optionally adds an ephemeral Quick Tunnel service.
81
+ name: ${input.projectName}
82
+
83
+ services:
84
+ agent:
85
+ build:
86
+ context: ${context}
87
+ dockerfile: ${dockerfile}
88
+ ports:
89
+ - "127.0.0.1:${input.port}:${input.port}"
90
+ environment:
91
+ PORT: "${input.port}"
92
+ FASTAGENT_STATE_DIR: "${MOUNT}"
93
+ ${secretEnv}
94
+ volumes:
95
+ - state:${MOUNT}
96
+ restart: unless-stopped
97
+ ${tunnelService}
98
+ volumes:
99
+ state:
100
+ `;
101
+ }
102
+ /** Compute local-Docker artifacts + the runbook; no Docker process is touched here. */
103
+ export function planDockerDeploy(input) {
104
+ const composePath = input.kitDir ? `${input.kitDir}/${DOCKER_COMPOSE_FILE}` : DOCKER_COMPOSE_FILE;
105
+ const artifacts = [{ path: composePath, content: composeYaml(input) }, ...containerArtifacts(input)];
106
+ const compose = `docker compose -f ${composePath}`;
107
+ const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
108
+ const required = secrets.filter((secret) => secret.required);
109
+ const optional = secrets.filter((secret) => !secret.required);
110
+ const paths = dockerWebhookPaths(input.channels);
111
+ const runbook = [
112
+ `# Run FastAgent in local Docker. ${composePath} / Dockerfile(.dockerignore) are generated above.`,
113
+ `# Existing artifacts are authoritative and kept; edit Dockerfile or ${composePath} freely.`,
114
+ `# Prereqs: Docker Engine/Desktop with Compose >= ${MIN_DOCKER_COMPOSE_VERSION} (\`docker compose version\`).`,
115
+ ];
116
+ if (required.length > 0) {
117
+ runbook.push(`# Required environment values (put them in the workspace .env or export them):`, ...required.map((secret) => `# ${secret.name}: ${secret.hint}`));
118
+ }
119
+ if (optional.length > 0) {
120
+ runbook.push(`# Optional environment values:`, ...optional.map((secret) => `# ${secret.name}: ${secret.hint}`));
121
+ }
122
+ if (!isEnvKey(input.modelAuth)) {
123
+ runbook.push(input.modelAuth === undefined
124
+ ? `# Model auth: none found locally — run \`fastagent login\` or set a provider API key before --run.`
125
+ : `# Model auth: local source is "${input.modelAuth}" — \`--run\` carries auth.json as FASTAGENT_AUTH_SEED.`, `# For a manual Compose run, provide a provider API key or set FASTAGENT_AUTH_SEED yourself.`);
126
+ }
127
+ if (input.kitDir) {
128
+ runbook.push(`# Repo-as-workspace: run from the REPO ROOT. Compose lives under ${input.kitDir}/ but builds`, `# the whole repository; only the kit's dependencies are installed by ${input.kitDir}/Dockerfile.`);
129
+ }
130
+ runbook.push(``, `# Build/create/reconcile the service. Re-running this is the complete local redeploy; state stays`, `# in the Compose volume mounted at ${MOUNT}.`, `${compose} up -d --build`, `curl --fail http://127.0.0.1:${input.port}/health`, ``, `# Operate it:`, `${compose} logs -f agent`, `${compose} ps`, `${compose} down # stops containers; keeps the state volume`, `# ${compose} down -v # DESTRUCTIVE: also deletes auth, sessions, and channel state`);
131
+ if (input.tunnel) {
132
+ runbook.push(``, `# Quick Tunnel: \`--run\` starts the Compose tunnel service and reads its ephemeral public URL.`, `# Detected Telegram/Feishu/Lark channels auto-register; GitHub prints the URL. Re-run after the`, `# tunnel container/Docker daemon restarts: a new Quick Tunnel URL must replace the old webhook URL.`, `${compose} logs -f tunnel`);
133
+ }
134
+ else if (paths.length > 0) {
135
+ runbook.push(``, `# Public ingress is operator-owned: add your tunnel/proxy/DNS/TLS, then configure the`, `# detected default endpoint(s): ${paths.map((path) => `https://<your-domain>${path}`).join(", ")}.`, `# If your channels/*.ts glue remaps a route, use that path instead.`);
136
+ }
137
+ runbook.push(`# Keep one agent replica: the local state volume is single-process; multiple replicas split/corrupt state.`);
138
+ return { artifacts, composePath, runbook };
139
+ }
@@ -0,0 +1,40 @@
1
+ import type { CliRunner } from "../runner.ts";
2
+ export interface DockerRunPlan {
3
+ /** Compose file relative to the runner cwd (the workspace root). */
4
+ composeFile: string;
5
+ /** Container port from config; used to ask Compose for the effective published host port. */
6
+ port: number;
7
+ /** Values interpolated by Compose. Keys/values are passed in the child environment, never argv. */
8
+ secrets: Record<string, string>;
9
+ /** Required names with no local value; gate before build/create. */
10
+ missingSecrets: string[];
11
+ /** Neither an env-key credential nor a readable auth.json is available. */
12
+ needsModelCredential: boolean;
13
+ /** `--tunnel` was requested for this run; a kept Compose file must actually contain that service. */
14
+ requireTunnel: boolean;
15
+ }
16
+ export type DockerRunOutcome = {
17
+ ok: true;
18
+ url?: string;
19
+ tunnelUrl?: string;
20
+ } | {
21
+ ok: false;
22
+ gate: string;
23
+ };
24
+ export type DockerHealthProbe = (healthUrl: string) => Promise<boolean>;
25
+ export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv) => Promise<string | undefined>;
26
+ /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
27
+ export declare function localUrlFromComposePort(stdout: string): string | undefined;
28
+ /** Poll the detached cloudflared service's logs until its assigned Quick Tunnel URL appears. */
29
+ export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile: string, env: NodeJS.ProcessEnv, options?: {
30
+ attempts?: number;
31
+ intervalMs?: number;
32
+ sleep?: (ms: number) => Promise<void>;
33
+ }): Promise<string | undefined>;
34
+ /**
35
+ * Drive Docker Compose. A custom Compose file remains authoritative: the driver invokes it as-is and
36
+ * only assumes the generated service contract (`agent`, config's container port) for optional URL/
37
+ * readiness reporting. If the service intentionally has no host-published port, a successful running
38
+ * service is still success (an operator-owned sidecar/reverse proxy may be its only ingress).
39
+ */
40
+ export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe): Promise<DockerRunOutcome>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally.
3
+ * Compose owns container/network/volume lifecycle; this driver owns only actionable gates, secret/auth
4
+ * carry through the child environment (never argv), and a readiness check on the published loopback port.
5
+ */
6
+ import { waitForHealth } from "../../channels/wait-health.js";
7
+ import { parseTunnelUrl } from "../../tunnel.js";
8
+ import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
9
+ /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
10
+ export function localUrlFromComposePort(stdout) {
11
+ const line = stdout
12
+ .split("\n")
13
+ .map((value) => value.trim())
14
+ .find(Boolean);
15
+ const port = line?.match(/:(\d+)$/)?.[1];
16
+ return port ? `http://127.0.0.1:${port}` : undefined;
17
+ }
18
+ const defaultHealthProbe = (healthUrl) => waitForHealth(healthUrl, 30_000, 500);
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ /** Poll the detached cloudflared service's logs until its assigned Quick Tunnel URL appears. */
21
+ export async function waitForComposeTunnelUrl(docker, composeFile, env, options = {}) {
22
+ const compose = ["compose", "-f", composeFile];
23
+ const attempts = options.attempts ?? 60;
24
+ for (let attempt = 0; attempt < attempts; attempt++) {
25
+ const logs = await docker([...compose, "logs", "--no-color", "tunnel"], { capture: true, env });
26
+ const url = logs.code === 0 ? parseTunnelUrl(logs.stdout) : undefined;
27
+ if (url)
28
+ return url;
29
+ if (attempt + 1 < attempts)
30
+ await (options.sleep ?? sleep)(options.intervalMs ?? 500);
31
+ }
32
+ return undefined;
33
+ }
34
+ const defaultTunnelUrlProbe = (docker, composeFile, env) => waitForComposeTunnelUrl(docker, composeFile, env);
35
+ /**
36
+ * Drive Docker Compose. A custom Compose file remains authoritative: the driver invokes it as-is and
37
+ * only assumes the generated service contract (`agent`, config's container port) for optional URL/
38
+ * readiness reporting. If the service intentionally has no host-published port, a successful running
39
+ * service is still success (an operator-owned sidecar/reverse proxy may be its only ingress).
40
+ */
41
+ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe) {
42
+ const gate = (message) => ({ ok: false, gate: message });
43
+ const compose = ["compose", "-f", plan.composeFile];
44
+ const env = plan.secrets;
45
+ // CLI/plugin gate first: unlike a daemon error, spawn ENOENT becomes 127 at the shared runner seam.
46
+ const version = await docker(["compose", "version"], { capture: true });
47
+ if (version.code === 127) {
48
+ return gate("Docker CLI not found — install Docker Engine/Desktop, then re-run");
49
+ }
50
+ if (version.code !== 0) {
51
+ return gate("Docker Compose plugin is unavailable — install/enable `docker compose`, then re-run");
52
+ }
53
+ // Credential gates precede the first side effect (build/create), with distinct remediation.
54
+ if (plan.needsModelCredential) {
55
+ return gate("no model credential — run `fastagent login`, or set a provider API key in .env, then re-run");
56
+ }
57
+ if (plan.missingSecrets.length > 0) {
58
+ return gate(`no local value for: ${plan.missingSecrets.join(", ")} — set them in .env (or the environment) and re-run`);
59
+ }
60
+ if ((await docker(["info"], { capture: true })).code !== 0) {
61
+ return gate("Docker daemon is unavailable — start Docker Engine/Desktop, then re-run");
62
+ }
63
+ // The file on disk is authoritative. Inspect its actual services before any build/create side effect,
64
+ // both to protect the `agent` run contract and to catch `--tunnel` against a kept non-tunnel topology.
65
+ const configured = await docker([...compose, "config", "--services"], { capture: true, env });
66
+ if (configured.code !== 0) {
67
+ return gate(`could not load ${plan.composeFile} — generated files require Docker Compose >= ` +
68
+ `${MIN_DOCKER_COMPOSE_VERSION}; upgrade Compose or fix the file, then re-run`);
69
+ }
70
+ const services = configured.stdout.split(/\s+/).filter(Boolean);
71
+ if (!services.includes("agent")) {
72
+ return gate(`Compose file must keep the "agent" service for \`fastagent deploy docker --run\``);
73
+ }
74
+ const hasTunnel = services.includes("tunnel");
75
+ if (plan.requireTunnel && !hasTunnel) {
76
+ return gate(`--tunnel was requested but the kept ${plan.composeFile} has no "tunnel" service — ` +
77
+ `edit it, delete it and regenerate, or pass --force`);
78
+ }
79
+ // Quick Tunnel logs are the control-plane output (the assigned URL). Remove its old container first so
80
+ // a rerun cannot read a stale URL from accumulated logs; `up` below creates one fresh tunnel, then the
81
+ // CLI registers that URL. The app container/volume are untouched.
82
+ if (hasTunnel) {
83
+ log("recreating the ephemeral tunnel service…");
84
+ if ((await docker([...compose, "rm", "-s", "-f", "tunnel"], { env })).code !== 0) {
85
+ return gate(`could not recreate the tunnel service — inspect \`docker compose -f ${plan.composeFile} ps\``);
86
+ }
87
+ }
88
+ log(`building and reconciling ${plan.composeFile}…`);
89
+ if ((await docker([...compose, "up", "-d", "--build"], { env })).code !== 0) {
90
+ return gate(`\`docker compose up\` failed — see the Docker output above; fix ${plan.composeFile} and re-run`);
91
+ }
92
+ // Detached `up` can return 0 just before a bad command exits. Verify the expected service is actually
93
+ // running so a broken custom Dockerfile/CMD cannot look deployed. Compose restart loops are excluded.
94
+ const running = await docker([...compose, "ps", "--status", "running", "--services"], {
95
+ capture: true,
96
+ env,
97
+ });
98
+ const runningServices = running.stdout.split(/\s+/).filter(Boolean);
99
+ if (running.code !== 0 || !runningServices.includes("agent")) {
100
+ return gate(`the Compose service "agent" is not running — inspect with \`docker compose -f ${plan.composeFile} logs agent\``);
101
+ }
102
+ if (hasTunnel && !runningServices.includes("tunnel")) {
103
+ return gate(`the Compose service "tunnel" is not running — inspect with \`docker compose -f ${plan.composeFile} logs tunnel\``);
104
+ }
105
+ // A user-owned topology may deliberately remove the host port and expose only through its own ingress.
106
+ // In that case Compose `port` is absent/non-zero: service-running is the available readiness floor.
107
+ const published = await docker([...compose, "port", "agent", String(plan.port)], { capture: true, env });
108
+ const url = published.code === 0 ? localUrlFromComposePort(published.stdout) : undefined;
109
+ if (!url) {
110
+ log("agent is running (no host-published port found; using the Compose ingress readiness floor)");
111
+ }
112
+ else {
113
+ const healthUrl = `${url}/health`;
114
+ if (!(await healthProbe(healthUrl))) {
115
+ return gate(`agent did not become healthy at ${healthUrl} — inspect \`docker compose -f ${plan.composeFile} logs agent\``);
116
+ }
117
+ }
118
+ if (!hasTunnel)
119
+ return { ok: true, url };
120
+ log("waiting for the Compose tunnel service to publish its Quick Tunnel URL…");
121
+ const tunnelUrl = await tunnelUrlProbe(docker, plan.composeFile, env);
122
+ if (!tunnelUrl) {
123
+ return gate(`tunnel did not publish a Quick Tunnel URL — inspect \`docker compose -f ${plan.composeFile} logs tunnel\``);
124
+ }
125
+ return { ok: true, url, tunnelUrl };
126
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * the target branch (Docker / Fly / Railway). Model-travel gate, channel discovery, model-auth probe, the
4
4
  * container facts + their warnings, and the hand-written-Dockerfile apt warning are identical on every
5
5
  * host — so they live here, out of the CLI dispatcher, testable in isolation (call it against a temp dir
6
6
  * and assert the gate / messages / facts). The CLI stays thin: run this, print the messages, branch by host.
@@ -68,8 +68,9 @@ export async function preflightDeploy(input) {
68
68
  const kitDir = agentDir === target ? undefined : relative(target, agentDir).split(sep).join("/");
69
69
  const factsDir = kitDir ? agentDir : target;
70
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.
71
+ // The repo-as-workspace deployment shape remains experimental for every target. Generation +
72
+ // runbook are supported; automated runners stay gated until an explicit end-to-end smoke validates
73
+ // context packing, ignore rules, installed deps, state, and write-back for this layout.
73
74
  return {
74
75
  ok: false,
75
76
  gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
@@ -5,10 +5,13 @@ export interface RunResult {
5
5
  stdout: string;
6
6
  }
7
7
  /** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
8
- * terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv). */
8
+ * terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
9
+ * `env` adds child-only environment values — Docker Compose interpolates secrets from it without putting
10
+ * values in argv or mutating the long-lived CLI process. */
9
11
  export type CliRunner = (args: string[], opts?: {
10
12
  capture?: boolean;
11
13
  input?: string;
14
+ env?: NodeJS.ProcessEnv;
12
15
  }) => Promise<RunResult>;
13
16
  /**
14
17
  * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
@@ -13,6 +13,7 @@ export function spawnRunner(bin, cwd) {
13
13
  return (args, opts) => new Promise((res) => {
14
14
  const child = spawn(bin, args, {
15
15
  cwd,
16
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
16
17
  stdio: [opts?.input ? "pipe" : "inherit", opts?.capture ? "pipe" : "inherit", "inherit"],
17
18
  });
18
19
  let out = "";