@design-drafts/cli 0.0.0 → 0.2.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/dist/index.mjs ADDED
@@ -0,0 +1,1396 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { cli } from "cli-forge";
6
+ import { confirm, isCancel, select, text } from "@clack/prompts";
7
+ import { execSync, spawn } from "node:child_process";
8
+ import { createServer } from "node:http";
9
+ //#region package.json
10
+ var version = "0.2.0";
11
+ //#endregion
12
+ //#region src/config.ts
13
+ const CONFIG_FILENAME = "design-drafts.config.json";
14
+ const DEFAULT_PREFIX = "drafts/";
15
+ const homeConfigPath = join(homedir(), CONFIG_FILENAME);
16
+ const homeJsonProvider = {
17
+ resolve: () => existsSync(homeConfigPath) ? homeConfigPath : void 0,
18
+ load: (filename) => JSON.parse(readFileSync(filename, "utf-8"))
19
+ };
20
+ function readConfig(configPath) {
21
+ return existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : {};
22
+ }
23
+ /** Reads a single key from the home config without prompting. Used by the magic
24
+ * `init` to decide whether the host has already been set up. */
25
+ function readHomeConfigValue(key) {
26
+ const value = readConfig(homeConfigPath)[key];
27
+ return typeof value === "string" ? value : void 0;
28
+ }
29
+ /** Resolves a value, prompting (with optional validation) when it's missing.
30
+ * Does NOT persist — callers that want the answer remembered should call
31
+ * `persistHomeConfigValue` only after the operation it feeds succeeds, so a
32
+ * value that led nowhere (e.g. a repo that failed to push) never becomes a
33
+ * sticky default. */
34
+ async function promptForValue(existing, label, promptMessage, validate) {
35
+ if (existing) return existing;
36
+ const value = await text({
37
+ message: promptMessage,
38
+ validate: (v) => {
39
+ if (!v?.trim()) return `${label} is required`;
40
+ return validate?.(v);
41
+ }
42
+ });
43
+ if (typeof value !== "string") process.exit(1);
44
+ return value;
45
+ }
46
+ function persistHomeConfigValue(key, value) {
47
+ const previousFile = readConfig(homeConfigPath);
48
+ if (previousFile[key] === value) return;
49
+ writeFileSync(homeConfigPath, JSON.stringify({
50
+ ...previousFile,
51
+ [key]: value
52
+ }, null, 2) + "\n");
53
+ }
54
+ function resolvePrefix(existing) {
55
+ if (typeof existing === "string") {
56
+ persistHomeConfigValue("prefix", existing);
57
+ return existing;
58
+ }
59
+ persistHomeConfigValue("prefix", DEFAULT_PREFIX);
60
+ return DEFAULT_PREFIX;
61
+ }
62
+ //#endregion
63
+ //#region src/errors.ts
64
+ /**
65
+ * An expected, user-facing failure. The top-level handler prints its message
66
+ * verbatim (no stack trace) and exits non-zero. Use it for situations the user
67
+ * can act on — a push that was rejected, a template that couldn't be fetched —
68
+ * as opposed to programmer errors, which should surface as ordinary Errors.
69
+ */
70
+ var CliError = class extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = "CliError";
74
+ }
75
+ };
76
+ /** Prints a failure as a single clean message and exits non-zero. A CliError's
77
+ * message is shown verbatim; anything else is an unexpected bug, labelled as
78
+ * such (with the stack only under DESIGN_DRAFTS_DEBUG). */
79
+ function reportError(error) {
80
+ if (error instanceof CliError) console.error(`\n${error.message}`);
81
+ else if (process.env.DESIGN_DRAFTS_DEBUG) console.error(error);
82
+ else {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ console.error(`\nUnexpected error: ${message}\nRe-run with DESIGN_DRAFTS_DEBUG=1 for details.`);
85
+ }
86
+ process.exit(1);
87
+ }
88
+ /**
89
+ * Runs a command handler, converting any thrown failure into a clean message +
90
+ * non-zero exit. We catch and exit here rather than letting the error escape,
91
+ * because cli-forge's own runCommand catch prints the raw error (with stack)
92
+ * and the help text — exiting first keeps the output clean.
93
+ */
94
+ async function runHandler(fn) {
95
+ try {
96
+ await fn();
97
+ } catch (error) {
98
+ reportError(error);
99
+ }
100
+ }
101
+ //#endregion
102
+ //#region src/exec.ts
103
+ /** Runs a command, streaming its output to the user's terminal. Throws on a
104
+ * non-zero exit. */
105
+ function exec(command, cwd) {
106
+ execSync(command, {
107
+ cwd,
108
+ stdio: "inherit"
109
+ });
110
+ }
111
+ /** Runs a command and returns its trimmed stdout, or undefined if it fails or
112
+ * produces no output. Stderr is suppressed — callers use this to probe state
113
+ * (does this tag exist? is gh authed?) where failure is an expected answer. */
114
+ function capture(command, cwd) {
115
+ try {
116
+ return execSync(command, {
117
+ cwd,
118
+ stdio: [
119
+ "ignore",
120
+ "pipe",
121
+ "ignore"
122
+ ]
123
+ }).toString("utf-8").trim() || void 0;
124
+ } catch {
125
+ return;
126
+ }
127
+ }
128
+ /** True when the command exits zero. */
129
+ function succeeds(command, cwd) {
130
+ try {
131
+ execSync(command, {
132
+ cwd,
133
+ stdio: "ignore"
134
+ });
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+ //#endregion
141
+ //#region src/github.ts
142
+ /**
143
+ * Picks a remote URL for a GitHub repo using auth that actually works.
144
+ *
145
+ * `gh config get git_protocol` reflects the transport the user authenticated
146
+ * with — if they set up gh over HTTPS, gh installs a git credential helper and
147
+ * HTTPS pushes succeed while SSH would fail (no key), and vice versa. We honor
148
+ * that signal and fall back to SSH (git's traditional default) when gh isn't
149
+ * present to ask.
150
+ */
151
+ function githubRemoteUrl(repo, cwd) {
152
+ if (capture("gh config get git_protocol", cwd) === "https") return `https://github.com/${repo}.git`;
153
+ return `git@github.com:${repo}.git`;
154
+ }
155
+ //#endregion
156
+ //#region src/site-name.ts
157
+ const SITE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,62}$/;
158
+ function slugifySiteName(input) {
159
+ return input.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+/, "").replace(/-+$/, "").replace(/^[-_]+/, "").slice(0, 63);
160
+ }
161
+ function validateSiteName(name) {
162
+ if (!name || !name.trim()) return {
163
+ ok: false,
164
+ reason: "site-name must not be empty"
165
+ };
166
+ if (name.length > 63) return {
167
+ ok: false,
168
+ reason: "site-name must be 63 characters or fewer",
169
+ suggestion: slugifySiteName(name) || void 0
170
+ };
171
+ if (!SITE_NAME_PATTERN.test(name)) return {
172
+ ok: false,
173
+ reason: "site-name must start with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, or underscores",
174
+ suggestion: slugifySiteName(name) || void 0
175
+ };
176
+ return { ok: true };
177
+ }
178
+ //#endregion
179
+ //#region src/init/templates.ts
180
+ const HOST_MARKER = "# design-drafts: host deploy workflow";
181
+ const DEPLOY_WORKFLOW = `${HOST_MARKER}
182
+ name: Deploy Preview
183
+
184
+ on:
185
+ push:
186
+ branches:
187
+ - 'drafts/**'
188
+ pull_request:
189
+ types: [opened, synchronize, reopened]
190
+ branches: [main]
191
+ delete:
192
+ workflow_dispatch:
193
+ inputs:
194
+ branch:
195
+ description: 'Branch to deploy a preview for'
196
+ required: true
197
+ type: string
198
+
199
+ # The gh-pages branch is the durable store of every preview (NOT the Pages
200
+ # source — Pages serves the artifact uploaded below). Each run reconstructs the
201
+ # full site from gh-pages, swaps in the changed preview, and republishes.
202
+ concurrency:
203
+ group: deploy-preview
204
+ cancel-in-progress: false
205
+
206
+ jobs:
207
+ build:
208
+ runs-on: ubuntu-latest
209
+ if: |
210
+ (github.event_name == 'push' && startsWith(github.ref_name, 'drafts/')) ||
211
+ (github.event_name == 'pull_request' && startsWith(github.head_ref, 'drafts/')) ||
212
+ (github.event_name == 'delete' && github.event.ref_type == 'branch' && startsWith(github.event.ref, 'drafts/')) ||
213
+ github.event_name == 'workflow_dispatch'
214
+ permissions:
215
+ contents: write
216
+ env:
217
+ BRANCH_NAME: \${{ inputs.branch || github.head_ref || github.ref_name }}
218
+ DRAFT_BRANCH_PREFIX: drafts/
219
+ steps:
220
+ - name: Checkout main
221
+ uses: actions/checkout@v4
222
+ with:
223
+ ref: main
224
+
225
+ - name: Setup pnpm
226
+ uses: pnpm/action-setup@v4
227
+
228
+ - name: Setup Node
229
+ uses: actions/setup-node@v4
230
+ with:
231
+ node-version: 20
232
+ cache: pnpm
233
+
234
+ - name: Install dependencies
235
+ run: pnpm install
236
+
237
+ - name: Configure git identity
238
+ run: |
239
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
240
+ git config --global user.name "github-actions[bot]"
241
+
242
+ - name: Checkout gh-pages store to staging
243
+ run: |
244
+ if git fetch origin gh-pages:gh-pages 2>/dev/null; then
245
+ git worktree add .gh-pages-staging gh-pages
246
+ else
247
+ echo "gh-pages store does not exist yet, starting fresh"
248
+ git worktree add --orphan -b gh-pages .gh-pages-staging
249
+ fi
250
+
251
+ - name: Resolve preview directory name
252
+ run: |
253
+ if [ "\${{ github.event_name }}" = "delete" ]; then
254
+ BRANCH_NAME="\${{ github.event.ref }}"
255
+ fi
256
+ # Strip the draft prefix so branch "drafts/my-site" maps to "/my-site/".
257
+ PREVIEW_DIR="\${BRANCH_NAME#\${DRAFT_BRANCH_PREFIX}}"
258
+ if [ -z "$PREVIEW_DIR" ] || [ "$PREVIEW_DIR" = "$BRANCH_NAME" ]; then
259
+ PREVIEW_DIR="$BRANCH_NAME"
260
+ fi
261
+ echo "PREVIEW_DIR=$PREVIEW_DIR" >> "$GITHUB_ENV"
262
+
263
+ - name: Stage branch content
264
+ if: github.event_name != 'delete'
265
+ run: |
266
+ git clone --depth 1 --branch "$BRANCH_NAME" \\
267
+ "https://x-access-token:\${{ secrets.GITHUB_TOKEN }}@github.com/\${{ github.repository }}.git" \\
268
+ /tmp/branch-content
269
+ rm -rf /tmp/branch-content/.git
270
+ # The CLI embeds this workflow on the draft branch so the push event
271
+ # can resolve it. Strip it back out so it never ships into the preview.
272
+ rm -rf /tmp/branch-content/.github
273
+ rm -rf ".gh-pages-staging/\${PREVIEW_DIR}"
274
+ mkdir -p ".gh-pages-staging/\${PREVIEW_DIR}"
275
+ cp -a /tmp/branch-content/. ".gh-pages-staging/\${PREVIEW_DIR}/"
276
+
277
+ - name: Remove deleted branch's directory
278
+ if: github.event_name == 'delete'
279
+ run: rm -rf ".gh-pages-staging/\${PREVIEW_DIR}"
280
+
281
+ - name: Build index site
282
+ run: pnpm build
283
+ env:
284
+ PAGES_DIR: \${{ github.workspace }}/.gh-pages-staging
285
+ DESIGN_DRAFTS_PREFIX: drafts/
286
+ DESIGN_DRAFTS_REPO: \${{ github.repository }}
287
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
288
+
289
+ - name: Copy index site into staging
290
+ run: |
291
+ cp -r dist/client/* .gh-pages-staging/ 2>/dev/null || true
292
+ touch .gh-pages-staging/.nojekyll
293
+
294
+ - name: Persist preview store to gh-pages
295
+ run: |
296
+ cd .gh-pages-staging
297
+ git add -A
298
+ git commit -m "deploy: \${PREVIEW_DIR}" || echo "no changes to persist"
299
+ git push origin HEAD:gh-pages
300
+
301
+ - name: Upload Pages artifact
302
+ uses: actions/upload-pages-artifact@v3
303
+ with:
304
+ path: .gh-pages-staging
305
+
306
+ deploy:
307
+ needs: build
308
+ runs-on: ubuntu-latest
309
+ permissions:
310
+ pages: write
311
+ id-token: write
312
+ environment:
313
+ name: github-pages
314
+ url: \${{ steps.deployment.outputs.page_url }}
315
+ steps:
316
+ - name: Deploy to GitHub Pages
317
+ id: deployment
318
+ uses: actions/deploy-pages@v4
319
+ `;
320
+ const GITIGNORE = `node_modules
321
+ dist
322
+ .gh-pages-staging
323
+ `;
324
+ /**
325
+ * The starter manifest written by `init draft`. It is a valid DraftManifest
326
+ * (see `@design-drafts/conventions`): a single-page, axis-free proposal whose
327
+ * one page is `index.html`. A schema-valid manifest matters because the toolbar
328
+ * silently no-ops on a manifest missing `name`/`pages`, so a stub with the wrong
329
+ * shape would leave a fresh draft without a working toolbar. The push command
330
+ * reads the `prompt` field for the commit trailer; the author fills in `axes`
331
+ * and additional `pages` as the draft grows.
332
+ */
333
+ function draftConfig(siteName, createdAt) {
334
+ return JSON.stringify({
335
+ $schema: "https://design-drafts.dev/schemas/draft-manifest.schema.json",
336
+ name: siteName,
337
+ prompt: "",
338
+ pages: [{
339
+ coordinates: {},
340
+ path: "index.html"
341
+ }],
342
+ createdAt
343
+ }, null, 2) + "\n";
344
+ }
345
+ const DRAFT_INDEX_HTML = `<!doctype html>
346
+ <html lang="en">
347
+ <head>
348
+ <meta charset="utf-8" />
349
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
350
+ <title>Draft preview</title>
351
+ </head>
352
+ <body>
353
+ <main>
354
+ <h1>Draft preview</h1>
355
+ <p>Replace this with your design draft, then run <code>design-drafts</code> to publish.</p>
356
+ </main>
357
+
358
+ <!--
359
+ design-drafts overlays, loaded from the CDN — no build step, no file to copy:
360
+ - toolbar: switches between the axes declared in design-drafts.config.json
361
+ - annotate: lets reviewers leave comments anchored to the page
362
+ Both are inert until they have something to do (the toolbar needs a
363
+ design-drafts.config.json; annotate waits for ?annotate=1 or its toggle), so they
364
+ are safe to keep on every page. Pinned to the current major (@0); drop a
365
+ tag to remove that overlay, or bump the pin when you upgrade. See each
366
+ package's README for self-hosting and version options.
367
+ -->
368
+ <script src="https://unpkg.com/@design-drafts/toolbar@0/dist/toolbar.js" defer><\/script>
369
+ <script src="https://unpkg.com/@design-drafts/annotate@0/dist/annotate.js" defer><\/script>
370
+ </body>
371
+ </html>
372
+ `;
373
+ //#endregion
374
+ //#region src/init/draft.ts
375
+ /** Writes the file only if it is absent, logging which path it created and
376
+ * which it left untouched. Returns true when a file was written. */
377
+ function writeIfAbsent(filePath, contents) {
378
+ const name = basename(filePath);
379
+ if (existsSync(filePath)) {
380
+ console.log(` skipped ${name} (already exists)`);
381
+ return false;
382
+ }
383
+ writeFileSync(filePath, contents);
384
+ console.log(` created ${name}`);
385
+ return true;
386
+ }
387
+ async function initDraft(opts) {
388
+ const targetDir = resolve(opts.path);
389
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
390
+ let siteName = await promptForValue(opts.siteName, "site-name", "Site name for this draft:");
391
+ const validation = validateSiteName(siteName);
392
+ if (!validation.ok) {
393
+ const fixed = validation.suggestion ?? slugifySiteName(siteName);
394
+ console.warn(`"${siteName}" is not a valid site-name (${validation.reason}); using "${fixed}".`);
395
+ siteName = fixed;
396
+ }
397
+ console.log(`\nScaffolding draft "${siteName}" in ${targetDir}:`);
398
+ const wroteManifest = writeIfAbsent(join(targetDir, "design-drafts.config.json"), draftConfig(siteName, (/* @__PURE__ */ new Date()).toISOString()));
399
+ const wroteIndex = writeIfAbsent(join(targetDir, "index.html"), DRAFT_INDEX_HTML);
400
+ if (!wroteManifest && !wroteIndex) {
401
+ console.log(`\nDraft "${siteName}" was already scaffolded; nothing to write.`);
402
+ return;
403
+ }
404
+ console.log(`\nDraft "${siteName}" ready.\nEdit index.html, then run \`design-drafts\` here to publish a preview.`);
405
+ }
406
+ //#endregion
407
+ //#region src/validate.ts
408
+ const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
409
+ const PREFIX_PATTERN = /^[A-Za-z0-9._/-]*$/;
410
+ const REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
411
+ function validateRepo(repo) {
412
+ if (!repo.trim()) return {
413
+ ok: false,
414
+ reason: "repo must not be empty"
415
+ };
416
+ if (!REPO_PATTERN.test(repo)) return {
417
+ ok: false,
418
+ reason: "repo must be in \"owner/name\" form using only letters, digits, \".\", \"_\", or \"-\""
419
+ };
420
+ return { ok: true };
421
+ }
422
+ function validatePrefix(prefix) {
423
+ if (!PREFIX_PATTERN.test(prefix)) return {
424
+ ok: false,
425
+ reason: "prefix may contain only letters, digits, \"/\", \".\", \"_\", or \"-\""
426
+ };
427
+ return { ok: true };
428
+ }
429
+ function validateTemplateRef(ref) {
430
+ if (!REF_PATTERN.test(ref)) return {
431
+ ok: false,
432
+ reason: "template-ref may contain only letters, digits, \"/\", \".\", \"_\", or \"-\""
433
+ };
434
+ return { ok: true };
435
+ }
436
+ //#endregion
437
+ //#region src/init/rewrites.ts
438
+ const CATALOG_PREFIX = "catalog:";
439
+ function resolveDepBlock(block, catalog) {
440
+ if (!block) return block;
441
+ const resolved = {};
442
+ for (const [name, spec] of Object.entries(block)) {
443
+ if (!spec.startsWith(CATALOG_PREFIX)) {
444
+ resolved[name] = spec;
445
+ continue;
446
+ }
447
+ if (spec.slice(8)) throw new Error(`Cannot resolve "${name}": named catalogs ("${spec}") are not supported`);
448
+ const version = catalog[name];
449
+ if (!version) throw new Error(`Cannot resolve "${name}": "${spec}" but no catalog entry exists for it`);
450
+ resolved[name] = version;
451
+ }
452
+ return resolved;
453
+ }
454
+ /**
455
+ * Rewrites a site package.json for life in a standalone host repo: every
456
+ * `catalog:` dependency specifier is replaced with the concrete version from
457
+ * the canonical workspace catalog, the nx project config is removed, and a
458
+ * `packageManager` field is set (so `pnpm/action-setup` can resolve a version
459
+ * in the generated repo — without it the first deploy fails).
460
+ */
461
+ function resolveSitePackageJson(raw, catalog, packageManager) {
462
+ const pkg = JSON.parse(raw);
463
+ const next = { ...pkg };
464
+ next.dependencies = resolveDepBlock(pkg.dependencies, catalog);
465
+ next.devDependencies = resolveDepBlock(pkg.devDependencies, catalog);
466
+ delete next.nx;
467
+ if (packageManager) next.packageManager = packageManager;
468
+ if (next.dependencies && Object.keys(next.dependencies).length === 0) delete next.dependencies;
469
+ if (next.devDependencies && Object.keys(next.devDependencies).length === 0) delete next.devDependencies;
470
+ return JSON.stringify(next, null, 2) + "\n";
471
+ }
472
+ /**
473
+ * Parses the `catalog:` block out of a canonical pnpm-workspace.yaml. We keep
474
+ * the parser intentionally small (flat `key: value` pairs under `catalog:`)
475
+ * rather than pulling in a YAML dependency — the catalog is always a flat map
476
+ * of package name to version string.
477
+ */
478
+ function parseCatalog(workspaceYaml) {
479
+ const lines = workspaceYaml.split("\n");
480
+ const catalog = {};
481
+ let inCatalog = false;
482
+ let catalogIndent = 0;
483
+ for (const line of lines) {
484
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
485
+ const indent = line.length - line.trimStart().length;
486
+ if (!inCatalog) {
487
+ if (line.trim() === "catalog:") {
488
+ inCatalog = true;
489
+ catalogIndent = indent;
490
+ }
491
+ continue;
492
+ }
493
+ if (indent <= catalogIndent) break;
494
+ const match = line.trim().match(/^['"]?([^'":]+)['"]?\s*:\s*(.+)$/);
495
+ if (match) {
496
+ const name = match[1].trim();
497
+ catalog[name] = match[2].trim().replace(/^['"]|['"]$/g, "");
498
+ }
499
+ }
500
+ return catalog;
501
+ }
502
+ /**
503
+ * Rewrites the Vite `base` so asset URLs resolve under the host repo's Pages
504
+ * subpath (https://<owner>.github.io/<repo>/). Accepts `org/repo` or a bare
505
+ * repo name and uses just the repo segment.
506
+ */
507
+ function rewriteViteBase(source, repo) {
508
+ const base = `/${repo.includes("/") ? repo.split("/").pop() : repo}/`;
509
+ const replaced = source.replace(/base:\s*(['"])(?:[^'"]*)\1/, `base: '${base}'`);
510
+ if (replaced === source) throw new Error("Could not find a `base:` option in vite.config to rewrite");
511
+ return replaced;
512
+ }
513
+ /**
514
+ * Folds the canonical base tsconfig's compilerOptions into the site tsconfig and
515
+ * drops `extends`, so the host needs no `tsconfig.base.json` at its root. The
516
+ * site's own compilerOptions win on conflict.
517
+ */
518
+ function inlineTsconfig(siteRaw, baseRaw) {
519
+ const site = JSON.parse(siteRaw);
520
+ const base = JSON.parse(baseRaw);
521
+ const merged = { ...site };
522
+ delete merged.extends;
523
+ merged.compilerOptions = {
524
+ ...base.compilerOptions ?? {},
525
+ ...site.compilerOptions ?? {}
526
+ };
527
+ return JSON.stringify(merged, null, 2) + "\n";
528
+ }
529
+ /**
530
+ * Picks the template ref to check the site out from. An explicit override wins;
531
+ * otherwise prefer the release tag matching the CLI version when it exists,
532
+ * falling back to the default branch.
533
+ */
534
+ function resolveTemplateRef(opts) {
535
+ if (opts.override) return opts.override;
536
+ const tag = `v${opts.cliVersion}`;
537
+ if (opts.tagExists(tag)) return tag;
538
+ return opts.defaultBranch;
539
+ }
540
+ //#endregion
541
+ //#region src/init/host.ts
542
+ const CANONICAL_REPO = "AgentEnder/design-drafts";
543
+ const CANONICAL_URL = `https://github.com/${CANONICAL_REPO}.git`;
544
+ const DEFAULT_BRANCH$1 = "main";
545
+ const SITE_SUBDIR = "packages/site";
546
+ const WORKFLOW_PATH$1 = ".github/workflows/deploy-preview.yml";
547
+ const TRANSFORMED = new Set([
548
+ "package.json",
549
+ "vite.config.ts",
550
+ "tsconfig.json"
551
+ ]);
552
+ function repoName(repo) {
553
+ return repo.includes("/") ? repo.split("/").pop() : repo;
554
+ }
555
+ function detectRemoteRepo(targetDir) {
556
+ const url = capture("git remote get-url origin", targetDir);
557
+ if (!url) return void 0;
558
+ return url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)?.[1];
559
+ }
560
+ /** True when the target already carries our generated workflow. */
561
+ function alreadyConfigured(targetDir) {
562
+ const workflow = join(targetDir, WORKFLOW_PATH$1);
563
+ if (!existsSync(workflow)) return false;
564
+ return readFileSync(workflow, "utf-8").includes(HOST_MARKER);
565
+ }
566
+ /** Sparse-checks-out the canonical site source into a temp dir, returning that
567
+ * path. Cone mode (the recommended default) always materialises top-level
568
+ * files, so the root files we read to resolve the site (pnpm-workspace.yaml's
569
+ * catalog and tsconfig.base.json) come along without listing them, while the
570
+ * sibling packages stay excluded. */
571
+ function sparseCheckout(ref) {
572
+ const tmp = mkdtempSync(join(tmpdir(), "design-drafts-host-"));
573
+ try {
574
+ exec(`git clone --filter=blob:none --no-checkout ${CANONICAL_URL} .`, tmp);
575
+ exec(`git sparse-checkout set ${SITE_SUBDIR}`, tmp);
576
+ exec(`git checkout ${ref}`, tmp);
577
+ } catch {
578
+ rmSync(tmp, {
579
+ recursive: true,
580
+ force: true
581
+ });
582
+ throw new CliError(`Could not fetch the host template from ${CANONICAL_REPO}@${ref}.\nCheck your network connection and that the ref exists (try --template-ref main).`);
583
+ }
584
+ return tmp;
585
+ }
586
+ function writeScaffold(checkout, targetDir, repo) {
587
+ const siteDir = join(checkout, SITE_SUBDIR);
588
+ cpSync(siteDir, targetDir, {
589
+ recursive: true,
590
+ filter: (src) => !TRANSFORMED.has(src.slice(siteDir.length + 1))
591
+ });
592
+ const catalog = parseCatalog(readFileSync(join(checkout, "pnpm-workspace.yaml"), "utf-8"));
593
+ const rootPkg = JSON.parse(readFileSync(join(checkout, "package.json"), "utf-8"));
594
+ writeFileSync(join(targetDir, "package.json"), resolveSitePackageJson(readFileSync(join(siteDir, "package.json"), "utf-8"), catalog, rootPkg.packageManager));
595
+ const viteRaw = readFileSync(join(siteDir, "vite.config.ts"), "utf-8");
596
+ writeFileSync(join(targetDir, "vite.config.ts"), rewriteViteBase(viteRaw, repo));
597
+ writeFileSync(join(targetDir, "tsconfig.json"), inlineTsconfig(readFileSync(join(siteDir, "tsconfig.json"), "utf-8"), readFileSync(join(checkout, "tsconfig.base.json"), "utf-8")));
598
+ const workflowDest = join(targetDir, WORKFLOW_PATH$1);
599
+ mkdirSync(dirname(workflowDest), { recursive: true });
600
+ writeFileSync(workflowDest, DEPLOY_WORKFLOW);
601
+ writeFileSync(join(targetDir, ".nojekyll"), "");
602
+ writeFileSync(join(targetDir, ".gitignore"), GITIGNORE);
603
+ }
604
+ function commitScaffold(targetDir) {
605
+ if (!existsSync(join(targetDir, ".git"))) exec(`git init -b ${DEFAULT_BRANCH$1}`, targetDir);
606
+ exec("git add .", targetDir);
607
+ if (capture("git status --porcelain", targetDir)) exec("git commit -m \"chore: scaffold design-drafts host\"", targetDir);
608
+ }
609
+ /** Best-effort GitHub wiring. Never hard-fails: the local scaffold already
610
+ * succeeded, so any gh gap becomes printed manual steps. Returns true when the
611
+ * repo is set up and pushed (so the caller can discard a throwaway scaffold). */
612
+ function configureGitHub(targetDir, repo, isPrivate) {
613
+ if (!succeeds("gh auth status", targetDir)) {
614
+ printManualSteps(repo, targetDir, isPrivate, "GitHub CLI (`gh`) is not installed or authenticated");
615
+ return false;
616
+ }
617
+ const visibility = isPrivate ? "--private" : "--public";
618
+ if (!succeeds(`gh repo view ${repo}`, targetDir)) {
619
+ if (!succeeds(`gh repo create ${repo} ${visibility} --source ${JSON.stringify(targetDir)} --remote origin --push`, targetDir)) {
620
+ printManualSteps(repo, targetDir, isPrivate, `could not create ${repo}`);
621
+ return false;
622
+ }
623
+ enablePages(targetDir, repo, isPrivate);
624
+ return true;
625
+ }
626
+ if (!detectRemoteRepo(targetDir)) exec(`git remote add origin ${githubRemoteUrl(repo, targetDir)}`, targetDir);
627
+ if (!succeeds(`git push -u origin ${DEFAULT_BRANCH$1}`, targetDir)) {
628
+ printManualSteps(repo, targetDir, isPrivate, `could not push to ${repo} (does its ${DEFAULT_BRANCH$1} already have commits?)`);
629
+ return false;
630
+ }
631
+ enablePages(targetDir, repo, isPrivate);
632
+ return true;
633
+ }
634
+ /** Sets the Pages source to "GitHub Actions" (build_type=workflow). */
635
+ function enablePages(targetDir, repo, isPrivate) {
636
+ const create = `gh api -X POST /repos/${repo}/pages -f build_type=workflow`;
637
+ const update = `gh api -X PUT /repos/${repo}/pages -f build_type=workflow`;
638
+ if (succeeds(create, targetDir) || succeeds(update, targetDir)) {
639
+ console.log(`Enabled GitHub Pages (Actions source) on ${repo}.`);
640
+ return;
641
+ }
642
+ console.warn(`Warning: could not enable GitHub Pages on ${repo}.${isPrivate ? " Private repos need GitHub Pro/Team for Pages — make the repo public or upgrade." : ""}\nEnable it manually: Settings -> Pages -> Build and deployment -> Source: GitHub Actions.`);
643
+ }
644
+ function printManualSteps(repo, targetDir, isPrivate, reason) {
645
+ console.warn(`\nScaffold is at ${targetDir}, but GitHub setup was skipped (${reason}).\nFinish manually:\n gh repo create ${repo} ${isPrivate ? "--private" : "--public"} --source ${JSON.stringify(targetDir)} --remote origin --push\n Settings -> Pages -> Source: GitHub Actions\n`);
646
+ }
647
+ /** Resolves repo visibility, prompting interactively when not preset. */
648
+ async function resolveVisibility(opts) {
649
+ if (typeof opts.private === "boolean") return opts.private;
650
+ const choice = await select({
651
+ message: "Repository visibility:",
652
+ options: [{
653
+ value: false,
654
+ label: "Public",
655
+ hint: "required for free GitHub Pages"
656
+ }, {
657
+ value: true,
658
+ label: "Private",
659
+ hint: "Pages needs GitHub Pro/Team"
660
+ }],
661
+ initialValue: false
662
+ });
663
+ if (isCancel(choice)) process.exit(1);
664
+ return choice;
665
+ }
666
+ /** Confirmation gate before the outward GitHub actions (create/push/Pages). */
667
+ async function confirmGitHub(repo, isPrivate) {
668
+ const answer = await confirm({ message: `Create ${isPrivate ? "private" : "public"} repo ${repo} on GitHub, push the scaffold, and enable Pages?` });
669
+ if (isCancel(answer)) process.exit(1);
670
+ return answer === true;
671
+ }
672
+ async function initHost(opts) {
673
+ const usingTmp = !opts.path;
674
+ const targetDir = opts.path ? resolve(opts.path) : mkdtempSync(join(tmpdir(), "design-drafts-host-scaffold-"));
675
+ if (opts.path && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
676
+ const repo = await promptForValue(opts.repo ?? readHomeConfigValue("repo") ?? (opts.path ? detectRemoteRepo(targetDir) : void 0), "repo", "GitHub repo for the host (org/repo):", (v) => validateRepo(v).ok ? void 0 : "use \"owner/name\" form");
677
+ const repoCheck = validateRepo(repo);
678
+ if (!repoCheck.ok) {
679
+ console.error(`Invalid repo "${repo}": ${repoCheck.reason}`);
680
+ process.exit(1);
681
+ }
682
+ if (opts.templateRef) {
683
+ const refCheck = validateTemplateRef(opts.templateRef);
684
+ if (!refCheck.ok) {
685
+ console.error(`Invalid template-ref "${opts.templateRef}": ${refCheck.reason}`);
686
+ process.exit(1);
687
+ }
688
+ }
689
+ if (opts.path && alreadyConfigured(targetDir)) {
690
+ console.log(`Host already configured at ${targetDir}; re-ensuring Pages.`);
691
+ enablePages(targetDir, repo, opts.private ?? false);
692
+ return;
693
+ }
694
+ const ref = resolveTemplateRef({
695
+ override: opts.templateRef,
696
+ cliVersion: opts.cliVersion,
697
+ defaultBranch: DEFAULT_BRANCH$1,
698
+ tagExists: (tag) => Boolean(capture(`git ls-remote --tags ${CANONICAL_URL} ${tag}`, targetDir))
699
+ });
700
+ console.log(`Scaffolding host "${repoName(repo)}" from ${CANONICAL_REPO}@${ref}...`);
701
+ const checkout = sparseCheckout(ref);
702
+ try {
703
+ writeScaffold(checkout, targetDir, repo);
704
+ } finally {
705
+ rmSync(checkout, {
706
+ recursive: true,
707
+ force: true
708
+ });
709
+ }
710
+ commitScaffold(targetDir);
711
+ const isPrivate = await resolveVisibility(opts);
712
+ if (!(opts.yes || await confirmGitHub(repo, isPrivate))) {
713
+ console.log(`\nStopped before GitHub setup. The scaffold is at ${targetDir}.`);
714
+ printManualSteps(repo, targetDir, isPrivate, "cancelled");
715
+ return;
716
+ }
717
+ const pushed = configureGitHub(targetDir, repo, isPrivate);
718
+ if (pushed) persistHomeConfigValue("repo", repo);
719
+ if (!usingTmp) {
720
+ console.log(`\nHost scaffolded at ${targetDir}.`);
721
+ return;
722
+ }
723
+ if (pushed) {
724
+ rmSync(targetDir, {
725
+ recursive: true,
726
+ force: true
727
+ });
728
+ console.log(`\nHost ready at https://github.com/${repo} (local scaffold discarded).`);
729
+ } else console.log(`\nKept the scaffold at ${targetDir} so you can finish the push.`);
730
+ }
731
+ //#endregion
732
+ //#region src/init/init.ts
733
+ /**
734
+ * The "from nothing to ready-to-publish" path. Scaffolds a host the first time
735
+ * (detected by the absence of a configured `repo`), then scaffolds a draft in
736
+ * the target directory. Stops short of auto-pushing a placeholder — it prints
737
+ * the single command to publish instead.
738
+ */
739
+ async function init(opts) {
740
+ if (!Boolean(opts.repo ?? readHomeConfigValue("repo"))) {
741
+ console.log("No host configured yet — setting one up first.\n");
742
+ await initHost({
743
+ repo: opts.repo,
744
+ templateRef: opts.templateRef,
745
+ cliVersion: opts.cliVersion
746
+ });
747
+ console.log("");
748
+ }
749
+ await initDraft({
750
+ path: opts.path,
751
+ siteName: opts.siteName
752
+ });
753
+ console.log("\nWhen the draft looks right, run `design-drafts` to publish.");
754
+ }
755
+ //#endregion
756
+ //#region src/draft-dir.ts
757
+ /**
758
+ * Resolves the draft directory a command should act on: the explicit path when
759
+ * given, otherwise the current working directory. Throws when the resolved
760
+ * directory has no `design-drafts.config.json`, so callers fail with an
761
+ * actionable message instead of silently operating on a non-draft directory.
762
+ */
763
+ function resolveDraftDir(explicit) {
764
+ const candidate = resolve(explicit ?? process.cwd());
765
+ if (!existsSync(join(candidate, "design-drafts.config.json"))) throw new Error(`No design-drafts.config.json at ${candidate}. Run from inside a draft directory or pass --draft <dir>.`);
766
+ return candidate;
767
+ }
768
+ //#endregion
769
+ //#region src/preview.ts
770
+ const DEFAULT_PORT = 4321;
771
+ const PORT_SCAN_ATTEMPTS = 20;
772
+ const MIME_TYPES = {
773
+ ".html": "text/html; charset=utf-8",
774
+ ".htm": "text/html; charset=utf-8",
775
+ ".css": "text/css; charset=utf-8",
776
+ ".js": "text/javascript; charset=utf-8",
777
+ ".mjs": "text/javascript; charset=utf-8",
778
+ ".json": "application/json; charset=utf-8",
779
+ ".map": "application/json; charset=utf-8",
780
+ ".svg": "image/svg+xml",
781
+ ".png": "image/png",
782
+ ".jpg": "image/jpeg",
783
+ ".jpeg": "image/jpeg",
784
+ ".webp": "image/webp",
785
+ ".gif": "image/gif",
786
+ ".ico": "image/x-icon",
787
+ ".avif": "image/avif",
788
+ ".woff": "font/woff",
789
+ ".woff2": "font/woff2",
790
+ ".ttf": "font/ttf",
791
+ ".otf": "font/otf",
792
+ ".txt": "text/plain; charset=utf-8",
793
+ ".md": "text/markdown; charset=utf-8"
794
+ };
795
+ /** Maps a file path to a Content-Type by extension, defaulting to a generic
796
+ * binary type for anything unrecognised. */
797
+ function contentTypeFor(filePath) {
798
+ return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
799
+ }
800
+ /**
801
+ * Resolves a request URL to an absolute path inside `draftDir`, or `null` when
802
+ * the request is malformed or tries to escape the draft root.
803
+ *
804
+ * The query string and hash are stripped, percent-escapes are decoded (so an
805
+ * encoded `..` can't sneak past), and the result is confirmed to live under
806
+ * `draftDir` via a `relative()` check. The returned path may be a file or a
807
+ * directory — the caller decides how to serve it.
808
+ */
809
+ function resolveServedFile(draftDir, urlPath) {
810
+ const withoutQuery = urlPath.split("?")[0].split("#")[0];
811
+ let decoded;
812
+ try {
813
+ decoded = decodeURIComponent(withoutQuery);
814
+ } catch {
815
+ return null;
816
+ }
817
+ const candidate = resolve(draftDir, "." + (decoded.startsWith("/") ? decoded : `/${decoded}`));
818
+ const rel = relative(draftDir, candidate);
819
+ if (rel && (rel.startsWith("..") || isAbsolute(rel))) return null;
820
+ return candidate;
821
+ }
822
+ /**
823
+ * Recursively collects every `.html` file beneath `dir`, returned as
824
+ * root-relative POSIX paths (e.g. `pages/sub/p.html`) sorted for stable output.
825
+ * Used to build the generated index when a directory has no index.html.
826
+ */
827
+ function collectHtmlPages(dir) {
828
+ const pages = [];
829
+ const walk = (current) => {
830
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
831
+ const abs = join(current, entry.name);
832
+ if (entry.isDirectory()) walk(abs);
833
+ else if (entry.isFile() && extname(entry.name).toLowerCase() === ".html") pages.push(relative(dir, abs).split(sep).join("/"));
834
+ }
835
+ };
836
+ walk(dir);
837
+ return pages.sort();
838
+ }
839
+ function escapeHtml(value) {
840
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
841
+ }
842
+ /**
843
+ * Renders a fallback index page that links to every `.html` page in the draft,
844
+ * shown when the requested directory has no index.html of its own. Links are
845
+ * root-absolute so they resolve regardless of which directory was requested.
846
+ */
847
+ function renderDirectoryIndex(draftDir) {
848
+ const pages = collectHtmlPages(draftDir);
849
+ return `<!doctype html>
850
+ <html lang="en">
851
+ <head>
852
+ <meta charset="utf-8" />
853
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
854
+ <title>Draft pages</title>
855
+ <style>
856
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 3rem auto; max-width: 40rem; padding: 0 1rem; }
857
+ h1 { font-size: 1.25rem; }
858
+ ul { list-style: none; padding: 0; }
859
+ li { margin: 0.25rem 0; }
860
+ a { color: #2563eb; text-decoration: none; }
861
+ a:hover { text-decoration: underline; }
862
+ .empty { color: #6b7280; }
863
+ </style>
864
+ </head>
865
+ <body>
866
+ <h1>Draft pages</h1>
867
+ <p>No <code>index.html</code> here — listing the pages in this draft:</p>
868
+ <ul>
869
+ ${pages.length ? pages.map((page) => ` <li><a href="/${page}">${escapeHtml(page)}</a></li>`).join("\n") : " <li class=\"empty\">No pages found in this draft yet.</li>"}
870
+ </ul>
871
+ </body>
872
+ </html>
873
+ `;
874
+ }
875
+ /** Builds the static file server for a draft directory without binding it to a
876
+ * port, so it can be exercised directly in tests. */
877
+ function createPreviewServer(draftDir) {
878
+ return createServer((req, res) => {
879
+ const safePath = resolveServedFile(draftDir, req.url ?? "/");
880
+ if (safePath === null) {
881
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
882
+ res.end("Forbidden");
883
+ return;
884
+ }
885
+ try {
886
+ let filePath = safePath;
887
+ if (statSync(filePath).isDirectory()) {
888
+ const indexPath = join(filePath, "index.html");
889
+ if (!existsSync(indexPath)) {
890
+ const listing = renderDirectoryIndex(draftDir);
891
+ res.writeHead(200, {
892
+ "Content-Type": "text/html; charset=utf-8",
893
+ "Content-Length": Buffer.byteLength(listing)
894
+ });
895
+ res.end(listing);
896
+ return;
897
+ }
898
+ filePath = indexPath;
899
+ }
900
+ const body = readFileSync(filePath);
901
+ res.writeHead(200, {
902
+ "Content-Type": contentTypeFor(filePath),
903
+ "Content-Length": body.length
904
+ });
905
+ res.end(body);
906
+ } catch {
907
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
908
+ res.end("Not found");
909
+ }
910
+ });
911
+ }
912
+ /**
913
+ * Binds the server to a port. With an explicit `requestedPort` we try only that
914
+ * port and surface a clear error if it's taken; without one we scan upward from
915
+ * the default until a free port is found.
916
+ */
917
+ function listen(server, requestedPort) {
918
+ const startPort = requestedPort ?? DEFAULT_PORT;
919
+ const maxAttempts = requestedPort === void 0 ? PORT_SCAN_ATTEMPTS : 1;
920
+ const tryPort = (port, attempt) => new Promise((resolvePort, rejectPort) => {
921
+ const onError = (error) => {
922
+ server.removeListener("listening", onListening);
923
+ if (error.code === "EADDRINUSE" && attempt + 1 < maxAttempts) {
924
+ resolvePort(tryPort(port + 1, attempt + 1));
925
+ return;
926
+ }
927
+ if (error.code === "EADDRINUSE") {
928
+ rejectPort(new CliError(`Port ${port} is already in use. Pass --port <n> to pick another.`));
929
+ return;
930
+ }
931
+ rejectPort(error);
932
+ };
933
+ const onListening = () => {
934
+ server.removeListener("error", onError);
935
+ resolvePort(port);
936
+ };
937
+ server.once("error", onError);
938
+ server.once("listening", onListening);
939
+ server.listen(port, "localhost");
940
+ });
941
+ return tryPort(startPort, 0);
942
+ }
943
+ /** Opens the given URL in the user's default browser. Best-effort: a missing
944
+ * opener or a spawn failure is swallowed so it never breaks the server. */
945
+ function openBrowser(url) {
946
+ const { platform } = process;
947
+ const [command, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
948
+ "/c",
949
+ "start",
950
+ "",
951
+ url
952
+ ]] : ["xdg-open", [url]];
953
+ try {
954
+ const child = spawn(command, args, {
955
+ stdio: "ignore",
956
+ detached: true
957
+ });
958
+ child.on("error", () => {});
959
+ child.unref();
960
+ } catch {}
961
+ }
962
+ /** Serves a work-in-progress draft directory over HTTP for local viewing. */
963
+ async function preview(opts) {
964
+ const url = `http://localhost:${await listen(createPreviewServer(resolveDraftDir(opts.draft)), opts.port)}/`;
965
+ console.log(`\nServing draft at ${url}`);
966
+ console.log("Press Ctrl+C to stop.");
967
+ if (opts.open !== false) openBrowser(url);
968
+ }
969
+ //#endregion
970
+ //#region src/ref-add.ts
971
+ const IMAGE_EXTENSIONS = new Set([
972
+ ".png",
973
+ ".webp",
974
+ ".jpg",
975
+ ".jpeg"
976
+ ]);
977
+ const REFERENCES_DIRNAME = "references";
978
+ const INSPIRATION_DIRNAME = "inspiration";
979
+ const LINKS_FILENAME = "links.md";
980
+ const LINKS_HEADER = "# Links\n\nAnnotated reference URLs. See `docs/conventions/references-protocol.md` — one URL per bullet, an em-dash, then a sentence saying what is being cited.\n\n";
981
+ async function refAdd(options) {
982
+ const draftDir = resolveDraftDir(options.draft);
983
+ ensureReferencesScaffold(draftDir);
984
+ if (isUrl(options.source)) {
985
+ if (looksLikeImageUrl(options.source)) {
986
+ await downloadInspiration({
987
+ url: options.source,
988
+ draftDir,
989
+ name: options.name,
990
+ note: options.note
991
+ });
992
+ return;
993
+ }
994
+ if (!options.note?.trim()) throw new Error("URL references require --note explaining what is being cited (e.g. 'typography pairing, NOT the color').");
995
+ appendLink({
996
+ url: options.source,
997
+ draftDir,
998
+ note: options.note
999
+ });
1000
+ return;
1001
+ }
1002
+ copyLocalInspiration({
1003
+ filePath: options.source,
1004
+ draftDir,
1005
+ name: options.name
1006
+ });
1007
+ }
1008
+ function isUrl(source) {
1009
+ try {
1010
+ const parsed = new URL(source);
1011
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
1012
+ } catch {
1013
+ return false;
1014
+ }
1015
+ }
1016
+ function looksLikeImageUrl(url) {
1017
+ const pathname = new URL(url).pathname.toLowerCase();
1018
+ return IMAGE_EXTENSIONS.has(extname(pathname));
1019
+ }
1020
+ function ensureReferencesScaffold(draftDir) {
1021
+ const refs = join(draftDir, REFERENCES_DIRNAME);
1022
+ if (!existsSync(refs)) mkdirSync(refs, { recursive: true });
1023
+ const linksPath = join(refs, LINKS_FILENAME);
1024
+ if (!existsSync(linksPath)) writeFileSync(linksPath, LINKS_HEADER);
1025
+ }
1026
+ function appendLink({ url, draftDir, note }) {
1027
+ const linksPath = join(draftDir, REFERENCES_DIRNAME, LINKS_FILENAME);
1028
+ const existing = existsSync(linksPath) ? readFileSync(linksPath, "utf-8") : LINKS_HEADER;
1029
+ const line = `- ${url} — ${note.trim()}\n`;
1030
+ writeFileSync(linksPath, existing.endsWith("\n") ? existing + line : existing + "\n" + line);
1031
+ process.stdout.write(`Added link to ${join(REFERENCES_DIRNAME, LINKS_FILENAME)}: ${url}\n`);
1032
+ }
1033
+ async function downloadInspiration({ url, draftDir, name, note }) {
1034
+ const inspirationDir = join(draftDir, REFERENCES_DIRNAME, INSPIRATION_DIRNAME);
1035
+ if (!existsSync(inspirationDir)) mkdirSync(inspirationDir, { recursive: true });
1036
+ const ext = extname(new URL(url).pathname).toLowerCase() || ".png";
1037
+ const finalName = uniqueFilename(inspirationDir, ensureExtension(name ?? deriveImageName(url), ext));
1038
+ const finalPath = join(inspirationDir, finalName);
1039
+ const response = await fetch(url);
1040
+ if (!response.ok) throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
1041
+ writeFileSync(finalPath, new Uint8Array(await response.arrayBuffer()));
1042
+ const relPath = join(REFERENCES_DIRNAME, INSPIRATION_DIRNAME, finalName);
1043
+ process.stdout.write(`Downloaded to ${relPath}\n`);
1044
+ if (!name) process.stdout.write(` Heads up: filename was derived from the URL. Per the references protocol,
1045
+ the filename IS the citation — rename to describe what's being cited
1046
+ (e.g. linear-empty-state-density${ext}).\n`);
1047
+ if (note?.trim()) appendLink({
1048
+ url,
1049
+ draftDir,
1050
+ note: `${note.trim()} (see ${INSPIRATION_DIRNAME}/${finalName})`
1051
+ });
1052
+ }
1053
+ function copyLocalInspiration({ filePath, draftDir, name }) {
1054
+ const absPath = resolve(filePath);
1055
+ if (!existsSync(absPath)) throw new Error(`File does not exist: ${absPath}`);
1056
+ if (!statSync(absPath).isFile()) throw new Error(`Not a regular file: ${absPath}`);
1057
+ const ext = extname(absPath).toLowerCase();
1058
+ if (!IMAGE_EXTENSIONS.has(ext)) throw new Error(`Inspiration files must be one of ${[...IMAGE_EXTENSIONS].join(", ")} per the references protocol. Got: ${ext || "(no extension)"}`);
1059
+ const inspirationDir = join(draftDir, REFERENCES_DIRNAME, INSPIRATION_DIRNAME);
1060
+ if (!existsSync(inspirationDir)) mkdirSync(inspirationDir, { recursive: true });
1061
+ const finalName = uniqueFilename(inspirationDir, ensureExtension(name ?? basename(absPath), ext));
1062
+ copyFileSync(absPath, join(inspirationDir, finalName));
1063
+ const relPath = join(REFERENCES_DIRNAME, INSPIRATION_DIRNAME, finalName);
1064
+ process.stdout.write(`Copied to ${relPath}\n`);
1065
+ if (!name) process.stdout.write(" Heads up: kept the original filename. Per the references protocol,\n the filename IS the citation — rename to describe what's being cited.\n");
1066
+ }
1067
+ function deriveImageName(url) {
1068
+ try {
1069
+ const parsed = new URL(url);
1070
+ const segments = parsed.pathname.split("/").filter(Boolean);
1071
+ const stem = (segments[segments.length - 1] ?? "").replace(/\.[a-z0-9]+$/i, "");
1072
+ const host = parsed.hostname.replace(/^www\./, "").replace(/\./g, "-");
1073
+ return slugify(stem ? `${host}-${stem}` : host);
1074
+ } catch {
1075
+ return "reference";
1076
+ }
1077
+ }
1078
+ function slugify(input) {
1079
+ return input.toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
1080
+ }
1081
+ function ensureExtension(name, ext) {
1082
+ return extname(name).toLowerCase() === ext.toLowerCase() ? name : `${name}${ext}`;
1083
+ }
1084
+ function uniqueFilename(dir, candidate) {
1085
+ if (!existsSync(join(dir, candidate))) return candidate;
1086
+ const ext = extname(candidate);
1087
+ const stem = candidate.slice(0, candidate.length - ext.length);
1088
+ for (let i = 2; i < 1e3; i++) {
1089
+ const next = `${stem}-${i}${ext}`;
1090
+ if (!existsSync(join(dir, next))) return next;
1091
+ }
1092
+ throw new Error(`Could not find a unique filename for ${candidate} in ${dir}`);
1093
+ }
1094
+ //#endregion
1095
+ //#region src/index.ts
1096
+ const CLI_VERSION = version;
1097
+ const DEFAULT_BRANCH = "main";
1098
+ const WORKFLOW_PATH = ".github/workflows/deploy-preview.yml";
1099
+ async function embedDeployWorkflow(repo, tmpDir) {
1100
+ const url = `https://raw.githubusercontent.com/${repo}/${DEFAULT_BRANCH}/${WORKFLOW_PATH}`;
1101
+ let contents;
1102
+ try {
1103
+ const response = await fetch(url);
1104
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1105
+ contents = await response.text();
1106
+ } catch (error) {
1107
+ const reason = error instanceof Error ? error.message : String(error);
1108
+ console.warn(`Warning: could not fetch deploy workflow from ${url} (${reason}).\nThe preview will not auto-deploy. Trigger it manually with:\n gh workflow run deploy-preview.yml -f branch=<branch> --repo ${repo}`);
1109
+ return;
1110
+ }
1111
+ const destination = join(tmpDir, WORKFLOW_PATH);
1112
+ mkdirSync(dirname(destination), { recursive: true });
1113
+ writeFileSync(destination, contents);
1114
+ }
1115
+ function getSourceMetadata(sourcePath) {
1116
+ return {
1117
+ sha: capture("git rev-parse HEAD", sourcePath),
1118
+ repo: capture("git remote get-url origin", sourcePath),
1119
+ authorName: capture("git config user.name", sourcePath),
1120
+ authorEmail: capture("git config user.email", sourcePath)
1121
+ };
1122
+ }
1123
+ function hashManifest(manifestPath) {
1124
+ try {
1125
+ const content = readFileSync(manifestPath, "utf-8");
1126
+ return createHash("sha256").update(content).digest("hex");
1127
+ } catch {
1128
+ return;
1129
+ }
1130
+ }
1131
+ function readManifestPrompt(manifestPath, sourcePath) {
1132
+ let raw;
1133
+ try {
1134
+ raw = readFileSync(manifestPath, "utf-8");
1135
+ } catch {
1136
+ return;
1137
+ }
1138
+ let parsed;
1139
+ try {
1140
+ parsed = JSON.parse(raw);
1141
+ } catch {
1142
+ return;
1143
+ }
1144
+ if (!parsed || typeof parsed !== "object" || !("prompt" in parsed)) return;
1145
+ const prompt = parsed.prompt;
1146
+ if (typeof prompt !== "string" || !prompt.trim()) return;
1147
+ const candidatePath = isAbsolute(prompt) ? prompt : join(sourcePath, prompt);
1148
+ try {
1149
+ const stat = statSync(candidatePath);
1150
+ if (stat.isFile() && stat.size < 200) {
1151
+ const inline = readFileSync(candidatePath, "utf-8").trim();
1152
+ if (inline) return inline.replace(/\s+/g, " ");
1153
+ }
1154
+ } catch {}
1155
+ return prompt.replace(/\s+/g, " ");
1156
+ }
1157
+ /** Reads the manifest's human-readable `name` so the push can derive a
1158
+ * site-name from it. Returns undefined when there is no manifest, it doesn't
1159
+ * parse, or it has no usable `name`. */
1160
+ function readManifestName(manifestPath) {
1161
+ let parsed;
1162
+ try {
1163
+ parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
1164
+ } catch {
1165
+ return;
1166
+ }
1167
+ if (!parsed || typeof parsed !== "object") return void 0;
1168
+ const name = parsed.name;
1169
+ return typeof name === "string" && name.trim() ? name : void 0;
1170
+ }
1171
+ /**
1172
+ * Resolves the site-name (the branch/preview directory name) for a push.
1173
+ *
1174
+ * Precedence: an explicit `--site-name` wins; otherwise we derive it from the
1175
+ * manifest's `name` by slugifying it (so "Toolbar redesign demo" becomes
1176
+ * "toolbar-redesign-demo"). Only when there is no manifest to read from do we
1177
+ * fall back to prompting. The manifest is the single per-draft store now, so we
1178
+ * never persist the answer to a separate file.
1179
+ */
1180
+ async function resolveSiteName(explicit, manifestPath) {
1181
+ if (explicit) return explicit;
1182
+ const fromManifest = readManifestName(manifestPath);
1183
+ if (fromManifest) {
1184
+ const slug = slugifySiteName(fromManifest);
1185
+ if (slug) return slug;
1186
+ }
1187
+ return promptForValue(void 0, "site-name", "Site name for this preview:");
1188
+ }
1189
+ function sanitizeAuthorName(name) {
1190
+ return name.replace(/</g, "(").replace(/>/g, ")");
1191
+ }
1192
+ /**
1193
+ * Builds the structured commit message used for every draft push.
1194
+ *
1195
+ * Format:
1196
+ *
1197
+ * push: <site-name>
1198
+ *
1199
+ * source-sha: <sha>
1200
+ * source-repo: <repo>
1201
+ * author: <Name <email>>
1202
+ * prompt: <one-line summary or path>
1203
+ * draft-config-sha: <sha256 of design-drafts.config.json>
1204
+ *
1205
+ * Each trailer line is omitted when its data is unavailable (no source git
1206
+ * repo, no manifest, no `prompt` field, etc.). The site-name subject line is
1207
+ * always present.
1208
+ */
1209
+ function buildCommitMessage(opts) {
1210
+ const lines = [`push: ${opts.siteName}`, ""];
1211
+ const { metadata } = opts;
1212
+ if (metadata.sha) lines.push(`source-sha: ${metadata.sha}`);
1213
+ if (metadata.repo) lines.push(`source-repo: ${metadata.repo}`);
1214
+ if (metadata.authorName && metadata.authorEmail) {
1215
+ const safeName = sanitizeAuthorName(metadata.authorName);
1216
+ lines.push(`author: ${safeName} <${metadata.authorEmail}>`);
1217
+ }
1218
+ if (opts.prompt) lines.push(`prompt: ${opts.prompt}`);
1219
+ if (opts.draftConfigSha) lines.push(`draft-config-sha: ${opts.draftConfigSha}`);
1220
+ if (lines.length === 2) lines.pop();
1221
+ return lines.join("\n") + "\n";
1222
+ }
1223
+ async function pushHandler(args) {
1224
+ const repo = await promptForValue(args.repo, "repo", "GitHub repo (org/repo):", (v) => validateRepo(v).ok ? void 0 : "use \"owner/name\" form");
1225
+ const sourcePath = resolve(args.path ?? ".");
1226
+ if (!existsSync(sourcePath)) {
1227
+ console.error(`Path does not exist: ${sourcePath}`);
1228
+ process.exit(1);
1229
+ }
1230
+ const manifestPath = join(sourcePath, "design-drafts.config.json");
1231
+ const siteName = await resolveSiteName(args["site-name"], manifestPath);
1232
+ const prefix = resolvePrefix(args.prefix);
1233
+ const validation = validateSiteName(siteName);
1234
+ if (!validation.ok) {
1235
+ console.error(`Invalid site-name "${siteName}": ${validation.reason}`);
1236
+ if (validation.suggestion) console.error(`Try: ${validation.suggestion}`);
1237
+ process.exit(1);
1238
+ }
1239
+ const repoCheck = validateRepo(repo);
1240
+ if (!repoCheck.ok) {
1241
+ console.error(`Invalid repo "${repo}": ${repoCheck.reason}`);
1242
+ process.exit(1);
1243
+ }
1244
+ const prefixCheck = validatePrefix(prefix);
1245
+ if (!prefixCheck.ok) {
1246
+ console.error(`Invalid prefix "${prefix}": ${prefixCheck.reason}`);
1247
+ process.exit(1);
1248
+ }
1249
+ const metadata = getSourceMetadata(sourcePath);
1250
+ const draftConfigSha = hashManifest(manifestPath);
1251
+ const commitMessage = buildCommitMessage({
1252
+ siteName,
1253
+ metadata,
1254
+ prompt: readManifestPrompt(manifestPath, sourcePath),
1255
+ draftConfigSha
1256
+ });
1257
+ const branchName = `${prefix}${siteName}`;
1258
+ const tmpDir = mkdtempSync(join(tmpdir(), "design-drafts-"));
1259
+ const messageFile = join(tmpdir(), `design-drafts-msg-${process.pid}.txt`);
1260
+ writeFileSync(messageFile, commitMessage);
1261
+ try {
1262
+ cpSync(sourcePath, tmpDir, { recursive: true });
1263
+ await embedDeployWorkflow(repo, tmpDir);
1264
+ exec(`git init -b ${branchName}`, tmpDir);
1265
+ exec(`git remote add origin ${githubRemoteUrl(repo, tmpDir)}`, tmpDir);
1266
+ exec("git add .", tmpDir);
1267
+ exec(`git commit -F ${JSON.stringify(messageFile)}`, tmpDir);
1268
+ try {
1269
+ exec(`git push --force origin ${branchName}`, tmpDir);
1270
+ } catch {
1271
+ throw new CliError(`Failed to push "${branchName}" to ${repo}.\nCheck that the repo exists and you have push access (\`gh auth status\`),\nthen re-run. Nothing was saved as a default.`);
1272
+ }
1273
+ persistHomeConfigValue("repo", repo);
1274
+ console.log(`\nPushed "${branchName}" to ${repo}`);
1275
+ } finally {
1276
+ rmSync(tmpDir, {
1277
+ recursive: true,
1278
+ force: true
1279
+ });
1280
+ rmSync(messageFile, { force: true });
1281
+ }
1282
+ }
1283
+ await cli("design-drafts", {
1284
+ description: "Push static site previews as branches to a design-drafts repo",
1285
+ builder: (args) => args.option("repo", {
1286
+ type: "string",
1287
+ description: "GitHub repo in org/repo form"
1288
+ }).option("site-name", {
1289
+ type: "string",
1290
+ description: "Name for this site preview (becomes the branch name)"
1291
+ }).option("template-ref", {
1292
+ type: "string",
1293
+ description: "Ref of the canonical repo to scaffold the host site from (default: matching version tag, else main)"
1294
+ }).env({ prefix: "DESIGN_DRAFTS" }).config(homeJsonProvider).command("init", {
1295
+ description: "Set up a host (if needed) and scaffold a draft, ready to publish",
1296
+ builder: (initArgs) => initArgs.command("host", {
1297
+ description: "Scaffold a GitHub repo to host draft previews",
1298
+ builder: (b) => b.option("path", {
1299
+ type: "string",
1300
+ description: "Persist the scaffold to this directory instead of a throwaway tmpdir"
1301
+ }).option("private", {
1302
+ type: "boolean",
1303
+ description: "Create a private repo (default: prompt; Pages needs Pro/Team for private)"
1304
+ }).option("yes", {
1305
+ type: "boolean",
1306
+ description: "Skip the confirmation prompt before GitHub setup"
1307
+ }),
1308
+ handler: (a) => runHandler(() => initHost({
1309
+ path: a.path,
1310
+ repo: a.repo,
1311
+ templateRef: a["template-ref"],
1312
+ private: a.private,
1313
+ yes: a.yes,
1314
+ cliVersion: CLI_VERSION
1315
+ }))
1316
+ }).command("draft", {
1317
+ description: "Scaffold a new draft directory",
1318
+ builder: (b) => b.positional("path", {
1319
+ type: "string",
1320
+ default: ".",
1321
+ description: "Directory to scaffold the draft into"
1322
+ }),
1323
+ handler: (a) => runHandler(() => initDraft({
1324
+ path: a.path,
1325
+ siteName: a["site-name"]
1326
+ }))
1327
+ }),
1328
+ handler: (a) => runHandler(() => init({
1329
+ path: ".",
1330
+ repo: a.repo,
1331
+ siteName: a["site-name"],
1332
+ templateRef: a["template-ref"],
1333
+ cliVersion: CLI_VERSION
1334
+ }))
1335
+ }).command("ref", {
1336
+ description: "Manage a draft's references/ directory (links and inspiration screenshots).",
1337
+ builder: (refArgs) => refArgs.command("add", {
1338
+ description: "Add a reference URL or screenshot to references/. URL → references/links.md (with --note); image URL or local image → references/inspiration/.",
1339
+ builder: (b) => b.positional("source", {
1340
+ type: "string",
1341
+ description: "URL or local file path to add as a reference"
1342
+ }).option("note", {
1343
+ type: "string",
1344
+ description: "Annotation describing what is being cited. Required for non-image URLs."
1345
+ }).option("name", {
1346
+ type: "string",
1347
+ description: "Override the filename used in references/inspiration/ (extension is added if missing)."
1348
+ }).option("draft", {
1349
+ type: "string",
1350
+ description: "Path to the draft directory containing design-drafts.config.json (default: cwd)."
1351
+ }),
1352
+ handler: (a) => runHandler(() => {
1353
+ if (!a.source) throw new CliError("design-drafts ref add requires a <source> argument (URL or file path).");
1354
+ return refAdd({
1355
+ source: a.source,
1356
+ note: a.note,
1357
+ name: a.name,
1358
+ draft: a.draft
1359
+ });
1360
+ })
1361
+ })
1362
+ }).command("preview", {
1363
+ description: "Serve a work-in-progress draft directory over HTTP for local viewing",
1364
+ builder: (b) => b.positional("path", {
1365
+ type: "string",
1366
+ default: ".",
1367
+ description: "Draft directory to serve (must contain design-drafts.config.json; default: cwd)"
1368
+ }).option("port", {
1369
+ type: "number",
1370
+ description: "Port to serve on (default: 4321; auto-increments if busy unless set explicitly)"
1371
+ }).option("open", {
1372
+ type: "boolean",
1373
+ default: true,
1374
+ description: "Open the preview in a browser (use --no-open to just print the URL)"
1375
+ }),
1376
+ handler: (a) => runHandler(() => preview({
1377
+ draft: a.path,
1378
+ port: a.port,
1379
+ open: a.open
1380
+ }))
1381
+ }).command("push", {
1382
+ alias: ["$0"],
1383
+ description: "Push a built directory as a draft preview branch",
1384
+ builder: (b) => b.positional("path", {
1385
+ type: "string",
1386
+ default: ".",
1387
+ description: "Directory to push as a site preview"
1388
+ }).option("prefix", {
1389
+ type: "string",
1390
+ description: "Branch prefix used when pushing previews (default: \"drafts/\"). Pass an empty string to push without a prefix."
1391
+ }),
1392
+ handler: (a) => runHandler(() => pushHandler(a))
1393
+ })
1394
+ }).forge();
1395
+ //#endregion
1396
+ export {};