@thedesignagent/mcp 0.2.5 → 0.4.0
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 +157 -5
- package/dist/auth-capture.js +245 -110
- package/dist/auth-store.d.ts +1 -0
- package/dist/auth-store.js +7 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +771 -0
- package/dist/credentials.d.ts +8 -0
- package/dist/credentials.js +44 -0
- package/dist/index.js +65 -44
- package/dist/screenshot.d.ts +1 -0
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @thedesignagent/mcp
|
|
2
2
|
|
|
3
|
-
MCP server for [TheDesignAgent](https://thedesignagent.ai): UX and visual judgment for UI that coding agents build.
|
|
3
|
+
MCP server and CLI for [TheDesignAgent](https://thedesignagent.ai): UX and visual judgment for UI that coding agents build.
|
|
4
4
|
|
|
5
5
|
Your agent gets a build brief before it builds a screen, and a scored review after: does the screen serve the user's job, does it follow UX heuristics, does it match your design system. Reviews use a real screenshot when the page is running.
|
|
6
6
|
|
|
@@ -32,6 +32,14 @@ Use the hosted server if you only need briefs and UX reviews. Use this package w
|
|
|
32
32
|
|
|
33
33
|
Get an API key (it starts with `tda_`) at [thedesignagent.ai/dashboard/api-keys](https://thedesignagent.ai/dashboard/api-keys).
|
|
34
34
|
|
|
35
|
+
You can put the key in each client's config as shown below, or save it once for every client and the CLI:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
npx -y --package=@thedesignagent/mcp thedesignagent login
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That writes `~/.thedesignagent/credentials`, readable only by you. `THEDESIGNAGENT_API_KEY` still wins when it's set, so you can then leave the `env` lines out of the configs below.
|
|
42
|
+
|
|
35
43
|
### Claude Code (recommended: the plugin)
|
|
36
44
|
|
|
37
45
|
The plugin includes this server, plus skills that teach the agent the brief → build → review loop and `/tda:brief` and `/tda:review` commands:
|
|
@@ -105,6 +113,134 @@ The package has two commands, so pass `--package` and name `thedesignagent-mcp`
|
|
|
105
113
|
|
|
106
114
|
After the first successful `Discover`, write `{ "project_id": "<id>" }` to `.thedesignagent` and commit it, so every agent on the repo shares the same project context.
|
|
107
115
|
|
|
116
|
+
## Command line and CI
|
|
117
|
+
|
|
118
|
+
The same package has a `thedesignagent` command for terminals, headless agent runs and CI. `npx thedesignagent` runs it with no install.
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
npx thedesignagent check http://localhost:3000/checkout --threshold 7
|
|
122
|
+
npx thedesignagent check app/checkout/page.tsx --checks ux
|
|
123
|
+
npx thedesignagent brief "Build the forecast approval queue"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Command | What it does |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `check [url\|file]` | With no target, checks every page listed in `.thedesignagent` (below). Otherwise scores a page from a screenshot (`visual`) or a source file (`ux`). On a URL, `--checks visual,ux --code <file>` runs both (the UX review reads the page's source). `--task` says what the screen is for. |
|
|
129
|
+
| `brief "<task>"` | Writes a build brief to `.thedesignagent-brief.md` (`--out -` prints it). Point any agent at it from AGENTS.md or CLAUDE.md. |
|
|
130
|
+
| `login [key]` | Saves your API key. Reads it from stdin when piped. |
|
|
131
|
+
|
|
132
|
+
`check` options: `--threshold <n>` exits 1 when a check's overall score is below `n`; `--json` prints the full result (scores, findings, recommendations, screenshot path) for scripts and agents.
|
|
133
|
+
|
|
134
|
+
Exit codes: `0` ok, `1` below threshold, `2` error (key, network, usage), `3` the page redirected to a login screen (no review ran, nothing charged).
|
|
135
|
+
|
|
136
|
+
Both commands find the project from `.thedesignagent` in the repo, or `--project <id>`, or `THEDESIGNAGENT_PROJECT_ID`. Commit `.thedesignagent`: CI clones often use a different remote URL than your machine, so the fallback (a hash of the remote URL) can point at a different project. A project's first brief needs a project model, which your coding agent builds on its first `Discover` call; after that the CLI's briefs work anywhere.
|
|
137
|
+
|
|
138
|
+
### Checking a set of pages
|
|
139
|
+
|
|
140
|
+
List the pages to check in `.thedesignagent`, and `check` with no target checks them all:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"project_id": "proj_...",
|
|
145
|
+
"check": {
|
|
146
|
+
"base_url": "http://localhost:3000",
|
|
147
|
+
"threshold": 7,
|
|
148
|
+
"pages": [
|
|
149
|
+
{ "path": "/checkout", "task": "Pay for the items in the cart" },
|
|
150
|
+
{ "path": "/orders", "code": "app/orders/page.tsx", "checks": ["visual", "ux"], "threshold": 7.5 }
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
npx thedesignagent check
|
|
158
|
+
npx thedesignagent check --base-url https://my-app-git-feature.vercel.app
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--base-url` (or `THEDESIGNAGENT_BASE_URL`) points the same pages at a preview deploy. A page's own `threshold` overrides the shared one, and `--threshold` overrides both. `code` paths are relative to the repo root. The run exits 1 if any page is below its threshold, and 3 if a page redirected to login.
|
|
162
|
+
|
|
163
|
+
### Only the pages a branch changed
|
|
164
|
+
|
|
165
|
+
`check --changed` diffs the branch against `origin/main` (in GitHub Actions, the pull request's base branch; `--base <ref>` to choose) and checks the Next.js App Router pages it touched. A changed file counts toward the nearest `page.tsx` above it, so editing `app/orders/_components/table.tsx` re-checks `/orders`. Route groups like `(shop)` are handled. Dynamic routes (`[id]`) have no URL to visit, so list a concrete page for them in `.thedesignagent` (with `code` pointing at the page file) and it's checked when that file changes.
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
npx thedesignagent check --changed --base-url https://my-app-git-feature.vercel.app --threshold 7
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Headless agent runs
|
|
172
|
+
|
|
173
|
+
Ask the agent to run the check and fix what it finds:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
claude -p "Build the checkout page. Then run 'npx thedesignagent check http://localhost:3000/checkout --json' and fix every critical and high finding."
|
|
177
|
+
codex exec "Build the checkout page. Then run 'npx thedesignagent check http://localhost:3000/checkout --json' and fix every critical and high finding."
|
|
178
|
+
grok -p "Build the checkout page. Then run 'npx thedesignagent check http://localhost:3000/checkout --json' and fix every critical and high finding."
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Or have the agent write the brief first: `npx thedesignagent brief "Build the checkout page" && claude -p "Read .thedesignagent-brief.md, then build the checkout page."`
|
|
182
|
+
|
|
183
|
+
### GitHub Actions
|
|
184
|
+
|
|
185
|
+
Fail a build when a page scores below 7. Add your key as the repository secret `THEDESIGNAGENT_API_KEY`. GitHub's Ubuntu runners include Chrome.
|
|
186
|
+
|
|
187
|
+
```yaml
|
|
188
|
+
name: Design check
|
|
189
|
+
on: pull_request
|
|
190
|
+
|
|
191
|
+
jobs:
|
|
192
|
+
design-check:
|
|
193
|
+
runs-on: ubuntu-latest
|
|
194
|
+
steps:
|
|
195
|
+
- uses: actions/checkout@v4
|
|
196
|
+
- uses: actions/setup-node@v4
|
|
197
|
+
with:
|
|
198
|
+
node-version: 20
|
|
199
|
+
- run: npm ci
|
|
200
|
+
- run: npm run build
|
|
201
|
+
- run: npm start &
|
|
202
|
+
- run: npx -y wait-on -t 120000 http://localhost:3000/checkout
|
|
203
|
+
- run: npx -y thedesignagent check http://localhost:3000/checkout --threshold 7
|
|
204
|
+
env:
|
|
205
|
+
THEDESIGNAGENT_API_KEY: ${{ secrets.THEDESIGNAGENT_API_KEY }}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### On pull requests: changed pages only, with a PR comment
|
|
209
|
+
|
|
210
|
+
Check only the pages the pull request touched and post the scores as one comment, updated on every push:
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
name: Design check
|
|
214
|
+
on: pull_request
|
|
215
|
+
|
|
216
|
+
permissions:
|
|
217
|
+
contents: read
|
|
218
|
+
pull-requests: write
|
|
219
|
+
|
|
220
|
+
jobs:
|
|
221
|
+
design-check:
|
|
222
|
+
runs-on: ubuntu-latest
|
|
223
|
+
steps:
|
|
224
|
+
- uses: actions/checkout@v4
|
|
225
|
+
with:
|
|
226
|
+
fetch-depth: 0
|
|
227
|
+
- uses: actions/setup-node@v4
|
|
228
|
+
with:
|
|
229
|
+
node-version: 20
|
|
230
|
+
- run: npm ci
|
|
231
|
+
- run: npm run build
|
|
232
|
+
- run: npm start &
|
|
233
|
+
- run: npx -y wait-on -t 120000 http://localhost:3000
|
|
234
|
+
- run: npx -y thedesignagent check --changed --base-url http://localhost:3000 --threshold 7 --pr-comment
|
|
235
|
+
env:
|
|
236
|
+
THEDESIGNAGENT_API_KEY: ${{ secrets.THEDESIGNAGENT_API_KEY }}
|
|
237
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`fetch-depth: 0` lets `--changed` diff against the base branch. The comment lists each page's scores against the threshold, with the top findings and fixes folded under each. If commenting fails (for example without `pull-requests: write`), the check still runs and still sets the exit code.
|
|
241
|
+
|
|
242
|
+
The score table and the critical and high findings appear on the run's summary page. For pages behind login, set `THEDESIGNAGENT_AUTH_SEED` to an AuthSeed object, `{ "cookies": [...] }` (the format `thedesignagent-auth` saves under `~/.thedesignagent/auth/`), ideally for a test account.
|
|
243
|
+
|
|
108
244
|
## Screenshots
|
|
109
245
|
|
|
110
246
|
`Visual` screenshots `render_url` with your local Chrome, Edge, Brave or Chromium. If it can't find one, set `CHROME_PATH` to the browser binary.
|
|
@@ -113,19 +249,35 @@ Screenshots are saved to `~/.thedesignagent/screenshots/` for 24 hours, so you c
|
|
|
113
249
|
|
|
114
250
|
### Pages behind login
|
|
115
251
|
|
|
116
|
-
|
|
252
|
+
When `Visual` lands on a login page it stops before reviewing (nothing is charged) and tells your agent how to save a session. Sessions are stored per site under `~/.thedesignagent/auth/` (readable only by you) and applied to every later screenshot of that site.
|
|
253
|
+
|
|
254
|
+
There are two ways to save one:
|
|
255
|
+
|
|
256
|
+
**A login recipe (automatic).** A small script in your repo, `.thedesignagent-login.mjs`, prints either a URL that logs in when opened (a magic link, a dev-only login route) or cookies as JSON. Pipe it into `login`:
|
|
117
257
|
|
|
118
258
|
```
|
|
119
|
-
npx -y --package=@thedesignagent/mcp thedesignagent-auth
|
|
259
|
+
node .thedesignagent-login.mjs | npx -y --package=@thedesignagent/mcp thedesignagent-auth login http://localhost:3000/dashboard
|
|
120
260
|
```
|
|
121
261
|
|
|
122
|
-
|
|
262
|
+
It logs in headlessly, checks the page no longer redirects to login, and saves. The Claude Code plugin's `/tda:setup` writes a recipe for Supabase apps.
|
|
263
|
+
|
|
264
|
+
**Capture by hand.** Opens a browser window; log in and it saves and closes by itself once you leave the login page:
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
npx -y --package=@thedesignagent/mcp thedesignagent-auth capture http://localhost:3000/dashboard
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
It uses a dedicated browser profile, so later re-captures are often instant. Agents can run it in the background: it ends with `TDA_AUTH_SAVED <origin>` or `TDA_AUTH_FAILED <reason>`, and gives up after 10 minutes.
|
|
271
|
+
|
|
272
|
+
Check what's saved with `thedesignagent-auth status <url>`.
|
|
123
273
|
|
|
124
274
|
## Environment variables
|
|
125
275
|
|
|
126
276
|
| Variable | Required | Purpose |
|
|
127
277
|
| --- | --- | --- |
|
|
128
|
-
| `THEDESIGNAGENT_API_KEY` | Yes | Your `tda_` API key |
|
|
278
|
+
| `THEDESIGNAGENT_API_KEY` | Yes, unless you ran `thedesignagent login` | Your `tda_` API key |
|
|
279
|
+
| `THEDESIGNAGENT_PROJECT_ID` | No | Project for the CLI when there's no `.thedesignagent` file |
|
|
280
|
+
| `THEDESIGNAGENT_AUTH_SEED` | No | Session cookies as JSON, for screenshots behind login in CI |
|
|
129
281
|
| `CHROME_PATH` | No | Browser binary for screenshots, if it isn't found automatically |
|
|
130
282
|
| `ANTHROPIC_API_KEY` | No | Enables a fallback review through Claude when TheDesignAgent's API is unreachable |
|
|
131
283
|
|
package/dist/auth-capture.js
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* thedesignagent-auth: save a logged-in session so `Visual` can screenshot
|
|
4
|
+
* pages behind login.
|
|
5
|
+
*
|
|
6
|
+
* capture <app-url> Opens a browser window; log in. Saves and closes on its own
|
|
7
|
+
* once you leave the login page (or press Enter / close it).
|
|
8
|
+
* Safe to run from an agent in the background.
|
|
9
|
+
* login <app-url> Non-interactive. Reads a login recipe's output on stdin:
|
|
10
|
+
* either a URL that logs in when opened (a magic link, a
|
|
11
|
+
* dev-only login route) or an AuthSeed JSON ({ cookies, ... }).
|
|
12
|
+
* Logs in headlessly, verifies, saves.
|
|
13
|
+
* status <app-url> Says whether a session is saved and when it expires.
|
|
14
|
+
*
|
|
15
|
+
* Every run ends with one machine-readable line for agents:
|
|
16
|
+
* TDA_AUTH_SAVED <origin> | TDA_AUTH_FAILED <reason> | TDA_AUTH_STATUS ...
|
|
17
|
+
*/
|
|
2
18
|
import { findChromeExecutable, isLoginUrl } from './screenshot.js';
|
|
3
|
-
import { saveStoredAuth } from './auth-store.js';
|
|
19
|
+
import { authFilePath, loadStoredAuth, saveStoredAuth, BROWSER_PROFILE_DIR } from './auth-store.js';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
4
21
|
import { createInterface } from 'node:readline';
|
|
22
|
+
const CAPTURE_TIMEOUT_MS = Number(process.env.TDA_CAPTURE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
23
|
+
const POLL_MS = 1500;
|
|
5
24
|
function printUsage() {
|
|
6
|
-
console.error('Usage:
|
|
7
|
-
console.error('
|
|
25
|
+
console.error('Usage:');
|
|
26
|
+
console.error(' thedesignagent-auth capture <app-url> log in by hand in a browser window');
|
|
27
|
+
console.error(' thedesignagent-auth login <app-url> log in from a recipe (URL or AuthSeed JSON on stdin)');
|
|
28
|
+
console.error(' thedesignagent-auth status <app-url> show whether a session is saved');
|
|
29
|
+
console.error('Example: node .thedesignagent-login.mjs | thedesignagent-auth login http://localhost:3000');
|
|
30
|
+
}
|
|
31
|
+
function fail(reason, detail) {
|
|
32
|
+
console.error('');
|
|
33
|
+
console.error(`✗ ${reason}`);
|
|
34
|
+
for (const line of detail ?? [])
|
|
35
|
+
console.error(` ${line}`);
|
|
36
|
+
console.log(`TDA_AUTH_FAILED ${reason}`);
|
|
37
|
+
process.exit(1);
|
|
8
38
|
}
|
|
9
39
|
async function snapshotSession(browser, page, hostname) {
|
|
10
40
|
try {
|
|
@@ -19,144 +49,249 @@ async function snapshotSession(browser, page, hostname) {
|
|
|
19
49
|
httpOnly: c.httpOnly,
|
|
20
50
|
secure: c.secure,
|
|
21
51
|
sameSite: (c.sameSite === 'Strict' || c.sameSite === 'Lax' || c.sameSite === 'None') ? c.sameSite : undefined,
|
|
52
|
+
...(c.expires > 0 && { expires: c.expires }),
|
|
22
53
|
}));
|
|
54
|
+
// Storage is unreadable on error pages (opaque origin): keep the cookies anyway.
|
|
23
55
|
const storage = await page.evaluate(() => ({
|
|
24
56
|
localStorage: Object.fromEntries(Object.keys(localStorage).map(k => [k, localStorage.getItem(k) ?? ''])),
|
|
25
57
|
sessionStorage: Object.fromEntries(Object.keys(sessionStorage).map(k => [k, sessionStorage.getItem(k) ?? ''])),
|
|
26
|
-
}));
|
|
27
|
-
return {
|
|
28
|
-
cookies,
|
|
29
|
-
localStorage: storage.localStorage,
|
|
30
|
-
sessionStorage: storage.sessionStorage,
|
|
31
|
-
finalUrl: page.url(),
|
|
32
|
-
};
|
|
58
|
+
})).catch(() => ({ localStorage: {}, sessionStorage: {} }));
|
|
59
|
+
return { cookies, localStorage: storage.localStorage, sessionStorage: storage.sessionStorage, finalUrl: page.url() };
|
|
33
60
|
}
|
|
34
61
|
catch {
|
|
35
62
|
return null;
|
|
36
63
|
}
|
|
37
64
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// No TTY (e.g. backgrounded by an agent) — never resolves. Browser-close will trigger instead.
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
-
rl.once('line', () => { rl.close(); resolve('enter'); });
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
function waitForBrowserClose(browser) {
|
|
49
|
-
return new Promise(resolve => {
|
|
50
|
-
browser.on('disconnected', () => resolve('closed'));
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
// Post-trigger verification: if the browser is still alive after Enter was
|
|
54
|
-
// pressed, navigate back to the original URL and re-snapshot. Catches "user
|
|
55
|
-
// pressed Enter too early" — the partial session would otherwise be saved
|
|
56
|
-
// silently and fail on every subsequent visual call.
|
|
57
|
-
async function verifyAndSnapshot(browser, page, url, trigger, fallback) {
|
|
58
|
-
if (trigger !== 'enter' || !browser.connected)
|
|
59
|
-
return fallback;
|
|
65
|
+
// Navigate back to the app URL and re-snapshot, so a session is only saved
|
|
66
|
+
// once the app itself accepts it (catches "confirmed before login finished").
|
|
67
|
+
async function verifySnapshot(browser, page, url) {
|
|
60
68
|
try {
|
|
61
|
-
|
|
62
|
-
await
|
|
63
|
-
const snap = await snapshotSession(browser, page, url.hostname);
|
|
64
|
-
return snap ?? fallback;
|
|
69
|
+
await page.goto(url.href, { waitUntil: 'networkidle2', timeout: 20000 });
|
|
70
|
+
return await snapshotSession(browser, page, url.hostname);
|
|
65
71
|
}
|
|
66
72
|
catch {
|
|
67
|
-
return
|
|
73
|
+
return null;
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
let url;
|
|
77
|
-
try {
|
|
78
|
-
url = new URL(urlArg);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
console.error(`Invalid URL: ${urlArg}`);
|
|
82
|
-
process.exit(1);
|
|
76
|
+
function saveSnapshot(url, snap, how) {
|
|
77
|
+
if (isLoginUrl(snap.finalUrl)) {
|
|
78
|
+
fail('session not accepted', [
|
|
79
|
+
`Opening ${url.href} still redirected to a login page: ${snap.finalUrl}`,
|
|
80
|
+
'Login may not have finished (a second factor or enrolment step still pending?).',
|
|
81
|
+
]);
|
|
83
82
|
}
|
|
83
|
+
const seed = {
|
|
84
|
+
cookies: snap.cookies,
|
|
85
|
+
...(Object.keys(snap.localStorage).length && { localStorage: snap.localStorage }),
|
|
86
|
+
...(Object.keys(snap.sessionStorage).length && { sessionStorage: snap.sessionStorage }),
|
|
87
|
+
};
|
|
88
|
+
const savedPath = saveStoredAuth(url.href, seed);
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(`✓ Logged in (${how}); ${snap.cookies.length} cookies, ${Object.keys(snap.localStorage).length} localStorage entries`);
|
|
91
|
+
console.log(`✓ Saved to ${savedPath}. Visual now uses it for every page on ${url.origin}.`);
|
|
92
|
+
console.log(`TDA_AUTH_SAVED ${url.origin}`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
async function launch(opts) {
|
|
84
96
|
const chromePath = findChromeExecutable();
|
|
85
|
-
if (!chromePath)
|
|
86
|
-
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
97
|
+
if (!chromePath)
|
|
98
|
+
fail('no browser found', ['Install Google Chrome (or Edge, Brave, Chromium), or set CHROME_PATH.']);
|
|
89
99
|
const puppeteer = (await import('puppeteer-core')).default;
|
|
90
|
-
|
|
91
|
-
console.log(`Opening ${url.href} in a browser window…`);
|
|
92
|
-
const browser = await puppeteer.launch({
|
|
100
|
+
return puppeteer.launch({
|
|
93
101
|
executablePath: chromePath,
|
|
94
|
-
headless:
|
|
102
|
+
headless: opts.headless,
|
|
95
103
|
defaultViewport: { width: 1440, height: 900 },
|
|
104
|
+
// A dedicated profile keeps "remember me" and SSO sessions between
|
|
105
|
+
// captures, so re-capturing after expiry is often instant.
|
|
106
|
+
...(opts.persistent && { userDataDir: BROWSER_PROFILE_DIR }),
|
|
96
107
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
97
108
|
});
|
|
109
|
+
}
|
|
110
|
+
// ── capture ──────────────────────────────────────────────────────────────────
|
|
111
|
+
function waitForEnter() {
|
|
112
|
+
return new Promise(resolve => {
|
|
113
|
+
if (!process.stdin.isTTY)
|
|
114
|
+
return; // backgrounded by an agent: rely on auto-detect / close
|
|
115
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
116
|
+
rl.once('line', () => { rl.close(); resolve('enter'); });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function capture(url) {
|
|
120
|
+
const hadSavedSession = existsSync(authFilePath(url.href));
|
|
121
|
+
const browser = await launch({ headless: process.env.TDA_CAPTURE_HEADLESS === '1', persistent: true });
|
|
98
122
|
const [page] = await browser.pages();
|
|
99
|
-
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => { });
|
|
124
|
+
console.log('▌TheDesignAgent▐ login capture');
|
|
125
|
+
console.log(`→ A browser window opened at ${url.href}. Log in to your app there.`);
|
|
126
|
+
console.log('→ It saves and closes by itself once you reach a logged-in page.');
|
|
127
|
+
if (process.stdin.isTTY)
|
|
128
|
+
console.log(' (Or press Enter here, or close the window, when you are done.)');
|
|
129
|
+
console.log('');
|
|
130
|
+
// Logged in = we saw a login page on this origin and have now left it (the
|
|
131
|
+
// page is back on the app, off any login path) for two polls in a row.
|
|
132
|
+
// If the dedicated profile was already logged in and the app loads straight
|
|
133
|
+
// away, that counts too when a session was saved before (a re-capture).
|
|
134
|
+
let sawLogin = isLoginUrl(page.url());
|
|
135
|
+
let stableOffLogin = 0;
|
|
136
|
+
const detected = new Promise(resolve => {
|
|
137
|
+
const timer = setInterval(() => {
|
|
138
|
+
let current;
|
|
139
|
+
try {
|
|
140
|
+
current = new URL(page.url());
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const onApp = current.origin === url.origin;
|
|
146
|
+
if (onApp && isLoginUrl(current.href)) {
|
|
147
|
+
sawLogin = true;
|
|
148
|
+
stableOffLogin = 0;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (onApp && (sawLogin || hadSavedSession))
|
|
152
|
+
stableOffLogin++;
|
|
153
|
+
else
|
|
154
|
+
stableOffLogin = 0;
|
|
155
|
+
if (stableOffLogin >= 2) {
|
|
156
|
+
clearInterval(timer);
|
|
157
|
+
resolve('detected');
|
|
158
|
+
}
|
|
159
|
+
}, POLL_MS);
|
|
160
|
+
browser.on('disconnected', () => clearInterval(timer));
|
|
161
|
+
});
|
|
102
162
|
let lastSnapshot = null;
|
|
103
|
-
const
|
|
163
|
+
const snapTimer = setInterval(async () => {
|
|
104
164
|
const snap = await snapshotSession(browser, page, url.hostname);
|
|
105
165
|
if (snap)
|
|
106
166
|
lastSnapshot = snap;
|
|
107
|
-
},
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
console.log(interactiveHint);
|
|
114
|
-
console.log('');
|
|
115
|
-
const trigger = await Promise.race([waitForEnter(), waitForBrowserClose(browser)]);
|
|
116
|
-
clearInterval(pollInterval);
|
|
117
|
-
const verifiedSnapshot = await verifyAndSnapshot(browser, page, url, trigger, lastSnapshot);
|
|
118
|
-
if (trigger !== 'closed') {
|
|
167
|
+
}, POLL_MS);
|
|
168
|
+
const closed = new Promise(resolve => browser.on('disconnected', () => resolve('closed')));
|
|
169
|
+
const timedOut = new Promise(resolve => setTimeout(() => resolve('timeout'), CAPTURE_TIMEOUT_MS).unref());
|
|
170
|
+
const trigger = await Promise.race([detected, waitForEnter(), closed, timedOut]);
|
|
171
|
+
clearInterval(snapTimer);
|
|
172
|
+
if (trigger === 'timeout') {
|
|
119
173
|
try {
|
|
120
174
|
await browser.close();
|
|
121
175
|
}
|
|
122
|
-
catch {
|
|
176
|
+
catch { }
|
|
177
|
+
fail('timed out waiting for login', [`No login completed within ${Math.round(CAPTURE_TIMEOUT_MS / 60000)} minutes. Run it again when ready.`]);
|
|
123
178
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
const snap = trigger === 'closed' ? lastSnapshot : (await verifySnapshot(browser, page, url)) ?? lastSnapshot;
|
|
180
|
+
try {
|
|
181
|
+
await browser.close();
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
if (!snap)
|
|
185
|
+
fail('no session captured', ['The window closed before a logged-in page loaded. Run it again.']);
|
|
186
|
+
saveSnapshot(url, snap, trigger === 'detected' ? 'detected automatically' : trigger === 'enter' ? 'Enter pressed' : 'window closed');
|
|
187
|
+
}
|
|
188
|
+
// ── login (from a recipe) ────────────────────────────────────────────────────
|
|
189
|
+
// Stream stdin: readFileSync(0) throws EAGAIN on a pipe whose writer hasn't
|
|
190
|
+
// finished yet (e.g. a recipe still waiting on a network call).
|
|
191
|
+
function readStdin() {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
let data = '';
|
|
194
|
+
process.stdin.setEncoding('utf8');
|
|
195
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
196
|
+
process.stdin.on('end', () => resolve(data));
|
|
197
|
+
process.stdin.on('error', reject);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async function login(url) {
|
|
201
|
+
if (process.stdin.isTTY)
|
|
202
|
+
fail('nothing on stdin', ['Pipe a login recipe into this command, e.g. node .thedesignagent-login.mjs | thedesignagent-auth login ' + url.origin]);
|
|
203
|
+
const input = (await readStdin()).trim();
|
|
204
|
+
if (!input)
|
|
205
|
+
fail('empty input', ['The login recipe printed nothing.']);
|
|
206
|
+
let seed = null;
|
|
207
|
+
let loginUrl = null;
|
|
208
|
+
if (input.startsWith('{')) {
|
|
209
|
+
try {
|
|
210
|
+
seed = JSON.parse(input);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
fail('recipe output is not valid JSON');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
loginUrl = input.split('\n')[0].trim();
|
|
218
|
+
try {
|
|
219
|
+
new URL(loginUrl);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
fail('recipe output is neither a URL nor JSON');
|
|
223
|
+
}
|
|
129
224
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
225
|
+
const browser = await launch({ headless: true, persistent: false });
|
|
226
|
+
try {
|
|
227
|
+
const page = await browser.newPage();
|
|
228
|
+
if (seed?.cookies?.length) {
|
|
229
|
+
await page.browserContext().setCookie(...seed.cookies.map(c => ({ ...c, domain: c.domain ?? url.hostname, path: c.path ?? '/' })));
|
|
230
|
+
}
|
|
231
|
+
if (seed?.localStorage || seed?.sessionStorage) {
|
|
232
|
+
await page.evaluateOnNewDocument((ls, ss) => {
|
|
233
|
+
try {
|
|
234
|
+
for (const [k, v] of ls)
|
|
235
|
+
localStorage.setItem(k, v);
|
|
236
|
+
for (const [k, v] of ss)
|
|
237
|
+
sessionStorage.setItem(k, v);
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}, Object.entries(seed.localStorage ?? {}), Object.entries(seed.sessionStorage ?? {}));
|
|
241
|
+
}
|
|
242
|
+
if (loginUrl) {
|
|
243
|
+
await page.goto(loginUrl, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => { });
|
|
244
|
+
}
|
|
245
|
+
const snap = await verifySnapshot(browser, page, url);
|
|
246
|
+
await browser.close();
|
|
247
|
+
if (!snap)
|
|
248
|
+
fail('could not load the app', [`Is the dev server running at ${url.origin}?`]);
|
|
249
|
+
saveSnapshot(url, snap, loginUrl ? 'login URL' : 'recipe cookies');
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
try {
|
|
253
|
+
await browser.close();
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
259
|
+
function status(url) {
|
|
260
|
+
const seed = loadStoredAuth(url.href);
|
|
261
|
+
if (!seed) {
|
|
262
|
+
console.log(`No saved session for ${url.origin}.`);
|
|
263
|
+
console.log(`TDA_AUTH_STATUS none ${url.origin}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const expiries = (seed.cookies ?? []).map(c => c.expires).filter((e) => typeof e === 'number' && e > 0);
|
|
267
|
+
if (!expiries.length) {
|
|
268
|
+
console.log(`Session saved for ${url.origin} (session cookies, no expiry recorded).`);
|
|
269
|
+
console.log(`TDA_AUTH_STATUS saved ${url.origin}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const soonest = new Date(Math.min(...expiries) * 1000);
|
|
273
|
+
const expired = soonest.getTime() < Date.now();
|
|
274
|
+
console.log(`Session saved for ${url.origin}; ${expired ? 'expired' : 'expires'} ${soonest.toISOString()}.`);
|
|
275
|
+
console.log(`TDA_AUTH_STATUS ${expired ? 'expired' : 'saved'} ${url.origin} ${soonest.toISOString()}`);
|
|
276
|
+
}
|
|
277
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
278
|
+
async function main() {
|
|
279
|
+
const [, , subcommand, urlArg] = process.argv;
|
|
280
|
+
if (!subcommand || !urlArg || !['capture', 'login', 'status'].includes(subcommand)) {
|
|
281
|
+
printUsage();
|
|
141
282
|
process.exit(1);
|
|
142
283
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.log(`▌TheDesignAgent▐ will now auto-apply this session for any URL on ${url.origin}.`);
|
|
156
|
-
console.log(`To re-capture (after expiry or logout), run this command again.`);
|
|
157
|
-
process.exit(0);
|
|
284
|
+
let url;
|
|
285
|
+
try {
|
|
286
|
+
url = new URL(urlArg);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
fail(`invalid URL: ${urlArg}`);
|
|
290
|
+
}
|
|
291
|
+
if (subcommand === 'status')
|
|
292
|
+
return status(url);
|
|
293
|
+
if (subcommand === 'login')
|
|
294
|
+
return login(url);
|
|
295
|
+
return capture(url);
|
|
158
296
|
}
|
|
159
|
-
main().catch(err =>
|
|
160
|
-
console.error('auth-capture failed:', err instanceof Error ? err.message : err);
|
|
161
|
-
process.exit(1);
|
|
162
|
-
});
|
|
297
|
+
main().catch(err => fail('auth command crashed', [err instanceof Error ? err.message : String(err)]));
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthSeed } from './screenshot.js';
|
|
2
2
|
export declare const AUTH_STORE_DIR: string;
|
|
3
|
+
export declare const BROWSER_PROFILE_DIR: string;
|
|
3
4
|
export declare function authFilePath(url: string): string;
|
|
4
5
|
export declare function loadStoredAuth(url: string): AuthSeed | null;
|
|
5
6
|
export declare function saveStoredAuth(url: string, seed: AuthSeed): string;
|
package/dist/auth-store.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
// Saved sessions are live login cookies: keep them readable by the owner only.
|
|
4
5
|
export const AUTH_STORE_DIR = join(homedir(), '.thedesignagent', 'auth');
|
|
6
|
+
export const BROWSER_PROFILE_DIR = join(homedir(), '.thedesignagent', 'browser-profile');
|
|
5
7
|
function safeFilename(origin) {
|
|
6
8
|
return origin.replace(/^https?:\/\//, '').replace(/[^a-z0-9.-]/gi, '_') + '.json';
|
|
7
9
|
}
|
|
@@ -20,8 +22,10 @@ export function loadStoredAuth(url) {
|
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
export function saveStoredAuth(url, seed) {
|
|
23
|
-
mkdirSync(AUTH_STORE_DIR, { recursive: true });
|
|
25
|
+
mkdirSync(AUTH_STORE_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
chmodSync(AUTH_STORE_DIR, 0o700);
|
|
24
27
|
const path = authFilePath(url);
|
|
25
|
-
writeFileSync(path, JSON.stringify(seed, null, 2));
|
|
28
|
+
writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
|
|
29
|
+
chmodSync(path, 0o600);
|
|
26
30
|
return path;
|
|
27
31
|
}
|
package/dist/cli.d.ts
ADDED