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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// factories from any MCP client. It runs locally over stdio. Runs use the
|
|
3
3
|
// model key in this process's environment, never one of ours, and stop at
|
|
4
4
|
// every human decision: the client must ask the person and call resume_run.
|
|
5
|
+
import * as fsp from 'node:fs/promises';
|
|
5
6
|
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
6
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
8
|
import { join, resolve, delimiter } from 'node:path';
|
|
@@ -43,19 +44,52 @@ function failure(error) {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Creates the server. Options exist for tests and embedding:
|
|
46
|
-
* env (keys, FACTORYHUB_FACTORIES, FACTORYHUB_RUNS_DIR), fetchImpl, factoryDirs, runsDir
|
|
47
|
+
* env (keys, FACTORYHUB_FACTORIES, FACTORYHUB_RUNS_DIR), fetchImpl, factoryDirs, runsDir,
|
|
48
|
+
* fs (the file system calls local factories are read with).
|
|
47
49
|
*/
|
|
48
|
-
export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs, runsDir } = {}) {
|
|
50
|
+
export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs, runsDir, fs = fsp } = {}) {
|
|
49
51
|
const dirs = factoryDirs || [HOUSE_FACTORIES, ...String(env.FACTORYHUB_FACTORIES || '').split(delimiter).filter(Boolean).map((dir) => resolve(dir))];
|
|
50
52
|
const runs = new Map();
|
|
51
53
|
const runFolder = runsDir || env.FACTORYHUB_RUNS_DIR || join(homedir(), '.factoryhub', 'runs');
|
|
52
54
|
|
|
53
55
|
async function localIds() {
|
|
54
56
|
const seen = new Map();
|
|
55
|
-
|
|
57
|
+
const lists = await Promise.all(dirs.map((dir) => listFactoryDirs(dir)));
|
|
58
|
+
dirs.forEach((dir, index) => { for (const id of lists[index]) if (!seen.has(id)) seen.set(id, join(dir, id)); });
|
|
56
59
|
return seen;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
// The folder of a local factory id, as localIds() would map it: the first
|
|
63
|
+
// factory directory with an entry of exactly that name that is a folder
|
|
64
|
+
// holding a factory.yaml. Only that entry is checked, not every folder, and
|
|
65
|
+
// an id that is not an entry name (such as "../x") is never joined to a path.
|
|
66
|
+
async function localLocation(id) {
|
|
67
|
+
for (const dir of dirs) {
|
|
68
|
+
const names = await fs.readdir(dir).catch(() => []);
|
|
69
|
+
if (!names.includes(id)) continue;
|
|
70
|
+
const location = join(dir, id);
|
|
71
|
+
const folder = await fs.lstat(location).then((stats) => stats.isDirectory(), () => false);
|
|
72
|
+
if (folder && existsSync(join(location, 'factory.yaml'))) return location;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Local factories are read and summarized again only when one of their
|
|
78
|
+
// files changed. A folder's signature is the time and size of every file
|
|
79
|
+
// readFolder would read, found by the same walk with a stat in place of
|
|
80
|
+
// each read; an edit, an added or a removed file changes it.
|
|
81
|
+
const cache = new Map();
|
|
82
|
+
const statFs = { readdir: fs.readdir, readFile: async (path) => { const stats = await fs.stat(path); return `${stats.mtimeMs}:${stats.size}`; } };
|
|
83
|
+
async function readLocal(id, location) {
|
|
84
|
+
const signature = JSON.stringify((await readFolder(location, { fs: statFs })).files);
|
|
85
|
+
const cached = cache.get(location);
|
|
86
|
+
if (cached?.signature === signature) return cached;
|
|
87
|
+
const { files, skipped } = await readFolder(location, { fs });
|
|
88
|
+
const entry = { signature, files, skipped, summary: await summarize(files, { id }) };
|
|
89
|
+
cache.set(location, entry);
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
|
|
59
93
|
async function resolveFactory({ id, path, source = 'local' }) {
|
|
60
94
|
if (path) {
|
|
61
95
|
const read = await readFactory(path, { fetchImpl });
|
|
@@ -66,10 +100,10 @@ export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs
|
|
|
66
100
|
const got = await hubClient({ env, fetchImpl }).get(id);
|
|
67
101
|
return { id, files: got.files, location: got.source, leftOut: got.leftOut };
|
|
68
102
|
}
|
|
69
|
-
const location =
|
|
103
|
+
const location = await localLocation(id);
|
|
70
104
|
if (!location) throw new Error(`No local factory "${id}". Call list_factories to see the ids, or pass source "hub" or a path.`);
|
|
71
|
-
const { files, skipped } = await
|
|
72
|
-
return { id, files, location, leftOut: skipped };
|
|
105
|
+
const { files, skipped, summary } = await readLocal(id, location);
|
|
106
|
+
return { id, files, location, leftOut: skipped, summary };
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
async function saveRun(state) {
|
|
@@ -131,11 +165,8 @@ export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs
|
|
|
131
165
|
let entries;
|
|
132
166
|
if (source === 'hub') entries = (await hubClient({ env, fetchImpl }).search(query || '')).map((entry) => ({ id: entry.id, title: entry.title, description: entry.description, version: entry.version, tags: entry.tags || [], runnable: entry.runnable, source: 'hub' }));
|
|
133
167
|
else {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const summary = await summarize((await readFolder(location)).files, { id });
|
|
137
|
-
if (matches(summary, query)) entries.push({ id, title: summary.title, description: summary.description, version: summary.version, tags: summary.tags, runnable: summary.runnable, inputs: summary.inputs.map((input) => input.key), steps: summary.steps.length, source: 'local', path: location });
|
|
138
|
-
}
|
|
168
|
+
const found = await Promise.all([...await localIds()].map(async ([id, location]) => ({ id, location, summary: (await readLocal(id, location)).summary })));
|
|
169
|
+
entries = found.filter(({ summary }) => matches(summary, query)).map(({ id, location, summary }) => ({ id, title: summary.title, description: summary.description, version: summary.version, tags: summary.tags, runnable: summary.runnable, inputs: summary.inputs.map((input) => input.key), steps: summary.steps.length, source: 'local', path: location }));
|
|
139
170
|
}
|
|
140
171
|
return result({ factories: entries }, `${entries.length} ${entries.length === 1 ? 'factory' : 'factories'}.`);
|
|
141
172
|
} catch (error) { return failure(error); }
|
|
@@ -149,7 +180,7 @@ export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs
|
|
|
149
180
|
}, async (args) => {
|
|
150
181
|
try {
|
|
151
182
|
const found = await resolveFactory(args);
|
|
152
|
-
const summary = await summarize(found.files, { id: found.id });
|
|
183
|
+
const summary = found.summary || await summarize(found.files, { id: found.id });
|
|
153
184
|
return result({ summary, files: canonicalFiles(found.files), location: found.location, left_out: found.leftOut }, `${summary.title} v${summary.version}.`);
|
|
154
185
|
} catch (error) { return failure(error); }
|
|
155
186
|
});
|
|
@@ -245,11 +276,10 @@ export function createServer({ env = process.env, fetchImpl = fetch, factoryDirs
|
|
|
245
276
|
|
|
246
277
|
server.registerResource('factory', new ResourceTemplate('factoryhub://factories/{id}', {
|
|
247
278
|
list: async () => {
|
|
248
|
-
const resources = []
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
279
|
+
const resources = await Promise.all([...await localIds()].map(async ([id, location]) => {
|
|
280
|
+
const { summary } = await readLocal(id, location);
|
|
281
|
+
return { uri: `factoryhub://factories/${id}`, name: id, title: summary.title, description: summary.description, mimeType: 'application/json' };
|
|
282
|
+
}));
|
|
253
283
|
return { resources };
|
|
254
284
|
},
|
|
255
285
|
}), { title: 'Factory bundle', description: 'A local factory as a transfer bundle: every file with its SHA-256.', mimeType: 'application/json' }, async (uri, { id }) => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@factoryhub/sdk",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The factoryhub SDK for JavaScript: log in, push, pull, publish and run factories on factoryhub, and verify run receipts. Published as the main export of the factoryhub package.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": true,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Mindburn-Labs/factoryhub.git",
|
|
14
|
+
"directory": "packages/sdk"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./src/index.d.ts",
|
|
19
|
+
"default": "./src/index.mjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Types for the factoryhub SDK (packages/sdk/src/index.mjs).
|
|
2
|
+
|
|
3
|
+
export type Scope = 'factories:read' | 'factories:write' | 'runs:start' | 'runs:read' | 'factories:publish' | 'runs:decide';
|
|
4
|
+
export const SCOPES: Scope[];
|
|
5
|
+
export const OPT_IN_SCOPES: Scope[];
|
|
6
|
+
export const DEFAULT_HUB: string;
|
|
7
|
+
|
|
8
|
+
export interface ConnectionOptions {
|
|
9
|
+
/** The hub address. Default: FACTORYHUB_BASE_URL or https://factory.mindburn.org. */
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/** A personal access token. Default: FACTORYHUB_TOKEN, then the saved login. */
|
|
12
|
+
token?: string;
|
|
13
|
+
/** The private-preview key. Default: FACTORYHUB_ACCESS_KEY. */
|
|
14
|
+
accessKey?: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
env?: Record<string, string | undefined>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class HubError extends Error {
|
|
20
|
+
status?: number;
|
|
21
|
+
code: string | null;
|
|
22
|
+
preview: boolean;
|
|
23
|
+
scope: string[] | null;
|
|
24
|
+
retryAfter: number | null;
|
|
25
|
+
details: Record<string, unknown> | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Account { id: string; handle: string; name: string | null; email?: string }
|
|
29
|
+
export interface TokenInfo { id: string; name: string; prefix: string; scopes: Scope[]; expires: string | null }
|
|
30
|
+
export interface DeviceCode { userCode: string; verificationUri: string; verificationUriComplete: string | null; expiresIn: number; scope: string | null }
|
|
31
|
+
|
|
32
|
+
export interface LoginOptions extends ConnectionOptions {
|
|
33
|
+
scopes?: Scope[];
|
|
34
|
+
name?: string;
|
|
35
|
+
days?: number;
|
|
36
|
+
/** Show the person the code and the page. Required for a device login. */
|
|
37
|
+
onCode?: (code: DeviceCode) => void | Promise<void>;
|
|
38
|
+
/** Store the token where the CLI finds it too. Default true. */
|
|
39
|
+
save?: boolean;
|
|
40
|
+
sleep?: (ms: number) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface LoginResult {
|
|
43
|
+
account: Account;
|
|
44
|
+
token: TokenInfo;
|
|
45
|
+
saved: string | null;
|
|
46
|
+
/** What happened to the token saved before: a device-login token is revoked, a passed-in one is kept. */
|
|
47
|
+
replaced: 'revoked' | 'not_revoked' | 'kept' | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PushResult {
|
|
51
|
+
factory: { id: string; url: string; title: string; visibility: string; mine: boolean; sha256: string };
|
|
52
|
+
parent: string | null;
|
|
53
|
+
unchanged: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface PullResult {
|
|
56
|
+
id: string;
|
|
57
|
+
files: Record<string, string>;
|
|
58
|
+
sha256: string;
|
|
59
|
+
source: string;
|
|
60
|
+
leftOut: string[];
|
|
61
|
+
/** True when `partial` let through a bundle that could not be checked whole. */
|
|
62
|
+
partial: boolean;
|
|
63
|
+
/** Why the bundle could not be checked whole, when `partial`. */
|
|
64
|
+
unchecked: string[];
|
|
65
|
+
dir: string | null;
|
|
66
|
+
/** Files an earlier pull wrote that `force` removed. */
|
|
67
|
+
removed: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface PullOptions {
|
|
70
|
+
dir?: string;
|
|
71
|
+
/** Replace an earlier pull: only files that pull recorded are removed. */
|
|
72
|
+
force?: boolean;
|
|
73
|
+
/** Accept a bundle that cannot be checked whole (no bundle hash, or files left out). */
|
|
74
|
+
partial?: boolean;
|
|
75
|
+
}
|
|
76
|
+
export interface PublishResult { factory: { id: string; visibility: string }; already: boolean; message: string }
|
|
77
|
+
export interface RunSummary { id: string; title: string; factoryId: string; started: string; status: string; url: string }
|
|
78
|
+
|
|
79
|
+
export interface RunEvent { i: number; at: string; type: string; step: string | null; status?: string; error?: string }
|
|
80
|
+
export interface Waiting {
|
|
81
|
+
approval_id: string;
|
|
82
|
+
step: string;
|
|
83
|
+
title: string;
|
|
84
|
+
ask: string;
|
|
85
|
+
artifact: string;
|
|
86
|
+
artifact_sha256: string;
|
|
87
|
+
revisionsLeft: number;
|
|
88
|
+
text: string;
|
|
89
|
+
complete: boolean;
|
|
90
|
+
totalChars: number;
|
|
91
|
+
}
|
|
92
|
+
export interface Decision { decision: 'approve' | 'revise' | 'reject'; note?: string }
|
|
93
|
+
|
|
94
|
+
export interface Verification {
|
|
95
|
+
/** The signature is valid, the deliverable (when given) matches, and the issuer is the hub (when checked against a hub). */
|
|
96
|
+
valid: boolean;
|
|
97
|
+
signed: boolean;
|
|
98
|
+
signatureValid: boolean;
|
|
99
|
+
deliverableMatches: boolean | null;
|
|
100
|
+
keyId: string | null;
|
|
101
|
+
problems: string[];
|
|
102
|
+
/** "pinned": a production key pinned in this package; "published": keys the hub at `host` publishes; "given": the keys passed in. */
|
|
103
|
+
trust: 'pinned' | 'published' | 'given' | null;
|
|
104
|
+
host?: string;
|
|
105
|
+
issuer?: string | null;
|
|
106
|
+
issuerMatches?: boolean;
|
|
107
|
+
}
|
|
108
|
+
export interface HostedVerification extends Omit<Verification, 'valid'> {
|
|
109
|
+
/** The one answer to act on: signature, deliverable and issuer all check out. */
|
|
110
|
+
verified: boolean;
|
|
111
|
+
host: string;
|
|
112
|
+
issuer: string | null;
|
|
113
|
+
issuerMatches: boolean;
|
|
114
|
+
}
|
|
115
|
+
export interface RunResult {
|
|
116
|
+
text: string;
|
|
117
|
+
receipt: Record<string, unknown> | null;
|
|
118
|
+
verification: HostedVerification;
|
|
119
|
+
review: string;
|
|
120
|
+
sha256: string | null;
|
|
121
|
+
}
|
|
122
|
+
export type RunOutcome =
|
|
123
|
+
| { status: 'done'; run: string; url: string; result: RunResult | null }
|
|
124
|
+
| { status: 'waiting'; run: string; url: string; waiting: Waiting }
|
|
125
|
+
| { status: string; run: string; url: string; error: string | null; stopped: { step: string; title: string; note: string } | null };
|
|
126
|
+
|
|
127
|
+
export interface RunOptions extends ConnectionOptions {
|
|
128
|
+
inputs?: Record<string, string>;
|
|
129
|
+
onEvent?: (event: RunEvent) => void | Promise<void>;
|
|
130
|
+
/** Return the person's own decision, or null to leave the run waiting. Needs runs:decide. An approval of a step whose text was not sent whole (`complete: false`) is refused. */
|
|
131
|
+
onDecision?: (waiting: Waiting) => Decision | null | Promise<Decision | null>;
|
|
132
|
+
idempotencyKey?: string;
|
|
133
|
+
every?: number;
|
|
134
|
+
sleep?: (ms: number) => Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ReceiptKey { key_id: string; public_key: string; alg?: string }
|
|
138
|
+
|
|
139
|
+
export class Hub {
|
|
140
|
+
constructor(options?: ConnectionOptions);
|
|
141
|
+
readonly baseUrl: string;
|
|
142
|
+
login(options?: LoginOptions & { token?: string }): Promise<LoginResult>;
|
|
143
|
+
logout(): Promise<boolean>;
|
|
144
|
+
whoami(): Promise<{ account: Account; token: TokenInfo }>;
|
|
145
|
+
push(source: string | Record<string, string>, options?: { parent?: string }): Promise<PushResult>;
|
|
146
|
+
pull(id: string, options?: PullOptions): Promise<PullResult>;
|
|
147
|
+
publish(id: string, options?: { note?: string }): Promise<PublishResult>;
|
|
148
|
+
runs(options?: { limit?: number }): Promise<RunSummary[]>;
|
|
149
|
+
run(id: string, options?: RunOptions): Promise<RunOutcome>;
|
|
150
|
+
keys(): Promise<ReceiptKey[]>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function login(options?: LoginOptions & { token?: string }): Promise<LoginResult>;
|
|
154
|
+
export function logout(options?: ConnectionOptions): Promise<boolean>;
|
|
155
|
+
export function whoami(options?: ConnectionOptions): Promise<{ account: Account; token: TokenInfo }>;
|
|
156
|
+
export function push(source: string | Record<string, string>, options?: ConnectionOptions & { parent?: string }): Promise<PushResult>;
|
|
157
|
+
export function pull(id: string, options?: ConnectionOptions & PullOptions): Promise<PullResult>;
|
|
158
|
+
export function publish(id: string, options?: ConnectionOptions & { note?: string }): Promise<PublishResult>;
|
|
159
|
+
export function run(id: string, options?: RunOptions): Promise<RunOutcome>;
|
|
160
|
+
export function runs(options?: ConnectionOptions & { limit?: number }): Promise<RunSummary[]>;
|
|
161
|
+
export function verifyReceipt(receipt: Record<string, unknown> | string, options?: ConnectionOptions & { keys?: ReceiptKey[]; deliverable?: string }): Promise<Verification>;
|
|
162
|
+
export function saveResult(result: RunResult, file: string): Promise<{ file: string; receipt: string | null }>;
|
|
163
|
+
export function credentialsPath(env?: Record<string, string | undefined>): string;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The factoryhub SDK for JavaScript: `import { login, push, run } from 'factoryhub'`.
|
|
2
|
+
//
|
|
3
|
+
// Every function takes the same connection options:
|
|
4
|
+
// baseUrl the hub address (default FACTORYHUB_BASE_URL or https://factory.mindburn.org)
|
|
5
|
+
// token a personal access token (default FACTORYHUB_TOKEN, then the login
|
|
6
|
+
// `factoryhub login` or login() saved for that hub)
|
|
7
|
+
// accessKey the preview key (default FACTORYHUB_ACCESS_KEY)
|
|
8
|
+
// fetch a fetch implementation (default the global one)
|
|
9
|
+
// env the environment to read those defaults from (default process.env)
|
|
10
|
+
//
|
|
11
|
+
// The SDK shares its token store with the CLI, so a login in either works for
|
|
12
|
+
// both. A token is sent only in the Authorization header, only over https or
|
|
13
|
+
// to this machine, and is never returned or logged; FACTORYHUB_TOKEN goes only
|
|
14
|
+
// to the production hub or the one FACTORYHUB_BASE_URL names. Redirects are
|
|
15
|
+
// followed only within the hub. Nothing here decides a step of a run for the
|
|
16
|
+
// person: run() asks the caller's onDecision, and refuses an approval of a
|
|
17
|
+
// step whose text was not sent whole.
|
|
18
|
+
//
|
|
19
|
+
// Receipts from https://factory.mindburn.org verify against the production
|
|
20
|
+
// keys pinned in this package; from any other hub, against the keys that hub
|
|
21
|
+
// publishes. The receipt's issuer must be the hub either way.
|
|
22
|
+
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { hostname } from 'node:os';
|
|
25
|
+
import { resolve, dirname } from 'node:path';
|
|
26
|
+
import { hubClient, deviceLogin, checkedBase, HubError, DEFAULT_HUB, SCOPES, OPT_IN_SCOPES, TOKEN_PATTERN } from '../../engine/lib/hub.mjs';
|
|
27
|
+
import { resolveToken, saveCredential, storedCredential, deleteCredential, credentialsPath } from '../../engine/lib/credentials.mjs';
|
|
28
|
+
import { followRun, runResult, newIdempotencyKey, checkHostedReceipt, FINISHED } from '../../engine/lib/hosted.mjs';
|
|
29
|
+
import { readFolder, writePulled } from '../../engine/lib/node.mjs';
|
|
30
|
+
import { parseBundle, withForeman, verifyReceipt as verifyWithKeys } from '../../engine/src/factory-engine.mjs';
|
|
31
|
+
|
|
32
|
+
export { HubError, DEFAULT_HUB, SCOPES, OPT_IN_SCOPES };
|
|
33
|
+
|
|
34
|
+
// https, or http to this machine only: no token or key is sent in the clear.
|
|
35
|
+
function baseOf(options) {
|
|
36
|
+
const env = options.env || process.env;
|
|
37
|
+
return checkedBase(options.baseUrl || env.FACTORYHUB_BASE_URL || DEFAULT_HUB);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A connection to one factoryhub site. The module functions below make one
|
|
42
|
+
* for each call; keep one with `new Hub(options)` to reuse it.
|
|
43
|
+
*/
|
|
44
|
+
export class Hub {
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.options = { ...options };
|
|
47
|
+
this.env = options.env || process.env;
|
|
48
|
+
this.baseUrl = baseOf(options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async #client({ needToken = false } = {}) {
|
|
52
|
+
let token = this.options.token || null;
|
|
53
|
+
let source = token ? 'option' : null;
|
|
54
|
+
let scopes = null;
|
|
55
|
+
if (!token) {
|
|
56
|
+
const found = await resolveToken(this.baseUrl, this.env);
|
|
57
|
+
if (found.expired && needToken) throw new HubError(`The saved login for ${this.baseUrl} expired on ${String(found.record.expires).slice(0, 10)}. Log in again with login().`, { code: 'unauthorized', status: 401 });
|
|
58
|
+
token = found.token;
|
|
59
|
+
source = found.source;
|
|
60
|
+
scopes = found.record?.scopes || null;
|
|
61
|
+
if (found.warning && token) process.emitWarning(found.warning, { code: 'FACTORYHUB_TOKEN_NOT_SENT' });
|
|
62
|
+
if (found.warning && needToken && !token) throw new HubError(`Not logged in to ${this.baseUrl}. ${found.warning}`, { code: 'unauthorized', status: 401 });
|
|
63
|
+
}
|
|
64
|
+
if (needToken && !token) throw new HubError(`Not logged in to ${this.baseUrl}: pass { token }, set FACTORYHUB_TOKEN, or call login() first.`, { code: 'unauthorized', status: 401 });
|
|
65
|
+
return hubClient({ baseUrl: this.baseUrl, accessKey: this.options.accessKey, env: this.env, fetchImpl: this.options.fetch || fetch, token, tokenSource: source === 'option' ? null : source, tokenScopes: scopes });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Logs in and stores the token where the CLI finds it too.
|
|
70
|
+
* With `token`, checks that token and stores it. Without, runs a device
|
|
71
|
+
* login: `onCode({ userCode, verificationUri, verificationUriComplete, expiresIn })`
|
|
72
|
+
* must show the person the code and the page; the promise resolves once
|
|
73
|
+
* they approve. Returns { account, token, saved, replaced } without the
|
|
74
|
+
* secret. A token an earlier device login saved is revoked when this one
|
|
75
|
+
* replaces it; a token that was passed in is never revoked.
|
|
76
|
+
*/
|
|
77
|
+
async login({ token, scopes, name, days, onCode, save = true, sleep } = {}) {
|
|
78
|
+
let secret = token;
|
|
79
|
+
const origin = secret === undefined ? 'device' : 'pasted';
|
|
80
|
+
if (secret === undefined) {
|
|
81
|
+
if (typeof onCode !== 'function') throw new HubError('A device login needs onCode, to show the person the code and the page.', { code: 'usage' });
|
|
82
|
+
const unknown = (scopes || []).filter((scope) => !SCOPES.includes(scope));
|
|
83
|
+
if (unknown.length) throw new HubError(`Unknown scope ${unknown.join(', ')}. Choose from: ${SCOPES.join(', ')}.`, { code: 'usage' });
|
|
84
|
+
const anonymous = hubClient({ baseUrl: this.baseUrl, accessKey: this.options.accessKey, env: this.env, fetchImpl: this.options.fetch || fetch });
|
|
85
|
+
const answer = await deviceLogin(anonymous, { name: name || `factoryhub SDK on ${hostname() || 'this computer'}`.slice(0, 60), scopes, days, onCode, sleep });
|
|
86
|
+
secret = answer.access_token;
|
|
87
|
+
} else if (!TOKEN_PATTERN.test(String(secret))) {
|
|
88
|
+
throw new HubError('That is not a factoryhub personal access token: they start with fhpat_ and are 49 characters long.', { code: 'usage' });
|
|
89
|
+
}
|
|
90
|
+
const me = await hubClient({ baseUrl: this.baseUrl, accessKey: this.options.accessKey, env: this.env, fetchImpl: this.options.fetch || fetch, token: secret }).me();
|
|
91
|
+
let saved = null;
|
|
92
|
+
let replaced = null;
|
|
93
|
+
if (save) {
|
|
94
|
+
const previous = await storedCredential(this.baseUrl, this.env);
|
|
95
|
+
saved = await saveCredential(this.baseUrl, { token: secret, origin, id: me.token.id, name: me.token.name, prefix: me.token.prefix, scopes: me.token.scopes, expires: me.token.expires, account: me.account.handle }, this.env);
|
|
96
|
+
if (previous?.token && previous.token !== secret) {
|
|
97
|
+
if (previous.origin !== 'device') replaced = 'kept';
|
|
98
|
+
else {
|
|
99
|
+
replaced = await hubClient({ baseUrl: this.baseUrl, accessKey: this.options.accessKey, env: this.env, fetchImpl: this.options.fetch || fetch, token: previous.token }).revoke().then(() => 'revoked', () => 'not_revoked');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.options.token = secret;
|
|
104
|
+
return { account: me.account, token: me.token, saved, replaced };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Revokes the stored token on the hub, then forgets it. True when there was one. */
|
|
108
|
+
async logout() {
|
|
109
|
+
const stored = await storedCredential(this.baseUrl, this.env);
|
|
110
|
+
if (!stored) return false;
|
|
111
|
+
try {
|
|
112
|
+
await hubClient({ baseUrl: this.baseUrl, accessKey: this.options.accessKey, env: this.env, fetchImpl: this.options.fetch || fetch, token: stored.token, tokenSource: 'file' }).revoke();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.status !== 401) throw error;
|
|
115
|
+
}
|
|
116
|
+
await deleteCredential(this.baseUrl, this.env);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The account and token the token acts for: { account, token }. */
|
|
121
|
+
async whoami() {
|
|
122
|
+
return (await this.#client({ needToken: true })).me();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Saves a factory to the account, privately. `source` is a folder path or a
|
|
127
|
+
* { path: content } map. The factory is checked here first, as the site
|
|
128
|
+
* checks it. Returns { factory: { id, url, title, visibility, sha256 }, parent, unchanged }.
|
|
129
|
+
*/
|
|
130
|
+
async push(source, { parent } = {}) {
|
|
131
|
+
const files = typeof source === 'string' ? (await readFolder(resolve(source))).files : source;
|
|
132
|
+
if (!files || typeof files !== 'object') throw new HubError('push takes a folder path or a { path: content } map of factory files.', { code: 'usage' });
|
|
133
|
+
const parsed = parseBundle(withForeman(files));
|
|
134
|
+
if (!parsed.ok) throw new HubError(`This is not a valid factory yet:\n${parsed.errors.map((error) => ` - ${error}`).join('\n')}`, { code: 'invalid_factory' });
|
|
135
|
+
return (await this.#client({ needToken: true })).push(files, { parent });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Downloads a factory the caller can open, checking every file's SHA-256
|
|
140
|
+
* and the files together against the bundle hash in the hub's answer:
|
|
141
|
+
* integrity against that answer, not a signature. A bundle that cannot be
|
|
142
|
+
* checked whole is refused unless `partial`. With `dir`, also writes the
|
|
143
|
+
* files into that folder, never through a symbolic link (refusing a
|
|
144
|
+
* non-empty one unless `force`; `force` removes only files an earlier pull
|
|
145
|
+
* wrote there). Returns { id, files, sha256, source, leftOut, partial, unchecked, dir, removed }.
|
|
146
|
+
*/
|
|
147
|
+
async pull(id, { dir, force = false, partial = false } = {}) {
|
|
148
|
+
const target = dir ? resolve(dir) : null;
|
|
149
|
+
if (target && !force && existsSync(target) && (await readdir(target)).length) throw new HubError(`${target} is not empty. Pass { force: true } to write into it.`, { code: 'usage' });
|
|
150
|
+
const factory = await (await this.#client()).pull(id, { partial });
|
|
151
|
+
const { removed } = target ? await writePulled(target, factory.files, { replace: force, record: { id: factory.id, sha256: factory.sha256, source: factory.source, partial: factory.partial } }) : { removed: [] };
|
|
152
|
+
return { ...factory, dir: target, removed };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Publishes one of the account's factories: it is public at once. Returns { factory, already, message }. */
|
|
156
|
+
async publish(id, { note = '' } = {}) {
|
|
157
|
+
return (await this.#client({ needToken: true })).publish(id, { note });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The account's recent hosted runs. */
|
|
161
|
+
async runs({ limit } = {}) {
|
|
162
|
+
return (await this.#client({ needToken: true })).runs({ limit });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Runs a factory on the hub and follows it.
|
|
167
|
+
* inputs { key: value }
|
|
168
|
+
* onEvent (event) => void, for progress
|
|
169
|
+
* onDecision async (waiting) => { decision: 'approve' | 'revise' | 'reject', note? } | null
|
|
170
|
+
* Called where the run waits for the person, with the exact
|
|
171
|
+
* text. Return the person's own decision, or null to leave
|
|
172
|
+
* the run waiting. Needs a token with the opt-in runs:decide.
|
|
173
|
+
* When the hub sent only part of the text (`complete: false`),
|
|
174
|
+
* an approval is refused: decide on the run page.
|
|
175
|
+
* Resolves to { status, run, url, result?, waiting? }: status 'done' with
|
|
176
|
+
* result { text, receipt, verification, review }, 'waiting' with the step,
|
|
177
|
+
* or 'failed' / 'stopped'.
|
|
178
|
+
*/
|
|
179
|
+
async run(id, { inputs = {}, onEvent, onDecision, idempotencyKey = newIdempotencyKey(), every = 2000, sleep } = {}) {
|
|
180
|
+
const hub = await this.#client({ needToken: true });
|
|
181
|
+
const started = await hub.startRun(id, inputs, { idempotencyKey });
|
|
182
|
+
const decided = new Set();
|
|
183
|
+
for (;;) {
|
|
184
|
+
const view = await followRun(hub, started.id, { onEvent: onEvent ? (event) => onEvent(event) : undefined, every, sleep });
|
|
185
|
+
const waiting = view.waiting;
|
|
186
|
+
const key = waiting ? `${waiting.approval_id}:${waiting.artifact_sha256}` : null;
|
|
187
|
+
if (key && decided.has(key)) { await (sleep || ((ms) => new Promise((done) => setTimeout(done, ms))))(every); continue; }
|
|
188
|
+
if (waiting) {
|
|
189
|
+
const choice = onDecision ? await onDecision(waiting) : null;
|
|
190
|
+
if (!choice) return { status: 'waiting', run: started.id, url: view.url, waiting };
|
|
191
|
+
if (choice.decision === 'approve' && waiting.complete !== true) {
|
|
192
|
+
throw new HubError(`Not approved: the hub sent only part of "${waiting.title}" (${waiting.totalChars} characters in all), so it cannot be approved from here. Read and decide on the run page: ${view.url}`, { code: 'incomplete' });
|
|
193
|
+
}
|
|
194
|
+
await hub.decide(view.id, { approvalId: waiting.approval_id, seenSha256: waiting.artifact_sha256, decision: choice.decision, note: choice.note || '' });
|
|
195
|
+
decided.add(key);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (view.status === 'done') return { status: 'done', run: started.id, url: view.url, result: await runResult(hub, started.id) };
|
|
199
|
+
return { status: FINISHED.has(view.status) ? view.status : 'unknown', run: started.id, url: view.url, error: view.error || null, stopped: view.stopped || null };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The hub's published receipt keys. */
|
|
204
|
+
async keys() {
|
|
205
|
+
return (await this.#client()).keys();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Checks a run receipt's Ed25519 signature over its canonical JSON and, with
|
|
211
|
+
* `deliverable`, that the text matches the hash it records. With `keys`, it
|
|
212
|
+
* runs offline against exactly those keys (trust "given"). Without, it checks
|
|
213
|
+
* the receipt as a receipt from the hub at `baseUrl`: on
|
|
214
|
+
* https://factory.mindburn.org against the production keys pinned in this
|
|
215
|
+
* package (trust "pinned"), elsewhere against the keys that hub publishes
|
|
216
|
+
* (trust "published"), and its issuer against the hub's host. `receipt` is an
|
|
217
|
+
* object, a JSON string or a path to a .receipt.json file.
|
|
218
|
+
* Returns { valid, signed, signatureValid, deliverableMatches, keyId, problems, trust, host?, issuerMatches? }.
|
|
219
|
+
*/
|
|
220
|
+
export async function verifyReceipt(receipt, { keys, deliverable, ...options } = {}) {
|
|
221
|
+
let value = receipt;
|
|
222
|
+
if (typeof value === 'string') value = value.trimStart().startsWith('{') ? JSON.parse(value) : JSON.parse(await readFile(value, 'utf8'));
|
|
223
|
+
if (keys) {
|
|
224
|
+
const result = await verifyWithKeys(value, keys, deliverable);
|
|
225
|
+
return { valid: result.signatureValid && result.deliverableMatches !== false, ...result, trust: 'given' };
|
|
226
|
+
}
|
|
227
|
+
const hub = new Hub(options);
|
|
228
|
+
const result = await checkHostedReceipt(value, { base: hub.baseUrl, keys: await hub.keys(), text: deliverable });
|
|
229
|
+
return { valid: result.verified, ...result };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Writes a finished run's result and receipt next to each other. */
|
|
233
|
+
export async function saveResult(result, file) {
|
|
234
|
+
const path = resolve(file);
|
|
235
|
+
await mkdir(dirname(path), { recursive: true });
|
|
236
|
+
await writeFile(path, result.text);
|
|
237
|
+
const receipt = `${path.replace(/\.md$/, '')}.receipt.json`;
|
|
238
|
+
if (result.receipt) await writeFile(receipt, `${JSON.stringify(result.receipt, null, 2)}\n`);
|
|
239
|
+
return { file: path, receipt: result.receipt ? receipt : null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const one = (method) => (first, second) => {
|
|
243
|
+
const takesTarget = ['push', 'pull', 'publish', 'run'].includes(method);
|
|
244
|
+
const options = (takesTarget ? second : first) || {};
|
|
245
|
+
const hub = new Hub(options);
|
|
246
|
+
return takesTarget ? hub[method](first, options) : hub[method](options);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Logs in; see Hub#login. */
|
|
250
|
+
export const login = one('login');
|
|
251
|
+
/** Revokes and forgets the stored token; see Hub#logout. */
|
|
252
|
+
export const logout = one('logout');
|
|
253
|
+
/** Who the token acts for; see Hub#whoami. */
|
|
254
|
+
export const whoami = one('whoami');
|
|
255
|
+
/** Saves a folder or files privately; see Hub#push. */
|
|
256
|
+
export const push = one('push');
|
|
257
|
+
/** Downloads and checks a factory; see Hub#pull. */
|
|
258
|
+
export const pull = one('pull');
|
|
259
|
+
/** Publishes a factory; see Hub#publish. */
|
|
260
|
+
export const publish = one('publish');
|
|
261
|
+
/** Runs a factory on the hub; see Hub#run. */
|
|
262
|
+
export const run = one('run');
|
|
263
|
+
/** The account's runs; see Hub#runs. */
|
|
264
|
+
export const runs = one('runs');
|
|
265
|
+
|
|
266
|
+
export { credentialsPath };
|
package/spec/README.md
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
| [`factory-format.md`](factory-format.md) | the specification, v1alpha1 |
|
|
6
6
|
| [`schema/factory.schema.json`](schema/factory.schema.json) | JSON Schema (2020-12) for `factory.yaml` |
|
|
7
7
|
| [`schema/flow.schema.json`](schema/flow.schema.json) | JSON Schema (2020-12) for `flow.yaml` |
|
|
8
|
+
| [`schema/factory.yaml.json`](schema/factory.yaml.json) | JSON Schema (draft-07) for a whole factory in one file, the [single-file form](factory-format.md#13-the-single-file-form) |
|
|
8
9
|
| [`skill-export.md`](skill-export.md) | how a factory becomes an Agent Skill folder |
|
|
9
10
|
| [`skill-export/`](skill-export) | the expected `SKILL.md` of every house factory |
|
|
10
|
-
| [`fixtures/`](fixtures) | good, bad, semantic and lenient factories that test the schemas against the engine |
|
|
11
|
+
| [`fixtures/`](fixtures) | good, bad, semantic, unimplemented and lenient factories that test the schemas against the engine |
|
|
12
|
+
| [`conformance/`](conformance) | what the reference engine gives for every fixture, house factory and edge case, for other implementations to match |
|
|
11
13
|
| [`test/`](test) | those tests |
|
|
12
14
|
| [`rfcs/`](rfcs) | proposals to change the format |
|
|
13
15
|
|
|
@@ -24,6 +26,10 @@ type:
|
|
|
24
26
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/Mindburn-Labs/factoryhub/main/spec/schema/flow.schema.json
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
A single-file factory names `schema/factory.yaml.json` the same way. Setup
|
|
30
|
+
for VS Code, Neovim, Helix, Zed and JetBrains IDEs is in
|
|
31
|
+
[docs/editor-setup.md](../docs/editor-setup.md).
|
|
32
|
+
|
|
27
33
|
The schemas check one file at a time. Rules that span steps and files, such as
|
|
28
34
|
an agent file existing for every step, are in
|
|
29
35
|
[section 6 of the spec](factory-format.md#6-validity-and-runnability);
|