@design-drafts/cli 0.0.0 → 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/LICENSE +21 -0
- package/README.md +65 -0
- package/bin.js +2 -0
- package/dist/index.mjs +964 -0
- package/package.json +50 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Craigory Coppola
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @design-drafts/cli
|
|
2
|
+
|
|
3
|
+
Push static site previews as branches to a **design-drafts** repo, and have
|
|
4
|
+
them deployed to GitHub Pages automatically.
|
|
5
|
+
|
|
6
|
+
A *host* repo collects many *draft* previews. You scaffold a host once, then
|
|
7
|
+
push any built static directory as a draft branch; a GitHub Actions workflow
|
|
8
|
+
publishes each branch under its own path on GitHub Pages and an index site
|
|
9
|
+
links them all together.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
# one-off
|
|
15
|
+
npx @design-drafts/cli --help
|
|
16
|
+
|
|
17
|
+
# or install globally for the `design-drafts` binary
|
|
18
|
+
npm i -g @design-drafts/cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
### `design-drafts push [path]`
|
|
24
|
+
|
|
25
|
+
Push a built static directory (default `.`) as a draft preview branch. This is
|
|
26
|
+
the default command, so `design-drafts ./dist` works too.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
design-drafts push ./dist --repo my-org/design-previews --site-name homepage-v2
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `--repo <org/repo>` — the host repo to push to (remembered after the first run).
|
|
33
|
+
- `--site-name <name>` — branch/preview name (prompted if omitted).
|
|
34
|
+
- `--prefix <prefix>` — branch prefix for previews (default `drafts/`; pass `""` to push without one).
|
|
35
|
+
|
|
36
|
+
### `design-drafts init host`
|
|
37
|
+
|
|
38
|
+
Scaffold a new GitHub repo configured to host draft previews (deploy workflow,
|
|
39
|
+
index site, Pages setup).
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
design-drafts init host --repo my-org/design-previews
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `--private` — create the GitHub repo as private.
|
|
46
|
+
- `--yes` — skip the confirmation prompt before GitHub setup.
|
|
47
|
+
|
|
48
|
+
### `design-drafts init draft [directory]`
|
|
49
|
+
|
|
50
|
+
Scaffold a new draft directory locally.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
design-drafts init draft ./my-draft
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
Shared options (`--repo`, `--site-name`, `--template-ref`) can be supplied via
|
|
59
|
+
flags, the `DESIGN_DRAFTS_*` environment variables, or a JSON config file. The
|
|
60
|
+
`--repo` value is persisted to a per-user config after the first successful
|
|
61
|
+
push, so subsequent runs don't need it.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
package/bin.js
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { ConfigurationProviders, cli } from "cli-forge";
|
|
6
|
+
import { confirm, isCancel, select, text } from "@clack/prompts";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
//#region package.json
|
|
9
|
+
var version = "0.1.0";
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/config.ts
|
|
12
|
+
const CONFIG_FILENAME = "design-drafts.config.json";
|
|
13
|
+
const DEFAULT_PREFIX = "drafts/";
|
|
14
|
+
const homeConfigPath = join(homedir(), CONFIG_FILENAME);
|
|
15
|
+
const localConfigPath = join(process.cwd(), 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
|
+
async function promptAndPersist(existing, argKey, configPath, promptMessage, validate) {
|
|
47
|
+
if (existing) return existing;
|
|
48
|
+
const value = await promptForValue(existing, argKey, promptMessage, validate);
|
|
49
|
+
writeFileSync(configPath, JSON.stringify({
|
|
50
|
+
...readConfig(configPath),
|
|
51
|
+
[argKey]: value
|
|
52
|
+
}, null, 2) + "\n");
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function persistHomeConfigValue(key, value) {
|
|
56
|
+
const previousFile = readConfig(homeConfigPath);
|
|
57
|
+
if (previousFile[key] === value) return;
|
|
58
|
+
writeFileSync(homeConfigPath, JSON.stringify({
|
|
59
|
+
...previousFile,
|
|
60
|
+
[key]: value
|
|
61
|
+
}, null, 2) + "\n");
|
|
62
|
+
}
|
|
63
|
+
function resolvePrefix(existing) {
|
|
64
|
+
if (typeof existing === "string") {
|
|
65
|
+
persistHomeConfigValue("prefix", existing);
|
|
66
|
+
return existing;
|
|
67
|
+
}
|
|
68
|
+
persistHomeConfigValue("prefix", DEFAULT_PREFIX);
|
|
69
|
+
return DEFAULT_PREFIX;
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/errors.ts
|
|
73
|
+
/**
|
|
74
|
+
* An expected, user-facing failure. The top-level handler prints its message
|
|
75
|
+
* verbatim (no stack trace) and exits non-zero. Use it for situations the user
|
|
76
|
+
* can act on — a push that was rejected, a template that couldn't be fetched —
|
|
77
|
+
* as opposed to programmer errors, which should surface as ordinary Errors.
|
|
78
|
+
*/
|
|
79
|
+
var CliError = class extends Error {
|
|
80
|
+
constructor(message) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "CliError";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/** Prints a failure as a single clean message and exits non-zero. A CliError's
|
|
86
|
+
* message is shown verbatim; anything else is an unexpected bug, labelled as
|
|
87
|
+
* such (with the stack only under DESIGN_DRAFTS_DEBUG). */
|
|
88
|
+
function reportError(error) {
|
|
89
|
+
if (error instanceof CliError) console.error(`\n${error.message}`);
|
|
90
|
+
else if (process.env.DESIGN_DRAFTS_DEBUG) console.error(error);
|
|
91
|
+
else {
|
|
92
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
93
|
+
console.error(`\nUnexpected error: ${message}\nRe-run with DESIGN_DRAFTS_DEBUG=1 for details.`);
|
|
94
|
+
}
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Runs a command handler, converting any thrown failure into a clean message +
|
|
99
|
+
* non-zero exit. We catch and exit here rather than letting the error escape,
|
|
100
|
+
* because cli-forge's own runCommand catch prints the raw error (with stack)
|
|
101
|
+
* and the help text — exiting first keeps the output clean.
|
|
102
|
+
*/
|
|
103
|
+
async function runHandler(fn) {
|
|
104
|
+
try {
|
|
105
|
+
await fn();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
reportError(error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/exec.ts
|
|
112
|
+
/** Runs a command, streaming its output to the user's terminal. Throws on a
|
|
113
|
+
* non-zero exit. */
|
|
114
|
+
function exec(command, cwd) {
|
|
115
|
+
execSync(command, {
|
|
116
|
+
cwd,
|
|
117
|
+
stdio: "inherit"
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Runs a command and returns its trimmed stdout, or undefined if it fails or
|
|
121
|
+
* produces no output. Stderr is suppressed — callers use this to probe state
|
|
122
|
+
* (does this tag exist? is gh authed?) where failure is an expected answer. */
|
|
123
|
+
function capture(command, cwd) {
|
|
124
|
+
try {
|
|
125
|
+
return execSync(command, {
|
|
126
|
+
cwd,
|
|
127
|
+
stdio: [
|
|
128
|
+
"ignore",
|
|
129
|
+
"pipe",
|
|
130
|
+
"ignore"
|
|
131
|
+
]
|
|
132
|
+
}).toString("utf-8").trim() || void 0;
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** True when the command exits zero. */
|
|
138
|
+
function succeeds(command, cwd) {
|
|
139
|
+
try {
|
|
140
|
+
execSync(command, {
|
|
141
|
+
cwd,
|
|
142
|
+
stdio: "ignore"
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/github.ts
|
|
151
|
+
/**
|
|
152
|
+
* Picks a remote URL for a GitHub repo using auth that actually works.
|
|
153
|
+
*
|
|
154
|
+
* `gh config get git_protocol` reflects the transport the user authenticated
|
|
155
|
+
* with — if they set up gh over HTTPS, gh installs a git credential helper and
|
|
156
|
+
* HTTPS pushes succeed while SSH would fail (no key), and vice versa. We honor
|
|
157
|
+
* that signal and fall back to SSH (git's traditional default) when gh isn't
|
|
158
|
+
* present to ask.
|
|
159
|
+
*/
|
|
160
|
+
function githubRemoteUrl(repo, cwd) {
|
|
161
|
+
if (capture("gh config get git_protocol", cwd) === "https") return `https://github.com/${repo}.git`;
|
|
162
|
+
return `git@github.com:${repo}.git`;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/site-name.ts
|
|
166
|
+
const SITE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]{0,62}$/;
|
|
167
|
+
function slugifySiteName(input) {
|
|
168
|
+
return input.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+/, "").replace(/-+$/, "").replace(/^[-_]+/, "").slice(0, 63);
|
|
169
|
+
}
|
|
170
|
+
function validateSiteName(name) {
|
|
171
|
+
if (!name || !name.trim()) return {
|
|
172
|
+
ok: false,
|
|
173
|
+
reason: "site-name must not be empty"
|
|
174
|
+
};
|
|
175
|
+
if (name.length > 63) return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: "site-name must be 63 characters or fewer",
|
|
178
|
+
suggestion: slugifySiteName(name) || void 0
|
|
179
|
+
};
|
|
180
|
+
if (!SITE_NAME_PATTERN.test(name)) return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: "site-name must start with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, or underscores",
|
|
183
|
+
suggestion: slugifySiteName(name) || void 0
|
|
184
|
+
};
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/init/templates.ts
|
|
189
|
+
const HOST_MARKER = "# design-drafts: host deploy workflow";
|
|
190
|
+
const DEPLOY_WORKFLOW = `${HOST_MARKER}
|
|
191
|
+
name: Deploy Preview
|
|
192
|
+
|
|
193
|
+
on:
|
|
194
|
+
push:
|
|
195
|
+
branches:
|
|
196
|
+
- 'drafts/**'
|
|
197
|
+
pull_request:
|
|
198
|
+
types: [opened, synchronize, reopened]
|
|
199
|
+
branches: [main]
|
|
200
|
+
delete:
|
|
201
|
+
workflow_dispatch:
|
|
202
|
+
inputs:
|
|
203
|
+
branch:
|
|
204
|
+
description: 'Branch to deploy a preview for'
|
|
205
|
+
required: true
|
|
206
|
+
type: string
|
|
207
|
+
|
|
208
|
+
# The gh-pages branch is the durable store of every preview (NOT the Pages
|
|
209
|
+
# source — Pages serves the artifact uploaded below). Each run reconstructs the
|
|
210
|
+
# full site from gh-pages, swaps in the changed preview, and republishes.
|
|
211
|
+
concurrency:
|
|
212
|
+
group: deploy-preview
|
|
213
|
+
cancel-in-progress: false
|
|
214
|
+
|
|
215
|
+
jobs:
|
|
216
|
+
build:
|
|
217
|
+
runs-on: ubuntu-latest
|
|
218
|
+
if: |
|
|
219
|
+
(github.event_name == 'push' && startsWith(github.ref_name, 'drafts/')) ||
|
|
220
|
+
(github.event_name == 'pull_request' && startsWith(github.head_ref, 'drafts/')) ||
|
|
221
|
+
(github.event_name == 'delete' && github.event.ref_type == 'branch' && startsWith(github.event.ref, 'drafts/')) ||
|
|
222
|
+
github.event_name == 'workflow_dispatch'
|
|
223
|
+
permissions:
|
|
224
|
+
contents: write
|
|
225
|
+
env:
|
|
226
|
+
BRANCH_NAME: \${{ inputs.branch || github.head_ref || github.ref_name }}
|
|
227
|
+
DRAFT_BRANCH_PREFIX: drafts/
|
|
228
|
+
steps:
|
|
229
|
+
- name: Checkout main
|
|
230
|
+
uses: actions/checkout@v4
|
|
231
|
+
with:
|
|
232
|
+
ref: main
|
|
233
|
+
|
|
234
|
+
- name: Setup pnpm
|
|
235
|
+
uses: pnpm/action-setup@v4
|
|
236
|
+
|
|
237
|
+
- name: Setup Node
|
|
238
|
+
uses: actions/setup-node@v4
|
|
239
|
+
with:
|
|
240
|
+
node-version: 20
|
|
241
|
+
cache: pnpm
|
|
242
|
+
|
|
243
|
+
- name: Install dependencies
|
|
244
|
+
run: pnpm install
|
|
245
|
+
|
|
246
|
+
- name: Configure git identity
|
|
247
|
+
run: |
|
|
248
|
+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
249
|
+
git config --global user.name "github-actions[bot]"
|
|
250
|
+
|
|
251
|
+
- name: Checkout gh-pages store to staging
|
|
252
|
+
run: |
|
|
253
|
+
if git fetch origin gh-pages:gh-pages 2>/dev/null; then
|
|
254
|
+
git worktree add .gh-pages-staging gh-pages
|
|
255
|
+
else
|
|
256
|
+
echo "gh-pages store does not exist yet, starting fresh"
|
|
257
|
+
git worktree add --orphan -b gh-pages .gh-pages-staging
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
- name: Resolve preview directory name
|
|
261
|
+
run: |
|
|
262
|
+
if [ "\${{ github.event_name }}" = "delete" ]; then
|
|
263
|
+
BRANCH_NAME="\${{ github.event.ref }}"
|
|
264
|
+
fi
|
|
265
|
+
# Strip the draft prefix so branch "drafts/my-site" maps to "/my-site/".
|
|
266
|
+
PREVIEW_DIR="\${BRANCH_NAME#\${DRAFT_BRANCH_PREFIX}}"
|
|
267
|
+
if [ -z "$PREVIEW_DIR" ] || [ "$PREVIEW_DIR" = "$BRANCH_NAME" ]; then
|
|
268
|
+
PREVIEW_DIR="$BRANCH_NAME"
|
|
269
|
+
fi
|
|
270
|
+
echo "PREVIEW_DIR=$PREVIEW_DIR" >> "$GITHUB_ENV"
|
|
271
|
+
|
|
272
|
+
- name: Stage branch content
|
|
273
|
+
if: github.event_name != 'delete'
|
|
274
|
+
run: |
|
|
275
|
+
git clone --depth 1 --branch "$BRANCH_NAME" \\
|
|
276
|
+
"https://x-access-token:\${{ secrets.GITHUB_TOKEN }}@github.com/\${{ github.repository }}.git" \\
|
|
277
|
+
/tmp/branch-content
|
|
278
|
+
rm -rf /tmp/branch-content/.git
|
|
279
|
+
# The CLI embeds this workflow on the draft branch so the push event
|
|
280
|
+
# can resolve it. Strip it back out so it never ships into the preview.
|
|
281
|
+
rm -rf /tmp/branch-content/.github
|
|
282
|
+
rm -rf ".gh-pages-staging/\${PREVIEW_DIR}"
|
|
283
|
+
mkdir -p ".gh-pages-staging/\${PREVIEW_DIR}"
|
|
284
|
+
cp -a /tmp/branch-content/. ".gh-pages-staging/\${PREVIEW_DIR}/"
|
|
285
|
+
|
|
286
|
+
- name: Remove deleted branch's directory
|
|
287
|
+
if: github.event_name == 'delete'
|
|
288
|
+
run: rm -rf ".gh-pages-staging/\${PREVIEW_DIR}"
|
|
289
|
+
|
|
290
|
+
- name: Build index site
|
|
291
|
+
run: pnpm build
|
|
292
|
+
env:
|
|
293
|
+
PAGES_DIR: \${{ github.workspace }}/.gh-pages-staging
|
|
294
|
+
DESIGN_DRAFTS_PREFIX: drafts/
|
|
295
|
+
DESIGN_DRAFTS_REPO: \${{ github.repository }}
|
|
296
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
297
|
+
|
|
298
|
+
- name: Copy index site into staging
|
|
299
|
+
run: |
|
|
300
|
+
cp -r dist/client/* .gh-pages-staging/ 2>/dev/null || true
|
|
301
|
+
touch .gh-pages-staging/.nojekyll
|
|
302
|
+
|
|
303
|
+
- name: Persist preview store to gh-pages
|
|
304
|
+
run: |
|
|
305
|
+
cd .gh-pages-staging
|
|
306
|
+
git add -A
|
|
307
|
+
git commit -m "deploy: \${PREVIEW_DIR}" || echo "no changes to persist"
|
|
308
|
+
git push origin HEAD:gh-pages
|
|
309
|
+
|
|
310
|
+
- name: Upload Pages artifact
|
|
311
|
+
uses: actions/upload-pages-artifact@v3
|
|
312
|
+
with:
|
|
313
|
+
path: .gh-pages-staging
|
|
314
|
+
|
|
315
|
+
deploy:
|
|
316
|
+
needs: build
|
|
317
|
+
runs-on: ubuntu-latest
|
|
318
|
+
permissions:
|
|
319
|
+
pages: write
|
|
320
|
+
id-token: write
|
|
321
|
+
environment:
|
|
322
|
+
name: github-pages
|
|
323
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
324
|
+
steps:
|
|
325
|
+
- name: Deploy to GitHub Pages
|
|
326
|
+
id: deployment
|
|
327
|
+
uses: actions/deploy-pages@v4
|
|
328
|
+
`;
|
|
329
|
+
const GITIGNORE = `node_modules
|
|
330
|
+
dist
|
|
331
|
+
.gh-pages-staging
|
|
332
|
+
`;
|
|
333
|
+
/**
|
|
334
|
+
* The starter manifest written by \`init draft\`. The push command reads the
|
|
335
|
+
* \`prompt\` field for the commit trailer.
|
|
336
|
+
*/
|
|
337
|
+
function draftConfig(siteName) {
|
|
338
|
+
return JSON.stringify({
|
|
339
|
+
siteName,
|
|
340
|
+
prompt: ""
|
|
341
|
+
}, null, 2) + "\n";
|
|
342
|
+
}
|
|
343
|
+
const DRAFT_INDEX_HTML = `<!doctype html>
|
|
344
|
+
<html lang="en">
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="utf-8" />
|
|
347
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
348
|
+
<title>Draft preview</title>
|
|
349
|
+
</head>
|
|
350
|
+
<body>
|
|
351
|
+
<main>
|
|
352
|
+
<h1>Draft preview</h1>
|
|
353
|
+
<p>Replace this with your design draft, then run <code>design-drafts</code> to publish.</p>
|
|
354
|
+
</main>
|
|
355
|
+
</body>
|
|
356
|
+
</html>
|
|
357
|
+
`;
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/init/draft.ts
|
|
360
|
+
/** Writes the file only if it is absent, logging which path it created and
|
|
361
|
+
* which it left untouched. Returns true when a file was written. */
|
|
362
|
+
function writeIfAbsent(filePath, contents) {
|
|
363
|
+
const name = basename(filePath);
|
|
364
|
+
if (existsSync(filePath)) {
|
|
365
|
+
console.log(` skipped ${name} (already exists)`);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
writeFileSync(filePath, contents);
|
|
369
|
+
console.log(` created ${name}`);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
async function initDraft(opts) {
|
|
373
|
+
const targetDir = resolve(opts.path);
|
|
374
|
+
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
375
|
+
let siteName = await promptAndPersist(opts.siteName, "site-name", localConfigPath, "Site name for this draft:");
|
|
376
|
+
const validation = validateSiteName(siteName);
|
|
377
|
+
if (!validation.ok) {
|
|
378
|
+
const fixed = validation.suggestion ?? slugifySiteName(siteName);
|
|
379
|
+
console.warn(`"${siteName}" is not a valid site-name (${validation.reason}); using "${fixed}".`);
|
|
380
|
+
siteName = fixed;
|
|
381
|
+
}
|
|
382
|
+
console.log(`\nScaffolding draft "${siteName}" in ${targetDir}:`);
|
|
383
|
+
const wroteManifest = writeIfAbsent(join(targetDir, "draft.config.json"), draftConfig(siteName));
|
|
384
|
+
const wroteIndex = writeIfAbsent(join(targetDir, "index.html"), DRAFT_INDEX_HTML);
|
|
385
|
+
if (!wroteManifest && !wroteIndex) {
|
|
386
|
+
console.log(`\nDraft "${siteName}" was already scaffolded; nothing to write.`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
console.log(`\nDraft "${siteName}" ready.\nEdit index.html, then run \`design-drafts\` here to publish a preview.`);
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/validate.ts
|
|
393
|
+
const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
394
|
+
const PREFIX_PATTERN = /^[A-Za-z0-9._/-]*$/;
|
|
395
|
+
const REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
|
|
396
|
+
function validateRepo(repo) {
|
|
397
|
+
if (!repo.trim()) return {
|
|
398
|
+
ok: false,
|
|
399
|
+
reason: "repo must not be empty"
|
|
400
|
+
};
|
|
401
|
+
if (!REPO_PATTERN.test(repo)) return {
|
|
402
|
+
ok: false,
|
|
403
|
+
reason: "repo must be in \"owner/name\" form using only letters, digits, \".\", \"_\", or \"-\""
|
|
404
|
+
};
|
|
405
|
+
return { ok: true };
|
|
406
|
+
}
|
|
407
|
+
function validatePrefix(prefix) {
|
|
408
|
+
if (!PREFIX_PATTERN.test(prefix)) return {
|
|
409
|
+
ok: false,
|
|
410
|
+
reason: "prefix may contain only letters, digits, \"/\", \".\", \"_\", or \"-\""
|
|
411
|
+
};
|
|
412
|
+
return { ok: true };
|
|
413
|
+
}
|
|
414
|
+
function validateTemplateRef(ref) {
|
|
415
|
+
if (!REF_PATTERN.test(ref)) return {
|
|
416
|
+
ok: false,
|
|
417
|
+
reason: "template-ref may contain only letters, digits, \"/\", \".\", \"_\", or \"-\""
|
|
418
|
+
};
|
|
419
|
+
return { ok: true };
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/init/rewrites.ts
|
|
423
|
+
const CATALOG_PREFIX = "catalog:";
|
|
424
|
+
function resolveDepBlock(block, catalog) {
|
|
425
|
+
if (!block) return block;
|
|
426
|
+
const resolved = {};
|
|
427
|
+
for (const [name, spec] of Object.entries(block)) {
|
|
428
|
+
if (!spec.startsWith(CATALOG_PREFIX)) {
|
|
429
|
+
resolved[name] = spec;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (spec.slice(8)) throw new Error(`Cannot resolve "${name}": named catalogs ("${spec}") are not supported`);
|
|
433
|
+
const version = catalog[name];
|
|
434
|
+
if (!version) throw new Error(`Cannot resolve "${name}": "${spec}" but no catalog entry exists for it`);
|
|
435
|
+
resolved[name] = version;
|
|
436
|
+
}
|
|
437
|
+
return resolved;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Rewrites a site package.json for life in a standalone host repo: every
|
|
441
|
+
* `catalog:` dependency specifier is replaced with the concrete version from
|
|
442
|
+
* the canonical workspace catalog, the nx project config is removed, and a
|
|
443
|
+
* `packageManager` field is set (so `pnpm/action-setup` can resolve a version
|
|
444
|
+
* in the generated repo — without it the first deploy fails).
|
|
445
|
+
*/
|
|
446
|
+
function resolveSitePackageJson(raw, catalog, packageManager) {
|
|
447
|
+
const pkg = JSON.parse(raw);
|
|
448
|
+
const next = { ...pkg };
|
|
449
|
+
next.dependencies = resolveDepBlock(pkg.dependencies, catalog);
|
|
450
|
+
next.devDependencies = resolveDepBlock(pkg.devDependencies, catalog);
|
|
451
|
+
delete next.nx;
|
|
452
|
+
if (packageManager) next.packageManager = packageManager;
|
|
453
|
+
if (next.dependencies && Object.keys(next.dependencies).length === 0) delete next.dependencies;
|
|
454
|
+
if (next.devDependencies && Object.keys(next.devDependencies).length === 0) delete next.devDependencies;
|
|
455
|
+
return JSON.stringify(next, null, 2) + "\n";
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Parses the `catalog:` block out of a canonical pnpm-workspace.yaml. We keep
|
|
459
|
+
* the parser intentionally small (flat `key: value` pairs under `catalog:`)
|
|
460
|
+
* rather than pulling in a YAML dependency — the catalog is always a flat map
|
|
461
|
+
* of package name to version string.
|
|
462
|
+
*/
|
|
463
|
+
function parseCatalog(workspaceYaml) {
|
|
464
|
+
const lines = workspaceYaml.split("\n");
|
|
465
|
+
const catalog = {};
|
|
466
|
+
let inCatalog = false;
|
|
467
|
+
let catalogIndent = 0;
|
|
468
|
+
for (const line of lines) {
|
|
469
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
|
|
470
|
+
const indent = line.length - line.trimStart().length;
|
|
471
|
+
if (!inCatalog) {
|
|
472
|
+
if (line.trim() === "catalog:") {
|
|
473
|
+
inCatalog = true;
|
|
474
|
+
catalogIndent = indent;
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (indent <= catalogIndent) break;
|
|
479
|
+
const match = line.trim().match(/^['"]?([^'":]+)['"]?\s*:\s*(.+)$/);
|
|
480
|
+
if (match) {
|
|
481
|
+
const name = match[1].trim();
|
|
482
|
+
catalog[name] = match[2].trim().replace(/^['"]|['"]$/g, "");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return catalog;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Rewrites the Vite `base` so asset URLs resolve under the host repo's Pages
|
|
489
|
+
* subpath (https://<owner>.github.io/<repo>/). Accepts `org/repo` or a bare
|
|
490
|
+
* repo name and uses just the repo segment.
|
|
491
|
+
*/
|
|
492
|
+
function rewriteViteBase(source, repo) {
|
|
493
|
+
const base = `/${repo.includes("/") ? repo.split("/").pop() : repo}/`;
|
|
494
|
+
const replaced = source.replace(/base:\s*(['"])(?:[^'"]*)\1/, `base: '${base}'`);
|
|
495
|
+
if (replaced === source) throw new Error("Could not find a `base:` option in vite.config to rewrite");
|
|
496
|
+
return replaced;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Folds the canonical base tsconfig's compilerOptions into the site tsconfig and
|
|
500
|
+
* drops `extends`, so the host needs no `tsconfig.base.json` at its root. The
|
|
501
|
+
* site's own compilerOptions win on conflict.
|
|
502
|
+
*/
|
|
503
|
+
function inlineTsconfig(siteRaw, baseRaw) {
|
|
504
|
+
const site = JSON.parse(siteRaw);
|
|
505
|
+
const base = JSON.parse(baseRaw);
|
|
506
|
+
const merged = { ...site };
|
|
507
|
+
delete merged.extends;
|
|
508
|
+
merged.compilerOptions = {
|
|
509
|
+
...base.compilerOptions ?? {},
|
|
510
|
+
...site.compilerOptions ?? {}
|
|
511
|
+
};
|
|
512
|
+
return JSON.stringify(merged, null, 2) + "\n";
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Picks the template ref to check the site out from. An explicit override wins;
|
|
516
|
+
* otherwise prefer the release tag matching the CLI version when it exists,
|
|
517
|
+
* falling back to the default branch.
|
|
518
|
+
*/
|
|
519
|
+
function resolveTemplateRef(opts) {
|
|
520
|
+
if (opts.override) return opts.override;
|
|
521
|
+
const tag = `v${opts.cliVersion}`;
|
|
522
|
+
if (opts.tagExists(tag)) return tag;
|
|
523
|
+
return opts.defaultBranch;
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/init/host.ts
|
|
527
|
+
const CANONICAL_REPO = "AgentEnder/design-drafts";
|
|
528
|
+
const CANONICAL_URL = `https://github.com/${CANONICAL_REPO}.git`;
|
|
529
|
+
const DEFAULT_BRANCH$1 = "main";
|
|
530
|
+
const SITE_SUBDIR = "packages/site";
|
|
531
|
+
const WORKFLOW_PATH$1 = ".github/workflows/deploy-preview.yml";
|
|
532
|
+
const TRANSFORMED = new Set([
|
|
533
|
+
"package.json",
|
|
534
|
+
"vite.config.ts",
|
|
535
|
+
"tsconfig.json"
|
|
536
|
+
]);
|
|
537
|
+
function repoName(repo) {
|
|
538
|
+
return repo.includes("/") ? repo.split("/").pop() : repo;
|
|
539
|
+
}
|
|
540
|
+
function detectRemoteRepo(targetDir) {
|
|
541
|
+
const url = capture("git remote get-url origin", targetDir);
|
|
542
|
+
if (!url) return void 0;
|
|
543
|
+
return url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)?.[1];
|
|
544
|
+
}
|
|
545
|
+
/** True when the target already carries our generated workflow. */
|
|
546
|
+
function alreadyConfigured(targetDir) {
|
|
547
|
+
const workflow = join(targetDir, WORKFLOW_PATH$1);
|
|
548
|
+
if (!existsSync(workflow)) return false;
|
|
549
|
+
return readFileSync(workflow, "utf-8").includes(HOST_MARKER);
|
|
550
|
+
}
|
|
551
|
+
/** Sparse-checks-out the canonical site source into a temp dir, returning that
|
|
552
|
+
* path. Cone mode (the recommended default) always materialises top-level
|
|
553
|
+
* files, so the root files we read to resolve the site (pnpm-workspace.yaml's
|
|
554
|
+
* catalog and tsconfig.base.json) come along without listing them, while the
|
|
555
|
+
* sibling packages stay excluded. */
|
|
556
|
+
function sparseCheckout(ref) {
|
|
557
|
+
const tmp = mkdtempSync(join(tmpdir(), "design-drafts-host-"));
|
|
558
|
+
try {
|
|
559
|
+
exec(`git clone --filter=blob:none --no-checkout ${CANONICAL_URL} .`, tmp);
|
|
560
|
+
exec(`git sparse-checkout set ${SITE_SUBDIR}`, tmp);
|
|
561
|
+
exec(`git checkout ${ref}`, tmp);
|
|
562
|
+
} catch {
|
|
563
|
+
rmSync(tmp, {
|
|
564
|
+
recursive: true,
|
|
565
|
+
force: true
|
|
566
|
+
});
|
|
567
|
+
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).`);
|
|
568
|
+
}
|
|
569
|
+
return tmp;
|
|
570
|
+
}
|
|
571
|
+
function writeScaffold(checkout, targetDir, repo) {
|
|
572
|
+
const siteDir = join(checkout, SITE_SUBDIR);
|
|
573
|
+
cpSync(siteDir, targetDir, {
|
|
574
|
+
recursive: true,
|
|
575
|
+
filter: (src) => !TRANSFORMED.has(src.slice(siteDir.length + 1))
|
|
576
|
+
});
|
|
577
|
+
const catalog = parseCatalog(readFileSync(join(checkout, "pnpm-workspace.yaml"), "utf-8"));
|
|
578
|
+
const rootPkg = JSON.parse(readFileSync(join(checkout, "package.json"), "utf-8"));
|
|
579
|
+
writeFileSync(join(targetDir, "package.json"), resolveSitePackageJson(readFileSync(join(siteDir, "package.json"), "utf-8"), catalog, rootPkg.packageManager));
|
|
580
|
+
const viteRaw = readFileSync(join(siteDir, "vite.config.ts"), "utf-8");
|
|
581
|
+
writeFileSync(join(targetDir, "vite.config.ts"), rewriteViteBase(viteRaw, repo));
|
|
582
|
+
writeFileSync(join(targetDir, "tsconfig.json"), inlineTsconfig(readFileSync(join(siteDir, "tsconfig.json"), "utf-8"), readFileSync(join(checkout, "tsconfig.base.json"), "utf-8")));
|
|
583
|
+
const workflowDest = join(targetDir, WORKFLOW_PATH$1);
|
|
584
|
+
mkdirSync(dirname(workflowDest), { recursive: true });
|
|
585
|
+
writeFileSync(workflowDest, DEPLOY_WORKFLOW);
|
|
586
|
+
writeFileSync(join(targetDir, ".nojekyll"), "");
|
|
587
|
+
writeFileSync(join(targetDir, ".gitignore"), GITIGNORE);
|
|
588
|
+
}
|
|
589
|
+
function commitScaffold(targetDir) {
|
|
590
|
+
if (!existsSync(join(targetDir, ".git"))) exec(`git init -b ${DEFAULT_BRANCH$1}`, targetDir);
|
|
591
|
+
exec("git add .", targetDir);
|
|
592
|
+
if (capture("git status --porcelain", targetDir)) exec("git commit -m \"chore: scaffold design-drafts host\"", targetDir);
|
|
593
|
+
}
|
|
594
|
+
/** Best-effort GitHub wiring. Never hard-fails: the local scaffold already
|
|
595
|
+
* succeeded, so any gh gap becomes printed manual steps. Returns true when the
|
|
596
|
+
* repo is set up and pushed (so the caller can discard a throwaway scaffold). */
|
|
597
|
+
function configureGitHub(targetDir, repo, isPrivate) {
|
|
598
|
+
if (!succeeds("gh auth status", targetDir)) {
|
|
599
|
+
printManualSteps(repo, targetDir, isPrivate, "GitHub CLI (`gh`) is not installed or authenticated");
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
const visibility = isPrivate ? "--private" : "--public";
|
|
603
|
+
if (!succeeds(`gh repo view ${repo}`, targetDir)) {
|
|
604
|
+
if (!succeeds(`gh repo create ${repo} ${visibility} --source ${JSON.stringify(targetDir)} --remote origin --push`, targetDir)) {
|
|
605
|
+
printManualSteps(repo, targetDir, isPrivate, `could not create ${repo}`);
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
enablePages(targetDir, repo, isPrivate);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
if (!detectRemoteRepo(targetDir)) exec(`git remote add origin ${githubRemoteUrl(repo, targetDir)}`, targetDir);
|
|
612
|
+
if (!succeeds(`git push -u origin ${DEFAULT_BRANCH$1}`, targetDir)) {
|
|
613
|
+
printManualSteps(repo, targetDir, isPrivate, `could not push to ${repo} (does its ${DEFAULT_BRANCH$1} already have commits?)`);
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
enablePages(targetDir, repo, isPrivate);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
/** Sets the Pages source to "GitHub Actions" (build_type=workflow). */
|
|
620
|
+
function enablePages(targetDir, repo, isPrivate) {
|
|
621
|
+
const create = `gh api -X POST /repos/${repo}/pages -f build_type=workflow`;
|
|
622
|
+
const update = `gh api -X PUT /repos/${repo}/pages -f build_type=workflow`;
|
|
623
|
+
if (succeeds(create, targetDir) || succeeds(update, targetDir)) {
|
|
624
|
+
console.log(`Enabled GitHub Pages (Actions source) on ${repo}.`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
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.`);
|
|
628
|
+
}
|
|
629
|
+
function printManualSteps(repo, targetDir, isPrivate, reason) {
|
|
630
|
+
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`);
|
|
631
|
+
}
|
|
632
|
+
/** Resolves repo visibility, prompting interactively when not preset. */
|
|
633
|
+
async function resolveVisibility(opts) {
|
|
634
|
+
if (typeof opts.private === "boolean") return opts.private;
|
|
635
|
+
const choice = await select({
|
|
636
|
+
message: "Repository visibility:",
|
|
637
|
+
options: [{
|
|
638
|
+
value: false,
|
|
639
|
+
label: "Public",
|
|
640
|
+
hint: "required for free GitHub Pages"
|
|
641
|
+
}, {
|
|
642
|
+
value: true,
|
|
643
|
+
label: "Private",
|
|
644
|
+
hint: "Pages needs GitHub Pro/Team"
|
|
645
|
+
}],
|
|
646
|
+
initialValue: false
|
|
647
|
+
});
|
|
648
|
+
if (isCancel(choice)) process.exit(1);
|
|
649
|
+
return choice;
|
|
650
|
+
}
|
|
651
|
+
/** Confirmation gate before the outward GitHub actions (create/push/Pages). */
|
|
652
|
+
async function confirmGitHub(repo, isPrivate) {
|
|
653
|
+
const answer = await confirm({ message: `Create ${isPrivate ? "private" : "public"} repo ${repo} on GitHub, push the scaffold, and enable Pages?` });
|
|
654
|
+
if (isCancel(answer)) process.exit(1);
|
|
655
|
+
return answer === true;
|
|
656
|
+
}
|
|
657
|
+
async function initHost(opts) {
|
|
658
|
+
const usingTmp = !opts.path;
|
|
659
|
+
const targetDir = opts.path ? resolve(opts.path) : mkdtempSync(join(tmpdir(), "design-drafts-host-scaffold-"));
|
|
660
|
+
if (opts.path && !existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
661
|
+
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");
|
|
662
|
+
const repoCheck = validateRepo(repo);
|
|
663
|
+
if (!repoCheck.ok) {
|
|
664
|
+
console.error(`Invalid repo "${repo}": ${repoCheck.reason}`);
|
|
665
|
+
process.exit(1);
|
|
666
|
+
}
|
|
667
|
+
if (opts.templateRef) {
|
|
668
|
+
const refCheck = validateTemplateRef(opts.templateRef);
|
|
669
|
+
if (!refCheck.ok) {
|
|
670
|
+
console.error(`Invalid template-ref "${opts.templateRef}": ${refCheck.reason}`);
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (opts.path && alreadyConfigured(targetDir)) {
|
|
675
|
+
console.log(`Host already configured at ${targetDir}; re-ensuring Pages.`);
|
|
676
|
+
enablePages(targetDir, repo, opts.private ?? false);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const ref = resolveTemplateRef({
|
|
680
|
+
override: opts.templateRef,
|
|
681
|
+
cliVersion: opts.cliVersion,
|
|
682
|
+
defaultBranch: DEFAULT_BRANCH$1,
|
|
683
|
+
tagExists: (tag) => Boolean(capture(`git ls-remote --tags ${CANONICAL_URL} ${tag}`, targetDir))
|
|
684
|
+
});
|
|
685
|
+
console.log(`Scaffolding host "${repoName(repo)}" from ${CANONICAL_REPO}@${ref}...`);
|
|
686
|
+
const checkout = sparseCheckout(ref);
|
|
687
|
+
try {
|
|
688
|
+
writeScaffold(checkout, targetDir, repo);
|
|
689
|
+
} finally {
|
|
690
|
+
rmSync(checkout, {
|
|
691
|
+
recursive: true,
|
|
692
|
+
force: true
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
commitScaffold(targetDir);
|
|
696
|
+
const isPrivate = await resolveVisibility(opts);
|
|
697
|
+
if (!(opts.yes || await confirmGitHub(repo, isPrivate))) {
|
|
698
|
+
console.log(`\nStopped before GitHub setup. The scaffold is at ${targetDir}.`);
|
|
699
|
+
printManualSteps(repo, targetDir, isPrivate, "cancelled");
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const pushed = configureGitHub(targetDir, repo, isPrivate);
|
|
703
|
+
if (pushed) persistHomeConfigValue("repo", repo);
|
|
704
|
+
if (!usingTmp) {
|
|
705
|
+
console.log(`\nHost scaffolded at ${targetDir}.`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (pushed) {
|
|
709
|
+
rmSync(targetDir, {
|
|
710
|
+
recursive: true,
|
|
711
|
+
force: true
|
|
712
|
+
});
|
|
713
|
+
console.log(`\nHost ready at https://github.com/${repo} (local scaffold discarded).`);
|
|
714
|
+
} else console.log(`\nKept the scaffold at ${targetDir} so you can finish the push.`);
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/init/init.ts
|
|
718
|
+
/**
|
|
719
|
+
* The "from nothing to ready-to-publish" path. Scaffolds a host the first time
|
|
720
|
+
* (detected by the absence of a configured `repo`), then scaffolds a draft in
|
|
721
|
+
* the target directory. Stops short of auto-pushing a placeholder — it prints
|
|
722
|
+
* the single command to publish instead.
|
|
723
|
+
*/
|
|
724
|
+
async function init(opts) {
|
|
725
|
+
if (!Boolean(opts.repo ?? readHomeConfigValue("repo"))) {
|
|
726
|
+
console.log("No host configured yet — setting one up first.\n");
|
|
727
|
+
await initHost({
|
|
728
|
+
repo: opts.repo,
|
|
729
|
+
templateRef: opts.templateRef,
|
|
730
|
+
cliVersion: opts.cliVersion
|
|
731
|
+
});
|
|
732
|
+
console.log("");
|
|
733
|
+
}
|
|
734
|
+
await initDraft({
|
|
735
|
+
path: opts.path,
|
|
736
|
+
siteName: opts.siteName
|
|
737
|
+
});
|
|
738
|
+
console.log("\nWhen the draft looks right, run `design-drafts` to publish.");
|
|
739
|
+
}
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region src/index.ts
|
|
742
|
+
const CLI_VERSION = version;
|
|
743
|
+
const DEFAULT_BRANCH = "main";
|
|
744
|
+
const WORKFLOW_PATH = ".github/workflows/deploy-preview.yml";
|
|
745
|
+
async function embedDeployWorkflow(repo, tmpDir) {
|
|
746
|
+
const url = `https://raw.githubusercontent.com/${repo}/${DEFAULT_BRANCH}/${WORKFLOW_PATH}`;
|
|
747
|
+
let contents;
|
|
748
|
+
try {
|
|
749
|
+
const response = await fetch(url);
|
|
750
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
751
|
+
contents = await response.text();
|
|
752
|
+
} catch (error) {
|
|
753
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
754
|
+
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}`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const destination = join(tmpDir, WORKFLOW_PATH);
|
|
758
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
759
|
+
writeFileSync(destination, contents);
|
|
760
|
+
}
|
|
761
|
+
function getSourceMetadata(sourcePath) {
|
|
762
|
+
return {
|
|
763
|
+
sha: capture("git rev-parse HEAD", sourcePath),
|
|
764
|
+
repo: capture("git remote get-url origin", sourcePath),
|
|
765
|
+
authorName: capture("git config user.name", sourcePath),
|
|
766
|
+
authorEmail: capture("git config user.email", sourcePath)
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function hashManifest(manifestPath) {
|
|
770
|
+
try {
|
|
771
|
+
const content = readFileSync(manifestPath, "utf-8");
|
|
772
|
+
return createHash("sha256").update(content).digest("hex");
|
|
773
|
+
} catch {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function readManifestPrompt(manifestPath, sourcePath) {
|
|
778
|
+
let raw;
|
|
779
|
+
try {
|
|
780
|
+
raw = readFileSync(manifestPath, "utf-8");
|
|
781
|
+
} catch {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
let parsed;
|
|
785
|
+
try {
|
|
786
|
+
parsed = JSON.parse(raw);
|
|
787
|
+
} catch {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (!parsed || typeof parsed !== "object" || !("prompt" in parsed)) return;
|
|
791
|
+
const prompt = parsed.prompt;
|
|
792
|
+
if (typeof prompt !== "string" || !prompt.trim()) return;
|
|
793
|
+
const candidatePath = isAbsolute(prompt) ? prompt : join(sourcePath, prompt);
|
|
794
|
+
try {
|
|
795
|
+
const stat = statSync(candidatePath);
|
|
796
|
+
if (stat.isFile() && stat.size < 200) {
|
|
797
|
+
const inline = readFileSync(candidatePath, "utf-8").trim();
|
|
798
|
+
if (inline) return inline.replace(/\s+/g, " ");
|
|
799
|
+
}
|
|
800
|
+
} catch {}
|
|
801
|
+
return prompt.replace(/\s+/g, " ");
|
|
802
|
+
}
|
|
803
|
+
function sanitizeAuthorName(name) {
|
|
804
|
+
return name.replace(/</g, "(").replace(/>/g, ")");
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Builds the structured commit message used for every draft push.
|
|
808
|
+
*
|
|
809
|
+
* Format:
|
|
810
|
+
*
|
|
811
|
+
* push: <site-name>
|
|
812
|
+
*
|
|
813
|
+
* source-sha: <sha>
|
|
814
|
+
* source-repo: <repo>
|
|
815
|
+
* author: <Name <email>>
|
|
816
|
+
* prompt: <one-line summary or path>
|
|
817
|
+
* draft-config-sha: <sha256 of draft.config.json>
|
|
818
|
+
*
|
|
819
|
+
* Each trailer line is omitted when its data is unavailable (no source git
|
|
820
|
+
* repo, no manifest, no `prompt` field, etc.). The site-name subject line is
|
|
821
|
+
* always present.
|
|
822
|
+
*/
|
|
823
|
+
function buildCommitMessage(opts) {
|
|
824
|
+
const lines = [`push: ${opts.siteName}`, ""];
|
|
825
|
+
const { metadata } = opts;
|
|
826
|
+
if (metadata.sha) lines.push(`source-sha: ${metadata.sha}`);
|
|
827
|
+
if (metadata.repo) lines.push(`source-repo: ${metadata.repo}`);
|
|
828
|
+
if (metadata.authorName && metadata.authorEmail) {
|
|
829
|
+
const safeName = sanitizeAuthorName(metadata.authorName);
|
|
830
|
+
lines.push(`author: ${safeName} <${metadata.authorEmail}>`);
|
|
831
|
+
}
|
|
832
|
+
if (opts.prompt) lines.push(`prompt: ${opts.prompt}`);
|
|
833
|
+
if (opts.draftConfigSha) lines.push(`draft-config-sha: ${opts.draftConfigSha}`);
|
|
834
|
+
if (lines.length === 2) lines.pop();
|
|
835
|
+
return lines.join("\n") + "\n";
|
|
836
|
+
}
|
|
837
|
+
async function pushHandler(args) {
|
|
838
|
+
const repo = await promptForValue(args.repo, "repo", "GitHub repo (org/repo):", (v) => validateRepo(v).ok ? void 0 : "use \"owner/name\" form");
|
|
839
|
+
const siteName = await promptAndPersist(args["site-name"], "site-name", localConfigPath, "Site name for this preview:");
|
|
840
|
+
const prefix = resolvePrefix(args.prefix);
|
|
841
|
+
const sourcePath = resolve(args.path ?? ".");
|
|
842
|
+
const validation = validateSiteName(siteName);
|
|
843
|
+
if (!validation.ok) {
|
|
844
|
+
console.error(`Invalid site-name "${siteName}": ${validation.reason}`);
|
|
845
|
+
if (validation.suggestion) console.error(`Try: ${validation.suggestion}`);
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
const repoCheck = validateRepo(repo);
|
|
849
|
+
if (!repoCheck.ok) {
|
|
850
|
+
console.error(`Invalid repo "${repo}": ${repoCheck.reason}`);
|
|
851
|
+
process.exit(1);
|
|
852
|
+
}
|
|
853
|
+
const prefixCheck = validatePrefix(prefix);
|
|
854
|
+
if (!prefixCheck.ok) {
|
|
855
|
+
console.error(`Invalid prefix "${prefix}": ${prefixCheck.reason}`);
|
|
856
|
+
process.exit(1);
|
|
857
|
+
}
|
|
858
|
+
if (!existsSync(sourcePath)) {
|
|
859
|
+
console.error(`Path does not exist: ${sourcePath}`);
|
|
860
|
+
process.exit(1);
|
|
861
|
+
}
|
|
862
|
+
const manifestPath = join(sourcePath, "draft.config.json");
|
|
863
|
+
const metadata = getSourceMetadata(sourcePath);
|
|
864
|
+
const draftConfigSha = hashManifest(manifestPath);
|
|
865
|
+
const commitMessage = buildCommitMessage({
|
|
866
|
+
siteName,
|
|
867
|
+
metadata,
|
|
868
|
+
prompt: readManifestPrompt(manifestPath, sourcePath),
|
|
869
|
+
draftConfigSha
|
|
870
|
+
});
|
|
871
|
+
const branchName = `${prefix}${siteName}`;
|
|
872
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "design-drafts-"));
|
|
873
|
+
const messageFile = join(tmpdir(), `design-drafts-msg-${process.pid}.txt`);
|
|
874
|
+
writeFileSync(messageFile, commitMessage);
|
|
875
|
+
try {
|
|
876
|
+
cpSync(sourcePath, tmpDir, { recursive: true });
|
|
877
|
+
await embedDeployWorkflow(repo, tmpDir);
|
|
878
|
+
exec(`git init -b ${branchName}`, tmpDir);
|
|
879
|
+
exec(`git remote add origin ${githubRemoteUrl(repo, tmpDir)}`, tmpDir);
|
|
880
|
+
exec("git add .", tmpDir);
|
|
881
|
+
exec(`git commit -F ${JSON.stringify(messageFile)}`, tmpDir);
|
|
882
|
+
try {
|
|
883
|
+
exec(`git push --force origin ${branchName}`, tmpDir);
|
|
884
|
+
} catch {
|
|
885
|
+
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.`);
|
|
886
|
+
}
|
|
887
|
+
persistHomeConfigValue("repo", repo);
|
|
888
|
+
console.log(`\nPushed "${branchName}" to ${repo}`);
|
|
889
|
+
} finally {
|
|
890
|
+
rmSync(tmpDir, {
|
|
891
|
+
recursive: true,
|
|
892
|
+
force: true
|
|
893
|
+
});
|
|
894
|
+
rmSync(messageFile, { force: true });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
await cli("design-drafts", {
|
|
898
|
+
description: "Push static site previews as branches to a design-drafts repo",
|
|
899
|
+
builder: (args) => args.option("repo", {
|
|
900
|
+
type: "string",
|
|
901
|
+
description: "GitHub repo in org/repo form"
|
|
902
|
+
}).option("site-name", {
|
|
903
|
+
type: "string",
|
|
904
|
+
description: "Name for this site preview (becomes the branch name)"
|
|
905
|
+
}).option("template-ref", {
|
|
906
|
+
type: "string",
|
|
907
|
+
description: "Ref of the canonical repo to scaffold the host site from (default: matching version tag, else main)"
|
|
908
|
+
}).env({ prefix: "DESIGN_DRAFTS" }).config(homeJsonProvider).config(ConfigurationProviders.JsonFile(CONFIG_FILENAME)).command("init", {
|
|
909
|
+
description: "Set up a host (if needed) and scaffold a draft, ready to publish",
|
|
910
|
+
builder: (initArgs) => initArgs.command("host", {
|
|
911
|
+
description: "Scaffold a GitHub repo to host draft previews",
|
|
912
|
+
builder: (b) => b.option("path", {
|
|
913
|
+
type: "string",
|
|
914
|
+
description: "Persist the scaffold to this directory instead of a throwaway tmpdir"
|
|
915
|
+
}).option("private", {
|
|
916
|
+
type: "boolean",
|
|
917
|
+
description: "Create a private repo (default: prompt; Pages needs Pro/Team for private)"
|
|
918
|
+
}).option("yes", {
|
|
919
|
+
type: "boolean",
|
|
920
|
+
description: "Skip the confirmation prompt before GitHub setup"
|
|
921
|
+
}),
|
|
922
|
+
handler: (a) => runHandler(() => initHost({
|
|
923
|
+
path: a.path,
|
|
924
|
+
repo: a.repo,
|
|
925
|
+
templateRef: a["template-ref"],
|
|
926
|
+
private: a.private,
|
|
927
|
+
yes: a.yes,
|
|
928
|
+
cliVersion: CLI_VERSION
|
|
929
|
+
}))
|
|
930
|
+
}).command("draft", {
|
|
931
|
+
description: "Scaffold a new draft directory",
|
|
932
|
+
builder: (b) => b.positional("path", {
|
|
933
|
+
type: "string",
|
|
934
|
+
default: ".",
|
|
935
|
+
description: "Directory to scaffold the draft into"
|
|
936
|
+
}),
|
|
937
|
+
handler: (a) => runHandler(() => initDraft({
|
|
938
|
+
path: a.path,
|
|
939
|
+
siteName: a["site-name"]
|
|
940
|
+
}))
|
|
941
|
+
}),
|
|
942
|
+
handler: (a) => runHandler(() => init({
|
|
943
|
+
path: ".",
|
|
944
|
+
repo: a.repo,
|
|
945
|
+
siteName: a["site-name"],
|
|
946
|
+
templateRef: a["template-ref"],
|
|
947
|
+
cliVersion: CLI_VERSION
|
|
948
|
+
}))
|
|
949
|
+
}).command("push", {
|
|
950
|
+
alias: ["$0"],
|
|
951
|
+
description: "Push a built directory as a draft preview branch",
|
|
952
|
+
builder: (b) => b.positional("path", {
|
|
953
|
+
type: "string",
|
|
954
|
+
default: ".",
|
|
955
|
+
description: "Directory to push as a site preview"
|
|
956
|
+
}).option("prefix", {
|
|
957
|
+
type: "string",
|
|
958
|
+
description: "Branch prefix used when pushing previews (default: \"drafts/\"). Pass an empty string to push without a prefix."
|
|
959
|
+
}),
|
|
960
|
+
handler: (a) => runHandler(() => pushHandler(a))
|
|
961
|
+
})
|
|
962
|
+
}).forge();
|
|
963
|
+
//#endregion
|
|
964
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,8 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@design-drafts/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for scaffolding and deploying design-drafts previews to GitHub Pages.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"design-drafts": "./bin.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"bin.js",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/AgentEnder/design-drafts.git",
|
|
21
|
+
"directory": "packages/cli"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/AgentEnder/design-drafts/tree/main/packages/cli#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/AgentEnder/design-drafts/issues"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"design-drafts",
|
|
29
|
+
"cli",
|
|
30
|
+
"preview",
|
|
31
|
+
"github-pages",
|
|
32
|
+
"static-site"
|
|
33
|
+
],
|
|
5
34
|
"publishConfig": {
|
|
6
35
|
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@clack/prompts": "^1.1.0",
|
|
39
|
+
"cli-forge": "^1.9.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "18.16.9",
|
|
43
|
+
"tsdown": "^0.21.7",
|
|
44
|
+
"typescript": "5.9.3",
|
|
45
|
+
"vitest": "^4.1.4",
|
|
46
|
+
"yaml": "^2.8.3"
|
|
47
|
+
},
|
|
48
|
+
"nx": {
|
|
49
|
+
"name": "cli"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown src/index.ts --format esm --out-dir dist",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run"
|
|
7
55
|
}
|
|
8
56
|
}
|