diff --git a/RELEASE_WORKFLOW.md b/RELEASE_WORKFLOW.md index 98391e7..2f4a02c 100644 --- a/RELEASE_WORKFLOW.md +++ b/RELEASE_WORKFLOW.md @@ -137,7 +137,7 @@ gh pr merge PR_NUMBER --merge --admin --delete-branch Choosing the relevant check - For broad product, Worker, policy, or generated-output changes: `npm run check`. -- For maintenance script changes, such as `scripts/doctor.mjs`, `scripts/install.mjs`, `scripts/upgrade.mjs`, or shared +- For maintenance script changes, such as `scripts/doctor.mjs`, `scripts/setup.mjs`, `scripts/upgrade.mjs`, or shared script libraries: run the focused test that covers the changed path, then `npm run check` before merge. - For doctor output changes: `npm run doctor -- --json`. - For opt-in upstream nudge changes: `npm run doctor -- --json --check-upstream`. diff --git a/package.json b/package.json index 0405c7e..9ec82bb 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,15 @@ "local-install": "node scripts/local-install.mjs", "local-publish": "node scripts/local-publish.mjs", "optimize:badges": "find defaults/public -name 'v8s-redirected*.svg' -print0 | xargs -0 svgo --config scripts/svgo.config.mjs", - "setup": "node scripts/install.mjs", + "setup": "node scripts/setup.mjs", "smoke": "npm run smoke:analytics", "smoke:analytics": "npm run build && node scripts/smoke-analytics.mjs", "test": "npm run test:all", - "test:all": "npm run test:worker && npm run test:registry && npm run test:install && npm run test:maintenance && npm run test:upstream-release && npm run test:build-core && npm run test:build-html-core", + "test:all": "npm run test:worker && npm run test:registry && npm run test:install && npm run test:install-core && npm run test:maintenance && npm run test:upstream-release && npm run test:build-core && npm run test:build-html-core", "test:build-core": "node scripts/build-site-core.test.mjs", "test:build-html-core": "node scripts/build-html-core.test.mjs", "test:install": "node scripts/install.test.mjs", + "test:install-core": "node scripts/install-core.test.mjs", "test:maintenance": "node scripts/maintenance.test.mjs", "test:registry": "node scripts/registry.test.mjs", "test:upstream-release": "node scripts/upstream-release.test.mjs", diff --git a/scripts/install-core.test.mjs b/scripts/install-core.test.mjs new file mode 100644 index 0000000..67854b8 --- /dev/null +++ b/scripts/install-core.test.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { + analyticsDisclosureDefault, + defaultBrandingSlogan, + defaultContactEmail, + normalizeAccessTeamDomain, + normalizeArgs, + normalizeAnalyticsProviders, + normalizeDomain, + normalizeLanguages, + normalizeRandomSlugLength, + normalizeSloganMap, + parseArgs, + slugifyOwner, + slugifyWorker, + suggestWordmarkSplit +} from "./lib/install/core.mjs"; + +{ + assert.deepEqual(parseArgs(["--domain", "https://Go.Example/path", "--no-check", "--customize-public"]), { + analytics: "disabled", + check: false, + customizePublic: true, + dryRun: false, + domain: "https://Go.Example/path", + force: false, + owner: "owner" + }); + assert.throws(() => parseArgs(["--domain"]), /Missing value/); + assert.throws(() => parseArgs(["unexpected"]), /Unknown argument/); +} + +{ + assert.equal(normalizeDomain("https://Go.Example/path"), "go.example"); + assert.equal(slugifyWorker("Go Example!!!"), "go-example"); + assert.equal(slugifyOwner("Team Name!"), "team-name"); + assert.deepEqual(normalizeLanguages("fr-CA,en,fr,de"), ["en", "fr", "de"]); + assert.equal(normalizeAnalyticsProviders("Umami, FATHOM"), "umami,fathom"); + assert.throws(() => normalizeAnalyticsProviders("plausible"), /Unsupported analytics provider/); + assert.equal(normalizeRandomSlugLength("12"), 12); + assert.throws(() => normalizeRandomSlugLength("0"), /Random slug length/); +} + +{ + assert.equal(defaultContactEmail("security", "https://Example.com/path"), "security@example.com"); + assert.equal(normalizeAccessTeamDomain("https://team.cloudflareaccess.com/"), "team.cloudflareaccess.com"); + assert.deepEqual(suggestWordmarkSplit("go.example"), { black: "go.", green: "example" }); +} + +{ + assert.equal(analyticsDisclosureDefault("disabled"), "No analytics enabled."); + assert.equal( + analyticsDisclosureDefault("umami"), + "Privacy-respecting analytics are configured for operations, security, and reliability." + ); + assert.equal( + defaultBrandingSlogan({ operatorLegalName: "Example Inc." }, "en"), + "A short-link service for Example Inc.'s projects" + ); + assert.deepEqual(normalizeSloganMap("Hello", ["en", "fr"], { operatorLegalName: "Example Inc." }), { + en: "Hello", + fr: "Un service de liens courts pour les projets de Example Inc." + }); +} + +{ + const normalized = normalizeArgs( + { + analytics: "umami", + domain: "https://Go.Example/path", + owner: "Team Name", + operatorAbuseContact: "abuse@example.com", + operatorSecurityContact: "security@example.com", + operatorTimezone: "America/Toronto", + wordmarkBlack: "Go.", + wordmarkGreen: "Example" + }, + { + defaultRandomSlugLength: 5, + fallbackLastUpdated: "2026-06-05" + } + ); + + assert.equal(normalized.domain, "go.example"); + assert.equal(normalized.workerName, "go-example"); + assert.equal(normalized.owner, "team-name"); + assert.equal(normalized.randomSlugLength, 5); + assert.equal(normalized.operator.short_domain, "go.example"); + assert.equal(normalized.operator.last_updated, "2026-06-05"); + assert.equal(normalized.operator.analytics_retention, "180 days"); + assert.equal(normalized.configureBranding, true); +} + +{ + assert.throws( + () => + normalizeArgs( + { + domain: "go.example", + operatorAbuseContact: "abuse@example.com", + operatorSecurityContact: "security@example.com", + operatorTimezone: "-4" + }, + { fallbackLastUpdated: "2026-06-05" } + ), + /Operator timezone must be an IANA timezone name/ + ); +} + +console.log("install core tests ok"); diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs index b795eb7..dc5bbdf 100644 --- a/scripts/install.test.mjs +++ b/scripts/install.test.mjs @@ -29,7 +29,7 @@ function makeFixture() { } function runSetup(cwd, extraArgs) { - return execFileSync(process.execPath, ["scripts/install.mjs", "--no-check", ...extraArgs], { + return execFileSync(process.execPath, ["scripts/setup.mjs", "--no-check", ...extraArgs], { cwd, encoding: "utf8", env: { ...process.env, V8S_INTERNAL_SETUP: "1" }, diff --git a/scripts/lib/install/core.mjs b/scripts/lib/install/core.mjs new file mode 100644 index 0000000..9d2174a --- /dev/null +++ b/scripts/lib/install/core.mjs @@ -0,0 +1,395 @@ +export const DEFAULT_DOMAIN = "v8s.link"; +export const DEFAULT_LANGUAGE = "en"; +export const DEFAULT_LANGUAGES = ["en", "de", "es", "fr", "it"]; +export const DEFAULT_RANDOM_SLUG_LENGTH = 3; +export const DEFAULT_OPERATOR_TIMEZONE = "UTC"; +export const MAX_RANDOM_SLUG_LENGTH = 64; +export const MAX_WORKER_NAME_LENGTH = 63; + +export function parseArgs(argv) { + const args = { + analytics: "disabled", + check: true, + dryRun: false, + force: false, + owner: "owner" + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--no-check") { + args.check = false; + } else if (arg === "--dry-run") { + args.dryRun = true; + } else if (arg === "--force") { + args.force = true; + } else if (arg === "--customize-public") { + args.customizePublic = true; + } else if (arg === "--no-customize-public") { + args.customizePublic = false; + } else if (arg.startsWith("--")) { + const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + throw new Error(`Missing value for ${arg}`); + } + args[key] = next; + index += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +export function normalizeArgs(args, options = {}) { + args.domain = normalizeDomain(args.domain); + if (!args.operatorShortDomain) args.operatorShortDomain = args.domain; + args.workerName = args.workerName ? slugifyWorker(args.workerName) : slugifyWorker(args.domain); + args.analytics = normalizeAnalyticsProviders(args.analytics); + args.owner = slugifyOwner(args.owner); + args.randomSlugLength = normalizeRandomSlugLength( + args.randomSlugLength || options.defaultRandomSlugLength || DEFAULT_RANDOM_SLUG_LENGTH + ); + args.languages = normalizeLanguages(args.languages); + args.configureBranding = + args.configureBranding ?? + (args.customizePublic != null || + args.brandingSlogan != null || + args.brandingSlogans != null || + args.wordmarkBlack != null || + args.wordmarkGreen != null); + args.configureBranding = normalizeBoolean(args.configureBranding); + args.customizePublic = normalizeBoolean(args.customizePublic); + args.brandingSlogans = normalizeSloganMap(args.brandingSlogans ?? args.brandingSlogan, args.languages, args); + args.operator = normalizeOperator(args, { + fallbackLastUpdated: options.fallbackLastUpdated + }); + + if (!args.domain) throw new Error("Domain cannot be empty."); + if (!args.workerName) throw new Error("Worker name cannot be empty."); + validateWorkerName(args.workerName); + validateOperator(args.operator); + if (args.configureBranding) { + const split = normalizeWordmarkSplit(args); + args.wordmarkBlack = split.black; + args.wordmarkGreen = split.green; + } + + return args; +} + +export function normalizeDomain(value) { + return String(value || "") + .trim() + .replace(/^https?:\/\//i, "") + .replace(/\/.*$/g, "") + .toLowerCase(); +} + +export function slugifyWorker(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_WORKER_NAME_LENGTH) + .replace(/-+$/g, ""); +} + +export function validateWorkerName(value) { + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value)) { + throw new Error( + "Worker name must use lowercase letters, numbers, and hyphens; it must start and end with a letter or number." + ); + } +} + +export function slugifyOwner(value) { + return ( + String(value || "owner") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") || "owner" + ); +} + +export function normalizeAnalyticsProviders(value) { + const providers = String(value || "disabled") + .split(",") + .map((provider) => provider.trim().toLowerCase()) + .filter(Boolean); + + if (!providers.length) return "disabled"; + + const allowed = new Set(["disabled", "none", "off", "umami", "fathom"]); + for (const provider of providers) { + if (!allowed.has(provider)) throw new Error(`Unsupported analytics provider: ${provider}`); + } + + return providers.join(","); +} + +export function normalizeRandomSlugLength(value) { + const number = Number.parseInt(String(value || ""), 10); + if (!Number.isInteger(number) || number < 1 || number > MAX_RANDOM_SLUG_LENGTH) { + throw new Error(`Random slug length must be an integer from 1 to ${MAX_RANDOM_SLUG_LENGTH}.`); + } + return number; +} + +export function normalizeLanguages(value) { + const languages = String(value || DEFAULT_LANGUAGES.join(",")) + .split(",") + .map((language) => language.trim().toLowerCase().split("-")[0]) + .filter(Boolean); + const unique = [...new Set(languages)]; + const ordered = unique.includes(DEFAULT_LANGUAGE) ? unique : [DEFAULT_LANGUAGE, ...unique]; + return [DEFAULT_LANGUAGE, ...ordered.filter((language) => language !== DEFAULT_LANGUAGE)]; +} + +export function normalizeBoolean(value) { + if (typeof value === "boolean") return value; + if (value == null || value === "") return false; + return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase()); +} + +export function isAnalyticsDisabled(value) { + const providers = String(value || "disabled") + .split(",") + .map((provider) => provider.trim().toLowerCase()) + .filter(Boolean); + return !providers.length || providers.some((provider) => ["disabled", "none", "off"].includes(provider)); +} + +export function defaultContactEmail(localPart, domain) { + const normalizedDomain = normalizeDomain(domain); + return normalizedDomain ? `${localPart}@${normalizedDomain}` : ""; +} + +export function normalizeWordmarkSplit(args) { + const suggested = suggestWordmarkSplit(args.domain); + return { + black: String(args.wordmarkBlack || suggested.black).trim(), + green: String(args.wordmarkGreen || suggested.green).trim() + }; +} + +export function normalizeOperator(args, options = {}) { + const operatorDomain = normalizeDomain(args.operatorDomain || ""); + const emailDomain = operatorDomain || args.domain; + const contactEmail = String(args.operatorContactEmail || defaultContactEmail("hello", emailDomain)).trim(); + const privacyContact = String(args.operatorPrivacyContact || defaultContactEmail("privacy", emailDomain)).trim(); + const abuseContact = String(args.operatorAbuseContact || defaultContactEmail("abuse", emailDomain)).trim(); + const securityContact = String(args.operatorSecurityContact || defaultContactEmail("security", emailDomain)).trim(); + const fallbackLastUpdated = String(options.fallbackLastUpdated || "").trim(); + + return { + legal_name: String(args.operatorLegalName || "").trim(), + short_domain: normalizeDomain(args.operatorShortDomain || args.domain), + operator_domain: operatorDomain, + jurisdiction: String(args.operatorJurisdiction || "").trim(), + governing_law: String(args.operatorGoverningLaw || args.operatorJurisdiction || "").trim(), + contact_email: contactEmail, + privacy_contact: privacyContact, + abuse_contact: abuseContact, + security_contact: securityContact, + timezone: normalizeTimezone(args.operatorTimezone || DEFAULT_OPERATOR_TIMEZONE), + last_updated: String(args.operatorLastUpdated || fallbackLastUpdated).trim(), + analytics_disclosure: String(args.operatorAnalyticsDisclosure || analyticsDisclosureDefault(args.analytics)).trim(), + analytics_retention: String(args.operatorAnalyticsRetention || analyticsRetentionDefault(args.analytics)).trim(), + abuse_response_window: String(args.operatorAbuseResponseWindow || "5 business days").trim(), + legal_pages_enabled: args.configureLegalPages === true + }; +} + +export function hasConfiguredLegalPages(operator) { + return Boolean( + String(operator?.jurisdiction || "").trim() && + String(operator?.governing_law || "").trim() && + String(operator?.contact_email || "").trim() && + String(operator?.privacy_contact || "").trim() + ); +} + +export function hasConfiguredPublicContactEmails(operator) { + return Boolean( + String(operator?.operator_domain || "").trim() || + String(operator?.contact_email || "").trim() || + String(operator?.privacy_contact || "").trim() || + String(operator?.abuse_contact || "").trim() || + String(operator?.security_contact || "").trim() + ); +} + +export function hasContactArgs(args) { + return Boolean( + String(args.operatorDomain || "").trim() || + String(args.operatorContactEmail || "").trim() || + String(args.operatorPrivacyContact || "").trim() || + String(args.operatorAbuseContact || "").trim() || + String(args.operatorSecurityContact || "").trim() + ); +} + +export function validateOperator(operator) { + const required = + operator.legal_pages_enabled === true + ? [ + "legal_name", + "short_domain", + "jurisdiction", + "governing_law", + "contact_email", + "privacy_contact", + "abuse_contact", + "security_contact", + "last_updated", + "analytics_disclosure", + "abuse_response_window" + ] + : ["short_domain", "abuse_contact", "security_contact", "last_updated", "abuse_response_window"]; + const missing = required.filter((field) => !String(operator[field] || "").trim()); + const emailFields = + operator.legal_pages_enabled === true + ? ["contact_email", "privacy_contact", "abuse_contact", "security_contact"] + : ["abuse_contact", "security_contact"]; + const invalidEmails = emailFields.filter((field) => !isEmail(operator[field])); + const invalidDate = /^\d{4}-\d{2}-\d{2}$/.test(String(operator.last_updated || "")) ? [] : ["last_updated"]; + const invalidTimezone = isValidTimezone(operator.timezone) ? [] : ["timezone"]; + const issues = [...new Set([...missing, ...invalidEmails, ...invalidDate, ...invalidTimezone])]; + + if (issues.length) { + throw new Error(`Operator configuration needs valid values for: ${issues.join(", ")}`); + } +} + +export function isEmail(value) { + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || "")); +} + +export function configuredTimezone(value, isStoredValue = false) { + const timezone = String(value || "").trim(); + if (isStoredValue) return timezone || DEFAULT_OPERATOR_TIMEZONE; + if (!timezone || timezone === DEFAULT_OPERATOR_TIMEZONE) return localTimezone(); + return timezone; +} + +export function normalizeTimezone(value) { + const timezone = String(value || "").trim() || DEFAULT_OPERATOR_TIMEZONE; + if (isValidTimezone(timezone)) return timezone; + throw new Error( + `Operator timezone must be an IANA timezone name such as America/Toronto, not an offset such as ${timezone}. IANA timezones handle daylight saving time automatically.` + ); +} + +export function isValidTimezone(value) { + try { + new Intl.DateTimeFormat("en", { timeZone: value }).format(new Date()); + return true; + } catch { + return false; + } +} + +export function analyticsDisclosureDefault(providers) { + const normalized = String(providers || "disabled").toLowerCase(); + return normalized === "disabled" || normalized.includes("none") || normalized.includes("off") + ? "No analytics enabled." + : "Privacy-respecting analytics are configured for operations, security, and reliability."; +} + +export function analyticsRetentionDefault(providers) { + const normalized = String(providers || "disabled").toLowerCase(); + return normalized === "disabled" || normalized.includes("none") || normalized.includes("off") ? "" : "180 days"; +} + +export function suggestWordmarkSplit(domain) { + const normalized = normalizeDomain(domain); + const parts = normalized.split(".").filter(Boolean); + if (parts.length < 2) return { black: normalized, green: "" }; + + return { + black: `${parts.slice(0, -1).join(".")}.`, + green: parts.at(-1) + }; +} + +export function hasConfiguredBranding(branding) { + return Boolean( + branding?.custom_public === true || + hasConfiguredSlogan(branding?.slogan) || + String(branding?.wordmark?.black || "").trim() || + String(branding?.wordmark?.green || "").trim() + ); +} + +export function hasConfiguredSlogan(slogan) { + if (slogan && typeof slogan === "object" && !Array.isArray(slogan)) { + return Object.values(slogan).some((value) => String(value || "").trim()); + } + return Boolean(String(slogan || "").trim()); +} + +export function normalizeSloganMap(value, languages, args) { + const normalized = {}; + const supported = Array.isArray(languages) && languages.length ? languages : DEFAULT_LANGUAGES; + + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const language of supported) { + const slogan = String(value[language] || value.en || "").trim(); + if (slogan) normalized[language] = slogan; + } + return normalized; + } + + const slogan = String(value || "").trim(); + if (!slogan) return normalized; + for (const language of supported) { + normalized[language] = language === "en" ? slogan : defaultBrandingSlogan(args, language); + } + return normalized; +} + +export function defaultBrandingSlogan(args, language = "en") { + const operatorName = String(args.operatorLegalName || "").trim(); + if (!operatorName) { + return ( + { + en: "A short-link service powered by vanityURLs", + fr: "Un service de liens courts propulsé par vanityURLs", + es: "Un servicio de enlaces cortos impulsado por vanityURLs", + it: "Un servizio di link brevi alimentato da vanityURLs", + de: "Ein Kurzlink-Dienst, betrieben mit vanityURLs" + }[language] || "A short-link service powered by vanityURLs" + ); + } + + return ( + { + en: `A short-link service for ${operatorName}'s projects`, + fr: `Un service de liens courts pour les projets de ${operatorName}`, + es: `Un servicio de enlaces cortos para los proyectos de ${operatorName}`, + it: `Un servizio di link brevi per i progetti di ${operatorName}`, + de: `Ein Kurzlink-Dienst fuer die Projekte von ${operatorName}` + }[language] || `A short-link service for ${operatorName}'s projects` + ); +} + +export function normalizeAccessTeamDomain(value) { + return String(value || "") + .trim() + .replace(/^https?:\/\//i, "") + .replace(/\/+$/g, ""); +} + +export function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function localTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_OPERATOR_TIMEZONE; +} diff --git a/scripts/maintenance.test.mjs b/scripts/maintenance.test.mjs index 19d0199..a1449a8 100644 --- a/scripts/maintenance.test.mjs +++ b/scripts/maintenance.test.mjs @@ -48,7 +48,7 @@ function runCommand(cwd, args) { { const fixture = makeFixture(); run(fixture, [ - "scripts/install.mjs", + "scripts/setup.mjs", "--no-check", "--domain", "go.example", @@ -102,7 +102,7 @@ function runCommand(cwd, args) { { const fixture = makeFixture(); run(fixture, [ - "scripts/install.mjs", + "scripts/setup.mjs", "--no-check", "--domain", "go.example", @@ -149,7 +149,7 @@ function runCommand(cwd, args) { { const fixture = makeFixture(); run(fixture, [ - "scripts/install.mjs", + "scripts/setup.mjs", "--no-check", "--domain", "go.example", @@ -195,7 +195,7 @@ function runCommand(cwd, args) { { const fixture = makeFixture(); run(fixture, [ - "scripts/install.mjs", + "scripts/setup.mjs", "--no-check", "--domain", "go.example", diff --git a/scripts/install.mjs b/scripts/setup.mjs similarity index 67% rename from scripts/install.mjs rename to scripts/setup.mjs index c00fb7b..d09bf05 100644 --- a/scripts/install.mjs +++ b/scripts/setup.mjs @@ -6,6 +6,31 @@ import { execFileSync } from "node:child_process"; import readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { copyDirectory, hasCopyableFiles, mergeSiteConfig, supportedLanguages } from "./lib/build-assets.mjs"; +import { + analyticsDisclosureDefault, + analyticsRetentionDefault, + configuredTimezone, + DEFAULT_DOMAIN, + DEFAULT_LANGUAGE, + DEFAULT_LANGUAGES, + defaultBrandingSlogan, + defaultContactEmail, + escapeRegExp, + hasConfiguredBranding, + hasConfiguredLegalPages, + hasConfiguredPublicContactEmails, + hasConfiguredSlogan, + hasContactArgs, + isAnalyticsDisabled, + normalizeAccessTeamDomain, + normalizeArgs, + normalizeDomain, + normalizeLanguages, + normalizeSloganMap, + parseArgs, + slugifyWorker, + suggestWordmarkSplit +} from "./lib/install/core.mjs"; const ROOT = process.cwd(); const WRANGLER_PATH = path.join(ROOT, "wrangler.toml"); @@ -16,54 +41,9 @@ const CUSTOM_SITE_CONFIG_PATH = path.join(CUSTOM_DIR, "v8s-site-config.json"); const DEFAULT_SITE_CONFIG_PATH = path.join(ROOT, "defaults", "v8s-site-config.json"); const DEFAULT_LINKS_PATH = path.join(ROOT, "defaults", "v8s-links.txt"); const DEFAULT_PUBLIC_DIR = path.join(ROOT, "defaults", "public"); -const DEFAULT_DOMAIN = "v8s.link"; -const DEFAULT_LANGUAGE = "en"; -const DEFAULT_LANGUAGES = ["en", "de", "es", "fr", "it"]; -const DEFAULT_RANDOM_SLUG_LENGTH = 3; -const DEFAULT_OPERATOR_TIMEZONE = "UTC"; -const MAX_RANDOM_SLUG_LENGTH = 64; -const MAX_WORKER_NAME_LENGTH = 63; const PROJECT_SITE_URL = "https://www.vanityURLs.link"; const PUBLIC_ASSET_VERSION = "20260601"; -function parseArgs(argv) { - const args = { - analytics: "disabled", - check: true, - dryRun: false, - force: false, - owner: "owner" - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - - if (arg === "--no-check") { - args.check = false; - } else if (arg === "--dry-run") { - args.dryRun = true; - } else if (arg === "--force") { - args.force = true; - } else if (arg === "--customize-public") { - args.customizePublic = true; - } else if (arg === "--no-customize-public") { - args.customizePublic = false; - } else if (arg.startsWith("--")) { - const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); - const next = argv[index + 1]; - if (!next || next.startsWith("--")) { - throw new Error(`Missing value for ${arg}`); - } - args[key] = next; - index += 1; - } else { - throw new Error(`Unknown argument: ${arg}`); - } - } - - return args; -} - async function promptForMissing(args) { if (!process.stdin.isTTY && process.env.V8S_INTERNAL_SETUP !== "1") { throw new Error("Run npm run setup in an interactive terminal."); @@ -284,101 +264,6 @@ async function promptForMissing(args) { return args; } -function normalizeArgs(args) { - args.domain = normalizeDomain(args.domain); - if (!args.operatorShortDomain) args.operatorShortDomain = args.domain; - args.workerName = args.workerName ? slugifyWorker(args.workerName) : slugifyWorker(args.domain); - args.analytics = normalizeAnalyticsProviders(args.analytics); - args.owner = slugifyOwner(args.owner); - args.randomSlugLength = normalizeRandomSlugLength( - args.randomSlugLength || loadSiteConfig().links?.random_slug_length || DEFAULT_RANDOM_SLUG_LENGTH - ); - args.languages = normalizeLanguages(args.languages); - args.configureBranding = - args.configureBranding ?? - (args.customizePublic != null || - args.brandingSlogan != null || - args.brandingSlogans != null || - args.wordmarkBlack != null || - args.wordmarkGreen != null); - args.configureBranding = normalizeBoolean(args.configureBranding); - args.customizePublic = normalizeBoolean(args.customizePublic); - args.brandingSlogans = normalizeSloganMap(args.brandingSlogans ?? args.brandingSlogan, args.languages, args); - args.operator = normalizeOperator(args); - - if (!args.domain) throw new Error("Domain cannot be empty."); - if (!args.workerName) throw new Error("Worker name cannot be empty."); - validateWorkerName(args.workerName); - validateOperator(args.operator); - if (args.configureBranding) { - const split = normalizeWordmarkSplit(args); - args.wordmarkBlack = split.black; - args.wordmarkGreen = split.green; - } - - return args; -} - -function normalizeDomain(value) { - return String(value || "") - .trim() - .replace(/^https?:\/\//i, "") - .replace(/\/.*$/g, "") - .toLowerCase(); -} - -function slugifyWorker(value) { - return String(value || "") - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, MAX_WORKER_NAME_LENGTH) - .replace(/-+$/g, ""); -} - -function validateWorkerName(value) { - if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value)) { - throw new Error( - "Worker name must use lowercase letters, numbers, and hyphens; it must start and end with a letter or number." - ); - } -} - -function slugifyOwner(value) { - return ( - String(value || "owner") - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, "") || "owner" - ); -} - -function normalizeAnalyticsProviders(value) { - const providers = String(value || "disabled") - .split(",") - .map((provider) => provider.trim().toLowerCase()) - .filter(Boolean); - - if (!providers.length) return "disabled"; - - const allowed = new Set(["disabled", "none", "off", "umami", "fathom"]); - for (const provider of providers) { - if (!allowed.has(provider)) throw new Error(`Unsupported analytics provider: ${provider}`); - } - - return providers.join(","); -} - -function normalizeRandomSlugLength(value) { - const number = Number.parseInt(String(value || ""), 10); - if (!Number.isInteger(number) || number < 1 || number > MAX_RANDOM_SLUG_LENGTH) { - throw new Error(`Random slug length must be an integer from 1 to ${MAX_RANDOM_SLUG_LENGTH}.`); - } - return number; -} - function loadWranglerConfig() { if (!fs.existsSync(WRANGLER_PATH)) return {}; @@ -424,163 +309,6 @@ function inferOwnerFromLinks() { return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] || ""; } -function normalizeLanguages(value) { - const languages = String(value || DEFAULT_LANGUAGES.join(",")) - .split(",") - .map((language) => language.trim().toLowerCase().split("-")[0]) - .filter(Boolean); - const unique = [...new Set(languages)]; - const ordered = unique.includes(DEFAULT_LANGUAGE) ? unique : [DEFAULT_LANGUAGE, ...unique]; - return [DEFAULT_LANGUAGE, ...ordered.filter((language) => language !== DEFAULT_LANGUAGE)]; -} - -function normalizeBoolean(value) { - if (typeof value === "boolean") return value; - if (value == null || value === "") return false; - return ["1", "true", "yes", "y", "on"].includes(String(value).trim().toLowerCase()); -} - -function isAnalyticsDisabled(value) { - const providers = String(value || "disabled") - .split(",") - .map((provider) => provider.trim().toLowerCase()) - .filter(Boolean); - return !providers.length || providers.some((provider) => ["disabled", "none", "off"].includes(provider)); -} - -function defaultContactEmail(localPart, domain) { - const normalizedDomain = normalizeDomain(domain); - return normalizedDomain ? `${localPart}@${normalizedDomain}` : ""; -} - -function normalizeWordmarkSplit(args) { - const suggested = suggestWordmarkSplit(args.domain); - return { - black: String(args.wordmarkBlack || suggested.black).trim(), - green: String(args.wordmarkGreen || suggested.green).trim() - }; -} - -function normalizeOperator(args) { - const operatorDomain = normalizeDomain(args.operatorDomain || ""); - const emailDomain = operatorDomain || args.domain; - const contactEmail = String(args.operatorContactEmail || defaultContactEmail("hello", emailDomain)).trim(); - const privacyContact = String(args.operatorPrivacyContact || defaultContactEmail("privacy", emailDomain)).trim(); - const abuseContact = String(args.operatorAbuseContact || defaultContactEmail("abuse", emailDomain)).trim(); - const securityContact = String(args.operatorSecurityContact || defaultContactEmail("security", emailDomain)).trim(); - - return { - legal_name: String(args.operatorLegalName || "").trim(), - short_domain: normalizeDomain(args.operatorShortDomain || args.domain), - operator_domain: operatorDomain, - jurisdiction: String(args.operatorJurisdiction || "").trim(), - governing_law: String(args.operatorGoverningLaw || args.operatorJurisdiction || "").trim(), - contact_email: contactEmail, - privacy_contact: privacyContact, - abuse_contact: abuseContact, - security_contact: securityContact, - timezone: normalizeTimezone(args.operatorTimezone || DEFAULT_OPERATOR_TIMEZONE), - last_updated: String(args.operatorLastUpdated || gitLastUpdatedDate() || todayIsoDate()).trim(), - analytics_disclosure: String(args.operatorAnalyticsDisclosure || analyticsDisclosureDefault(args.analytics)).trim(), - analytics_retention: String(args.operatorAnalyticsRetention || analyticsRetentionDefault(args.analytics)).trim(), - abuse_response_window: String(args.operatorAbuseResponseWindow || "5 business days").trim(), - legal_pages_enabled: args.configureLegalPages === true - }; -} - -function hasConfiguredLegalPages(operator) { - return Boolean( - String(operator?.jurisdiction || "").trim() && - String(operator?.governing_law || "").trim() && - String(operator?.contact_email || "").trim() && - String(operator?.privacy_contact || "").trim() - ); -} - -function hasConfiguredPublicContactEmails(operator) { - return Boolean( - String(operator?.operator_domain || "").trim() || - String(operator?.contact_email || "").trim() || - String(operator?.privacy_contact || "").trim() || - String(operator?.abuse_contact || "").trim() || - String(operator?.security_contact || "").trim() - ); -} - -function hasContactArgs(args) { - return Boolean( - String(args.operatorDomain || "").trim() || - String(args.operatorContactEmail || "").trim() || - String(args.operatorPrivacyContact || "").trim() || - String(args.operatorAbuseContact || "").trim() || - String(args.operatorSecurityContact || "").trim() - ); -} - -function validateOperator(operator) { - const required = - operator.legal_pages_enabled === true - ? [ - "legal_name", - "short_domain", - "jurisdiction", - "governing_law", - "contact_email", - "privacy_contact", - "abuse_contact", - "security_contact", - "last_updated", - "analytics_disclosure", - "abuse_response_window" - ] - : ["short_domain", "abuse_contact", "security_contact", "last_updated", "abuse_response_window"]; - const missing = required.filter((field) => !String(operator[field] || "").trim()); - const emailFields = - operator.legal_pages_enabled === true - ? ["contact_email", "privacy_contact", "abuse_contact", "security_contact"] - : ["abuse_contact", "security_contact"]; - const invalidEmails = emailFields.filter((field) => !isEmail(operator[field])); - const invalidDate = /^\d{4}-\d{2}-\d{2}$/.test(String(operator.last_updated || "")) ? [] : ["last_updated"]; - const invalidTimezone = isValidTimezone(operator.timezone) ? [] : ["timezone"]; - const issues = [...new Set([...missing, ...invalidEmails, ...invalidDate, ...invalidTimezone])]; - - if (issues.length) { - throw new Error(`Operator configuration needs valid values for: ${issues.join(", ")}`); - } -} - -function isEmail(value) { - return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || "")); -} - -function localTimezone() { - return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_OPERATOR_TIMEZONE; -} - -function configuredTimezone(value, isStoredValue = false) { - const timezone = String(value || "").trim(); - if (isStoredValue) return timezone || DEFAULT_OPERATOR_TIMEZONE; - if (!timezone || timezone === DEFAULT_OPERATOR_TIMEZONE) return localTimezone(); - return timezone; -} - -function normalizeTimezone(value) { - const timezone = String(value || "").trim() || DEFAULT_OPERATOR_TIMEZONE; - if (isValidTimezone(timezone)) return timezone; - throw new Error( - `Operator timezone must be an IANA timezone name such as America/Toronto, not an offset such as ${timezone}. IANA timezones handle daylight saving time automatically.` - ); -} - -function isValidTimezone(value) { - try { - new Intl.DateTimeFormat("en", { timeZone: value }).format(new Date()); - return true; - } catch { - return false; - } -} - function todayIsoDate() { return new Date().toISOString().slice(0, 10); } @@ -597,45 +325,6 @@ function gitLastUpdatedDate() { } } -function analyticsDisclosureDefault(providers) { - const normalized = String(providers || "disabled").toLowerCase(); - return normalized === "disabled" || normalized.includes("none") || normalized.includes("off") - ? "No analytics enabled." - : "Privacy-respecting analytics are configured for operations, security, and reliability."; -} - -function analyticsRetentionDefault(providers) { - const normalized = String(providers || "disabled").toLowerCase(); - return normalized === "disabled" || normalized.includes("none") || normalized.includes("off") ? "" : "180 days"; -} - -function suggestWordmarkSplit(domain) { - const normalized = normalizeDomain(domain); - const parts = normalized.split(".").filter(Boolean); - if (parts.length < 2) return { black: normalized, green: "" }; - - return { - black: `${parts.slice(0, -1).join(".")}.`, - green: parts.at(-1) - }; -} - -function hasConfiguredBranding(branding) { - return Boolean( - branding?.custom_public === true || - hasConfiguredSlogan(branding?.slogan) || - String(branding?.wordmark?.black || "").trim() || - String(branding?.wordmark?.green || "").trim() - ); -} - -function hasConfiguredSlogan(slogan) { - if (slogan && typeof slogan === "object" && !Array.isArray(slogan)) { - return Object.values(slogan).some((value) => String(value || "").trim()); - } - return Boolean(String(slogan || "").trim()); -} - async function promptForBrandingSlogans(rl, args, configuredSlogan) { const slogans = {}; const configured = normalizeSloganMap(configuredSlogan, args.languages, args); @@ -649,51 +338,6 @@ async function promptForBrandingSlogans(rl, args, configuredSlogan) { return slogans; } -function normalizeSloganMap(value, languages, args) { - const normalized = {}; - const supported = Array.isArray(languages) && languages.length ? languages : DEFAULT_LANGUAGES; - - if (value && typeof value === "object" && !Array.isArray(value)) { - for (const language of supported) { - const slogan = String(value[language] || value.en || "").trim(); - if (slogan) normalized[language] = slogan; - } - return normalized; - } - - const slogan = String(value || "").trim(); - if (!slogan) return normalized; - for (const language of supported) { - normalized[language] = language === "en" ? slogan : defaultBrandingSlogan(args, language); - } - return normalized; -} - -function defaultBrandingSlogan(args, language = "en") { - const operatorName = String(args.operatorLegalName || "").trim(); - if (!operatorName) { - return ( - { - en: "A short-link service powered by vanityURLs", - fr: "Un service de liens courts propulsé par vanityURLs", - es: "Un servicio de enlaces cortos impulsado por vanityURLs", - it: "Un servizio di link brevi alimentato da vanityURLs", - de: "Ein Kurzlink-Dienst, betrieben mit vanityURLs" - }[language] || "A short-link service powered by vanityURLs" - ); - } - - return ( - { - en: `A short-link service for ${operatorName}'s projects`, - fr: `Un service de liens courts pour les projets de ${operatorName}`, - es: `Un servicio de enlaces cortos para los proyectos de ${operatorName}`, - it: `Un servizio di link brevi per i progetti di ${operatorName}`, - de: `Ein Kurzlink-Dienst fuer die Projekte von ${operatorName}` - }[language] || `A short-link service for ${operatorName}'s projects` - ); -} - async function confirm(rl, label, defaultValue) { const suffix = defaultValue ? "Y/n" : "y/N"; const answer = (await rl.question(`${label} (${suffix}): `)).trim().toLowerCase(); @@ -1123,17 +767,6 @@ function setSectionString(toml, section, key, value) { return `${before}${nextBody}${after}`; } -function normalizeAccessTeamDomain(value) { - return String(value || "") - .trim() - .replace(/^https?:\/\//i, "") - .replace(/\/+$/g, ""); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - function writeFile(filePath, content, args) { if (args.dryRun) { console.log(`[dry-run] would write ${path.relative(ROOT, filePath)}`); @@ -1180,7 +813,10 @@ function printNextSteps(args) { } async function main() { - const args = normalizeArgs(await promptForMissing(parseArgs(process.argv.slice(2)))); + const args = normalizeArgs(await promptForMissing(parseArgs(process.argv.slice(2))), { + defaultRandomSlugLength: loadSiteConfig().links?.random_slug_length, + fallbackLastUpdated: gitLastUpdatedDate() || todayIsoDate() + }); args.previousSiteConfig = loadSiteConfig(); createCustomFiles(args); diff --git a/scripts/upgrade.mjs b/scripts/upgrade.mjs index aa32a58..15a1066 100644 --- a/scripts/upgrade.mjs +++ b/scripts/upgrade.mjs @@ -395,7 +395,14 @@ function runDoctor(args) { if (output) console.log(output); } +function packageVersion() { + return String(readJson("package.json").version || "").trim(); +} + function printPostRunNote(args) { + const version = packageVersion(); + if (version) console.log(`[version] vanityURLs ${version}`); + if (!args.dryRun) { const status = worktreeStatus(); if (status) console.log("Review with git status --short and git diff, then commit and push.");