@reclaimprotocol/agent 0.1.37 → 0.1.38
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 +1 -1
- package/lib/mcp/tools/agent/build.js +2 -0
- package/lib/mcp/tools/agent/example-app.d.ts +2 -0
- package/lib/mcp/tools/agent/example-app.js +167 -0
- package/package.json +2 -2
- package/skill/start-integration.md +6 -4
- package/skill/verification-client-integration.md +4 -4
- package/src/mcp/tools/agent/build.ts +2 -0
- package/src/mcp/tools/agent/example-app.ts +227 -0
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ The MCP server targets one of two backends. An explicit `USE_OLD_DEVTOOLS` setti
|
|
|
134
134
|
- `get_me_providers` — list the providers you own (the only provider-listing tool in this mode).
|
|
135
135
|
- `session_analytics_logs` — the milestone events for a verification session, which show how far it got.
|
|
136
136
|
- `session_logs` — the log entries the in-app SDK emitted during that session, each with its event type, log level, and logger name. Filter by event type, level, or substring, page with `limit` and `offset`, or set `saveTo` to write every matching entry to an NDJSON file. The backend searches only the last 3 days unless you pass `startTime` and `endTime`, and it deletes entries after 30 days.
|
|
137
|
-
- **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
|
|
137
|
+
- **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `create_example_app` writes the builder-example app into a workspace that has none; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
|
|
138
138
|
|
|
139
139
|
Relevant env vars: `RECLAIM_OLD_API_URL` (default `https://devapi.reclaimprotocol.org`), `RECLAIM_OLD_API_TOKEN` / `RECLAIM_OLD_ETH_UID` (headless identity), `RECLAIM_OLD_LOGIN_URL` (default `https://dev.reclaimprotocol.org`); builder mode uses `RECLAIM_API_URL` / `RECLAIM_API_TOKEN`.
|
|
140
140
|
|
|
@@ -17,6 +17,7 @@ import { captureTools, resetCaptures } from "./capture.js";
|
|
|
17
17
|
import { checkAttestorTool } from "./check-attestor.js";
|
|
18
18
|
import { draftTools } from "./draft-tools.js";
|
|
19
19
|
import { listTabsTool } from "./eval.js";
|
|
20
|
+
import { exampleAppTool } from "./example-app.js";
|
|
20
21
|
import { howItWorksTool } from "./how-it-works.js";
|
|
21
22
|
import { liveViewTools } from "./live-view.js";
|
|
22
23
|
import { podProofTools } from "./pod-proof.js";
|
|
@@ -81,6 +82,7 @@ export function buildAgentTools(client, builderClient = client, externalState) {
|
|
|
81
82
|
}
|
|
82
83
|
if (client) {
|
|
83
84
|
tools.push(startIntegrationTool());
|
|
85
|
+
tools.push(exampleAppTool());
|
|
84
86
|
tools.push(createVersionFromCaptureTool(client, state.attach, state.captures));
|
|
85
87
|
}
|
|
86
88
|
return tools;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create_example_app` — write reclaimprotocol/builder-example into the
|
|
3
|
+
* developer's workspace, so a project with no app starts from a ready-to-run
|
|
4
|
+
* backend and frontend instead of agent-written code. The MCP server fetches
|
|
5
|
+
* the files itself (each checked against its git blob hash) and writes them at
|
|
6
|
+
* the workspace root: the coding agent's later `npm install` and `npm run dev`
|
|
7
|
+
* then run in the workspace it started in, not in a clone. The backend's
|
|
8
|
+
* `@reclaimprotocol/client` is set to the latest published version.
|
|
9
|
+
*/
|
|
10
|
+
import { badGateway, badRequest, conflict } from '@hapi/boom';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
16
|
+
import { defineTool } from "../../server.js";
|
|
17
|
+
const REPO = 'reclaimprotocol/builder-example';
|
|
18
|
+
const BACKEND_PACKAGE = join('packages', 'backend', 'package.json');
|
|
19
|
+
const CLIENT_PACKAGE = '@reclaimprotocol/client';
|
|
20
|
+
/** The example is ~50 small files; anything far past that isn't it. */
|
|
21
|
+
const MAX_FILES = 500;
|
|
22
|
+
const MAX_BYTES = 10000000;
|
|
23
|
+
export function exampleAppTool(fetchFn = fetch) {
|
|
24
|
+
return defineTool({
|
|
25
|
+
name: 'create_example_app',
|
|
26
|
+
description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
|
|
27
|
+
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
28
|
+
+ 'verify callbacks, and render every verification state) into '
|
|
29
|
+
+ '`projectDir`, and sets its backend to the latest '
|
|
30
|
+
+ '`@reclaimprotocol/client`. Use it when the workspace has no app '
|
|
31
|
+
+ 'yet, with the workspace root you were started in as '
|
|
32
|
+
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
33
|
+
+ 'when a file it would write already exists with different '
|
|
34
|
+
+ 'content. Returns the commit, file count, client version, and '
|
|
35
|
+
+ 'next steps.',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
required: ['projectDir'],
|
|
39
|
+
properties: {
|
|
40
|
+
projectDir: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'Absolute path of the workspace root you were started in.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
annotations: { openWorldHint: true, destructiveHint: false },
|
|
47
|
+
}, async ({ projectDir }) => {
|
|
48
|
+
assert(isAbsolute(projectDir), badRequest('`projectDir` must be an absolute path.'));
|
|
49
|
+
const commit = await latestCommit();
|
|
50
|
+
const files = await listFiles(commit);
|
|
51
|
+
const clientVersion = await latestClientVersion();
|
|
52
|
+
const staged = await Promise.all(files.map(async (file) => {
|
|
53
|
+
const bytes = await download(commit, file);
|
|
54
|
+
return {
|
|
55
|
+
path: file.path,
|
|
56
|
+
bytes: file.path === BACKEND_PACKAGE
|
|
57
|
+
? withClientVersion(bytes, clientVersion)
|
|
58
|
+
: bytes,
|
|
59
|
+
};
|
|
60
|
+
}));
|
|
61
|
+
const conflicts = staged
|
|
62
|
+
.filter(({ path, bytes }) => {
|
|
63
|
+
const dest = join(projectDir, path);
|
|
64
|
+
return existsSync(dest) && !readFileSync(dest).equals(bytes);
|
|
65
|
+
})
|
|
66
|
+
.map(({ path }) => path);
|
|
67
|
+
assert(!conflicts.length, conflict('These files already exist with different content: '
|
|
68
|
+
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
69
|
+
+ 'another directory or move them.'));
|
|
70
|
+
// Stage inside projectDir, so the moves below stay on one filesystem.
|
|
71
|
+
const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'));
|
|
72
|
+
try {
|
|
73
|
+
for (const { path, bytes } of staged) {
|
|
74
|
+
await mkdir(dirname(join(tmp, path)), { recursive: true });
|
|
75
|
+
await writeFile(join(tmp, path), bytes);
|
|
76
|
+
}
|
|
77
|
+
for (const { path } of staged) {
|
|
78
|
+
const dest = join(projectDir, path);
|
|
79
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
80
|
+
await rename(join(tmp, path), dest);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await rm(tmp, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
projectDir,
|
|
88
|
+
repository: `https://github.com/${REPO}`,
|
|
89
|
+
commit,
|
|
90
|
+
files: staged.length,
|
|
91
|
+
clientVersion,
|
|
92
|
+
next: [
|
|
93
|
+
'Call issue_org_token with this projectDir and envFile '
|
|
94
|
+
+ '"packages/backend/.env".',
|
|
95
|
+
'Run `npm install`, then `npm run dev`, from projectDir. The '
|
|
96
|
+
+ 'backend listens on port 3000; the app opens at '
|
|
97
|
+
+ 'http://localhost:5173.',
|
|
98
|
+
'Call proxy_callback_events({ port: 3000, path: '
|
|
99
|
+
+ '"/reclaim/callback" }).',
|
|
100
|
+
'Put the provider in packages/backend/config.json and restart '
|
|
101
|
+
+ 'the backend.',
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
async function latestCommit() {
|
|
106
|
+
const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
|
|
107
|
+
assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
const commit = text.trim();
|
|
110
|
+
assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'));
|
|
111
|
+
return commit;
|
|
112
|
+
}
|
|
113
|
+
async function listFiles(commit) {
|
|
114
|
+
const res = await fetchFn(`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`, { headers: { Accept: 'application/vnd.github+json' } });
|
|
115
|
+
assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`));
|
|
116
|
+
const tree = await res.json();
|
|
117
|
+
assert(typeof tree === 'object' && tree !== null && 'tree' in tree
|
|
118
|
+
&& Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated), badGateway(`unexpected ${REPO} file listing`));
|
|
119
|
+
const files = tree.tree
|
|
120
|
+
.filter((e) => {
|
|
121
|
+
return typeof e === 'object' && e !== null && 'type' in e
|
|
122
|
+
&& e.type === 'blob';
|
|
123
|
+
})
|
|
124
|
+
.map((entry) => {
|
|
125
|
+
assert(typeof entry === 'object' && entry !== null
|
|
126
|
+
&& 'path' in entry && typeof entry.path === 'string'
|
|
127
|
+
&& entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
|
|
128
|
+
&& s !== '.' && s !== '..' && s !== '.git')
|
|
129
|
+
&& 'sha' in entry && typeof entry.sha === 'string'
|
|
130
|
+
&& 'size' in entry && typeof entry.size === 'number', badGateway(`unexpected entry in the ${REPO} file listing`));
|
|
131
|
+
return { path: entry.path, sha: entry.sha, size: entry.size };
|
|
132
|
+
});
|
|
133
|
+
assert(files.length > 0 && files.length <= MAX_FILES
|
|
134
|
+
&& files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES, badGateway(`unexpected ${REPO} size`));
|
|
135
|
+
return files;
|
|
136
|
+
}
|
|
137
|
+
async function download(commit, file) {
|
|
138
|
+
const res = await fetchFn(`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`);
|
|
139
|
+
assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`));
|
|
140
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
141
|
+
assert(gitBlobSha(bytes) === file.sha, badGateway(`${file.path} failed its integrity check`));
|
|
142
|
+
return bytes;
|
|
143
|
+
}
|
|
144
|
+
async function latestClientVersion() {
|
|
145
|
+
const res = await fetchFn(`https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`);
|
|
146
|
+
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
|
|
147
|
+
const body = await res.json();
|
|
148
|
+
assert(typeof body === 'object' && body !== null && 'version' in body
|
|
149
|
+
&& typeof body.version === 'string'
|
|
150
|
+
&& /^\d+\.\d+\.\d+$/.test(body.version), badGateway(`npm ${CLIENT_PACKAGE}: no version`));
|
|
151
|
+
return body.version;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The backend's package.json with the client range set to `^version`. */
|
|
155
|
+
function withClientVersion(bytes, version) {
|
|
156
|
+
const text = bytes.toString('utf8');
|
|
157
|
+
const next = text.replace(/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/, (_, open, close) => `${open}^${version}${close}`);
|
|
158
|
+
assert(next !== text || text.includes(`"^${version}"`), badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`));
|
|
159
|
+
return Buffer.from(next);
|
|
160
|
+
}
|
|
161
|
+
/** Git's blob object id: sha1 of `blob <size>\0<content>`. */
|
|
162
|
+
function gitBlobSha(bytes) {
|
|
163
|
+
return createHash('sha1')
|
|
164
|
+
.update(`blob ${bytes.length}\0`)
|
|
165
|
+
.update(bytes)
|
|
166
|
+
.digest('hex');
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reclaimprotocol/agent",
|
|
3
3
|
"title": "Reclaim Agent",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.38",
|
|
5
5
|
"description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@noble/hashes": "^2.2.0",
|
|
85
85
|
"@puppeteer/browsers": "3.2.4",
|
|
86
86
|
"@reclaimprotocol/attestor-core": "^5.1.6",
|
|
87
|
-
"@reclaimprotocol/client": "0.1.
|
|
87
|
+
"@reclaimprotocol/client": "0.1.38",
|
|
88
88
|
"@reclaimprotocol/tls": "^0.1.3",
|
|
89
89
|
"chrome-remote-interface": "^0.33.0",
|
|
90
90
|
"domhandler": "^6.0.0",
|
|
@@ -46,7 +46,7 @@ Build a new organization-private provider for exactly "{{goal}}". Do not search
|
|
|
46
46
|
|
|
47
47
|
Setup does not need the provider. Start it after step 2 has a working proof, or while you wait on the developer during step 2 (for example, while they sign in to the site). Don't let it delay a step that needs the developer.
|
|
48
48
|
|
|
49
|
-
**Delegation.**
|
|
49
|
+
**Delegation.** When the workspace already has an app and you can run subagents, hand this step to one subagent as soon as you are signed in, and keep working on step 2: step 2 needs the developer at the browser, step 3 does not. Give the subagent a self-contained brief:
|
|
50
50
|
|
|
51
51
|
```
|
|
52
52
|
Goal: let this app's users prove "{{goal}}" with Reclaim.
|
|
@@ -61,10 +61,12 @@ Without subagents, do step 2 first, and fill waits for the developer with this s
|
|
|
61
61
|
|
|
62
62
|
**No app yet?** If the workspace has no app to add verification to (it is empty, or has no backend or frontend), don't write one. Bootstrap from [builder-example](https://github.com/reclaimprotocol/builder-example), a ready-to-run Fastify backend and Vite frontend that already create sessions, verify callbacks, poll status, and render every UI state:
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
Do this yourself, not in a subagent.
|
|
65
|
+
|
|
66
|
+
- [ ] Call `create_example_app` with the workspace root you were started in as `projectDir`. It writes the example there and sets its backend to the latest `@reclaimprotocol/client`. Don't clone the repository or create a subdirectory: commands you run inside a clone count as running external code, and permission checks may block them.
|
|
67
|
+
- [ ] Call `issue_org_token` with the same `projectDir` and `envFile: "packages/backend/.env"`.
|
|
66
68
|
- [ ] Only when the developer asks for session binding or encrypted results: call `issue_org_keypair` with the same `projectDir` and `envFile` (add `encryptResults: true` for encrypted results). It writes `RECLAIM_ORG_ETH_PRIVATE_KEY` and registers the public key. Then pass `orgEthPrivateKey: process.env.RECLAIM_ORG_ETH_PRIVATE_KEY` to `reclaim.sessions.create` in `packages/backend/src/routes/verifications.ts` (binding) and to `reclaim.results.receive` in `packages/backend/src/routes/callback.ts` (decryption and TEE proof checks), and add an empty `RECLAIM_ORG_ETH_PRIVATE_KEY=` line to `packages/backend/example.env`.
|
|
67
|
-
- [ ] From the
|
|
69
|
+
- [ ] From the workspace root, run `npm install`, then `npm run dev`. The backend listens on port 3000; the app opens at <http://localhost:5173>. If a permission check blocks either command, don't work around it: end your turn, say which command was blocked and that it installs and runs the Reclaim example app, and ask the developer to approve it.
|
|
68
70
|
- [ ] Call `proxy_callback_events({ port: 3000, path: "/reclaim/callback" })`.
|
|
69
71
|
- [ ] Skip the rest of this step. In step 4, the provider goes in `packages/backend/config.json`: replace the example entry with `{ "id": "<provider ID>", "name": "<what it proves>" }`, then restart the backend, because it reads the file at startup.
|
|
70
72
|
|
|
@@ -14,10 +14,10 @@ proof works, or while you wait on the developer. When you need the developer
|
|
|
14
14
|
to answer or act, end your turn with the question and every link they need;
|
|
15
15
|
don't ask through a background or asynchronous question tool.
|
|
16
16
|
|
|
17
|
-
When the workspace has no app yet,
|
|
18
|
-
[builder-example](https://github.com/reclaimprotocol/builder-example)
|
|
19
|
-
`start_integration` plan describes, instead of writing a
|
|
20
|
-
frontend. Otherwise, inspect the app's frontend, backend, package manager,
|
|
17
|
+
When the workspace has no app yet, call `create_example_app` to write
|
|
18
|
+
[builder-example](https://github.com/reclaimprotocol/builder-example) into the
|
|
19
|
+
workspace root, as the `start_integration` plan describes, instead of writing a
|
|
20
|
+
new backend and frontend or cloning the repository. Otherwise, inspect the app's frontend, backend, package manager,
|
|
21
21
|
configuration, and callback conventions. Complete the integration in its codebase and run its
|
|
22
22
|
relevant checks. The MCP tools configure Builder resources; your file tools
|
|
23
23
|
edit the app.
|
|
@@ -20,6 +20,7 @@ import { type CaptureStore, captureTools, resetCaptures } from './capture.ts'
|
|
|
20
20
|
import { checkAttestorTool } from './check-attestor.ts'
|
|
21
21
|
import { draftTools } from './draft-tools.ts'
|
|
22
22
|
import { listTabsTool } from './eval.ts'
|
|
23
|
+
import { exampleAppTool } from './example-app.ts'
|
|
23
24
|
import { howItWorksTool } from './how-it-works.ts'
|
|
24
25
|
import { liveViewTools } from './live-view.ts'
|
|
25
26
|
import { podProofTools } from './pod-proof.ts'
|
|
@@ -101,6 +102,7 @@ export function buildAgentTools(
|
|
|
101
102
|
|
|
102
103
|
if(client) {
|
|
103
104
|
tools.push(startIntegrationTool())
|
|
105
|
+
tools.push(exampleAppTool())
|
|
104
106
|
tools.push(createVersionFromCaptureTool(
|
|
105
107
|
client, state.attach, state.captures,
|
|
106
108
|
))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create_example_app` — write reclaimprotocol/builder-example into the
|
|
3
|
+
* developer's workspace, so a project with no app starts from a ready-to-run
|
|
4
|
+
* backend and frontend instead of agent-written code. The MCP server fetches
|
|
5
|
+
* the files itself (each checked against its git blob hash) and writes them at
|
|
6
|
+
* the workspace root: the coding agent's later `npm install` and `npm run dev`
|
|
7
|
+
* then run in the workspace it started in, not in a clone. The backend's
|
|
8
|
+
* `@reclaimprotocol/client` is set to the latest published version.
|
|
9
|
+
*/
|
|
10
|
+
import { badGateway, badRequest, conflict } from '@hapi/boom'
|
|
11
|
+
import assert from 'node:assert'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import {
|
|
15
|
+
mkdir,
|
|
16
|
+
mkdtemp,
|
|
17
|
+
rename,
|
|
18
|
+
rm,
|
|
19
|
+
writeFile
|
|
20
|
+
} from 'node:fs/promises'
|
|
21
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
22
|
+
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
23
|
+
|
|
24
|
+
const REPO = 'reclaimprotocol/builder-example'
|
|
25
|
+
const BACKEND_PACKAGE = join('packages', 'backend', 'package.json')
|
|
26
|
+
const CLIENT_PACKAGE = '@reclaimprotocol/client'
|
|
27
|
+
/** The example is ~50 small files; anything far past that isn't it. */
|
|
28
|
+
const MAX_FILES = 500
|
|
29
|
+
const MAX_BYTES = 10_000_000
|
|
30
|
+
|
|
31
|
+
export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
32
|
+
return defineTool<{ projectDir: string }>(
|
|
33
|
+
{
|
|
34
|
+
name: 'create_example_app',
|
|
35
|
+
description:
|
|
36
|
+
'Writes the Reclaim example app (reclaimprotocol/builder-example: '
|
|
37
|
+
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
38
|
+
+ 'verify callbacks, and render every verification state) into '
|
|
39
|
+
+ '`projectDir`, and sets its backend to the latest '
|
|
40
|
+
+ '`@reclaimprotocol/client`. Use it when the workspace has no app '
|
|
41
|
+
+ 'yet, with the workspace root you were started in as '
|
|
42
|
+
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
43
|
+
+ 'when a file it would write already exists with different '
|
|
44
|
+
+ 'content. Returns the commit, file count, client version, and '
|
|
45
|
+
+ 'next steps.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
required: ['projectDir'],
|
|
49
|
+
properties: {
|
|
50
|
+
projectDir: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description:
|
|
53
|
+
'Absolute path of the workspace root you were started in.',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
annotations: { openWorldHint: true, destructiveHint: false },
|
|
58
|
+
},
|
|
59
|
+
async({ projectDir }) => {
|
|
60
|
+
assert(
|
|
61
|
+
isAbsolute(projectDir),
|
|
62
|
+
badRequest('`projectDir` must be an absolute path.'),
|
|
63
|
+
)
|
|
64
|
+
const commit = await latestCommit()
|
|
65
|
+
const files = await listFiles(commit)
|
|
66
|
+
const clientVersion = await latestClientVersion()
|
|
67
|
+
const staged = await Promise.all(files.map(async(file) => {
|
|
68
|
+
const bytes = await download(commit, file)
|
|
69
|
+
return {
|
|
70
|
+
path: file.path,
|
|
71
|
+
bytes: file.path === BACKEND_PACKAGE
|
|
72
|
+
? withClientVersion(bytes, clientVersion)
|
|
73
|
+
: bytes,
|
|
74
|
+
}
|
|
75
|
+
}))
|
|
76
|
+
const conflicts = staged
|
|
77
|
+
.filter(({ path, bytes }) => {
|
|
78
|
+
const dest = join(projectDir, path)
|
|
79
|
+
return existsSync(dest) && !readFileSync(dest).equals(bytes)
|
|
80
|
+
})
|
|
81
|
+
.map(({ path }) => path)
|
|
82
|
+
assert(
|
|
83
|
+
!conflicts.length,
|
|
84
|
+
conflict(
|
|
85
|
+
'These files already exist with different content: '
|
|
86
|
+
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
87
|
+
+ 'another directory or move them.',
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// Stage inside projectDir, so the moves below stay on one filesystem.
|
|
92
|
+
const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'))
|
|
93
|
+
try {
|
|
94
|
+
for(const { path, bytes } of staged) {
|
|
95
|
+
await mkdir(dirname(join(tmp, path)), { recursive: true })
|
|
96
|
+
await writeFile(join(tmp, path), bytes)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for(const { path } of staged) {
|
|
100
|
+
const dest = join(projectDir, path)
|
|
101
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
102
|
+
await rename(join(tmp, path), dest)
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
await rm(tmp, { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
projectDir,
|
|
110
|
+
repository: `https://github.com/${REPO}`,
|
|
111
|
+
commit,
|
|
112
|
+
files: staged.length,
|
|
113
|
+
clientVersion,
|
|
114
|
+
next: [
|
|
115
|
+
'Call issue_org_token with this projectDir and envFile '
|
|
116
|
+
+ '"packages/backend/.env".',
|
|
117
|
+
'Run `npm install`, then `npm run dev`, from projectDir. The '
|
|
118
|
+
+ 'backend listens on port 3000; the app opens at '
|
|
119
|
+
+ 'http://localhost:5173.',
|
|
120
|
+
'Call proxy_callback_events({ port: 3000, path: '
|
|
121
|
+
+ '"/reclaim/callback" }).',
|
|
122
|
+
'Put the provider in packages/backend/config.json and restart '
|
|
123
|
+
+ 'the backend.',
|
|
124
|
+
],
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async function latestCommit() {
|
|
130
|
+
const res = await fetchFn(
|
|
131
|
+
`https://api.github.com/repos/${REPO}/commits/main`,
|
|
132
|
+
{ headers: { Accept: 'application/vnd.github.sha' } },
|
|
133
|
+
)
|
|
134
|
+
assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`))
|
|
135
|
+
const text = await res.text()
|
|
136
|
+
const commit = text.trim()
|
|
137
|
+
assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'))
|
|
138
|
+
return commit
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function listFiles(commit: string) {
|
|
142
|
+
const res = await fetchFn(
|
|
143
|
+
`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`,
|
|
144
|
+
{ headers: { Accept: 'application/vnd.github+json' } },
|
|
145
|
+
)
|
|
146
|
+
assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`))
|
|
147
|
+
const tree: unknown = await res.json()
|
|
148
|
+
assert(
|
|
149
|
+
typeof tree === 'object' && tree !== null && 'tree' in tree
|
|
150
|
+
&& Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated),
|
|
151
|
+
badGateway(`unexpected ${REPO} file listing`),
|
|
152
|
+
)
|
|
153
|
+
const files = tree.tree
|
|
154
|
+
.filter((e: unknown) => {
|
|
155
|
+
return typeof e === 'object' && e !== null && 'type' in e
|
|
156
|
+
&& e.type === 'blob'
|
|
157
|
+
})
|
|
158
|
+
.map((entry: unknown) => {
|
|
159
|
+
assert(
|
|
160
|
+
typeof entry === 'object' && entry !== null
|
|
161
|
+
&& 'path' in entry && typeof entry.path === 'string'
|
|
162
|
+
&& entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
|
|
163
|
+
&& s !== '.' && s !== '..' && s !== '.git')
|
|
164
|
+
&& 'sha' in entry && typeof entry.sha === 'string'
|
|
165
|
+
&& 'size' in entry && typeof entry.size === 'number',
|
|
166
|
+
badGateway(`unexpected entry in the ${REPO} file listing`),
|
|
167
|
+
)
|
|
168
|
+
return { path: entry.path, sha: entry.sha, size: entry.size }
|
|
169
|
+
})
|
|
170
|
+
assert(
|
|
171
|
+
files.length > 0 && files.length <= MAX_FILES
|
|
172
|
+
&& files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES,
|
|
173
|
+
badGateway(`unexpected ${REPO} size`),
|
|
174
|
+
)
|
|
175
|
+
return files
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function download(commit: string, file: { path: string, sha: string }) {
|
|
179
|
+
const res = await fetchFn(
|
|
180
|
+
`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`,
|
|
181
|
+
)
|
|
182
|
+
assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`))
|
|
183
|
+
const bytes = Buffer.from(await res.arrayBuffer())
|
|
184
|
+
assert(
|
|
185
|
+
gitBlobSha(bytes) === file.sha,
|
|
186
|
+
badGateway(`${file.path} failed its integrity check`),
|
|
187
|
+
)
|
|
188
|
+
return bytes
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function latestClientVersion() {
|
|
192
|
+
const res = await fetchFn(
|
|
193
|
+
`https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`,
|
|
194
|
+
)
|
|
195
|
+
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
|
|
196
|
+
const body: unknown = await res.json()
|
|
197
|
+
assert(
|
|
198
|
+
typeof body === 'object' && body !== null && 'version' in body
|
|
199
|
+
&& typeof body.version === 'string'
|
|
200
|
+
&& /^\d+\.\d+\.\d+$/.test(body.version),
|
|
201
|
+
badGateway(`npm ${CLIENT_PACKAGE}: no version`),
|
|
202
|
+
)
|
|
203
|
+
return body.version
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The backend's package.json with the client range set to `^version`. */
|
|
208
|
+
function withClientVersion(bytes: Buffer, version: string) {
|
|
209
|
+
const text = bytes.toString('utf8')
|
|
210
|
+
const next = text.replace(
|
|
211
|
+
/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/,
|
|
212
|
+
(_, open: string, close: string) => `${open}^${version}${close}`,
|
|
213
|
+
)
|
|
214
|
+
assert(
|
|
215
|
+
next !== text || text.includes(`"^${version}"`),
|
|
216
|
+
badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`),
|
|
217
|
+
)
|
|
218
|
+
return Buffer.from(next)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Git's blob object id: sha1 of `blob <size>\0<content>`. */
|
|
222
|
+
function gitBlobSha(bytes: Buffer) {
|
|
223
|
+
return createHash('sha1')
|
|
224
|
+
.update(`blob ${bytes.length}\0`)
|
|
225
|
+
.update(bytes)
|
|
226
|
+
.digest('hex')
|
|
227
|
+
}
|