Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/submitfleet",
"version": "0.2.0",
"version": "0.3.0",
"description": "SubmitFleet: launch your product on 339 launch sites and directories from one profile. Prepared submissions, accounts with passkeys, a tracked launch queue, CLI, MCP server and API.",
"type": "module",
"license": "MIT",
Expand Down
15 changes: 14 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { buildMcpServer } from './mcp.js';
import { SubmitFleet } from './service.js';
import { runTui } from './tui.js';
import { resendMailer } from './mail.js';
import { accessToken, DEFAULT_URL, login, logout, mcpStdioProxy, readAuth, remote } from './remote.js';
import { accessToken, DEFAULT_URL, login, loginWithKey, logout, mcpStdioProxy, readAuth, remote } from './remote.js';
import { SubmitFleetError } from './util.js';
import { createApiKey, ensureDefaultWorkspace, type Actor, type Role } from './workspaces.js';

Expand All @@ -25,6 +25,8 @@ Usage: submitfleet <command> [options]

Account (hosted at submitfleet.com, or --url for your own server)
login [--url URL] [--no-browser] sign in through your browser (OAuth 2.1 + PKCE); later commands use your account
login --key KEY | --key - sign in with an API key from Settings (- reads it from stdin)
or set SUBMITFLEET_API_KEY (and SUBMITFLEET_URL) in the environment
logout | whoami
--local ignore the signed-in account and use the local database

Expand Down Expand Up @@ -152,6 +154,7 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
local: { type: 'boolean' },
url: { type: 'string' },
'no-browser': { type: 'boolean' },
key: { type: 'string' },
},
});
const [cmd, sub, arg] = pos;
Expand All @@ -167,6 +170,16 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
// Account commands, and the hosted account whenever the CLI is signed in.
if (cmd === 'login') {
try {
const url = String(o.url ?? process.env.SUBMITFLEET_URL ?? DEFAULT_URL);
if (o.key !== undefined) {
// --key - reads the key from stdin so it stays out of shell history.
let key = String(o.key);
if (key === '-' || key === 'true') key = readFileSync(0, 'utf8').trim();
const a = await loginWithKey(url, key);
const who = (await remote('GET', '/api/me')) as { role?: string; workspace_id?: string };
out({ signed_in: true, url: a.url, with: 'api_key', role: who.role ?? null, workspace_id: who.workspace_id ?? null });
return EXIT.ok;
}
const a = await login(String(o.url ?? process.env.SUBMITFLEET_URL ?? DEFAULT_URL), log, { open: !o['no-browser'] });
const who = (await remote('GET', '/api/me')) as { user?: { email?: string } };
out({ signed_in: true, url: a.url, email: who.user?.email ?? null });
Expand Down
83 changes: 57 additions & 26 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function fetchPublic(raw: string, f: typeof fetch = guardedFetch, a
let url = new URL(raw);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
await assertPublic(url);
const res = await f(url, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept, 'user-agent': 'SubmitFleet/0.2 (+https://submitfleet.com)' } });
const res = await f(url, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept, 'user-agent': 'SubmitFleet/0.3 (+https://submitfleet.com)' } });
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
url = new URL(res.headers.get('location')!, url);
continue;
Expand Down Expand Up @@ -102,11 +102,21 @@ export interface ProposedFacts {
tagline: string | null;
description: string | null;
icon_url: string | null;
/** Every icon worth trying, most useful first. */
icon_candidates: string[];
x: string | null;
github: string | null;
}

/** Reads what a page says about itself. Every value is a proposal for the owner to edit and approve. */
const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, '');
const escapeRe = (x: string) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function clean(x: string | null | undefined): string | null {
if (!x) return null;
const t = decodeEntities(x).replace(/\s+/g, ' ').trim().replace(/[\s.:|·\-–—]+$/, '').replace(/^[\s|·\-–—:]+/, '');
return t || null;
}

