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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { out, json, styles, usage, connect, day, BASE_URL_HELP } from '../util.mjs';
|
|
2
|
+
import { credentialsPath } from '../../../engine/lib/credentials.mjs';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'whoami',
|
|
6
|
+
usage: `Usage: factoryhub whoami [--base-url url] [--json]
|
|
7
|
+
|
|
8
|
+
Asks factoryhub who the token acts for: the account, the token's name,
|
|
9
|
+
scopes and expiry, and where the token comes from (FACTORYHUB_TOKEN or the
|
|
10
|
+
login saved on this computer). The token itself is never printed.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 logged in, 1 not logged in or the token is not accepted.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--base-url ${BASE_URL_HELP}
|
|
16
|
+
--json print the answer as JSON`,
|
|
17
|
+
options: { boolean: ['json'], string: ['base-url'] },
|
|
18
|
+
async run({ positional, flags }, io) {
|
|
19
|
+
if (positional.length) throw usage('whoami takes no words, only options.');
|
|
20
|
+
const { hub, credential, base } = await connect(flags, io, { needToken: true });
|
|
21
|
+
const me = await hub.me();
|
|
22
|
+
const source = credential.source === 'env' ? 'FACTORYHUB_TOKEN' : credentialsPath(io.env);
|
|
23
|
+
if (flags.json) {
|
|
24
|
+
json(io, { base, source: credential.source, account: me.account, token: me.token });
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const s = styles(io);
|
|
28
|
+
out(io, `${s.bold(`@${me.account.handle}`)}${me.account.name ? ` (${me.account.name})` : ''} on ${base}`);
|
|
29
|
+
out(io, ` Token ${me.token.name} (${me.token.prefix ? `${me.token.prefix}…` : 'fhpat_…'}), from ${source}`);
|
|
30
|
+
out(io, ` Scopes ${me.token.scopes.join(', ')}`);
|
|
31
|
+
out(io, ` Expires ${day(me.token.expires)}`);
|
|
32
|
+
return 0;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// `factoryhub run <id> --hosted` and `factoryhub runs <id>`: start and follow
|
|
2
|
+
// runs on factoryhub, decide where the person decides, and save the result
|
|
3
|
+
// with its signed receipt.
|
|
4
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { resolve, relative, dirname } from 'node:path';
|
|
6
|
+
import { out, err, json, styles, usage, connect } from './util.mjs';
|
|
7
|
+
import { followRun, runResult, newIdempotencyKey, receiptVerdict } from '../../engine/lib/hosted.mjs';
|
|
8
|
+
import { FACTORY_ID } from '../../engine/lib/hub.mjs';
|
|
9
|
+
|
|
10
|
+
const pauseFor = (io) => io.sleep || ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
11
|
+
/** Options of `run` that only a run on this machine uses; a hosted run refuses them. */
|
|
12
|
+
export const LOCAL_ONLY = ['provider', 'model', 'base-url', 'api-key-env', 'effort', 'max-output', 'timeout', 'check-model', 'check-effort', 'save',
|
|
13
|
+
'experimental-tools', 'mcp', 'mcp-token-env', 'allow-tool', 'read-only', 'nango', 'nango-user', 'grant', 'bind', 'target'];
|
|
14
|
+
// A repeatable option is an empty list when it is not given.
|
|
15
|
+
const given = (value) => (Array.isArray(value) ? value.length > 0 : value !== undefined);
|
|
16
|
+
|
|
17
|
+
/** name=value pairs, with name=@file read from a file. */
|
|
18
|
+
export async function readInputs(pairs, io) {
|
|
19
|
+
const inputs = {};
|
|
20
|
+
for (const pair of pairs) {
|
|
21
|
+
const at = pair.indexOf('=');
|
|
22
|
+
if (at < 1) throw usage(`--input needs name=value, not "${pair}".`);
|
|
23
|
+
const value = pair.slice(at + 1);
|
|
24
|
+
inputs[pair.slice(0, at)] = value.startsWith('@') ? await readFile(resolve(io.cwd, value.slice(1)), 'utf8') : value;
|
|
25
|
+
}
|
|
26
|
+
return inputs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Saves a finished run's result and receipt; returns their paths. Without
|
|
31
|
+
* `outFile` the name comes from the factory id, which the hub sends: one that
|
|
32
|
+
* is not a factory id is replaced by "run", so it never names a path.
|
|
33
|
+
*/
|
|
34
|
+
export async function saveResult(io, { name, result, outFile, finished }) {
|
|
35
|
+
const stamp = new Date(finished || Date.now()).toISOString().slice(0, 16).replace(/[-:T]/g, '');
|
|
36
|
+
const file = resolve(io.cwd, outFile || `${FACTORY_ID.test(String(name || '')) ? name : 'run'}-${stamp}.md`);
|
|
37
|
+
const receiptFile = `${file.replace(/\.md$/, '')}.receipt.json`;
|
|
38
|
+
await mkdir(dirname(file), { recursive: true });
|
|
39
|
+
await writeFile(file, result.text);
|
|
40
|
+
if (result.receipt) await writeFile(receiptFile, `${JSON.stringify(result.receipt, null, 2)}\n`);
|
|
41
|
+
return { file: relative(io.cwd, file) || file, receipt: result.receipt ? relative(io.cwd, receiptFile) || receiptFile : null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reports a run that finished, waits or failed. Returns the exit code:
|
|
47
|
+
* 0 done, 1 failed or stopped, 3 waiting for a decision.
|
|
48
|
+
*/
|
|
49
|
+
export async function finish(hub, run, { io, flags, name }) {
|
|
50
|
+
const s = styles(io);
|
|
51
|
+
const say = (text = '') => err(io, text);
|
|
52
|
+
if (run.waiting) {
|
|
53
|
+
const waiting = run.waiting;
|
|
54
|
+
if (flags.json) json(io, { status: 'waiting', run: run.id, url: run.url, step: waiting.step, title: waiting.title, ask: waiting.ask, approval_id: waiting.approval_id, artifact_sha256: waiting.artifact_sha256, complete: waiting.complete });
|
|
55
|
+
say(`\nWaiting at "${waiting.title}" for your decision. Nothing was decided.\nDecide on the run page: ${run.url}\nThen read the result with: factoryhub runs ${run.id}`);
|
|
56
|
+
return 3;
|
|
57
|
+
}
|
|
58
|
+
if (run.status === 'done') {
|
|
59
|
+
const result = await runResult(hub, run.id);
|
|
60
|
+
if (!result) {
|
|
61
|
+
if (flags.json) json(io, { status: 'done', run: run.id, url: run.url, error: 'no_result' });
|
|
62
|
+
say(`\n${s.red('No result')}: the run is done, but ${hub.base} sent no result to save. ${run.url}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const saved = await saveResult(io, { name, result, outFile: flags.out });
|
|
66
|
+
if (flags.json) json(io, { status: 'done', run: run.id, url: run.url, deliverable: saved.file, receipt: saved.receipt, review: result.review, verification: result.verification });
|
|
67
|
+
else out(io, result.text);
|
|
68
|
+
say(s.dim(`\nSaved ${saved.file}${saved.receipt ? ' and its receipt' : ''}. Final check: ${result.review}. Receipt: ${receiptVerdict(result.verification)}.`));
|
|
69
|
+
return result.verification.verified ? 0 : 1;
|
|
70
|
+
}
|
|
71
|
+
if (flags.json) json(io, { status: run.status, run: run.id, url: run.url, error: run.error, stopped: run.stopped });
|
|
72
|
+
if (run.status === 'stopped' && run.stopped) say(`\nStopped at "${run.stopped.title}" by a decision. No result was made.`);
|
|
73
|
+
else say(`\n${s.red(run.status === 'failed' ? 'Failed' : `Run ${run.status}`)}${run.error ? `: ${run.error}` : '.'} ${run.url}`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Asks the person at the terminal; null when nobody answers. */
|
|
78
|
+
async function askPerson(reader, io, waiting) {
|
|
79
|
+
const s = styles(io);
|
|
80
|
+
let answer = '';
|
|
81
|
+
while (!answer) {
|
|
82
|
+
const line = await reader.ask(`\nType ${s.bold('approve')} to continue, ${s.bold('reject')} to stop the run, or what to change:\n> `);
|
|
83
|
+
if (line === null) return null;
|
|
84
|
+
answer = line.trim();
|
|
85
|
+
}
|
|
86
|
+
if (/^(approve|a|yes|y)$/i.test(answer)) return { decision: 'approve' };
|
|
87
|
+
if (/^(reject|stop|no|n)$/i.test(answer)) return { decision: 'reject' };
|
|
88
|
+
if (waiting.revisionsLeft === 0) err(io, s.dim('This step cannot be sent back again: approve it or reject it.'));
|
|
89
|
+
return waiting.revisionsLeft === 0 ? askPerson(reader, io, waiting) : { decision: 'revise', note: answer };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `factoryhub run <id> --hosted`. */
|
|
93
|
+
export async function hostedRun(target, flags, io, reader) {
|
|
94
|
+
const used = LOCAL_ONLY.filter((name) => given(flags[name]));
|
|
95
|
+
if (used.length) throw usage(`--${used[0]} applies to a run on this machine. A hosted run uses factoryhub's model; leave it out.`);
|
|
96
|
+
const s = styles(io);
|
|
97
|
+
const say = (text = '') => err(io, text);
|
|
98
|
+
const { hub } = await connect({ 'base-url': flags.hub }, io, { needToken: true });
|
|
99
|
+
const me = await hub.me();
|
|
100
|
+
const scopes = me.token.scopes || [];
|
|
101
|
+
if (!scopes.includes('runs:start') || !scopes.includes('runs:read')) {
|
|
102
|
+
throw new Error(`A hosted run needs a token with runs:start and runs:read; this one has ${scopes.join(', ') || 'none'}. Log in again: factoryhub login --scopes ${[...new Set([...scopes, 'runs:start', 'runs:read'])].join(',')}`);
|
|
103
|
+
}
|
|
104
|
+
const canDecide = scopes.includes('runs:decide');
|
|
105
|
+
if (flags.yes && !canDecide) throw new Error('--yes on a hosted run needs a token with the opt-in runs:decide scope. Log in again with it, or leave out --yes and decide on the run page.');
|
|
106
|
+
|
|
107
|
+
const { factory } = await hub.factory(target);
|
|
108
|
+
if (!factory.runnable) throw new Error(`${target} does not run: ${(factory.blockers || []).join(' ') || 'it has no steps.'}`);
|
|
109
|
+
const inputs = await readInputs(flags.input, io);
|
|
110
|
+
say(`\n${s.bold(factory.title)} ${s.dim(`v${factory.version}, hosted on ${hub.base}`)}`);
|
|
111
|
+
if (factory.description) say(factory.description);
|
|
112
|
+
for (const input of factory.inputs || []) {
|
|
113
|
+
if (inputs[input.key] !== undefined || !io.interactive) continue;
|
|
114
|
+
const answer = await reader.ask(`\n${s.bold(input.label)}${input.required ? '' : s.dim(' (optional, Enter to skip)')} ${s.dim(`${input.type}${input.type === 'longtext' ? ', or @file' : ''}`)}\n${s.dim(input.help || '')}\n> `);
|
|
115
|
+
if (answer === null) break;
|
|
116
|
+
if (answer !== '' || input.required) inputs[input.key] = answer.startsWith('@') ? await readFile(resolve(io.cwd, answer.slice(1).trim()), 'utf8') : answer;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const started = await hub.startRun(target, inputs, { idempotencyKey: newIdempotencyKey() });
|
|
120
|
+
say(`Started ${started.url}${Number.isFinite(started.runsLeftToday) ? s.dim(` (${started.runsLeftToday} hosted runs left today)`) : ''}`);
|
|
121
|
+
say(s.dim('Ctrl+C stops following here; the run goes on, and "factoryhub runs" finds it.'));
|
|
122
|
+
const titles = new Map();
|
|
123
|
+
const decided = new Set();
|
|
124
|
+
const pause = pauseFor(io);
|
|
125
|
+
for (;;) {
|
|
126
|
+
const run = await followRun(hub, started.id, {
|
|
127
|
+
sleep: io.sleep,
|
|
128
|
+
onEvent(event, view) {
|
|
129
|
+
for (const step of view.steps || []) titles.set(step.id, step.title);
|
|
130
|
+
const title = titles.get(event.step) || event.step || '';
|
|
131
|
+
if (event.type === 'step_started') say(`\n${s.bold(title)}`);
|
|
132
|
+
else if (event.type === 'step_done') say(s.dim(' done'));
|
|
133
|
+
else if (event.type === 'check') say(` ${event.status === 'passed' ? s.green('passed') : s.yellow(event.status || 'checked')}`);
|
|
134
|
+
else if (event.type === 'run_failed') say(`\n${s.red('Stopped')}: ${event.error || ''}`);
|
|
135
|
+
else if (event.type && title) say(s.dim(` ${title}: ${event.type.replace(/_/g, ' ')}${event.status ? ` (${event.status})` : ''}`));
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const waiting = run.waiting;
|
|
139
|
+
// A decision this command sent that the run has not taken yet: keep following.
|
|
140
|
+
if (waiting && decided.has(`${waiting.approval_id}:${waiting.artifact_sha256}`)) { await pause(2000); continue; }
|
|
141
|
+
if (!waiting) return finish(hub, run, { io, flags, name: started.factoryId || target });
|
|
142
|
+
if (!flags.json) say(`\n${s.bold(waiting.title)} ${s.yellow('needs your decision')}\n\n${waiting.text}${waiting.complete ? '' : `\n[${waiting.totalChars} characters in all; the rest is on the run page.]`}\n\n${s.dim(waiting.ask)}`);
|
|
143
|
+
let choice = null;
|
|
144
|
+
if (flags.yes) { choice = { decision: 'approve' }; say(s.dim('Approved by --yes.')); }
|
|
145
|
+
else if (canDecide && io.interactive && waiting.complete) choice = await askPerson(reader, io, waiting);
|
|
146
|
+
else if (!canDecide && io.interactive) say(s.dim('This token cannot decide steps (it lacks the opt-in runs:decide scope).'));
|
|
147
|
+
if (!choice) return finish(hub, run, { io, flags, name: started.factoryId || target });
|
|
148
|
+
await hub.decide(run.id, { approvalId: waiting.approval_id, seenSha256: waiting.artifact_sha256, decision: choice.decision, note: choice.note || '' });
|
|
149
|
+
decided.add(`${waiting.approval_id}:${waiting.artifact_sha256}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `factoryhub runs` with no id: the account's recent runs. */
|
|
154
|
+
export async function listRuns(flags, io) {
|
|
155
|
+
const { hub } = await connect(flags, io, { needToken: true });
|
|
156
|
+
const limit = flags.limit === undefined ? undefined : Number(flags.limit);
|
|
157
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 30)) throw usage('--limit needs a whole number from 1 to 30.');
|
|
158
|
+
return hub.runs({ limit });
|
|
159
|
+
}
|
|
@@ -1,22 +1,73 @@
|
|
|
1
1
|
// factoryhub: find, run, make and share AI factories from the terminal.
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
4
5
|
import { parseArgs } from './args.mjs';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
import { hasControl } from '../../engine/lib/text.mjs';
|
|
7
|
+
|
|
8
|
+
// Every command, in the order --help lists them. A command's module is
|
|
9
|
+
// imported only when that command runs: `factoryhub --version` or one
|
|
10
|
+
// command does not load the engine, the packer, the runner and the hub
|
|
11
|
+
// client of all the others. Usage and options stay in each module.
|
|
12
|
+
export const COMMANDS = [
|
|
13
|
+
{ name: 'list', summary: 'List the house factories, or the factory folders in a directory.', load: () => import('./commands/list.mjs') },
|
|
14
|
+
{ name: 'inspect', summary: 'Show what a factory does: inputs, steps in order, agents, tools, checks.', load: () => import('./commands/inspect.mjs') },
|
|
15
|
+
{ name: 'validate', summary: 'Check factories the way the engine reads them. No key, no model.', load: () => import('./commands/validate.mjs') },
|
|
16
|
+
{ name: 'run', summary: 'Run a factory with your own model key or a local model. Stops at every decision.', load: () => import('./commands/run.mjs') },
|
|
17
|
+
{ name: 'resume', summary: 'Continue a saved run with the person\'s decision.', load: () => import('./commands/resume.mjs') },
|
|
18
|
+
{ name: 'new', summary: 'Create a new factory folder that validates and runs.', load: () => import('./commands/new.mjs') },
|
|
19
|
+
{ name: 'pack', summary: 'Pack a factory into a zip that runs anywhere with Node, or a bundle .json.', load: () => import('./commands/pack.mjs') },
|
|
20
|
+
{ name: 'export', summary: 'Export a factory as an Agent Skill folder (SKILL.md plus the factory).', load: () => import('./commands/export.mjs') },
|
|
21
|
+
{ name: 'search', summary: 'Search the published factories on factoryhub (needs FACTORYHUB_ACCESS_KEY in preview).', load: () => import('./commands/search.mjs') },
|
|
22
|
+
{ name: 'get', summary: 'Download a published factory from factoryhub into a folder, checking every file.', load: () => import('./commands/get.mjs') },
|
|
23
|
+
{ name: 'login', summary: 'Log in to factoryhub from this computer, with a code you approve in your browser.', load: () => import('./commands/login.mjs') },
|
|
24
|
+
{ name: 'logout', summary: 'Revoke this computer\'s factoryhub token on the server, then forget it.', load: () => import('./commands/logout.mjs') },
|
|
25
|
+
{ name: 'whoami', summary: 'Show which factoryhub account and token this computer uses.', load: () => import('./commands/whoami.mjs') },
|
|
26
|
+
{ name: 'push', summary: 'Save a factory folder to your factoryhub account, privately. Nothing is published.', load: () => import('./commands/push.mjs') },
|
|
27
|
+
{ name: 'pull', summary: 'Download a factory you can open on factoryhub, yours included, checking every file.', load: () => import('./commands/pull.mjs') },
|
|
28
|
+
{ name: 'publish', summary: 'Publish one of your factories on factoryhub, so anyone can find and run it.', load: () => import('./commands/publish.mjs') },
|
|
29
|
+
{ name: 'runs', summary: 'List your hosted runs on factoryhub, or show one and save its result and receipt.', load: () => import('./commands/runs.mjs') },
|
|
30
|
+
{ name: 'mcp', summary: 'Start the local MCP server on stdio, for MCP clients.', load: () => import('./commands/mcp.mjs') },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Every command's module with its summary, for the CLI reference. */
|
|
34
|
+
export async function loadCommands() {
|
|
35
|
+
return Promise.all(COMMANDS.map(async ({ name, summary, load }) => ({ ...(await load()).default, name, summary })));
|
|
36
|
+
}
|
|
16
37
|
|
|
17
|
-
export const COMMANDS = [list, inspect, validate, run, resume, create, pack, exportCommand, search, get, mcp];
|
|
18
38
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
19
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The command that opens `url` in a browser on `platform`, or null when the
|
|
42
|
+
* address is not a plain http(s) one. No shell runs it: on Windows,
|
|
43
|
+
* rundll32 hands the address to the default browser, so characters cmd.exe
|
|
44
|
+
* would read (& | ^ % < > ") are never interpreted.
|
|
45
|
+
*/
|
|
46
|
+
export function browserCommand(url, platform = process.platform) {
|
|
47
|
+
let parsed;
|
|
48
|
+
try { parsed = new URL(String(url)); } catch { return null; }
|
|
49
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return null;
|
|
50
|
+
const href = parsed.href;
|
|
51
|
+
// The URL parser percent-encodes spaces, quotes and controls; refuse anything it left.
|
|
52
|
+
if (/[\s"'`<>\\]/.test(href) || hasControl(href)) return null;
|
|
53
|
+
if (platform === 'darwin') return ['open', [href]];
|
|
54
|
+
if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', href]];
|
|
55
|
+
return ['xdg-open', [href]];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Opens an address in the person's browser. True when a browser was started. */
|
|
59
|
+
export function openInBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
60
|
+
const command = browserCommand(url, platform);
|
|
61
|
+
if (!command) return Promise.resolve(false);
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
try {
|
|
64
|
+
const child = spawnImpl(command[0], command[1], { stdio: 'ignore', detached: true, shell: false, windowsHide: true });
|
|
65
|
+
child.once('error', () => resolve(false));
|
|
66
|
+
child.once('spawn', () => { child.unref(); resolve(true); });
|
|
67
|
+
} catch { resolve(false); }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
20
71
|
/** The streams and environment a command uses. Tests pass their own. */
|
|
21
72
|
export function defaultIo() {
|
|
22
73
|
return {
|
|
@@ -28,6 +79,20 @@ export function defaultIo() {
|
|
|
28
79
|
fetch: globalThis.fetch,
|
|
29
80
|
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
30
81
|
color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
|
|
82
|
+
// Runs `cleanup` (at most five seconds) before Ctrl+C or a stop signal
|
|
83
|
+
// ends the process. Returns the function that removes it again.
|
|
84
|
+
onInterrupt(cleanup) {
|
|
85
|
+
const handler = (signal) => {
|
|
86
|
+
Promise.race([Promise.resolve().then(cleanup).catch(() => {}), new Promise((resolve) => { setTimeout(resolve, 5000).unref?.(); })])
|
|
87
|
+
.finally(() => process.exit(signal === 'SIGTERM' ? 143 : 130));
|
|
88
|
+
};
|
|
89
|
+
process.once('SIGINT', handler);
|
|
90
|
+
process.once('SIGTERM', handler);
|
|
91
|
+
return () => { process.off('SIGINT', handler); process.off('SIGTERM', handler); };
|
|
92
|
+
},
|
|
93
|
+
// Only at a terminal: a script or an agent's shell never opens a browser.
|
|
94
|
+
openUrl: process.stdin.isTTY && process.stdout.isTTY ? openInBrowser : null,
|
|
95
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
31
96
|
};
|
|
32
97
|
}
|
|
33
98
|
|
|
@@ -51,8 +116,9 @@ export async function main(argv, io = defaultIo()) {
|
|
|
51
116
|
const [name, ...rest] = argv;
|
|
52
117
|
if (!name || name === '--help' || name === '-h' || name === 'help') { help(io); return 0; }
|
|
53
118
|
if (name === '--version' || name === '-v') { io.stdout.write(`${VERSION}\n`); return 0; }
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
119
|
+
const entry = COMMANDS.find((item) => item.name === name);
|
|
120
|
+
if (!entry) { io.stderr.write(`Unknown command "${name}". Run "factoryhub --help" for the list.\n`); return 2; }
|
|
121
|
+
const command = (await entry.load()).default;
|
|
56
122
|
let args;
|
|
57
123
|
try { args = parseArgs(rest, command.options || {}); } catch (error) {
|
|
58
124
|
io.stderr.write(`${error.message}\n\n${command.usage}\n`);
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Experimental tool sources for `factoryhub run`: reading the flags, and
|
|
2
|
+
// opening the MCP servers and Nango sessions they name. Nothing here runs
|
|
3
|
+
// without --experimental-tools. See docs/experimental-tool-sources.md.
|
|
4
|
+
import { usage } from './util.mjs';
|
|
5
|
+
import { createMcpClient, MIN_TOKEN_LENGTH } from '../../engine/src/mcp-client.mjs';
|
|
6
|
+
import { createToolSources, displayLine } from '../../engine/src/tool-sources.mjs';
|
|
7
|
+
import { openNangoSession } from '../../engine/lib/nango.mjs';
|
|
8
|
+
import { isPrivateIp } from '../../engine/lib/run.mjs';
|
|
9
|
+
import { isPublicAddress } from '../../engine/src/factory-engine.mjs';
|
|
10
|
+
import { lookup } from 'node:dns/promises';
|
|
11
|
+
|
|
12
|
+
// Flags that name sources on the command line; and flags that grant the sources a factory declares.
|
|
13
|
+
export const TOOL_SOURCE_FLAGS = ['mcp', 'mcp-token-env', 'allow-tool', 'read-only', 'nango'];
|
|
14
|
+
export const GRANT_FLAGS = ['grant', 'bind', 'target'];
|
|
15
|
+
const NAME = /^[a-z0-9][a-z0-9_-]{0,40}$/;
|
|
16
|
+
const TOOL = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
17
|
+
const ENV = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
18
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
19
|
+
|
|
20
|
+
const split = (value, sep, flag) => {
|
|
21
|
+
const at = value.indexOf(sep);
|
|
22
|
+
if (at < 1 || at === value.length - 1) throw usage(`--${flag} needs ${flag === 'mcp' ? 'name=url' : flag === 'mcp-token-env' ? 'name=ENV_VAR' : 'name:tool'}, not "${value}".`);
|
|
23
|
+
return [value.slice(0, at), value.slice(at + 1)];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The tool sources the flags name, checked, or null when none are given.
|
|
28
|
+
* Every flag here needs --experimental-tools.
|
|
29
|
+
*/
|
|
30
|
+
export function toolSourcePlan(flags, env) {
|
|
31
|
+
const given = [...TOOL_SOURCE_FLAGS, ...GRANT_FLAGS].filter((flag) => flags[flag]?.length);
|
|
32
|
+
if (flags['nango-user'] !== undefined) given.push('nango-user');
|
|
33
|
+
if (!flags['experimental-tools']) {
|
|
34
|
+
if (given.length) throw usage(`${given.map((flag) => `--${flag}`).join(', ')} need${given.length === 1 ? 's' : ''} --experimental-tools: tool sources are an experimental runner mode.`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const granting = GRANT_FLAGS.filter((flag) => flags[flag]?.length);
|
|
38
|
+
if (granting.length) throw usage(`${granting.map((flag) => `--${flag}`).join(', ')} grant${granting.length === 1 ? 's' : ''} the sources a factory declares, and this factory declares none.`);
|
|
39
|
+
const sources = new Map();
|
|
40
|
+
const add = (name, entry, flag) => {
|
|
41
|
+
if (!NAME.test(name)) throw usage(`--${flag}: "${name}" is not a source name. Use lowercase letters, digits, - and _.`);
|
|
42
|
+
if (sources.has(name)) throw usage(`The tool source "${name}" is named twice.`);
|
|
43
|
+
sources.set(name, { name, allow: [], readOnly: [], ...entry });
|
|
44
|
+
};
|
|
45
|
+
for (const value of flags.mcp || []) {
|
|
46
|
+
const [name, address] = split(value, '=', 'mcp');
|
|
47
|
+
let url;
|
|
48
|
+
try { url = new URL(address); } catch { throw usage(`--mcp ${name}: "${address}" is not a URL.`); }
|
|
49
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname))) throw usage(`--mcp ${name}: the address must start with https:// (http:// only for this machine).`);
|
|
50
|
+
if (url.username || url.password) throw usage(`--mcp ${name}: put credentials in an environment variable and name it with --mcp-token-env, not in the address.`);
|
|
51
|
+
add(name, { kind: 'mcp', url: url.href, tokenEnv: null }, 'mcp');
|
|
52
|
+
}
|
|
53
|
+
for (const value of flags['mcp-token-env'] || []) {
|
|
54
|
+
const [name, variable] = split(value, '=', 'mcp-token-env');
|
|
55
|
+
const source = sources.get(name);
|
|
56
|
+
if (!source || source.kind !== 'mcp') throw usage(`--mcp-token-env ${name}: there is no --mcp ${name}=<url>.`);
|
|
57
|
+
if (!ENV.test(variable)) throw usage(`--mcp-token-env ${name}: "${variable}" is not an environment variable name.`);
|
|
58
|
+
if (!env[variable]) throw usage(`${variable} is not set.`);
|
|
59
|
+
if (env[variable].length < MIN_TOKEN_LENGTH) throw usage(`${variable} holds a token shorter than ${MIN_TOKEN_LENGTH} characters, too short to find again in what the server sends back.`);
|
|
60
|
+
source.tokenEnv = variable;
|
|
61
|
+
}
|
|
62
|
+
for (const value of flags.nango || []) {
|
|
63
|
+
const [integration, list] = value.includes(':') ? split(value, ':', 'nango') : [value, ''];
|
|
64
|
+
add(integration, { kind: 'nango' }, 'nango');
|
|
65
|
+
for (const tool of list.split(',').map((item) => item.trim()).filter(Boolean)) sources.get(integration).allow.push(tool);
|
|
66
|
+
}
|
|
67
|
+
for (const [flag, field] of [['allow-tool', 'allow'], ['read-only', 'readOnly']]) {
|
|
68
|
+
for (const value of flags[flag] || []) {
|
|
69
|
+
const [name, list] = split(value, ':', flag);
|
|
70
|
+
const source = sources.get(name);
|
|
71
|
+
if (!source) throw usage(`--${flag} ${name}: there is no tool source "${name}". Name it with --mcp or --nango first.`);
|
|
72
|
+
source[field].push(...list.split(',').map((item) => item.trim()).filter(Boolean));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!sources.size) throw usage('--experimental-tools needs a tool source: --mcp name=url or --nango integration:tool.');
|
|
76
|
+
if (flags['nango-user'] !== undefined && ![...sources.values()].some((source) => source.kind === 'nango')) throw usage('--nango-user goes with --nango.');
|
|
77
|
+
for (const source of sources.values()) {
|
|
78
|
+
const tools = [...source.allow, ...source.readOnly];
|
|
79
|
+
const bad = tools.find((tool) => !TOOL.test(tool));
|
|
80
|
+
if (bad) throw usage(`"${bad}" is not a tool name.`);
|
|
81
|
+
if (!tools.length) throw usage(`Allow at least one tool of "${source.name}": --allow-tool ${source.name}:<tool>, --read-only ${source.name}:<tool>${source.kind === 'nango' ? ` or --nango ${source.name}:<tool>` : ''}.`);
|
|
82
|
+
if (source.kind === 'nango' && !env.NANGO_API_KEY) throw usage('NANGO_API_KEY is not set.');
|
|
83
|
+
source.allow = [...new Set(source.allow)];
|
|
84
|
+
source.readOnly = [...new Set(source.readOnly)];
|
|
85
|
+
}
|
|
86
|
+
return { sources: [...sources.values()], endUserId: flags['nango-user'] || 'local-dev-user' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What is open, so it can be closed from anywhere: the end of a run, an
|
|
91
|
+
* error, or Ctrl+C while sources are still being opened. Closing twice is
|
|
92
|
+
* harmless.
|
|
93
|
+
*/
|
|
94
|
+
export function openThings() {
|
|
95
|
+
const closers = [];
|
|
96
|
+
return {
|
|
97
|
+
add(closeOne) { closers.push(closeOne); },
|
|
98
|
+
async close() { await Promise.all(closers.splice(0).map((closeOne) => Promise.resolve().then(closeOne).catch(() => {}))); },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Opens every source in the plan and builds the run's catalog. Each source
|
|
104
|
+
* is added to `open` as soon as it exists. Resolves to `{ sources, close }`,
|
|
105
|
+
* or to `{ needsConnection }` when a Nango integration is not connected yet
|
|
106
|
+
* (nothing stays open then).
|
|
107
|
+
*/
|
|
108
|
+
export async function openToolSources(plan, { env, fetchImpl, open = openThings() }) {
|
|
109
|
+
const closers = { push: (closeOne) => open.add(closeOne) };
|
|
110
|
+
const close = () => open.close();
|
|
111
|
+
try {
|
|
112
|
+
const specs = [];
|
|
113
|
+
for (const source of plan.sources) {
|
|
114
|
+
let client;
|
|
115
|
+
if (source.kind === 'nango') {
|
|
116
|
+
// Registered for Ctrl+C before Nango is first asked: see openNangoSession.
|
|
117
|
+
const session = await openNangoSession({ apiKey: env.NANGO_API_KEY, integration: source.integration || source.name, tools: [...new Set([...source.allow, ...source.readOnly])], endUserId: plan.endUserId, fetchImpl, register: (closeOne) => closers.push(closeOne) });
|
|
118
|
+
if (!session.connected) {
|
|
119
|
+
await close();
|
|
120
|
+
return { needsConnection: session };
|
|
121
|
+
}
|
|
122
|
+
client = session.client;
|
|
123
|
+
} else {
|
|
124
|
+
client = createMcpClient({ url: source.url, token: source.tokenEnv ? env[source.tokenEnv] : '', fetchImpl });
|
|
125
|
+
closers.push(() => client.close());
|
|
126
|
+
}
|
|
127
|
+
specs.push({
|
|
128
|
+
name: source.name, client, allow: source.allow, readOnly: source.readOnly,
|
|
129
|
+
// A source the factory declares: its declarations, target, and how it was granted.
|
|
130
|
+
...(source.declared ? { declared: source.declared, target: source.target || null, targetValue: source.targetValue } : {}),
|
|
131
|
+
...(source.kind === 'nango' && source.integration && source.integration !== source.name ? { prefix: source.integration } : {}),
|
|
132
|
+
granted: source.granted || (source.kind === 'nango' ? 'nango' : 'address'),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const sources = await createToolSources(specs);
|
|
136
|
+
return { sources, close };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
await close();
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const PLAIN = { read: 'reads', write: 'changes something', external: 'sends outside', irreversible: 'cannot be undone' };
|
|
144
|
+
const hostOf = (url) => { try { return new URL(url).host; } catch { return '(no address)'; } };
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The plan for the sources a factory declares (RFC 0017, experimental): for
|
|
148
|
+
* each source a step uses, the grant the flags give it, if any. Nothing is
|
|
149
|
+
* granted by the factory alone, and --yes never grants. A source the person
|
|
150
|
+
* has not granted here may still be granted at an interactive terminal; the
|
|
151
|
+
* run starts only when every one is.
|
|
152
|
+
*
|
|
153
|
+
* - `--grant <source>`: the address the factory declares, with a token from
|
|
154
|
+
* `--mcp-token-env <source>=<ENV>` when the server needs one.
|
|
155
|
+
* - `--bind <source>=nango:<integration>`: a Nango agent session limited to
|
|
156
|
+
* the declared tools the steps use.
|
|
157
|
+
* - `--bind <source>=<url>`: another address, with a token as above.
|
|
158
|
+
* - `--target <source>=<value>`: the one target the run is limited to.
|
|
159
|
+
* - `--read-only <source>:<tool>`: the person vouches that a declared read
|
|
160
|
+
* only reads, for a server that does not mark it so.
|
|
161
|
+
*/
|
|
162
|
+
export function declaredPlan(declarations, flags, env) {
|
|
163
|
+
const cliOnly = ['mcp', 'nango', 'allow-tool'].filter((flag) => flags[flag]?.length);
|
|
164
|
+
if (cliOnly.length) throw usage(`This factory declares the sources it needs: grant them with --grant or --bind, not ${cliOnly.map((flag) => `--${flag}`).join(', ')}.`);
|
|
165
|
+
const used = new Map();
|
|
166
|
+
for (const capability of Object.keys(declarations.uses)) {
|
|
167
|
+
const [source, tool] = capability.split('/');
|
|
168
|
+
if (!used.has(source)) used.set(source, new Set());
|
|
169
|
+
used.get(source).add(tool);
|
|
170
|
+
}
|
|
171
|
+
const known = (key, flag) => { if (!Object.hasOwn(declarations.sources, key)) throw usage(`--${flag} ${displayLine(key)}: the factory declares no source "${displayLine(key)}".`); return declarations.sources[key]; };
|
|
172
|
+
const bindings = new Map();
|
|
173
|
+
const bind = (key, binding) => { if (bindings.has(key)) throw usage(`The source "${key}" is granted twice.`); bindings.set(key, binding); };
|
|
174
|
+
for (const key of flags.grant || []) {
|
|
175
|
+
const source = known(key, 'grant');
|
|
176
|
+
if (!source.url) throw usage(`The source "${key}" declares no address, only a registry name: bind it with --bind ${key}=<url> or --bind ${key}=nango:<integration>.`);
|
|
177
|
+
bind(key, { kind: 'mcp', url: source.url, granted: 'declared' });
|
|
178
|
+
}
|
|
179
|
+
for (const value of flags.bind || []) {
|
|
180
|
+
const [key, where] = split(value, '=', 'bind');
|
|
181
|
+
known(key, 'bind');
|
|
182
|
+
if (where.startsWith('nango:')) {
|
|
183
|
+
const integration = where.slice('nango:'.length);
|
|
184
|
+
if (!NAME.test(integration)) throw usage(`--bind ${key}: "${integration}" is not a Nango integration name.`);
|
|
185
|
+
if (!env.NANGO_API_KEY) throw usage('NANGO_API_KEY is not set.');
|
|
186
|
+
bind(key, { kind: 'nango', integration, granted: 'nango' });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let url;
|
|
190
|
+
try { url = new URL(where); } catch { throw usage(`--bind ${key}: "${where}" is not a URL or nango:<integration>.`); }
|
|
191
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname))) throw usage(`--bind ${key}: the address must start with https:// (http:// only for this machine).`);
|
|
192
|
+
if (url.username || url.password) throw usage(`--bind ${key}: put credentials in an environment variable and name it with --mcp-token-env, not in the address.`);
|
|
193
|
+
bind(key, { kind: 'mcp', url: url.href, granted: 'address' });
|
|
194
|
+
}
|
|
195
|
+
const targets = new Map();
|
|
196
|
+
for (const value of flags.target || []) {
|
|
197
|
+
const [key, given] = split(value, '=', 'target');
|
|
198
|
+
const source = known(key, 'target');
|
|
199
|
+
if (!source.target) throw usage(`The source "${key}" declares no target.`);
|
|
200
|
+
if (given.length > 500 || /[\p{Cc}\p{Cf}]/u.test(given)) throw usage(`--target ${key}: a target is plain text of at most 500 characters.`);
|
|
201
|
+
targets.set(key, given);
|
|
202
|
+
}
|
|
203
|
+
const tokens = new Map();
|
|
204
|
+
for (const value of flags['mcp-token-env'] || []) {
|
|
205
|
+
const [key, variable] = split(value, '=', 'mcp-token-env');
|
|
206
|
+
known(key, 'mcp-token-env');
|
|
207
|
+
if (!ENV.test(variable)) throw usage(`--mcp-token-env ${key}: "${variable}" is not an environment variable name.`);
|
|
208
|
+
if (!env[variable]) throw usage(`${variable} is not set.`);
|
|
209
|
+
if (env[variable].length < MIN_TOKEN_LENGTH) throw usage(`${variable} holds a token shorter than ${MIN_TOKEN_LENGTH} characters, too short to find again in what the server sends back.`);
|
|
210
|
+
tokens.set(key, variable);
|
|
211
|
+
}
|
|
212
|
+
const vouched = new Map();
|
|
213
|
+
for (const value of flags['read-only'] || []) {
|
|
214
|
+
const [key, list] = split(value, ':', 'read-only');
|
|
215
|
+
const source = known(key, 'read-only');
|
|
216
|
+
for (const tool of list.split(',').map((item) => item.trim()).filter(Boolean)) {
|
|
217
|
+
if (!Object.hasOwn(source.tools, tool)) throw usage(`--read-only ${key}: the factory declares no tool "${displayLine(tool)}" for that source.`);
|
|
218
|
+
if (source.tools[tool].effect !== 'read') throw usage(`--read-only ${key}: the factory declares "${tool}" as ${source.tools[tool].effect}, so it cannot be vouched for as a read.`);
|
|
219
|
+
if (!vouched.has(key)) vouched.set(key, []);
|
|
220
|
+
vouched.get(key).push(tool);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const [key, binding] of bindings) if (binding.kind === 'nango' && tokens.has(key)) throw usage(`--mcp-token-env ${key}: a Nango session brings its own token.`);
|
|
224
|
+
if (flags['nango-user'] !== undefined && ![...bindings.values()].some((binding) => binding.kind === 'nango')) throw usage('--nango-user goes with --bind <source>=nango:<integration>.');
|
|
225
|
+
return {
|
|
226
|
+
endUserId: flags['nango-user'] || 'local-dev-user',
|
|
227
|
+
sources: [...used.keys()].map((key) => {
|
|
228
|
+
const source = declarations.sources[key];
|
|
229
|
+
return {
|
|
230
|
+
name: key, title: source.title, url: source.url, binding: bindings.get(key) || null,
|
|
231
|
+
allow: [...used.get(key)], readOnly: vouched.get(key) || [], declared: source.tools, target: source.target,
|
|
232
|
+
targetValue: targets.get(key), tokenEnv: tokens.get(key) || null, uses: declarations.uses,
|
|
233
|
+
};
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* An address the factory declares is reached only when it is public: a host
|
|
240
|
+
* name on the normal ports that resolves to no local or private address
|
|
241
|
+
* (section 5.5 of the format). The check is made before any connection.
|
|
242
|
+
* An address the person gives with --bind is theirs to choose.
|
|
243
|
+
*/
|
|
244
|
+
export async function checkDeclaredAddress(source, { lookupImpl = (host) => lookup(host, { all: true, verbatim: true }) } = {}) {
|
|
245
|
+
const host = hostOf(source.url);
|
|
246
|
+
const refuse = (why) => usage(`The address the factory declares for the source "${source.name}" (${host}) ${why}, so nothing was connected. Bind it yourself with --bind ${source.name}=<url> if you mean to reach it.`);
|
|
247
|
+
if (!isPublicAddress(source.url)) throw refuse('is not a public address');
|
|
248
|
+
let answers;
|
|
249
|
+
try { answers = await lookupImpl(new URL(source.url).hostname); } catch { throw refuse('could not be found'); }
|
|
250
|
+
if (!answers?.length || answers.some((answer) => isPrivateIp(answer.address))) throw refuse('points at this machine or a private network');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What the factory asks for, for the person to read before granting anything. */
|
|
254
|
+
export function consentText({ title, version, hash, plan, provider }) {
|
|
255
|
+
const lines = [`${displayLine(title, 120)} v${displayLine(version, 40)} (sha256 ${hash.slice(0, 12)}) asks to use these sources. Nothing is connected until each one is granted:`];
|
|
256
|
+
for (const source of plan.sources) {
|
|
257
|
+
const binding = source.binding;
|
|
258
|
+
const where = !binding ? (source.url ? `${hostOf(source.url)}, the address the factory declares` : 'no address yet: the factory names only a registry entry')
|
|
259
|
+
: binding.kind === 'nango' ? `a Nango agent session for ${binding.integration}, limited to the tools below`
|
|
260
|
+
: `${hostOf(binding.url)}${binding.granted === 'declared' ? ', the address the factory declares' : ', the address you gave'}`;
|
|
261
|
+
lines.push('', ` ${source.name}: ${displayLine(source.title, 100)}`, ` reached at: ${where}`);
|
|
262
|
+
if (source.target) lines.push(` limited to one ${displayLine(source.target.label, 40)}: ${source.targetValue === undefined ? '(not given yet)' : displayLine(source.targetValue, 200)}${source.target.help ? `. ${displayLine(source.target.help, 200)}` : ''}`);
|
|
263
|
+
for (const tool of source.allow) {
|
|
264
|
+
const declared = source.declared[tool];
|
|
265
|
+
const steps = source.uses[`${source.name}/${tool}`] || [];
|
|
266
|
+
lines.push(` ${source.name}/${tool} "${displayLine(declared.title, 100)}" the factory says it ${PLAIN[declared.effect]} used by: ${steps.map((step) => displayLine(step, 100)).join(', ')}${source.target && !declared.target ? ` (not limited to one ${displayLine(source.target.label, 40)})` : ''}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
lines.push('', ' A source receives the arguments the model writes, which can include the inputs a step sees.',
|
|
270
|
+
` What a tool returns goes to the model provider: ${displayLine(provider, 200)}. Only tools that read can run, and a grant lasts for this run only.`);
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// Shared bits for the commands: styling, output and the engine imports.
|
|
2
|
+
import { hubClient, checkedBase, DEFAULT_HUB } from '../../engine/lib/hub.mjs';
|
|
3
|
+
import { resolveToken } from '../../engine/lib/credentials.mjs';
|
|
4
|
+
import { usage as usageError } from '../../engine/lib/node.mjs';
|
|
2
5
|
export * as engine from '../../engine/src/factory-engine.mjs';
|
|
3
6
|
export { readFactory, readFolder, writeFiles, listFactoryDirs, usage, HOUSE_FACTORIES } from '../../engine/lib/node.mjs';
|
|
4
7
|
export { summarize, canonicalFiles, matches } from '../../engine/lib/catalog.mjs';
|
|
@@ -19,3 +22,37 @@ export function table(rows) {
|
|
|
19
22
|
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => String(row[column] ?? '').length)));
|
|
20
23
|
return rows.map((row) => row.map((cell, column) => (column === row.length - 1 ? String(cell ?? '') : String(cell ?? '').padEnd(widths[column]))).join(' ').trimEnd()).join('\n');
|
|
21
24
|
}
|
|
25
|
+
|
|
26
|
+
// ---- the factoryhub site ----
|
|
27
|
+
|
|
28
|
+
export { DEFAULT_HUB };
|
|
29
|
+
export const BASE_URL_HELP = `the factoryhub address (default: FACTORYHUB_BASE_URL or ${DEFAULT_HUB})`;
|
|
30
|
+
|
|
31
|
+
/** The hub address a command talks to. Tokens travel only over https, or http to this machine. */
|
|
32
|
+
export function hubBase(flags, io) {
|
|
33
|
+
try {
|
|
34
|
+
return checkedBase(flags['base-url'] || io.env.FACTORYHUB_BASE_URL || DEFAULT_HUB);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw usageError(error.message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A date for people: 2027-02-13. */
|
|
41
|
+
export const day = (iso) => (iso ? String(iso).slice(0, 10) : 'never');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A hub client with the token from FACTORYHUB_TOKEN or `factoryhub login`.
|
|
45
|
+
* With `needToken`, refuses before any request when there is none.
|
|
46
|
+
*/
|
|
47
|
+
export async function connect(flags, io, { needToken = false } = {}) {
|
|
48
|
+
const base = hubBase(flags, io);
|
|
49
|
+
const credential = await resolveToken(base, io.env);
|
|
50
|
+
if (credential.warning) io.stderr.write(`${styles(io).yellow(credential.warning)}\n`);
|
|
51
|
+
if (needToken && !credential.token) {
|
|
52
|
+
throw new Error(credential.expired
|
|
53
|
+
? `Your login to ${base} expired on ${day(credential.record.expires)}. Run "factoryhub login" to log in again.`
|
|
54
|
+
: `You are not logged in to ${base}. Run "factoryhub login" first, or set FACTORYHUB_TOKEN.`);
|
|
55
|
+
}
|
|
56
|
+
const hub = hubClient({ baseUrl: base, env: io.env, fetchImpl: io.fetch, token: credential.token, tokenSource: credential.source, tokenScopes: credential.record?.scopes || null });
|
|
57
|
+
return { hub, credential, base };
|
|
58
|
+
}
|