@fastagent-sh/fastagent 0.12.0 → 0.13.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.
- package/README.md +42 -36
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- package/dist/channels/feishu/crypto.d.ts +13 -0
- package/dist/channels/feishu/crypto.js +41 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +36 -0
- package/dist/channels/feishu/feishu.js +359 -0
- package/dist/channels/feishu/invoke-turn.d.ts +59 -0
- package/dist/channels/feishu/invoke-turn.js +106 -0
- package/dist/channels/feishu/parse.d.ts +125 -0
- package/dist/channels/feishu/parse.js +175 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +34 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +32 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- package/dist/cli-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +68 -23
- package/dist/deploy/container.js +10 -6
- package/dist/deploy/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/chat.js +3 -3
- package/dist/engines/pi/create.d.ts +0 -1
- package/dist/engines/pi/create.js +8 -7
- package/dist/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +73 -7
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +19 -8
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable turn intent: the L1 half of durable execution (the L2 exactly-once / deterministic
|
|
3
|
+
* step-replay layer is the K-axis backend — docs/design/core.md §11). Persists an accepted turn BEFORE
|
|
4
|
+
* the webhook 200 (pre-ACK, like the telegram context-buffer) and removes it when the turn ENDS — the
|
|
5
|
+
* runner's `finally`: a completed turn OR any caught error both remove it. Precisely, a completed turn's
|
|
6
|
+
* removal fires at the `completed` event (the session is committed), which is BEFORE the reply reaches
|
|
7
|
+
* the chat platform: so L1 recovers the ACKed-but-un-COMPLETED window, not un-DELIVERED. A crash in
|
|
8
|
+
* that narrow tail (completed, session-committed, but the message not yet sent) leaves the answer in the
|
|
9
|
+
* session history undelivered and is deliberately NOT replayed — replaying a session-committed turn
|
|
10
|
+
* would double-append it; the asker re-asks (and sees the prior answer in history). L1's scope is the
|
|
11
|
+
* INTERRUPTED-run window (the `finally` never runs); a caught error is NOT retried here — a `failed`
|
|
12
|
+
* event already told the user, and a transport throw is dropped exactly like the pre-L1 in-memory queue
|
|
13
|
+
* did (replaying it could double-send). Only an interrupted run leaves the record on disk — and
|
|
14
|
+
* "interrupted" is not just a rare crash: `runStart` has no graceful drain (cli.ts), so a SIGTERM exits
|
|
15
|
+
* mid-turn too, i.e. EVERY rolling deploy that catches an in-flight turn. Recovery re-enqueues it next start.
|
|
16
|
+
*
|
|
17
|
+
* This recovers the ACKed-but-unfinished window the in-memory turn-queue drops (turn-queue.ts). Weigh
|
|
18
|
+
* the trade before trusting it: the alternative (dropping the turn) fails VISIBLY and self-corrects
|
|
19
|
+
* (the turn vanished, the asker re-asks); replay's sharpest cost is the opposite — invisible. It is
|
|
20
|
+
* at-least-once, not exactly-once:
|
|
21
|
+
* - PRIMARY cost: replay re-runs the WHOLE turn, so every external side effect happens AGAIN — re-sent
|
|
22
|
+
* messages, re-fired tool actions — and nobody may notice (unlike the visible loss it replaces).
|
|
23
|
+
* And the trigger is not rare (above): it fires on every deploy that interrupts an in-flight turn,
|
|
24
|
+
* not just on crashes. So "safe only if the turn's tools are idempotent" is a bar to judge against
|
|
25
|
+
* DEPLOY frequency: it holds for a Q&A bot, and is the gate for adding side-effecting tools. (A
|
|
26
|
+
* mid-stream interruption also leaves an orphan "💭 Thinking…" preview — cosmetic.)
|
|
27
|
+
* - The pre-ACK window overlaps webhook redelivery: a crash AFTER the persist but BEFORE the 200
|
|
28
|
+
* means recovery replays the turn AND the platform (never-ACKed) redelivers the same event — same
|
|
29
|
+
* id, and unless the channel dedups accepted ids, the turn can run twice. Exactly-once (a persisted
|
|
30
|
+
* delivery key) is L2.
|
|
31
|
+
*
|
|
32
|
+
* Channel-neutral, generic over the record shape: the channel supplies its own record type (everything
|
|
33
|
+
* its runner needs to re-execute the turn), the shape validator (state files are an IO boundary), the
|
|
34
|
+
* arrival ordering for recovery, and its log label. telegram/turn-store.ts and the Feishu channel are the
|
|
35
|
+
* two consumers.
|
|
36
|
+
*
|
|
37
|
+
* ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
|
|
38
|
+
* crashes the process would replay forever under a container restart policy. The counter is bumped at
|
|
39
|
+
* `startAttempt` — when a turn is about to RUN, not per restart cycle — so it counts a turn's OWN
|
|
40
|
+
* execution attempts: `recover()` re-enqueues every surviving turn without touching its count, and a
|
|
41
|
+
* poison turn at a session's head is dropped on its own N+1th run WITHOUT penalizing the never-run turns
|
|
42
|
+
* queued behind it (they keep their full budget and get their turn once the poison one is gone). On drop,
|
|
43
|
+
* the runner notifies the asker (the chain's end must get a signal — a log line the user can't see isn't).
|
|
44
|
+
* The bump is the ONE post-ACK write that fails CLOSED (not best-effort): if it can't be persisted the
|
|
45
|
+
* turn is DEFERRED — skipped this cycle and left on disk to replay on the next start — rather than run
|
|
46
|
+
* untracked, because an unpersisted count lets recover() re-run a poison turn every restart (the ceiling
|
|
47
|
+
* would never advance on disk). It replays on the next START — a restart is required; disk recovery alone
|
|
48
|
+
* does not re-run a deferred turn — and the asker is NOT told (a transient system degrade, not a user-
|
|
49
|
+
* actionable failure — telling them "ask again" would double-answer on that replay).
|
|
50
|
+
* Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
|
|
51
|
+
* power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
|
|
52
|
+
*/
|
|
53
|
+
import { log } from "../log.js";
|
|
54
|
+
import { loadStateFile, saveStateFile } from "./state.js";
|
|
55
|
+
export function createTurnStore(path, opts) {
|
|
56
|
+
const { label, isRecord, order } = opts;
|
|
57
|
+
const load = () => {
|
|
58
|
+
const raw = loadStateFile(path);
|
|
59
|
+
if (raw === undefined)
|
|
60
|
+
return new Map();
|
|
61
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.values(raw).every(isRecord)) {
|
|
62
|
+
return new Map(Object.entries(raw));
|
|
63
|
+
}
|
|
64
|
+
log.warn(`${label} unexpected shape in ${path} — starting with no pending turns`);
|
|
65
|
+
return new Map();
|
|
66
|
+
};
|
|
67
|
+
const turns = load();
|
|
68
|
+
const persist = () => saveStateFile(path, Object.fromEntries(turns));
|
|
69
|
+
// Post-ACK writes (remove, startAttempt) must not abort a turn: log a failed write, never throw.
|
|
70
|
+
const persistBestEffort = (what) => {
|
|
71
|
+
try {
|
|
72
|
+
persist();
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
log.error(`${label} turn-store ${what} write failed post-ACK: ${String(e)}`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
add(rec) {
|
|
80
|
+
// Idempotent on re-add: a redelivery (pre-ACK crash → never-ACKed event replayed) re-submits an id
|
|
81
|
+
// already in the store. Skip it — the intent is already durable, and overwriting would RESET this
|
|
82
|
+
// turn's execution count, handing a poison turn a fresh ceiling budget.
|
|
83
|
+
if (turns.has(rec.id))
|
|
84
|
+
return;
|
|
85
|
+
turns.set(rec.id, rec);
|
|
86
|
+
try {
|
|
87
|
+
persist(); // pre-ACK: a throw becomes the webhook's 500 and the platform redelivers
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
|
|
91
|
+
// entry makes the redelivery's `add` short-circuit on `turns.has` — running the turn with its
|
|
92
|
+
// intent never persisted, defeating this module — and gets flushed later by an unrelated persist.
|
|
93
|
+
turns.delete(rec.id);
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
remove(id) {
|
|
98
|
+
if (turns.delete(id))
|
|
99
|
+
persistBestEffort("remove (a restart may replay an answered turn)");
|
|
100
|
+
},
|
|
101
|
+
recover() {
|
|
102
|
+
// The channel's arrival order, applied explicitly rather than leaning on JS object-key enumeration
|
|
103
|
+
// happening to survive the load's JSON round-trip.
|
|
104
|
+
return [...turns.values()].sort(order);
|
|
105
|
+
},
|
|
106
|
+
startAttempt(id, maxAttempts) {
|
|
107
|
+
const rec = turns.get(id);
|
|
108
|
+
if (!rec)
|
|
109
|
+
return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
|
|
110
|
+
const attempts = rec.attempts + 1;
|
|
111
|
+
if (attempts > maxAttempts) {
|
|
112
|
+
// State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
|
|
113
|
+
// this whether IT poisoned the process or a deploy/OOM took it down each time.
|
|
114
|
+
log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
|
|
115
|
+
`(session=${rec.session}) — it may be crashing the process, or was killed mid-run each time; notifying the asker`);
|
|
116
|
+
turns.delete(id);
|
|
117
|
+
persistBestEffort("drop");
|
|
118
|
+
return "exceeded";
|
|
119
|
+
}
|
|
120
|
+
turns.set(id, { ...rec, attempts });
|
|
121
|
+
try {
|
|
122
|
+
persist();
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
// The bump MUST be durable, unlike remove/drop: if it isn't, a restart's recover() reads the old
|
|
126
|
+
// count and RE-RUNS this turn — a poison turn would re-execute forever (the ceiling never advances
|
|
127
|
+
// on disk). Fail closed: roll the memory bump back (match disk) and DEFER — the record stays on
|
|
128
|
+
// disk with its old count and replays on the next start (a restart is required; disk recovery
|
|
129
|
+
// alone won't re-run it). Not a drop: under disk failure the removal couldn't persist anyway.
|
|
130
|
+
// The runner skips silently (no notify).
|
|
131
|
+
turns.set(id, rec);
|
|
132
|
+
log.error(`${label} cannot persist turn ${id}'s attempt count — deferring it to the next start rather ` +
|
|
133
|
+
`than run it untracked (session=${rec.session}): ${String(e)}`);
|
|
134
|
+
return "defer";
|
|
135
|
+
}
|
|
136
|
+
return "run";
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
|
|
3
|
+
* settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
|
|
4
|
+
* probe has its own short timeout so one slow attempt can't eat the whole budget.
|
|
5
|
+
*/
|
|
6
|
+
export declare function waitForHealth(healthUrl: string, timeoutMs: number, intervalMs: number): Promise<boolean>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readiness probe shared by the webhook registrars (telegram setWebhook, lark config PATCH): both
|
|
3
|
+
* platforms VERIFY the URL at registration time, and a fresh deploy's container or a fresh tunnel's
|
|
4
|
+
* DNS is not routable for some seconds — registering before the server actually serves would fail.
|
|
5
|
+
* Tracking real readiness (not a fixed timer) is what fixes that race.
|
|
6
|
+
*/
|
|
7
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
|
+
/**
|
|
9
|
+
* Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
|
|
10
|
+
* settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
|
|
11
|
+
* probe has its own short timeout so one slow attempt can't eat the whole budget.
|
|
12
|
+
*/
|
|
13
|
+
export async function waitForHealth(healthUrl, timeoutMs, intervalMs) {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
for (;;) {
|
|
16
|
+
try {
|
|
17
|
+
if ((await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })).ok)
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* not routable yet — keep polling until the deadline */
|
|
22
|
+
}
|
|
23
|
+
if (Date.now() >= deadline)
|
|
24
|
+
return false;
|
|
25
|
+
await sleep(intervalMs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create or resume the platform app behind `add feishu` / `add lark`. Returns credentials for the
|
|
3
|
+
* caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
|
|
4
|
+
* the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
|
|
5
|
+
* irreversible and must not wait for the caller). Throws on refusal (a committable .env, a
|
|
6
|
+
* non-interactive lark run); the caller surfaces that as a startup failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark", envIgnored: boolean): Promise<Record<string, string> | undefined>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent add feishu|lark` app onboarding — the cloud-facing half of runAdd, kept out of cli.ts
|
|
3
|
+
* (which self-executes on import), mirroring cli-models.ts/cli-auth.ts. The POLICY already lives in
|
|
4
|
+
* testable modules (register-app.ts, bootstrap-token.ts, lark/onboard.ts); this layer is the terminal
|
|
5
|
+
* wiring: clack prompts, .env staging, browser opens, progress lines.
|
|
6
|
+
*
|
|
7
|
+
* Feishu (scan-to-create): the device flow creates the app; App ID/Secret are persisted at the
|
|
8
|
+
* irreversible creation boundary, then the Verification Token is captured over a throwaway tunnel and
|
|
9
|
+
* persisted as a second stage — a re-run RESUMES the app instead of minting another. Lark
|
|
10
|
+
* (guided-console): open the unbound launcher, validate entered credentials, probe the same
|
|
11
|
+
* webhook/token bootstrap, and fall back to a manual token prompt on the definitive config-route 404.
|
|
12
|
+
*/
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { isCancel, log as clackLog, password, text as clackText } from "@clack/prompts";
|
|
16
|
+
import { bootstrapFeishuVerificationToken } from "./channels/feishu/bootstrap-token.js";
|
|
17
|
+
import { cloudFor } from "./channels/feishu/cloud.js";
|
|
18
|
+
import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError, } from "./channels/feishu/feishu-api.js";
|
|
19
|
+
import { registerFeishuApp } from "./channels/feishu/register-app.js";
|
|
20
|
+
import { onboardLarkApp } from "./channels/lark/onboard.js";
|
|
21
|
+
import { parseEnvContent } from "./env.js";
|
|
22
|
+
import { openExternalUrl } from "./open-url.js";
|
|
23
|
+
import { appendChannelDotEnv } from "./scaffold/add-channel.js";
|
|
24
|
+
import { startCloudflareTunnel } from "./tunnel.js";
|
|
25
|
+
/**
|
|
26
|
+
* Create or resume the platform app behind `add feishu` / `add lark`. Returns credentials for the
|
|
27
|
+
* caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
|
|
28
|
+
* the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
|
|
29
|
+
* irreversible and must not wait for the caller). Throws on refusal (a committable .env, a
|
|
30
|
+
* non-interactive lark run); the caller surfaces that as a startup failure.
|
|
31
|
+
*/
|
|
32
|
+
export async function onboardFeishuCloudApp(target, kind, envIgnored) {
|
|
33
|
+
const { envPrefix, apiBase, capabilities } = cloudFor(kind);
|
|
34
|
+
// The CLI must never materialize a real credential into a committable file — refuse, don't warn.
|
|
35
|
+
if (!envIgnored) {
|
|
36
|
+
throw new Error(kind === "feishu"
|
|
37
|
+
? "`add feishu` creates an app and writes real credentials to .env — add .env to .gitignore/.fastagentignore first, then re-run"
|
|
38
|
+
: "`add lark` writes real app credentials to .env — add .env to .gitignore/.fastagentignore first, then re-run");
|
|
39
|
+
}
|
|
40
|
+
const existing = await activeDotEnvValues(target, [
|
|
41
|
+
`${envPrefix}_APP_ID`,
|
|
42
|
+
`${envPrefix}_APP_SECRET`,
|
|
43
|
+
`${envPrefix}_VERIFICATION_TOKEN`,
|
|
44
|
+
]);
|
|
45
|
+
if (Object.keys(existing).length === 3) {
|
|
46
|
+
console.error(`[fastagent] ${envPrefix}_APP_ID/SECRET/VERIFICATION_TOKEN already set in .env — keeping them`);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (capabilities.appCreation === "scan-to-create") {
|
|
50
|
+
await createFeishuAppFlow(target, existing);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
// guided-console (lark): the intl cloud cannot complete the bound device flow — collect + validate.
|
|
54
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
55
|
+
throw new Error("`add lark` needs an interactive terminal to onboard the Lark app credentials — re-run it in a terminal");
|
|
56
|
+
}
|
|
57
|
+
return onboardLarkApp({
|
|
58
|
+
openUrl: openExternalUrl,
|
|
59
|
+
note: (message) => clackLog.info(message),
|
|
60
|
+
async prompt(message, opts) {
|
|
61
|
+
const result = opts?.hidden ? await password({ message }) : await clackText({ message });
|
|
62
|
+
return isCancel(result) ? undefined : result;
|
|
63
|
+
},
|
|
64
|
+
}, {
|
|
65
|
+
existing,
|
|
66
|
+
verifyCredentials: async (appId, appSecret) => {
|
|
67
|
+
await createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret }).verifyCredentials();
|
|
68
|
+
console.error(`[fastagent] Lark App ID / Secret verified`);
|
|
69
|
+
},
|
|
70
|
+
bootstrapWebhook: async (appId, appSecret) => {
|
|
71
|
+
const api = createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret });
|
|
72
|
+
console.error(`[fastagent] trying Lark's webhook-mode + Verification-Token bootstrap (temporary tunnel)…`);
|
|
73
|
+
try {
|
|
74
|
+
const token = await bootstrapFeishuVerificationToken({
|
|
75
|
+
api,
|
|
76
|
+
appId,
|
|
77
|
+
kind: "lark",
|
|
78
|
+
startTunnel: (port) => startCloudflareTunnel(port),
|
|
79
|
+
onTunnelReady: (url) => console.error(`[fastagent] temporary tunnel ready → ${url}; registering webhook mode now…`),
|
|
80
|
+
onPatchRetry: ({ error, attempt, attempts, retryMs }) => console.error(`[fastagent] Lark could not validate the fresh tunnel yet (${String(error)}); retrying PATCH ${attempt + 1}/${attempts} in ${Math.round(retryMs / 1000)}s…`),
|
|
81
|
+
// A route-level 404 is definitive, not edge weather: fall back immediately. Retry only
|
|
82
|
+
// actual edge/network weather; scope/auth/config failures remain immediate.
|
|
83
|
+
shouldRetryPatch: (error) => !isFeishuConfigApiMissing(error) && isTransientFeishuRegistrationError(error),
|
|
84
|
+
});
|
|
85
|
+
console.error(`[fastagent] Lark Verification Token captured; Subscription mode changed to webhook in the app draft`);
|
|
86
|
+
return { token };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!isFeishuConfigApiMissing(error))
|
|
90
|
+
throw error;
|
|
91
|
+
const manualReason = "This Lark app returned HTTP 404 for the application-config API, so automatic mode/token bootstrap is unavailable.";
|
|
92
|
+
console.error(`[fastagent] ${manualReason}`);
|
|
93
|
+
return { manualReason };
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The scan-to-create flow `add feishu` runs by default. The device-authorization grant
|
|
100
|
+
* creates a pre-configured agent app (bot capability, messaging scopes, event subscriptions) when the
|
|
101
|
+
* user confirms a link in the app, and hands back the credentials; App ID/Secret are persisted at that
|
|
102
|
+
* irreversible boundary before the platform-generated Verification Token is captured from the
|
|
103
|
+
* registration challenge (bootstrap-token.ts). The Token is persisted as a second stage, so .env is
|
|
104
|
+
* complete before the one remaining version-publish action. The event Request URL is NOT left pointing at the throwaway
|
|
105
|
+
* tunnel for long: `dev --tunnel` / `deploy --run` re-register it against the live URL.
|
|
106
|
+
*
|
|
107
|
+
* Feishu is the reference cloud and the only kind that runs this BOUND device flow. Lark is an explicit
|
|
108
|
+
* compatibility profile: its lagging control plane uses the unbound launcher + guided credentials,
|
|
109
|
+
* then probes the canonical token/mode bootstrap with a manual fallback.
|
|
110
|
+
*/
|
|
111
|
+
async function createFeishuAppFlow(target, existing) {
|
|
112
|
+
const { apiBase } = cloudFor("feishu");
|
|
113
|
+
let appId = existing.FEISHU_APP_ID;
|
|
114
|
+
let appSecret = existing.FEISHU_APP_SECRET;
|
|
115
|
+
if (appId && appSecret) {
|
|
116
|
+
console.error(`[fastagent] resuming Feishu app ${appId} from .env to capture its missing Verification Token`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.error(`[fastagent] creating the Feishu app (confirm in the app)…`);
|
|
120
|
+
const app = await registerFeishuApp({
|
|
121
|
+
name: "{user}'s agent", // the platform expands {user} to the confirming user's name; editable on the page
|
|
122
|
+
desc: "Served by fastagent",
|
|
123
|
+
// The agent template alone is not enough to SERVE: the v7 config PATCH (webhook auto-registration
|
|
124
|
+
// in `dev --tunnel` / `deploy --run`) demands application:application:patch, and the app must
|
|
125
|
+
// subscribe the receive event. Addons merge both onto the confirm page — no manual app setup.
|
|
126
|
+
addons: {
|
|
127
|
+
scopes: { tenant: ["application:application:patch"] },
|
|
128
|
+
events: { items: { tenant: ["im.message.receive_v1"] } },
|
|
129
|
+
},
|
|
130
|
+
onVerificationUrl: ({ url, expiresInS }) => {
|
|
131
|
+
console.error(`\n Opening the confirmation link in your browser (or open it in Feishu / render it as a QR code) — valid for ${Math.round(expiresInS / 60)} minutes:\n\n ${url}\n\n waiting for confirmation… (keep this running — the credentials are delivered here)`);
|
|
132
|
+
openExternalUrl(url); // best-effort, like `login` — the URL above is the fallback
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
console.error(`[fastagent] app created: ${app.appId}${app.tenantBrand ? ` (${app.tenantBrand} tenant)` : ""}`);
|
|
136
|
+
// A cross-brand confirmation should be impossible (each confirm page refuses the other brand's
|
|
137
|
+
// code) — but if the platform ever reports one, the credentials would land in the WRONG kind's env
|
|
138
|
+
// namespace and serve the wrong cloud. Fail visibly instead of writing them.
|
|
139
|
+
if (app.tenantBrand && app.tenantBrand !== "feishu") {
|
|
140
|
+
throw new Error(`the confirming account is a ${app.tenantBrand} tenant, but this is \`add feishu\` — run \`fastagent add ${app.tenantBrand}\` instead`);
|
|
141
|
+
}
|
|
142
|
+
appId = app.appId;
|
|
143
|
+
appSecret = app.appSecret;
|
|
144
|
+
// IRREVERSIBLE BOUNDARY: the remote app now exists and its one-time Secret is in memory. Persist
|
|
145
|
+
// both before any config read, temporary tunnel, or Token bootstrap can be interrupted. Partial old
|
|
146
|
+
// lines are overwritten because these newly-minted credentials are authoritative as one pair.
|
|
147
|
+
await appendChannelDotEnv(target, "feishu", {
|
|
148
|
+
FEISHU_APP_ID: appId,
|
|
149
|
+
FEISHU_APP_SECRET: appSecret,
|
|
150
|
+
// A Token from a partial OLD credential set belongs to another App. Clear it at the same
|
|
151
|
+
// boundary; successful bootstrap below replaces the empty line with this App's Token.
|
|
152
|
+
FEISHU_VERIFICATION_TOKEN: "",
|
|
153
|
+
}, ["FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_VERIFICATION_TOKEN"]);
|
|
154
|
+
console.error(`[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to .env before Token bootstrap`);
|
|
155
|
+
}
|
|
156
|
+
// The webhook channel authenticates plaintext events by the platform-generated Verification Token.
|
|
157
|
+
// Try the cheap read first (the v6 detail MAY someday return `encryption`), then the real path: the
|
|
158
|
+
// token's only programmatic delivery is the url_verification challenge during registration — capture
|
|
159
|
+
// it over a throwaway tunnel (bootstrap-token.ts). Failing both is a one-line manual copy; the staged
|
|
160
|
+
// ID/Secret pair makes a re-run resume this App rather than mint another one.
|
|
161
|
+
const tokenVar = "FEISHU_VERIFICATION_TOKEN";
|
|
162
|
+
const api = createFeishuApi({ baseUrl: apiBase, appId, appSecret });
|
|
163
|
+
let token;
|
|
164
|
+
let webhookModeChanged = false;
|
|
165
|
+
try {
|
|
166
|
+
const cfg = await api.getAppConfig(appId);
|
|
167
|
+
token = cfg.verificationToken;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* the read surface is best-effort — the bootstrap below is the real path */
|
|
171
|
+
}
|
|
172
|
+
if (!token) {
|
|
173
|
+
console.error(`[fastagent] capturing the Verification Token — a throwaway webhook registration delivers it (spinning up a temporary tunnel; can take a few minutes on a slow edge)…`);
|
|
174
|
+
try {
|
|
175
|
+
token = await bootstrapFeishuVerificationToken({
|
|
176
|
+
api,
|
|
177
|
+
appId,
|
|
178
|
+
startTunnel: (port) => startCloudflareTunnel(port),
|
|
179
|
+
});
|
|
180
|
+
webhookModeChanged = true;
|
|
181
|
+
console.error(`[fastagent] Verification Token captured`);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
// Transient tunnel weather is the usual cause. Do NOT suggest re-running `add feishu` as a new
|
|
185
|
+
// scan: the staged pair makes the re-run resume THIS app; manual copy completes it too.
|
|
186
|
+
console.error(`[fastagent] warn: could not capture the Verification Token: ${String(e)} — usually a transient tunnel issue; finish this app with the manual copy below`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (token) {
|
|
190
|
+
// Persist the second credential stage immediately too — opening the publish page and generic
|
|
191
|
+
// scaffold finalization happen only after the complete runtime credential set is durable.
|
|
192
|
+
const staged = await appendChannelDotEnv(target, "feishu", { [tokenVar]: token }, [tokenVar]);
|
|
193
|
+
console.error(`[fastagent] wrote ${staged.written.join(", ")} to .env`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
console.error(`[fastagent] copy it manually: developer console → Events & Callbacks → Encryption Strategy → Verification Token → ${tokenVar} in .env`);
|
|
197
|
+
}
|
|
198
|
+
if (webhookModeChanged) {
|
|
199
|
+
// The bootstrap's PATCH flipped event mode in the DRAFT. It takes effect only after a version
|
|
200
|
+
// publish, which has no API; later dev/deploy runs change only the Request URL immediately.
|
|
201
|
+
const versionUrl = `${apiBase}/app/${appId}/version`;
|
|
202
|
+
console.error(`[fastagent] one console click remains: CREATE + PUBLISH a version (self-approved) — the switch to webhook mode takes effect on publish. Opening ${versionUrl}`);
|
|
203
|
+
openExternalUrl(versionUrl);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Active run-root `.env` values for the requested names — decided by THE .env parser, so this
|
|
207
|
+
* check can never disagree with what `loadEnvFile` reads. Empty/commented values are absent. */
|
|
208
|
+
async function activeDotEnvValues(dir, names) {
|
|
209
|
+
let content;
|
|
210
|
+
try {
|
|
211
|
+
content = await readFile(join(dir, ".env"), "utf8");
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
if (e.code === "ENOENT")
|
|
215
|
+
return {};
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
const parsed = parseEnvContent(content);
|
|
219
|
+
return Object.fromEntries(names.flatMap((name) => {
|
|
220
|
+
const value = parsed.get(name)?.trim();
|
|
221
|
+
return value ? [[name, value]] : [];
|
|
222
|
+
}));
|
|
223
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { logAgentLoop } from "./observe.js";
|
|
|
13
13
|
import { log, setLogLevel } from "./log.js";
|
|
14
14
|
import { loadDotEnv } from "./env.js";
|
|
15
15
|
import { installProxyFetch } from "./proxy.js";
|
|
16
|
+
import { openExternalUrl } from "./open-url.js";
|
|
16
17
|
import { createInvokeHandler } from "./channels/http.js";
|
|
17
18
|
import { text } from "./channels/respond.js";
|
|
18
19
|
import { parseRouteKey, router, serveNode } from "./host/node.js";
|
|
@@ -44,6 +45,8 @@ import { deployRailwayRun } from "./deploy/railway/run.js";
|
|
|
44
45
|
import { authSeedBytes, deployFlyRun } from "./deploy/fly/run.js";
|
|
45
46
|
import { spawnRunner } from "./deploy/runner.js";
|
|
46
47
|
import { assembleSecrets } from "./deploy/secrets.js";
|
|
48
|
+
import { registerFeishuWebhook } from "./channels/feishu/register-webhook.js";
|
|
49
|
+
import { onboardFeishuCloudApp } from "./cli-add-feishu.js";
|
|
47
50
|
import { registerTelegramWebhook } from "./channels/telegram/register-webhook.js";
|
|
48
51
|
import { loadSchedules } from "./schedule/discover.js";
|
|
49
52
|
import { readRuns } from "./schedule/audit.js";
|
|
@@ -64,7 +67,7 @@ function usage(code) {
|
|
|
64
67
|
fastagent dev [dir] [--port N] [--model provider/modelId] [--auth-path file] [--no-watch] [--tunnel]
|
|
65
68
|
fastagent chat [dir] [--model provider/modelId]
|
|
66
69
|
fastagent start [dir] [--port N] [--model provider/modelId] [--sessions-dir dir] [--auth-path file] [--tunnel]
|
|
67
|
-
fastagent add github | telegram | skill <source> [dir]
|
|
70
|
+
fastagent add github | telegram | feishu | lark | skill <source> [dir]
|
|
68
71
|
fastagent deploy fly|railway [dir] [--run] [--force] [--stop] [--no-scale-to-zero] [--into-linked]
|
|
69
72
|
fastagent login [provider] [--auth-path file]
|
|
70
73
|
fastagent --version
|
|
@@ -75,7 +78,7 @@ function usage(code) {
|
|
|
75
78
|
disable). Files the agent writes as work product never trigger a restart.
|
|
76
79
|
model precedence: --model > FASTAGENT_MODEL > fastagent.config.ts
|
|
77
80
|
--tunnel expose it on a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared)
|
|
78
|
-
and auto-register the webhook channels (telegram
|
|
81
|
+
and auto-register the webhook channels (telegram, feishu, lark; github prints the URL)
|
|
79
82
|
chat open the SAME assembled agent in pi's interactive TUI (the real harness, not a
|
|
80
83
|
crude REPL) — to try it locally before serving. Same model/tool/skill resolution
|
|
81
84
|
as dev; pi handles login, sessions, and /resume natively.
|
|
@@ -123,8 +126,16 @@ function usage(code) {
|
|
|
123
126
|
point it at ~/.fastagent/auth.json to share one credential across projects)
|
|
124
127
|
--tunnel same as dev: a public HTTPS URL + auto-registered webhooks, for hosting a bot from
|
|
125
128
|
your own box without deploying (the quick-tunnel URL is ephemeral, not for production)
|
|
126
|
-
add github | telegram: scaffold channels/<kind>.ts — third-party adapter glue
|
|
127
|
-
to edit (github maps events in on(); telegram
|
|
129
|
+
add github | telegram | feishu | lark: scaffold channels/<kind>.ts — third-party adapter glue
|
|
130
|
+
with the policy to edit (github maps events in on(); telegram/feishu/lark route in the
|
|
131
|
+
optional route()). Feishu (open.feishu.cn) is the canonical implementation; Lark international
|
|
132
|
+
(open.larksuite.com) is its compatibility profile with degraded control-plane setup.
|
|
133
|
+
feishu also CREATES + configures the platform app (confirm a link in the app — the platform's
|
|
134
|
+
"scan to create" flow; one version-publish action remains) and writes credentials to .env;
|
|
135
|
+
a persisted ID/Secret pair resumes missing-Token setup instead of creating another app. lark
|
|
136
|
+
opens the intl developer console only for a new/partial pair, validates App ID/Secret, then
|
|
137
|
+
probes Feishu's webhook-mode + Token automation; an explicit
|
|
138
|
+
config-route 404 falls back to a hidden Token prompt + manual mode/URL setup.
|
|
128
139
|
skill <source>: vendor an Agent Skills skill into skills/<name>/ (git ref owner/repo/path, a
|
|
129
140
|
local path, or a bare name from ~/.agents/skills; --update re-fetches, review with git diff)
|
|
130
141
|
deploy fly|railway [dir]: generate host config + Dockerfile/.dockerignore from the definition and
|
|
@@ -164,6 +175,7 @@ const { positionals, values } = parseArgs({
|
|
|
164
175
|
"agent-dir": { type: "string" },
|
|
165
176
|
"no-watch": { type: "boolean" },
|
|
166
177
|
tunnel: { type: "boolean" },
|
|
178
|
+
"create-app": { type: "boolean" },
|
|
167
179
|
update: { type: "boolean" },
|
|
168
180
|
force: { type: "boolean" },
|
|
169
181
|
stop: { type: "boolean" },
|
|
@@ -575,18 +587,39 @@ async function runAdd() {
|
|
|
575
587
|
process.exit(1);
|
|
576
588
|
}
|
|
577
589
|
const channelKind = kind;
|
|
590
|
+
// App creation is not a flag — it is what `add feishu` IS (the scan-to-create flow is the default
|
|
591
|
+
// and only path there). The retired --create-app spelling gets a pointer, not silence.
|
|
592
|
+
if (values["create-app"]) {
|
|
593
|
+
if (channelKind === "feishu") {
|
|
594
|
+
console.error(`[fastagent] note: --create-app is retired — \`add feishu\` creates the app by default`);
|
|
595
|
+
}
|
|
596
|
+
else if (channelKind === "lark") {
|
|
597
|
+
failStartup(new Error("--create-app is retired — `add lark` now opens the developer console and guides credential setup by default"));
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
failStartup(new Error("--create-app is retired — app creation is the default behavior of `add feishu`"));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
578
603
|
// The channel (glue + companion tool) is agent surface — it lands in agentDir (config.agentDir, or
|
|
579
604
|
// target when flat), the same place dev/start discover channels/. .env(.example) and the secret
|
|
580
605
|
// hygiene stay at the run root, where .env is actually read.
|
|
581
606
|
const { config: addConfig } = await loadConfig(target).catch(failStartup);
|
|
582
607
|
const channelHome = resolveAgentDir(target, addConfig);
|
|
583
|
-
// Preconditions before the write, so a refusal is side-effect-free.
|
|
608
|
+
// Preconditions before the write, so a refusal is side-effect-free. feishu/lark are exceptions:
|
|
609
|
+
// their add is scaffold + ONBOARD THE APP, so an existing scaffold skips the write and continues (a
|
|
610
|
+
// failed or cancelled scan/paste flow must be re-runnable without hand-deleting glue); never touch it.
|
|
611
|
+
const file = join(channelHome, "channels", `${channelKind}.ts`);
|
|
584
612
|
if (await channelExists(channelHome, channelKind).catch(failStartup)) {
|
|
585
|
-
|
|
613
|
+
if (channelKind !== "feishu" && channelKind !== "lark") {
|
|
614
|
+
failStartup(new Error(`${relative(target, file)} already exists — edit it, or remove it to re-scaffold`));
|
|
615
|
+
}
|
|
616
|
+
console.error(`[fastagent] ${relative(target, file)} already exists — keeping it`);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
await assertChannelReady(channelHome).catch(failStartup);
|
|
620
|
+
await scaffoldChannel(channelHome, channelKind).catch(failStartup);
|
|
621
|
+
console.error(`[fastagent] created ${relative(target, file)}`);
|
|
586
622
|
}
|
|
587
|
-
await assertChannelReady(channelHome).catch(failStartup);
|
|
588
|
-
const file = await scaffoldChannel(channelHome, channelKind).catch(failStartup);
|
|
589
|
-
console.error(`[fastagent] created ${relative(target, file)}`);
|
|
590
623
|
if (await appendChannelEnv(target, channelKind).catch(failStartup)) {
|
|
591
624
|
console.error(`[fastagent] added ${channelKind} env vars to .env.example`);
|
|
592
625
|
}
|
|
@@ -598,11 +631,21 @@ async function runAdd() {
|
|
|
598
631
|
if (!envIgnored) {
|
|
599
632
|
console.error(`[fastagent] warn: .env is not gitignored — a deploy that copies the directory would ship a secret placed there; add .env to .gitignore/.fastagentignore, or use a real env var`);
|
|
600
633
|
}
|
|
634
|
+
// `add feishu`/`add lark` = scaffold + CREATE OR RESUME the app (cli-add-feishu.ts): feishu persists
|
|
635
|
+
// its irreversible App ID/Secret boundary internally; lark returns guided credentials for the
|
|
636
|
+
// generic .env write below.
|
|
637
|
+
let created;
|
|
638
|
+
if (channelKind === "feishu" || channelKind === "lark") {
|
|
639
|
+
created = await onboardFeishuCloudApp(target, channelKind, envIgnored).catch(failStartup);
|
|
640
|
+
}
|
|
601
641
|
const { env, steps } = channelSetup(channelKind);
|
|
602
642
|
const generated = Object.fromEntries(env.filter((e) => e.generate).map((e) => [e.name, randomBytes(24).toString("hex")]));
|
|
603
643
|
// Kind-neutral: every channel's generated secrets get the same treatment (github's webhook secret is
|
|
604
|
-
// the same class of value as telegram's).
|
|
605
|
-
|
|
644
|
+
// the same class of value as telegram's); guided Lark credentials ride the same write as overwrites.
|
|
645
|
+
// Feishu's irreversible credentials were already staged inside cli-add-feishu.ts before bootstrap.
|
|
646
|
+
const dotEnv = envIgnored
|
|
647
|
+
? await appendChannelDotEnv(target, channelKind, { ...generated, ...created }, Object.keys(created ?? {})).catch(failStartup)
|
|
648
|
+
: undefined;
|
|
606
649
|
if (dotEnv && dotEnv.written.length > 0) {
|
|
607
650
|
console.error(`[fastagent] wrote ${dotEnv.written.join(", ")} to .env`);
|
|
608
651
|
}
|
|
@@ -617,11 +660,12 @@ async function runAdd() {
|
|
|
617
660
|
if (dotEnv?.written.includes(e.name)) {
|
|
618
661
|
// Written, but its hint may still carry an action (github: paste the same value into the webhook
|
|
619
662
|
// UI) — keep the variable visible instead of silently absorbing it.
|
|
620
|
-
console.error(` ${e.name} — generated and written to .env # ${e.hint}`);
|
|
663
|
+
console.error(` ${e.name} — ${e.generate ? "generated and " : ""}written to .env # ${e.hint}`);
|
|
621
664
|
continue;
|
|
622
665
|
}
|
|
623
666
|
const value = e.generate ? `=${generated[e.name]}` : "";
|
|
624
|
-
|
|
667
|
+
const action = e.required ? "set" : "optionally set";
|
|
668
|
+
console.error(` ${action} ${e.name}${value} in .env${envIgnored ? " (gitignored)" : ""} # ${e.hint}`);
|
|
625
669
|
}
|
|
626
670
|
// Steps carry `{channel}`/`{tools}` path placeholders (their filenames are the scaffold's private
|
|
627
671
|
// knowledge) — resolve them to the real workspace-relative locations (agentDir-aware) here.
|
|
@@ -629,7 +673,12 @@ async function runAdd() {
|
|
|
629
673
|
for (const s of steps) {
|
|
630
674
|
console.error(` ${s.replace("{channel}", relative(target, file)).replace("{tools}", `${kitPrefix}tools`)}`);
|
|
631
675
|
}
|
|
632
|
-
|
|
676
|
+
if (channelKind !== "lark") {
|
|
677
|
+
console.error(` fastagent dev --tunnel # serve locally + a public URL, auto-registering the webhook`);
|
|
678
|
+
}
|
|
679
|
+
// The app-creation flow leaves keep-alive sockets behind (platform API fetches, the throwaway tunnel's
|
|
680
|
+
// health probes) that would hold the event loop open for a while — the work is done, exit crisply.
|
|
681
|
+
process.exit(0);
|
|
633
682
|
}
|
|
634
683
|
/** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
|
|
635
684
|
async function runAddSkill() {
|
|
@@ -681,6 +730,7 @@ async function runDeploy() {
|
|
|
681
730
|
process.exit(1);
|
|
682
731
|
}
|
|
683
732
|
loadDotEnv(target); // a custom provider/tool may read a key at config load
|
|
733
|
+
installProxyFetch(); // post-deploy channel API calls must honor HTTP(S)_PROXY under Node
|
|
684
734
|
const { config } = await loadConfig(target).catch(failStartup);
|
|
685
735
|
const modelSpec = resolveModelSpec(values.model, config);
|
|
686
736
|
// The host-neutral pre-flight (model-travel gate, channel discovery, model-auth probe, container facts +
|
|
@@ -853,7 +903,7 @@ async function runDeployFly(params) {
|
|
|
853
903
|
console.error(`[fastagent] deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`);
|
|
854
904
|
process.exit(1);
|
|
855
905
|
}
|
|
856
|
-
const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl));
|
|
906
|
+
const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
|
|
857
907
|
if (!outcome.ok) {
|
|
858
908
|
console.error(`[fastagent] deploy stopped: ${outcome.gate}`);
|
|
859
909
|
process.exit(1);
|
|
@@ -887,7 +937,7 @@ async function runDeployRailway(params) {
|
|
|
887
937
|
console.error(`[fastagent] deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`);
|
|
888
938
|
process.exit(1);
|
|
889
939
|
}
|
|
890
|
-
const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked: !!values["into-linked"] }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl));
|
|
940
|
+
const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked: !!values["into-linked"] }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
|
|
891
941
|
if (!outcome.ok) {
|
|
892
942
|
console.error(`[fastagent] deploy stopped: ${outcome.gate}`);
|
|
893
943
|
process.exit(1);
|
|
@@ -931,11 +981,6 @@ async function runLogin() {
|
|
|
931
981
|
console.error(`[fastagent] logged in to ${result.provider} (${result.method}) — saved to ${authPath}`);
|
|
932
982
|
process.exit(0); // the undici proxy agent's keep-alive sockets would otherwise hold the event loop open
|
|
933
983
|
}
|
|
934
|
-
/** Best-effort open a URL in the default browser; failure is fine (the URL is always printed too). */
|
|
935
|
-
function openBrowser(url) {
|
|
936
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
937
|
-
spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => { });
|
|
938
|
-
}
|
|
939
984
|
/** Login terminal IO via @clack/prompts: a searchable list once long, a hidden prompt for keys. */
|
|
940
985
|
function terminalLoginIO() {
|
|
941
986
|
return {
|
|
@@ -950,7 +995,7 @@ function terminalLoginIO() {
|
|
|
950
995
|
return isCancel(r) ? undefined : r;
|
|
951
996
|
},
|
|
952
997
|
note: (message) => clackLog.info(message),
|
|
953
|
-
openUrl:
|
|
998
|
+
openUrl: openExternalUrl,
|
|
954
999
|
};
|
|
955
1000
|
}
|
|
956
1001
|
/**
|
|
@@ -1118,7 +1163,7 @@ function maybeTunnel(workspaceDir, boundPort) {
|
|
|
1118
1163
|
void startCloudflareTunnel(boundPort).then((t) => {
|
|
1119
1164
|
if (!t)
|
|
1120
1165
|
return;
|
|
1121
|
-
void announceWebhooks(workspaceDir, t.url);
|
|
1166
|
+
void announceWebhooks(workspaceDir, t.url, { openUrl: openExternalUrl });
|
|
1122
1167
|
// Single-process (start / --no-watch): close the tunnel on exit (watch mode's supervisor owns its own).
|
|
1123
1168
|
const cleanup = () => {
|
|
1124
1169
|
t.close();
|
package/dist/deploy/container.js
CHANGED
|
@@ -120,12 +120,16 @@ COPY . .
|
|
|
120
120
|
CMD ["./node_modules/.bin/fastagent", "start", "/app"]
|
|
121
121
|
`;
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
.
|
|
125
|
-
.
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
/** Patterns are RECURSIVE (`**/`) on purpose — dockerignore patterns are root-anchored (unlike
|
|
124
|
+
* .gitignore), and a repo-as-agent can hold nested projects: a bare `node_modules` would upload their
|
|
125
|
+
* build-machine deps (macOS binaries!) and a bare `.env` would bake their secrets into the image.
|
|
126
|
+
* `.git` stays root-anchored: nested projects' own `.git` ships (the agent's write-back needs it). */
|
|
127
|
+
const DOCKERIGNORE = `**/node_modules
|
|
128
|
+
**/.fastagent
|
|
129
|
+
**/.env
|
|
130
|
+
**/.env.*
|
|
131
|
+
!**/.env.example
|
|
132
|
+
**/*.log
|
|
129
133
|
# .git is excluded to keep the image small. If your agent runs git on its OWN history
|
|
130
134
|
# (git log/blame over the repo it ships in), delete the next line so that history is in the image.
|
|
131
135
|
.git
|