factoryhub 0.1.0 → 0.1.1

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 (87) hide show
  1. package/README.md +9 -3
  2. package/factories/index.json +231 -0
  3. package/factories/newsletter-note/README.md +11 -0
  4. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  5. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  6. package/factories/newsletter-note/factory.yaml +12 -0
  7. package/factories/newsletter-note/flow.yaml +45 -0
  8. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  9. package/factories/release-desk/README.md +13 -0
  10. package/factories/release-desk/agents/editor/agent.md +10 -0
  11. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  12. package/factories/release-desk/factory.yaml +13 -0
  13. package/factories/release-desk/flow.yaml +67 -0
  14. package/factories/release-desk/mindburn.tests.yaml +163 -0
  15. package/factories/short-video-cut/README.md +11 -0
  16. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  17. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  18. package/factories/short-video-cut/factory.yaml +13 -0
  19. package/factories/short-video-cut/flow.yaml +46 -0
  20. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  21. package/factories/video-facts/README.md +11 -0
  22. package/factories/video-facts/agents/analyst/agent.md +10 -0
  23. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  24. package/factories/video-facts/factory.yaml +13 -0
  25. package/factories/video-facts/flow.yaml +39 -0
  26. package/factories/video-facts/mindburn.tests.yaml +34 -0
  27. package/factories/video-to-posts/README.md +35 -0
  28. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  29. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  30. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  31. package/factories/video-to-posts/factory.yaml +12 -0
  32. package/factories/video-to-posts/flow.yaml +62 -0
  33. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  34. package/factories/week-of-posts/README.md +11 -0
  35. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  36. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  37. package/factories/week-of-posts/factory.yaml +12 -0
  38. package/factories/week-of-posts/flow.yaml +46 -0
  39. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  40. package/package.json +15 -2
  41. package/packages/cli/package.json +1 -1
  42. package/packages/cli/src/commands/export.mjs +0 -1
  43. package/packages/cli/src/commands/get.mjs +0 -1
  44. package/packages/cli/src/commands/inspect.mjs +0 -1
  45. package/packages/cli/src/commands/list.mjs +1 -6
  46. package/packages/cli/src/commands/login.mjs +161 -0
  47. package/packages/cli/src/commands/logout.mjs +43 -0
  48. package/packages/cli/src/commands/mcp.mjs +0 -1
  49. package/packages/cli/src/commands/new.mjs +0 -1
  50. package/packages/cli/src/commands/pack.mjs +0 -1
  51. package/packages/cli/src/commands/publish.mjs +62 -0
  52. package/packages/cli/src/commands/pull.mjs +59 -0
  53. package/packages/cli/src/commands/push.mjs +115 -0
  54. package/packages/cli/src/commands/resume.mjs +0 -1
  55. package/packages/cli/src/commands/run.mjs +182 -20
  56. package/packages/cli/src/commands/runs.mjs +52 -0
  57. package/packages/cli/src/commands/search.mjs +0 -1
  58. package/packages/cli/src/commands/validate.mjs +1 -3
  59. package/packages/cli/src/commands/whoami.mjs +34 -0
  60. package/packages/cli/src/hosted.mjs +159 -0
  61. package/packages/cli/src/main.mjs +80 -14
  62. package/packages/cli/src/tool-sources.mjs +272 -0
  63. package/packages/cli/src/util.mjs +37 -0
  64. package/packages/engine/lib/credentials.mjs +121 -0
  65. package/packages/engine/lib/hosted.mjs +119 -0
  66. package/packages/engine/lib/hub.mjs +376 -24
  67. package/packages/engine/lib/nango.mjs +157 -0
  68. package/packages/engine/lib/node.mjs +161 -33
  69. package/packages/engine/lib/providers.mjs +8 -3
  70. package/packages/engine/lib/run.mjs +70 -9
  71. package/packages/engine/lib/text.mjs +5 -0
  72. package/packages/engine/package.json +8 -2
  73. package/packages/engine/src/factory-engine.mjs +415 -68
  74. package/packages/engine/src/flow-sources.mjs +135 -0
  75. package/packages/engine/src/mcp-client.mjs +429 -0
  76. package/packages/engine/src/run.mjs +81 -5
  77. package/packages/engine/src/tool-sources.mjs +566 -0
  78. package/packages/mcp/package.json +1 -1
  79. package/packages/mcp/src/server.mjs +47 -17
  80. package/packages/sdk/package.json +28 -0
  81. package/packages/sdk/src/index.d.ts +163 -0
  82. package/packages/sdk/src/index.mjs +266 -0
  83. package/spec/README.md +7 -1
  84. package/spec/factory-format.md +264 -19
  85. package/spec/schema/factory.yaml.json +815 -0
  86. package/spec/schema/flow.schema.json +24 -2
  87. package/spec/skill-export.md +3 -0
@@ -1,54 +1,279 @@
1
- // A client for the public read API of the factoryhub site.
1
+ // A client for the factoryhub site.
2
2
  //
3
+ // Public reads, no account:
3
4
  // GET {base}/api/factories the published catalogue
4
5
  // GET {base}/f/{id}.json one factory as a transfer bundle, each file with its SHA-256
5
6
  //
