backlinkflow 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.
@@ -0,0 +1,90 @@
1
+ async function clickSubmit(page) {
2
+ const btn = page.locator('button[type="submit"], input[type="submit"], button:has-text("Submit"), button:has-text("Add"), button:has-text("Launch"), button:has-text("Save")').first();
3
+ if (await btn.isVisible().catch(() => false)) {
4
+ await btn.click();
5
+ return true;
6
+ }
7
+ await page.keyboard.press("Enter");
8
+ return true;
9
+ }
10
+ const genericAdapter = {
11
+ matches: [],
12
+ async submit({ page, payload, siteUrl, log }) {
13
+ await page.waitForLoadState("domcontentloaded");
14
+ await page.waitForTimeout(1500);
15
+ const name = page.locator('input[name*="name" i], input[placeholder*="name" i], input[placeholder*="title" i]').first();
16
+ const url = page.locator('input[name*="url" i], input[type="url"], input[placeholder*="url" i], input[placeholder*="website" i]').first();
17
+ const email = page.locator('input[type="email"], input[name*="email" i]').first();
18
+ const desc = page.locator("textarea").first();
19
+ let filled = 0;
20
+ if (await name.isVisible().catch(() => false)) {
21
+ await name.fill(payload.name);
22
+ filled++;
23
+ }
24
+ if (await url.isVisible().catch(() => false)) {
25
+ await url.fill(siteUrl);
26
+ filled++;
27
+ }
28
+ if (await email.isVisible().catch(() => false) && payload.fields?.email) {
29
+ await email.fill(payload.fields.email);
30
+ filled++;
31
+ }
32
+ if (await desc.isVisible().catch(() => false)) {
33
+ await desc.fill(payload.description);
34
+ filled++;
35
+ }
36
+ if (filled === 0) {
37
+ return { ok: false, note: "no fillable form fields detected" };
38
+ }
39
+ log(` filled ${filled} fields`);
40
+ await page.waitForTimeout(800);
41
+ const ok = await clickSubmit(page);
42
+ await page.waitForTimeout(2500);
43
+ const body = await page.textContent("body").catch(() => "");
44
+ const success = /(thank you|submitted|success|received|we'll review|got it|in review|pending|added)/i.test(body);
45
+ const stillOnForm = await name.isVisible().catch(() => false);
46
+ return {
47
+ ok: ok && (success || !stillOnForm),
48
+ note: success ? "success message detected" : stillOnForm ? "may still be on form" : "form submitted, awaiting response"
49
+ };
50
+ }
51
+ };
52
+ const saashubAdapter = {
53
+ matches: ["SaaSHub", "saashub.com"],
54
+ needsCredentials: true,
55
+ async submit({ page, payload, siteUrl, credentials, log }) {
56
+ const creds = credentials?.saashub;
57
+ if (!creds?.email || !creds?.password) {
58
+ return { ok: false, note: "SaaSHub needs credentials.saashub in config" };
59
+ }
60
+ await page.goto("https://www.saashub.com/login");
61
+ await page.waitForTimeout(1200);
62
+ await page.fill('input[name="email"], input[type="email"]', creds.email);
63
+ await page.fill('input[name="password"], input[type="password"]', creds.password);
64
+ await page.click('button[type="submit"], input[type="submit"]');
65
+ await page.waitForTimeout(2500);
66
+ await page.goto("https://www.saashub.com/new");
67
+ await page.waitForTimeout(1500);
68
+ await genericAdapter.submit({ page, payload, siteUrl, log });
69
+ return { ok: true, note: "submitted via saashub adapter" };
70
+ }
71
+ };
72
+ const productHuntAdapter = {
73
+ matches: ["Product Hunt", "producthunt.com"],
74
+ async submit() {
75
+ return { ok: false, note: "Product Hunt is manual-only (anti-bot). Use linkflow payload to prep copy." };
76
+ }
77
+ };
78
+ const ADAPTERS = [genericAdapter, saashubAdapter, productHuntAdapter];
79
+ function findAdapter(dirName) {
80
+ const n = dirName.toLowerCase();
81
+ return ADAPTERS.find((a) => a.matches.some((m) => n.includes(m.toLowerCase()))) || null;
82
+ }
83
+ export {
84
+ ADAPTERS,
85
+ findAdapter,
86
+ genericAdapter,
87
+ productHuntAdapter,
88
+ saashubAdapter
89
+ };
90
+ //# sourceMappingURL=adapters.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/engine/adapters.ts"],
4
+ "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 site adapter registry.\n * Each adapter knows how to submit to one directory. The generic adapter\n * auto-detects form fields; site-specific adapters handle login/paywall/captcha.\n */\nimport type { Page } from 'playwright';\nimport type { Payload } from '../types.js';\n\nexport interface SubmitContext {\n page: Page;\n payload: Payload;\n siteUrl: string;\n email?: string;\n /** credentials for login-required sites (from config) */\n credentials?: Record<string, { email?: string; password?: string }>;\n /** log a step */\n log: (msg: string) => void;\n}\n\nexport interface Adapter {\n /** Directory name(s) this adapter handles (matched against dir.name). */\n matches: string[];\n /** Requires login credentials? */\n needsCredentials?: boolean;\n submit(ctx: SubmitContext): Promise<{ ok: boolean; proof?: string; note?: string }>;\n}\n\n/** Try to click a submit button, fallback to Enter. */\nasync function clickSubmit(page: Page): Promise<boolean> {\n const btn = page.locator('button[type=\"submit\"], input[type=\"submit\"], button:has-text(\"Submit\"), button:has-text(\"Add\"), button:has-text(\"Launch\"), button:has-text(\"Save\")').first();\n if (await btn.isVisible().catch(() => false)) {\n await btn.click();\n return true;\n }\n // fallback: press Enter in a focused field\n await page.keyboard.press('Enter');\n return true;\n}\n\n/** Generic adapter \u2014 smart auto-detection for any form-based directory. */\nexport const genericAdapter: Adapter = {\n matches: [],\n async submit({ page, payload, siteUrl, log }) {\n // Wait for form to settle\n await page.waitForLoadState('domcontentloaded');\n await page.waitForTimeout(1500);\n\n // Detect and fill fields\n const name = page.locator('input[name*=\"name\" i], input[placeholder*=\"name\" i], input[placeholder*=\"title\" i]').first();\n const url = page.locator('input[name*=\"url\" i], input[type=\"url\"], input[placeholder*=\"url\" i], input[placeholder*=\"website\" i]').first();\n const email = page.locator('input[type=\"email\"], input[name*=\"email\" i]').first();\n const desc = page.locator('textarea').first();\n\n let filled = 0;\n if (await name.isVisible().catch(() => false)) { await name.fill(payload.name); filled++; }\n if (await url.isVisible().catch(() => false)) { await url.fill(siteUrl); filled++; }\n if (await email.isVisible().catch(() => false) && payload.fields?.email) { await email.fill(payload.fields.email); filled++; }\n if (await desc.isVisible().catch(() => false)) { await desc.fill(payload.description); filled++; }\n\n if (filled === 0) {\n return { ok: false, note: 'no fillable form fields detected' };\n }\n\n log(` filled ${filled} fields`);\n await page.waitForTimeout(800);\n\n // Submit\n const ok = await clickSubmit(page);\n await page.waitForTimeout(2500);\n\n // Verify: did we leave the form / get a success message?\n const body = await page.textContent('body').catch(() => '');\n const success = /(thank you|submitted|success|received|we'll review|got it|in review|pending|added)/i.test(body);\n const stillOnForm = await name.isVisible().catch(() => false);\n\n return {\n ok: ok && (success || !stillOnForm),\n note: success ? 'success message detected' : stillOnForm ? 'may still be on form' : 'form submitted, awaiting response',\n };\n },\n};\n\n/** SaaSHub \u2014 requires login. */\nexport const saashubAdapter: Adapter = {\n matches: ['SaaSHub', 'saashub.com'],\n needsCredentials: true,\n async submit({ page, payload, siteUrl, credentials, log }) {\n const creds = credentials?.saashub;\n if (!creds?.email || !creds?.password) {\n return { ok: false, note: 'SaaSHub needs credentials.saashub in config' };\n }\n // Login\n await page.goto('https://www.saashub.com/login');\n await page.waitForTimeout(1200);\n await page.fill('input[name=\"email\"], input[type=\"email\"]', creds.email);\n await page.fill('input[name=\"password\"], input[type=\"password\"]', creds.password);\n await page.click('button[type=\"submit\"], input[type=\"submit\"]');\n await page.waitForTimeout(2500);\n\n // Submit page\n await page.goto('https://www.saashub.com/new');\n await page.waitForTimeout(1500);\n await genericAdapter.submit({ page, payload, siteUrl, log });\n return { ok: true, note: 'submitted via saashub adapter' };\n },\n};\n\n/** Product Hunt \u2014 anti-bot, manual only. Mark as manual. */\nexport const productHuntAdapter: Adapter = {\n matches: ['Product Hunt', 'producthunt.com'],\n async submit() {\n return { ok: false, note: 'Product Hunt is manual-only (anti-bot). Use linkflow payload to prep copy.' };\n },\n};\n\n/** Registry: all adapters. */\nexport const ADAPTERS: Adapter[] = [genericAdapter, saashubAdapter, productHuntAdapter];\n\n/** Find an adapter for a directory name. */\nexport function findAdapter(dirName: string): Adapter | null {\n const n = dirName.toLowerCase();\n return ADAPTERS.find((a) => a.matches.some((m) => n.includes(m.toLowerCase()))) || null;\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,59 @@
1
+ import { chromium } from "playwright";
2
+ async function launchBrowser() {
3
+ const browser = await chromium.launch({
4
+ headless: !process.env.LINKFLOW_HEADED,
5
+ args: ["--disable-blink-features=AutomationControlled"]
6
+ });
7
+ const context = await browser.newContext({
8
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
9
+ viewport: { width: 1280, height: 900 }
10
+ });
11
+ const page = await context.newPage();
12
+ return { browser, page };
13
+ }
14
+ async function closeBrowser(session) {
15
+ await session.browser.close();
16
+ }
17
+ async function humanType(page, selector, text) {
18
+ await page.click(selector);
19
+ for (const ch of text) {
20
+ await page.keyboard.type(ch, { delay: 20 + Math.random() * 40 });
21
+ }
22
+ }
23
+ async function screenshot(page, name, dir) {
24
+ const fs = await import("fs");
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const p = `${dir}/${name}.png`;
27
+ await page.screenshot({ path: p, fullPage: false });
28
+ return p;
29
+ }
30
+ function delay(ms) {
31
+ return new Promise((r) => setTimeout(r, ms + Math.random() * 500));
32
+ }
33
+ async function scrollThrough(page) {
34
+ await page.evaluate(async () => {
35
+ const h = document.body.scrollHeight;
36
+ for (let y = 0; y < h; y += 300) {
37
+ window.scrollTo(0, y);
38
+ await new Promise((r) => setTimeout(r, 150));
39
+ }
40
+ window.scrollTo(0, 0);
41
+ });
42
+ }
43
+ function looksLikeLogin(bodyText, pageUrl) {
44
+ const b = bodyText.toLowerCase().slice(0, 1e3);
45
+ const u = pageUrl.toLowerCase();
46
+ const hasLoginForm = /(log ?in|sign ?in|log ?in|create account|register)/.test(b);
47
+ const hasSubmitForm = /(submit|add (your )?(tool|site|product|app)|get listed|list your)/.test(b);
48
+ return hasLoginForm && !hasSubmitForm;
49
+ }
50
+ export {
51
+ closeBrowser,
52
+ delay,
53
+ humanType,
54
+ launchBrowser,
55
+ looksLikeLogin,
56
+ screenshot,
57
+ scrollThrough
58
+ };
59
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/engine/browser.ts"],
4
+ "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 browser engine wrapper around Playwright.\n * Headless Chromium, humanized typing, screenshots for proof-of-submission.\n */\nimport { chromium, type Browser, type Page } from 'playwright';\n\nexport interface BrowserSession {\n browser: Browser;\n page: Page;\n}\n\n/** Launch a fresh headless browser (non-headless if LINKFLOW_HEADED=1). */\nexport async function launchBrowser(): Promise<BrowserSession> {\n const browser = await chromium.launch({\n headless: !process.env.LINKFLOW_HEADED,\n args: ['--disable-blink-features=AutomationControlled'],\n });\n const context = await browser.newContext({\n userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',\n viewport: { width: 1280, height: 900 },\n });\n const page = await context.newPage();\n return { browser, page };\n}\n\nexport async function closeBrowser(session: BrowserSession): Promise<void> {\n await session.browser.close();\n}\n\n/** Human-like typing with random delay between keys. */\nexport async function humanType(page: Page, selector: string, text: string): Promise<void> {\n await page.click(selector);\n for (const ch of text) {\n await page.keyboard.type(ch, { delay: 20 + Math.random() * 40 });\n }\n}\n\n/** Screenshot the current page \u2014 proof of submission state. */\nexport async function screenshot(page: Page, name: string, dir: string): Promise<string> {\n const fs = await import('fs');\n fs.mkdirSync(dir, { recursive: true });\n const p = `${dir}/${name}.png`;\n await page.screenshot({ path: p, fullPage: false });\n return p;\n}\n\n/** Random humanized delay between actions. */\nexport function delay(ms: number): Promise<void> {\n return new Promise((r) => setTimeout(r, ms + Math.random() * 500));\n}\n\n/** Scroll the page slowly to trigger lazy-loading forms. */\nexport async function scrollThrough(page: Page): Promise<void> {\n await page.evaluate(async () => {\n const h = document.body.scrollHeight;\n for (let y = 0; y < h; y += 300) {\n window.scrollTo(0, y);\n await new Promise((r) => setTimeout(r, 150));\n }\n window.scrollTo(0, 0);\n });\n}\n\n/** Detect if the page looks like a login wall (vs an open form). */\nexport function looksLikeLogin(bodyText: string, pageUrl: string): boolean {\n const b = bodyText.toLowerCase().slice(0, 1000);\n const u = pageUrl.toLowerCase();\n const hasLoginForm = /(log ?in|sign ?in|log ?in|create account|register)/.test(b);\n const hasSubmitForm = /(submit|add (your )?(tool|site|product|app)|get listed|list your)/.test(b);\n return hasLoginForm && !hasSubmitForm;\n}\n"],
5
+ "mappings": "AAIA,SAAS,gBAAyC;AAQlD,eAAsB,gBAAyC;AAC7D,QAAM,UAAU,MAAM,SAAS,OAAO;AAAA,IACpC,UAAU,CAAC,QAAQ,IAAI;AAAA,IACvB,MAAM,CAAC,+CAA+C;AAAA,EACxD,CAAC;AACD,QAAM,UAAU,MAAM,QAAQ,WAAW;AAAA,IACvC,WAAW;AAAA,IACX,UAAU,EAAE,OAAO,MAAM,QAAQ,IAAI;AAAA,EACvC,CAAC;AACD,QAAM,OAAO,MAAM,QAAQ,QAAQ;AACnC,SAAO,EAAE,SAAS,KAAK;AACzB;AAEA,eAAsB,aAAa,SAAwC;AACzE,QAAM,QAAQ,QAAQ,MAAM;AAC9B;AAGA,eAAsB,UAAU,MAAY,UAAkB,MAA6B;AACzF,QAAM,KAAK,MAAM,QAAQ;AACzB,aAAW,MAAM,MAAM;AACrB,UAAM,KAAK,SAAS,KAAK,IAAI,EAAE,OAAO,KAAK,KAAK,OAAO,IAAI,GAAG,CAAC;AAAA,EACjE;AACF;AAGA,eAAsB,WAAW,MAAY,MAAc,KAA8B;AACvF,QAAM,KAAK,MAAM,OAAO,IAAI;AAC5B,KAAG,UAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AACrC,QAAM,IAAI,GAAG,GAAG,IAAI,IAAI;AACxB,QAAM,KAAK,WAAW,EAAE,MAAM,GAAG,UAAU,MAAM,CAAC;AAClD,SAAO;AACT;AAGO,SAAS,MAAM,IAA2B;AAC/C,SAAO,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,KAAK,KAAK,OAAO,IAAI,GAAG,CAAC;AACnE;AAGA,eAAsB,cAAc,MAA2B;AAC7D,QAAM,KAAK,SAAS,YAAY;AAC9B,UAAM,IAAI,SAAS,KAAK;AACxB,aAAS,IAAI,GAAG,IAAI,GAAG,KAAK,KAAK;AAC/B,aAAO,SAAS,GAAG,CAAC;AACpB,YAAM,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,GAAG,CAAC;AAAA,IAC7C;AACA,WAAO,SAAS,GAAG,CAAC;AAAA,EACtB,CAAC;AACH;AAGO,SAAS,eAAe,UAAkB,SAA0B;AACzE,QAAM,IAAI,SAAS,YAAY,EAAE,MAAM,GAAG,GAAI;AAC9C,QAAM,IAAI,QAAQ,YAAY;AAC9B,QAAM,eAAe,qDAAqD,KAAK,CAAC;AAChF,QAAM,gBAAgB,oEAAoE,KAAK,CAAC;AAChG,SAAO,gBAAgB,CAAC;AAC1B;",
6
+ "names": []
7
+ }
@@ -0,0 +1,58 @@
1
+ const NAME_RE = /(name|title|product|app.?name|tool.?name|startup)/i;
2
+ const URL_RE = /(url|website|link|homepage|site|domain)/i;
3
+ const EMAIL_RE = /(email|e-?mail)/i;
4
+ const DESC_RE = /(desc|description|about|summary|detail|intro|what)/i;
5
+ const SUBMIT_RE = /(submit|send|add|post|create|list|suggest|save|launch)/i;
6
+ function byText(page, re) {
7
+ return [
8
+ page.locator('input[type="text"], input:not([type]), textarea, input[type="url"], input[type="email"]').filter({ hasText: re }),
9
+ page.locator('input[type="text"], input:not([type]), textarea, input[type="url"], input[type="email"]').filter({ has: page.locator("..") }),
10
+ page.locator(`input[placeholder*="${re.source}" i]`),
11
+ page.locator(`input[name*="${re.source}" i]`),
12
+ page.locator(`textarea[placeholder*="${re.source}" i]`),
13
+ page.locator(`textarea[name*="${re.source}" i]`)
14
+ ].flat();
15
+ }
16
+ async function detectFields(page) {
17
+ const out = {};
18
+ out.name = page.locator('input[name*="name" i], input[placeholder*="name" i], input[placeholder*="title" i], input[name*="title" i]').first().or(page.locator('input[type="text"]').first());
19
+ out.url = page.locator('input[name*="url" i], input[type="url"], input[placeholder*="url" i], input[placeholder*="website" i]').first();
20
+ out.email = page.locator('input[type="email"], input[name*="email" i], input[placeholder*="email" i]').first();
21
+ out.description = page.locator("textarea").first();
22
+ out.submit = page.locator('button[type="submit"], input[type="submit"], button:has-text("Submit"), button:has-text("Add"), button:has-text("Launch"), button:has-text("Save")').first();
23
+ return out;
24
+ }
25
+ async function isUsable(loc) {
26
+ if (!loc) return false;
27
+ try {
28
+ return await loc.isVisible();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ async function fillVisible(page, fields, data) {
34
+ const filled = [];
35
+ if (data.name && await isUsable(fields.name)) {
36
+ await fields.name.fill(data.name);
37
+ filled.push("name");
38
+ }
39
+ if (data.url && await isUsable(fields.url)) {
40
+ await fields.url.fill(data.url);
41
+ filled.push("url");
42
+ }
43
+ if (data.email && await isUsable(fields.email)) {
44
+ await fields.email.fill(data.email);
45
+ filled.push("email");
46
+ }
47
+ if (data.description && await isUsable(fields.description)) {
48
+ await fields.description.fill(data.description);
49
+ filled.push("description");
50
+ }
51
+ return filled;
52
+ }
53
+ export {
54
+ detectFields,
55
+ fillVisible,
56
+ isUsable
57
+ };
58
+ //# sourceMappingURL=fields.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/engine/fields.ts"],
4
+ "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 smart form field detection.\n * Finds name/url/email/description/submit elements by label, placeholder, and name attrs.\n */\nimport type { Page, Locator } from 'playwright';\n\nexport interface DetectedFields {\n name?: Locator;\n url?: Locator;\n email?: Locator;\n description?: Locator;\n submit?: Locator;\n}\n\nconst NAME_RE = /(name|title|product|app.?name|tool.?name|startup)/i;\nconst URL_RE = /(url|website|link|homepage|site|domain)/i;\nconst EMAIL_RE = /(email|e-?mail)/i;\nconst DESC_RE = /(desc|description|about|summary|detail|intro|what)/i;\nconst SUBMIT_RE = /(submit|send|add|post|create|list|suggest|save|launch)/i;\n\n/** Build a locator list from label+placeholder+name attrs. */\nfunction byText(page: Page, re: RegExp): Locator[] {\n return [\n page.locator('input[type=\"text\"], input:not([type]), textarea, input[type=\"url\"], input[type=\"email\"]').filter({ hasText: re }),\n page.locator('input[type=\"text\"], input:not([type]), textarea, input[type=\"url\"], input[type=\"email\"]').filter({ has: page.locator('..') }),\n page.locator(`input[placeholder*=\"${re.source}\" i]`),\n page.locator(`input[name*=\"${re.source}\" i]`),\n page.locator(`textarea[placeholder*=\"${re.source}\" i]`),\n page.locator(`textarea[name*=\"${re.source}\" i]`),\n ].flat();\n}\n\n/** Detect the form fields on the current page. */\nexport async function detectFields(page: Page): Promise<DetectedFields> {\n const out: DetectedFields = {};\n\n // Name\n out.name = page.locator('input[name*=\"name\" i], input[placeholder*=\"name\" i], input[placeholder*=\"title\" i], input[name*=\"title\" i]').first().or(page.locator('input[type=\"text\"]').first());\n // URL\n out.url = page.locator('input[name*=\"url\" i], input[type=\"url\"], input[placeholder*=\"url\" i], input[placeholder*=\"website\" i]').first();\n // Email\n out.email = page.locator('input[type=\"email\"], input[name*=\"email\" i], input[placeholder*=\"email\" i]').first();\n // Description\n out.description = page.locator('textarea').first();\n // Submit\n out.submit = page.locator('button[type=\"submit\"], input[type=\"submit\"], button:has-text(\"Submit\"), button:has-text(\"Add\"), button:has-text(\"Launch\"), button:has-text(\"Save\")').first();\n\n return out;\n}\n\n/** Verify a locator is visible before filling. */\nexport async function isUsable(loc: Locator | undefined): Promise<boolean> {\n if (!loc) return false;\n try {\n return await loc.isVisible();\n } catch {\n return false;\n }\n}\n\n/** Fill only visible fields, return which were filled. */\nexport async function fillVisible(page: Page, fields: DetectedFields, data: {\n name?: string; url?: string; email?: string; description?: string;\n}): Promise<string[]> {\n const filled: string[] = [];\n if (data.name && await isUsable(fields.name)) { await fields.name!.fill(data.name); filled.push('name'); }\n if (data.url && await isUsable(fields.url)) { await fields.url!.fill(data.url); filled.push('url'); }\n if (data.email && await isUsable(fields.email)) { await fields.email!.fill(data.email); filled.push('email'); }\n if (data.description && await isUsable(fields.description)) { await fields.description!.fill(data.description); filled.push('description'); }\n return filled;\n}\n"],
5
+ "mappings": "AAcA,MAAM,UAAU;AAChB,MAAM,SAAS;AACf,MAAM,WAAW;AACjB,MAAM,UAAU;AAChB,MAAM,YAAY;AAGlB,SAAS,OAAO,MAAY,IAAuB;AACjD,SAAO;AAAA,IACL,KAAK,QAAQ,yFAAyF,EAAE,OAAO,EAAE,SAAS,GAAG,CAAC;AAAA,IAC9H,KAAK,QAAQ,yFAAyF,EAAE,OAAO,EAAE,KAAK,KAAK,QAAQ,IAAI,EAAE,CAAC;AAAA,IAC1I,KAAK,QAAQ,uBAAuB,GAAG,MAAM,MAAM;AAAA,IACnD,KAAK,QAAQ,gBAAgB,GAAG,MAAM,MAAM;AAAA,IAC5C,KAAK,QAAQ,0BAA0B,GAAG,MAAM,MAAM;AAAA,IACtD,KAAK,QAAQ,mBAAmB,GAAG,MAAM,MAAM;AAAA,EACjD,EAAE,KAAK;AACT;AAGA,eAAsB,aAAa,MAAqC;AACtE,QAAM,MAAsB,CAAC;AAG7B,MAAI,OAAO,KAAK,QAAQ,4GAA4G,EAAE,MAAM,EAAE,GAAG,KAAK,QAAQ,oBAAoB,EAAE,MAAM,CAAC;AAE3L,MAAI,MAAM,KAAK,QAAQ,uGAAuG,EAAE,MAAM;AAEtI,MAAI,QAAQ,KAAK,QAAQ,4EAA4E,EAAE,MAAM;AAE7G,MAAI,cAAc,KAAK,QAAQ,UAAU,EAAE,MAAM;AAEjD,MAAI,SAAS,KAAK,QAAQ,oJAAoJ,EAAE,MAAM;AAEtL,SAAO;AACT;AAGA,eAAsB,SAAS,KAA4C;AACzE,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACF,WAAO,MAAM,IAAI,UAAU;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAsB,YAAY,MAAY,QAAwB,MAEhD;AACpB,QAAM,SAAmB,CAAC;AAC1B,MAAI,KAAK,QAAQ,MAAM,SAAS,OAAO,IAAI,GAAG;AAAE,UAAM,OAAO,KAAM,KAAK,KAAK,IAAI;AAAG,WAAO,KAAK,MAAM;AAAA,EAAG;AACzG,MAAI,KAAK,OAAO,MAAM,SAAS,OAAO,GAAG,GAAG;AAAE,UAAM,OAAO,IAAK,KAAK,KAAK,GAAG;AAAG,WAAO,KAAK,KAAK;AAAA,EAAG;AACpG,MAAI,KAAK,SAAS,MAAM,SAAS,OAAO,KAAK,GAAG;AAAE,UAAM,OAAO,MAAO,KAAK,KAAK,KAAK;AAAG,WAAO,KAAK,OAAO;AAAA,EAAG;AAC9G,MAAI,KAAK,eAAe,MAAM,SAAS,OAAO,WAAW,GAAG;AAAE,UAAM,OAAO,YAAa,KAAK,KAAK,WAAW;AAAG,WAAO,KAAK,aAAa;AAAA,EAAG;AAC5I,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -0,0 +1,90 @@
1
+ import { launchBrowser, closeBrowser, screenshot, delay, looksLikeLogin } from "./browser.js";
2
+ import { findAdapter, genericAdapter } from "./adapters.js";
3
+ import { recordSubmission } from "../tracker.js";
4
+ import { loadConfig } from "../config.js";
5
+ async function preflight(url) {
6
+ try {
7
+ const res = await fetch(url, {
8
+ method: "GET",
9
+ redirect: "follow",
10
+ signal: AbortSignal.timeout(12e3),
11
+ headers: { "User-Agent": "Mozilla/5.0 LinkFlow/0.2" }
12
+ });
13
+ if (res.status === 404) return { ok: false, status: 404 };
14
+ if (res.status >= 500) return { ok: false, status: res.status };
15
+ return { ok: true, status: res.status };
16
+ } catch {
17
+ return { ok: false };
18
+ }
19
+ }
20
+ async function submitOne(dir, payload, opts) {
21
+ const cfg = loadConfig();
22
+ const { siteUrl, dryRun = false, proofDir = ".linkflow/proofs" } = opts;
23
+ if (dir.status === "dead") {
24
+ return { directory: dir.name, status: "skipped", note: "status=dead" };
25
+ }
26
+ if (dir.status === "paid") {
27
+ return { directory: dir.name, status: "skipped", note: "status=paid" };
28
+ }
29
+ const pf = await preflight(dir.submitUrl);
30
+ if (!pf.ok) {
31
+ const note = pf.status ? `preflight HTTP ${pf.status}` : "preflight unreachable";
32
+ recordSubmission({ site: siteUrl, directory: dir.name, status: "failed", submittedAt: (/* @__PURE__ */ new Date()).toISOString(), notes: note });
33
+ return { directory: dir.name, status: "failed", note };
34
+ }
35
+ if (dryRun) {
36
+ return { directory: dir.name, status: "pending", note: "dry-run (preflight OK)" };
37
+ }
38
+ const { browser, page } = await launchBrowser();
39
+ try {
40
+ const adapter = findAdapter(dir.name) || genericAdapter;
41
+ if (adapter.needsCredentials && !cfg.ai?.apiKey) {
42
+ }
43
+ const ctx = {
44
+ page,
45
+ payload,
46
+ siteUrl,
47
+ email: cfg.siteName ? void 0 : void 0,
48
+ credentials: cfg.credentials,
49
+ log: (m) => console.log(m)
50
+ };
51
+ console.log(` \u{1F4C4} Opening ${dir.submitUrl}`);
52
+ await page.goto(dir.submitUrl, { waitUntil: "domcontentloaded", timeout: 45e3 });
53
+ await delay(1500);
54
+ const body = await page.textContent("body").catch(() => "");
55
+ const url = page.url();
56
+ if (/404|not found|page not found/i.test(body) && /404/.test(url)) {
57
+ return { directory: dir.name, status: "failed", note: "page 404" };
58
+ }
59
+ if (looksLikeLogin(body, url)) {
60
+ return { directory: dir.name, status: "failed", note: "login wall \u2014 needs credentials/manual" };
61
+ }
62
+ const result = await adapter.submit(ctx);
63
+ const status = result.ok ? "submitted" : "failed";
64
+ let proof;
65
+ if (result.ok && !dryRun) {
66
+ proof = await screenshot(page, `${dir.name.replace(/[^a-z0-9]+/gi, "-")}`, proofDir);
67
+ }
68
+ const rec = {
69
+ site: siteUrl,
70
+ directory: dir.name,
71
+ status,
72
+ submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
73
+ url: dir.submitUrl,
74
+ proof,
75
+ notes: result.note
76
+ };
77
+ recordSubmission(rec);
78
+ return { directory: dir.name, status, note: result.note, proof };
79
+ } catch (err) {
80
+ const note = `browser error: ${err.message.slice(0, 100)}`;
81
+ recordSubmission({ site: siteUrl, directory: dir.name, status: "failed", submittedAt: (/* @__PURE__ */ new Date()).toISOString(), notes: note });
82
+ return { directory: dir.name, status: "failed", note };
83
+ } finally {
84
+ await closeBrowser({ browser, page });
85
+ }
86
+ }
87
+ export {
88
+ submitOne
89
+ };
90
+ //# sourceMappingURL=submit.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/engine/submit.ts"],
4
+ "sourcesContent": ["/**\n * LinkFlow v0.2 \u2014 submission orchestrator.\n * Runs one directory submission end-to-end:\n * preflight (HTTP check) \u2192 launch browser \u2192 navigate \u2192 adapter submit \u2192 verify \u2192 track\n */\nimport { launchBrowser, closeBrowser, screenshot, delay, looksLikeLogin } from './browser.js';\nimport { findAdapter, genericAdapter, type SubmitContext } from './adapters.js';\nimport type { DirectoryEntry, Payload, SubmissionRecord } from '../types.js';\nimport { recordSubmission } from '../tracker.js';\nimport { loadConfig } from '../config.js';\n\nexport interface SubmitOptions {\n siteUrl: string;\n dryRun?: boolean;\n proofDir?: string;\n maxWaitMs?: number;\n}\n\nexport interface SubmitResult {\n directory: string;\n status: 'submitted' | 'pending' | 'failed' | 'skipped';\n note?: string;\n proof?: string;\n}\n\n/** Preflight HTTP check \u2014 catch dead sites before opening a browser. */\nasync function preflight(url: string): Promise<{ ok: boolean; status?: number }> {\n try {\n const res = await fetch(url, {\n method: 'GET',\n redirect: 'follow',\n signal: AbortSignal.timeout(12000),\n headers: { 'User-Agent': 'Mozilla/5.0 LinkFlow/0.2' },\n });\n if (res.status === 404) return { ok: false, status: 404 };\n if (res.status >= 500) return { ok: false, status: res.status };\n return { ok: true, status: res.status };\n } catch {\n return { ok: false };\n }\n}\n\n/** Submit one directory entry. */\nexport async function submitOne(dir: DirectoryEntry, payload: Payload, opts: SubmitOptions): Promise<SubmitResult> {\n const cfg = loadConfig();\n const { siteUrl, dryRun = false, proofDir = '.linkflow/proofs' } = opts;\n\n // 1. Skip known-dead\n if (dir.status === 'dead') {\n return { directory: dir.name, status: 'skipped', note: 'status=dead' };\n }\n if (dir.status === 'paid') {\n return { directory: dir.name, status: 'skipped', note: 'status=paid' };\n }\n\n // 2. Preflight\n const pf = await preflight(dir.submitUrl);\n if (!pf.ok) {\n const note = pf.status ? `preflight HTTP ${pf.status}` : 'preflight unreachable';\n // record as failed so it won't retry\n recordSubmission({ site: siteUrl, directory: dir.name, status: 'failed', submittedAt: new Date().toISOString(), notes: note });\n return { directory: dir.name, status: 'failed', note };\n }\n\n if (dryRun) {\n return { directory: dir.name, status: 'pending', note: 'dry-run (preflight OK)' };\n }\n\n // 3. Launch browser\n const { browser, page } = await launchBrowser();\n try {\n const adapter = findAdapter(dir.name) || genericAdapter;\n if (adapter.needsCredentials && !cfg.ai?.apiKey) {\n // not actually credential-related, but we check config presence loosely\n }\n\n const ctx: SubmitContext = {\n page,\n payload,\n siteUrl,\n email: cfg.siteName ? undefined : undefined,\n credentials: (cfg as any).credentials,\n log: (m) => console.log(m),\n };\n\n console.log(` \uD83D\uDCC4 Opening ${dir.submitUrl}`);\n await page.goto(dir.submitUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });\n await delay(1500);\n\n // 4. Validate page\n const body = await page.textContent('body').catch(() => '');\n const url = page.url();\n if (/404|not found|page not found/i.test(body) && /404/.test(url)) {\n return { directory: dir.name, status: 'failed', note: 'page 404' };\n }\n if (looksLikeLogin(body, url)) {\n return { directory: dir.name, status: 'failed', note: 'login wall \u2014 needs credentials/manual' };\n }\n\n // 5. Run adapter\n const result = await adapter.submit(ctx);\n const status: 'submitted' | 'pending' | 'failed' = result.ok ? 'submitted' : 'failed';\n let proof: string | undefined;\n if (result.ok && !dryRun) {\n proof = await screenshot(page, `${dir.name.replace(/[^a-z0-9]+/gi, '-')}`, proofDir);\n }\n\n const rec: SubmissionRecord = {\n site: siteUrl,\n directory: dir.name,\n status,\n submittedAt: new Date().toISOString(),\n url: dir.submitUrl,\n proof,\n notes: result.note,\n };\n recordSubmission(rec);\n\n return { directory: dir.name, status, note: result.note, proof };\n } catch (err) {\n const note = `browser error: ${(err as Error).message.slice(0, 100)}`;\n recordSubmission({ site: siteUrl, directory: dir.name, status: 'failed', submittedAt: new Date().toISOString(), notes: note });\n return { directory: dir.name, status: 'failed', note };\n } finally {\n await closeBrowser({ browser, page });\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { loadDirectories, searchDirectories, categories, dbStats } from "./database.js";
6
+ import { loadConfig, hasAiConfig, printConfigSummary } from "./config.js";
7
+ import { resetAiCallCount, getAiCallCount } from "./ai.js";
8
+ import { generatePayload } from "./payload.js";
9
+ import { loadTracker, alreadySubmitted, recordSubmission, trackerSummary } from "./tracker.js";
10
+ import { writeReport, reportPath } from "./report.js";
11
+ import { submitOne } from "./engine/submit.js";
12
+ const rawArgs = process.argv.slice(2);
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const VERB = rawArgs[0] && !rawArgs[0].startsWith("--") ? rawArgs[0] : "list";
15
+ const VERB_ARG = rawArgs[1] && !rawArgs[1].startsWith("--") ? rawArgs[1] : null;
16
+ const flag = (name) => {
17
+ const i = rawArgs.indexOf(name);
18
+ return i !== -1 ? rawArgs[i + 1] : void 0;
19
+ };
20
+ const has = (name) => rawArgs.includes(name);
21
+ const DRY_RUN = has("--dry-run");
22
+ const LIMIT = parseInt(flag("--limit") || "0") || 0;
23
+ const CONFIG_FILE = flag("--config");
24
+ function formatDir(d, i) {
25
+ return `${String(i + 1).padStart(3)}. ${d.name.padEnd(28)} [${d.category.padEnd(22)}] auto=${d.auto} ${d.dr ? `DR${d.dr}` : ""} ${d.status && d.status !== "active" ? `(${d.status})` : ""}`;
26
+ }
27
+ async function cmdList() {
28
+ const cat = flag("--category");
29
+ const all = loadDirectories();
30
+ const filtered = cat ? all.filter((d) => d.category === cat) : all;
31
+ console.log(`
32
+ LinkFlow directory database: ${all.length} sites
33
+ `);
34
+ for (const [i, d] of filtered.entries()) {
35
+ if (LIMIT && i >= LIMIT) break;
36
+ console.log(formatDir(d, i));
37
+ }
38
+ console.log(`
39
+ Categories: ${categories().join(", ")}`);
40
+ }
41
+ async function cmdSearch() {
42
+ const q = VERB_ARG || "";
43
+ const results = searchDirectories(q);
44
+ console.log(`
45
+ Search "${q}": ${results.length} matches
46
+ `);
47
+ for (const [i, d] of results.entries()) {
48
+ if (LIMIT && i >= LIMIT) break;
49
+ console.log(formatDir(d, i));
50
+ console.log(` ${d.submitUrl}`);
51
+ }
52
+ }
53
+ async function cmdSubmit() {
54
+ const siteUrl = VERB_ARG;
55
+ if (!siteUrl) {
56
+ console.log("Usage: linkflow submit <site-url> [--dry-run] [--limit N] [--category X] [--go]");
57
+ process.exit(1);
58
+ }
59
+ const cfg = loadConfig(CONFIG_FILE);
60
+ console.log("\nLinkFlow submit");
61
+ console.log("\u2500".repeat(50));
62
+ printConfigSummary(cfg);
63
+ if (!hasAiConfig(cfg)) {
64
+ console.log(" \u26A0\uFE0F No AI config (.env.local AI_BASE_URL/AI_API_KEY) \u2014 using template payloads.");
65
+ }
66
+ const cat = flag("--category");
67
+ const targets = loadDirectories().filter((d) => {
68
+ if (cat && d.category !== cat) return false;
69
+ return d.auto === "yes" || d.auto === "manual";
70
+ });
71
+ const selected = LIMIT ? targets.slice(0, LIMIT) : targets;
72
+ console.log(`
73
+ Target directories: ${selected.length} (auto+manual, ${cat || "all categories"})
74
+ `);
75
+ resetAiCallCount();
76
+ const records = loadTracker();
77
+ let submitted = 0, skipped = 0, failed = 0;
78
+ for (const [i, dir] of selected.entries()) {
79
+ if (alreadySubmitted(siteUrl, dir.name)) {
80
+ console.log(` [${i + 1}/${selected.length}] \u23ED\uFE0F ${dir.name} \u2014 already submitted (tracked)`);
81
+ skipped++;
82
+ continue;
83
+ }
84
+ const payload = await generatePayload(dir, cfg);
85
+ console.log(` [${i + 1}/${selected.length}] \u{1F680} ${dir.name}`);
86
+ console.log(` tagline: ${payload.tagline.slice(0, 80)}`);
87
+ console.log(` submit: ${dir.submitUrl}`);
88
+ if (has("--go")) {
89
+ const result = await submitOne(dir, payload, {
90
+ siteUrl,
91
+ proofDir: ".linkflow/proofs"
92
+ });
93
+ console.log(` \u2192 ${result.status}: ${result.note || ""}${result.proof ? ` (proof: ${result.proof})` : ""}`);
94
+ if (result.status === "submitted") submitted++;
95
+ else if (result.status === "failed") failed++;
96
+ const perDay = cfg.pacing?.perDay ?? 10;
97
+ if (i + 1 >= perDay && i + 1 < selected.length) {
98
+ console.log(` \u23F8\uFE0F Daily pacing limit (${perDay}) reached \u2014 stopping.`);
99
+ break;
100
+ }
101
+ if (i + 1 < selected.length) {
102
+ const pause = (cfg.pacing?.minSeconds ?? 60) * 1e3;
103
+ console.log(` \u23F3 pacing ${pause / 1e3}s before next\u2026`);
104
+ await new Promise((r) => setTimeout(r, pause));
105
+ }
106
+ } else {
107
+ console.log(` desc: ${payload.description.slice(0, 100)}`);
108
+ if (!DRY_RUN) {
109
+ recordSubmission({
110
+ site: siteUrl,
111
+ directory: dir.name,
112
+ status: "pending",
113
+ submittedAt: (/* @__PURE__ */ new Date()).toISOString(),
114
+ url: dir.submitUrl,
115
+ notes: "planned \u2014 run with --go for automation"
116
+ });
117
+ submitted++;
118
+ }
119
+ }
120
+ }
121
+ console.log(`
122
+ Done: ${submitted} submitted/planned, ${skipped} skipped, ${failed} failed. AI calls: ${getAiCallCount()}`);
123
+ if (!DRY_RUN) {
124
+ const all = loadTracker();
125
+ writeReport(siteUrl, all.filter((r) => r.site === siteUrl));
126
+ const { md } = reportPath();
127
+ console.log(` Report: ${md}`);
128
+ } else {
129
+ console.log(" (dry-run \u2014 nothing recorded)");
130
+ }
131
+ }
132
+ async function cmdPayload() {
133
+ const siteUrl = VERB_ARG;
134
+ if (!siteUrl) {
135
+ console.log("Usage: linkflow payload <site-url> [--directory X]");
136
+ process.exit(1);
137
+ }
138
+ const cfg = loadConfig(CONFIG_FILE);
139
+ const dirName = flag("--directory");
140
+ const dirs = dirName ? loadDirectories().filter((d) => d.name.toLowerCase().includes(dirName.toLowerCase())) : loadDirectories();
141
+ if (!dirs.length) {
142
+ console.log(" No matching directory.");
143
+ return;
144
+ }
145
+ for (const dir of dirs.slice(0, 3)) {
146
+ const payload = await generatePayload(dir, cfg);
147
+ console.log(`
148
+ === ${dir.name} ===`);
149
+ console.log(JSON.stringify(payload, null, 2));
150
+ }
151
+ }
152
+ function cmdStatus() {
153
+ const s = trackerSummary();
154
+ console.log("\nLinkFlow tracker");
155
+ console.log("\u2500".repeat(50));
156
+ console.log(` Total records: ${s.total}`);
157
+ for (const [k, v] of Object.entries(s.byStatus)) console.log(` ${k}: ${v}`);
158
+ if (s.sites.length) console.log(` Sites: ${s.sites.join(", ")}`);
159
+ }
160
+ function cmdReport() {
161
+ const cfg = loadConfig();
162
+ const site = VERB_ARG || cfg.siteUrl || "all";
163
+ const all = loadTracker();
164
+ const filtered = site === "all" ? all : all.filter((r) => r.site === site);
165
+ writeReport(site, filtered);
166
+ const { md } = reportPath();
167
+ console.log(`
168
+ Report written: ${md}`);
169
+ }
170
+ function cmdStats() {
171
+ const s = dbStats();
172
+ console.log("\nLinkFlow database stats");
173
+ console.log("\u2500".repeat(50));
174
+ console.log(` Total directories: ${s.total}`);
175
+ for (const [k, v] of Object.entries(s.byCategory)) console.log(` ${k}: ${v}`);
176
+ console.log(` Auto-submittable: ${s.auto}`);
177
+ }
178
+ function cmdInit() {
179
+ const tpl = {
180
+ siteName: "My Product",
181
+ siteUrl: "https://example.com",
182
+ siteDescription: "A short, honest description of what it is and who it is for.",
183
+ tags: ["saas", "devtools"],
184
+ contentDomain: "SaaS product",
185
+ writingSample: "Paste 2-3 sentences in your site voice here.",
186
+ ai: {
187
+ provider: "openai",
188
+ baseUrl: "http://192.168.0.254:20128/v1",
189
+ apiKey: "your-omniroute-key",
190
+ model: "auto/best-free",
191
+ maxCallsPerRun: 20
192
+ },
193
+ pacing: { minSeconds: 60, perDay: 10 }
194
+ };
195
+ const p = path.join(process.cwd(), "linkflow.config.json");
196
+ if (fs.existsSync(p)) {
197
+ console.log(` ${p} already exists \u2014 not overwriting.`);
198
+ } else {
199
+ fs.writeFileSync(p, JSON.stringify(tpl, null, 2));
200
+ console.log(` Created ${p}`);
201
+ }
202
+ }
203
+ async function cmdDbReview() {
204
+ const { execSync } = await import("child_process");
205
+ const script = path.join(__dirname, "..", "scripts", "review-db.py");
206
+ if (!fs.existsSync(script)) {
207
+ console.log(" review-db.py not found \u2014 run from repo root.");
208
+ return;
209
+ }
210
+ try {
211
+ const out = execSync(`python3 "${script}"`, { encoding: "utf8" });
212
+ console.log(out);
213
+ } catch (err) {
214
+ console.log(" Review failed:", err.message);
215
+ }
216
+ }
217
+ async function cmdDbRegenerate() {
218
+ const { execSync } = await import("child_process");
219
+ const script = path.join(__dirname, "..", "scripts", "regenerate-db.py");
220
+ if (!fs.existsSync(script)) {
221
+ console.log(" regenerate-db.py not found \u2014 run from repo root.");
222
+ return;
223
+ }
224
+ try {
225
+ const out = execSync(`python3 "${script}"`, { encoding: "utf8" });
226
+ console.log(out);
227
+ } catch (err) {
228
+ console.log(" Regenerate failed:", err.message);
229
+ }
230
+ }
231
+ async function main() {
232
+ switch (VERB) {
233
+ case "list":
234
+ await cmdList();
235
+ break;
236
+ case "search":
237
+ await cmdSearch();
238
+ break;
239
+ case "submit":
240
+ await cmdSubmit();
241
+ break;
242
+ case "payload":
243
+ await cmdPayload();
244
+ break;
245
+ case "status":
246
+ cmdStatus();
247
+ break;
248
+ case "report":
249
+ cmdReport();
250
+ break;
251
+ case "stats":
252
+ cmdStats();
253
+ break;
254
+ case "db:review":
255
+ await cmdDbReview();
256
+ break;
257
+ case "db:regenerate":
258
+ await cmdDbRegenerate();
259
+ break;
260
+ case "init":
261
+ cmdInit();
262
+ break;
263
+ default:
264
+ console.log(`Unknown command: ${VERB}
265
+ Run 'linkflow' with: list | search | submit | payload | status | report | stats | db:review | db:regenerate | init`);
266
+ process.exit(1);
267
+ }
268
+ }
269
+ main().catch((err) => {
270
+ console.error("LinkFlow error:", err);
271
+ process.exit(1);
272
+ });
273
+ //# sourceMappingURL=index.js.map