@fastagent-sh/fastagent 0.28.0 → 0.29.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 (59) hide show
  1. package/README.md +2 -1
  2. package/dist/channels/feishu/feishu-api.d.ts +1 -0
  3. package/dist/channels/feishu/feishu-api.js +4 -0
  4. package/dist/channels/feishu/register-app.d.ts +2 -0
  5. package/dist/channels/feishu/register-app.js +1 -1
  6. package/dist/channels/lark/credentials-page.d.ts +25 -0
  7. package/dist/channels/lark/credentials-page.js +206 -0
  8. package/dist/channels/lark/onboard.d.ts +18 -2
  9. package/dist/channels/lark/onboard.js +36 -6
  10. package/dist/channels/slack/setup-server.js +75 -30
  11. package/dist/channels/wait-health.js +1 -1
  12. package/dist/cli/add-feishu.d.ts +0 -9
  13. package/dist/cli/add-feishu.js +62 -38
  14. package/dist/cli/commands/add.js +5 -12
  15. package/dist/cli/commands/deploy.js +1 -8
  16. package/dist/cli/commands/info.js +4 -0
  17. package/dist/cli/commands/mcp.d.ts +12 -0
  18. package/dist/cli/commands/mcp.js +156 -0
  19. package/dist/cli/program.js +59 -0
  20. package/dist/deploy/agentcore/destroy.js +1 -1
  21. package/dist/deploy/build-context.js +4 -0
  22. package/dist/deploy/container.js +1 -2
  23. package/dist/deploy/docker/run.d.ts +16 -3
  24. package/dist/deploy/docker/run.js +54 -1
  25. package/dist/deploy/fly/run.d.ts +0 -14
  26. package/dist/deploy/fly/run.js +4 -5
  27. package/dist/dev-supervisor.js +3 -2
  28. package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
  29. package/dist/harnesses/pi-durable/mcp.js +34 -0
  30. package/dist/harnesses/pi-durable/open.d.ts +1 -1
  31. package/dist/harnesses/pi-durable/open.js +12 -3
  32. package/dist/harnesses/pi-durable/serve.d.ts +4 -0
  33. package/dist/harnesses/pi-durable/serve.js +47 -17
  34. package/dist/http/contract.d.ts +3 -3
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +4 -0
  37. package/dist/locked-file.d.ts +2 -2
  38. package/dist/locked-file.js +8 -3
  39. package/dist/mcp/auth.d.ts +22 -0
  40. package/dist/mcp/auth.js +180 -0
  41. package/dist/mcp/client.d.ts +15 -0
  42. package/dist/mcp/client.js +170 -0
  43. package/dist/mcp/config.d.ts +34 -0
  44. package/dist/mcp/config.js +142 -0
  45. package/dist/mcp/grants.d.ts +4 -0
  46. package/dist/mcp/grants.js +132 -0
  47. package/dist/mcp/host.d.ts +16 -0
  48. package/dist/mcp/host.js +120 -0
  49. package/dist/mcp/network.d.ts +12 -0
  50. package/dist/mcp/network.js +150 -0
  51. package/dist/mcp/sign-in.d.ts +2 -0
  52. package/dist/mcp/sign-in.js +204 -0
  53. package/dist/mcp-api.d.ts +93 -0
  54. package/dist/mcp-api.js +10 -0
  55. package/dist/setup-page.d.ts +51 -0
  56. package/dist/setup-page.js +149 -0
  57. package/dist/value-file.d.ts +1 -0
  58. package/dist/value-file.js +15 -16
  59. package/package.json +3 -2
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The pages a person sees while FastAgent sets an app up with them: where they paste a credential, and what happens next.
3
+ * One design for every channel (Slack's tokens, Lark's credentials), so a second page is a repetition of the first.
4
+ *
5
+ * The page is self-contained: no font, script or image comes from anywhere else, and it follows the system's light or
6
+ * dark. It is served to a browser on this machine (or through a tunnel), so a secret pasted into it goes from the browser
7
+ * to this process and nowhere else — never through a coding agent's conversation or a file the person writes.
8
+ *
9
+ * Design rules it keeps, so a page added later keeps them too: one left edge; one primary button per page; a label is the
10
+ * name the other site uses for the value; an error sits above the field it is about; red is for an error and green for
11
+ * success only; every text is at least 4.5:1 against its background and an input's edge 3:1.
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ export function setupRequestUrl(request, response) {
15
+ const url = URL.parse(request.url ?? "/", "http://127.0.0.1");
16
+ if (url === null) {
17
+ response.writeHead(400).end("invalid request URL\n");
18
+ return undefined;
19
+ }
20
+ return url;
21
+ }
22
+ const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
23
+ const CSS = `
24
+ :root{color-scheme:light dark;--bg:#f4f5f7;--card:#fff;--ink:#11151a;--muted:#4f5a67;--line:#d7dce2;--field:#fff;--edge:#727c88;
25
+ --accent:#0a6a7d;--accent-hover:#085666;--on-accent:#fff;--accent-tint:#e2f2f5;--ok:#17713f;--ok-tint:#e2f4ea;--bad:#b3261e;--bad-tint:#fdecea}
26
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1014;--card:#161a20;--ink:#f2f4f7;--muted:#a3adba;--line:#2c333d;--field:#0d1014;--edge:#6b7684;
27
+ --accent:#46bfd4;--accent-hover:#6bd0e1;--on-accent:#06222a;--accent-tint:#123039;--ok:#5ccd91;--ok-tint:#11301f;--bad:#ff948b;--bad-tint:#3a1815}}
28
+ *{box-sizing:border-box}
29
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
30
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
31
+ h1,h2{text-wrap:balance}p{text-wrap:pretty}
32
+ main{max-width:520px;margin:48px auto;padding:0 16px}
33
+ .brand{margin:0 0 16px;font-weight:700;letter-spacing:-.01em}
34
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:32px}
35
+ h1{font-size:28px;line-height:1.2;font-weight:700;letter-spacing:-.011em;margin:0 0 32px}
36
+ .step+.step{margin-top:40px}
37
+ .step header{display:flex;align-items:center;gap:12px;margin:0 0 8px}
38
+ .step header.bare{margin-bottom:16px}
39
+ .num{flex:none;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;background:var(--accent-tint);color:var(--accent);font-weight:700;font-size:14px}
40
+ .step h2{font-size:18px;line-height:28px;font-weight:700;margin:0}
41
+ .step p{margin:0 0 16px;color:var(--muted)}
42
+ .step p b{color:var(--ink);font-weight:600;white-space:nowrap}
43
+ label{display:block;font-weight:600;font-size:14px;margin:0 0 4px}
44
+ .field{position:relative;margin-bottom:16px}
45
+ input{width:100%;height:48px;padding:0 72px 0 16px;font:15px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink);background:var(--field);border:1px solid var(--edge);border-radius:8px}
46
+ input.plain{padding-right:16px}
47
+ input[aria-invalid=true]{border-color:var(--bad)}
48
+ input:focus-visible,button:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
49
+ .toggle{position:absolute;right:4px;bottom:4px;height:40px;padding:0 12px;border:0;border-radius:6px;background:none;color:var(--accent);font:inherit;font-size:14px;font-weight:600;cursor:pointer}
50
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:48px;padding:0 24px;border-radius:8px;font:inherit;font-size:16px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer;border:2px solid var(--accent)}
51
+ .btn.primary{background:var(--accent);color:var(--on-accent)}
52
+ .btn.primary:hover{background:var(--accent-hover);border-color:var(--accent-hover)}
53
+ .btn.secondary{background:transparent;color:var(--accent)}
54
+ .btn.secondary:hover{background:var(--accent-tint)}
55
+ .btn[disabled]{opacity:.65;cursor:progress}
56
+ .btn.block{width:100%;margin-top:8px}
57
+ .step .privacy{display:flex;align-items:flex-start;gap:8px;margin:12px 0 0;color:var(--muted);font-size:14px;line-height:20px}
58
+ .privacy svg{flex:none;margin-top:2px}
59
+ .error{margin:0 0 16px;padding:12px 16px;border-left:4px solid var(--bad);background:var(--bad-tint);border-radius:0 8px 8px 0;font-size:15px}
60
+ .error b{display:block}
61
+ .error .why{display:block;margin-top:4px;font-size:13px;color:var(--muted);overflow-wrap:anywhere}
62
+ .state{display:flex;align-items:center;gap:16px;margin:0 0 16px}
63
+ .state h1{margin:0}
64
+ .glyph{flex:none;width:40px;height:40px;border-radius:50%;display:grid;place-items:center}
65
+ .glyph.ok{background:var(--ok-tint);color:var(--ok)}
66
+ .glyph.bad{background:var(--bad-tint);color:var(--bad)}
67
+ .glyph.wait{border:4px solid var(--accent-tint);border-top-color:var(--accent);animation:r 1s linear infinite}
68
+ @keyframes r{to{transform:rotate(360deg)}}
69
+ @media (prefers-reduced-motion:reduce){.glyph.wait{animation:none}}
70
+ .lede{margin:0;color:var(--muted)}
71
+ .next{margin:32px 0 0;padding-top:24px;border-top:1px solid var(--line);font-weight:600}
72
+ @media (max-width:480px){.card{padding:24px 20px}h1{font-size:24px}}
73
+ `;
74
+ /** Show/hide for a secret, and a busy button so a second click cannot submit twice. */
75
+ const SCRIPT = `
76
+ document.querySelectorAll(".toggle").forEach(function(b){b.addEventListener("click",function(){
77
+ var i=document.getElementById(b.dataset.for),s=i.type==="password";i.type=s?"text":"password";b.textContent=s?"Hide":"Show"})});
78
+ document.querySelectorAll("form").forEach(function(f){f.addEventListener("submit",function(){
79
+ var g=f.querySelector(".btn.primary");if(g){g.disabled=true;g.textContent="Checking\\u2026"}})});
80
+ `;
81
+ const LOCK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>`;
82
+ const OUT = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>`;
83
+ const TICK = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>`;
84
+ const CROSS = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>`;
85
+ /** `1 Title`: the number and the title on one line, so the step's content below shares the card's one left edge. */
86
+ export function step(number, title, content, options = {}) {
87
+ return (`<section class="step"><header${options.bare ? ' class="bare"' : ""}><span class="num" aria-hidden="true">${number}</span>` +
88
+ `<h2>${escapeHtml(title)}</h2></header>${content}</section>`);
89
+ }
90
+ /** A sentence under a step's title. `html` is trusted markup: use `<b>` for a name the person must find on another site. */
91
+ export const prose = (html) => `<p>${html}</p>`;
92
+ /** A button that opens another site in a new tab (secondary: the step's real action is the primary button below). */
93
+ export function openButton(label, href) {
94
+ return `<a class="btn secondary" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)} ${OUT}</a>`;
95
+ }
96
+ /**
97
+ * A labelled input. `label` is the name the other site gives the value, so the person never has to map one to the other.
98
+ * A secret gets a Show/Hide toggle; nothing is ever put back in a secret field after a refusal.
99
+ */
100
+ export function field(input) {
101
+ const { id, label, placeholder, secret, value, invalid, autofocus } = input;
102
+ return (`<div class="field"><label for="${id}">${escapeHtml(label)}</label>` +
103
+ `<input id="${id}" name="${id}" class="${secret ? "" : "plain"}" type="${secret ? "password" : "text"}" ` +
104
+ `${placeholder ? `placeholder="${escapeHtml(placeholder)}" ` : ""}${value ? `value="${escapeHtml(value)}" ` : ""}` +
105
+ `${invalid ? 'aria-invalid="true" ' : ""}${autofocus ? "autofocus " : ""}` +
106
+ `autocomplete="off" autocapitalize="off" spellcheck="false" required>` +
107
+ `${secret ? `<button class="toggle" type="button" data-for="${id}">Show</button>` : ""}</div>`);
108
+ }
109
+ /**
110
+ * What was refused, in bold, and what to do about it. Sits above the field it is about. `why` is the other side's own
111
+ * words, for someone who has to look it up: small and set apart, so the sentence the person reads stays one they can act on.
112
+ */
113
+ export function refusal(headline, detail, why) {
114
+ return (`<div class="error" role="alert"><b>${escapeHtml(headline)}</b>${detail ? escapeHtml(detail) : ""}` +
115
+ `${why ? `<span class="why">${escapeHtml(why)}</span>` : ""}</div>`);
116
+ }
117
+ export const submit = (label) => `<button class="btn primary block" type="submit">${escapeHtml(label)}</button>`;
118
+ /** The promise about where a pasted value goes. Said once, the same way, on every page that asks for one. */
119
+ export const privacy = () => `<p class="privacy">${LOCK}<span>Stays on this computer. Never shared with your coding agent.</span></p>`;
120
+ /** A page that asks for something: a heading, then the steps, in one form posting back to this address. */
121
+ export function formCard(heading, steps) {
122
+ return `<form class="card" method="post"><h1>${escapeHtml(heading)}</h1>${steps}</form>`;
123
+ }
124
+ /** A page that says where things stand: working (it moves on by itself), done, or stopped. */
125
+ export function statusCard(input) {
126
+ const glyph = input.state === "working"
127
+ ? `<span class="glyph wait" role="status" aria-label="Working"></span>`
128
+ : `<span class="glyph ${input.state === "done" ? "ok" : "bad"}">${input.state === "done" ? TICK : CROSS}</span>`;
129
+ return (`<div class="card"><div class="state">${glyph}<h1>${escapeHtml(input.heading)}</h1></div>` +
130
+ `${input.lede ? `<p class="lede">${escapeHtml(input.lede)}</p>` : ""}` +
131
+ `${input.next ? `<p class="next">${escapeHtml(input.next)}</p>` : ""}</div>`);
132
+ }
133
+ /**
134
+ * Send a page. The headers are those of a page that takes a secret: nothing cached, nothing framed, nothing loaded from
135
+ * elsewhere (a nonce admits only this page's own style and script). No `form-action`: browsers apply it to the redirects a
136
+ * submission is answered with, and Slack's page answers one by sending the browser on to Slack.
137
+ */
138
+ export function sendPage(response, status, page) {
139
+ const nonce = randomBytes(16).toString("base64");
140
+ response.statusCode = status;
141
+ response.setHeader("content-type", "text/html; charset=utf-8");
142
+ response.setHeader("cache-control", "no-store");
143
+ response.setHeader("referrer-policy", "no-referrer");
144
+ response.setHeader("x-content-type-options", "nosniff");
145
+ response.setHeader("content-security-policy", `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; base-uri 'none'; frame-ancestors 'none'`);
146
+ response.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
147
+ `${page.head ?? ""}<title>${escapeHtml(page.title)}</title><style nonce="${nonce}">${CSS}</style>` +
148
+ `<main><p class="brand">FastAgent</p>${page.card}</main><script nonce="${nonce}">${SCRIPT}</script>`);
149
+ }
@@ -33,6 +33,7 @@ export interface ValueWriteResult {
33
33
  }