7
+ // The developer API, /api/v1, with a personal access token (fhpat_…) sent as
8
+ // `Authorization: Bearer`. `GET /api/v1` describes it; the routes used here:
9
+ // GET /api/v1/me who the token acts for
10
+ // DELETE /api/v1/me/token revoke this token (logout)
11
+ // POST /api/v1/factories push files as a private factory factories:write
12
+ // GET /api/v1/factories/{id}/bundle pull a transfer bundle (factories:read widens)
13
+ // POST /api/v1/factories/{id}/publish publish factories:publish (opt-in)
14
+ // GET /api/v1/me/runs the account's runs runs:read
15
+ // POST /api/v1/runs start a hosted run runs:start
16
+ // GET /api/v1/runs/{id}?after&offset follow one runs:read
17
+ // POST /api/v1/runs/{id}/decision decide a waiting step runs:decide (opt-in)
18
+ //
19
+ // Device login (RFC 8628) gets a token without pasting one: start at
20
+ // /oauth/device_authorization, the person approves on /device, and polling
21
+ // /oauth/token hands the token over once.
22
+ //
6
23
  // While the site is in private preview it answers only requests that carry
7
24
  // the preview access key in the `x-hub-access-key` header. Everyone else
8
- // gets 404. Set FACTORYHUB_ACCESS_KEY to send it.
9
- import { fromEnvelope } from '../src/factory-engine.mjs';
25
+ // gets 404. Set FACTORYHUB_ACCESS_KEY to send it. A token alone does not
26
+ // pass the preview.
27
+ //
28
+ // A token is only ever sent in the Authorization header. It never appears in
29
+ // an address, an error message or anything this module prints. A token that
30
+ // is not shaped like one is refused before any request.
31
+ //
32
+ // Redirects are followed only within the hub's own origin. A redirect to any
33
+ // other origin, or from https to http, is refused, so neither the token nor
34
+ // the preview key ever reaches another site.
35
+ //
36
+ // Answers are read as a stream and refused once they pass a size cap, before
37
+ // the whole body is read, whatever its content encoding.
38
+ import { fromEnvelope, bundleHash } from '../src/factory-engine.mjs';
10
39
  import { matches } from './catalog.mjs';
40
+ import { hasControl } from './text.mjs';
11
41
 
12
42
  export const DEFAULT_HUB = 'https://factory.mindburn.org';
13
43
  export const ACCESS_HEADER = 'x-hub-access-key';
44
+ export const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
45
+ export const TOKEN_PATTERN = /^fhpat_[A-Za-z0-9_-]{43}$/;
46
+ /** Every scope a personal access token can hold. The last two are opt-in. */
47
+ export const SCOPES = ['factories:read', 'factories:write', 'runs:start', 'runs:read', 'factories:publish', 'runs:decide'];
48
+ export const OPT_IN_SCOPES = ['factories:publish', 'runs:decide'];
14
49
  const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
50
+ /** A factory id as the hub makes them; also safe as part of a file name. */
51
+ export const FACTORY_ID = /^[a-z0-9][a-z0-9-]{0,89}$/;
52
+ const RUN_ID = /^[A-Za-z0-9_-]{16,40}$/;
53
+ /** The largest answer read from the hub, in bytes after decoding. */
54
+ export const MAX_ANSWER_BYTES = 8 * 1024 * 1024;
55
+ const MAX_REDIRECTS = 3;
56
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
57
+ /** Device login polling bounds, in seconds (RFC 8628 section 3.5). */
58
+ export const POLL_MIN_SECONDS = 5;
59
+ export const POLL_MAX_SECONDS = 60;
60
+ const MAX_CODE_SECONDS = 3600;
15
61
 
16
62
  export class HubError extends Error {
17
- constructor(message, { status, preview = false } = {}) {
63
+ constructor(message, { status, code, preview = false, scope = null, retryAfter = null, details = null } = {}) {
18
64
  super(message);
19
65
  this.status = status;
66
+ this.code = code || null;
20
67
  this.preview = preview;
68
+ this.scope = scope;
69
+ this.retryAfter = retryAfter;
70
+ this.details = details;
71
+ }
72
+ }
73
+
74
+ const scopeFrom = (header) => /scope="([^"]*)"/.exec(header || '')?.[1]?.split(/\s+/).filter(Boolean) || null;
75
+ const enc = encodeURIComponent;
76
+
77
+ /**
78
+ * The hub address, checked: https, or http to this machine only. Throws a
79
+ * usage HubError otherwise, so no token or key is ever sent in the clear.
80
+ */
81
+ export function checkedBase(value) {
82
+ const base = String(value || DEFAULT_HUB).replace(/\/+$/, '');
83
+ let url;
84
+ try { url = new URL(base); } catch { throw new HubError(`"${base}" is not a web address.`, { code: 'usage' }); }
85
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))) {
86
+ throw new HubError(`${base} is not an https address. factoryhub sends tokens and keys only over https (or http to this machine).`, { code: 'usage' });
87
+ }
88
+ if (url.username || url.password || url.search || url.hash) throw new HubError(`${base} must be a plain address, with no user name, query or fragment.`, { code: 'usage' });
89
+ return base;
90
+ }
91
+
92
+ /** Reads a response body as text, refusing it once it passes `limit` bytes. */
93
+ export async function readCapped(response, limit = MAX_ANSWER_BYTES) {
94
+ const declared = Number(response.headers?.get?.('content-length'));
95
+ if (Number.isFinite(declared) && declared > limit) {
96
+ await response.body?.cancel?.().catch(() => {});
97
+ return { tooLarge: true, text: '' };
98
+ }
99
+ if (!response.body?.getReader) {
100
+ const text = await response.text();
101
+ return new TextEncoder().encode(text).byteLength > limit ? { tooLarge: true, text: '' } : { tooLarge: false, text };
102
+ }
103
+ const reader = response.body.getReader();
104
+ const decoder = new TextDecoder();
105
+ let size = 0;
106
+ let text = '';
107
+ for (;;) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+ size += value.byteLength;
111
+ if (size > limit) {
112
+ await reader.cancel().catch(() => {});
113
+ return { tooLarge: true, text: '' };
114
+ }
115
+ text += decoder.decode(value, { stream: true });
21
116
  }
