@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/notify.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Telling a person what their agents did while they were not looking.
|
|
2
|
+
//
|
|
3
|
+
// An agent that runs when the laptop is closed needs a way to say "I
|
|
4
|
+
// finished", "I failed", and above all "I am waiting for you" — a parked
|
|
5
|
+
// approval nobody hears about is a run that never happens. The channel is a
|
|
6
|
+
// webhook out: one URL, declared in AGENTS.md frontmatter, POSTed a small
|
|
7
|
+
// JSON body. Slack, Discord, ntfy, a phone-push relay and a plain inbox
|
|
8
|
+
// service all speak that; building any one of them natively would just be
|
|
9
|
+
// this plus vendor formatting.
|
|
10
|
+
//
|
|
11
|
+
// notify:
|
|
12
|
+
// url: ${SLACK_WEBHOOK_URL} # a secret name, or a literal URL
|
|
13
|
+
// events: [failed, awaiting-approval, completed]
|
|
14
|
+
//
|
|
15
|
+
// Or, for people who live in an inbox rather than a channel:
|
|
16
|
+
//
|
|
17
|
+
// notify:
|
|
18
|
+
// email: ops@example.com # sent via the RESEND_API_KEY connection
|
|
19
|
+
// events: [failed, completed]
|
|
20
|
+
//
|
|
21
|
+
// Email is the one vendor worth speaking natively: the platform already
|
|
22
|
+
// holds a Resend connection for its email tool, and "just email me" is the
|
|
23
|
+
// notification default for most of the world. Everything else stays a
|
|
24
|
+
// webhook — one URL covers Slack, Discord, ntfy and every relay.
|
|
25
|
+
//
|
|
26
|
+
// Workspace config replaces account config whole, like provider: — a URL is
|
|
27
|
+
// a destination, and merging two destinations sends half your alerts to a
|
|
28
|
+
// channel you stopped reading.
|
|
29
|
+
//
|
|
30
|
+
// Defaults: failed and awaiting-approval. Completed is opt-in — a schedule
|
|
31
|
+
// that works is the quiet kind of good news, and a channel that pings on
|
|
32
|
+
// every success gets muted, which un-pings the failures too.
|
|
33
|
+
|
|
34
|
+
import { readAgentsMd } from "./runner.ts";
|
|
35
|
+
import { accountDir, workspaceDir, runCost, type RunRecord } from "./store.ts";
|
|
36
|
+
import { getSecret } from "./secrets.ts";
|
|
37
|
+
import { approveToken, publicUrl } from "./webhook.ts";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The platform's own mail: an invite, a low balance.
|
|
41
|
+
*
|
|
42
|
+
* These are from foldrun, about the platform, to the account's owner — so
|
|
43
|
+
* they go through the platform's Resend key and sender (FOLDRUN_RESEND_API_KEY,
|
|
44
|
+
* FOLDRUN_EMAIL_FROM), never through anything the customer configured.
|
|
45
|
+
*
|
|
46
|
+
* Without a platform key — the CLI on a laptop, a test — the account's own
|
|
47
|
+
* key and EMAIL_FROM are the fallback.
|
|
48
|
+
*/
|
|
49
|
+
export function platformMail(tenant: string): { key: string; from: string } | null {
|
|
50
|
+
const key = process.env.FOLDRUN_RESEND_API_KEY;
|
|
51
|
+
if (key) return { key, from: process.env.FOLDRUN_EMAIL_FROM || "foldrun <hello@foldrun.io>" };
|
|
52
|
+
return accountMail(tenant);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The account's own Resend key and sender, or null when it has none. */
|
|
56
|
+
export function accountMail(tenant: string): { key: string; from: string } | null {
|
|
57
|
+
const own = getSecret(tenant, "RESEND_API_KEY");
|
|
58
|
+
if (!own) return null;
|
|
59
|
+
const from = getSecret(tenant, "EMAIL_FROM")?.value?.trim() || "foldrun <onboarding@resend.dev>";
|
|
60
|
+
return { key: own.value, from };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A run notification is the account's mail, not the platform's: it is a
|
|
65
|
+
* desk telling its owner what it found, and the owner chose the sender —
|
|
66
|
+
* their RESEND_API_KEY and EMAIL_FROM on the account, the same connection
|
|
67
|
+
* their agents' `email` tool uses. So it comes from the address they
|
|
68
|
+
* expect, under their own domain's reputation and their own inbox rules;
|
|
69
|
+
* 2026-09-06, every notification from the platform's address was in the
|
|
70
|
+
* owner's bin. The platform's key is the fallback for an account that set
|
|
71
|
+
* none, so `notify:` works before anyone has configured mail at all.
|
|
72
|
+
*/
|
|
73
|
+
export function notifyMail(tenant: string): { key: string; from: string } | null {
|
|
74
|
+
return accountMail(tenant) ?? platformMail(tenant);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface NotifyConfig {
|
|
78
|
+
url?: string;
|
|
79
|
+
email?: string;
|
|
80
|
+
events: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const DEFAULT_EVENTS = ["failed", "awaiting-approval"];
|
|
84
|
+
|
|
85
|
+
export function notifyConfig(tenant: string, workspace: string): NotifyConfig | null {
|
|
86
|
+
const raw =
|
|
87
|
+
(readAgentsMd(workspaceDir(tenant, workspace))?.data.notify as unknown) ??
|
|
88
|
+
(readAgentsMd(accountDir(tenant))?.data.notify as unknown);
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
if (typeof raw === "string") {
|
|
91
|
+
// A bare string is whichever destination it looks like.
|
|
92
|
+
return raw.includes("@") && !raw.includes("/")
|
|
93
|
+
? { email: raw, events: DEFAULT_EVENTS }
|
|
94
|
+
: { url: raw, events: DEFAULT_EVENTS };
|
|
95
|
+
}
|
|
96
|
+
if (typeof raw === "object" && raw !== null) {
|
|
97
|
+
const o = raw as { url?: unknown; email?: unknown; events?: unknown };
|
|
98
|
+
const url = typeof o.url === "string" ? o.url : undefined;
|
|
99
|
+
const email = typeof o.email === "string" ? o.email : undefined;
|
|
100
|
+
if (!url && !email) return null;
|
|
101
|
+
return {
|
|
102
|
+
url,
|
|
103
|
+
email,
|
|
104
|
+
events: Array.isArray(o.events) && o.events.length ? o.events.map(String) : DEFAULT_EVENTS,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Said once per process, not once per run: the fix is one env edit, and a
|
|
111
|
+
// line per parked run would bury the failures around it.
|
|
112
|
+
let warnedNoPublicUrl = false;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The approve/reject links for a run waiting on a person, or null when the
|
|
116
|
+
* install cannot say where it lives. A notification that only says "waiting
|
|
117
|
+
* for you" sends the reader to find a laptop; one that carries the decision
|
|
118
|
+
* lets them make it where they read it.
|
|
119
|
+
*
|
|
120
|
+
* The reject link goes to the same page with the choice pre-selected — a
|
|
121
|
+
* GET must not decide anything, because inbox link-checkers follow links.
|
|
122
|
+
*/
|
|
123
|
+
export function approvalLinks(
|
|
124
|
+
tenant: string,
|
|
125
|
+
workspace: string,
|
|
126
|
+
runId: string,
|
|
127
|
+
): { approveUrl: string; rejectUrl: string } | null {
|
|
128
|
+
const base = publicUrl();
|
|
129
|
+
if (!base) {
|
|
130
|
+
if (!warnedNoPublicUrl) {
|
|
131
|
+
warnedNoPublicUrl = true;
|
|
132
|
+
console.error(
|
|
133
|
+
"[foldrun] notify: approval links need FOLDRUN_PUBLIC_URL (the install's public origin); sending without them",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const approveUrl = `${base}/api/approve/${tenant}/${workspace}/${runId}?token=${approveToken(tenant, workspace, runId)}`;
|
|
139
|
+
return { approveUrl, rejectUrl: `${approveUrl}&decision=reject` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Fire the webhook for a run's state, if the workspace asked to hear about
|
|
144
|
+
* it. Failures are logged and swallowed — a broken Slack hook must never
|
|
145
|
+
* fail the run it is reporting on.
|
|
146
|
+
*/
|
|
147
|
+
/** Flows whose completion is nobody's news: eval cases and adhoc runs. */
|
|
148
|
+
export function isQuietFlow(flow: string): boolean {
|
|
149
|
+
return /^(eval|adhoc):/.test(flow);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function sendRunNotification(
|
|
153
|
+
tenant: string,
|
|
154
|
+
workspace: string,
|
|
155
|
+
run: RunRecord,
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
const config = notifyConfig(tenant, workspace);
|
|
158
|
+
if (!config || !config.events.includes(run.status)) return false;
|
|
159
|
+
// A test is not news. An eval case or an adhoc single-agent run is started
|
|
160
|
+
// by a person who is watching it, and a desk that emails "completed" for
|
|
161
|
+
// every one of them buries the weekly verdict under two hundred of these —
|
|
162
|
+
// on 2026-09-06 the reader's inbox rule had sent the lot to the bin, the
|
|
163
|
+
// real reports with them. Failures and gates still send: a person asked.
|
|
164
|
+
if (run.status === "completed" && isQuietFlow(run.flow)) return false;
|
|
165
|
+
|
|
166
|
+
const failed = run.steps.filter((s) => s.status === "failed").map((s) => s.agent);
|
|
167
|
+
const waitingSteps = run.steps.filter((s) => s.status === "awaiting-approval");
|
|
168
|
+
const waiting = waitingSteps.map((s) => s.agent);
|
|
169
|
+
// A park on `wait: event` is not a question for the reader: the message
|
|
170
|
+
// says what the run is waiting for, so nobody hunts for a button that
|
|
171
|
+
// the outside world is meant to press.
|
|
172
|
+
const onEvent = waitingSteps.length > 0 && waitingSteps.every((s) => s.waitFor === "event");
|
|
173
|
+
const headline =
|
|
174
|
+
run.status === "completed"
|
|
175
|
+
? `✓ ${run.flow} completed`
|
|
176
|
+
: run.status === "failed"
|
|
177
|
+
? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
|
|
178
|
+
: onEvent
|
|
179
|
+
? `⏳ ${run.flow} is waiting for an external event${waiting.length ? ` (${waiting.join(", ")})` : ""}`
|
|
180
|
+
: `⏸ ${run.flow} is waiting for your approval${waiting.length ? ` (${waiting.join(", ")})` : ""}`;
|
|
181
|
+
|
|
182
|
+
// What the run concluded, if it concluded anything. This is the whole point
|
|
183
|
+
// of the message: "✓ health completed · $0.42" tells you a thing ran and
|
|
184
|
+
// what it cost, and nothing at all about what it found. A scheduled desk
|
|
185
|
+
// reporting only its own existence is a desk nobody reads by week three.
|
|
186
|
+
const summary = run.summary?.trim() || null;
|
|
187
|
+
|
|
188
|
+
const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run.id) : null;
|
|
189
|
+
// At a gate the reader is being asked something; the question goes in the
|
|
190
|
+
// mail, and so does what the run has concluded so far — the last finished
|
|
191
|
+
// step's first line, which is the proposal's own summary ("… ·
|
|
192
|
+
// TARGETS-PROPOSAL: 1 change"). Without these a gate email said only
|
|
193
|
+
// that a step was waiting, and the decision meant opening the dashboard.
|
|
194
|
+
const asks = waitingSteps.map((s) => s.ask?.trim()).filter((a): a is string => Boolean(a));
|
|
195
|
+
const soFar =
|
|
196
|
+
run.status === "awaiting-approval" && !summary
|
|
197
|
+
? [...run.steps].reverse().find((s) => s.status === "completed" && s.result?.trim())?.result?.trim().split("\n")[0] ?? null
|
|
198
|
+
: null;
|
|
199
|
+
|
|
200
|
+
const body = {
|
|
201
|
+
// `text` is what Slack-shaped receivers render; the rest is for anything
|
|
202
|
+
// that wants the data instead of the sentence.
|
|
203
|
+
text: summary
|
|
204
|
+
? `${headline} — ${summary} · ${workspace}/${run.id} · $${runCost(run).toFixed(4)}`
|
|
205
|
+
: `${headline} — ${workspace}/${run.id} · $${runCost(run).toFixed(4)}`,
|
|
206
|
+
workspace,
|
|
207
|
+
runId: run.id,
|
|
208
|
+
flow: run.flow,
|
|
209
|
+
status: run.status,
|
|
210
|
+
summary,
|
|
211
|
+
costUsd: runCost(run),
|
|
212
|
+
startedAt: run.startedAt,
|
|
213
|
+
finishedAt: run.finishedAt,
|
|
214
|
+
...(links ?? {}),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
if (config.email) {
|
|
219
|
+
const mail = notifyMail(tenant);
|
|
220
|
+
if (!mail) {
|
|
221
|
+
console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
from: mail.from,
|
|
229
|
+
to: config.email,
|
|
230
|
+
// The subject is the headline and what the run concluded, and
|
|
231
|
+
// nothing else. Ids and costs belong in the body: a subject line is
|
|
232
|
+
// read in a list, where the only useful question it can answer is
|
|
233
|
+
// whether this one needs opening.
|
|
234
|
+
subject: summary ? `${headline} — ${summary}`.slice(0, 160) : headline,
|
|
235
|
+
text:
|
|
236
|
+
`${headline}\n` +
|
|
237
|
+
(summary ? `\n${summary}\n` : soFar ? `\n${soFar}\n` : "") +
|
|
238
|
+
(asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
|
|
239
|
+
`\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
|
|
240
|
+
`cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
|
|
241
|
+
(links ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n` : ""),
|
|
242
|
+
}),
|
|
243
|
+
signal: AbortSignal.timeout(8000),
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
// Resend's body says WHY — "domain not verified", "can only send to
|
|
247
|
+
// your own address" — and a status alone sent someone to the wrong
|
|
248
|
+
// dashboard for twenty minutes on 2026-09-06.
|
|
249
|
+
const why = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300);
|
|
250
|
+
console.error(`[foldrun] notify email: ${tenant}/${workspace} → HTTP ${res.status} ${why}`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ${SECRET} so the Slack URL — itself a credential — can live in the
|
|
257
|
+
// vault instead of in a file that gets committed.
|
|
258
|
+
const url = (config.url ?? "").replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
259
|
+
const hit = getSecret(tenant, name, workspace);
|
|
260
|
+
return hit ? hit.value : whole;
|
|
261
|
+
});
|
|
262
|
+
if (!url || url.includes("${")) {
|
|
263
|
+
console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
const res = await fetch(url, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "content-type": "application/json" },
|
|
269
|
+
body: JSON.stringify(body),
|
|
270
|
+
signal: AbortSignal.timeout(5000),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
console.error(`[foldrun] notify: ${tenant}/${workspace} → HTTP ${res.status}`);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error(`[foldrun] notify: ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Prefills for the common OAuth providers: URLs and quirks, never
|
|
2
|
+
// credentials. One list, read by the CLI (`foldrun connect`), the dashboard's
|
|
3
|
+
// Connections form and the platform's consent callback, so a provider's
|
|
4
|
+
// refresh-token quirk is written down once.
|
|
5
|
+
//
|
|
6
|
+
// What stays with the account is the OAuth *client* (id + secret): a consent
|
|
7
|
+
// screen belongs to a registered app, so the developer brings those two
|
|
8
|
+
// values from the provider's console and everything else is here.
|
|
9
|
+
|
|
10
|
+
export interface OAuthPreset {
|
|
11
|
+
authorize_url: string;
|
|
12
|
+
token_url: string;
|
|
13
|
+
/** Extra authorize-URL params (Google: access_type=offline&prompt=consent). */
|
|
14
|
+
authorize_extra?: Record<string, string>;
|
|
15
|
+
/** Scopes to start from — every provider names them differently, and a
|
|
16
|
+
* form with the right shape in it is worth more than a blank field. */
|
|
17
|
+
scopes_example?: string;
|
|
18
|
+
/** The one thing about this provider that costs an afternoon to learn. */
|
|
19
|
+
hint: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const OAUTH_PRESETS: Record<string, OAuthPreset> = {
|
|
23
|
+
google: {
|
|
24
|
+
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
25
|
+
token_url: "https://oauth2.googleapis.com/token",
|
|
26
|
+
authorize_extra: { access_type: "offline", prompt: "consent" },
|
|
27
|
+
scopes_example: "https://www.googleapis.com/auth/adwords",
|
|
28
|
+
hint: "Scopes are full URLs. Offline access is requested for you, so a refresh token comes back.",
|
|
29
|
+
},
|
|
30
|
+
github: {
|
|
31
|
+
authorize_url: "https://github.com/login/oauth/authorize",
|
|
32
|
+
token_url: "https://github.com/login/oauth/access_token",
|
|
33
|
+
scopes_example: "repo read:org",
|
|
34
|
+
hint: "GitHub issues long-lived tokens and no refresh token — stored as a static secret.",
|
|
35
|
+
},
|
|
36
|
+
microsoft: {
|
|
37
|
+
authorize_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
38
|
+
token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
39
|
+
scopes_example: "offline_access https://graph.microsoft.com/.default",
|
|
40
|
+
hint: "Include offline_access in the scopes or no refresh token is issued.",
|
|
41
|
+
},
|
|
42
|
+
linkedin: {
|
|
43
|
+
authorize_url: "https://www.linkedin.com/oauth/v2/authorization",
|
|
44
|
+
token_url: "https://www.linkedin.com/oauth/v2/accessToken",
|
|
45
|
+
scopes_example: "openid profile r_organization_admin r_organization_social w_organization_social",
|
|
46
|
+
hint:
|
|
47
|
+
"Posting as a company page needs w_organization_social and a page admin signing in; r_organization_admin lets the token list the pages it may post to. " +
|
|
48
|
+
"Access tokens last 60 days; a refresh token is issued only to apps LinkedIn has enabled for it, " +
|
|
49
|
+
"otherwise the token is stored as is and you connect again when it expires. " +
|
|
50
|
+
"The redirect URL must match what the app registers exactly, and only localhost may be plain http.",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const OAUTH_PRESET_NAMES = Object.keys(OAUTH_PRESETS);
|