34
34
  /** What a command says when it wrote a secret into a secrets directory fastagent does not own and git may track. */
35
35
  export declare function unprotectedSecretsNote(dir: string): string;
36
+ export declare function prepareSecretsDirectory(dir: string, secretsDir: string): Promise<string | undefined>;
36
37
  /**
37
38
  * Write `entries` into the selected environment's value file, under a `# --- <section> ---` line. A name with a
38
39
  * non-empty active value is kept unless `overwrite` names it.
@@ -69,6 +69,20 @@ export function unprotectedSecretsNote(dir) {
69
69
  return (`${dir} (FASTAGENT_SECRETS_DIR) now holds a secret and has no .gitignore — that directory is yours, so ` +
70
70
  "fastagent will not write one into it. Make sure git does not track what is in there.");
71
71
  }
72
+ export async function prepareSecretsDirectory(dir, secretsDir) {
73
+ await mkdir(secretsDir, { recursive: true });
74
+ const owned = secretsDir === join(dir, SECRETS_DIRNAME) || secretsDir === join(dir, SECRETS_DIRNAME, "production");
75
+ if (owned) {
76
+ // EEXIST preserves an existing ignore file, including one created by a concurrent writer.
77
+ await writeFile(join(secretsDir, ".gitignore"), baseTemplate("secrets.gitignore"), { flag: "wx" }).catch((error) => {
78
+ if (error.code !== "EEXIST")
79
+ throw error;
80
+ });
81
+ }
82
+ else if (!(await exists(join(secretsDir, ".gitignore")))) {
83
+ return secretsDir;
84
+ }
85
+ }
72
86
  /** Whether `.env` content carries a non-empty ACTIVE value for `name`. */
