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
@@ -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 --out FILE
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
- const read = await engine.fromEnvelope(await (await fetch(target)).json());
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: process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || '', baseUrl: options.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', model: options.model || process.env.FACTORY_MODEL || 'gpt-5' });
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. Each redirect is checked too.
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 `Address: ${url}\n\n${engine.htmlToText(await response.text(), url)}`;
246
+ return pageText(response, url);
171
247
  }
172
248
  throw new Error('the page redirects too many times');
173
249
  },