vagejs 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +244 -2
- package/dist/audit/a11y.d.ts +13 -0
- package/dist/audit/a11y.js +33 -0
- package/dist/audit/audit.d.ts +31 -0
- package/dist/audit/audit.js +143 -0
- package/dist/audit/budgets.d.ts +22 -0
- package/dist/audit/budgets.js +106 -0
- package/dist/audit/lighthouse.d.ts +3 -0
- package/dist/audit/lighthouse.js +48 -0
- package/dist/audit/remote.d.ts +65 -0
- package/dist/audit/remote.js +123 -0
- package/dist/audit/vitals.d.ts +28 -0
- package/dist/audit/vitals.js +18 -0
- package/dist/checks/check.d.ts +31 -0
- package/dist/checks/check.js +434 -0
- package/dist/checks/integrity.d.ts +36 -0
- package/dist/checks/integrity.js +145 -0
- package/dist/checks/links.d.ts +18 -0
- package/dist/checks/links.js +47 -0
- package/dist/checks/verify.d.ts +35 -0
- package/dist/checks/verify.js +167 -0
- package/dist/cli/create.d.ts +10 -0
- package/dist/cli/create.js +39 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +710 -0
- package/dist/cli/info.d.ts +82 -0
- package/dist/cli/info.js +100 -0
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +28 -0
- package/dist/content/markdown.js +56 -0
- package/dist/content/sanitize.d.ts +16 -0
- package/dist/content/sanitize.js +57 -0
- package/dist/core/build.d.ts +25 -0
- package/dist/core/build.js +54 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +180 -0
- package/dist/core/config.js +260 -0
- package/dist/core/errors.d.ts +7 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/html.d.ts +24 -0
- package/dist/core/html.js +42 -0
- package/dist/core/loader.d.ts +10 -0
- package/dist/core/loader.js +16 -0
- package/dist/core/pages.d.ts +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/project-config.d.ts +3 -0
- package/dist/core/project-config.js +14 -0
- package/dist/core/render.d.ts +53 -0
- package/dist/core/render.js +104 -0
- package/dist/core/routes.d.ts +24 -0
- package/dist/core/routes.js +70 -0
- package/dist/core/site.d.ts +42 -0
- package/dist/core/site.js +182 -0
- package/dist/core/validate.d.ts +12 -0
- package/dist/core/validate.js +36 -0
- package/dist/deploy/cloudflare-pages.d.ts +20 -0
- package/dist/deploy/cloudflare-pages.js +42 -0
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/git.d.ts +20 -0
- package/dist/deploy/git.js +55 -0
- package/dist/deploy/github-pages.d.ts +33 -0
- package/dist/deploy/github-pages.js +99 -0
- package/dist/deploy/headers.d.ts +11 -0
- package/dist/deploy/headers.js +103 -0
- package/dist/deploy/publish.d.ts +45 -0
- package/dist/deploy/publish.js +148 -0
- package/dist/deploy/redirects.d.ts +7 -0
- package/dist/deploy/redirects.js +18 -0
- package/dist/deploy/secrets.d.ts +6 -0
- package/dist/deploy/secrets.js +66 -0
- package/dist/dev/guard.d.ts +9 -0
- package/dist/dev/guard.js +57 -0
- package/dist/dev/listen.d.ts +6 -0
- package/dist/dev/listen.js +13 -0
- package/dist/dev/preview.d.ts +6 -0
- package/dist/dev/preview.js +72 -0
- package/dist/dev/server.d.ts +8 -0
- package/dist/dev/server.js +104 -0
- package/dist/discovery/crawlers.d.ts +13 -0
- package/dist/discovery/crawlers.js +2 -0
- package/dist/discovery/llms.d.ts +12 -0
- package/dist/discovery/llms.js +19 -0
- package/dist/discovery/robots.d.ts +8 -0
- package/dist/discovery/robots.js +40 -0
- package/dist/discovery/sitemap.d.ts +8 -0
- package/dist/discovery/sitemap.js +11 -0
- package/dist/guidelines/monitor.d.ts +55 -0
- package/dist/guidelines/monitor.js +126 -0
- package/dist/guidelines/pack.d.ts +41 -0
- package/dist/guidelines/pack.js +13 -0
- package/dist/guidelines/rule-pack.json +611 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/integrations/api-keys.d.ts +26 -0
- package/dist/integrations/api-keys.js +65 -0
- package/dist/integrations/ga4-data.d.ts +13 -0
- package/dist/integrations/ga4-data.js +39 -0
- package/dist/integrations/google-auth.d.ts +47 -0
- package/dist/integrations/google-auth.js +157 -0
- package/dist/integrations/http.d.ts +15 -0
- package/dist/integrations/http.js +40 -0
- package/dist/integrations/indexnow.d.ts +18 -0
- package/dist/integrations/indexnow.js +38 -0
- package/dist/integrations/search-console.d.ts +25 -0
- package/dist/integrations/search-console.js +39 -0
- package/dist/integrations/tags.d.ts +22 -0
- package/dist/integrations/tags.js +67 -0
- package/dist/seo/head.d.ts +20 -0
- package/dist/seo/head.js +85 -0
- package/dist/seo/jsonld.d.ts +35 -0
- package/dist/seo/jsonld.js +31 -0
- package/dist/seo/urls.d.ts +3 -0
- package/dist/seo/urls.js +10 -0
- package/package.json +81 -4
- package/templates/starter/.env.example +12 -0
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +30 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +166 -0
- package/templates/starter/CLAUDE.md +3 -0
- package/templates/starter/_gitignore +8 -0
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +16 -0
- package/templates/starter/public/apple-touch-icon.png +0 -0
- package/templates/starter/public/favicon.ico +0 -0
- package/templates/starter/public/favicon.svg +1 -0
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +38 -0
- package/templates/starter/src/pages/404.md +7 -0
- package/templates/starter/src/pages/about.md +8 -0
- package/templates/starter/src/pages/blog/first-post.md +10 -0
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +8 -0
- package/templates/starter/vage.config.ts +38 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { cac } from "cac";
|
|
6
|
+
import { check } from "../checks/check.js";
|
|
7
|
+
import { build } from "../core/build.js";
|
|
8
|
+
import { VageError } from "../core/errors.js";
|
|
9
|
+
import { assertBranchName, assertCloudflareProject, assertVageSpec, assertWorkerName } from "../core/validate.js";
|
|
10
|
+
import { startDevServer } from "../dev/server.js";
|
|
11
|
+
import { startPreviewServer } from "../dev/preview.js";
|
|
12
|
+
import { createInterface } from "node:readline/promises";
|
|
13
|
+
import { createProject } from "./create.js";
|
|
14
|
+
import { frameworkInfo } from "./info.js";
|
|
15
|
+
const { version } = createRequire(import.meta.url)("../../package.json");
|
|
16
|
+
const cli = cac("vage");
|
|
17
|
+
cli
|
|
18
|
+
.command("create <dir>", "Scaffold a new vagejs website")
|
|
19
|
+
.option("--vage-spec <spec>", "Dependency spec for vagejs in the new package.json", { default: `^${version}` })
|
|
20
|
+
.option("--json", "Print machine-readable JSON")
|
|
21
|
+
.action((dir, opts) => run(opts, () => {
|
|
22
|
+
const result = createProject(dir, assertVageSpec(opts.vageSpec));
|
|
23
|
+
if (opts.json)
|
|
24
|
+
return print({ ok: true, ...result });
|
|
25
|
+
const rel = path.relative(process.cwd(), result.dir) || ".";
|
|
26
|
+
console.log(`Created ${result.dir}\n\nNext steps:\n cd ${rel}\n npm install\n npm run dev`);
|
|
27
|
+
}));
|
|
28
|
+
cli
|
|
29
|
+
.command("dev", "Start the dev server with live reload")
|
|
30
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
31
|
+
.option("--port <port>", "Port (fails if taken; default picks a free port from 5173)")
|
|
32
|
+
.option("--host [host]", "Expose on the network")
|
|
33
|
+
.action((opts) => run(opts, async () => {
|
|
34
|
+
const server = await startDevServer(opts.root, { port: parsePort(opts.port), host: opts.host });
|
|
35
|
+
console.log(`vagejs dev server running at ${server.resolvedUrls?.local[0]}`);
|
|
36
|
+
server.resolvedUrls?.network.forEach((url) => console.log(` network: ${url}`));
|
|
37
|
+
}));
|
|
38
|
+
cli
|
|
39
|
+
.command("build", "Build the static site into dist/")
|
|
40
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
41
|
+
.option("--json", "Print machine-readable JSON")
|
|
42
|
+
.action((opts) => run(opts, async () => {
|
|
43
|
+
const result = await build(opts.root);
|
|
44
|
+
if (opts.json)
|
|
45
|
+
return print({ ok: true, ...result });
|
|
46
|
+
for (const page of result.pages)
|
|
47
|
+
console.log(` ${page.route.padEnd(30)} -> ${page.output}`);
|
|
48
|
+
for (const r of result.redirects)
|
|
49
|
+
console.log(` ${r.from.padEnd(30)} -> ${r.output} (redirect to ${r.to})`);
|
|
50
|
+
for (const file of result.files)
|
|
51
|
+
console.log(` ${"".padEnd(30)} -> ${file}`);
|
|
52
|
+
for (const draft of result.skippedDrafts)
|
|
53
|
+
console.log(` (draft, skipped) ${draft}`);
|
|
54
|
+
for (const warning of result.warnings)
|
|
55
|
+
console.log(`\nWarning: ${warning}`);
|
|
56
|
+
console.log(`\nBuilt ${result.pages.length} page(s) into ${result.outDir} in ${result.durationMs}ms`);
|
|
57
|
+
}));
|
|
58
|
+
cli
|
|
59
|
+
.command("check", "Check SEO, links, structured data and basic accessibility (writes nothing)")
|
|
60
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
61
|
+
.option("--strict", "Fail on warnings too")
|
|
62
|
+
.option("--json", "Print machine-readable JSON")
|
|
63
|
+
.action((opts) => run(opts, async () => {
|
|
64
|
+
const result = await check(opts.root, { strict: opts.strict });
|
|
65
|
+
if (opts.json)
|
|
66
|
+
print(result);
|
|
67
|
+
else {
|
|
68
|
+
printFindings(result.findings);
|
|
69
|
+
console.log(`\nChecked ${result.pagesChecked} page(s): ${result.errors} error(s), ${result.warnings} warning(s).`);
|
|
70
|
+
console.log("Passing checks do not guarantee indexing, rankings, rich results or AI citations.");
|
|
71
|
+
}
|
|
72
|
+
if (!result.ok)
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}));
|
|
75
|
+
cli
|
|
76
|
+
.command("audit", "Performance budgets and accessibility (local build), or PageSpeed/CrUX for a deployed --url")
|
|
77
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
78
|
+
.option("--url <url>", "Audit a deployed URL with PageSpeed Insights (+ CrUX field data with CRUX_API_KEY)")
|
|
79
|
+
.option("--lighthouse", "Also run Lighthouse locally (needs the lighthouse package and Chrome)")
|
|
80
|
+
.option("--strategy <strategy>", "mobile, desktop or both", { default: "both" })
|
|
81
|
+
.option("--strict", "Fail on warnings too (budgets, lab scores, API failures)")
|
|
82
|
+
.option("--json", "Print machine-readable JSON")
|
|
83
|
+
.action((opts) => run(opts, async () => {
|
|
84
|
+
if (!["mobile", "desktop", "both"].includes(opts.strategy)) {
|
|
85
|
+
throw new VageError(`Invalid --strategy "${opts.strategy}". Use mobile, desktop or both.`);
|
|
86
|
+
}
|
|
87
|
+
const strategies = opts.strategy === "both" ? ["mobile", "desktop"] : [opts.strategy];
|
|
88
|
+
// Loaded lazily: jsdom and axe are only needed for audits.
|
|
89
|
+
const { audit } = await import("../audit/audit.js");
|
|
90
|
+
const { kbText } = await import("../audit/budgets.js");
|
|
91
|
+
const interactive = Boolean(process.stdin.isTTY && !opts.json);
|
|
92
|
+
const result = await audit(opts.root, {
|
|
93
|
+
url: opts.url,
|
|
94
|
+
lighthouse: opts.lighthouse,
|
|
95
|
+
strict: opts.strict,
|
|
96
|
+
strategies: [...strategies],
|
|
97
|
+
askForKey: interactive ? askForApiKey : undefined,
|
|
98
|
+
});
|
|
99
|
+
if (opts.json)
|
|
100
|
+
print(result);
|
|
101
|
+
else {
|
|
102
|
+
for (const p of result.pages) {
|
|
103
|
+
const b = p.bytes;
|
|
104
|
+
console.log(` ${p.route.padEnd(28)} total ${kbText(p.total).padStart(9)} js ${kbText(b.javascript)} css ${kbText(b.css)} img ${kbText(b.image)} font ${kbText(b.font)} ${p.requests} req`);
|
|
105
|
+
}
|
|
106
|
+
for (const r of result.lab) {
|
|
107
|
+
const s = r.scores;
|
|
108
|
+
console.log(` [lab ${r.source}, ${r.strategy}] ${r.url}: perf ${s.performance} a11y ${s.accessibility} seo ${s.seo} bp ${s["best-practices"]} (${r.toolVersion}, ${r.fetchTime})`);
|
|
109
|
+
for (const o of r.opportunities)
|
|
110
|
+
console.log(` - ${o.title}${o.displayValue ? `: ${o.displayValue}` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
for (const f of result.field) {
|
|
113
|
+
const m = Object.entries(f.metrics).map(([k, v]) => `${k.toUpperCase()} p75 ${v.p75} (${v.rating})`).join(", ");
|
|
114
|
+
console.log(` [field CrUX, ${f.formFactor}, ${f.scope}] ${m || "no metrics"}${f.collectionPeriod ? ` — ${f.collectionPeriod}` : ""}`);
|
|
115
|
+
}
|
|
116
|
+
if (result.findings.length)
|
|
117
|
+
console.log("");
|
|
118
|
+
printFindings(result.findings);
|
|
119
|
+
for (const note of result.notes)
|
|
120
|
+
console.log(`\nNote: ${note}`);
|
|
121
|
+
console.log(`\nAudit of ${result.target}: ${result.errors} error(s), ${result.warnings} warning(s).`);
|
|
122
|
+
}
|
|
123
|
+
if (!result.ok)
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}));
|
|
126
|
+
cli
|
|
127
|
+
.command("preview", "Serve the production build from dist/")
|
|
128
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
129
|
+
.option("--port <port>", "Port (fails if taken; default picks a free port from 4173)")
|
|
130
|
+
.option("--host [host]", "Expose on the network")
|
|
131
|
+
.action((opts) => run(opts, async () => {
|
|
132
|
+
const server = await startPreviewServer(opts.root, { port: parsePort(opts.port), host: opts.host });
|
|
133
|
+
console.log(`vagejs preview running at ${server.resolvedUrls?.local[0]}`);
|
|
134
|
+
server.resolvedUrls?.network.forEach((url) => console.log(` network: ${url}`));
|
|
135
|
+
}));
|
|
136
|
+
cli
|
|
137
|
+
.command("info", "Describe commands, config and frontmatter (use --json for agents)")
|
|
138
|
+
.option("--json", "Print machine-readable JSON")
|
|
139
|
+
.action((opts) => run(opts, () => {
|
|
140
|
+
const info = frameworkInfo(cli, version);
|
|
141
|
+
if (opts.json)
|
|
142
|
+
return print(info);
|
|
143
|
+
console.log(`vagejs ${info.version}\n`);
|
|
144
|
+
for (const c of info.commands)
|
|
145
|
+
console.log(` ${c.usage.padEnd(28)} ${c.description}`);
|
|
146
|
+
console.log(`\nConfig: ${info.config.files.join(" | ")}. Pages: ${info.routing.pagesDir}/**/*.md`);
|
|
147
|
+
console.log("Run `vage info --json` for full config and frontmatter JSON Schemas.");
|
|
148
|
+
}));
|
|
149
|
+
cli
|
|
150
|
+
.command("deploy:github", "Check, build, then safely commit and push the project to GitHub (asks first)")
|
|
151
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
152
|
+
.option("--remote <url>", "Git URL to add as origin if there is none")
|
|
153
|
+
.option("--branch <name>", "Branch for a new repository (default: main); must match the current branch otherwise")
|
|
154
|
+
.option("-m, --message <msg>", "Commit message")
|
|
155
|
+
.option("--dry-run", "Run check + build and show the plan; no git changes")
|
|
156
|
+
.option("--yes", "Skip the confirmation prompt")
|
|
157
|
+
.option("--json", "Print machine-readable JSON")
|
|
158
|
+
.action((opts) => run(opts, async () => {
|
|
159
|
+
const { publish } = await import("../deploy/publish.js");
|
|
160
|
+
const result = await publish(opts.root, { ...opts, confirm: (plan) => confirmPlan(plan, opts) });
|
|
161
|
+
if (opts.dryRun)
|
|
162
|
+
return opts.json ? print({ ok: true, dryRun: true, plan: result.plan }) : console.log("Dry run: nothing changed.");
|
|
163
|
+
if (opts.json)
|
|
164
|
+
return print({ ok: result.status !== "aborted", ...result });
|
|
165
|
+
reportPublish(result);
|
|
166
|
+
}));
|
|
167
|
+
cli
|
|
168
|
+
.command("deploy", "Deploy: GitHub Pages (workflow + push, waits for GitHub), Cloudflare Workers (new version, no downtime) or Cloudflare Pages")
|
|
169
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
170
|
+
.option("--target <host>", "github-pages, cloudflare-workers or cloudflare-pages (default: deploy.target in config)")
|
|
171
|
+
.option("--project <name>", "Cloudflare Pages project or Worker name (default: deploy.cloudflareProject / cloudflareWorker)")
|
|
172
|
+
.option("--remote <url>", "Git URL to add as origin if there is none")
|
|
173
|
+
.option("--branch <name>", "Branch for a new repository (default: main)")
|
|
174
|
+
.option("-m, --message <msg>", "Commit message")
|
|
175
|
+
.option("--no-wait", "Don't wait for the GitHub Actions deploy run")
|
|
176
|
+
.option("--dry-run", "Run check + build and show the plan; no git changes")
|
|
177
|
+
.option("--yes", "Skip the confirmation prompt")
|
|
178
|
+
.option("--json", "Print machine-readable JSON")
|
|
179
|
+
.action((opts) => run(opts, async () => {
|
|
180
|
+
const { loadProjectConfig } = await import("../core/project-config.js");
|
|
181
|
+
const config = await loadProjectConfig(path.resolve(opts.root));
|
|
182
|
+
const target = opts.target ?? config.deploy.target;
|
|
183
|
+
if (target === "cloudflare-pages")
|
|
184
|
+
return deployToCloudflare(opts, config.deploy.cloudflareProject);
|
|
185
|
+
if (target === "cloudflare-workers")
|
|
186
|
+
return deployToWorkers(opts, config.deploy.cloudflareWorker ?? config.deploy.cloudflareProject, config.site);
|
|
187
|
+
if (target !== "github-pages")
|
|
188
|
+
throw new VageError(`Unknown --target "${target}". Use github-pages, cloudflare-workers or cloudflare-pages.`);
|
|
189
|
+
const { publish } = await import("../deploy/publish.js");
|
|
190
|
+
const pages = await import("../deploy/github-pages.js");
|
|
191
|
+
const result = await publish(opts.root, {
|
|
192
|
+
...opts,
|
|
193
|
+
extraFiles: (branch) => ({ [pages.PAGES_WORKFLOW]: pages.pagesWorkflow(branch) }),
|
|
194
|
+
confirm: (plan) => confirmPlan(plan, opts, "Then enable GitHub Pages (source: GitHub Actions) via gh if needed, and wait for the deploy run."),
|
|
195
|
+
});
|
|
196
|
+
if (opts.dryRun)
|
|
197
|
+
return opts.json ? print({ ok: true, dryRun: true, plan: result.plan }) : console.log("Dry run: nothing changed.");
|
|
198
|
+
if (result.status !== "published") {
|
|
199
|
+
if (opts.json)
|
|
200
|
+
return print({ ok: result.status !== "aborted", ...result, deployment: { status: "unconfirmed", reason: `Nothing was pushed (${result.status}).` } });
|
|
201
|
+
reportPublish(result);
|
|
202
|
+
if (result.status === "committed")
|
|
203
|
+
console.log("Not deployed: there is no remote. Pass --remote <git-url>.");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!opts.json)
|
|
207
|
+
reportPublish(result);
|
|
208
|
+
const enabled = await pages.ensurePagesEnabled(result.plan.root);
|
|
209
|
+
const deployment = typeof enabled === "object"
|
|
210
|
+
? { status: "unconfirmed", reason: enabled.manual }
|
|
211
|
+
: opts.wait
|
|
212
|
+
? await pages.confirmPagesDeployment(result.plan.root, result.commit)
|
|
213
|
+
: { status: "unconfirmed", reason: "Not waiting (--no-wait); the deploy runs in GitHub Actions." };
|
|
214
|
+
if (opts.json)
|
|
215
|
+
return print({ ok: deployment.status !== "failure", ...result, deployment });
|
|
216
|
+
if (deployment.status === "success")
|
|
217
|
+
console.log(`\nDeployed: ${deployment.pageUrl ?? "(Pages URL unavailable)"}\nRun: ${deployment.runUrl}`);
|
|
218
|
+
else
|
|
219
|
+
console.log(`\nDeployment ${deployment.status === "failure" ? "FAILED" : "not confirmed"}: ${deployment.reason}${deployment.runUrl ? `\nRun: ${deployment.runUrl}` : ""}`);
|
|
220
|
+
if (deployment.status === "failure")
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}));
|
|
223
|
+
const OAUTH_SETUP = `Set GOOGLE_OAUTH_CLIENT_ID (and GOOGLE_OAUTH_CLIENT_SECRET) from a Google Cloud OAuth client of type "Desktop app":
|
|
224
|
+
console.cloud.google.com → APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app.
|
|
225
|
+
Enable the "Google Search Console API" and/or "Google Analytics Data API" for that project.
|
|
226
|
+
Keep these in your shell or a git-ignored .env, never in vage.config.ts.`;
|
|
227
|
+
cli
|
|
228
|
+
.command("connect <service>", "Connect Search Console (gsc) or Analytics (ga4) via OAuth, or save your own PageSpeed / CrUX API key (pagespeed, crux)")
|
|
229
|
+
.option("--no-open", "Print the consent URL instead of opening a browser")
|
|
230
|
+
.option("--json", "Print machine-readable JSON")
|
|
231
|
+
.action((service, opts) => run(opts, async () => {
|
|
232
|
+
if (service === "pagespeed" || service === "crux") {
|
|
233
|
+
if (!process.stdin.isTTY)
|
|
234
|
+
throw new VageError(`Run this in a terminal, or set ${(await import("../integrations/api-keys.js")).API_KEYS[service].env} in your environment instead.`);
|
|
235
|
+
const saved = await askForApiKey(service, { alwaysSave: true });
|
|
236
|
+
if (opts.json)
|
|
237
|
+
return print({ ok: Boolean(saved), service });
|
|
238
|
+
return console.log(saved ? "Saved. `vage audit --url` will use this key." : "Nothing saved.");
|
|
239
|
+
}
|
|
240
|
+
if (service !== "gsc" && service !== "ga4")
|
|
241
|
+
throw new VageError(`Unknown service "${service}". Use gsc, ga4, pagespeed or crux.`);
|
|
242
|
+
const google = await import("../integrations/google-auth.js");
|
|
243
|
+
const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID;
|
|
244
|
+
if (!clientId)
|
|
245
|
+
throw new VageError(`GOOGLE_OAUTH_CLIENT_ID is not set.\n${OAUTH_SETUP}`);
|
|
246
|
+
const log = opts.json ? console.error : console.log;
|
|
247
|
+
log(`Requesting read-only access: ${google.SCOPES[service]}`);
|
|
248
|
+
log(`The refresh token will be stored outside this project at ${google.credentialsPath()} (mode 600).`);
|
|
249
|
+
const creds = await google.connectGoogle({
|
|
250
|
+
scopes: [google.SCOPES[service]],
|
|
251
|
+
clientId,
|
|
252
|
+
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET,
|
|
253
|
+
open: (url) => {
|
|
254
|
+
log(`\nOpen this URL to approve access:\n${url}\n`);
|
|
255
|
+
if (opts.open)
|
|
256
|
+
openBrowser(url);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
let properties;
|
|
260
|
+
if (service === "gsc") {
|
|
261
|
+
const { listSites } = await import("../integrations/search-console.js");
|
|
262
|
+
properties = await listSites(await google.getAccessToken(google.SCOPES.gsc, "gsc"));
|
|
263
|
+
}
|
|
264
|
+
if (opts.json)
|
|
265
|
+
return print({ ok: true, service, scopes: creds.scopes, properties });
|
|
266
|
+
log(`Connected (${creds.scopes.length} scope(s)).`);
|
|
267
|
+
if (properties) {
|
|
268
|
+
log(properties.length ? "Search Console properties you can access:" : "No Search Console properties found for this account.");
|
|
269
|
+
for (const p of properties)
|
|
270
|
+
log(` ${p.siteUrl} (${p.permissionLevel})`);
|
|
271
|
+
if (properties[0])
|
|
272
|
+
log(`\nSet one in vage.config.ts: integrations: { searchConsole: { property: "${properties[0].siteUrl}" } }`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
log('Set the numeric property ID in vage.config.ts: integrations: { ga4: { propertyId: "123456789" } }');
|
|
276
|
+
}
|
|
277
|
+
}));
|
|
278
|
+
cli
|
|
279
|
+
.command("disconnect <provider>", "Remove a stored connection: google (revokes OAuth), pagespeed or crux (saved API key)")
|
|
280
|
+
.option("--json", "Print machine-readable JSON")
|
|
281
|
+
.action((provider, opts) => run(opts, async () => {
|
|
282
|
+
if (provider === "pagespeed" || provider === "crux") {
|
|
283
|
+
const { removeApiKey } = await import("../integrations/api-keys.js");
|
|
284
|
+
const removed = removeApiKey(provider);
|
|
285
|
+
if (opts.json)
|
|
286
|
+
return print({ ok: true, removed });
|
|
287
|
+
return console.log(removed ? `Removed the saved ${provider} key. (Also revoke it in Google Cloud Console if it is no longer needed.)` : `No saved ${provider} key.`);
|
|
288
|
+
}
|
|
289
|
+
if (provider !== "google")
|
|
290
|
+
throw new VageError(`Unknown provider "${provider}". Use google, pagespeed or crux.`);
|
|
291
|
+
const { disconnectGoogle } = await import("../integrations/google-auth.js");
|
|
292
|
+
const result = await disconnectGoogle();
|
|
293
|
+
if (opts.json)
|
|
294
|
+
return print({ ok: true, ...result });
|
|
295
|
+
if (!result.hadConnection)
|
|
296
|
+
return console.log("No Google connection was stored.");
|
|
297
|
+
console.log(result.revoked
|
|
298
|
+
? "Revoked access at Google and deleted the local token."
|
|
299
|
+
: "Deleted the local token, but revocation at Google failed. Remove access at https://myaccount.google.com/permissions.");
|
|
300
|
+
}));
|
|
301
|
+
cli
|
|
302
|
+
.command("links [action] [...domains]", "List the external domains the site links to; the site owner approves them into links.lock.json (asks first)")
|
|
303
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
304
|
+
.option("--all", "approve: every domain the site links to now")
|
|
305
|
+
.option("--prune", "approve: also remove approved domains the site no longer links to")
|
|
306
|
+
.option("--dry-run", "approve: show the change without writing")
|
|
307
|
+
.option("--yes", "Skip the confirmation prompt (only with the site owner's approval)")
|
|
308
|
+
.option("--json", "Print machine-readable JSON")
|
|
309
|
+
.action((action, domains, opts) => run(opts, async () => {
|
|
310
|
+
const { linkStatus, nextLock, readLinkLock, writeLinkLock } = await import("../checks/links.js");
|
|
311
|
+
const root = path.resolve(opts.root);
|
|
312
|
+
const status = await linkStatus(root);
|
|
313
|
+
if (!action || action === "list") {
|
|
314
|
+
if (opts.json)
|
|
315
|
+
return print({ ok: true, ...status });
|
|
316
|
+
if (!status.domains.length)
|
|
317
|
+
return console.log("The site links to no external domains.");
|
|
318
|
+
for (const d of status.domains)
|
|
319
|
+
console.log(`${d.approved ? "approved" : "NEW "} ${d.domain.padEnd(36)} ${d.pages.length} page(s): ${d.pages.slice(0, 3).join(", ")}${d.pages.length > 3 ? " …" : ""}`);
|
|
320
|
+
for (const d of status.unused)
|
|
321
|
+
console.log(`unused ${d} (approved, no longer linked; drop with "vage links approve --prune")`);
|
|
322
|
+
if (!status.hasLock)
|
|
323
|
+
console.log('\nNo links.lock.json yet. Review the list, then: npx vage links approve --all');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (action !== "approve")
|
|
327
|
+
throw new VageError(`Unknown action "${action}". Use "vage links" to list or "vage links approve <domain...>".`);
|
|
328
|
+
const add = opts.all ? status.domains.map((d) => d.domain) : domains;
|
|
329
|
+
if (!add.length && !opts.prune)
|
|
330
|
+
throw new VageError('Name the domains to approve ("vage links approve example.com"), or pass --all after reviewing "vage links".');
|
|
331
|
+
const current = readLinkLock(root)?.domains ?? [];
|
|
332
|
+
const next = nextLock(status, current, add, Boolean(opts.prune));
|
|
333
|
+
const added = next.filter((d) => !current.includes(d));
|
|
334
|
+
const removed = current.filter((d) => !next.includes(d));
|
|
335
|
+
if (!added.length && !removed.length)
|
|
336
|
+
return opts.json ? print({ ok: true, changed: false }) : console.log("links.lock.json already up to date.");
|
|
337
|
+
const lines = ["Update links.lock.json", ...added.map((d) => ` + ${d}`), ...removed.map((d) => ` - ${d}`)];
|
|
338
|
+
if (!(await askConfirm(lines, opts))) {
|
|
339
|
+
return opts.json ? print({ ok: !!opts.dryRun, dryRun: !!opts.dryRun, added, removed }) : console.log(opts.dryRun ? "Dry run: nothing written." : "Nothing written.");
|
|
340
|
+
}
|
|
341
|
+
writeLinkLock(root, next);
|
|
342
|
+
if (opts.json)
|
|
343
|
+
print({ ok: true, added, removed });
|
|
344
|
+
else
|
|
345
|
+
console.log(`links.lock.json updated: ${added.length} added, ${removed.length} removed.`);
|
|
346
|
+
}));
|
|
347
|
+
cli
|
|
348
|
+
.command("verify", "Compare the live site with your files: extra links, scripts, text or sitemap pages mean it was changed outside your repository")
|
|
349
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
350
|
+
.option("--url <url>", "Live site origin (default: site in vage.config.ts)")
|
|
351
|
+
.option("--json", "Print machine-readable JSON")
|
|
352
|
+
.action((opts) => run(opts, async () => {
|
|
353
|
+
const { verifySite } = await import("../checks/verify.js");
|
|
354
|
+
const result = await verifySite(opts.root, { url: opts.url });
|
|
355
|
+
if (opts.json)
|
|
356
|
+
print(result);
|
|
357
|
+
else {
|
|
358
|
+
for (const d of result.differences)
|
|
359
|
+
console.log(`${d.severity === "error" ? "error " : "warning"} ${d.kind.padEnd(15)} ${d.route}\n ${d.detail}`);
|
|
360
|
+
console.log(`\nVerified ${result.pagesChecked} page(s) on ${result.url}: ${result.differences.filter((d) => d.severity === "error").length} error(s), ${result.differences.filter((d) => d.severity === "warning").length} warning(s).`);
|
|
361
|
+
if (!result.ok)
|
|
362
|
+
console.log("The live site has content that isn't in your files. If you didn't deploy it, rotate your host and Git credentials, then redeploy from the repository.");
|
|
363
|
+
}
|
|
364
|
+
if (!result.ok)
|
|
365
|
+
process.exitCode = 1;
|
|
366
|
+
}));
|
|
367
|
+
cli
|
|
368
|
+
.command("report <kind>", "Report from Search Console (search) or GA4 (analytics); needs vage connect")
|
|
369
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
370
|
+
.option("--property <property>", "Search Console property (default: integrations.searchConsole.property)")
|
|
371
|
+
.option("--property-id <id>", "GA4 property ID (default: integrations.ga4.propertyId)")
|
|
372
|
+
.option("--days <n>", "Days to include", { default: 28 })
|
|
373
|
+
.option("--json", "Print machine-readable JSON")
|
|
374
|
+
.action((kind, opts) => run(opts, async () => {
|
|
375
|
+
if (kind !== "search" && kind !== "analytics")
|
|
376
|
+
throw new VageError(`Unknown report "${kind}". Use "search" or "analytics".`);
|
|
377
|
+
const days = Number(opts.days);
|
|
378
|
+
if (!Number.isInteger(days) || days < 1 || days > 480)
|
|
379
|
+
throw new VageError("--days must be a whole number from 1 to 480.");
|
|
380
|
+
const google = await import("../integrations/google-auth.js");
|
|
381
|
+
const { loadProjectConfig } = await import("../core/project-config.js");
|
|
382
|
+
const site = await loadProjectConfig(opts.root);
|
|
383
|
+
if (kind === "search") {
|
|
384
|
+
const property = opts.property ?? site.integrations.searchConsole?.property;
|
|
385
|
+
if (!property)
|
|
386
|
+
throw new VageError('No Search Console property. Pass --property or set integrations.searchConsole.property (list them with "vage connect gsc").');
|
|
387
|
+
const { searchReport } = await import("../integrations/search-console.js");
|
|
388
|
+
const report = await searchReport(await google.getAccessToken(google.SCOPES.gsc, "gsc"), property, days);
|
|
389
|
+
if (opts.json)
|
|
390
|
+
return print({ ok: true, ...report });
|
|
391
|
+
console.log(`Search Console: ${report.property}, ${report.startDate} to ${report.endDate} (Pacific Time; finalized data only)\n`);
|
|
392
|
+
for (const [title, rows] of [["Top queries", report.queries], ["Top pages", report.pages]]) {
|
|
393
|
+
console.log(`${title}:`);
|
|
394
|
+
if (!rows.length)
|
|
395
|
+
console.log(" (no data)");
|
|
396
|
+
for (const r of rows)
|
|
397
|
+
console.log(` ${r.key.slice(0, 60).padEnd(60)} ${String(r.clicks).padStart(6)} clicks ${String(r.impressions).padStart(8)} impr ctr ${(r.ctr * 100).toFixed(1)}% pos ${r.position.toFixed(1)}`);
|
|
398
|
+
console.log("");
|
|
399
|
+
}
|
|
400
|
+
console.log("The API returns top rows only and does not cover every Search Console report (e.g. page indexing).");
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
const propertyId = opts.propertyId ?? site.integrations.ga4?.propertyId;
|
|
404
|
+
if (!propertyId)
|
|
405
|
+
throw new VageError("No GA4 property. Pass --property-id or set integrations.ga4.propertyId.");
|
|
406
|
+
const { analyticsReport } = await import("../integrations/ga4-data.js");
|
|
407
|
+
const report = await analyticsReport(await google.getAccessToken(google.SCOPES.ga4, "ga4"), propertyId, days);
|
|
408
|
+
if (opts.json)
|
|
409
|
+
return print({ ok: true, ...report });
|
|
410
|
+
console.log(`GA4 property ${report.propertyId}, last ${report.days} days: top pages by views\n`);
|
|
411
|
+
if (!report.rows.length)
|
|
412
|
+
console.log(" (no data)");
|
|
413
|
+
for (const r of report.rows)
|
|
414
|
+
console.log(` ${r.pagePath.slice(0, 60).padEnd(60)} ${String(r.screenPageViews).padStart(7)} views ${String(r.activeUsers).padStart(6)} users ${String(r.sessions).padStart(6)} sessions`);
|
|
415
|
+
}
|
|
416
|
+
}));
|
|
417
|
+
cli
|
|
418
|
+
.command("indexnow", "Notify IndexNow search engines about changed URLs (asks first)")
|
|
419
|
+
.option("--root <dir>", "Project root", { default: "." })
|
|
420
|
+
.option("--changed-since <ref>", "Submit pages changed since a git ref (e.g. HEAD~1)")
|
|
421
|
+
.option("--urls <list>", "Comma-separated URLs or paths to submit")
|
|
422
|
+
.option("--all", "Submit every page listed in sitemap.xml")
|
|
423
|
+
.option("--dry-run", "Show the URLs without submitting")
|
|
424
|
+
.option("--yes", "Skip the confirmation prompt")
|
|
425
|
+
.option("--json", "Print machine-readable JSON")
|
|
426
|
+
.action((opts) => run(opts, async () => {
|
|
427
|
+
const root = path.resolve(opts.root);
|
|
428
|
+
const { createBuildLoader } = await import("../core/loader.js");
|
|
429
|
+
const { renderSite, listedPages } = await import("../core/site.js");
|
|
430
|
+
const loader = await createBuildLoader(root);
|
|
431
|
+
const model = await renderSite(root, loader.load).finally(() => loader.close());
|
|
432
|
+
const { site } = model;
|
|
433
|
+
const config = site.integrations.indexNow;
|
|
434
|
+
if (!config)
|
|
435
|
+
throw new VageError('IndexNow is not configured. Add integrations: { indexNow: { key: "<8-128 letters, digits or dashes>" } } and deploy.');
|
|
436
|
+
if (!site.site)
|
|
437
|
+
throw new VageError('"site" must be set in vage.config.ts to submit URLs.');
|
|
438
|
+
const abs = (p) => new URL(p, site.site).href;
|
|
439
|
+
let urls;
|
|
440
|
+
if (opts.all)
|
|
441
|
+
urls = listedPages(model).map((p) => p.meta.canonical);
|
|
442
|
+
else if (opts.urls)
|
|
443
|
+
urls = opts.urls.split(",").map((u) => abs(u.trim()));
|
|
444
|
+
else if (opts.changedSince) {
|
|
445
|
+
const { changedPageRoutes } = await import("../deploy/git.js");
|
|
446
|
+
urls = (await changedPageRoutes(root, opts.changedSince)).map(abs);
|
|
447
|
+
if (!urls.length)
|
|
448
|
+
return opts.json ? print({ ok: true, submitted: 0 }) : console.log(`No page changes since ${opts.changedSince}.`);
|
|
449
|
+
}
|
|
450
|
+
else
|
|
451
|
+
throw new VageError("Choose what to submit: --changed-since <ref>, --urls <list>, or --all.");
|
|
452
|
+
const lines = [`IndexNow: submit ${urls.length} URL(s) to ${config.endpoint}`, ...urls.slice(0, 20).map((u) => ` ${u}`), ...(urls.length > 20 ? [` … ${urls.length - 20} more`] : [])];
|
|
453
|
+
if (!(await askConfirm(lines, opts)))
|
|
454
|
+
return opts.json ? print({ ok: true, dryRun: Boolean(opts.dryRun), urls }) : console.log("Nothing submitted.");
|
|
455
|
+
const { submitIndexNow } = await import("../integrations/indexnow.js");
|
|
456
|
+
const result = await submitIndexNow({ site: site.site, key: config.key, endpoint: config.endpoint, urls });
|
|
457
|
+
if (opts.json)
|
|
458
|
+
return print({ ok: true, ...result });
|
|
459
|
+
console.log(`${result.status === 202 ? "Received (key validation pending)" : "Accepted"}: ${result.submitted} URL(s). IndexNow is a hint; it doesn't guarantee crawling or indexing.`);
|
|
460
|
+
}));
|
|
461
|
+
cli
|
|
462
|
+
.command("guidelines", "Show the rule pack: provider facts in use, their official sources, and the changelog")
|
|
463
|
+
.option("--json", "Print machine-readable JSON")
|
|
464
|
+
.action((opts) => run(opts, async () => {
|
|
465
|
+
const { rulePack } = await import("../guidelines/pack.js");
|
|
466
|
+
if (opts.json)
|
|
467
|
+
return print(rulePack);
|
|
468
|
+
console.log(`Rule pack ${rulePack.version} (released ${rulePack.released})\n`);
|
|
469
|
+
for (const r of rulePack.rules) {
|
|
470
|
+
console.log(` ${r.id}${r.behaviorChanging ? " [changes output]" : ""}\n ${r.summary}`);
|
|
471
|
+
for (const id of r.sources)
|
|
472
|
+
console.log(` - ${rulePack.sources.find((s) => s.id === id)?.url}`);
|
|
473
|
+
}
|
|
474
|
+
console.log("\nChangelog:");
|
|
475
|
+
for (const c of rulePack.changelog)
|
|
476
|
+
console.log(` ${c.version} (${c.date}, reviewed by ${c.reviewedBy}): ${c.summary}`);
|
|
477
|
+
console.log('\nPin this version with guidelines: { rulePack: "' + rulePack.version + '" } in vage.config.ts.');
|
|
478
|
+
}));
|
|
479
|
+
cli.help();
|
|
480
|
+
cli.version(version);
|
|
481
|
+
try {
|
|
482
|
+
cli.parse(process.argv, { run: false });
|
|
483
|
+
if (!cli.matchedCommand && cli.args.length === 0 && !cli.options.help && !cli.options.version) {
|
|
484
|
+
cli.outputHelp();
|
|
485
|
+
}
|
|
486
|
+
else if (!cli.matchedCommand && cli.args.length > 0) {
|
|
487
|
+
throw new VageError(`Unknown command "${cli.args[0]}". Run "vage --help" for the list of commands.`);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
await cli.runMatchedCommand();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
fail(err, Boolean(cli.options.json));
|
|
495
|
+
}
|
|
496
|
+
async function run(opts, fn) {
|
|
497
|
+
try {
|
|
498
|
+
await fn();
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
fail(err, Boolean(opts.json));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function fail(err, json) {
|
|
505
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
506
|
+
if (json)
|
|
507
|
+
print({ ok: false, error: message });
|
|
508
|
+
else if (err instanceof VageError)
|
|
509
|
+
console.error(`Error: ${message}`);
|
|
510
|
+
else
|
|
511
|
+
console.error(err);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
function printFindings(findings) {
|
|
515
|
+
for (const f of findings) {
|
|
516
|
+
const where = f.page ? `${f.page}${f.source ? ` (${f.source})` : ""}` : (f.source ?? "site");
|
|
517
|
+
console.log(`${f.severity === "error" ? "error " : "warning"} ${f.rule.padEnd(22)} ${where}\n ${f.message}${f.docs ? `\n see ${f.docs}` : ""}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async function deployToCloudflare(opts, configuredProject) {
|
|
521
|
+
const project = opts.project ?? configuredProject;
|
|
522
|
+
if (!project)
|
|
523
|
+
throw new VageError("No Cloudflare Pages project. Pass --project <name> or set deploy.cloudflareProject.");
|
|
524
|
+
assertCloudflareProject(project);
|
|
525
|
+
const root = path.resolve(opts.root);
|
|
526
|
+
const { check } = await import("../checks/check.js");
|
|
527
|
+
const checked = await check(root);
|
|
528
|
+
if (checked.errors)
|
|
529
|
+
throw new VageError(`vage check found ${checked.errors} error(s). Fix them before deploying.`);
|
|
530
|
+
const result = await build(root);
|
|
531
|
+
const { gitRaw } = await import("../deploy/git.js");
|
|
532
|
+
const currentBranch = (await gitRaw(root, ["branch", "--show-current"])).stdout.trim();
|
|
533
|
+
const commit = (await gitRaw(root, ["rev-parse", "HEAD"])).stdout.trim() || undefined;
|
|
534
|
+
const branch = assertBranchName(opts.branch ?? (currentBranch || "main"));
|
|
535
|
+
const { deployCloudflarePages, WRANGLER } = await import("../deploy/cloudflare-pages.js");
|
|
536
|
+
const lines = [
|
|
537
|
+
`Deploy plan (Cloudflare Pages)`,
|
|
538
|
+
` • upload ${result.outDir} (${result.pages.length} page(s), ${result.files.length} generated file(s)) to project "${project}"`,
|
|
539
|
+
` • branch "${branch}": the production branch updates the live site; other branches get a preview URL`,
|
|
540
|
+
` • runs: npx ${WRANGLER} pages deploy dist --project-name ${project} --branch ${branch}`,
|
|
541
|
+
];
|
|
542
|
+
if (!(await askConfirm(lines, opts))) {
|
|
543
|
+
if (opts.dryRun)
|
|
544
|
+
return opts.json ? print({ ok: true, dryRun: true, target: "cloudflare-pages", project, branch }) : console.log("Dry run: nothing deployed.");
|
|
545
|
+
return opts.json ? print({ ok: false, status: "aborted" }) : console.log("Nothing deployed.");
|
|
546
|
+
}
|
|
547
|
+
const deployment = await deployCloudflarePages(root, { project, branch, commit });
|
|
548
|
+
if (opts.json)
|
|
549
|
+
print({ ok: deployment.status !== "failure", target: "cloudflare-pages", project, deployment });
|
|
550
|
+
else if (deployment.status === "success")
|
|
551
|
+
console.log(`Deployed and verified: ${deployment.deploymentUrl} (branch ${branch})`);
|
|
552
|
+
else
|
|
553
|
+
console.log(`Deployment ${deployment.status === "failure" ? "FAILED" : "not confirmed"}: ${deployment.reason}`);
|
|
554
|
+
if (deployment.status === "failure")
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
async function deployToWorkers(opts, configuredWorker, siteUrl) {
|
|
558
|
+
const root = path.resolve(opts.root);
|
|
559
|
+
const { deployCloudflareWorkers, planWorkersDeploy } = await import("../deploy/cloudflare-workers.js");
|
|
560
|
+
const { WRANGLER } = await import("../deploy/cloudflare-pages.js");
|
|
561
|
+
const requested = opts.project ?? configuredWorker;
|
|
562
|
+
if (requested)
|
|
563
|
+
assertWorkerName(requested);
|
|
564
|
+
const plan = planWorkersDeploy(root, requested);
|
|
565
|
+
const { check } = await import("../checks/check.js");
|
|
566
|
+
const checked = await check(root);
|
|
567
|
+
if (checked.errors)
|
|
568
|
+
throw new VageError(`vage check found ${checked.errors} error(s). Fix them before deploying.`);
|
|
569
|
+
const result = await build(root);
|
|
570
|
+
const steps = [
|
|
571
|
+
`upload ${result.outDir} (${result.pages.length} page(s), ${result.files.length} generated file(s)) to Worker "${plan.worker}"`,
|
|
572
|
+
plan.config ? `uses ${plan.config.file} (routes and custom domains stay as they are)` : "no wrangler config: a temporary one serves dist/ (with your 404 page)",
|
|
573
|
+
`runs: npx ${WRANGLER} versions upload, then versions deploy <version>@100% (a brand-new Worker gets wrangler deploy once)`,
|
|
574
|
+
`verifies: ${siteUrl ?? "the Worker's workers.dev URL"} answers 200`,
|
|
575
|
+
];
|
|
576
|
+
const lines = ["Deploy plan (Cloudflare Workers)", ...steps.map((s) => ` • ${s}`), ...plan.warnings.map((w) => ` ! ${w}`)];
|
|
577
|
+
if (!(await askConfirm(lines, opts))) {
|
|
578
|
+
if (opts.dryRun) {
|
|
579
|
+
return opts.json
|
|
580
|
+
? print({ ok: true, dryRun: true, target: "cloudflare-workers", worker: plan.worker, config: plan.config?.file, steps, warnings: plan.warnings })
|
|
581
|
+
: console.log("Dry run: nothing deployed.");
|
|
582
|
+
}
|
|
583
|
+
return opts.json ? print({ ok: false, status: "aborted" }) : console.log("Nothing deployed.");
|
|
584
|
+
}
|
|
585
|
+
const deployment = await deployCloudflareWorkers(root, { plan, siteUrl });
|
|
586
|
+
if (opts.json)
|
|
587
|
+
print({ ok: deployment.status !== "failure", target: "cloudflare-workers", deployment });
|
|
588
|
+
else if (deployment.status === "success")
|
|
589
|
+
console.log(`Deployed and verified: ${deployment.pageUrl} (${deployment.mode === "first-deploy" ? "first deploy" : `version ${deployment.versionId}`})`);
|
|
590
|
+
else
|
|
591
|
+
console.log(`Deployment ${deployment.status === "failure" ? "FAILED" : "not confirmed"}: ${deployment.reason}`);
|
|
592
|
+
if (deployment.status === "failure")
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
async function confirmPlan(plan, opts, extra) {
|
|
596
|
+
const lines = [`Publish plan for ${plan.root}`];
|
|
597
|
+
if (plan.initRepo)
|
|
598
|
+
lines.push(` • git init (branch ${plan.branch})`);
|
|
599
|
+
if (plan.addRemote)
|
|
600
|
+
lines.push(` • add origin ${plan.remote}`);
|
|
601
|
+
lines.push(` • commit ${plan.files.length} file(s) on ${plan.branch}: "${plan.message}"`);
|
|
602
|
+
for (const f of plan.files)
|
|
603
|
+
lines.push(` ${f.status} ${f.path}`);
|
|
604
|
+
lines.push(plan.remote ? ` • push to ${plan.remote} (${plan.branch}); never forced` : " • no push (no remote)");
|
|
605
|
+
if (extra)
|
|
606
|
+
lines.push(` • ${extra}`);
|
|
607
|
+
for (const w of plan.warnings)
|
|
608
|
+
lines.push(` ! ${w}`);
|
|
609
|
+
return askConfirm(lines, opts);
|
|
610
|
+
}
|
|
611
|
+
/** Shows the plan, then asks. --dry-run never proceeds; --yes skips the prompt; non-TTY without --yes refuses. */
|
|
612
|
+
async function askConfirm(lines, opts) {
|
|
613
|
+
if (!opts.json || opts.dryRun)
|
|
614
|
+
for (const line of lines)
|
|
615
|
+
(opts.json ? console.error : console.log)(line);
|
|
616
|
+
if (opts.dryRun)
|
|
617
|
+
return false;
|
|
618
|
+
if (opts.yes)
|
|
619
|
+
return true;
|
|
620
|
+
if (!process.stdin.isTTY)
|
|
621
|
+
throw new VageError("Confirmation required. Review the plan with --dry-run, then re-run with --yes.");
|
|
622
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
623
|
+
try {
|
|
624
|
+
return /^y(es)?$/i.test((await rl.question("Proceed? [y/N] ")).trim());
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
rl.close();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function reportPublish(result) {
|
|
631
|
+
if (result.status === "aborted")
|
|
632
|
+
return console.log("Nothing changed.");
|
|
633
|
+
if (result.status === "up-to-date")
|
|
634
|
+
return console.log("Nothing to publish: no changes and the remote is up to date.");
|
|
635
|
+
if (result.status === "committed")
|
|
636
|
+
return console.log(`Committed ${result.commit.slice(0, 7)} locally (no remote).`);
|
|
637
|
+
const r = result.confirmedRemote;
|
|
638
|
+
console.log(`Pushed ${r.sha.slice(0, 7)} to ${r.url} (${r.branch}); confirmed on the remote.`);
|
|
639
|
+
}
|
|
640
|
+
function print(value) {
|
|
641
|
+
console.log(JSON.stringify(value, null, 2));
|
|
642
|
+
}
|
|
643
|
+
/** Asks for the user's own API key (hidden input), optionally saving it outside the project. */
|
|
644
|
+
async function askForApiKey(name, opts = {}) {
|
|
645
|
+
const { API_KEYS, saveApiKey } = await import("../integrations/api-keys.js");
|
|
646
|
+
const k = API_KEYS[name];
|
|
647
|
+
console.log(`\n${k.label} uses your own Google Cloud API key (vagejs has no shared key).\nGet one: ${k.howTo}`);
|
|
648
|
+
const key = (await askHidden(`Paste your ${name} API key (input hidden, Enter to skip): `)).trim();
|
|
649
|
+
if (!key)
|
|
650
|
+
return undefined;
|
|
651
|
+
let save = opts.alwaysSave ?? false;
|
|
652
|
+
if (!save) {
|
|
653
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
654
|
+
try {
|
|
655
|
+
save = !/^n(o)?$/i.test((await rl.question("Save it for future audits, outside this project? [Y/n] ")).trim());
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
rl.close();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (save)
|
|
662
|
+
console.log(`Saved to ${saveApiKey(name, key)} (mode 600).`);
|
|
663
|
+
return key;
|
|
664
|
+
}
|
|
665
|
+
function askHidden(question) {
|
|
666
|
+
const stdin = process.stdin;
|
|
667
|
+
process.stdout.write(question);
|
|
668
|
+
stdin.setRawMode(true);
|
|
669
|
+
stdin.resume();
|
|
670
|
+
stdin.setEncoding("utf8");
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
let value = "";
|
|
673
|
+
const done = () => {
|
|
674
|
+
stdin.off("data", onData);
|
|
675
|
+
stdin.setRawMode(false);
|
|
676
|
+
stdin.pause();
|
|
677
|
+
process.stdout.write("\n");
|
|
678
|
+
};
|
|
679
|
+
const onData = (chunk) => {
|
|
680
|
+
for (const ch of chunk) {
|
|
681
|
+
if (ch === "\r" || ch === "\n") {
|
|
682
|
+
done();
|
|
683
|
+
return resolve(value);
|
|
684
|
+
}
|
|
685
|
+
if (ch === "\u0003") {
|
|
686
|
+
done();
|
|
687
|
+
return reject(new VageError("Cancelled."));
|
|
688
|
+
}
|
|
689
|
+
value = ch === "\u007f" || ch === "\b" ? value.slice(0, -1) : value + ch;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
stdin.on("data", onData);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
function openBrowser(url) {
|
|
696
|
+
const [cmd, args] =
|
|
697
|
+
// Windows: rundll32 opens the URL without cmd.exe, which would treat "&" in the URL as a command separator.
|
|
698
|
+
process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
699
|
+
// Best effort; the URL is printed too.
|
|
700
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
|
|
701
|
+
}
|
|
702
|
+
function parsePort(value) {
|
|
703
|
+
if (value === undefined)
|
|
704
|
+
return undefined;
|
|
705
|
+
const port = Number(value);
|
|
706
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
707
|
+
throw new VageError(`Invalid port "${value}". Use a whole number between 1 and 65535.`);
|
|
708
|
+
}
|
|
709
|
+
return port;
|
|
710
|
+
}
|