73
87
  function hasActiveEnvValue(content, name) {
74
88
  return (parseEnvContent(content).get(name)?.trim() ?? "") !== "";
@@ -82,22 +96,7 @@ function mentionsEnvName(content, name) {
82
96
  */
83
97
  export async function writeValues(dir, section, entries, overwrite = []) {
84
98
  const file = dotEnvPath(dir);
85
- const secretsDir = dirname(file);
86
- await mkdir(secretsDir, { recursive: true });
87
- // THE one exception to "fastagent has no opinion about git": the directory it writes secrets into carries its own
88
- // `.gitignore`.
89
- const owned = secretsDir === join(dir, SECRETS_DIRNAME) || secretsDir === join(dir, SECRETS_DIRNAME, "production");
90
- let unprotectedSecretsDir;
91
- if (owned) {
92
- // Only EEXIST is tolerable (already protected, or a concurrent writer).
93
- await writeFile(join(secretsDir, ".gitignore"), baseTemplate("secrets.gitignore"), { flag: "wx" }).catch((e) => {
94
- if (e.code !== "EEXIST")
95
- throw e;
96
- });
97
- }
98
- else if (!(await exists(join(secretsDir, ".gitignore")))) {
99
- unprotectedSecretsDir = secretsDir;
100
- }
99
+ const unprotectedSecretsDir = await prepareSecretsDirectory(dir, dirname(file));
101
100
  let current = "";
102
101
  try {
103
102
  current = await readFile(file, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on Telegram, Slack, Feishu, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",
@@ -77,7 +77,6 @@
77
77
  "build": "tsc -p tsconfig.build.json",
78
78
  "prepack": "npm run build",
79
79
  "test": "vitest --run",
80
- "test:live": "vitest --run --config vitest.live.config.ts",
81
80
  "typecheck": "tsc --noEmit",
82
81
  "lint": "biome check src test && node scripts/check-doc-links.mjs && knip",
83
82
  "format": "biome check --write src test",
@@ -95,6 +94,7 @@
95
94
  "@effect/platform-bun": "^4.0.7",
96
95
  "@effect/platform-node": "^4.0.7",
97
96
  "@larksuiteoapi/node-sdk": "^1.76.0",
97
+ "@modelcontextprotocol/client": "2.4.0",
98
98
  "@smithy/signature-v4": "^5.7.4",
99
99
  "chokidar": "^6.0.0",
100
100
  "commander": "^15.0.0",
@@ -110,6 +110,7 @@
110
110
  "@biomejs/biome": "^2.6.4",
111
111
  "@earendil-works/pi-coding-agent": "1.4.0",
112
112
  "@hono/node-server": "^2.1.5",
113
+ "@modelcontextprotocol/server": "2.4.0",
113
114
  "@types/express": "^5.0.6",
114
115
  "@types/proper-lockfile": "^4.1.4",
115
116
  "express": "^5.3.0",