export function parseFacts(html: string, pageUrl: string): ProposedFacts {
const head = html.slice(0, 300_000);
const metas = head.match(/<meta\b[^>]*>/gi) ?? [];
Expand All @@ -121,34 +131,54 @@ export function parseFacts(html: string, pageUrl: string): ProposedFacts {
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head);
return m ? decodeEntities(m[1]!.replace(/\s+/g, ' ')).trim() : null;
})();
const siteName = meta('og:site_name') ?? meta('application-name');
const ogTitle = meta('og:title') ?? meta('twitter:title');
let name = siteName ?? null;
let tagline: string | null = null;
const t = ogTitle ?? title;
if (t) {
const parts = t.split(/\s+[|\-–—:·]\s+/);
if (!name) name = parts[0] ?? t;
const rest = parts.filter((p) => p !== name);
if (rest.length) tagline = rest.join(' - ');
const host = new URL(pageUrl).hostname.replace(/^www\./, '');
const label = norm(host.split('.')[0]!);
const siteName = clean(meta('og:site_name') ?? meta('application-name'));
const titleText = clean(meta('og:title') ?? meta('twitter:title') ?? title);
// "Name | tagline", "Name - tagline", "Name: tagline", "tagline · Name"
const parts = (titleText ?? '').split(/\s+[|\-–—·]\s+|:\s+/).map(clean).filter((x): x is string => !!x);
const matchesDomain = (x: string) => {
const n = norm(x);
return !!n && (n === label || n.includes(label) || label.includes(n));
};
// A declared site name wins when it is the product's name; a four-word "site name" that does
// not match the domain is a slogan ("Know before it drops"), not a name.
const brandLike = (x: string | null) => !!x && x.length <= 40 && (matchesDomain(x) || x.split(/\s+/).length <= 3);
let name: string | null =
(siteName && matchesDomain(siteName) ? siteName : null) ??
parts.find(matchesDomain) ??
(brandLike(siteName) ? siteName : null) ??
(parts.length === 1 && brandLike(parts[0]!) ? parts[0]! : null) ??
host.split('.')[0]!;
name = clean(name);
const description = clean(meta('og:description') ?? meta('description') ?? meta('twitter:description'));
let tagline: string | null = parts.filter((x) => norm(x) !== norm(name ?? '') && !matchesDomain(x)).join(' - ') || null;
if (tagline && name) tagline = clean(tagline.replace(new RegExp(`^${escapeRe(name)}\\s*[:|\\-–—·]\\s*`, 'i'), ''));
if (!tagline && description) {
const first = description.split(/(?<=[.!?])\s/)[0]!;
if (first.length <= 90) tagline = clean(first.replace(/[.]$/, ''));
}
const description = meta('og:description') ?? meta('description') ?? meta('twitter:description');
if (!tagline && description && description.length <= 80) tagline = description;
if (tagline && name && norm(tagline) === norm(name)) tagline = null;
const links = head.match(/<link\b[^>]*>/gi) ?? [];
const icon = (() => {
for (const want of ['apple-touch-icon', 'icon', 'shortcut icon']) {
for (const l of links) {
if ((attr(l, 'rel') ?? '').toLowerCase().split(/\s+/).join(' ') === want && attr(l, 'href')) {
try {
return new URL(attr(l, 'href')!, pageUrl).toString();
} catch {
/* ignore malformed href */
}
}
}
const icons: string[] = [];
const addIcon = (href: string | null) => {
if (!href) return;
try {
const u = new URL(href, pageUrl).toString();
if (!icons.includes(u)) icons.push(u);
} catch {
/* ignore malformed href */
}
return null;
})();
};
// Raster icons first (directories want PNG/JPEG), biggest declared size first.
const iconLinks = links
.map((l) => ({ rel: (attr(l, 'rel') ?? '').toLowerCase(), href: attr(l, 'href'), type: (attr(l, 'type') ?? '').toLowerCase(), size: Number((attr(l, 'sizes') ?? '').split('x')[0]) || 0 }))
.filter((l) => /(^|\s)(apple-touch-icon|icon)(\s|$)/.test(l.rel) && l.href);
const raster = (l: { href: string | null; type: string }) => !/\.svg(\?|$)|\.ico(\?|$)/i.test(l.href ?? '') && !/svg|icon$/.test(l.type);
for (const l of iconLinks.filter(raster).sort((a, b) => (b.rel.includes('apple') ? 1 : 0) - (a.rel.includes('apple') ? 1 : 0) || b.size - a.size)) addIcon(l.href);
for (const conventional of ['/apple-touch-icon.png', '/icon-512.png', '/icon-192.png', '/logo.png', '/favicon.png']) addIcon(conventional);
for (const l of iconLinks.filter((l) => !raster(l))) addIcon(l.href);
const icon = icons[0] ?? null;
const anchors = head.match(/href\s*=\s*["']([^"']+)["']/gi) ?? [];
const find = (re: RegExp) => {
for (const a of anchors) {
Expand All @@ -165,6 +195,7 @@ export function parseFacts(html: string, pageUrl: string): ProposedFacts {
tagline: tagline ? tagline.slice(0, 120) : null,
description: description ? description.slice(0, 2000) : null,
icon_url: icon,
icon_candidates: icons,
x: xHandle && /^@?[A-Za-z0-9_]{1,15}$/.test(xHandle) ? (xHandle.startsWith('@') ? xHandle : `@${xHandle}`) : null,
github: gh,
};
Expand Down
15 changes: 10 additions & 5 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
import { INSTALL_SH } from './installer.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Mailer } from './mail.js';
import { mountWeb, resolveActor, sameOrigin } from './web.js';
import { mountWeb, resolveActor, sameOrigin, WEB_ROOT } from './web.js';
import { buildMcpServer } from './mcp.js';
import type { SubmitFleet } from './service.js';
import { SubmitFleetError } from './util.js';
Expand Down Expand Up @@ -114,7 +115,9 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[];
const ready = checks.find((x) => x.check === 'database')?.ok === true;
return c.json({ ready, checks }, ready ? 200 : 503);
});
app.get('/install.sh', (c) => c.body(INSTALL_SH, 200, { 'content-type': 'text/x-shellscript; charset=utf-8' }));
// The house npm-CLI installer (w3bs.org template): brings its own Node when the machine has none.
const installSh = readFileSync(join(WEB_ROOT, 'install.sh'), 'utf8');
app.get('/install.sh', (c) => c.body(installSh, 200, { 'content-type': 'text/x-shellscript; charset=utf-8', 'cache-control': 'no-cache' }));
// Bearer tokens (agents, CLI) or the browser session. A cookie-authenticated write must
// come from our own pages (CSRF); cross-site browser calls are refused either way.
const auth = async (c: Context<Env>, next: () => Promise<void>) => {
Expand Down Expand Up @@ -251,8 +254,10 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[];
return c.json(await fleet.productSetLogo(A(c), c.req.param('id'), bytes, c.req.header('content-type') ?? ''));
});
api.post('/products/:id/logo/from-url', async (c) => {
const b = await body(c, z.object({ url: z.string().url().max(2000) }));
return c.json(await fleet.productLogoFromUrl(A(c), c.req.param('id'), b.url));
const b = await body(c, z.object({ url: z.string().url().max(2000).optional(), urls: z.array(z.string().url().max(2000)).max(10).optional() }));
const urls = b.urls ?? (b.url ? [b.url] : []);
if (!urls.length) throw new SubmitFleetError('invalid_request', 'url or urls is required');
return c.json(await fleet.productLogoFromCandidates(A(c), c.req.param('id'), urls));
});
api.get('/catalog-stats', async (c) => c.json(await fleet.catalogStats()));
api.get('/campaigns/:id/queue', async (c) => c.json(await fleet.campaignQueue(A(c), c.req.param('id'))));
Expand Down
31 changes: 0 additions & 31 deletions src/installer.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { SubmitFleet } from './service.js';
import { SubmitFleetError } from './util.js';
import type { Actor } from './workspaces.js';

export const MCP_SERVER_VERSION = '0.1.0';
export const MCP_SERVER_VERSION = '0.3.0';

type Json = unknown;

Expand Down
26 changes: 24 additions & 2 deletions src/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,24 @@ export interface StoredAuth {
access_token: string;
refresh_token: string;
expires_at: number;
/** Set when signed in with an API key: no refresh, no expiry. */
api_key?: boolean;
}

/** An API key from the environment wins over a stored sign-in (agents, CI). */
function envAuth(): StoredAuth | null {
const key = process.env.SUBMITFLEET_API_KEY;
if (!key) return null;
return { url: (process.env.SUBMITFLEET_URL ?? DEFAULT_URL).replace(/\/+$/, ''), access_token: key, refresh_token: '', expires_at: Number.MAX_SAFE_INTEGER, api_key: true };
}

export function authFile(): string {
return process.env.SUBMITFLEET_AUTH_FILE ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'submitfleet', 'auth.json');
}

