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
@@ -0,0 +1,121 @@
1
+ // Where the factoryhub CLI and SDK keep personal access tokens.
2
+ //
3
+ // FACTORYHUB_TOKEN wins when it is set
4
+ // ${XDG_CONFIG_HOME:-~/.config}/factoryhub/credentials.json otherwise
5
+ //
6
+ // The file maps each hub address to the token `factoryhub login` stored for
7
+ // it, with the token's name, scopes, expiry and origin: "device" for a token
8
+ // this computer received by device login, "pasted" for one the person passed
9
+ // in. On macOS and Linux only the person may read it (mode 0600, in a 0700
10
+ // folder), and a file others can read is refused, as ssh refuses a readable
11
+ // key. On Windows no permissions are set: the file is as private as the
12
+ // person's user folder. Nothing here prints or logs a token.
13
+ //
14
+ // FACTORYHUB_TOKEN goes only to the production hub, or to the hub named in
15
+ // FACTORYHUB_BASE_URL: a --base-url pointing elsewhere does not receive it.
16
+ import { readFile, writeFile, rename, mkdir, chmod, stat, rm } from 'node:fs/promises';
17
+ import { homedir } from 'node:os';
18
+ import { join, dirname } from 'node:path';
19
+ import { DEFAULT_HUB, TOKEN_PATTERN } from './hub.mjs';
20
+
21
+ export const TOKEN_ENV = 'FACTORYHUB_TOKEN';
22
+ export { TOKEN_PATTERN };
23
+ const POSIX = process.platform !== 'win32';
24
+
25
+ /** How private the credentials file is on this computer, for messages. */
26
+ export const PRIVACY = POSIX
27
+ ? 'readable only by you (mode 0600)'
28
+ : 'protected only by your Windows user folder\'s permissions: factoryhub does not set file permissions on Windows';
29
+
30
+ /** The credentials file for this environment. */
31
+ export function credentialsPath(env = process.env) {
32
+ const config = env.XDG_CONFIG_HOME || join(env.HOME || env.USERPROFILE || homedir(), '.config');
33
+ return join(config, 'factoryhub', 'credentials.json');
34
+ }
35
+
36
+ async function readAll(env) {
37
+ const path = credentialsPath(env);
38
+ let info;
39
+ try { info = await stat(path); } catch (error) {
40
+ if (error.code === 'ENOENT') return {};
41
+ throw error;
42
+ }
43
+ if (POSIX && (info.mode & 0o077)) throw new Error(`${path} can be read by other users of this computer, so factoryhub will not use it. Run "chmod 600 ${path}" and try again.`);
44
+ let parsed;
45
+ try { parsed = JSON.parse(await readFile(path, 'utf8')); } catch {
46
+ throw new Error(`${path} is not valid JSON. Delete it and run "factoryhub login" again.`);
47
+ }
48
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
49
+ }
50
+
51
+ async function writeAll(env, all) {
52
+ const path = credentialsPath(env);
53
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
+ // Written beside the file and renamed over it, so a crash never leaves half
55
+ // a file, and created 0600 so the token is never readable by others.
56
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
57
+ try {
58
+ await writeFile(temporary, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
59
+ if (POSIX) await chmod(temporary, 0o600);
60
+ await rename(temporary, path);
61
+ } catch (error) {
62
+ await rm(temporary, { force: true });
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ /** The stored record for one hub, or null. */
68
+ export async function storedCredential(base, env = process.env) {
69
+ const record = (await readAll(env))[base];
70
+ return record && typeof record.token === 'string' ? record : null;
71
+ }
72
+
73
+ /**
74
+ * Stores the token `login` received for one hub, replacing any earlier one.
75
+ * `record.origin` is "device" for a token received by device login on this
76
+ * computer, "pasted" for one passed in; only a "device" token is ever revoked
77
+ * when a later login replaces it.
78
+ */
79
+ export async function saveCredential(base, record, env = process.env) {
80
+ if (!TOKEN_PATTERN.test(record?.token || '')) throw new Error('That is not a factoryhub personal access token.');
81
+ if (!['device', 'pasted'].includes(record.origin)) throw new Error('A stored token needs its origin: "device" or "pasted".');
82
+ const all = await readAll(env);
83
+ all[base] = { ...record, saved: new Date().toISOString() };
84
+ await writeAll(env, all);
85
+ return credentialsPath(env);
86
+ }
87
+
88
+ /** Forgets the token stored for one hub. True when there was one. */
89
+ export async function deleteCredential(base, env = process.env) {
90
+ const all = await readAll(env);
91
+ if (!all[base]) return false;
92
+ delete all[base];
93
+ if (Object.keys(all).length) await writeAll(env, all);
94
+ else await rm(credentialsPath(env), { force: true });
95
+ return true;
96
+ }
97
+
98
+ /** The same hub address, written either way: no trailing slash. */
99
+ const sameBase = (a, b) => Boolean(a && b) && String(a).replace(/\/+$/, '') === String(b).replace(/\/+$/, '');
100
+
101
+ /** Whether FACTORYHUB_TOKEN may be sent to `base`: production, or the hub FACTORYHUB_BASE_URL names. */
102
+ export function envTokenGoesTo(base, env = process.env) {
103
+ return sameBase(base, DEFAULT_HUB) || sameBase(base, env.FACTORYHUB_BASE_URL);
104
+ }
105
+
106
+ /**
107
+ * The token to send to one hub: FACTORYHUB_TOKEN, else the stored one.
108
+ * FACTORYHUB_TOKEN is used only for production or the hub FACTORYHUB_BASE_URL
109
+ * names; for any other hub it is left out and `warning` says so.
110
+ * Returns { token, source: 'env' | 'file' | null, record, expired, warning }.
111
+ */
112
+ export async function resolveToken(base, env = process.env, now = Date.now()) {
113
+ const fromEnv = (env[TOKEN_ENV] || '').trim();
114
+ let warning = null;
115
+ if (fromEnv && envTokenGoesTo(base, env)) return { token: fromEnv, source: 'env', record: null, expired: false, warning };
116
+ if (fromEnv) warning = `FACTORYHUB_TOKEN was not sent to ${String(base).replace(/\/+$/, '')}: it goes only to ${DEFAULT_HUB} or to the address in FACTORYHUB_BASE_URL.`;
117
+ const record = await storedCredential(base, env);
118
+ if (!record) return { token: null, source: null, record: null, expired: false, warning };
119
+ const expired = Boolean(record.expires) && Date.parse(record.expires) <= now;
120
+ return { token: expired ? null : record.token, source: 'file', record, expired, warning };
121
+ }
@@ -0,0 +1,119 @@
1
+ // Hosted runs on a factoryhub site, through /api/v1: follow a run's events,
2
+ // stop where it waits for the person, and read the whole result with its
3
+ // signed receipt. Shared by the CLI and the SDK. Nothing here decides a step:
4
+ // a decision is sent only when the caller passes one the person made.
5
+ import { randomUUID } from 'node:crypto';
6
+ import { verifyReceipt, fromBase64Url } from '../src/factory-engine.mjs';
7
+ import { DEFAULT_HUB } from './hub.mjs';
8
+
9
+ /**
10
+ * The receipt signing keys of the production hub, pinned by the SHA-256 of
11
+ * the raw Ed25519 public key. A receipt from https://factory.mindburn.org
12
+ * verifies only against these, whatever its key endpoint answers; a new key
13
+ * needs a release. Read from
14
+ * https://factory.mindburn.org/.well-known/factory-hub-keys.json:
15
+ * hub-20a58d31af55 created 2026-11-10T14:17:31.177Z
16
+ */
17
+ export const PINNED_KEYS = Object.freeze({
18
+ [DEFAULT_HUB]: Object.freeze(['3c2acc0709aef8748b5dd8b8ea6574a4ddbdd9911096fc6b89242425f6a9e62d']),
19
+ });
20
+
21
+ /** The SHA-256 of a published key's raw public key bytes, or null when it is not one. */
22
+ export async function keyFingerprint(key) {
23
+ try {
24
+ const raw = fromBase64Url(String(key?.public_key || ''));
25
+ if (raw.length !== 32) return null;
26
+ return [...new Uint8Array(await crypto.subtle.digest('SHA-256', raw))].map((byte) => byte.toString(16).padStart(2, '0')).join('');
27
+ } catch { return null; }
28
+ }
29
+
30
+ /**
31
+ * Checks a hosted run's receipt the way the CLI and SDK report it:
32
+ * - the signature, against the pinned keys for the production hub, or
33
+ * against the keys the hub at `base` publishes for any other address;
34
+ * - the receipt's issuer, against the hub's host;
35
+ * - with `text`, the deliverable against the hash the receipt records.
36
+ * `keys` are the hub's published keys; `pinned` overrides PINNED_KEYS (tests).
37
+ * Returns verifyReceipt's answer plus { trust, host, issuer, issuerMatches, verified }.
38
+ * `trust` is "pinned" or "published"; `verified` is the one answer to act on.
39
+ */
40
+ export async function checkHostedReceipt(receipt, { base, keys, text, pinned = PINNED_KEYS } = {}) {
41
+ const url = new URL(base);
42
+ const host = url.host;
43
+ const pins = pinned[url.origin] || null;
44
+ let usable = Array.isArray(keys) ? keys : [];
45
+ if (pins) {
46
+ const fingerprints = await Promise.all(usable.map(keyFingerprint));
47
+ usable = usable.filter((_, index) => fingerprints[index] && pins.includes(fingerprints[index]));
48
+ }
49
+ const result = await verifyReceipt(receipt, usable, text);
50
+ if (pins && result.signed && !usable.some((key) => key.key_id === result.keyId)) {
51
+ result.problems = result.problems.map((problem) => (problem.startsWith('No published key with id') ? `Key ${result.keyId} is not one of the production keys pinned in this version of factoryhub.` : problem));
52
+ }
53
+ const issuer = receipt && typeof receipt === 'object' ? receipt.issuer ?? null : null;
54
+ const issuerMatches = issuer === host;
55
+ if (receipt?.kind === 'factory-run-receipt' && !issuerMatches) result.problems.push(`The receipt names its issuer as ${issuer === null ? 'nobody' : `"${issuer}"`}, not ${host}.`);
56
+ return { ...result, trust: pins ? 'pinned' : 'published', host, issuer, issuerMatches, verified: result.signatureValid && result.deliverableMatches !== false && issuerMatches };
57
+ }
58
+
59
+ /** One line for a person: what the receipt check found. */
60
+ export function receiptVerdict(verification) {
61
+ if (verification.verified) {
62
+ return verification.trust === 'pinned'
63
+ ? `signature valid against a production key pinned in factoryhub (${verification.keyId})`
64
+ : `signature valid against keys published by ${verification.host}`;
65
+ }
66
+ return `not verified: ${verification.problems.join(' ') || 'unknown reason'}`;
67
+ }
68
+
69
+ /** Run states after which nothing more happens. */
70
+ export const FINISHED = new Set(['done', 'failed', 'stopped', 'expired', 'deleted', 'cancelled', 'canceled']);
71
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
72
+
73
+ /** An Idempotency-Key for one start: the same key retried returns the same run. */
74
+ export const newIdempotencyKey = () => `fh-${randomUUID()}`;
75
+
76
+ /**
77
+ * Polls a run until it finishes or waits for a decision. Calls onEvent for
78
+ * every new event, in order. Returns the last run view.
79
+ */
80
+ export async function followRun(hub, id, { onEvent = () => {}, sleep = wait, every = 2000, after = 0 } = {}) {
81
+ let cursor = after;
82
+ let shown = -1;
83
+ for (;;) {
84
+ const run = await hub.getRun(id, { after: cursor });
85
+ for (const event of run.events || []) {
86
+ if (Number.isInteger(event.i) && event.i <= shown) continue;
87
+ if (Number.isInteger(event.i)) shown = event.i;
88
+ await onEvent(event, run);
89
+ }
90
+ if (Number.isInteger(run.cursor) && run.cursor > cursor) cursor = run.cursor;
91
+ if (run.waiting || FINISHED.has(run.status)) return run;
92
+ await sleep(every);
93
+ }
94
+ }
95
+
96
+ const MAX_PAGES = 1000;
97
+
98
+ /**
99
+ * The whole result of a finished run, paged in, with the receipt checked
100
+ * here (checkHostedReceipt), not only by the hub. Returns null when the run
101
+ * is not done or the hub sent no result.
102
+ * Returns { text, receipt, verification, review, sha256 }.
103
+ */
104
+ export async function runResult(hub, id, { keys, pinned } = {}) {
105
+ let run = await hub.getRun(id, { offset: 0 });
106
+ if (run?.status !== 'done' || !run.result || typeof run.result.text !== 'string') return null;
107
+ const receipt = run.receipt || null;
108
+ let text = run.result.text;
109
+ for (let pages = 1; Number.isInteger(run.result.nextOffset) && run.result.nextOffset > 0; pages += 1) {
110
+ if (pages >= MAX_PAGES) throw new Error(`The result of run ${id} did not end after ${MAX_PAGES} pages.`);
111
+ run = await hub.getRun(id, { offset: run.result.nextOffset });
112
+ if (!run?.result || typeof run.result.text !== 'string') throw new Error(`The hub stopped sending the result of run ${id} part way.`);
113
+ text += run.result.text;
114
+ }
115
+ const verification = receipt
116
+ ? await checkHostedReceipt(receipt, { base: hub.base, keys: keys || await hub.keys(), text, pinned })
117
+ : { signed: false, signatureValid: false, deliverableMatches: null, keyId: null, trust: null, host: new URL(hub.base).host, issuer: null, issuerMatches: false, verified: false, problems: ['The hub sent no receipt.'] };
118
+ return { text, receipt, verification, review: receipt?.deliverable_review?.status || run.review || 'unknown', sha256: run.result.sha256 || null };
119
+ }