Files
postplan/bin/postplan.js
T
2026-08-21 20:44:06 -04:00

409 lines
12 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { validateHtml } from "../src/html-policy.js";
// Single source of truth for the version: package.json. CI bumps it on every
// merge to main, so a hardcoded copy here would immediately drift.
const { version: VERSION } = createRequire(import.meta.url)("../package.json");
const DEFAULT_API_URL = "https://postplan.dev";
const POSTPLAN_DIR = path.join(os.homedir(), ".postplan");
const CONFIG_PATH = path.join(POSTPLAN_DIR, "config.json");
const CREDENTIALS_PATH = path.join(POSTPLAN_DIR, "credentials.json");
const DRAFTS_PATH = path.join(POSTPLAN_DIR, "drafts.json");
class CliError extends Error {}
const program = new Command();
program
.name("postplan")
.description("Upload static HTML drafts to Postplan.")
.version(VERSION);
const authCommand = program.command("auth").description("Manage CLI authentication.");
authCommand
.command("set")
.argument("<api-key>", "Postplan API key")
.option("--api-url <url>", "Override the default Postplan API base URL")
.action((apiKey, options) => {
saveCredentials(apiKey, options.apiUrl);
console.log("Postplan credentials saved.");
});
authCommand
.command("login")
.description("Log in by pasting an API key from the browser. Works over SSH.")
.option("--api-url <url>", "Override the default Postplan API base URL")
.action(async (options) => {
const { apiUrl } = readAuth(options.apiUrl, { requireApiKey: false });
console.log("Open this in your browser (any device):\n");
console.log(` ${apiUrl}/cli/auth\n`);
console.log("Sign in, generate a key, then paste it below.\n");
const readline = await import("node:readline/promises");
const { once } = await import("node:events");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let apiKey;
try {
// rl.question never resolves if stdin closes (EOF/ctrl-d) — race the
// close event so that path hits the "No key entered" error below
// instead of exiting 0 silently.
apiKey = (
await Promise.race([
rl.question("Paste your API key: "),
once(rl, "close").then(() => "")
])
).trim();
} finally {
rl.close();
}
if (!apiKey) {
throw new CliError("No key entered. Nothing saved.");
}
const response = await fetch(`${apiUrl}/api/me`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const body = await response.json();
if (!response.ok) {
throw new CliError(body.error || "That key was rejected. Nothing saved.");
}
saveCredentials(apiKey, options.apiUrl);
console.log(`\nLogged in as ${body.accountName} (key: ${body.apiKeyName}).`);
});
program
.command("whoami")
.description("Check the configured Postplan credentials.")
.action(async () => {
const { apiUrl, apiKey } = readAuth();
const response = await fetch(`${apiUrl}/api/me`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const body = await response.json();
if (!response.ok) {
throw new CliError(body.error || "Authentication failed.");
}
console.log(`Account: ${body.accountName} (${body.accountId})`);
console.log(`API key: ${body.apiKeyName} (${body.apiKeyId})`);
});
program
.command("upload")
.argument("<file>", "HTML file path")
.option("--draft <draft-id>", "Update a specific draft")
.option("--new", "Always create a new draft")
.option("--description <text>", "Set a short description for the draft")
.option("--api-url <url>", "Override the default Postplan API base URL")
.description("Upload or update an HTML draft.")
.action(async (file, options) => {
const resolvedFile = path.resolve(file);
const { apiUrl, apiKey } = readAuth(options.apiUrl, { requireApiKey: false });
if (!fs.existsSync(resolvedFile)) {
throw new CliError(`File does not exist: ${resolvedFile}`);
}
const html = fs.readFileSync(resolvedFile, "utf8");
const validation = validateHtml(html);
if (!validation.ok) {
throw new CliError(`HTML failed Postplan validation:\n- ${validation.errors.join("\n- ")}`);
}
const drafts = readDrafts();
const knownDraft = drafts.files?.[resolvedFile];
const draftId = options.new ? null : options.draft || knownDraft?.draftId || null;
const payload = {
html,
filename: path.basename(resolvedFile),
draftId,
description: options.description,
metadata: {
...collectGitMetadata(path.dirname(resolvedFile)),
...collectCiMetadata(),
cliVersion: VERSION,
fileSha256: sha256(html)
}
};
const headers = {
"Content-Type": "application/json",
"User-Agent": `postplan/${VERSION}`
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${apiUrl}/api/uploads`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const body = await response.json();
if (!response.ok) {
const details = body.errors?.length ? `\n- ${body.errors.join("\n- ")}` : "";
throw new CliError(`${body.error || "Upload failed."}${details}`);
}
drafts.files ||= {};
drafts.files[resolvedFile] = {
draftId: body.draftId,
publicUrl: body.publicUrl,
rawUrl: body.rawUrl || `${body.publicUrl.replace(/\/+$/, "")}/raw`,
latestVersionNumber: body.versionNumber,
updatedAt: new Date().toISOString()
};
writeJson(DRAFTS_PATH, drafts, 0o600);
console.log(draftId ? "Updated draft" : "Uploaded draft");
console.log(`URL: ${body.publicUrl}`);
console.log(`Raw HTML: ${body.rawUrl || `${body.publicUrl.replace(/\/+$/, "")}/raw`}`);
console.log(`Draft ID: ${body.draftId}`);
console.log(`Version: ${body.versionNumber}`);
for (const warning of body.warnings || []) {
console.warn(`Warning: ${warning}`);
}
});
program
.command("list")
.description("List the drafts published to your account.")
.option("--api-url <url>", "Override the default Postplan API base URL")
.option("--json", "Print the raw JSON response")
.action(async (options) => {
const { apiUrl, apiKey } = readAuth(options.apiUrl);
const response = await fetch(`${apiUrl}/api/drafts`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const body = await response.json();
if (!response.ok) {
throw new CliError(body.error || "Failed to list drafts.");
}
const drafts = body.drafts || [];
if (options.json) {
console.log(JSON.stringify(drafts, null, 2));
return;
}
if (!drafts.length) {
console.log("No drafts yet. Publish one with: postplan upload <file>");
return;
}
console.log(`Drafts (${drafts.length})\n`);
for (const draft of drafts) {
const repo = draft.repoOrg && draft.repoName ? `${draft.repoOrg}/${draft.repoName}` : "no repo";
const version = draft.latestVersionNumber ? `v${draft.latestVersionNumber}` : "no versions";
const count = `${draft.versionCount} version${draft.versionCount === 1 ? "" : "s"}`;
const disabled = draft.disabled ? " · disabled" : "";
console.log(draft.title || "Untitled Draft");
console.log(` ${repo} · ${version} · ${count} · updated ${timeAgo(draft.updatedAt)}${disabled}`);
console.log(` ${draft.publicUrl}`);
if (draft.description) {
console.log(` ${draft.description}`);
}
console.log("");
}
});
program.exitOverride();
program.parseAsync(process.argv).catch((error) => {
if (error instanceof CliError) {
console.error(error.message);
process.exit(1);
}
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
process.exit(0);
}
console.error(error.message || error);
process.exit(1);
});
function readAuth(apiUrlOverride, { requireApiKey = true } = {}) {
const config = readJson(CONFIG_PATH, {});
const credentials = readJson(CREDENTIALS_PATH, {});
const apiUrl = (
apiUrlOverride ||
process.env.POSTPLAN_API_URL ||
config.apiUrl ||
DEFAULT_API_URL
).replace(/\/+$/, "");
const apiKey = process.env.POSTPLAN_API_KEY || credentials.apiKey;
if (requireApiKey && !apiKey) {
throw new CliError("Missing API key. Run: postplan auth set <api-key>");
}
return { apiUrl, apiKey };
}
function ensureStateDir() {
fs.mkdirSync(POSTPLAN_DIR, { recursive: true, mode: 0o700 });
}
function saveCredentials(apiKey, apiUrlOverride) {
ensureStateDir();
if (apiUrlOverride) {
writeJson(CONFIG_PATH, {
...readJson(CONFIG_PATH, {}),
apiUrl: apiUrlOverride.replace(/\/+$/, "")
});
}
writeJson(
CREDENTIALS_PATH,
{
apiKey,
updatedAt: new Date().toISOString()
},
0o600
);
}
function readDrafts() {
return readJson(DRAFTS_PATH, { files: {} });
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return fallback;
}
}
function writeJson(file, value, mode = 0o600) {
ensureStateDir();
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode });
fs.chmodSync(file, mode);
}
function collectGitMetadata(cwd) {
const repoRoot = git(["rev-parse", "--show-toplevel"], cwd);
const remote = git(["config", "--get", "remote.origin.url"], cwd);
const parsedRemote = parseRemote(remote);
const status = git(["status", "--porcelain"], cwd);
return {
repoOrg: parsedRemote.org || inferOrgFromRoot(repoRoot),
repoName: parsedRemote.name || (repoRoot ? path.basename(repoRoot) : null),
repoHost: parsedRemote.host || null,
gitBranch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd),
gitCommitSha: git(["rev-parse", "HEAD"], cwd),
gitCommitSubject: git(["log", "-1", "--format=%s"], cwd),
// null when not a git repo; true/false when a working tree is present.
gitDirty: status === null ? null : status.length > 0
};
}
// Best-effort CI provenance. GitHub Actions is detected precisely (with a run
// URL); other CI systems are flagged generically. Nothing here is trusted for
// authorization — it is metadata for the dashboard and audit trail only.
function collectCiMetadata() {
const env = process.env;
if (env.GITHUB_ACTIONS === "true") {
const server = env.GITHUB_SERVER_URL || "https://github.com";
const repo = env.GITHUB_REPOSITORY;
const runId = env.GITHUB_RUN_ID;
return {
ciProvider: "github_actions",
ciRunUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : null,
ciActor: env.GITHUB_ACTOR || null
};
}
if (env.CI) {
return { ciProvider: "unknown" };
}
return {};
}
function git(args, cwd) {
try {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
} catch {
return null;
}
}
function parseRemote(remote) {
if (!remote) return {};
const cleaned = remote.replace(/\.git$/, "");
const sshMatch = cleaned.match(/^[^@]+@([^:]+):([^/]+)\/(.+)$/);
if (sshMatch) {
return { host: sshMatch[1], org: sshMatch[2], name: path.basename(sshMatch[3]) };
}
try {
const url = new URL(cleaned);
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length >= 2) {
return { host: url.hostname, org: parts[0], name: parts.at(-1) };
}
} catch {
// Fall through to path parsing.
}
const parts = cleaned.split("/").filter(Boolean);
if (parts.length >= 2) {
return { org: parts.at(-2), name: parts.at(-1) };
}
return {};
}
function inferOrgFromRoot(repoRoot) {
if (!repoRoot) return null;
return path.basename(path.dirname(repoRoot));
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function timeAgo(value) {
if (!value) return "unknown";
const then = new Date(value).getTime();
if (Number.isNaN(then)) return "unknown";
const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
const units = [
["year", 31_536_000],
["month", 2_592_000],
["week", 604_800],
["day", 86_400],
["hour", 3_600],
["minute", 60]
];
for (const [name, secs] of units) {
const amount = Math.floor(seconds / secs);
if (amount >= 1) return `${amount} ${name}${amount === 1 ? "" : "s"} ago`;
}
return "just now";
}