@1dolinski/fastforms 0.1.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 +113 -0
- package/SKILL.md +79 -0
- package/bin/fastforms.js +396 -0
- package/lib/chrome.js +66 -0
- package/lib/fill.js +188 -0
- package/lib/local.js +156 -0
- package/lib/personas.js +176 -0
- package/lib/platforms.js +65 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# fastforms
|
|
2
|
+
|
|
3
|
+
Fill any form fast. Manage personas locally, fill forms from your terminal.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. Create your personas
|
|
9
|
+
npx fastforms init
|
|
10
|
+
|
|
11
|
+
# 2. Enable remote debugging in Chrome
|
|
12
|
+
# Open chrome://inspect/#remote-debugging and toggle it on
|
|
13
|
+
|
|
14
|
+
# 3. Fill any form (never submits)
|
|
15
|
+
npx fastforms fill https://example.com/apply
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
1. **`fastforms init`** walks you through creating user + business personas interactively
|
|
21
|
+
2. Personas are saved as simple JSON in `.fastforms/user.json` and `.fastforms/business.json`
|
|
22
|
+
3. **`fastforms fill <url>`** connects to Chrome, opens the form, fills it by label matching
|
|
23
|
+
4. **Review and submit manually** in Chrome
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- Chrome >= 144
|
|
28
|
+
- Node.js >= 18
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `fastforms init` | Create personas interactively |
|
|
35
|
+
| `fastforms fill <url>` | Fill any form |
|
|
36
|
+
| `fastforms edit` | Update existing personas |
|
|
37
|
+
| `fastforms personas` | Open web persona manager in Chrome |
|
|
38
|
+
|
|
39
|
+
### Fill options
|
|
40
|
+
|
|
41
|
+
| Option | Description |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `--web` | Use web app personas instead of local files |
|
|
44
|
+
| `--dir <path>` | Custom persona directory path |
|
|
45
|
+
| `--port <port>` | Chrome debug port (auto-detected) |
|
|
46
|
+
| `--user <hint>` | User persona hint (web mode) |
|
|
47
|
+
| `--business <hint>` | Business persona hint (web mode) |
|
|
48
|
+
|
|
49
|
+
## `.fastforms/` directory
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
.fastforms/
|
|
53
|
+
user.json # Your user persona
|
|
54
|
+
business.json # Your business persona
|
|
55
|
+
dumps/ # Optional: raw text context files
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`user.json` — just fill in what you have:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"name": "chris",
|
|
63
|
+
"fullName": "Chris Dolinski",
|
|
64
|
+
"email": "chris@example.com",
|
|
65
|
+
"role": "Founder",
|
|
66
|
+
"location": "Toronto, ON",
|
|
67
|
+
"linkedIn": "linkedin.com/in/1dolinski",
|
|
68
|
+
"github": "github.com/1dolinski",
|
|
69
|
+
"bio": "Serial entrepreneur",
|
|
70
|
+
"facts": {
|
|
71
|
+
"x handle": "@1dolinski",
|
|
72
|
+
"telegram": "@chris"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`business.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"name": "APINow.fun",
|
|
82
|
+
"oneLiner": "x402 everything",
|
|
83
|
+
"website": "apinow.fun",
|
|
84
|
+
"problem": "Payments are broken",
|
|
85
|
+
"solution": "Fix them with x402"
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Web app (optional)
|
|
90
|
+
|
|
91
|
+
You can also manage personas in the web UI at [293-fastforms.vercel.app/persona](https://293-fastforms.vercel.app/persona) and use `--web` flag to pull from there. The web app has an "Export for CLI" button to download your personas as local JSON files.
|
|
92
|
+
|
|
93
|
+
## Contributing
|
|
94
|
+
|
|
95
|
+
PRs welcome. To develop locally:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git clone https://github.com/1dolinski/fastforms.git
|
|
99
|
+
cd fastforms
|
|
100
|
+
npm install
|
|
101
|
+
node bin/fastforms.js init
|
|
102
|
+
node bin/fastforms.js fill https://example.com/apply
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Adding a new form mapping
|
|
106
|
+
|
|
107
|
+
1. Add a `buildXxxData(user, biz)` function in `lib/fill.js`
|
|
108
|
+
2. Add a URL check in `fillForm()`
|
|
109
|
+
3. Test with `node bin/fastforms.js fill <url>`
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# fastforms
|
|
2
|
+
|
|
3
|
+
Fill any form fast using your personas.
|
|
4
|
+
|
|
5
|
+
## Triggers
|
|
6
|
+
|
|
7
|
+
Use this skill when the user says any of:
|
|
8
|
+
- "fill a form", "fill out this form", "fill out this application"
|
|
9
|
+
- "apply to X", "submit my info to X"
|
|
10
|
+
- "autofill form", "autofill with my persona"
|
|
11
|
+
- "fill this form with my persona"
|
|
12
|
+
- "use fastforms", "fastforms fill"
|
|
13
|
+
- "set up my personas", "init fastforms"
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
`fastforms` is a CLI tool that:
|
|
18
|
+
|
|
19
|
+
1. Manages personas locally in a `.fastforms/` directory (simple JSON files)
|
|
20
|
+
2. Connects to Chrome via the DevTools Protocol
|
|
21
|
+
3. Fills any form using label-matching — never submits
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# 1. Create your personas interactively
|
|
27
|
+
npx fastforms init
|
|
28
|
+
|
|
29
|
+
# 2. Enable remote debugging in Chrome
|
|
30
|
+
# Open chrome://inspect/#remote-debugging
|
|
31
|
+
|
|
32
|
+
# 3. Fill any form
|
|
33
|
+
npx fastforms fill https://example.com/apply
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
### `npx fastforms init`
|
|
39
|
+
|
|
40
|
+
Conversational persona builder. Walks through user + business persona fields, saves to `.fastforms/user.json` and `.fastforms/business.json`.
|
|
41
|
+
|
|
42
|
+
### `npx fastforms fill <url>`
|
|
43
|
+
|
|
44
|
+
Fills any form. Reads personas from local `.fastforms/` directory by default.
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
- `--web` — use web app personas (https://293-fastforms.vercel.app) instead of local files
|
|
48
|
+
- `--dir <path>` — custom path to persona directory
|
|
49
|
+
- `--port <port>` — Chrome debug port (auto-detected by default)
|
|
50
|
+
|
|
51
|
+
### `npx fastforms edit`
|
|
52
|
+
|
|
53
|
+
Re-run the persona builder with current values pre-filled.
|
|
54
|
+
|
|
55
|
+
### `npx fastforms personas`
|
|
56
|
+
|
|
57
|
+
Opens the web persona manager in Chrome.
|
|
58
|
+
|
|
59
|
+
## Persona sources
|
|
60
|
+
|
|
61
|
+
1. **Local `.fastforms/` directory** (default) — simple JSON files, no web app needed
|
|
62
|
+
2. **Web app** (`--web` flag) — https://293-fastforms.vercel.app/persona manages personas in localStorage, CLI reads via CDP
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
1. Reads personas from `.fastforms/user.json` and `.fastforms/business.json`
|
|
67
|
+
2. Auto-discovers Chrome's debug port from `DevToolsActivePort`
|
|
68
|
+
3. Opens (or reuses) the target form URL tab
|
|
69
|
+
4. Fills using label-matching heuristics. Has site-specific mappings for known forms.
|
|
70
|
+
5. Shows what was filled, what was skipped. **Never submits.**
|
|
71
|
+
|
|
72
|
+
## Agent instructions
|
|
73
|
+
|
|
74
|
+
When the user asks you to fill a form:
|
|
75
|
+
|
|
76
|
+
1. Check if `.fastforms/` exists. If not, run `npx fastforms init`
|
|
77
|
+
2. Run `npx fastforms fill <the-url>`
|
|
78
|
+
3. If Chrome debugging isn't enabled, tell the user to open `chrome://inspect/#remote-debugging`
|
|
79
|
+
4. After filling, tell the user to review in Chrome and submit manually
|
package/bin/fastforms.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "readline";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
|
|
8
|
+
import { ensureChromeReady } from "../lib/chrome.js";
|
|
9
|
+
import {
|
|
10
|
+
connectToChrome,
|
|
11
|
+
pullPersonas,
|
|
12
|
+
selectPersonas,
|
|
13
|
+
showPersonaDetails,
|
|
14
|
+
offerSetDefaults,
|
|
15
|
+
offerOpenPersonaManager,
|
|
16
|
+
} from "../lib/personas.js";
|
|
17
|
+
import { fillForm } from "../lib/fill.js";
|
|
18
|
+
import {
|
|
19
|
+
findFastformsDir,
|
|
20
|
+
ensureDir,
|
|
21
|
+
loadLocalPersonas,
|
|
22
|
+
saveUserPersona,
|
|
23
|
+
saveBusinessPersona,
|
|
24
|
+
userTemplate,
|
|
25
|
+
businessTemplate,
|
|
26
|
+
} from "../lib/local.js";
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const command = args[0];
|
|
30
|
+
|
|
31
|
+
function flag(name) {
|
|
32
|
+
const idx = args.indexOf(name);
|
|
33
|
+
return idx !== -1 ? args[idx + 1] : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasFlag(name) {
|
|
37
|
+
return args.includes(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const explicitPort = flag("--port") ? Number(flag("--port")) : null;
|
|
41
|
+
|
|
42
|
+
let _rl = null;
|
|
43
|
+
function getRL() {
|
|
44
|
+
if (!_rl) _rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
return _rl;
|
|
46
|
+
}
|
|
47
|
+
function closeRL() {
|
|
48
|
+
if (_rl) { _rl.close(); _rl = null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ask(prompt, fallback = "") {
|
|
52
|
+
return new Promise((r) => getRL().question(prompt, (a) => r(a.trim() || fallback)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function help() {
|
|
56
|
+
console.log(`
|
|
57
|
+
fastforms — Fill any form fast.
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
fastforms init Set up your personas interactively
|
|
61
|
+
fastforms fill <url> Fill a form with your personas
|
|
62
|
+
fastforms edit Edit your existing personas
|
|
63
|
+
fastforms personas Open web persona manager in Chrome
|
|
64
|
+
fastforms Show this help
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
--web Use web app personas instead of local .fastforms/
|
|
68
|
+
--dir <path> Path to .fastforms/ directory (default: auto-detect)
|
|
69
|
+
--user <hint> User persona name/hint (web mode)
|
|
70
|
+
--business <hint> Business persona name/hint (web mode)
|
|
71
|
+
--port <port> Chrome debug port (auto-detected by default)
|
|
72
|
+
|
|
73
|
+
Quick start:
|
|
74
|
+
1. npx fastforms init
|
|
75
|
+
2. Enable remote debugging: chrome://inspect/#remote-debugging
|
|
76
|
+
3. npx fastforms fill https://example.com/apply
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// init — conversational persona builder
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async function init() {
|
|
85
|
+
const dirArg = flag("--dir");
|
|
86
|
+
const dir = dirArg || join(process.cwd(), ".fastforms");
|
|
87
|
+
|
|
88
|
+
console.log("\n fastforms — Let's set up your personas.\n");
|
|
89
|
+
|
|
90
|
+
if (existsSync(join(dir, "user.json"))) {
|
|
91
|
+
const ans = await ask(" .fastforms/ already exists. Overwrite? [y/N]: ");
|
|
92
|
+
if (ans.toLowerCase() !== "y") {
|
|
93
|
+
console.log(" Use 'fastforms edit' to update existing personas.\n");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- User persona ---
|
|
99
|
+
console.log(" --- User persona ---\n");
|
|
100
|
+
const user = userTemplate();
|
|
101
|
+
|
|
102
|
+
user.name = await ask(" Name (identifier): ");
|
|
103
|
+
user.fullName = await ask(" Full name: ");
|
|
104
|
+
user.email = await ask(" Email: ");
|
|
105
|
+
user.role = await ask(" Role / title: ");
|
|
106
|
+
user.location = await ask(" Location: ");
|
|
107
|
+
user.linkedIn = await ask(" LinkedIn (optional): ");
|
|
108
|
+
user.github = await ask(" GitHub (optional): ");
|
|
109
|
+
user.bio = await ask(" Short bio (optional): ");
|
|
110
|
+
|
|
111
|
+
// Custom facts
|
|
112
|
+
console.log("\n Add custom facts (e.g. 'x handle = @1dolinski'). Press Enter to skip.\n");
|
|
113
|
+
while (true) {
|
|
114
|
+
const raw = await ask(" fact: ");
|
|
115
|
+
if (!raw) break;
|
|
116
|
+
const eq = raw.indexOf("=");
|
|
117
|
+
if (eq === -1) {
|
|
118
|
+
console.log(" Use format: key = value");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const key = raw.slice(0, eq).trim();
|
|
122
|
+
const value = raw.slice(eq + 1).trim();
|
|
123
|
+
if (key) user.facts[key] = value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Business persona ---
|
|
127
|
+
console.log("\n --- Business persona ---\n");
|
|
128
|
+
const biz = businessTemplate();
|
|
129
|
+
|
|
130
|
+
biz.name = await ask(" Company / project name: ");
|
|
131
|
+
biz.oneLiner = await ask(" One-liner: ");
|
|
132
|
+
biz.website = await ask(" Website (optional): ");
|
|
133
|
+
biz.category = await ask(" Category (optional): ");
|
|
134
|
+
biz.location = await ask(" Location (optional): ");
|
|
135
|
+
biz.problem = await ask(" Problem you're solving (optional): ");
|
|
136
|
+
biz.solution = await ask(" Your solution (optional): ");
|
|
137
|
+
biz.targetUsers = await ask(" Target users (optional): ");
|
|
138
|
+
biz.traction = await ask(" Traction (optional): ");
|
|
139
|
+
biz.businessModel = await ask(" Business model (optional): ");
|
|
140
|
+
biz.differentiators = await ask(" Differentiators (optional): ");
|
|
141
|
+
|
|
142
|
+
console.log("\n Add business facts. Press Enter to skip.\n");
|
|
143
|
+
while (true) {
|
|
144
|
+
const raw = await ask(" fact: ");
|
|
145
|
+
if (!raw) break;
|
|
146
|
+
const eq = raw.indexOf("=");
|
|
147
|
+
if (eq === -1) {
|
|
148
|
+
console.log(" Use format: key = value");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const key = raw.slice(0, eq).trim();
|
|
152
|
+
const value = raw.slice(eq + 1).trim();
|
|
153
|
+
if (key) biz.facts[key] = value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Save
|
|
157
|
+
ensureDir(dir);
|
|
158
|
+
saveUserPersona(dir, user);
|
|
159
|
+
saveBusinessPersona(dir, biz);
|
|
160
|
+
|
|
161
|
+
console.log(`\n Saved to ${dir}/`);
|
|
162
|
+
console.log(" user.json");
|
|
163
|
+
console.log(" business.json");
|
|
164
|
+
console.log("\n Next: npx fastforms fill <url>\n");
|
|
165
|
+
closeRL();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// edit — re-run init with pre-filled values
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async function edit() {
|
|
173
|
+
const dirArg = flag("--dir");
|
|
174
|
+
const dir = dirArg || findFastformsDir();
|
|
175
|
+
|
|
176
|
+
if (!dir) {
|
|
177
|
+
console.error(" No .fastforms/ directory found. Run 'fastforms init' first.\n");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const dump = loadLocalPersonas(dir);
|
|
182
|
+
const existing = dump.personas[0];
|
|
183
|
+
const existingBiz = dump.businessPersonas[0];
|
|
184
|
+
|
|
185
|
+
console.log("\n fastforms — Edit your personas. Press Enter to keep current value.\n");
|
|
186
|
+
|
|
187
|
+
// --- User ---
|
|
188
|
+
console.log(" --- User persona ---\n");
|
|
189
|
+
const ep = existing?.profile || {};
|
|
190
|
+
const user = userTemplate();
|
|
191
|
+
|
|
192
|
+
user.name = await ask(` Name [${existing?.name || ""}]: `, existing?.name || "");
|
|
193
|
+
user.fullName = await ask(` Full name [${ep.fullName || ""}]: `, ep.fullName || "");
|
|
194
|
+
user.email = await ask(` Email [${ep.email || ""}]: `, ep.email || "");
|
|
195
|
+
user.role = await ask(` Role [${ep.currentRole || ""}]: `, ep.currentRole || "");
|
|
196
|
+
user.location = await ask(` Location [${ep.location || ""}]: `, ep.location || "");
|
|
197
|
+
user.linkedIn = await ask(` LinkedIn [${ep.linkedIn || ""}]: `, ep.linkedIn || "");
|
|
198
|
+
user.github = await ask(` GitHub [${ep.github || ""}]: `, ep.github || "");
|
|
199
|
+
user.bio = await ask(` Bio [${ep.bio ? ep.bio.slice(0, 40) + "..." : ""}]: `, ep.bio || "");
|
|
200
|
+
|
|
201
|
+
// Carry over existing facts
|
|
202
|
+
const existingFacts = {};
|
|
203
|
+
for (const f of (existing?.customFacts || [])) {
|
|
204
|
+
if (f.enabled !== false) existingFacts[f.key] = f.value;
|
|
205
|
+
}
|
|
206
|
+
user.facts = { ...existingFacts };
|
|
207
|
+
|
|
208
|
+
if (Object.keys(user.facts).length) {
|
|
209
|
+
console.log("\n Current facts:");
|
|
210
|
+
for (const [k, v] of Object.entries(user.facts)) {
|
|
211
|
+
console.log(` ${k} = ${v}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
console.log("\n Add/update facts (Enter to finish):\n");
|
|
215
|
+
while (true) {
|
|
216
|
+
const raw = await ask(" fact: ");
|
|
217
|
+
if (!raw) break;
|
|
218
|
+
const eq = raw.indexOf("=");
|
|
219
|
+
if (eq === -1) { console.log(" Use format: key = value"); continue; }
|
|
220
|
+
user.facts[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- Business ---
|
|
224
|
+
console.log("\n --- Business persona ---\n");
|
|
225
|
+
const bp = existingBiz?.profile || {};
|
|
226
|
+
const biz = businessTemplate();
|
|
227
|
+
|
|
228
|
+
biz.name = await ask(` Company [${existingBiz?.name || ""}]: `, existingBiz?.name || "");
|
|
229
|
+
biz.oneLiner = await ask(` One-liner [${bp.oneLiner || ""}]: `, bp.oneLiner || "");
|
|
230
|
+
biz.website = await ask(` Website [${bp.website || ""}]: `, bp.website || "");
|
|
231
|
+
biz.category = await ask(` Category [${bp.category || ""}]: `, bp.category || "");
|
|
232
|
+
biz.location = await ask(` Location [${bp.location || ""}]: `, bp.location || "");
|
|
233
|
+
biz.problem = await ask(` Problem [${bp.problem ? bp.problem.slice(0, 40) + "..." : ""}]: `, bp.problem || "");
|
|
234
|
+
biz.solution = await ask(` Solution [${bp.solution ? bp.solution.slice(0, 40) + "..." : ""}]: `, bp.solution || "");
|
|
235
|
+
biz.targetUsers = await ask(` Target users [${bp.targetUsers ? bp.targetUsers.slice(0, 40) + "..." : ""}]: `, bp.targetUsers || "");
|
|
236
|
+
biz.traction = await ask(` Traction [${bp.traction ? bp.traction.slice(0, 40) + "..." : ""}]: `, bp.traction || "");
|
|
237
|
+
biz.businessModel = await ask(` Business model [${bp.businessModel ? bp.businessModel.slice(0, 40) + "..." : ""}]: `, bp.businessModel || "");
|
|
238
|
+
biz.differentiators = await ask(` Differentiators [${bp.differentiators ? bp.differentiators.slice(0, 40) + "..." : ""}]: `, bp.differentiators || "");
|
|
239
|
+
|
|
240
|
+
const existingBizFacts = {};
|
|
241
|
+
for (const f of (existingBiz?.customFacts || [])) {
|
|
242
|
+
if (f.enabled !== false) existingBizFacts[f.key] = f.value;
|
|
243
|
+
}
|
|
244
|
+
biz.facts = { ...existingBizFacts };
|
|
245
|
+
|
|
246
|
+
if (Object.keys(biz.facts).length) {
|
|
247
|
+
console.log("\n Current facts:");
|
|
248
|
+
for (const [k, v] of Object.entries(biz.facts)) {
|
|
249
|
+
console.log(` ${k} = ${v}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
console.log("\n Add/update facts (Enter to finish):\n");
|
|
253
|
+
while (true) {
|
|
254
|
+
const raw = await ask(" fact: ");
|
|
255
|
+
if (!raw) break;
|
|
256
|
+
const eq = raw.indexOf("=");
|
|
257
|
+
if (eq === -1) { console.log(" Use format: key = value"); continue; }
|
|
258
|
+
biz.facts[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
saveUserPersona(dir, user);
|
|
262
|
+
saveBusinessPersona(dir, biz);
|
|
263
|
+
console.log(`\n Updated ${dir}/\n`);
|
|
264
|
+
closeRL();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// fill — the main event
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async function fill() {
|
|
272
|
+
const formUrl = args.find((a) => a.startsWith("http"));
|
|
273
|
+
if (!formUrl) {
|
|
274
|
+
console.error(" Usage: fastforms fill <url>\n");
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log(`\n fastforms fill — NO SUBMIT\n`);
|
|
279
|
+
console.log(` Target: ${formUrl}\n`);
|
|
280
|
+
|
|
281
|
+
const port = await ensureChromeReady(explicitPort);
|
|
282
|
+
console.log(` Connected to Chrome on port ${port}.`);
|
|
283
|
+
|
|
284
|
+
let dump;
|
|
285
|
+
let browser;
|
|
286
|
+
|
|
287
|
+
if (hasFlag("--web")) {
|
|
288
|
+
// Web app mode
|
|
289
|
+
console.log(" Pulling personas from web app...");
|
|
290
|
+
browser = await connectToChrome(port);
|
|
291
|
+
dump = await pullPersonas(browser);
|
|
292
|
+
} else {
|
|
293
|
+
// Local mode (default)
|
|
294
|
+
const dirArg = flag("--dir");
|
|
295
|
+
const dir = dirArg || findFastformsDir();
|
|
296
|
+
|
|
297
|
+
if (!dir) {
|
|
298
|
+
console.error(" No .fastforms/ directory found.");
|
|
299
|
+
console.error(" Run 'fastforms init' to create one, or use --web for web app mode.\n");
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.log(` Loading personas from ${dir}/`);
|
|
304
|
+
dump = loadLocalPersonas(dir);
|
|
305
|
+
browser = await connectToChrome(port);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const personas = dump.personas || [];
|
|
309
|
+
const bizPersonas = dump.businessPersonas || [];
|
|
310
|
+
console.log(` Found ${personas.length} user persona(s), ${bizPersonas.length} business persona(s).`);
|
|
311
|
+
|
|
312
|
+
if (!personas.length && !bizPersonas.length) {
|
|
313
|
+
console.error("\n No personas found.");
|
|
314
|
+
if (hasFlag("--web")) {
|
|
315
|
+
await offerOpenPersonaManager(browser);
|
|
316
|
+
} else {
|
|
317
|
+
console.error(" Run 'fastforms init' to create personas.\n");
|
|
318
|
+
}
|
|
319
|
+
browser.disconnect();
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const userHint = flag("--user");
|
|
324
|
+
const bizHint = flag("--business");
|
|
325
|
+
const { user, biz } = await selectPersonas(dump, userHint, bizHint);
|
|
326
|
+
|
|
327
|
+
if (!user && !biz) {
|
|
328
|
+
console.error("\n No matching personas.");
|
|
329
|
+
browser.disconnect();
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
showPersonaDetails(user, biz);
|
|
334
|
+
|
|
335
|
+
const pages = await browser.pages();
|
|
336
|
+
const host = new URL(formUrl).host;
|
|
337
|
+
let page = pages.find((p) => p.url().includes(host));
|
|
338
|
+
|
|
339
|
+
if (page) {
|
|
340
|
+
console.log(`\n Using existing tab for ${host}`);
|
|
341
|
+
await page.bringToFront();
|
|
342
|
+
} else {
|
|
343
|
+
page = await browser.newPage();
|
|
344
|
+
await page.goto(formUrl, { waitUntil: "networkidle2", timeout: 45_000 });
|
|
345
|
+
console.log(`\n Opened ${formUrl}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await fillForm(page, formUrl, user, biz);
|
|
349
|
+
|
|
350
|
+
if (hasFlag("--web")) {
|
|
351
|
+
await offerSetDefaults(user, biz);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
browser.disconnect();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// personas — open web manager
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
async function openPersonas() {
|
|
362
|
+
const port = await ensureChromeReady(explicitPort);
|
|
363
|
+
const browser = await connectToChrome(port);
|
|
364
|
+
const pages = await browser.pages();
|
|
365
|
+
const existing = pages.find((p) => p.url().includes("293-fastforms.vercel.app/persona"));
|
|
366
|
+
if (existing) {
|
|
367
|
+
await existing.bringToFront();
|
|
368
|
+
console.log(" Focused existing persona manager tab.");
|
|
369
|
+
} else {
|
|
370
|
+
const tab = await browser.newPage();
|
|
371
|
+
await tab.goto("https://293-fastforms.vercel.app/persona", { waitUntil: "networkidle2", timeout: 30_000 });
|
|
372
|
+
console.log(" Opened https://293-fastforms.vercel.app/persona");
|
|
373
|
+
}
|
|
374
|
+
browser.disconnect();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Route
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
switch (command) {
|
|
382
|
+
case "init":
|
|
383
|
+
init().catch((e) => { console.error(e.message); process.exit(1); });
|
|
384
|
+
break;
|
|
385
|
+
case "edit":
|
|
386
|
+
edit().catch((e) => { console.error(e.message); process.exit(1); });
|
|
387
|
+
break;
|
|
388
|
+
case "fill":
|
|
389
|
+
fill().catch((e) => { console.error(e.message); process.exit(1); });
|
|
390
|
+
break;
|
|
391
|
+
case "personas":
|
|
392
|
+
openPersonas().catch((e) => { console.error(e.message); process.exit(1); });
|
|
393
|
+
break;
|
|
394
|
+
default:
|
|
395
|
+
help();
|
|
396
|
+
}
|
package/lib/chrome.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getDataDir } from "./platforms.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the DevToolsActivePort file from Chrome's user data directory.
|
|
7
|
+
* Chrome writes this file when remote debugging is enabled (via
|
|
8
|
+
* chrome://inspect/#remote-debugging or --remote-debugging-port).
|
|
9
|
+
* Returns { port, wsPath } or null.
|
|
10
|
+
*/
|
|
11
|
+
export function readDevToolsActivePort() {
|
|
12
|
+
const filePath = join(getDataDir(), "DevToolsActivePort");
|
|
13
|
+
if (!existsSync(filePath)) return null;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const lines = readFileSync(filePath, "utf-8").trim().split("\n");
|
|
17
|
+
if (lines.length < 2) return null;
|
|
18
|
+
const port = Number(lines[0]);
|
|
19
|
+
if (!port || isNaN(port)) return null;
|
|
20
|
+
return { port, wsPath: lines[1] };
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function isCdpReady(port) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
29
|
+
return res.ok;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function ensureChromeReady(explicitPort) {
|
|
36
|
+
// If user passed --port, try that directly
|
|
37
|
+
if (explicitPort) {
|
|
38
|
+
if (await isCdpReady(explicitPort)) return explicitPort;
|
|
39
|
+
|
|
40
|
+
console.error(`\n Cannot connect to Chrome on port ${explicitPort}.\n`);
|
|
41
|
+
console.error(" Enable remote debugging in Chrome:");
|
|
42
|
+
console.error(" 1. Open chrome://inspect/#remote-debugging");
|
|
43
|
+
console.error(" 2. Toggle 'Allow remote debugging' on");
|
|
44
|
+
console.error(" 3. Re-run this command\n");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Auto-discover from DevToolsActivePort
|
|
49
|
+
const active = readDevToolsActivePort();
|
|
50
|
+
if (active && await isCdpReady(active.port)) return active.port;
|
|
51
|
+
|
|
52
|
+
// Fallback: try common port
|
|
53
|
+
if (await isCdpReady(9222)) return 9222;
|
|
54
|
+
|
|
55
|
+
console.error("\n Chrome remote debugging is not enabled.\n");
|
|
56
|
+
console.error(" To enable it:");
|
|
57
|
+
console.error(" 1. Open chrome://inspect/#remote-debugging in Chrome");
|
|
58
|
+
console.error(" 2. Toggle 'Allow remote debugging' on");
|
|
59
|
+
console.error(" 3. Re-run this command\n");
|
|
60
|
+
|
|
61
|
+
if (active) {
|
|
62
|
+
console.error(` (Found stale DevToolsActivePort for port ${active.port} — restart Chrome if needed)\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
package/lib/fill.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { getCustomFact } from "./personas.js";
|
|
2
|
+
|
|
3
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
|
|
5
|
+
export async function fillByLabel(page, label, value, opts = {}) {
|
|
6
|
+
if (!value) return false;
|
|
7
|
+
return page.evaluate((lbl, val, ta) => {
|
|
8
|
+
const norm = (s) => s?.toLowerCase().replace(/[*\s]+/g, " ").trim() || "";
|
|
9
|
+
for (const el of document.querySelectorAll("label, h3, h4, p, span, div")) {
|
|
10
|
+
if (!norm(el.textContent).includes(norm(lbl))) continue;
|
|
11
|
+
const parent = el.closest("div[class], section, fieldset") || el.parentElement;
|
|
12
|
+
if (!parent) continue;
|
|
13
|
+
const input = parent.querySelector(
|
|
14
|
+
ta ? "textarea" : 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea'
|
|
15
|
+
);
|
|
16
|
+
if (!input) continue;
|
|
17
|
+
input.focus();
|
|
18
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
19
|
+
input.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, "value"
|
|
20
|
+
)?.set;
|
|
21
|
+
if (setter) setter.call(input, val); else input.value = val;
|
|
22
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
23
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}, label, value, opts.textarea ?? false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function clickRadio(page, sectionHint, value) {
|
|
31
|
+
return page.evaluate((hint, val) => {
|
|
32
|
+
for (const el of document.querySelectorAll("label, [role='radio'], button")) {
|
|
33
|
+
if (el.textContent?.trim().toLowerCase() !== val.toLowerCase()) continue;
|
|
34
|
+
const sec = el.closest("section, fieldset, div");
|
|
35
|
+
const heading = sec?.querySelector("h2, h3, h4, label, p");
|
|
36
|
+
if (!heading || heading.textContent?.toLowerCase().includes(hint.toLowerCase())) {
|
|
37
|
+
el.click();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}, sectionHint, value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function clickCheckbox(page, textHint) {
|
|
46
|
+
return page.evaluate((hint) => {
|
|
47
|
+
for (const el of document.querySelectorAll("label, [role='checkbox'], span, div")) {
|
|
48
|
+
if (el.textContent?.trim().toLowerCase().includes(hint.toLowerCase())) {
|
|
49
|
+
const cb = el.querySelector('input[type="checkbox"]') || el;
|
|
50
|
+
cb.click();
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}, textHint);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildNitroData(user, biz) {
|
|
59
|
+
const u = user?.profile || {};
|
|
60
|
+
const b = biz?.profile || {};
|
|
61
|
+
const fact = (p, k) => getCustomFact(p, k);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
fields: [
|
|
65
|
+
{ label: "Company / Project name", value: b.productName || b.companyName || b.name || "" },
|
|
66
|
+
{ label: "Email", value: u.email || "" },
|
|
67
|
+
{ label: "One-line description", value: b.oneLiner || "" },
|
|
68
|
+
{ label: "What are you building", value: b.solution || [b.problem, b.solution].filter(Boolean).join("\n\n"), textarea: true },
|
|
69
|
+
{ label: "Full Name", value: u.fullName || "" },
|
|
70
|
+
{ label: "Role in the company", value: u.currentRole || "" },
|
|
71
|
+
{ label: "X Handle", value: fact(user, "x handle") || fact(user, "twitter") || "" },
|
|
72
|
+
{ label: "LinkedIn username", value: u.linkedIn || "" },
|
|
73
|
+
{ label: "Telegram", value: fact(user, "telegram") || "" },
|
|
74
|
+
{ label: "GitHub username", value: u.github || "" },
|
|
75
|
+
{ label: "Why are you the right founder", value: [u.bio, u.keySkills ? `Key skills: ${u.keySkills}` : "", u.favoriteProjects ? `Notable projects: ${u.favoriteProjects}` : ""].filter(Boolean).join("\n\n"), textarea: true },
|
|
76
|
+
{ label: "video content or long form writing", value: u.portfolio || "", textarea: true },
|
|
77
|
+
{ label: "How did the founders meet", value: fact(user, "founders met") || fact(biz, "founders") || `I'm a solo founder building ${b.productName || b.companyName || "this project"} full-time.`, textarea: true },
|
|
78
|
+
{ label: "Current location of founders", value: u.location || b.location || "" },
|
|
79
|
+
{ label: "how do you plan to spend", value: fact(biz, "spend") || fact(biz, "500k") || "", textarea: true },
|
|
80
|
+
{ label: "What problem are you solving", value: b.problem || "", textarea: true },
|
|
81
|
+
{ label: "closest comparables", value: b.differentiators || "", textarea: true },
|
|
82
|
+
{ label: "Product Link", value: b.website || "" },
|
|
83
|
+
{ label: "What changed in the tech or market", value: fact(biz, "timing") || fact(biz, "why now") || b.rawFacts || "", textarea: true },
|
|
84
|
+
{ label: "Dune Dashboard", value: "" },
|
|
85
|
+
{ label: "Google Analytics", value: "", textarea: true },
|
|
86
|
+
{ label: "target segment", value: b.targetUsers || "", textarea: true },
|
|
87
|
+
{ label: "wedge into the market", value: b.businessModel || b.differentiators || "", textarea: true },
|
|
88
|
+
{ label: "traction have you achieved", value: b.traction || "", textarea: true },
|
|
89
|
+
{ label: "Runway at current burn", value: "12" },
|
|
90
|
+
{ label: "What attracts you the most about Nitro", value: fact(biz, "nitro") || fact(biz, "accelerator") || `The density of high-signal founders and the NYC residency. ${b.productName || "Our product"} is at a stage where concentrated feedback from experienced operators would dramatically accelerate our trajectory.`, textarea: true },
|
|
91
|
+
{ label: "Pick one of the mentors", value: fact(biz, "mentor") || "", textarea: true },
|
|
92
|
+
{ label: "What did you get done last week", value: fact(biz, "last week") || fact(user, "last week") || "", textarea: true },
|
|
93
|
+
],
|
|
94
|
+
radios: [
|
|
95
|
+
{ section: "committed full-time", value: "yes" },
|
|
96
|
+
{ section: "raised funding before", value: "no" },
|
|
97
|
+
{ section: "currently fundraising", value: "yes" },
|
|
98
|
+
{ section: "exclusively in Nitro", value: "yes" },
|
|
99
|
+
{ section: "attend the full 1-month NYC", value: "yes" },
|
|
100
|
+
],
|
|
101
|
+
checkboxes: ["MVP / demo exists"],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function genericFill(page, user, biz) {
|
|
106
|
+
const u = user?.profile || {};
|
|
107
|
+
const b = biz?.profile || {};
|
|
108
|
+
const fact = (p, k) => getCustomFact(p, k);
|
|
109
|
+
|
|
110
|
+
const fieldMap = [
|
|
111
|
+
{ hints: ["company", "project name", "organization"], value: b.productName || b.companyName || b.name || "" },
|
|
112
|
+
{ hints: ["email"], value: u.email || "" },
|
|
113
|
+
{ hints: ["full name", "your name", "first name"], value: u.fullName || "" },
|
|
114
|
+
{ hints: ["one-line", "one liner", "tagline", "short description"], value: b.oneLiner || "" },
|
|
115
|
+
{ hints: ["what are you building", "describe your product", "about your project"], value: b.solution || "", textarea: true },
|
|
116
|
+
{ hints: ["role", "title", "position"], value: u.currentRole || "" },
|
|
117
|
+
{ hints: ["x handle", "twitter"], value: fact(user, "x handle") || fact(user, "twitter") || "" },
|
|
118
|
+
{ hints: ["linkedin"], value: u.linkedIn || "" },
|
|
119
|
+
{ hints: ["telegram"], value: fact(user, "telegram") || "" },
|
|
120
|
+
{ hints: ["github"], value: u.github || "" },
|
|
121
|
+
{ hints: ["website", "url", "product link"], value: b.website || u.portfolio || "" },
|
|
122
|
+
{ hints: ["phone", "tel"], value: u.phone || "" },
|
|
123
|
+
{ hints: ["location", "city", "where are you based"], value: u.location || b.location || "" },
|
|
124
|
+
{ hints: ["bio", "about yourself", "tell us about you"], value: u.bio || "", textarea: true },
|
|
125
|
+
{ hints: ["problem", "what problem"], value: b.problem || "", textarea: true },
|
|
126
|
+
{ hints: ["solution", "how does it work"], value: b.solution || "", textarea: true },
|
|
127
|
+
{ hints: ["traction", "progress", "metrics"], value: b.traction || "", textarea: true },
|
|
128
|
+
{ hints: ["target", "customer", "user"], value: b.targetUsers || "", textarea: true },
|
|
129
|
+
{ hints: ["business model", "revenue", "monetiz"], value: b.businessModel || "", textarea: true },
|
|
130
|
+
{ hints: ["differentiator", "competitive", "unique"], value: b.differentiators || "", textarea: true },
|
|
131
|
+
{ hints: ["why you", "right founder", "why are you"], value: u.bio || "", textarea: true },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
let filled = 0, skipped = 0;
|
|
135
|
+
for (const f of fieldMap) {
|
|
136
|
+
if (!f.value) { skipped++; continue; }
|
|
137
|
+
for (const hint of f.hints) {
|
|
138
|
+
const ok = await fillByLabel(page, hint, f.value, { textarea: f.textarea });
|
|
139
|
+
if (ok) {
|
|
140
|
+
console.log(` + "${hint}" = "${f.value.slice(0, 60)}${f.value.length > 60 ? "..." : ""}"`);
|
|
141
|
+
filled++;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { filled, skipped };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function fillForm(page, formUrl, user, biz) {
|
|
150
|
+
console.log(`\n Filling form...\n`);
|
|
151
|
+
await sleep(2000);
|
|
152
|
+
|
|
153
|
+
if (formUrl.includes("nitroacc.xyz")) {
|
|
154
|
+
const data = buildNitroData(user, biz);
|
|
155
|
+
let filled = 0, skipped = 0;
|
|
156
|
+
|
|
157
|
+
for (const f of data.fields) {
|
|
158
|
+
if (!f.value) { skipped++; continue; }
|
|
159
|
+
const ok = await fillByLabel(page, f.label, f.value, { textarea: f.textarea });
|
|
160
|
+
if (ok) {
|
|
161
|
+
console.log(` + "${f.label}" = "${f.value.slice(0, 60)}${f.value.length > 60 ? "..." : ""}"`);
|
|
162
|
+
filled++;
|
|
163
|
+
} else {
|
|
164
|
+
console.log(` x "${f.label}" — no matching input found`);
|
|
165
|
+
}
|
|
166
|
+
await sleep(150);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const r of data.radios) {
|
|
170
|
+
const ok = await clickRadio(page, r.section, r.value);
|
|
171
|
+
console.log(` ${ok ? "+" : "x"} radio: "${r.section}" = ${r.value}`);
|
|
172
|
+
await sleep(200);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const c of data.checkboxes) {
|
|
176
|
+
const ok = await clickCheckbox(page, c);
|
|
177
|
+
console.log(` ${ok ? "+" : "x"} checkbox: "${c}"`);
|
|
178
|
+
await sleep(150);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`\n Filled ${filled}, skipped ${skipped} (empty persona fields).`);
|
|
182
|
+
} else {
|
|
183
|
+
const { filled, skipped } = await genericFill(page, user, biz);
|
|
184
|
+
console.log(`\n Filled ${filled}, skipped ${skipped} (empty or no match).`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log(`\n FORM NOT SUBMITTED — review in Chrome and submit manually.\n`);
|
|
188
|
+
}
|
package/lib/local.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
export function findFastformsDir() {
|
|
6
|
+
const local = join(process.cwd(), ".fastforms");
|
|
7
|
+
if (existsSync(local)) return local;
|
|
8
|
+
|
|
9
|
+
const global = join(homedir(), ".fastforms");
|
|
10
|
+
if (existsSync(global)) return global;
|
|
11
|
+
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ensureDir(dir) {
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readJson(path) {
|
|
21
|
+
try { return JSON.parse(readFileSync(path, "utf-8")); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeJson(path, data) {
|
|
25
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize flat local format into the shape fill.js expects:
|
|
30
|
+
* { name, profile: { ...fields }, customFacts: [...] }
|
|
31
|
+
*/
|
|
32
|
+
function normalizeUser(raw) {
|
|
33
|
+
if (!raw) return null;
|
|
34
|
+
const { name, facts, ...rest } = raw;
|
|
35
|
+
return {
|
|
36
|
+
name: name || "default",
|
|
37
|
+
profile: {
|
|
38
|
+
fullName: rest.fullName || "",
|
|
39
|
+
email: rest.email || "",
|
|
40
|
+
phone: rest.phone || "",
|
|
41
|
+
location: rest.location || "",
|
|
42
|
+
linkedIn: rest.linkedIn || "",
|
|
43
|
+
github: rest.github || "",
|
|
44
|
+
portfolio: rest.portfolio || "",
|
|
45
|
+
currentRole: rest.role || rest.currentRole || "",
|
|
46
|
+
bio: rest.bio || "",
|
|
47
|
+
keySkills: rest.keySkills || "",
|
|
48
|
+
favoriteProjects: rest.favoriteProjects || "",
|
|
49
|
+
rawFacts: rest.rawFacts || "",
|
|
50
|
+
},
|
|
51
|
+
customFacts: factsToArray(facts),
|
|
52
|
+
customPreferences: [],
|
|
53
|
+
dumps: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeBusiness(raw) {
|
|
58
|
+
if (!raw) return null;
|
|
59
|
+
const { name, facts, ...rest } = raw;
|
|
60
|
+
return {
|
|
61
|
+
name: name || "default business",
|
|
62
|
+
profile: {
|
|
63
|
+
name: name || "",
|
|
64
|
+
companyName: rest.companyName || rest.company || name || "",
|
|
65
|
+
productName: rest.productName || rest.product || name || "",
|
|
66
|
+
website: rest.website || "",
|
|
67
|
+
category: rest.category || "",
|
|
68
|
+
location: rest.location || "",
|
|
69
|
+
oneLiner: rest.oneLiner || "",
|
|
70
|
+
targetUsers: rest.targetUsers || "",
|
|
71
|
+
problem: rest.problem || "",
|
|
72
|
+
solution: rest.solution || "",
|
|
73
|
+
traction: rest.traction || "",
|
|
74
|
+
businessModel: rest.businessModel || "",
|
|
75
|
+
differentiators: rest.differentiators || "",
|
|
76
|
+
value: rest.value || "",
|
|
77
|
+
rawFacts: rest.rawFacts || "",
|
|
78
|
+
},
|
|
79
|
+
customFacts: factsToArray(facts),
|
|
80
|
+
customPreferences: [],
|
|
81
|
+
dumps: [],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function factsToArray(facts) {
|
|
86
|
+
if (!facts || typeof facts !== "object") return [];
|
|
87
|
+
return Object.entries(facts).map(([key, value], i) => ({
|
|
88
|
+
id: `fact-${i}`,
|
|
89
|
+
key,
|
|
90
|
+
value: String(value),
|
|
91
|
+
enabled: true,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function loadLocalPersonas(dir) {
|
|
96
|
+
const userPath = join(dir, "user.json");
|
|
97
|
+
const bizPath = join(dir, "business.json");
|
|
98
|
+
|
|
99
|
+
const rawUser = readJson(userPath);
|
|
100
|
+
const rawBiz = readJson(bizPath);
|
|
101
|
+
|
|
102
|
+
const personas = rawUser ? [normalizeUser(rawUser)] : [];
|
|
103
|
+
const businessPersonas = rawBiz ? [normalizeBusiness(rawBiz)] : [];
|
|
104
|
+
|
|
105
|
+
return { personas, businessPersonas };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function saveUserPersona(dir, data) {
|
|
109
|
+
ensureDir(dir);
|
|
110
|
+
writeJson(join(dir, "user.json"), data);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function saveBusinessPersona(dir, data) {
|
|
114
|
+
ensureDir(dir);
|
|
115
|
+
writeJson(join(dir, "business.json"), data);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function loadDefaults(dir) {
|
|
119
|
+
return readJson(join(dir, "defaults.json")) || {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function saveDefaults(dir, patch) {
|
|
123
|
+
const current = loadDefaults(dir);
|
|
124
|
+
writeJson(join(dir, "defaults.json"), { ...current, ...patch });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function userTemplate() {
|
|
128
|
+
return {
|
|
129
|
+
name: "",
|
|
130
|
+
fullName: "",
|
|
131
|
+
email: "",
|
|
132
|
+
role: "",
|
|
133
|
+
location: "",
|
|
134
|
+
linkedIn: "",
|
|
135
|
+
github: "",
|
|
136
|
+
bio: "",
|
|
137
|
+
facts: {},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function businessTemplate() {
|
|
142
|
+
return {
|
|
143
|
+
name: "",
|
|
144
|
+
oneLiner: "",
|
|
145
|
+
website: "",
|
|
146
|
+
category: "",
|
|
147
|
+
location: "",
|
|
148
|
+
problem: "",
|
|
149
|
+
solution: "",
|
|
150
|
+
targetUsers: "",
|
|
151
|
+
traction: "",
|
|
152
|
+
businessModel: "",
|
|
153
|
+
differentiators: "",
|
|
154
|
+
facts: {},
|
|
155
|
+
};
|
|
156
|
+
}
|
package/lib/personas.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import puppeteer from "puppeteer-core";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { createInterface } from "readline";
|
|
6
|
+
|
|
7
|
+
const FASTFORMS_URL = "https://293-fastforms.vercel.app/";
|
|
8
|
+
const PERSONA_URL = "https://293-fastforms.vercel.app/persona";
|
|
9
|
+
const CONFIG_PATH = join(homedir(), ".fastforms.json");
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEYS = {
|
|
12
|
+
personas: "fastforms.personas.v1",
|
|
13
|
+
businessPersonas: "fastforms.businessPersonas.v1",
|
|
14
|
+
applications: "fastforms.applications.v1",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function loadConfig() {
|
|
18
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch { return {}; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function saveConfig(patch) {
|
|
22
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ ...loadConfig(), ...patch }, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ask(prompt) {
|
|
26
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
27
|
+
return new Promise((r) => rl.question(prompt, (a) => { rl.close(); r(a.trim()); }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function connectToChrome(port = 9222) {
|
|
31
|
+
return puppeteer.connect({ browserURL: `http://127.0.0.1:${port}` });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function pullPersonas(browser) {
|
|
35
|
+
const pages = await browser.pages();
|
|
36
|
+
let tab = pages.find((p) => p.url().includes("293-fastforms"));
|
|
37
|
+
let opened = false;
|
|
38
|
+
|
|
39
|
+
if (tab) {
|
|
40
|
+
console.log(" Using existing FastForms tab.");
|
|
41
|
+
} else {
|
|
42
|
+
tab = await browser.newPage();
|
|
43
|
+
await tab.goto(FASTFORMS_URL, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
44
|
+
console.log(" Opened FastForms tab.");
|
|
45
|
+
opened = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dump = await tab.evaluate((keys) => {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [name, sk] of Object.entries(keys)) {
|
|
51
|
+
const raw = localStorage.getItem(sk);
|
|
52
|
+
if (raw) try { out[name] = JSON.parse(raw); } catch { out[name] = raw; }
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}, STORAGE_KEYS);
|
|
56
|
+
|
|
57
|
+
if (opened) await tab.close();
|
|
58
|
+
return dump;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function pickPersona(list, hint, keys) {
|
|
62
|
+
if (!list?.length) return null;
|
|
63
|
+
if (!hint) return list[0];
|
|
64
|
+
const h = hint.toLowerCase();
|
|
65
|
+
for (const k of keys) {
|
|
66
|
+
const match = list.find((p) => {
|
|
67
|
+
const val = k.split(".").reduce((o, s) => o?.[s], p);
|
|
68
|
+
return typeof val === "string" && val.toLowerCase().includes(h);
|
|
69
|
+
});
|
|
70
|
+
if (match) return match;
|
|
71
|
+
}
|
|
72
|
+
return list[0];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getCustomFact(persona, key) {
|
|
76
|
+
if (!persona) return "";
|
|
77
|
+
const h = key.toLowerCase();
|
|
78
|
+
return (persona.customFacts || []).find(
|
|
79
|
+
(f) => f.enabled !== false && f.key?.toLowerCase().includes(h)
|
|
80
|
+
)?.value || "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printPersonaSummary(label, persona, profileKeys) {
|
|
84
|
+
if (!persona) return;
|
|
85
|
+
const p = persona.profile || {};
|
|
86
|
+
console.log(`\n ${label}: ${persona.name}`);
|
|
87
|
+
for (const [key, display] of profileKeys) {
|
|
88
|
+
const val = p[key];
|
|
89
|
+
if (val) console.log(` ${display}: ${String(val).slice(0, 80)}${String(val).length > 80 ? "..." : ""}`);
|
|
90
|
+
}
|
|
91
|
+
const facts = (persona.customFacts || []).filter((f) => f.enabled !== false && f.value);
|
|
92
|
+
if (facts.length) console.log(` Custom facts: ${facts.length}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function showPersonaDetails(user, biz) {
|
|
96
|
+
printPersonaSummary("User persona", user, [
|
|
97
|
+
["fullName", "Name"],
|
|
98
|
+
["email", "Email"],
|
|
99
|
+
["currentRole", "Role"],
|
|
100
|
+
["location", "Location"],
|
|
101
|
+
["linkedIn", "LinkedIn"],
|
|
102
|
+
["github", "GitHub"],
|
|
103
|
+
]);
|
|
104
|
+
printPersonaSummary("Business persona", biz, [
|
|
105
|
+
["companyName", "Company"],
|
|
106
|
+
["productName", "Product"],
|
|
107
|
+
["oneLiner", "One-liner"],
|
|
108
|
+
["website", "Website"],
|
|
109
|
+
["category", "Category"],
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function selectPersonas(dump, userHint, bizHint) {
|
|
114
|
+
const config = loadConfig();
|
|
115
|
+
const personas = dump.personas || [];
|
|
116
|
+
const bizPersonas = dump.businessPersonas || [];
|
|
117
|
+
|
|
118
|
+
// Use defaults if no hint and defaults are saved
|
|
119
|
+
const effectiveUserHint = userHint || config.defaultUser || "";
|
|
120
|
+
const effectiveBizHint = bizHint || config.defaultBusiness || "";
|
|
121
|
+
|
|
122
|
+
let user = pickPersona(personas, effectiveUserHint, ["name", "profile.fullName"]);
|
|
123
|
+
let biz = pickPersona(bizPersonas, effectiveBizHint, ["name", "profile.companyName", "profile.productName"]);
|
|
124
|
+
|
|
125
|
+
// Interactive selection if multiple and no effective hint
|
|
126
|
+
if (!effectiveUserHint && personas.length > 1) {
|
|
127
|
+
console.log("\n User personas:\n");
|
|
128
|
+
personas.forEach((p, i) => console.log(` ${i + 1}. ${p.name} (${p.profile?.fullName || "?"})`));
|
|
129
|
+
const ans = await ask(`\n Pick user persona [1-${personas.length}]: `);
|
|
130
|
+
const idx = Number(ans) - 1;
|
|
131
|
+
if (idx >= 0 && idx < personas.length) user = personas[idx];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!effectiveBizHint && bizPersonas.length > 1) {
|
|
135
|
+
console.log("\n Business personas:\n");
|
|
136
|
+
bizPersonas.forEach((p, i) => console.log(` ${i + 1}. ${p.name} (${p.profile?.companyName || "?"})`));
|
|
137
|
+
const ans = await ask(`\n Pick business persona [1-${bizPersonas.length}]: `);
|
|
138
|
+
const idx = Number(ans) - 1;
|
|
139
|
+
if (idx >= 0 && idx < bizPersonas.length) biz = bizPersonas[idx];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { user, biz };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function offerSetDefaults(user, biz) {
|
|
146
|
+
const config = loadConfig();
|
|
147
|
+
const userChanged = user && config.defaultUser !== user.name;
|
|
148
|
+
const bizChanged = biz && config.defaultBusiness !== biz.name;
|
|
149
|
+
|
|
150
|
+
if (!userChanged && !bizChanged) return;
|
|
151
|
+
|
|
152
|
+
const names = [user?.name, biz?.name].filter(Boolean).join(" + ");
|
|
153
|
+
const ans = await ask(`\n Save "${names}" as default personas? [y/N]: `);
|
|
154
|
+
if (ans.toLowerCase() === "y") {
|
|
155
|
+
const patch = {};
|
|
156
|
+
if (user) patch.defaultUser = user.name;
|
|
157
|
+
if (biz) patch.defaultBusiness = biz.name;
|
|
158
|
+
saveConfig(patch);
|
|
159
|
+
console.log(" Defaults saved to ~/.fastforms.json");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function offerOpenPersonaManager(browser) {
|
|
164
|
+
const ans = await ask("\n Open persona manager in Chrome? [y/N]: ");
|
|
165
|
+
if (ans.toLowerCase() === "y") {
|
|
166
|
+
const pages = await browser.pages();
|
|
167
|
+
const existing = pages.find((p) => p.url().includes("293-fastforms.vercel.app/persona"));
|
|
168
|
+
if (existing) {
|
|
169
|
+
await existing.bringToFront();
|
|
170
|
+
} else {
|
|
171
|
+
const tab = await browser.newPage();
|
|
172
|
+
await tab.goto(PERSONA_URL, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
173
|
+
}
|
|
174
|
+
console.log(" Opened: " + PERSONA_URL);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/lib/platforms.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { execFileSync } from "child_process";
|
|
5
|
+
|
|
6
|
+
const HOME = homedir();
|
|
7
|
+
|
|
8
|
+
const PLATFORMS = {
|
|
9
|
+
darwin: {
|
|
10
|
+
chromePaths: [
|
|
11
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
12
|
+
`${HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
13
|
+
],
|
|
14
|
+
dataDir: join(HOME, "Library", "Application Support", "Google", "Chrome"),
|
|
15
|
+
},
|
|
16
|
+
linux: {
|
|
17
|
+
chromePaths: [
|
|
18
|
+
"/usr/bin/google-chrome-stable",
|
|
19
|
+
"/usr/bin/google-chrome",
|
|
20
|
+
"/usr/bin/chromium-browser",
|
|
21
|
+
"/usr/bin/chromium",
|
|
22
|
+
"/snap/bin/chromium",
|
|
23
|
+
],
|
|
24
|
+
dataDir: join(HOME, ".config", "google-chrome"),
|
|
25
|
+
},
|
|
26
|
+
win32: {
|
|
27
|
+
chromePaths: [
|
|
28
|
+
join(process.env.PROGRAMFILES || "", "Google", "Chrome", "Application", "chrome.exe"),
|
|
29
|
+
join(process.env["PROGRAMFILES(X86)"] || "", "Google", "Chrome", "Application", "chrome.exe"),
|
|
30
|
+
join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
|
|
31
|
+
],
|
|
32
|
+
dataDir: join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "User Data"),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function getPlatformConfig() {
|
|
37
|
+
const cfg = PLATFORMS[process.platform];
|
|
38
|
+
if (!cfg) throw new Error(`Unsupported platform: ${process.platform}`);
|
|
39
|
+
return cfg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function findChrome() {
|
|
43
|
+
const { chromePaths } = getPlatformConfig();
|
|
44
|
+
for (const p of chromePaths) {
|
|
45
|
+
if (existsSync(p)) return p;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getChromeVersion(chromePath) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = execFileSync(chromePath, ["--version"], {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
timeout: 5000,
|
|
55
|
+
}).trim();
|
|
56
|
+
const match = raw.match(/(\d+)\.\d+\.\d+\.\d+/);
|
|
57
|
+
return match ? { full: match[0], major: Number(match[1]) } : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getDataDir() {
|
|
64
|
+
return getPlatformConfig().dataDir;
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@1dolinski/fastforms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fill any form fast. Manage personas on the web, fill forms from your terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fastforms": "./bin/fastforms.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"SKILL.md",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"puppeteer-core": "^24.39.1"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/1dolinski/fastforms.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"forms",
|
|
28
|
+
"automation",
|
|
29
|
+
"chrome",
|
|
30
|
+
"puppeteer",
|
|
31
|
+
"personas"
|
|
32
|
+
]
|
|
33
|
+
}
|