@venturewild/workspace 0.6.3 → 0.6.5
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/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +85 -85
- package/server/bin/wild-workspace.mjs +1096 -1096
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +974 -974
- package/server/src/bazaar/index.mjs +88 -88
- package/server/src/bazaar/mcp-server.mjs +429 -429
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
- package/server/src/canvas/core.mjs +446 -446
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -108
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +3332 -3332
- package/server/src/listings-rails.mjs +156 -156
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +295 -295
- package/server/src/workspaces.mjs +145 -145
- package/web/dist/assets/index-nEl9swiQ.js +131 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DVflHhYJ.js +0 -131
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
// Mock "TickUp" candidate-matching service.
|
|
2
|
-
//
|
|
3
|
-
// v1 stand-in for the producer's real tool/API (there is no real TickUp). The
|
|
4
|
-
// point is the FLOW, not real ML: a deterministic, offline ranker so the wiring
|
|
5
|
-
// is demonstrable and the demo looks smart. The built site POSTs { role,
|
|
6
|
-
// candidates } here and renders the ranked result.
|
|
7
|
-
|
|
8
|
-
const STOPWORDS = new Set([
|
|
9
|
-
'a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'at',
|
|
10
|
-
'by', 'plus', 'pace', 'senior', 'junior', 'mid', 'lead', 'staff', 'engineer',
|
|
11
|
-
'developer', 'years', 'year', 'yr', 'yrs', 'experience', 'exp', 'who', 'can',
|
|
12
|
-
'is', 'are', 'be', 'we', 'you', 'your', 'our', 'team', 'role', 'work',
|
|
13
|
-
]);
|
|
14
|
-
|
|
15
|
-
function tokenize(text) {
|
|
16
|
-
return new Set(
|
|
17
|
-
String(text || '')
|
|
18
|
-
.toLowerCase()
|
|
19
|
-
.replace(/[^a-z0-9+#.]+/g, ' ')
|
|
20
|
-
.split(' ')
|
|
21
|
-
.map((w) => w.trim())
|
|
22
|
-
.filter((w) => w.length >= 2 && !STOPWORDS.has(w)),
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function parseYears(text) {
|
|
27
|
-
const m = String(text || '').match(/(\d+)\s*\+?\s*(?:y|yr|yrs|year|years)\b/i);
|
|
28
|
-
return m ? Math.min(parseInt(m[1], 10), 25) : 0;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function parseCandidate(line) {
|
|
32
|
-
const raw = String(line || '').trim();
|
|
33
|
-
// split "Name — skills" on em/en dash, hyphen, or colon
|
|
34
|
-
const m = raw.split(/\s+[—–:-]\s+/);
|
|
35
|
-
let name = raw;
|
|
36
|
-
let skills = '';
|
|
37
|
-
if (m.length >= 2) {
|
|
38
|
-
name = m[0].trim();
|
|
39
|
-
skills = m.slice(1).join(' ').trim();
|
|
40
|
-
}
|
|
41
|
-
return { name: name || raw, skills, raw };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
45
|
-
|
|
46
|
-
export function matchCandidates({ role, candidates } = {}) {
|
|
47
|
-
const roleTokens = tokenize(role);
|
|
48
|
-
const list = (Array.isArray(candidates) ? candidates : [])
|
|
49
|
-
// Defensive: a candidate may be a string, an object, or junk (null/number).
|
|
50
|
-
// `typeof null === 'object'` would crash `null.name`, so coerce carefully.
|
|
51
|
-
.map((c) => {
|
|
52
|
-
if (typeof c === 'string') return c;
|
|
53
|
-
if (c && typeof c === 'object') return `${c.name || ''} — ${c.skills || ''}`;
|
|
54
|
-
return '';
|
|
55
|
-
})
|
|
56
|
-
.map((l) => String(l).trim())
|
|
57
|
-
.filter(Boolean)
|
|
58
|
-
.filter((l) => /[a-z0-9]/i.test(l)) // drop punctuation-only lines (e.g. "{}" -> " — ")
|
|
59
|
-
.slice(0, 500); // bound CPU on a publicly-reachable endpoint
|
|
60
|
-
|
|
61
|
-
const ranked = list.map((line) => {
|
|
62
|
-
const { name, skills, raw } = parseCandidate(line);
|
|
63
|
-
// Skills as a structured list (chips) — the contract returns string[], so a
|
|
64
|
-
// consumer's site can render them directly without re-parsing.
|
|
65
|
-
const skillList = skills
|
|
66
|
-
.split(/[,;/]/)
|
|
67
|
-
.map((s) => s.trim())
|
|
68
|
-
.filter(Boolean)
|
|
69
|
-
.slice(0, 8);
|
|
70
|
-
const candTokens = tokenize(`${skills} ${name}`);
|
|
71
|
-
const matched = [...roleTokens].filter((t) => candTokens.has(t));
|
|
72
|
-
const years = parseYears(raw);
|
|
73
|
-
const yearsBonus = Math.min(years, 10) * 2;
|
|
74
|
-
let score;
|
|
75
|
-
if (matched.length > 0) {
|
|
76
|
-
score = clamp(40 + matched.length * 14 + yearsBonus, 45, 98);
|
|
77
|
-
} else {
|
|
78
|
-
score = clamp(8 + yearsBonus, 5, 35);
|
|
79
|
-
}
|
|
80
|
-
const top = matched.slice(0, 3);
|
|
81
|
-
let why;
|
|
82
|
-
if (top.length > 0) {
|
|
83
|
-
why =
|
|
84
|
-
`Strong on ${top.join(', ')}` +
|
|
85
|
-
(years ? ` · ${years}y experience` : '') +
|
|
86
|
-
(matched.length > 3 ? ` · +${matched.length - 3} more matches` : '');
|
|
87
|
-
} else {
|
|
88
|
-
why = years
|
|
89
|
-
? `Limited overlap with the role, but ${years}y of experience`
|
|
90
|
-
: 'Limited overlap with the role';
|
|
91
|
-
}
|
|
92
|
-
return { name, skills: skillList, score: Math.round(score), why, matched };
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
ranked.sort((a, b) => b.score - a.score);
|
|
96
|
-
return { role: String(role || ''), count: ranked.length, ranked };
|
|
97
|
-
}
|
|
1
|
+
// Mock "TickUp" candidate-matching service.
|
|
2
|
+
//
|
|
3
|
+
// v1 stand-in for the producer's real tool/API (there is no real TickUp). The
|
|
4
|
+
// point is the FLOW, not real ML: a deterministic, offline ranker so the wiring
|
|
5
|
+
// is demonstrable and the demo looks smart. The built site POSTs { role,
|
|
6
|
+
// candidates } here and renders the ranked result.
|
|
7
|
+
|
|
8
|
+
const STOPWORDS = new Set([
|
|
9
|
+
'a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'at',
|
|
10
|
+
'by', 'plus', 'pace', 'senior', 'junior', 'mid', 'lead', 'staff', 'engineer',
|
|
11
|
+
'developer', 'years', 'year', 'yr', 'yrs', 'experience', 'exp', 'who', 'can',
|
|
12
|
+
'is', 'are', 'be', 'we', 'you', 'your', 'our', 'team', 'role', 'work',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function tokenize(text) {
|
|
16
|
+
return new Set(
|
|
17
|
+
String(text || '')
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9+#.]+/g, ' ')
|
|
20
|
+
.split(' ')
|
|
21
|
+
.map((w) => w.trim())
|
|
22
|
+
.filter((w) => w.length >= 2 && !STOPWORDS.has(w)),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseYears(text) {
|
|
27
|
+
const m = String(text || '').match(/(\d+)\s*\+?\s*(?:y|yr|yrs|year|years)\b/i);
|
|
28
|
+
return m ? Math.min(parseInt(m[1], 10), 25) : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseCandidate(line) {
|
|
32
|
+
const raw = String(line || '').trim();
|
|
33
|
+
// split "Name — skills" on em/en dash, hyphen, or colon
|
|
34
|
+
const m = raw.split(/\s+[—–:-]\s+/);
|
|
35
|
+
let name = raw;
|
|
36
|
+
let skills = '';
|
|
37
|
+
if (m.length >= 2) {
|
|
38
|
+
name = m[0].trim();
|
|
39
|
+
skills = m.slice(1).join(' ').trim();
|
|
40
|
+
}
|
|
41
|
+
return { name: name || raw, skills, raw };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
45
|
+
|
|
46
|
+
export function matchCandidates({ role, candidates } = {}) {
|
|
47
|
+
const roleTokens = tokenize(role);
|
|
48
|
+
const list = (Array.isArray(candidates) ? candidates : [])
|
|
49
|
+
// Defensive: a candidate may be a string, an object, or junk (null/number).
|
|
50
|
+
// `typeof null === 'object'` would crash `null.name`, so coerce carefully.
|
|
51
|
+
.map((c) => {
|
|
52
|
+
if (typeof c === 'string') return c;
|
|
53
|
+
if (c && typeof c === 'object') return `${c.name || ''} — ${c.skills || ''}`;
|
|
54
|
+
return '';
|
|
55
|
+
})
|
|
56
|
+
.map((l) => String(l).trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.filter((l) => /[a-z0-9]/i.test(l)) // drop punctuation-only lines (e.g. "{}" -> " — ")
|
|
59
|
+
.slice(0, 500); // bound CPU on a publicly-reachable endpoint
|
|
60
|
+
|
|
61
|
+
const ranked = list.map((line) => {
|
|
62
|
+
const { name, skills, raw } = parseCandidate(line);
|
|
63
|
+
// Skills as a structured list (chips) — the contract returns string[], so a
|
|
64
|
+
// consumer's site can render them directly without re-parsing.
|
|
65
|
+
const skillList = skills
|
|
66
|
+
.split(/[,;/]/)
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.slice(0, 8);
|
|
70
|
+
const candTokens = tokenize(`${skills} ${name}`);
|
|
71
|
+
const matched = [...roleTokens].filter((t) => candTokens.has(t));
|
|
72
|
+
const years = parseYears(raw);
|
|
73
|
+
const yearsBonus = Math.min(years, 10) * 2;
|
|
74
|
+
let score;
|
|
75
|
+
if (matched.length > 0) {
|
|
76
|
+
score = clamp(40 + matched.length * 14 + yearsBonus, 45, 98);
|
|
77
|
+
} else {
|
|
78
|
+
score = clamp(8 + yearsBonus, 5, 35);
|
|
79
|
+
}
|
|
80
|
+
const top = matched.slice(0, 3);
|
|
81
|
+
let why;
|
|
82
|
+
if (top.length > 0) {
|
|
83
|
+
why =
|
|
84
|
+
`Strong on ${top.join(', ')}` +
|
|
85
|
+
(years ? ` · ${years}y experience` : '') +
|
|
86
|
+
(matched.length > 3 ? ` · +${matched.length - 3} more matches` : '');
|
|
87
|
+
} else {
|
|
88
|
+
why = years
|
|
89
|
+
? `Limited overlap with the role, but ${years}y of experience`
|
|
90
|
+
: 'Limited overlap with the role';
|
|
91
|
+
}
|
|
92
|
+
return { name, skills: skillList, score: Math.round(score), why, matched };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
96
|
+
return { role: String(role || ''), count: ranked.length, ranked };
|
|
97
|
+
}
|
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
// Bazaar preview host — static-file helper for serving the agent's build through
|
|
2
|
-
// the MAIN wild-workspace server at /preview/* (same-origin, behind the existing
|
|
3
|
-
// auth/role middleware). NOT a separate listener: serving same-origin means no
|
|
4
|
-
// port to squat (no collision with the user's real dev servers), no mixed-content
|
|
5
|
-
// under the public https proxy, and the built page's fetch('./match') is genuinely
|
|
6
|
-
// same-origin. The matching service (POST /preview/match) and the live earnings
|
|
7
|
-
// broadcast live in index.mjs, which has the activity bus + chat clients.
|
|
8
|
-
//
|
|
9
|
-
// The build itself is the USER'S PRODUCT and lives in their workspace; this module
|
|
10
|
-
// only READS from a build dir. No bazaar bookkeeping is ever written into the
|
|
11
|
-
// synced workspace (CLAUDE.md rule #1) — that all lives in ~/.wild-workspace/bazaar.
|
|
12
|
-
|
|
13
|
-
import fs from 'node:fs';
|
|
14
|
-
import path from 'node:path';
|
|
15
|
-
import mime from 'mime-types';
|
|
16
|
-
|
|
17
|
-
// Confine the preview build dir to the user's workspace. `dir` comes from the
|
|
18
|
-
// agent (preview.json) and is NOT trusted to stay inside the workspace — an
|
|
19
|
-
// absolute path or `..` segments would otherwise let /preview/* read anywhere on
|
|
20
|
-
// disk (host secrets). Returns the absolute build dir, or null if it escapes.
|
|
21
|
-
export function confineBuildDir(workspaceDir, dir) {
|
|
22
|
-
if (!dir) return null;
|
|
23
|
-
const root = path.resolve(workspaceDir);
|
|
24
|
-
const resolved = path.resolve(root, dir);
|
|
25
|
-
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
26
|
-
return resolved;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// True if `file` (after symlink resolution) stays inside `root` (after symlink
|
|
30
|
-
// resolution). Defeats a symlink/junction planted in the build dir that points
|
|
31
|
-
// outside it — the lexical guard in resolvePreviewFile follows no symlinks, but
|
|
32
|
-
// fs.readFile does.
|
|
33
|
-
function realpathInside(file, root) {
|
|
34
|
-
try {
|
|
35
|
-
const realRoot = fs.realpathSync(root);
|
|
36
|
-
const realFile = fs.realpathSync(file);
|
|
37
|
-
return realFile === realRoot || realFile.startsWith(realRoot + path.sep);
|
|
38
|
-
} catch {
|
|
39
|
-
return false; // missing/broken link — refuse
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Resolve a request path (everything after /preview/) to an absolute file inside
|
|
44
|
-
// buildDir, with a path-traversal guard. Returns null if it escapes buildDir.
|
|
45
|
-
export function resolvePreviewFile(buildDir, reqPath) {
|
|
46
|
-
const root = path.resolve(buildDir);
|
|
47
|
-
const clean = decodeURIComponent(String(reqPath || '').split('?')[0]).replace(/\\/g, '/');
|
|
48
|
-
const rel = clean.replace(/^\/+/, '');
|
|
49
|
-
const resolved = path.resolve(root, rel || 'index.html');
|
|
50
|
-
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
51
|
-
return resolved;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Serve one file from the build dir. Returns { status, contentType, body, headers }.
|
|
55
|
-
// body is a Buffer (file bytes) or a string (the waiting/404 page).
|
|
56
|
-
export function servePreviewFile(buildDir, reqPath) {
|
|
57
|
-
if (!buildDir) {
|
|
58
|
-
return { status: 200, contentType: 'text/html', body: WAITING_PAGE };
|
|
59
|
-
}
|
|
60
|
-
const file = resolvePreviewFile(buildDir, reqPath);
|
|
61
|
-
if (!file) return { status: 403, contentType: 'text/plain', body: 'forbidden' };
|
|
62
|
-
|
|
63
|
-
let target = file;
|
|
64
|
-
try {
|
|
65
|
-
if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
|
|
66
|
-
} catch {
|
|
67
|
-
/* not a dir / doesn't exist — fall through to read */
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
// Symlink guard: the lexical check above follows no symlinks, but readFileSync
|
|
72
|
-
// does — so re-check the real path stays inside the build dir before reading.
|
|
73
|
-
if (!realpathInside(target, buildDir)) {
|
|
74
|
-
return { status: 403, contentType: 'text/plain', body: 'forbidden' };
|
|
75
|
-
}
|
|
76
|
-
const body = fs.readFileSync(target);
|
|
77
|
-
const contentType = mime.lookup(target) || 'application/octet-stream';
|
|
78
|
-
return { status: 200, contentType, body, headers: { 'Cache-Control': 'no-store' } };
|
|
79
|
-
} catch {
|
|
80
|
-
// SPA-ish fallback: serve index.html for unknown paths if it exists.
|
|
81
|
-
try {
|
|
82
|
-
const body = fs.readFileSync(path.join(path.resolve(buildDir), 'index.html'));
|
|
83
|
-
return { status: 200, contentType: 'text/html', body, headers: { 'Cache-Control': 'no-store' } };
|
|
84
|
-
} catch {
|
|
85
|
-
return { status: 404, contentType: 'text/html', body: NOT_FOUND_PAGE };
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export const WAITING_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
|
|
91
|
-
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f1117;color:#9aa4b2}div{text-align:center;max-width:340px;line-height:1.6}</style>
|
|
92
|
-
</head><body><div><p>🛠️ Your build will appear here the moment your agent finishes it.</p></div></body></html>`;
|
|
93
|
-
|
|
94
|
-
export const NOT_FOUND_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Not found</title></head>
|
|
95
|
-
<body style="font-family:system-ui;padding:40px;color:#555">Nothing here yet.</body></html>`;
|
|
1
|
+
// Bazaar preview host — static-file helper for serving the agent's build through
|
|
2
|
+
// the MAIN wild-workspace server at /preview/* (same-origin, behind the existing
|
|
3
|
+
// auth/role middleware). NOT a separate listener: serving same-origin means no
|
|
4
|
+
// port to squat (no collision with the user's real dev servers), no mixed-content
|
|
5
|
+
// under the public https proxy, and the built page's fetch('./match') is genuinely
|
|
6
|
+
// same-origin. The matching service (POST /preview/match) and the live earnings
|
|
7
|
+
// broadcast live in index.mjs, which has the activity bus + chat clients.
|
|
8
|
+
//
|
|
9
|
+
// The build itself is the USER'S PRODUCT and lives in their workspace; this module
|
|
10
|
+
// only READS from a build dir. No bazaar bookkeeping is ever written into the
|
|
11
|
+
// synced workspace (CLAUDE.md rule #1) — that all lives in ~/.wild-workspace/bazaar.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import mime from 'mime-types';
|
|
16
|
+
|
|
17
|
+
// Confine the preview build dir to the user's workspace. `dir` comes from the
|
|
18
|
+
// agent (preview.json) and is NOT trusted to stay inside the workspace — an
|
|
19
|
+
// absolute path or `..` segments would otherwise let /preview/* read anywhere on
|
|
20
|
+
// disk (host secrets). Returns the absolute build dir, or null if it escapes.
|
|
21
|
+
export function confineBuildDir(workspaceDir, dir) {
|
|
22
|
+
if (!dir) return null;
|
|
23
|
+
const root = path.resolve(workspaceDir);
|
|
24
|
+
const resolved = path.resolve(root, dir);
|
|
25
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// True if `file` (after symlink resolution) stays inside `root` (after symlink
|
|
30
|
+
// resolution). Defeats a symlink/junction planted in the build dir that points
|
|
31
|
+
// outside it — the lexical guard in resolvePreviewFile follows no symlinks, but
|
|
32
|
+
// fs.readFile does.
|
|
33
|
+
function realpathInside(file, root) {
|
|
34
|
+
try {
|
|
35
|
+
const realRoot = fs.realpathSync(root);
|
|
36
|
+
const realFile = fs.realpathSync(file);
|
|
37
|
+
return realFile === realRoot || realFile.startsWith(realRoot + path.sep);
|
|
38
|
+
} catch {
|
|
39
|
+
return false; // missing/broken link — refuse
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve a request path (everything after /preview/) to an absolute file inside
|
|
44
|
+
// buildDir, with a path-traversal guard. Returns null if it escapes buildDir.
|
|
45
|
+
export function resolvePreviewFile(buildDir, reqPath) {
|
|
46
|
+
const root = path.resolve(buildDir);
|
|
47
|
+
const clean = decodeURIComponent(String(reqPath || '').split('?')[0]).replace(/\\/g, '/');
|
|
48
|
+
const rel = clean.replace(/^\/+/, '');
|
|
49
|
+
const resolved = path.resolve(root, rel || 'index.html');
|
|
50
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Serve one file from the build dir. Returns { status, contentType, body, headers }.
|
|
55
|
+
// body is a Buffer (file bytes) or a string (the waiting/404 page).
|
|
56
|
+
export function servePreviewFile(buildDir, reqPath) {
|
|
57
|
+
if (!buildDir) {
|
|
58
|
+
return { status: 200, contentType: 'text/html', body: WAITING_PAGE };
|
|
59
|
+
}
|
|
60
|
+
const file = resolvePreviewFile(buildDir, reqPath);
|
|
61
|
+
if (!file) return { status: 403, contentType: 'text/plain', body: 'forbidden' };
|
|
62
|
+
|
|
63
|
+
let target = file;
|
|
64
|
+
try {
|
|
65
|
+
if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
|
|
66
|
+
} catch {
|
|
67
|
+
/* not a dir / doesn't exist — fall through to read */
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Symlink guard: the lexical check above follows no symlinks, but readFileSync
|
|
72
|
+
// does — so re-check the real path stays inside the build dir before reading.
|
|
73
|
+
if (!realpathInside(target, buildDir)) {
|
|
74
|
+
return { status: 403, contentType: 'text/plain', body: 'forbidden' };
|
|
75
|
+
}
|
|
76
|
+
const body = fs.readFileSync(target);
|
|
77
|
+
const contentType = mime.lookup(target) || 'application/octet-stream';
|
|
78
|
+
return { status: 200, contentType, body, headers: { 'Cache-Control': 'no-store' } };
|
|
79
|
+
} catch {
|
|
80
|
+
// SPA-ish fallback: serve index.html for unknown paths if it exists.
|
|
81
|
+
try {
|
|
82
|
+
const body = fs.readFileSync(path.join(path.resolve(buildDir), 'index.html'));
|
|
83
|
+
return { status: 200, contentType: 'text/html', body, headers: { 'Cache-Control': 'no-store' } };
|
|
84
|
+
} catch {
|
|
85
|
+
return { status: 404, contentType: 'text/html', body: NOT_FOUND_PAGE };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const WAITING_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
|
|
91
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f1117;color:#9aa4b2}div{text-align:center;max-width:340px;line-height:1.6}</style>
|
|
92
|
+
</head><body><div><p>🛠️ Your build will appear here the moment your agent finishes it.</p></div></body></html>`;
|
|
93
|
+
|
|
94
|
+
export const NOT_FOUND_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Not found</title></head>
|
|
95
|
+
<body style="font-family:system-ui;padding:40px;color:#555">Nothing here yet.</body></html>`;
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
# Marco's feedback form — how to build it
|
|
2
|
-
|
|
3
|
-
> Absorb and build in one shot. Single self-contained `feedback-form/index.html`
|
|
4
|
-
> (inline CSS + JS), no build step.
|
|
5
|
-
|
|
6
|
-
## Build steps
|
|
7
|
-
|
|
8
|
-
1. Create `feedback-form/index.html`.
|
|
9
|
-
2. The page:
|
|
10
|
-
- A **star rating** (1–5, clickable).
|
|
11
|
-
- A **comment** textarea.
|
|
12
|
-
- A **Submit** button. On submit, store `{rating, comment, ts}` in
|
|
13
|
-
`localStorage` and clear the form.
|
|
14
|
-
- A **live summary**: average rating across stored responses + the 3 most
|
|
15
|
-
recent comments. Updates immediately on submit.
|
|
16
|
-
3. Clean, friendly styling, one accent color. Prefill nothing; show a small empty
|
|
17
|
-
state ("Be the first to leave feedback").
|
|
18
|
-
|
|
19
|
-
## Finish
|
|
20
|
-
|
|
21
|
-
After writing the files: call **`launch_preview`** `{ "dir": "feedback-form" }`,
|
|
22
|
-
then **`record_use`** with recipe id `customer-feedback-form` and a short summary,
|
|
23
|
-
then tell the user it's live in the preview (briefly).
|
|
1
|
+
# Marco's feedback form — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb and build in one shot. Single self-contained `feedback-form/index.html`
|
|
4
|
+
> (inline CSS + JS), no build step.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `feedback-form/index.html`.
|
|
9
|
+
2. The page:
|
|
10
|
+
- A **star rating** (1–5, clickable).
|
|
11
|
+
- A **comment** textarea.
|
|
12
|
+
- A **Submit** button. On submit, store `{rating, comment, ts}` in
|
|
13
|
+
`localStorage` and clear the form.
|
|
14
|
+
- A **live summary**: average rating across stored responses + the 3 most
|
|
15
|
+
recent comments. Updates immediately on submit.
|
|
16
|
+
3. Clean, friendly styling, one accent color. Prefill nothing; show a small empty
|
|
17
|
+
state ("Be the first to leave feedback").
|
|
18
|
+
|
|
19
|
+
## Finish
|
|
20
|
+
|
|
21
|
+
After writing the files: call **`launch_preview`** `{ "dir": "feedback-form" }`,
|
|
22
|
+
then **`record_use`** with recipe id `customer-feedback-form` and a short summary,
|
|
23
|
+
then tell the user it's live in the preview (briefly).
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "customer-feedback-form",
|
|
3
|
-
"title": "Customer feedback form with instant summary",
|
|
4
|
-
"producer": { "name": "Marco", "handle": "marco", "kind": "maker" },
|
|
5
|
-
"pitch": "Collect feedback and see the mood at a glance — built in one shot.",
|
|
6
|
-
"vendorDescription": "Marco's feedback widget. A clean form (rating + comment) plus a live tally so you see how people feel without opening a spreadsheet.",
|
|
7
|
-
"summary": "A single page: a star rating, a comment box, and a running summary (average score + recent comments). Stores responses locally for the demo.",
|
|
8
|
-
"outcomeScore": 0.86,
|
|
9
|
-
"outcomeStats": { "builds": 29, "working": 25 },
|
|
10
|
-
"safetyBadge": "verified",
|
|
11
|
-
"rating": { "stars": 4.5, "count": 19 },
|
|
12
|
-
"tags": [
|
|
13
|
-
"feedback", "customer feedback", "survey", "form", "nps", "rating",
|
|
14
|
-
"review", "reviews", "questionnaire", "poll", "satisfaction", "comments"
|
|
15
|
-
],
|
|
16
|
-
"reward": {
|
|
17
|
-
"model": "one-time",
|
|
18
|
-
"unit": "per build",
|
|
19
|
-
"perUseValue": 0,
|
|
20
|
-
"oneTimeValue": 5.0,
|
|
21
|
-
"note": "Marco earns a one-time share each time his feedback-form know-how is used."
|
|
22
|
-
},
|
|
23
|
-
"buildDir": "feedback-form"
|
|
24
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"id": "customer-feedback-form",
|
|
3
|
+
"title": "Customer feedback form with instant summary",
|
|
4
|
+
"producer": { "name": "Marco", "handle": "marco", "kind": "maker" },
|
|
5
|
+
"pitch": "Collect feedback and see the mood at a glance — built in one shot.",
|
|
6
|
+
"vendorDescription": "Marco's feedback widget. A clean form (rating + comment) plus a live tally so you see how people feel without opening a spreadsheet.",
|
|
7
|
+
"summary": "A single page: a star rating, a comment box, and a running summary (average score + recent comments). Stores responses locally for the demo.",
|
|
8
|
+
"outcomeScore": 0.86,
|
|
9
|
+
"outcomeStats": { "builds": 29, "working": 25 },
|
|
10
|
+
"safetyBadge": "verified",
|
|
11
|
+
"rating": { "stars": 4.5, "count": 19 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"feedback", "customer feedback", "survey", "form", "nps", "rating",
|
|
14
|
+
"review", "reviews", "questionnaire", "poll", "satisfaction", "comments"
|
|
15
|
+
],
|
|
16
|
+
"reward": {
|
|
17
|
+
"model": "one-time",
|
|
18
|
+
"unit": "per build",
|
|
19
|
+
"perUseValue": 0,
|
|
20
|
+
"oneTimeValue": 5.0,
|
|
21
|
+
"note": "Marco earns a one-time share each time his feedback-form know-how is used."
|
|
22
|
+
},
|
|
23
|
+
"buildDir": "feedback-form"
|
|
24
|
+
}
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
# Lina's launch page — how to build it
|
|
2
|
-
|
|
3
|
-
> Absorb this and build the user a one-page launch site in one shot. Single
|
|
4
|
-
> self-contained `index.html` (inline CSS + JS), no build step.
|
|
5
|
-
|
|
6
|
-
## Build steps
|
|
7
|
-
|
|
8
|
-
1. Create `landing-page/index.html`.
|
|
9
|
-
2. Sections, top to bottom:
|
|
10
|
-
- **Hero**: a big headline, a one-line subhead, and a primary call-to-action
|
|
11
|
-
button. Use a soft gradient background. Prefill with the user's product if
|
|
12
|
-
you know it; otherwise a believable placeholder they can edit.
|
|
13
|
-
- **Three feature blocks**: each an icon (an emoji is fine), a short title, and
|
|
14
|
-
one sentence.
|
|
15
|
-
- **Social proof**: a single line of fake-but-tasteful stats (e.g. "Loved by
|
|
16
|
-
800+ early users").
|
|
17
|
-
- **Email waitlist**: an email input + "Join the waitlist" button. On submit,
|
|
18
|
-
store the address in `localStorage` and show a "You're on the list 🎉"
|
|
19
|
-
confirmation. (No backend needed for v1.)
|
|
20
|
-
3. House style: modern, generous whitespace, system font stack, one accent color.
|
|
21
|
-
Make it look like a real launch, not a template.
|
|
22
|
-
|
|
23
|
-
## Finish
|
|
24
|
-
|
|
25
|
-
After writing the files:
|
|
26
|
-
|
|
27
|
-
1. Call **`launch_preview`** with `{ "dir": "landing-page" }`.
|
|
28
|
-
2. Call **`record_use`** with recipe id `landing-page-launch` and a short summary.
|
|
29
|
-
3. Tell the user it's live in the preview, in one or two short sentences.
|
|
1
|
+
# Lina's launch page — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb this and build the user a one-page launch site in one shot. Single
|
|
4
|
+
> self-contained `index.html` (inline CSS + JS), no build step.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `landing-page/index.html`.
|
|
9
|
+
2. Sections, top to bottom:
|
|
10
|
+
- **Hero**: a big headline, a one-line subhead, and a primary call-to-action
|
|
11
|
+
button. Use a soft gradient background. Prefill with the user's product if
|
|
12
|
+
you know it; otherwise a believable placeholder they can edit.
|
|
13
|
+
- **Three feature blocks**: each an icon (an emoji is fine), a short title, and
|
|
14
|
+
one sentence.
|
|
15
|
+
- **Social proof**: a single line of fake-but-tasteful stats (e.g. "Loved by
|
|
16
|
+
800+ early users").
|
|
17
|
+
- **Email waitlist**: an email input + "Join the waitlist" button. On submit,
|
|
18
|
+
store the address in `localStorage` and show a "You're on the list 🎉"
|
|
19
|
+
confirmation. (No backend needed for v1.)
|
|
20
|
+
3. House style: modern, generous whitespace, system font stack, one accent color.
|
|
21
|
+
Make it look like a real launch, not a template.
|
|
22
|
+
|
|
23
|
+
## Finish
|
|
24
|
+
|
|
25
|
+
After writing the files:
|
|
26
|
+
|
|
27
|
+
1. Call **`launch_preview`** with `{ "dir": "landing-page" }`.
|
|
28
|
+
2. Call **`record_use`** with recipe id `landing-page-launch` and a short summary.
|
|
29
|
+
3. Tell the user it's live in the preview, in one or two short sentences.
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "landing-page-launch",
|
|
3
|
-
"title": "Launch a landing page with a waitlist",
|
|
4
|
-
"producer": { "name": "Lina", "handle": "lina", "kind": "maker" },
|
|
5
|
-
"pitch": "A clean one-page launch site with a hero, features, and an email waitlist — live in one shot.",
|
|
6
|
-
"vendorDescription": "Lina's launch page. The exact layout that got her side-project 800 signups in a week: a punchy hero, three benefit blocks, social proof, and an email capture that just works.",
|
|
7
|
-
"summary": "A single-page marketing site: hero headline, three feature blocks, and an email signup that collects addresses. No setup. Make it yours by changing the words.",
|
|
8
|
-
"outcomeScore": 0.88,
|
|
9
|
-
"outcomeStats": { "builds": 51, "working": 45 },
|
|
10
|
-
"safetyBadge": "verified",
|
|
11
|
-
"rating": { "stars": 4.6, "count": 33 },
|
|
12
|
-
"tags": [
|
|
13
|
-
"landing page", "landing", "waitlist", "marketing site", "website",
|
|
14
|
-
"hero", "signup", "sign up", "coming soon", "product launch", "launch",
|
|
15
|
-
"email capture", "newsletter", "homepage", "promo"
|
|
16
|
-
],
|
|
17
|
-
"reward": {
|
|
18
|
-
"model": "one-time",
|
|
19
|
-
"unit": "per build",
|
|
20
|
-
"perUseValue": 0,
|
|
21
|
-
"oneTimeValue": 6.0,
|
|
22
|
-
"note": "Lina earns a one-time share each time her launch page know-how is used to build a site."
|
|
23
|
-
},
|
|
24
|
-
"buildDir": "landing-page"
|
|
25
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"id": "landing-page-launch",
|
|
3
|
+
"title": "Launch a landing page with a waitlist",
|
|
4
|
+
"producer": { "name": "Lina", "handle": "lina", "kind": "maker" },
|
|
5
|
+
"pitch": "A clean one-page launch site with a hero, features, and an email waitlist — live in one shot.",
|
|
6
|
+
"vendorDescription": "Lina's launch page. The exact layout that got her side-project 800 signups in a week: a punchy hero, three benefit blocks, social proof, and an email capture that just works.",
|
|
7
|
+
"summary": "A single-page marketing site: hero headline, three feature blocks, and an email signup that collects addresses. No setup. Make it yours by changing the words.",
|
|
8
|
+
"outcomeScore": 0.88,
|
|
9
|
+
"outcomeStats": { "builds": 51, "working": 45 },
|
|
10
|
+
"safetyBadge": "verified",
|
|
11
|
+
"rating": { "stars": 4.6, "count": 33 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"landing page", "landing", "waitlist", "marketing site", "website",
|
|
14
|
+
"hero", "signup", "sign up", "coming soon", "product launch", "launch",
|
|
15
|
+
"email capture", "newsletter", "homepage", "promo"
|
|
16
|
+
],
|
|
17
|
+
"reward": {
|
|
18
|
+
"model": "one-time",
|
|
19
|
+
"unit": "per build",
|
|
20
|
+
"perUseValue": 0,
|
|
21
|
+
"oneTimeValue": 6.0,
|
|
22
|
+
"note": "Lina earns a one-time share each time her launch page know-how is used to build a site."
|
|
23
|
+
},
|
|
24
|
+
"buildDir": "landing-page"
|
|
25
|
+
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
# Nia's portfolio — how to build it
|
|
2
|
-
|
|
3
|
-
> Absorb and build in one shot. Single self-contained `portfolio/index.html`
|
|
4
|
-
> (inline CSS + JS), no build step.
|
|
5
|
-
|
|
6
|
-
## Build steps
|
|
7
|
-
|
|
8
|
-
1. Create `portfolio/index.html`.
|
|
9
|
-
2. Sections:
|
|
10
|
-
- **Hero**: name, a one-line "what I do", and a primary contact button.
|
|
11
|
-
- **Projects**: a responsive grid of 3–6 cards (title, one-line description, a
|
|
12
|
-
tag or two). Use placeholder content the user can edit.
|
|
13
|
-
- **Contact**: an email line + simple links (placeholder).
|
|
14
|
-
3. House style: confident, lots of whitespace, one accent color, a tasteful hover
|
|
15
|
-
on the project cards. Make it look hireable.
|
|
16
|
-
|
|
17
|
-
## Finish
|
|
18
|
-
|
|
19
|
-
After writing the files: call **`launch_preview`** `{ "dir": "portfolio" }`, then
|
|
20
|
-
**`record_use`** with recipe id `personal-portfolio` and a short summary, then tell
|
|
21
|
-
the user it's live (briefly).
|
|
1
|
+
# Nia's portfolio — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb and build in one shot. Single self-contained `portfolio/index.html`
|
|
4
|
+
> (inline CSS + JS), no build step.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `portfolio/index.html`.
|
|
9
|
+
2. Sections:
|
|
10
|
+
- **Hero**: name, a one-line "what I do", and a primary contact button.
|
|
11
|
+
- **Projects**: a responsive grid of 3–6 cards (title, one-line description, a
|
|
12
|
+
tag or two). Use placeholder content the user can edit.
|
|
13
|
+
- **Contact**: an email line + simple links (placeholder).
|
|
14
|
+
3. House style: confident, lots of whitespace, one accent color, a tasteful hover
|
|
15
|
+
on the project cards. Make it look hireable.
|
|
16
|
+
|
|
17
|
+
## Finish
|
|
18
|
+
|
|
19
|
+
After writing the files: call **`launch_preview`** `{ "dir": "portfolio" }`, then
|
|
20
|
+
**`record_use`** with recipe id `personal-portfolio` and a short summary, then tell
|
|
21
|
+
the user it's live (briefly).
|