117
+ return { tooLarge: false, text: text + decoder.decode() };
22
118
  }
23
119
 
24
- export function hubClient({ baseUrl, accessKey, env = process.env, fetchImpl = fetch } = {}) {
25
- const base = String(baseUrl || env.FACTORYHUB_BASE_URL || DEFAULT_HUB).replace(/\/+$/, '');
120
+ /**
121
+ * @param {object} options
122
+ * @param {string} [options.baseUrl] the hub address (default FACTORYHUB_BASE_URL or DEFAULT_HUB)
123
+ * @param {string} [options.accessKey] the preview key (default FACTORYHUB_ACCESS_KEY)
124
+ * @param {string|null} [options.token] a personal access token, or null for anonymous calls
125
+ * @param {'env'|'file'|null} [options.tokenSource] where the token came from, for messages
126
+ * @param {string[]} [options.tokenScopes] the token's scopes when known, for messages
127
+ */
128
+ export function hubClient({ baseUrl, accessKey, token = null, tokenSource = null, tokenScopes = null, env = process.env, fetchImpl = fetch, timeoutMs = 30000, maxBytes = MAX_ANSWER_BYTES } = {}) {
129
+ const base = checkedBase(baseUrl || env.FACTORYHUB_BASE_URL || DEFAULT_HUB);
130
+ const origin = new URL(base).origin;
26
131
  const key = accessKey ?? env.FACTORYHUB_ACCESS_KEY ?? '';
27
132
  const headers = { accept: 'application/json', ...(key ? { [ACCESS_HEADER]: key } : {}) };
133
+ const loginAgain = { env: 'Set FACTORYHUB_TOKEN to a valid token, or unset it and run "factoryhub login".', file: 'Run "factoryhub login" again.' }[tokenSource] || 'Check the token, or run "factoryhub login".';
134
+ const whose = { env: 'FACTORYHUB_TOKEN', file: 'Your saved token' }[tokenSource] || 'The token';
135
+ // Checked before any request, and never repeated: a value that is not a
136
+ // token could be anything, a password pasted by mistake included.
137
+ if (token !== null && token !== undefined && !TOKEN_PATTERN.test(String(token))) {
138
+ throw new HubError(`${whose} is not a factoryhub personal access token: they start with fhpat_ and are 49 characters long. ${loginAgain}`, { code: 'usage' });
139
+ }
140
+ if (key && hasControl(String(key))) throw new HubError('FACTORYHUB_ACCESS_KEY holds a control character, so it was not sent.', { code: 'usage' });
28
141
 
29
- async function getJson(path) {
142
+ // One request. Returns { status, body, headers } without judging the status.
143
+ // Redirects are followed only inside the hub's own origin.
144
+ async function send(method, path, { body, form, auth = true, extra = {} } = {}) {
145
+ const sent = { ...headers, ...extra };
146
+ if (auth && token) sent.authorization = `Bearer ${token}`;
147
+ let payload;
148
+ if (form) { sent['content-type'] = 'application/x-www-form-urlencoded'; payload = new URLSearchParams(form).toString(); }
149
+ else if (body !== undefined) { sent['content-type'] = 'application/json'; payload = JSON.stringify(body); }
150
+ let url = `${base}${path}`;
30
151
  let response;
31
- try {
32
- response = await fetchImpl(`${base}${path}`, { headers, signal: AbortSignal.timeout(30000) });
33
- } catch (error) {
34
- throw new HubError(`Could not reach ${base}: ${error.message}`);
152
+ for (let hops = 0; ; hops += 1) {
153
+ try {
154
+ response = await fetchImpl(url, { method, headers: sent, body: payload, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
155
+ } catch (error) {
156
+ // The platform's message may quote a header, so only its code is shown.
157
+ const reason = error?.name === 'TimeoutError' ? 'it did not answer in time' : /^[A-Z_]+$/.test(String(error?.cause?.code || '')) ? error.cause.code : '';
158
+ throw new HubError(`Could not reach ${base}${reason ? ` (${reason})` : ''}.`, { code: 'network' });
159
+ }
160
+ if (![301, 302, 303, 307, 308].includes(response.status)) break;
161
+ await response.body?.cancel?.().catch(() => {});
162
+ let next;
163
+ try { next = new URL(response.headers.get('location') || '', url); } catch { next = null; }
164
+ if (!next || next.origin !== origin) {
165
+ throw new HubError(`${base}${path} redirected to ${next ? next.origin : 'an address that is not valid'}. factoryhub follows redirects only within ${origin}, so your token and preview key were not sent there.`, { status: response.status, code: 'redirect' });
166
+ }
167
+ if (hops >= MAX_REDIRECTS) throw new HubError(`${base}${path} redirected too many times.`, { status: response.status, code: 'redirect' });
168
+ if (method !== 'GET' && ![307, 308].includes(response.status)) throw new HubError(`${base}${path} answered a ${method} with a redirect, which factoryhub does not follow.`, { status: response.status, code: 'redirect' });
169
+ url = next.href;
35
170
  }
36
- const text = await response.text();
171
+ const answer = await readCapped(response, maxBytes);
172
+ if (answer.tooLarge) throw new HubError(`${base}${path} answered with more than ${Math.round(maxBytes / 1048576)} MB, so the answer was not read.`, { status: response.status, code: 'too_large' });
173
+ const { text } = answer;
37
174
  if (response.status === 404 && /private preview/i.test(text)) {
38
- throw new HubError(`${base} is in private preview and answers only with an access key. ${key ? 'The FACTORYHUB_ACCESS_KEY you set was not accepted.' : 'Set FACTORYHUB_ACCESS_KEY to the key you were given.'}`, { status: 404, preview: true });
175
+ throw new HubError(`${base} is in private preview and answers only with an access key. ${key ? 'The FACTORYHUB_ACCESS_KEY you set was not accepted.' : 'Set FACTORYHUB_ACCESS_KEY to the key you were given.'}`, { status: 404, preview: true, code: 'preview' });
176
+ }
177
+ let parsed = null;
178
+ try { parsed = JSON.parse(text); } catch { /* judged by the caller */ }
179
+ return { status: response.status, ok: response.ok, body: parsed, headers: response.headers };
180
+ }
181
+
182
+ // The token's scopes, for a hint that keeps them: from the caller, or asked once.
183
+ let knownScopes = tokenScopes;
184
+ async function scopesNow() {
185
+ if (knownScopes || !token) return knownScopes || [];
186
+ try {
187
+ const me = await send('GET', '/api/v1/me');
188
+ knownScopes = Array.isArray(me.body?.token?.scopes) ? me.body.token.scopes : [];
189
+ } catch { knownScopes = []; }
190
+ return knownScopes;
191
+ }
192
+
193
+ // Turns a refusal into a sentence that says what to do next.
194
+ async function refusal(answer, path) {
195
+ const { status, body } = answer;
196
+ const code = typeof body?.error === 'string' ? body.error : null;
197
+ const said = typeof body?.message === 'string' ? body.message : null;
198
+ const retryAfter = Number(answer.headers.get('retry-after')) || null;
199
+ if (status === 401 && code === 'invalid_token') return new HubError(`${whose} was not accepted by ${base}: it expired, was revoked, or is mistyped. ${loginAgain}`, { status, code });
200
+ if (status === 401) return new HubError(`This needs you to be logged in to ${base}. Run "factoryhub login", or set FACTORYHUB_TOKEN.`, { status, code: code || 'unauthorized' });
201
+ if (status === 403 && code === 'insufficient_scope') {
202
+ const missing = scopeFrom(answer.headers.get('www-authenticate')) || [];
203
+ const want = [...new Set([...await scopesNow(), ...missing])];
204
+ const ask = SCOPES.filter((scope) => want.includes(scope));
205
+ return new HubError(`Your token does not allow this: it needs ${missing.join(' and ') || 'another scope'}. Log in again and ask for it: factoryhub login --scopes ${ask.join(',')}`, { status, code, scope: missing });
206
+ }
207
+ if (status === 429) return new HubError(`${said || `${base} is refusing more requests for now.`}${retryAfter ? ` Try again in ${Math.ceil(retryAfter / 60)} minutes.` : ''}`, { status, code: code || 'limit', retryAfter });
208
+ const details = body && typeof body === 'object' ? Object.fromEntries(Object.entries(body).filter(([name]) => !['error', 'message'].includes(name))) : null;
209
+ return new HubError(said || `${base}${path} answered with status ${status}.`, { status, code, details: details && Object.keys(details).length ? details : null });
210
+ }
211
+
212
+ async function call(method, path, options = {}) {
213
+ if (options.needsToken && !token) throw new HubError(`Log in first: run "factoryhub login", or set FACTORYHUB_TOKEN.`, { status: 401, code: 'unauthorized' });
214
+ const answer = await send(method, path, options);
215
+ if (!answer.ok) throw await refusal(answer, path);
216
+ if (!answer.body || typeof answer.body !== 'object') throw new HubError(`${base}${path} did not answer with JSON.`, { status: answer.status });
217
+ return answer.body;
218
+ }
219
+ const getJson = (path, options) => call('GET', path, options);
220
+
221
+ // Reads and checks a transfer bundle: every file against its SHA-256 and,
222
+ // when strict, the whole bundle against the bundle hash in the same answer.
223
+ // These hashes prove the files arrived as the hub answered, not who wrote
224
+ // them: they are not a signature. A strict read refuses a bundle it cannot
225
+ // check whole (no bundle hash, or files left out) unless `partial` accepts
226
+ // that, and says so in `unchecked`.
227
+ async function readBundle(id, envelope, { strict, partial = false }) {
228
+ const read = await fromEnvelope(envelope);
229
+ const problems = [...read.problems];
230
+ const unchecked = [];
231
+ const sha256 = await bundleHash(read.files);
232
+ if (strict) {
233
+ const unsigned = (envelope.files || []).filter((entry) => entry && typeof entry.sha256 !== 'string').map((entry) => entry.path);
234
+ if (unsigned.length) problems.push(`These files carry no SHA-256, so they cannot be checked: ${unsigned.slice(0, 5).join(', ')}`);
235
+ const expected = envelope.package?.sourceSha256;
236
+ const gaps = [];
237
+ if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) gaps.push('The hub sent no bundle hash, so the files cannot be checked as a whole.');
238
+ if (read.leftOut.length) gaps.push(`The bundle leaves out ${read.leftOut.length === 1 ? 'a file' : `${read.leftOut.length} files`} it names (${read.leftOut.slice(0, 5).join(', ')}), so the files cannot be checked as a whole.`);
239
+ if (gaps.length && !partial) problems.push(...gaps, 'Pass --partial (or { partial: true }) to accept a bundle that cannot be checked whole.');
240
+ else unchecked.push(...gaps);
241
+ if (!problems.length && !gaps.length && sha256 !== expected) problems.push('The files together do not match the bundle hash in the hub\'s answer.');
39
242
  }
40
- let body = null;
41
- try { body = JSON.parse(text); } catch { /* reported below */ }
42
- if (!response.ok) throw new HubError(body?.message || `${base}${path} answered with status ${response.status}.`, { status: response.status });
43
- if (!body || typeof body !== 'object') throw new HubError(`${base}${path} did not answer with JSON.`, { status: response.status });
44
- return body;
243
+ if (problems.length) throw new HubError(`The bundle for ${id} did not pass its checks, so nothing was written:\n${problems.map((item) => ` - ${item}`).join('\n')}`, { code: 'bundle' });
244
+ return { id, files: read.files, leftOut: read.leftOut, source: envelope.source?.url || `${base}/f/${id}`, sha256, partial: unchecked.length > 0, unchecked };
45
245
  }
46
246
 
247
+ // A device-login path from GET /api/v1: a path on this hub, never another site.
248
+ const devicePath = (path, name) => {
249
+ let url = null;
250
+ if (typeof path === 'string' && path.startsWith('/') && !path.startsWith('//') && !path.includes('\\') && !hasControl(path)) {
251
+ try { url = new URL(`${base}${path}`); } catch { url = null; }
252
+ }
253
+ if (!url || url.origin !== origin) throw new HubError(`${base} described a device login ${name} that is not a path on ${origin}, so factoryhub will not use it.`, { code: 'device_off' });
254
+ return path;
255
+ };
256
+
257
+ const factoryId = (id) => {
258
+ if (!FACTORY_ID.test(String(id || ''))) throw new HubError(`"${id}" is not a factory id. Ids are lowercase letters, digits and hyphens, as in ${base}/f/<id>.`, { code: 'usage' });
259
+ return id;
260
+ };
261
+ const runId = (id) => {
262
+ if (!RUN_ID.test(String(id || ''))) throw new HubError(`"${id}" is not a run id. A run id is the last part of a run address, ${base}/r/<id>.`, { code: 'usage' });
263
+ return id;
264
+ };
265
+
47
266
  return {
48
267
  base,
268
+ origin,
269
+ hasToken: Boolean(token),
270
+ tokenSource,
271
+
272
+ // ---- public reads ----
273
+
49
274
  /** Every published factory in the catalogue. */
50
275
  async list() {
51
- const body = await getJson('/api/factories');
276
+ const body = await getJson('/api/factories', { auth: false });
52
277
  if (!Array.isArray(body.factories)) throw new HubError('The catalogue answer has no factories list.');
53
278
  return body.factories;
54
279
  },
@@ -56,16 +281,143 @@ export function hubClient({ baseUrl, accessKey, env = process.env, fetchImpl = f
56
281
  async search(query) {
57
282
  return (await this.list()).filter((entry) => matches(entry, query));
58
283
  },
59
- /** One factory's files, each checked against its recorded SHA-256. */
284
+ /** One published factory's files, each checked against its recorded SHA-256. */
60
285
  async get(id) {
61
286
  if (!ID.test(String(id || ''))) throw new HubError(`"${id}" is not a factory id. Ids are lowercase words joined by hyphens.`);
62
- const envelope = await getJson(`/f/${id}.json`);
63
- const read = await fromEnvelope(envelope);
64
- if (read.problems.length) throw new HubError(`The bundle for ${id} did not pass its checks:\n${read.problems.map((item) => ` - ${item}`).join('\n')}`);
65
- return { id, files: read.files, leftOut: read.leftOut, source: envelope.source?.url || `${base}/f/${id}` };
287
+ return readBundle(id, await getJson(`/f/${id}.json`, { auth: false }), { strict: false });
66
288
  },
67
289
  sourceUrl(id) {
68
290
  return `${base}/f/${id}`;
69
291
  },
292
+ /** The hub's published receipt keys: [{ key_id, public_key }]. */
293
+ async keys() {
294
+ const body = await getJson('/.well-known/factory-hub-keys.json', { auth: false });
295
+ if (!Array.isArray(body.keys)) throw new HubError('The key list has no keys.');
296
+ return body.keys;
297
+ },
298
+
299
+ // ---- the developer API ----
300
+
301
+ /** The description at GET /api/v1. */
302
+ describe() {
303
+ return getJson('/api/v1', { auth: false });
304
+ },
305
+ /** The account and token the token acts for. */
306
+ me() {
307
+ return getJson('/api/v1/me', { needsToken: true });
308
+ },
309
+ /** Revokes the token this client holds. */
310
+ revoke() {
311
+ return call('DELETE', '/api/v1/me/token', { needsToken: true });
312
+ },
313
+ /** Saves files to the account as a private factory. Nothing is published. */
314
+ push(files, { parent } = {}) {
315
+ return call('POST', '/api/v1/factories', { needsToken: true, body: { files, ...(parent ? { parent: factoryId(parent) } : {}) } });
316
+ },
317
+ /**
318
+ * One factory the caller can open, as checked files. A token with
319
+ * factories:read opens private ones. Refuses a bundle that cannot be
320
+ * checked whole unless `partial`.
321
+ */
322
+ async pull(id, { partial = false } = {}) {
323
+ factoryId(id);
324
+ return readBundle(id, await getJson(`/api/v1/factories/${enc(id)}/bundle`), { strict: true, partial });
325
+ },
326
+ /** Publishes one of the account's factories. */
327
+ publish(id, { note = '' } = {}) {
328
+ return call('POST', `/api/v1/factories/${enc(factoryId(id))}/publish`, { needsToken: true, body: { note } });
329
+ },
330
+ /** One factory with its inputs and steps. */
331
+ factory(id) {
332
+ return getJson(`/api/v1/factories/${enc(factoryId(id))}`);
333
+ },
334
+ /** The account's recent runs. */
335
+ async runs({ limit } = {}) {
336
+ return (await getJson(`/api/v1/me/runs${limit ? `?limit=${enc(limit)}` : ''}`, { needsToken: true })).runs;
337
+ },
338
+ /** Starts a hosted run. The same idempotency key and inputs within an hour return the same run. */
339
+ async startRun(id, inputs = {}, { idempotencyKey }) {
340
+ return (await call('POST', '/api/v1/runs', { needsToken: true, body: { factory_id: factoryId(id), inputs }, extra: { 'idempotency-key': idempotencyKey } })).run;
341
+ },
342
+ /** One run: events after `after`, the waiting step, and once done the result from `offset`. */
343
+ async getRun(id, { after = 0, offset = 0 } = {}) {
344
+ return (await getJson(`/api/v1/runs/${enc(runId(id))}?after=${enc(after)}&offset=${enc(offset)}`, { needsToken: true })).run;
345
+ },
346
+ /** Decides the step a run waits on, for the person. Needs runs:decide. */
347
+ decide(id, { approvalId, seenSha256, decision, note = '' }) {
348
+ return call('POST', `/api/v1/runs/${enc(runId(id))}/decision`, { needsToken: true, body: { approval_id: approvalId, seen_sha256: seenSha256, decision, note } });
349
+ },
350
+
351
+ // ---- device login ----
352
+
353
+ /** Starts a device login. `endpoints` comes from describe().auth.deviceLogin. */
354
+ async startDeviceLogin(endpoints, { name, scopes, days } = {}) {
355
+ const form = { ...(name ? { client_name: name } : {}), ...(scopes?.length ? { scope: scopes.join(' ') } : {}), ...(days ? { token_days: String(days) } : {}) };
356
+ const answer = await send('POST', devicePath(endpoints.start, 'start address'), { form, auth: false });
357
+ if (!answer.ok) throw new HubError(answer.body?.error_description || `${base}${endpoints.start} answered with status ${answer.status}.`, { status: answer.status, code: answer.body?.error || null });
358
+ const body = answer.body || {};
359
+ if (typeof body.device_code !== 'string' || typeof body.user_code !== 'string' || typeof body.verification_uri !== 'string') throw new HubError(`${base} did not start a device login.`);
360
+ return body;
361
+ },
362
+ /** One poll. Returns the token answer, or { error, error_description, interval }. */
363
+ async pollDeviceLogin(endpoints, deviceCode) {
364
+ const answer = await send('POST', devicePath(endpoints.token, 'token address'), { form: { grant_type: endpoints.grantType || DEVICE_GRANT, device_code: deviceCode }, auth: false });
365
+ if (answer.ok && typeof answer.body?.access_token === 'string') return answer.body;
366
+ if (answer.body && typeof answer.body.error === 'string') return answer.body;
367
+ throw new HubError(`${base}${endpoints.token} answered with status ${answer.status}.`, { status: answer.status });
368
+ },
70
369
  };
71
370
  }
371
+
372
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
373
+ // A positive, finite number of seconds from the hub, or 0.
374
+ const seconds = (value) => {
375
+ const number = Number(value);
376
+ return Number.isFinite(number) && number > 0 ? number : 0;
377
+ };
378
+ const pollInterval = (value) => Math.min(Math.max(value, POLL_MIN_SECONDS), POLL_MAX_SECONDS);
379
+
380
+ /**
381
+ * Runs a device login to its end: starts it, tells the caller the code to
382
+ * show, and polls at the interval the hub sets until the person approves,
383
+ * declines or the code expires. Returns the token answer:
384
+ * { access_token, token_type, expires_in, scope, token: { id, name, prefix, scopes, expires } }.
385
+ */
386
+ export async function deviceLogin(hub, { name, scopes, days, onCode = () => {}, sleep = wait, now = Date.now } = {}) {
387
+ let description;
388
+ try { description = await hub.describe(); } catch (error) {
389
+ if (error.preview) throw error;
390
+ description = null;
391
+ }
392
+ const endpoints = description?.auth?.deviceLogin;
393
+ if (!endpoints?.start || !endpoints?.token) {
394
+ throw new HubError(`${hub.base} does not offer login from a terminal yet. Make a token in your account settings there, then run: factoryhub login --token < token.txt`, { code: 'device_off' });
395
+ }
396
+ const started = await hub.startDeviceLogin(endpoints, { name, scopes, days });
397
+ const lifetime = Math.min(seconds(started.expires_in) || 900, MAX_CODE_SECONDS);
398
+ const deadline = now() + lifetime * 1000;
399
+ await onCode({ userCode: started.user_code, verificationUri: started.verification_uri, verificationUriComplete: started.verification_uri_complete || null, expiresIn: lifetime, scope: started.scope || null });
400
+ let interval = pollInterval(seconds(started.interval) || POLL_MIN_SECONDS);
401
+ let failures = 0;
402
+ for (;;) {
403
+ await sleep(interval * 1000);
404
+ if (now() >= deadline) throw new HubError('The code expired before it was approved. Run "factoryhub login" again.', { code: 'expired_token' });
405
+ let answer;
406
+ try { answer = await hub.pollDeviceLogin(endpoints, started.device_code); } catch (error) {
407
+ // A dropped connection is retried; the code stays valid until it expires.
408
+ if (error.code === 'network' && (failures += 1) < 3) continue;
409
+ throw error;
410
+ }
411
+ failures = 0;
412
+ if (answer.access_token) {
413
+ if (!TOKEN_PATTERN.test(answer.access_token)) throw new HubError(`${hub.base} answered with something that is not a personal access token.`);
414
+ return answer;
415
+ }
416
+ if (answer.error === 'authorization_pending') continue;
417
+ // RFC 8628: slow_down adds 5 seconds; a longer interval the hub names wins.
418
+ if (answer.error === 'slow_down') { interval = pollInterval(Math.max(interval + 5, seconds(answer.interval) || 0)); continue; }
419
+ if (answer.error === 'access_denied') throw new HubError(answer.error_description || 'The login was declined.', { code: 'access_denied' });
420
+ if (answer.error === 'expired_token') throw new HubError('The code expired before it was approved. Run "factoryhub login" again.', { code: 'expired_token' });
421
+ throw new HubError(answer.error_description || `The login failed: ${answer.error}.`, { code: answer.error });
422
+ }
423
+ }
@@ -0,0 +1,157 @@
1
+ // Experimental: a Nango agent session as a tool source. Creates a short
2
+ // session scoped to one integration and the named tools, for one end user,
3
+ // and hands back its MCP server. The Nango key stays in this process: the
4
+ // MCP server gets only the session's own token, which ends with the run.
5
+ // See https://nango.dev/docs/guides/agent-sessions.
6
+ import { createMcpClient } from '../src/mcp-client.mjs';
7
+
8
+ export const NANGO_API = 'https://api.nango.dev';
9
+ /** How long a session lives at most. The CLI also ends it when the run ends. */
10
+ export const NANGO_SESSION_TTL = '30m';
11
+
12
+ const fail = (message, extra = {}) => Object.assign(new Error(message), extra);
13
+ // What Nango sends is checked before it is shown or used: an error code, a
14
+ // connect link and its expiry. Nothing else Nango writes reaches an error.
15
+ const ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/;
16
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
17
+ const LINK = /^https:\/\/[\x21-\x7e]{1,2040}$/;
18
+ const END_TIMEOUT_MS = 3000;
19
+ /** How long an interrupt waits for Nango to answer a session it is creating, to end it. */
20
+ export const INTERRUPT_WAIT_MS = 1500;
21
+
22
+ // Nango's own hosts: the API host and the domain it belongs to (nango.dev
23
+ // for api.nango.dev). A session address elsewhere does not get the token.
24
+ function onNangoHost(address, api) {
25
+ let host;
26
+ try { host = new URL(address).hostname.toLowerCase(); } catch { return false; }
27
+ const apiHost = new URL(api).hostname.toLowerCase();
28
+ const labels = apiHost.split('.');
29
+ const domain = labels.length > 2 ? labels.slice(1).join('.') : apiHost;
30
+ return host === apiHost || host === domain || host.endsWith(`.${domain}`);
31
+ }
32
+
33
+ /**
34
+ * Opens a Nango agent session for `integration`, allowing and listing only
35
+ * `tools`, with the connection tagged `end_user_id: endUserId`.
36
+ *
37
+ * Resolves to `{ connected: true, client, session_id, close }`, where
38
+ * `client` is an MCP client for the session's server; or, when the person
39
+ * has not connected that integration yet, to `{ connected: false,
40
+ * connect_url, expires_at }` after ending the session again.
41
+ *
42
+ * `register(closeOne)`, when given, receives a function that ends
43
+ * everything this call creates, before the first request is sent: the CLI
44
+ * runs it on Ctrl+C. If it runs while Nango is still creating the session,
45
+ * it waits up to `interruptWaitMs` for Nango's answer and ends the session
46
+ * named; an answer that comes later is ended when it comes.
47
+ */
48
+ export async function openNangoSession({ apiKey, integration, tools, endUserId = 'local-dev-user', expiresIn = NANGO_SESSION_TTL, fetchImpl = fetch, baseUrl = NANGO_API, register = null, interruptWaitMs = INTERRUPT_WAIT_MS }) {
49
+ if (!apiKey) throw fail('NANGO_API_KEY is not set.', { usage: true });
50
+ if (!tools?.length) throw fail(`Name the ${integration} tools a run may use: --nango ${integration}:<tool>,<tool>.`, { usage: true });
51
+ const api = baseUrl.replace(/\/+$/, '');
52
+ const auth = { authorization: `Bearer ${apiKey}` };
53
+ // Registered before the first request: an interrupt, even while Nango is
54
+ // still creating the session, ends whatever has been created by then, and
55
+ // a session Nango names after an interrupt is ended as soon as it is named.
56
+ let interrupted = false;
57
+ let closeNow = null;
58
+ let markSettled;
59
+ const settled = new Promise((resolve) => { markSettled = resolve; });
60
+ register?.(async () => {
61
+ interrupted = true;
62
+ let timer;
63
+ await Promise.race([settled, new Promise((resolve) => { timer = setTimeout(resolve, interruptWaitMs); })]);
64
+ clearTimeout(timer);
65
+ await closeNow?.();
66
+ });
67
+ let data;
68
+ let client;
69
+ let ended = false;
70
+ // Once Nango names a session, it is ended on every path that does not use it.
71
+ const end = async () => {
72
+ if (ended || typeof data?.session_id !== 'string' || !data.session_id) return;
73
+ ended = true;
74
+ try {
75
+ const done = await fetchImpl(`${api}/sessions/${encodeURIComponent(data.session_id)}`, { method: 'DELETE', headers: auth, redirect: 'manual', signal: AbortSignal.timeout(END_TIMEOUT_MS) });
76
+ await done.body?.cancel?.().catch?.(() => {});
77
+ } catch { /* it expires on its own */ }
78
+ };
79
+ // Side by side, so a slow MCP DELETE never holds up ending Nango's session.
80
+ const closeAll = async () => { await Promise.all([client?.close(), end()]); };
81
+ try {
82
+ let response;
83
+ try {
84
+ response = await fetchImpl(`${api}/sessions`, {
85
+ method: 'POST',
86
+ headers: { ...auth, 'content-type': 'application/json' },
87
+ body: JSON.stringify({
88
+ tenant: { connections: { any: [{ tags: { end_user_id: endUserId } }] } },
89
+ toolset: { [integration]: { allow: { tools } } },
90
+ // Pinned tools are the ones the session's tools/list returns.
91
+ pinned_tools: { [integration]: tools },
92
+ // No search, generic execute or raw proxy: only the named tools. The
93
+ // connect tool is used by this runner alone, never offered to a model.
94
+ meta_tools: { nango_tool_search: false, nango_execute: false, nango_proxy: false, nango_create_connection: { enabled: true, tags: { end_user_id: endUserId } } },
95
+ expires_in: expiresIn,
96
+ }),
97
+ redirect: 'manual',
98
+ signal: AbortSignal.timeout(30000),
99
+ });
100
+ } catch {
101
+ throw fail(`Nango could not be reached at ${new URL(api).host}.`);
102
+ }
103
+ const body = await response.json().catch(() => ({}));
104
+ // The status and Nango's error code; never Nango's message, which may quote the key.
105
+ if (!response.ok) {
106
+ const code = typeof body?.error?.code === 'string' && ERROR_CODE.test(body.error.code) ? `, ${body.error.code}` : '';
107
+ throw fail(`Nango refused to create the session (HTTP ${response.status}${code}).`);
108
+ }
109
+ data = body?.data;
110
+ closeNow = end;
111
+ if (!data || typeof data.mcp_url !== 'string' || typeof data.session_token !== 'string' || typeof data.session_id !== 'string') {
112
+ await end();
113
+ throw fail('Nango answered without a usable session.');
114
+ }
115
+ // The session token goes only to an https address on Nango's own host.
116
+ if (!/^https:\/\//.test(data.mcp_url)) {
117
+ await end();
118
+ throw fail('Nango gave a session address that is not https.');
119
+ }
120
+ if (!onNangoHost(data.mcp_url, api)) {
121
+ await end();
122
+ throw fail('Nango gave a session address that is not on Nango\'s host.');
123
+ }
124
+ try { client = createMcpClient({ url: data.mcp_url, token: data.session_token, fetchImpl }); } catch (error) { await end(); throw error; }
125
+ closeNow = closeAll;
126
+ } finally {
127
+ markSettled();
128
+ }
129
+ if (interrupted) {
130
+ await closeAll();
131
+ throw fail('The run was interrupted while the Nango session was being created. The session was ended.');
132
+ }
133
+ if (data.toolset?.[integration]?.connected === false) {
134
+ // Ask the session for a link the person opens to connect the integration.
135
+ try {
136
+ const result = await client.callTool('nango_create_connection', { integration });
137
+ let link = result?.structuredContent;
138
+ if (!link?.connect_url) {
139
+ const text = (result?.content || []).find((block) => block?.type === 'text')?.text;
140
+ try { link = JSON.parse(text); } catch { link = null; }
141
+ }
142
+ if (typeof link?.connect_url !== 'string' || !LINK.test(link.connect_url)) throw fail(`${integration} is not connected in Nango for "${endUserId}", and Nango gave no usable connect link.`);
143
+ return { connected: false, integration, end_user_id: endUserId, connect_url: link.connect_url, expires_at: typeof link.expires_at === 'string' && TIMESTAMP.test(link.expires_at) ? link.expires_at : null };
144
+ } finally {
145
+ // Both sessions end, side by side: the MCP one and Nango's.
146
+ await closeAll();
147
+ }
148
+ }
149
+ return {
150
+ connected: true,
151
+ integration,
152
+ session_id: data.session_id,
153
+ expires_at: typeof data.expires_at === 'string' && TIMESTAMP.test(data.expires_at) ? data.expires_at : null,
154
+ client,
155
+ close: closeAll,
156
+ };
157
+ }