gh-postplan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # gh-postplan
2
+
3
+ Publish one HTML draft to GitHub Pages and get a stable, shareable URL. Every changed publish also keeps a permanent version URL.
4
+
5
+ ## Requirements
6
+
7
+ - Authenticated [GitHub CLI](https://cli.github.com/)
8
+ - Git
9
+ - Bun, or Node.js 22.18+
10
+
11
+ ## Setup
12
+
13
+ Configure an existing repository:
14
+
15
+ ```sh
16
+ npx gh-postplan setup owner/pages-repo
17
+ ```
18
+
19
+ Or create a new public repository:
20
+
21
+ ```sh
22
+ npx gh-postplan setup owner/pages-repo --create
23
+ ```
24
+
25
+ Setup creates or reuses the `gh-pages` branch, adds `.nojekyll`, and configures branch-based GitHub Pages. GitHub Pages is public even when an existing source repository is private.
26
+
27
+ ## Publish
28
+
29
+ ```sh
30
+ npx gh-postplan publish ./draft.html
31
+ ```
32
+
33
+ The command prints progress to stderr and only the stable URL to stdout. Publishing the same file again updates that URL and creates a permanent version such as `/drafts/a1b2c3/v2/`. Unchanged HTML creates no version.
34
+
35
+ ```sh
36
+ # Make a separate draft from the same file
37
+ npx gh-postplan publish ./draft.html --new
38
+
39
+ # Reconnect a file after losing local state
40
+ npx gh-postplan publish ./draft.html --draft a1b2c3
41
+
42
+ # Return immediately after pushing
43
+ npx gh-postplan publish ./draft.html --no-wait
44
+ ```
45
+
46
+ Only the HTML file is uploaded. Use self-contained HTML or absolute URLs for assets.
47
+
48
+ Configuration lives in `~/.config/gh-postplan`. Set `GH_POSTPLAN_REPO=owner/repo` to override the configured repository.
49
+
50
+ The npm package also includes an agent skill at `skills/gh-postplan/SKILL.md`.
package/dist/cli.js ADDED
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { randomBytes } from "node:crypto";
5
+ import { spawn } from "node:child_process";
6
+ import { constants } from "node:fs";
7
+ import {
8
+ access,
9
+ mkdir,
10
+ readFile,
11
+ readdir,
12
+ realpath,
13
+ rm,
14
+ stat,
15
+ writeFile
16
+ } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { dirname, extname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ var packageVersion = "0.1.0";
21
+ var configDirectory = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "gh-postplan");
22
+ var cacheDirectory = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "gh-postplan");
23
+ var configPath = join(configDirectory, "config.json");
24
+ var draftsPath = join(configDirectory, "drafts.json");
25
+
26
+ class PushError extends Error {
27
+ }
28
+ var help = `Usage:
29
+ gh-postplan setup OWNER/REPO [--create]
30
+ gh-postplan publish FILE [--new | --draft ID] [--no-wait]
31
+
32
+ Commands:
33
+ setup Configure an existing repo, or create a public one with --create
34
+ publish Publish one HTML file and keep its older versions available`;
35
+ function parseCommand(args) {
36
+ if (args.length === 0 || args.includes("--help") || args.includes("-h"))
37
+ return { kind: "help" };
38
+ if (args.length === 1 && (args[0] === "--version" || args[0] === "-v"))
39
+ return { kind: "version" };
40
+ const [name, subject, ...flags] = args;
41
+ if (name === "setup") {
42
+ if (!subject || flags.some((flag) => flag !== "--create"))
43
+ throw new Error(help);
44
+ return { kind: "setup", repo: subject, create: flags.includes("--create") };
45
+ }
46
+ if (name === "publish") {
47
+ if (!subject)
48
+ throw new Error(help);
49
+ let draft;
50
+ let fresh = false;
51
+ let wait = true;
52
+ for (let index = 0;index < flags.length; index++) {
53
+ const flag = flags[index];
54
+ if (flag === "--new")
55
+ fresh = true;
56
+ else if (flag === "--no-wait")
57
+ wait = false;
58
+ else if (flag === "--draft")
59
+ draft = flags[++index];
60
+ else
61
+ throw new Error(`Unknown option: ${flag ?? ""}
62
+
63
+ ${help}`);
64
+ }
65
+ if (fresh && draft)
66
+ throw new Error("--new and --draft cannot be used together");
67
+ if (flags.includes("--draft") && !draft)
68
+ throw new Error("--draft needs an ID");
69
+ if (draft && !/^[A-Za-z0-9_-]{4,64}$/.test(draft))
70
+ throw new Error("Invalid draft ID");
71
+ return { kind: "publish", file: subject, draft, fresh, wait };
72
+ }
73
+ throw new Error(`Unknown command: ${name ?? ""}
74
+
75
+ ${help}`);
76
+ }
77
+ function nextVersion(entries) {
78
+ return Math.max(0, ...entries.map((entry) => /^v(\d+)$/.exec(entry)?.[1]).filter(Boolean).map(Number)) + 1;
79
+ }
80
+ function pageUrls(base, id, version) {
81
+ const root = `${base.replace(/\/+$/, "")}/drafts/${id}/`;
82
+ return { current: root, version: `${root}v${version}/` };
83
+ }
84
+ function validateRepo(repo) {
85
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
86
+ throw new Error("Repository must look like OWNER/REPO");
87
+ }
88
+ }
89
+ async function run(command, args, cwd) {
90
+ return new Promise((resolveRun, reject) => {
91
+ const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
92
+ const stdout = [];
93
+ const stderr = [];
94
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
95
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
96
+ child.on("error", reject);
97
+ child.on("close", (code) => {
98
+ const output = Buffer.concat(stdout).toString().trim();
99
+ const error = Buffer.concat(stderr).toString().trim();
100
+ if (code === 0)
101
+ resolveRun(output);
102
+ else
103
+ reject(new Error(error || `${command} exited with status ${code ?? "unknown"}`));
104
+ });
105
+ });
106
+ }
107
+ async function succeeds(command, args, cwd) {
108
+ try {
109
+ await run(command, args, cwd);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ async function exists(path) {
116
+ try {
117
+ await access(path, constants.F_OK);
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+ async function readJson(path, fallback) {
124
+ try {
125
+ return JSON.parse(await readFile(path, "utf8"));
126
+ } catch (error) {
127
+ if (fallback !== undefined && error.code === "ENOENT")
128
+ return fallback;
129
+ throw new Error(`Cannot read ${path}: ${error.message}`);
130
+ }
131
+ }
132
+ async function writeJson(path, value) {
133
+ await mkdir(dirname(path), { recursive: true });
134
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
135
+ `);
136
+ }
137
+ async function checkTools() {
138
+ await run("git", ["--version"]);
139
+ await run("gh", ["--version"]);
140
+ await run("gh", ["auth", "status"]);
141
+ }
142
+ function clonePath(repo) {
143
+ return join(cacheDirectory, ...repo.split("/"));
144
+ }
145
+ async function cloneRepo(repo) {
146
+ const path = clonePath(repo);
147
+ if (await exists(join(path, ".git")))
148
+ return path;
149
+ if (await exists(path))
150
+ throw new Error(`Cache path exists but is not a Git clone: ${path}`);
151
+ await mkdir(dirname(path), { recursive: true });
152
+ await run("gh", ["repo", "clone", repo, path]);
153
+ return path;
154
+ }
155
+ async function setGitIdentity(path) {
156
+ const user = JSON.parse(await run("gh", ["api", "user"]));
157
+ if (!user.login || !user.id)
158
+ throw new Error("GitHub did not return an account login and ID");
159
+ await run("git", ["config", "user.name", user.login], path);
160
+ await run("git", ["config", "user.email", `${user.id}+${user.login}@users.noreply.github.com`], path);
161
+ }
162
+ async function switchToPagesBranch(path) {
163
+ await run("git", ["fetch", "origin"], path);
164
+ const remoteExists = await succeeds("git", ["ls-remote", "--exit-code", "--heads", "origin", "gh-pages"], path);
165
+ if (!remoteExists) {
166
+ await run("git", ["switch", "--orphan", "gh-pages"], path);
167
+ return false;
168
+ }
169
+ const localExists = await succeeds("git", ["show-ref", "--verify", "--quiet", "refs/heads/gh-pages"], path);
170
+ await run("git", localExists ? ["switch", "gh-pages"] : ["switch", "--track", "origin/gh-pages"], path);
171
+ await run("git", ["pull", "--ff-only", "origin", "gh-pages"], path);
172
+ return true;
173
+ }
174
+ async function configurePages(repo) {
175
+ const endpoint = `repos/${repo}/pages`;
176
+ const existsAlready = await succeeds("gh", ["api", endpoint]);
177
+ const method = existsAlready ? "PUT" : "POST";
178
+ await run("gh", [
179
+ "api",
180
+ "--method",
181
+ method,
182
+ endpoint,
183
+ "-f",
184
+ "build_type=legacy",
185
+ "-f",
186
+ "source[branch]=gh-pages",
187
+ "-f",
188
+ "source[path]=/"
189
+ ]);
190
+ return readPages(repo);
191
+ }
192
+ async function readPages(repo) {
193
+ const pages = JSON.parse(await run("gh", ["api", `repos/${repo}/pages`]));
194
+ if (!pages.html_url)
195
+ throw new Error("GitHub did not return a Pages URL");
196
+ return pages;
197
+ }
198
+ async function setup({ repo, create }) {
199
+ validateRepo(repo);
200
+ await checkTools();
201
+ let created = false;
202
+ try {
203
+ if (create) {
204
+ process.stderr.write(`Creating public repository ${repo}...
205
+ `);
206
+ await run("gh", ["repo", "create", repo, "--public"]);
207
+ created = true;
208
+ } else {
209
+ await run("gh", ["repo", "view", repo, "--json", "nameWithOwner"]);
210
+ }
211
+ const path = await cloneRepo(repo);
212
+ await setGitIdentity(path);
213
+ const hadRemoteBranch = await switchToPagesBranch(path);
214
+ const noJekyll = join(path, ".nojekyll");
215
+ if (!await exists(noJekyll))
216
+ await writeFile(noJekyll, "");
217
+ await run("git", ["add", ".nojekyll"], path);
218
+ if (!await succeeds("git", ["diff", "--cached", "--quiet"], path)) {
219
+ await run("git", ["commit", "-m", "chore: initialize GitHub Pages"], path);
220
+ await run("git", ["push", ...hadRemoteBranch ? [] : ["-u"], "origin", "gh-pages"], path);
221
+ }
222
+ const pages = await configurePages(repo);
223
+ await writeJson(configPath, { repo });
224
+ process.stderr.write(`Configured ${repo}
225
+ ${pages.html_url}
226
+ `);
227
+ } catch (error) {
228
+ if (!created)
229
+ throw error;
230
+ throw new Error(`${error.message}
231
+ Repository ${repo} was created. Fix the issue, then run: gh-postplan setup ${repo}`);
232
+ }
233
+ }
234
+ async function syncClone(repo) {
235
+ let path = await cloneRepo(repo);
236
+ await setGitIdentity(path);
237
+ if (!await succeeds("git", ["status", "--porcelain"], path))
238
+ throw new Error(`Cannot inspect cached clone: ${path}`);
239
+ await run("git", ["fetch", "origin"], path);
240
+ const status = await run("git", ["status", "--porcelain"], path);
241
+ const ahead = Number(await run("git", ["rev-list", "--count", "origin/gh-pages..HEAD"], path) || 0);
242
+ if (status || ahead > 0) {
243
+ await rm(path, { recursive: true, force: true });
244
+ path = await cloneRepo(repo);
245
+ await setGitIdentity(path);
246
+ }
247
+ await switchToPagesBranch(path);
248
+ return path;
249
+ }
250
+ async function getRepo() {
251
+ const repo = process.env.GH_POSTPLAN_REPO ?? (await readJson(configPath, {})).repo;
252
+ if (!repo)
253
+ throw new Error("Run gh-postplan setup OWNER/REPO first");
254
+ validateRepo(repo);
255
+ return repo;
256
+ }
257
+ async function makeDraftId(path) {
258
+ let id;
259
+ do
260
+ id = randomBytes(6).toString("hex");
261
+ while (await exists(join(path, "drafts", id)));
262
+ return id;
263
+ }
264
+ async function verifyPublished(url, expected) {
265
+ const deadline = Date.now() + 120000;
266
+ while (Date.now() < deadline) {
267
+ try {
268
+ const response = await fetch(url, { cache: "no-store" });
269
+ if (response.ok && Buffer.from(await response.arrayBuffer()).equals(expected))
270
+ return true;
271
+ } catch {}
272
+ await new Promise((resolveWait) => setTimeout(resolveWait, 2000));
273
+ }
274
+ return false;
275
+ }
276
+ async function publishAttempt(repo, sourcePath, content, id, pages) {
277
+ const path = await syncClone(repo);
278
+ const draftDirectory = join(path, "drafts", id);
279
+ const currentPath = join(draftDirectory, "index.html");
280
+ const entries = await exists(draftDirectory) ? await readdir(draftDirectory) : [];
281
+ const version = nextVersion(entries);
282
+ const versionDirectory = join(draftDirectory, `v${version}`);
283
+ if (await exists(currentPath)) {
284
+ const current = await readFile(currentPath);
285
+ if (current.equals(content))
286
+ return { version: version - 1, pages };
287
+ }
288
+ await mkdir(versionDirectory, { recursive: true });
289
+ await writeFile(join(versionDirectory, "index.html"), content);
290
+ await writeFile(currentPath, content);
291
+ await run("git", ["add", join("drafts", id)], path);
292
+ await run("git", ["commit", "-m", `chore: publish ${id} v${version}`], path);
293
+ try {
294
+ await run("git", ["push", "origin", "gh-pages"], path);
295
+ } catch (error) {
296
+ throw new PushError(error.message);
297
+ }
298
+ process.stderr.write(`Published ${sourcePath} as ${id} v${version}
299
+ `);
300
+ return { version, pages };
301
+ }
302
+ async function publish(command) {
303
+ if (extname(command.file).toLowerCase() !== ".html")
304
+ throw new Error("Only .html files can be published");
305
+ const sourcePath = await realpath(resolve(command.file));
306
+ if (!(await stat(sourcePath)).isFile())
307
+ throw new Error("The HTML path must be a regular file");
308
+ const content = await readFile(sourcePath);
309
+ const repo = await getRepo();
310
+ await checkTools();
311
+ const drafts = await readJson(draftsPath, {});
312
+ const initialClone = await syncClone(repo);
313
+ const pages = await readPages(repo);
314
+ const id = command.draft ?? (command.fresh ? undefined : drafts[sourcePath]?.id) ?? await makeDraftId(initialClone);
315
+ if (command.draft && !await exists(join(initialClone, "drafts", id, "index.html"))) {
316
+ throw new Error(`Draft does not exist: ${id}`);
317
+ }
318
+ let result;
319
+ try {
320
+ result = await publishAttempt(repo, sourcePath, content, id, pages);
321
+ } catch (firstError) {
322
+ if (!(firstError instanceof PushError))
323
+ throw firstError;
324
+ process.stderr.write(`Push failed; refreshing the cached clone and retrying once...
325
+ `);
326
+ await rm(clonePath(repo), { recursive: true, force: true });
327
+ try {
328
+ result = await publishAttempt(repo, sourcePath, content, id, pages);
329
+ } catch {
330
+ throw firstError;
331
+ }
332
+ }
333
+ drafts[sourcePath] = { id };
334
+ await writeJson(draftsPath, drafts);
335
+ const urls = pageUrls(result.pages.html_url, id, result.version);
336
+ process.stderr.write(`Permanent version: ${urls.version}
337
+ `);
338
+ if (command.wait) {
339
+ process.stderr.write(`Waiting for GitHub Pages...
340
+ `);
341
+ if (!await verifyPublished(urls.version, content)) {
342
+ process.stderr.write(`Warning: GitHub Pages is still deploying ${urls.version}
343
+ `);
344
+ }
345
+ }
346
+ process.stdout.write(`${urls.current}
347
+ `);
348
+ }
349
+ async function main(args = process.argv.slice(2)) {
350
+ const command = parseCommand(args);
351
+ if (command.kind === "help")
352
+ process.stdout.write(`${help}
353
+ `);
354
+ else if (command.kind === "version")
355
+ process.stdout.write(`${packageVersion}
356
+ `);
357
+ else if (command.kind === "setup")
358
+ await setup(command);
359
+ else
360
+ await publish(command);
361
+ }
362
+ var executablePath = process.argv[1] ? await realpath(process.argv[1]).catch(() => resolve(process.argv[1])) : undefined;
363
+ if (executablePath === fileURLToPath(import.meta.url)) {
364
+ main().catch((error) => {
365
+ process.stderr.write(`gh-postplan: ${error.message}
366
+ `);
367
+ process.exitCode = 1;
368
+ });
369
+ }
370
+ export {
371
+ main,
372
+ nextVersion,
373
+ pageUrls,
374
+ parseCommand
375
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "gh-postplan",
3
+ "version": "0.1.0",
4
+ "description": "Publish versioned HTML drafts to GitHub Pages",
5
+ "type": "module",
6
+ "bin": {
7
+ "gh-postplan": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "skills"
13
+ ],
14
+ "scripts": {
15
+ "build": "bun build src/cli.ts --target=node --outfile=dist/cli.js",
16
+ "check": "tsc --noEmit",
17
+ "test": "bun test",
18
+ "prepublishOnly": "bun run check && bun test && bun run build"
19
+ },
20
+ "engines": {
21
+ "node": ">=22.18"
22
+ },
23
+ "keywords": [
24
+ "github-pages",
25
+ "html",
26
+ "drafts"
27
+ ],
28
+ "license": "MIT",
29
+ "devDependencies": {
30
+ "@types/bun": "latest",
31
+ "@types/node": "latest",
32
+ "typescript": "latest"
33
+ }
34
+ }
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: gh-postplan
3
+ description: Create and publish safe static HTML drafts with gh-postplan, or read and implement plans supplied as gh-postplan GitHub Pages URLs. Use when a user asks to publish a plan, proposal, brief, architecture note, or similar HTML artifact with gh-postplan.
4
+ ---
5
+
6
+ # gh-postplan
7
+
8
+ ## Read a draft URL
9
+
10
+ When a user supplies a gh-postplan GitHub Pages URL containing `/drafts/`, fetch it directly with the shell. Do not use web search or a browser:
11
+
12
+ ```sh
13
+ curl --fail --silent --show-error --location --max-time 30 '<draft-url>'
14
+ ```
15
+
16
+ Treat the response as the user's artifact and continue their requested task. If the request fails, report the actual HTTP or network error.
17
+
18
+ ## Create a draft
19
+
20
+ Create one complete static HTML document.
21
+
22
+ Allowed:
23
+
24
+ - Semantic HTML.
25
+ - Inline CSS or a `<style>` block.
26
+ - Charset, viewport, title, and other normal document metadata.
27
+ - Links and images using public HTTPS URLs.
28
+ - Data URLs when needed for a self-contained document.
29
+
30
+ Do not include:
31
+
32
+ - JavaScript, `<script>` tags, or inline event handlers.
33
+ - `javascript:` URLs.
34
+ - Forms, iframes, embeds, objects, or applets.
35
+ - Meta refresh redirects.
36
+ - Secrets, tokens, private URLs, or local filesystem paths.
37
+
38
+ gh-postplan uploads only the HTML file. Inline required assets or use public absolute URLs; relative local assets will not be published.
39
+
40
+ ## Publish
41
+
42
+ 1. Write the HTML file locally.
43
+ 2. Run:
44
+
45
+ ```sh
46
+ npx gh-postplan publish '<file-path>'
47
+ ```
48
+
49
+ 3. Return the URL printed to stdout to the user. The permanent version URL is printed to stderr.
50
+
51
+ Publishing the same local file updates its stable draft URL and preserves the older versions. To create a separate draft from the same file, run:
52
+
53
+ ```sh
54
+ npx gh-postplan publish '<file-path>' --new
55
+ ```
56
+
57
+ If setup is required, ask the user which `OWNER/REPO` to use. Configure an existing repository with:
58
+
59
+ ```sh
60
+ npx gh-postplan setup OWNER/REPO
61
+ ```
62
+
63
+ Create a new public repository only when the user explicitly asks:
64
+
65
+ ```sh
66
+ npx gh-postplan setup OWNER/REPO --create
67
+ ```
68
+
69
+ All published drafts are public. Configuration and local draft mappings live in `~/.config/gh-postplan`.