From 85e460c320d4c5e06b43eca959ebf8e1510317f5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 15 Oct 2026 00:56:29 +0000 Subject: [PATCH 1/2] wip: extractor names/taglines/icons, CLI API-key login Co-Authored-By: Claude Opus 5.5 --- src/cli.ts | 15 ++++++++- src/extract.ts | 81 ++++++++++++++++++++++++++++++++--------------- src/http.ts | 6 ++-- src/remote.ts | 26 +++++++++++++-- src/service.ts | 13 ++++++++ web/assets/app.js | 4 ++- 6 files changed, 114 insertions(+), 31 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7d9fd41..0eb4436 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -25,6 +25,8 @@ Usage: submitfleet [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 @@ -152,6 +154,7 @@ export async function main(argv = process.argv.slice(2)): Promise { local: { type: 'boolean' }, url: { type: 'string' }, 'no-browser': { type: 'boolean' }, + key: { type: 'string' }, }, }); const [cmd, sub, arg] = pos; @@ -167,6 +170,16 @@ export async function main(argv = process.argv.slice(2)): Promise { // 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 }); diff --git a/src/extract.ts b/src/extract.ts index 5a64807..6094fdd 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -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(/]*>/gi) ?? []; @@ -121,34 +131,54 @@ export function parseFacts(html: string, pageUrl: string): ProposedFacts { const m = /]*>([\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(/]*>/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) { @@ -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, }; diff --git a/src/http.ts b/src/http.ts index 357a153..035da6f 100644 --- a/src/http.ts +++ b/src/http.ts @@ -251,8 +251,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')))); diff --git a/src/remote.ts b/src/remote.ts index 6acd1ed..f9d31e0 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -15,6 +15,15 @@ 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 { @@ -22,6 +31,8 @@ export function authFile(): string { } export function readAuth(): StoredAuth | null { + const env = envAuth(); + if (env) return env; try { return JSON.parse(readFileSync(authFile(), 'utf8')) as StoredAuth; } catch { @@ -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 { + 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 { 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; } @@ -115,7 +137,7 @@ export async function logout(): Promise { export async function accessToken(): Promise { 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); diff --git a/src/service.ts b/src/service.ts index 861d335..1227e72 100644 --- a/src/service.ts +++ b/src/service.ts @@ -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); diff --git a/web/assets/app.js b/web/assets/app.js index fb67f5e..367089a 100644 --- a/web/assets/app.js +++ b/web/assets/app.js @@ -286,6 +286,7 @@ async function newProduct() { const msg = h('div'); const holder = h('div'); let icon = null; + let iconCandidates = []; const fetchBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Fetch details'); const start = h('form', { class: 'card stack' }, h('label', { for: 'start-url' }, 'Product URL'), h('div', { class: 'btn-row' }, urlInput, fetchBtn), msg, h('button', { class: 'btn ghost sm', type: 'button', onclick: () => showForm({ url: urlInput.value.trim() }) }, 'Skip and fill in by hand')); @@ -296,7 +297,7 @@ async function newProduct() { iconBox, productForm(draft, async (body) => { const p = await post('/products', body); - if (icon && document.getElementById('use-icon')?.checked) await post(`/products/${p.id}/logo/from-url`, { url: icon }).catch(() => toast('Logo could not be fetched; upload one on the next page.')); + if (icon && document.getElementById('use-icon')?.checked) await post(`/products/${p.id}/logo/from-url`, { urls: iconCandidates }).catch(() => toast('Logo could not be fetched; upload one on the next page.')); go(`/app/products/${p.id}`); }, 'Save product'), ); @@ -311,6 +312,7 @@ async function newProduct() { try { const facts = await post('/products/extract', { url }); icon = facts.icon_url; + iconCandidates = facts.icon_candidates || (icon ? [icon] : []); showForm({ url, name: facts.name, tagline: facts.tagline, description: facts.description, facts: { x: facts.x, github: facts.github } }); } catch (err) { msg.replaceChildren(h('div', { class: 'notice warn' }, `Could not read that page (${err.message}). Fill in the details by hand.`)); From 34c0dfd2897805d07e8e398ac8d772118b335b6e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 15 Oct 2026 00:58:01 +0000 Subject: [PATCH 2/2] Better product autofill, API-key CLI login, house installer (0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Titles split on 'Name: tagline' as well as | - ·; the name is the part matching the domain (or a short declared site name); slogans become taglines, not names; stray punctuation and repeated names stripped - Every icon candidate is collected (raster first, conventional paths, SVG last) and logo import tries each until a real PNG/JPEG/WebP - submitfleet login --key KEY (or --key - from stdin) and SUBMITFLEET_API_KEY for agents - install.sh is the w3bs.org npm-CLI template: brings its own Node 24 when none is present Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/extract.ts | 2 +- src/http.ts | 9 ++- src/installer.ts | 31 --------- src/mcp.ts | 2 +- test/web.test.ts | 15 +++++ web/install.sh | 159 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 37 deletions(-) delete mode 100644 src/installer.ts create mode 100644 web/install.sh diff --git a/package.json b/package.json index aa58a62..b7f7226 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/extract.ts b/src/extract.ts index 6094fdd..d33cdb4 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -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; diff --git a/src/http.ts b/src/http.ts index 035da6f..72bd8de 100644 --- a/src/http.ts +++ b/src/http.ts @@ -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'; @@ -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, next: () => Promise) => { diff --git a/src/installer.ts b/src/installer.ts deleted file mode 100644 index 472d5c1..0000000 --- a/src/installer.ts +++ /dev/null @@ -1,31 +0,0 @@ -// The curl installer served at /install.sh. - -export const INSTALL_SH = `#!/bin/sh -# SubmitFleet CLI installer: curl -fsSL https://submitfleet.com/install.sh | sh -set -eu -if ! command -v node >/dev/null 2>&1; then - echo "submitfleet needs Node.js 22.5 or newer: https://nodejs.org/" >&2 - exit 1 -fi -major=$(node -p 'process.versions.node.split(".")[0]') -if [ "$major" -lt 22 ]; then - echo "submitfleet needs Node.js 22.5 or newer (found $(node -v))" >&2 - exit 1 -fi -# npm first: its global bin works out of the box. pnpm only when npm is missing or fails -# (pnpm add -g aborts unless its global bin directory was set up beforehand). -if command -v npm >/dev/null 2>&1 && npm install -g @profullstack/submitfleet; then - bin_dir="$(npm prefix -g)/bin" -elif command -v pnpm >/dev/null 2>&1 && pnpm add -g @profullstack/submitfleet; then - bin_dir="$(pnpm bin -g)" -else - echo "submitfleet: install failed (tried npm and pnpm)" >&2 - exit 1 -fi -if command -v submitfleet >/dev/null 2>&1; then - submitfleet doctor || true - echo "Installed. Try: submitfleet coverage report" -else - echo "Installed to $bin_dir, which is not on your PATH. Add it, then run: submitfleet doctor" -fi -`; diff --git a/src/mcp.ts b/src/mcp.ts index 1ae29d8..64840cd 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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; diff --git a/test/web.test.ts b/test/web.test.ts index 26c5e61..b90ffd9 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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('typeheard: drop a recording, get the transcript', 'https://typeheard.com/'); + expect(a).toMatchObject({ name: 'typeheard', tagline: 'drop a recording, get the transcript' }); + const b = parseFacts('Know before it drops', 'https://genrewatch.com/'); + expect(b.name).toBe('genrewatch'); + expect(b.tagline).toBe('Know before it drops'); + const c = parseFacts('bl0ggers. | Human-in-the-loop AI publishing', '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'); + }); +}); diff --git a/web/install.sh b/web/install.sh new file mode 100644 index 0000000..d9d6cad --- /dev/null +++ b/web/install.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# submitfleet installer. +# +# curl -fsSL https://submitfleet.com/install.sh | sh +# +# Installs @profullstack/submitfleet (commands: submitfleet) under your home directory. No root, no system +# package manager, no system files touched. If this machine has no Node 24+, +# an official Node build is fetched (checksum verified) into the same private directory +# and used from there. Run this again to update. Remove with: +# sh ~/.local/share/submitfleet/uninstall.sh +# +# sh -s -- --version X install a specific release +# sh -s -- --prefix DIR install root (default: ~/.local) +# +# Listed in the W3BS catalog (https://w3bs.org/packages): `w3bs install submitfleet` runs +# this same script and `w3bs update` re-runs it. +# +# Generated from the W3BS npm CLI installer template; keep the variables below in sync +# with package.json. Nothing runs until the last line, so a truncated download is inert. + +main() { + set -eu + PKG="@profullstack/submitfleet" + TOOL="submitfleet" + URL="https://submitfleet.com/install.sh" + NODE_MAJOR="24" + PREFIX="${INSTALL_PREFIX:-$HOME/.local}" + VERSION="" + NODE_CHOICE="${INSTALL_NODE:-auto}" + + while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="${2:?--version needs a value}"; shift ;; + --prefix) PREFIX="${2:?--prefix needs a value}"; shift ;; + -h | --help) echo "Usage: curl -fsSL $URL | sh [-s -- --version X --prefix DIR]"; return 0 ;; + *) echo "$TOOL installer: unknown option $1" >&2; return 64 ;; + esac + shift + done + + say() { printf '%s\n' "$*"; } + fail() { printf '%s installer: %s\n' "$TOOL" "$*" >&2; exit 1; } + + case "$(uname -s)" in + Linux) OS=linux ;; + Darwin) OS=darwin ;; + *) fail "unsupported operating system $(uname -s); Linux and macOS are supported" ;; + esac + case "$(uname -m)" in + x86_64 | amd64) ARCH=x64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) fail "unsupported CPU $(uname -m)" ;; + esac + if command -v curl >/dev/null 2>&1; then + fetch() { curl --proto '=https' --tlsv1.2 -fsSL "$1" -o "$2"; } + elif command -v wget >/dev/null 2>&1; then + fetch() { wget --https-only -q "$1" -O "$2"; } + else + fail "curl or wget is required" + fi + + SHARE="$PREFIX/share/$TOOL" + BIN="$PREFIX/bin" + mkdir -p "$SHARE" "$BIN" + + node_major() { "$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; } + NODE="" + NODE_KIND="" + if [ "$NODE_CHOICE" != private ] && command -v node >/dev/null 2>&1 && [ "$(node_major node)" -ge "$NODE_MAJOR" ]; then + NODE="$(command -v node)" + NODE_KIND=system + elif [ -x "$SHARE/node/bin/node" ] && [ "$(node_major "$SHARE/node/bin/node")" -ge "$NODE_MAJOR" ]; then + NODE="$SHARE/node/bin/node" + NODE_KIND=private + fi + if [ -z "$NODE" ]; then + say "No Node $NODE_MAJOR+ here; fetching an official Node 24 into $SHARE/node ..." + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + fetch "https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt" "$TMP/SHASUMS256.txt" || fail "cannot reach nodejs.org" + TARBALL="$(grep -o "node-v24\.[0-9.]*-$OS-$ARCH\.tar\.gz" "$TMP/SHASUMS256.txt" | head -n 1 || true)" + [ -n "$TARBALL" ] || fail "nodejs.org has no Node 24 build for $OS-$ARCH" + fetch "https://nodejs.org/dist/latest-v24.x/$TARBALL" "$TMP/$TARBALL" || fail "cannot download $TARBALL" + if command -v sha256sum >/dev/null 2>&1; then SUM="$(sha256sum "$TMP/$TARBALL" | cut -d ' ' -f 1)" + else SUM="$(shasum -a 256 "$TMP/$TARBALL" | cut -d ' ' -f 1)"; fi + grep -q "^$SUM $TARBALL\$" "$TMP/SHASUMS256.txt" || fail "checksum mismatch for $TARBALL" + rm -rf "$SHARE/node" && mkdir -p "$SHARE/node" + tar -xzf "$TMP/$TARBALL" -C "$SHARE/node" --strip-components=1 || fail "cannot unpack $TARBALL" + NODE="$SHARE/node/bin/node" + NODE_KIND=private + fi + if [ "$NODE_KIND" = private ]; then NPM="$SHARE/node/bin/npm"; else NPM="$(dirname "$NODE")/npm"; [ -x "$NPM" ] || NPM=npm; fi + # npm runs package scripts with the Node in PATH; make that the chosen one. + PATH="$(dirname "$NODE"):$PATH" + export PATH + + SPEC="$PKG" + [ -n "$VERSION" ] && SPEC="$PKG@$VERSION" + say "Installing $SPEC into $SHARE ..." + rm -rf "$SHARE/node_modules" "$SHARE/package.json" "$SHARE/package-lock.json" + cd "$SHARE" + printf '{ "name": "%s-install", "private": true }\n' "$TOOL" > package.json + "$NPM" install --silent --no-audit --no-fund --omit=dev "$SPEC" >/dev/null 2>&1 || + "$NPM" install --no-audit --no-fund --omit=dev "$SPEC" || fail "npm could not install $SPEC" + PKG_DIR="$SHARE/node_modules/$PKG" + [ -f "$PKG_DIR/package.json" ] || fail "$SPEC installed but $PKG_DIR is missing" + + # One shim per entry in the package's own `bin` map: a script, not a symlink, so the + # command always runs this copy with the chosen Node. + BINS="$("$NODE" -e ' + const p = require(process.argv[1] + "/package.json"); + const b = typeof p.bin === "string" ? { [p.name.split("/").pop()]: p.bin } : (p.bin || {}); + for (const [k, v] of Object.entries(b)) if (/^[A-Za-z0-9._-]+$/.test(k)) console.log(k + "\t" + v); + ' "$PKG_DIR")" + [ -n "$BINS" ] || fail "$SPEC declares no commands" + PATHS="$SHARE" + NAMES="" + OLDIFS="$IFS" + IFS=' +' + for line in $BINS; do + name="${line%% *}" + rel="${line#* }" + entry="$PKG_DIR/$rel" + [ -f "$entry" ] || fail "$SPEC is missing $rel" + printf '#!/bin/sh\n# %s, installed by the %s installer (%s).\nexec "%s" "%s" "$@"\n' "$name" "$TOOL" "$URL" "$NODE" "$entry" > "$BIN/$name" + chmod 0755 "$BIN/$name" + PATHS="$PATHS +$BIN/$name" + NAMES="$NAMES $name" + done + IFS="$OLDIFS" + + INSTALLED="$("$NODE" -p "require('$PKG_DIR/package.json').version" 2>/dev/null || echo unknown)" + { + printf '{\n "package": "%s",\n "version": "%s",\n "installer": "%s",\n' "$PKG" "$INSTALLED" "$URL" + printf ' "installedAt": "%s",\n "node": "%s",\n "paths": [\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$NODE_KIND" + printf '%s\n' "$PATHS" | sed 's/.*/ "&",/' | sed '$ s/,$//' + printf ' ]\n}\n' + } > "$SHARE/manifest.json" + { + echo '#!/bin/sh' + echo "# Removes $TOOL. Written by its installer." + echo 'set -eu' + printf '%s\n' "$PATHS" | sed 's|.*|rm -rf "&"|' + echo "echo '$TOOL removed.'" + } > "$SHARE/uninstall.sh" + chmod 0755 "$SHARE/uninstall.sh" + + say "" + say "Installed $PKG $INSTALLED:$NAMES (in $BIN)" + case ":$PATH:" in + *":$BIN:"*) ;; + *) say "Add $BIN to your PATH: echo 'export PATH=\"$BIN:\$PATH\"' >> ~/.profile" ;; + esac + say "Update by running the installer again; remove with: sh $SHARE/uninstall.sh" +} + +main "$@"