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.
- package/README.md +9 -3
- package/factories/index.json +231 -0
- package/factories/newsletter-note/README.md +11 -0
- package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
- package/factories/newsletter-note/agents/writer/agent.md +11 -0
- package/factories/newsletter-note/factory.yaml +12 -0
- package/factories/newsletter-note/flow.yaml +45 -0
- package/factories/newsletter-note/mindburn.tests.yaml +80 -0
- package/factories/release-desk/README.md +13 -0
- package/factories/release-desk/agents/editor/agent.md +10 -0
- package/factories/release-desk/agents/reviewer/agent.md +10 -0
- package/factories/release-desk/factory.yaml +13 -0
- package/factories/release-desk/flow.yaml +67 -0
- package/factories/release-desk/mindburn.tests.yaml +163 -0
- package/factories/short-video-cut/README.md +11 -0
- package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
- package/factories/short-video-cut/agents/writer/agent.md +11 -0
- package/factories/short-video-cut/factory.yaml +13 -0
- package/factories/short-video-cut/flow.yaml +46 -0
- package/factories/short-video-cut/mindburn.tests.yaml +80 -0
- package/factories/video-facts/README.md +11 -0
- package/factories/video-facts/agents/analyst/agent.md +10 -0
- package/factories/video-facts/agents/reviewer/agent.md +10 -0
- package/factories/video-facts/factory.yaml +13 -0
- package/factories/video-facts/flow.yaml +39 -0
- package/factories/video-facts/mindburn.tests.yaml +34 -0
- package/factories/video-to-posts/README.md +35 -0
- package/factories/video-to-posts/agents/analyst/agent.md +10 -0
- package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
- package/factories/video-to-posts/agents/writer/agent.md +11 -0
- package/factories/video-to-posts/factory.yaml +12 -0
- package/factories/video-to-posts/flow.yaml +62 -0
- package/factories/video-to-posts/mindburn.tests.yaml +34 -0
- package/factories/week-of-posts/README.md +11 -0
- package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
- package/factories/week-of-posts/agents/writer/agent.md +11 -0
- package/factories/week-of-posts/factory.yaml +12 -0
- package/factories/week-of-posts/flow.yaml +46 -0
- package/factories/week-of-posts/mindburn.tests.yaml +80 -0
- package/package.json +15 -2
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/export.mjs +0 -1
- package/packages/cli/src/commands/get.mjs +0 -1
- package/packages/cli/src/commands/inspect.mjs +0 -1
- package/packages/cli/src/commands/list.mjs +1 -6
- package/packages/cli/src/commands/login.mjs +161 -0
- package/packages/cli/src/commands/logout.mjs +43 -0
- package/packages/cli/src/commands/mcp.mjs +0 -1
- package/packages/cli/src/commands/new.mjs +0 -1
- package/packages/cli/src/commands/pack.mjs +0 -1
- package/packages/cli/src/commands/publish.mjs +62 -0
- package/packages/cli/src/commands/pull.mjs +59 -0
- package/packages/cli/src/commands/push.mjs +115 -0
- package/packages/cli/src/commands/resume.mjs +0 -1
- package/packages/cli/src/commands/run.mjs +182 -20
- package/packages/cli/src/commands/runs.mjs +52 -0
- package/packages/cli/src/commands/search.mjs +0 -1
- package/packages/cli/src/commands/validate.mjs +1 -3
- package/packages/cli/src/commands/whoami.mjs +34 -0
- package/packages/cli/src/hosted.mjs +159 -0
- package/packages/cli/src/main.mjs +80 -14
- package/packages/cli/src/tool-sources.mjs +272 -0
- package/packages/cli/src/util.mjs +37 -0
- package/packages/engine/lib/credentials.mjs +121 -0
- package/packages/engine/lib/hosted.mjs +119 -0
- package/packages/engine/lib/hub.mjs +376 -24
- package/packages/engine/lib/nango.mjs +157 -0
- package/packages/engine/lib/node.mjs +161 -33
- package/packages/engine/lib/providers.mjs +8 -3
- package/packages/engine/lib/run.mjs +70 -9
- package/packages/engine/lib/text.mjs +5 -0
- package/packages/engine/package.json +8 -2
- package/packages/engine/src/factory-engine.mjs +415 -68
- package/packages/engine/src/flow-sources.mjs +135 -0
- package/packages/engine/src/mcp-client.mjs +429 -0
- package/packages/engine/src/run.mjs +81 -5
- package/packages/engine/src/tool-sources.mjs +566 -0
- package/packages/mcp/package.json +1 -1
- package/packages/mcp/src/server.mjs +47 -17
- package/packages/sdk/package.json +28 -0
- package/packages/sdk/src/index.d.ts +163 -0
- package/packages/sdk/src/index.mjs +266 -0
- package/spec/README.md +7 -1
- package/spec/factory-format.md +264 -19
- package/spec/schema/factory.yaml.json +815 -0
- package/spec/schema/flow.schema.json +24 -2
- package/spec/skill-export.md +3 -0
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
// Model: set ANTHROPIC_API_KEY, or OPENAI_API_KEY, or point at any
|
|
10
10
|
// OpenAI-compatible server (Ollama, LM Studio, OpenRouter, vLLM):
|
|
11
11
|
// node run.mjs --base-url http://localhost:11434/v1 --model llama3.3
|
|
12
|
+
// A key from the environment goes only to its own provider: OPENAI_API_KEY to
|
|
13
|
+
// api.openai.com, OPENROUTER_API_KEY to openrouter.ai/api/v1. Any other
|
|
14
|
+
// --base-url gets a key only from the variable named with --api-key-env.
|
|
12
15
|
//
|
|
13
16
|
// Options: --input name=value (repeatable) --model NAME --provider anthropic|openai
|
|
14
|
-
// --base-url URL --
|
|
17
|
+
// --base-url URL --api-key-env NAME (the variable holding the key
|
|
18
|
+
// for --base-url) --out FILE
|
|
15
19
|
// --yes approve every decision step without asking. This is the
|
|
16
20
|
// only way a run is approved with nobody at the keyboard.
|
|
17
21
|
// --validate read the factory as the Hub does and list what is wrong
|
|
@@ -30,6 +34,8 @@ import { existsSync } from 'node:fs';
|
|
|
30
34
|
import { join, relative, dirname } from 'node:path';
|
|
31
35
|
import { fileURLToPath } from 'node:url';
|
|
32
36
|
import { createInterface } from 'node:readline/promises';
|
|
37
|
+
import { lookup } from 'node:dns/promises';
|
|
38
|
+
import { isIP } from 'node:net';
|
|
33
39
|
import process from 'node:process';
|
|
34
40
|
|
|
35
41
|
const enginePath = new URL('./factory-engine.mjs', import.meta.url);
|
|
@@ -109,7 +115,11 @@ target ||= existsSync('factory.yaml') ? '.' : dirname(fileURLToPath(import.meta.
|
|
|
109
115
|
let files;
|
|
110
116
|
let leftOut = [];
|
|
111
117
|
if (/^https?:\/\//.test(target)) {
|
|
112
|
-
|
|
118
|
+
// A server that never answers, or stops in the middle, ends this after 30
|
|
119
|
+
// seconds instead of hanging it.
|
|
120
|
+
const timedOut = (error) => { if (error?.name === 'TimeoutError') die(`${target} did not answer within 30 seconds.`); throw error; };
|
|
121
|
+
const response = await fetch(target, { signal: AbortSignal.timeout(30000) }).catch(timedOut);
|
|
122
|
+
const read = await engine.fromEnvelope(await response.json().catch(timedOut));
|
|
113
123
|
if (read.problems.length) die(`That bundle could not be read:\n${read.problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
114
124
|
({ files, leftOut } = read);
|
|
115
125
|
} else if (existsSync(target) && target.endsWith('.json')) {
|
|
@@ -129,9 +139,17 @@ const { factory } = parsed;
|
|
|
129
139
|
|
|
130
140
|
const kind = options.provider || (options.baseUrl || process.env.OPENAI_BASE_URL ? 'openai' : process.env.ANTHROPIC_API_KEY ? 'anthropic' : process.env.OPENAI_API_KEY ? 'openai' : null);
|
|
131
141
|
if (!kind) die('No model configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --base-url and --model for a local model.');
|
|
142
|
+
// A key from the environment goes only to its own provider's address, the
|
|
143
|
+
// rule lib/providers.mjs keeps for the CLI and the MCP server. Any other
|
|
144
|
+
// address, such as a local server, gets a key only from the variable the
|
|
145
|
+
// person named with --api-key-env, so a changed address never carries a key away.
|
|
146
|
+
const KEY_FOR_ADDRESS = { 'https://api.openai.com/v1': 'OPENAI_API_KEY', 'https://openrouter.ai/api/v1': 'OPENROUTER_API_KEY' };
|
|
147
|
+
const baseUrl = String(options.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
148
|
+
if (options.apiKeyEnv && !process.env[options.apiKeyEnv]) die(`${options.apiKeyEnv} is not set.`);
|
|
149
|
+
const keyName = options.apiKeyEnv || KEY_FOR_ADDRESS[baseUrl];
|
|
132
150
|
const provider = kind === 'anthropic'
|
|
133
151
|
? engine.anthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY, model: options.model || process.env.FACTORY_MODEL || 'claude-sonnet-5-5' })
|
|
134
|
-
: engine.openaiProvider({ apiKey:
|
|
152
|
+
: engine.openaiProvider({ apiKey: keyName ? process.env[keyName] || '' : '', baseUrl, model: options.model || process.env.FACTORY_MODEL || 'gpt-5' });
|
|
135
153
|
|
|
136
154
|
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
137
155
|
console.log(`\n${bold(factory.title)} ${dim(`v${factory.version}, ${provider.name}, ${provider.model}`)}\n${factory.description}\n`);
|
|
@@ -153,21 +171,79 @@ const tests = options.test ? await import(existsSync(testsPath) ? testsPath.href
|
|
|
153
171
|
const definition = tests?.readAuthorTests(files);
|
|
154
172
|
if (options.test && (!definition.declared || !definition.ok)) die(definition.errors.join('\n') || 'This factory has no mindburn.tests.yaml examples.');
|
|
155
173
|
|
|
174
|
+
// True for loopback, private, link-local, shared, reserved and multicast
|
|
175
|
+
// addresses. The same as isPrivateIp in lib/run.mjs.
|
|
176
|
+
function isPrivateIp(ip) {
|
|
177
|
+
if (isIP(ip) === 4) {
|
|
178
|
+
const [a, b] = ip.split('.').map(Number);
|
|
179
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
180
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
|
|
181
|
+
}
|
|
182
|
+
const low = ip.toLowerCase();
|
|
183
|
+
const mapped = low.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
184
|
+
if (mapped) return isPrivateIp(mapped[1]);
|
|
185
|
+
return low === '::' || low === '::1' || /^f[cd]/.test(low) || /^fe[89ab]/.test(low) || /^ff/.test(low) || low.startsWith('64:ff9b:');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A page is read up to 1,500,000 bytes, as on the factoryhub site, and only
|
|
189
|
+
// if it is text, HTML, XML or JSON. A cut page says so, so the run records
|
|
190
|
+
// that only part of it was read. The same as pageText in lib/run.mjs.
|
|
191
|
+
const PAGE_BYTES = 1500000;
|
|
192
|
+
async function pageText(response, url) {
|
|
193
|
+
const type = (response.headers.get('content-type') || '').toLowerCase();
|
|
194
|
+
if (type && !/text\/|json|xml/.test(type)) {
|
|
195
|
+
await response.body?.cancel().catch(() => {});
|
|
196
|
+
throw new Error(`the address is a ${type.split(';')[0].trim()} file, not a page`);
|
|
197
|
+
}
|
|
198
|
+
const reader = response.body?.getReader();
|
|
199
|
+
const chunks = [];
|
|
200
|
+
let total = 0;
|
|
201
|
+
let cut = false;
|
|
202
|
+
while (reader) {
|
|
203
|
+
const { done, value } = await reader.read();
|
|
204
|
+
if (done) break;
|
|
205
|
+
chunks.push(value);
|
|
206
|
+
total += value.byteLength;
|
|
207
|
+
if (total >= PAGE_BYTES) {
|
|
208
|
+
// A page that ends exactly at the limit was not cut.
|
|
209
|
+
cut = total > PAGE_BYTES || !(await reader.read()).done;
|
|
210
|
+
await reader.cancel().catch(() => {});
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const bytes = new Uint8Array(Math.min(total, PAGE_BYTES));
|
|
215
|
+
let offset = 0;
|
|
216
|
+
for (const chunk of chunks) {
|
|
217
|
+
const part = chunk.subarray(0, bytes.length - offset);
|
|
218
|
+
bytes.set(part, offset);
|
|
219
|
+
offset += part.byteLength;
|
|
220
|
+
}
|
|
221
|
+
const page = engine.htmlToText(new TextDecoder('utf-8').decode(bytes), url);
|
|
222
|
+
const note = cut && !/\[Page cut here: [^\]]*\]$/.test(page) ? '[Page cut here: only the first 1,500,000 bytes of the page were read]' : '';
|
|
223
|
+
return `Address: ${url}\n\n${[page, note].filter(Boolean).join('\n\n')}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
156
226
|
const titleOf = (id) => factory.steps.find((step) => step.id === id)?.title || id;
|
|
157
227
|
const host = {
|
|
158
228
|
provider,
|
|
159
229
|
limits: engine.DEFAULT_LIMITS,
|
|
160
230
|
now: () => Date.now(),
|
|
161
231
|
// Agents read public web pages only: never this machine or its network,
|
|
162
|
-
// whatever a factory or a page asks for.
|
|
232
|
+
// whatever a factory or a page asks for. The address text is checked, and
|
|
233
|
+
// every address its host name resolves to, so a public-looking name that
|
|
234
|
+
// points at this machine or its network is refused. Each redirect is checked
|
|
235
|
+
// too. The same as nodeHost in lib/run.mjs.
|
|
163
236
|
async fetchPage(address) {
|
|
164
237
|
let url = address;
|
|
165
238
|
for (let hop = 0; hop < 6; hop += 1) {
|
|
166
239
|
if (!engine.isPublicAddress(url)) throw new Error('that is not a public web address');
|
|
240
|
+
let answers;
|
|
241
|
+
try { answers = await lookup(new URL(url).hostname, { all: true, verbatim: true }); } catch { throw new Error('the address could not be found'); }
|
|
242
|
+
if (!answers.length || answers.some((answer) => isPrivateIp(answer.address))) throw new Error('that address points at a private network');
|
|
167
243
|
const response = await fetch(url, { redirect: 'manual', headers: { 'user-agent': 'Mozilla/5.0 (compatible; FactoryRunner/1.0)', accept: 'text/html,*/*;q=0.5' }, signal: AbortSignal.timeout(20000) });
|
|
168
244
|
if (response.status >= 300 && response.status < 400) { url = new URL(response.headers.get('location') || '', url).href; continue; }
|
|
169
245
|
if (!response.ok) throw new Error(`the site answered with status ${response.status}`);
|
|
170
|
-
return
|
|
246
|
+
return pageText(response, url);
|
|
171
247
|
}
|
|
172
248
|
throw new Error('the page redirects too many times');
|
|
173
249
|
},
|