export function readAuth(): StoredAuth | null {
const env = envAuth();
if (env) return env;
try {
return JSON.parse(readFileSync(authFile(), 'utf8')) as StoredAuth;
} catch {
Expand Down Expand Up @@ -103,10 +114,21 @@ export async function login(url = DEFAULT_URL, log: (s: string) => void = (s) =>
});
}

/** Signs the CLI in with an API key (Settings → API keys). The key is checked before it is stored. */
export async function loginWithKey(url: string, key: string): Promise<StoredAuth> {
const base = url.replace(/\/+$/, '');
const res = await fetch(`${base}/api/me`, { headers: { authorization: `Bearer ${key}` } });
if (!res.ok) throw new SubmitFleetError('invalid_key', res.status === 401 ? 'that API key is not valid' : `HTTP ${res.status}`);
const auth: StoredAuth = { url: base, access_token: key, refresh_token: '', expires_at: Number.MAX_SAFE_INTEGER, api_key: true };
writeAuth(auth);
return auth;
}

export async function logout(): Promise<boolean> {
const a = readAuth();
if (!a) return false;
await fetch(`${a.url}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: a.refresh_token }) }).catch(() => {});
// An API key is only forgotten here; revoke it in Settings if it should stop working everywhere.
if (!a.api_key) await fetch(`${a.url}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: a.refresh_token }) }).catch(() => {});
if (existsSync(authFile())) rmSync(authFile());
return true;
}
Expand All @@ -115,7 +137,7 @@ export async function logout(): Promise<boolean> {
export async function accessToken(): Promise<StoredAuth> {
const a = readAuth();
if (!a) throw new SubmitFleetError('not_logged_in', 'run submitfleet login first');
if (a.expires_at - Date.now() > 60_000) return a;
if (a.api_key || a.expires_at - Date.now() > 60_000) return a;
try {
const next = await tokenRequest(a.url, { grant_type: 'refresh_token', refresh_token: a.refresh_token, client_id: CLIENT_ID });
writeAuth(next);
Expand Down
13 changes: 13 additions & 0 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,19 @@ export class SubmitFleet {
return this.productSetLogo(a, id, r.body, mime);
};

/** Tries icon candidates in order and keeps the first real PNG, JPEG or WebP. */
productLogoFromCandidates = async (a: Actor, id: string, urls: string[]) => {
const errors: string[] = [];
for (const url of urls.slice(0, 10)) {
try {
return await this.productLogoFromUrl(a, id, url);
} catch (e) {
errors.push(`${url}: ${e instanceof Error ? e.message : e}`);
}
}
throw new SubmitFleetError('invalid_request', 'none of the icons is a PNG, JPEG or WebP image; upload a logo instead', errors);
};

/** Public logo bytes for a product, so a directory form can take a logo URL. */
productLogo = async (productId: string): Promise<{ bytes: Buffer; mime: string } | null> => {
const r = await one<{ logo_path: string | null; logo_mime: string | null }>(this.db, 'SELECT logo_path, logo_mime FROM products WHERE id = ?', productId);
Expand Down
15 changes: 15 additions & 0 deletions test/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,18 @@ describe('assisted flow over HTTP', () => {
expect(Buffer.from(await pub.arrayBuffer()).equals(png)).toBe(true);
});
});

describe('fact extraction', () => {
it('splits "Name: tagline" titles, prefers the domain name, and skips slogans as names', async () => {
const { parseFacts } = await import('../src/extract.js');
const a = parseFacts('<title>typeheard: drop a recording, get the transcript</title>', 'https://typeheard.com/');
expect(a).toMatchObject({ name: 'typeheard', tagline: 'drop a recording, get the transcript' });
const b = parseFacts('<meta property="og:site_name" content="Know before it drops"><title>Know before it drops</title><meta name="description" content="Follow a genre or a name and get told before it drops. Free.">', 'https://genrewatch.com/');
expect(b.name).toBe('genrewatch');
expect(b.tagline).toBe('Know before it drops');
const c = parseFacts('<title>bl0ggers. | Human-in-the-loop AI publishing</title><link rel="icon" href="/i.svg"><link rel="apple-touch-icon" href="/a.png">', 'https://bl0ggers.com/');
expect(c.name).toBe('bl0ggers');
expect(c.icon_candidates[0]).toBe('https://bl0ggers.com/a.png');
expect(c.icon_candidates.at(-1)).toBe('https://bl0ggers.com/i.svg');
});
});
Loading
Loading