@x12i/docify-export 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/publish.js CHANGED
@@ -4,16 +4,206 @@ import { join } from "node:path";
4
4
  import { DocifyConfigSchema, resolvePublishHostname, } from "@x12i/docify-core";
5
5
  const PAGES_MAX_FILES = 20_000;
6
6
  const PAGES_MAX_FILE_BYTES = 25 * 1024 * 1024;
7
- function defaultExec(command, args, opts) {
7
+ const ACCOUNT_ID_RE = /\b([a-f0-9]{32})\b/gi;
8
+ /** Never return an empty failure message when status ends with -failed. */
9
+ export function failedMessage(r, fallback) {
10
+ const text = [r.stderr, r.stdout, r.error?.message]
11
+ .map((s) => (typeof s === "string" ? s.trim() : ""))
12
+ .find((s) => s.length > 0);
13
+ if (text)
14
+ return text;
15
+ if (r.status === null && r.error)
16
+ return r.error.message;
17
+ return fallback;
18
+ }
19
+ export function defaultExec(command, args, opts) {
20
+ // Interactive commands (OAuth consent) must reach the terminal, otherwise the
21
+ // login URL and prompt are captured and the user sees nothing to approve.
22
+ // On Windows, npx is typically a .cmd shim — spawn without shell yields empty pipes.
23
+ const useShell = process.platform === "win32" || command === "npx";
8
24
  const r = spawnSync(command, args, {
9
25
  encoding: "utf8",
10
26
  env: { ...process.env, ...opts?.env },
27
+ stdio: opts?.interactive ? "inherit" : "pipe",
28
+ shell: useShell,
11
29
  });
12
30
  return {
13
31
  status: r.status,
14
32
  stdout: r.stdout ?? "",
15
33
  stderr: r.stderr ?? "",
34
+ error: r.error,
35
+ };
36
+ }
37
+ /** Parse account ids (and optional names) from `wrangler whoami` text or JSON. */
38
+ export function parseWhoamiAccounts(output) {
39
+ const trimmed = (output || "").trim();
40
+ if (!trimmed)
41
+ return [];
42
+ // JSON forms: { accounts: [{ id, name }] } or array of accounts
43
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
44
+ try {
45
+ const data = JSON.parse(trimmed);
46
+ const list = Array.isArray(data)
47
+ ? data
48
+ : data &&
49
+ typeof data === "object" &&
50
+ Array.isArray(data.accounts)
51
+ ? data.accounts
52
+ : null;
53
+ if (list) {
54
+ const fromJson = [];
55
+ for (const item of list) {
56
+ if (!item || typeof item !== "object")
57
+ continue;
58
+ const id = typeof item.id === "string"
59
+ ? item.id
60
+ : typeof item.account_id === "string"
61
+ ? item.account_id
62
+ : "";
63
+ if (!/^[a-f0-9]{32}$/i.test(id))
64
+ continue;
65
+ const name = typeof item.name === "string"
66
+ ? item.name
67
+ : undefined;
68
+ fromJson.push({ id: id.toLowerCase(), name });
69
+ }
70
+ if (fromJson.length)
71
+ return uniqueAccounts(fromJson);
72
+ }
73
+ }
74
+ catch {
75
+ // fall through to text parse
76
+ }
77
+ }
78
+ const accounts = [];
79
+ const lines = trimmed.split(/\r?\n/);
80
+ for (const line of lines) {
81
+ const ids = [...line.matchAll(ACCOUNT_ID_RE)].map((m) => m[1].toLowerCase());
82
+ if (!ids.length)
83
+ continue;
84
+ // Prefer last hex token on the line (typical whoami table: name … id)
85
+ const id = ids[ids.length - 1];
86
+ const namePart = line
87
+ .replace(ACCOUNT_ID_RE, "")
88
+ .replace(/[|\u2502]/g, " ")
89
+ .replace(/\s+/g, " ")
90
+ .trim();
91
+ accounts.push({
92
+ id,
93
+ name: namePart || undefined,
94
+ });
95
+ }
96
+ return uniqueAccounts(accounts);
97
+ }
98
+ function uniqueAccounts(list) {
99
+ const seen = new Set();
100
+ const out = [];
101
+ for (const a of list) {
102
+ if (seen.has(a.id))
103
+ continue;
104
+ seen.add(a.id);
105
+ out.push(a);
106
+ }
107
+ return out;
108
+ }
109
+ /** Parse Pages projects + custom domains from list --json or text. */
110
+ export function parsePagesProjectList(output) {
111
+ const trimmed = (output || "").trim();
112
+ if (!trimmed)
113
+ return [];
114
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
115
+ try {
116
+ const data = JSON.parse(trimmed);
117
+ const list = Array.isArray(data)
118
+ ? data
119
+ : data &&
120
+ typeof data === "object" &&
121
+ Array.isArray(data.projects)
122
+ ? data.projects
123
+ : data &&
124
+ typeof data === "object" &&
125
+ Array.isArray(data.result)
126
+ ? data.result
127
+ : null;
128
+ if (list) {
129
+ const projects = [];
130
+ for (const item of list) {
131
+ if (!item || typeof item !== "object")
132
+ continue;
133
+ const name = typeof item.name === "string"
134
+ ? item.name
135
+ : typeof item.project_name ===
136
+ "string"
137
+ ? item.project_name
138
+ : "";
139
+ if (!name)
140
+ continue;
141
+ const domains = collectDomains(item);
142
+ projects.push({ name, domains });
143
+ }
144
+ if (projects.length)
145
+ return projects;
146
+ }
147
+ }
148
+ catch {
149
+ // fall through
150
+ }
151
+ }
152
+ // Text fallback: lines that look like "name domain1, domain2" or include *.pages.dev
153
+ const projects = [];
154
+ for (const line of trimmed.split(/\r?\n/)) {
155
+ const domainMatches = [
156
+ ...line.matchAll(/\b([a-z0-9][a-z0-9.-]*\.(?:pages\.dev|[a-z0-9.-]+\.[a-z]{2,}))\b/gi),
157
+ ].map((m) => m[1].toLowerCase());
158
+ const nameMatch = /^[\s|]*([a-z0-9][a-z0-9_-]*)/i.exec(line);
159
+ if (!nameMatch)
160
+ continue;
161
+ const name = nameMatch[1];
162
+ if (/^(name|project|id|created|account)$/i.test(name) ||
163
+ name.length < 2) {
164
+ continue;
165
+ }
166
+ projects.push({ name, domains: domainMatches });
167
+ }
168
+ return projects;
169
+ }
170
+ function collectDomains(item) {
171
+ const domains = [];
172
+ const push = (v) => {
173
+ if (typeof v === "string" && v.trim())
174
+ domains.push(v.trim().toLowerCase());
16
175
  };
176
+ const rec = item;
177
+ push(rec.subdomain);
178
+ push(rec.domain);
179
+ if (Array.isArray(rec.domains)) {
180
+ for (const d of rec.domains) {
181
+ if (typeof d === "string")
182
+ push(d);
183
+ else if (d && typeof d === "object") {
184
+ push(d.name);
185
+ push(d.domain);
186
+ }
187
+ }
188
+ }
189
+ if (Array.isArray(rec.aliases)) {
190
+ for (const a of rec.aliases)
191
+ push(a);
192
+ }
193
+ if (rec.canonical_deployment && typeof rec.canonical_deployment === "object") {
194
+ const cd = rec.canonical_deployment;
195
+ if (Array.isArray(cd.aliases))
196
+ for (const a of cd.aliases)
197
+ push(a);
198
+ }
199
+ return [...new Set(domains)];
200
+ }
201
+ function findProjectByHostname(projects, hostname) {
202
+ const host = hostname.toLowerCase().replace(/^https?:\/\//, "");
203
+ return projects.find((p) => p.domains.some((d) => {
204
+ const domain = d.toLowerCase().replace(/^https?:\/\//, "");
205
+ return domain === host;
206
+ }));
17
207
  }
18
208
  function countFiles(dir) {
19
209
  let count = 0;
@@ -35,6 +225,11 @@ function countFiles(dir) {
35
225
  walk(dir);
36
226
  return { count, tooLarge };
37
227
  }
228
+ function formatAccountTable(accounts) {
229
+ return accounts
230
+ .map((a) => (a.name ? ` - ${a.name} (${a.id})` : ` - ${a.id}`))
231
+ .join("\n");
232
+ }
38
233
  /**
39
234
  * Provider-neutral Docify publisher. Cloudflare Pages is the first adapter,
40
235
  * using Wrangler Direct Upload (no credentials written to the repo).
@@ -51,16 +246,82 @@ export function createDocifyPublisher(configInput, deps = {}) {
51
246
  const publishCfg = publish;
52
247
  const hostname = resolvePublishHostname(publishCfg);
53
248
  const customDomainUrl = `https://${hostname}`;
54
- const project = publishCfg.projectName;
249
+ let project = publishCfg.projectName;
55
250
  const outputDir = config.build.outputDir;
251
+ const configuredAccountId = publishCfg.accountId ||
252
+ process.env.CLOUDFLARE_ACCOUNT_ID ||
253
+ process.env.CF_ACCOUNT_ID ||
254
+ "";
255
+ let effectiveAccountId = configuredAccountId;
256
+ const adoptExisting = deps.adoptExistingProject === true ||
257
+ publishCfg.adoptExistingProject === true;
258
+ const wranglerEnv = () => effectiveAccountId ? { CLOUDFLARE_ACCOUNT_ID: effectiveAccountId } : {};
259
+ const wrangler = (args, opts) => exec("npx", ["--yes", "wrangler", ...args], {
260
+ env: wranglerEnv(),
261
+ interactive: opts?.interactive,
262
+ });
263
+ const dashboardFor = (proj) => effectiveAccountId
264
+ ? `https://dash.cloudflare.com/${effectiveAccountId}/pages/view/${proj}`
265
+ : `https://dash.cloudflare.com/?to=/:account/pages/view/${proj}`;
56
266
  const baseResult = (environment, extra = {}) => ({
57
267
  provider: publishCfg.provider,
58
268
  project,
59
269
  environment,
60
270
  customDomainUrl,
61
271
  status: "ok",
272
+ dashboardUrl: dashboardFor(project),
62
273
  ...extra,
63
274
  });
275
+ function resolveAccount() {
276
+ if (configuredAccountId) {
277
+ effectiveAccountId = configuredAccountId;
278
+ return {
279
+ ok: true,
280
+ accountId: configuredAccountId,
281
+ accounts: [{ id: configuredAccountId }],
282
+ };
283
+ }
284
+ // Prefer JSON when Wrangler supports it; fall back to text.
285
+ let who = wrangler(["whoami", "--json"]);
286
+ let combined = `${who.stdout || ""}\n${who.stderr || ""}`;
287
+ let accounts = parseWhoamiAccounts(combined);
288
+ if (who.status !== 0 || accounts.length === 0) {
289
+ who = wrangler(["whoami"]);
290
+ combined = `${who.stdout || ""}\n${who.stderr || ""}`;
291
+ accounts = parseWhoamiAccounts(combined);
292
+ }
293
+ if (who.status !== 0 && accounts.length === 0) {
294
+ return {
295
+ ok: false,
296
+ status: "preflight-failed",
297
+ message: failedMessage(who, "Not logged in to Cloudflare. Run `docify publish connect`, then retry."),
298
+ };
299
+ }
300
+ if (accounts.length === 0) {
301
+ return {
302
+ ok: false,
303
+ status: "preflight-failed",
304
+ message: "No Cloudflare accounts visible. Run `docify publish connect`, then retry.",
305
+ };
306
+ }
307
+ if (accounts.length >= 2) {
308
+ const first = accounts[0].id;
309
+ return {
310
+ ok: false,
311
+ status: "preflight-failed",
312
+ message: [
313
+ "More than one Cloudflare account is available; set publish.accountId (or CLOUDFLARE_ACCOUNT_ID) for non-interactive deploy.",
314
+ "Accounts:",
315
+ formatAccountTable(accounts),
316
+ "Add to docify.config.json:",
317
+ JSON.stringify({ publish: { accountId: first } }, null, 2),
318
+ "(Pick the correct account id from the list above.)",
319
+ ].join("\n"),
320
+ };
321
+ }
322
+ effectiveAccountId = accounts[0].id;
323
+ return { ok: true, accountId: effectiveAccountId, accounts };
324
+ }
64
325
  async function verifyDeployment(baseUrl) {
65
326
  const checked = [];
66
327
  const failed = [];
@@ -82,7 +343,7 @@ export function createDocifyPublisher(configInput, deps = {}) {
82
343
  providerUrl: baseUrl,
83
344
  });
84
345
  }
85
- function preflight() {
346
+ function preflightBuildLimits() {
86
347
  if (!existsSync(outputDir)) {
87
348
  throw new Error(`Build output missing: ${outputDir}. Run build first.`);
88
349
  }
@@ -94,68 +355,182 @@ export function createDocifyPublisher(configInput, deps = {}) {
94
355
  throw new Error(`Preflight failed: files exceed size limit:\n${tooLarge.slice(0, 10).join("\n")}`);
95
356
  }
96
357
  }
358
+ function runPreflight() {
359
+ try {
360
+ preflightBuildLimits();
361
+ }
362
+ catch (e) {
363
+ return baseResult("production", {
364
+ status: "preflight-failed",
365
+ message: e instanceof Error ? e.message : String(e),
366
+ });
367
+ }
368
+ const account = resolveAccount();
369
+ if (!account.ok) {
370
+ return baseResult("production", {
371
+ status: account.status,
372
+ message: account.message,
373
+ });
374
+ }
375
+ const { count } = countFiles(outputDir);
376
+ return baseResult("production", {
377
+ status: "preflight-ok",
378
+ message: `${count} files under ${PAGES_MAX_FILES}; account=${account.accountId}`,
379
+ });
380
+ }
381
+ function ensureAccountOrFail(environment = "production") {
382
+ const account = resolveAccount();
383
+ if (!account.ok) {
384
+ return baseResult(environment, {
385
+ status: account.status,
386
+ message: account.message,
387
+ });
388
+ }
389
+ return null;
390
+ }
391
+ function listProjects() {
392
+ let list = wrangler(["pages", "project", "list", "--json"]);
393
+ let projects = parsePagesProjectList(list.stdout || list.stderr || "");
394
+ if (list.status !== 0 || projects.length === 0) {
395
+ list = wrangler(["pages", "project", "list"]);
396
+ if (list.status !== 0) {
397
+ return {
398
+ ok: false,
399
+ result: baseResult("production", {
400
+ status: "setup-failed",
401
+ message: failedMessage(list, "Failed to list Cloudflare Pages projects"),
402
+ }),
403
+ };
404
+ }
405
+ projects = parsePagesProjectList(list.stdout || list.stderr || "");
406
+ }
407
+ return { ok: true, projects };
408
+ }
97
409
  return {
98
410
  validateConfig() {
99
411
  return config;
100
412
  },
101
413
  async connect() {
102
414
  log("Connecting via Wrangler login (credentials stay outside the repo)…");
103
- const r = exec("npx", ["--yes", "wrangler", "login"]);
415
+ log("Approve the Cloudflare consent screen in the browser window that opens.");
416
+ const r = wrangler(["login"], { interactive: true });
104
417
  if (r.status !== 0) {
105
418
  return baseResult("production", {
106
419
  status: "connect-failed",
107
- message: r.stderr || r.stdout || "wrangler login failed",
420
+ message: failedMessage(r, "wrangler login failed"),
108
421
  });
109
422
  }
110
- const who = exec("npx", ["--yes", "wrangler", "whoami"]);
423
+ const who = wrangler(["whoami"]);
111
424
  return baseResult("production", {
112
425
  status: "connected",
113
- message: (who.stdout || "Connected").trim(),
426
+ message: (who.stdout || who.stderr || "Connected").trim() || "Connected",
427
+ });
428
+ },
429
+ async whoami() {
430
+ const who = wrangler(["whoami"]);
431
+ return baseResult("production", {
432
+ status: who.status === 0 ? "ok" : "whoami-failed",
433
+ message: who.status === 0
434
+ ? (who.stdout || who.stderr || "").trim() || "ok"
435
+ : failedMessage(who, "wrangler whoami failed"),
436
+ dashboardUrl: dashboardFor(project),
114
437
  });
115
438
  },
439
+ preflight: runPreflight,
116
440
  async setup() {
441
+ const accountFail = ensureAccountOrFail();
442
+ if (accountFail) {
443
+ return { ...accountFail, status: "setup-failed", message: accountFail.message };
444
+ }
117
445
  log(`Setting up Cloudflare Pages project "${project}" for ${hostname}…`);
118
- const list = exec("npx", ["--yes", "wrangler", "pages", "project", "list"]);
119
- const exists = (list.stdout || "").includes(project);
120
- if (!exists) {
121
- const confirmed = deps.confirm == null
122
- ? true
123
- : await deps.confirm(`Create Cloudflare Pages project "${project}"? Direct Upload projects cannot later switch to Git integration.`);
124
- if (!confirmed) {
446
+ const listed = listProjects();
447
+ if (!listed.ok)
448
+ return listed.result;
449
+ const { projects } = listed;
450
+ const byHostname = findProjectByHostname(projects, hostname);
451
+ const nameExists = projects.some((p) => p.name === project);
452
+ if (byHostname) {
453
+ if (byHostname.name === project) {
125
454
  return baseResult("production", {
126
- status: "setup-cancelled",
127
- message: "User cancelled project creation",
455
+ status: "adopted-existing-project",
456
+ message: `Hostname ${hostname} is already on Pages project "${project}". Associate custom domain in the dashboard if needed. Docify will not delete other domains.`,
457
+ dashboardUrl: dashboardFor(project),
128
458
  });
129
459
  }
130
- const created = exec("npx", [
131
- "--yes",
132
- "wrangler",
133
- "pages",
134
- "project",
135
- "create",
136
- project,
137
- "--production-branch",
138
- publishCfg.productionBranch,
139
- ]);
140
- if (created.status !== 0) {
460
+ if (!adoptExisting) {
141
461
  return baseResult("production", {
142
462
  status: "setup-failed",
143
- message: created.stderr || created.stdout,
463
+ message: [
464
+ `Hostname ${hostname} is already bound to Pages project "${byHostname.name}", but docify.config.json has projectName "${project}".`,
465
+ `Set "projectName": "${byHostname.name}" (or pass --adopt / publish.adoptExistingProject: true) and retry.`,
466
+ ].join("\n"),
467
+ project: byHostname.name,
468
+ dashboardUrl: dashboardFor(byHostname.name),
144
469
  });
145
470
  }
471
+ project = byHostname.name;
472
+ return baseResult("production", {
473
+ status: "adopted-existing-project",
474
+ message: `Adopted existing Pages project "${project}" that already serves ${hostname} (config projectName differed).`,
475
+ dashboardUrl: dashboardFor(project),
476
+ });
477
+ }
478
+ if (nameExists) {
479
+ return baseResult("production", {
480
+ status: "adopted-existing-project",
481
+ message: `Associate custom domain ${hostname} in Cloudflare Pages if not already linked. Docify will not delete other domains.`,
482
+ dashboardUrl: dashboardFor(project),
483
+ });
484
+ }
485
+ const confirmed = deps.confirm == null
486
+ ? true
487
+ : await deps.confirm(`Create Cloudflare Pages project "${project}"? Direct Upload projects cannot later switch to Git integration.`);
488
+ if (!confirmed) {
489
+ return baseResult("production", {
490
+ status: "setup-cancelled",
491
+ message: "User cancelled project creation",
492
+ });
493
+ }
494
+ const created = wrangler([
495
+ "pages",
496
+ "project",
497
+ "create",
498
+ project,
499
+ "--production-branch",
500
+ publishCfg.productionBranch,
501
+ ]);
502
+ if (created.status !== 0) {
503
+ return baseResult("production", {
504
+ status: "setup-failed",
505
+ message: failedMessage(created, "pages project create failed"),
506
+ });
146
507
  }
147
508
  return baseResult("production", {
148
- status: exists ? "adopted-existing-project" : "project-created",
509
+ status: "project-created",
149
510
  message: `Associate custom domain ${hostname} in Cloudflare Pages if not already linked. Docify will not delete other domains.`,
150
- dashboardUrl: `https://dash.cloudflare.com/?to=/:account/pages/view/${project}`,
511
+ dashboardUrl: dashboardFor(project),
151
512
  });
152
513
  },
153
514
  async deployProduction() {
154
- preflight();
515
+ try {
516
+ preflightBuildLimits();
517
+ }
518
+ catch (e) {
519
+ return baseResult("production", {
520
+ status: "deploy-failed",
521
+ message: e instanceof Error ? e.message : String(e),
522
+ });
523
+ }
524
+ const accountFail = ensureAccountOrFail();
525
+ if (accountFail) {
526
+ return {
527
+ ...accountFail,
528
+ status: "deploy-failed",
529
+ message: accountFail.message,
530
+ };
531
+ }
155
532
  log(`Deploying ${outputDir} → Pages project ${project} (production)…`);
156
- const r = exec("npx", [
157
- "--yes",
158
- "wrangler",
533
+ const r = wrangler([
159
534
  "pages",
160
535
  "deploy",
161
536
  outputDir,
@@ -163,11 +538,12 @@ export function createDocifyPublisher(configInput, deps = {}) {
163
538
  project,
164
539
  "--branch",
165
540
  publishCfg.productionBranch,
541
+ "--commit-dirty=true",
166
542
  ]);
167
543
  if (r.status !== 0) {
168
544
  return baseResult("production", {
169
545
  status: "deploy-failed",
170
- message: r.stderr || r.stdout,
546
+ message: failedMessage(r, "pages deploy failed"),
171
547
  });
172
548
  }
173
549
  const urlMatch = /https:\/\/[^\s]+/.exec(r.stdout);
@@ -182,14 +558,29 @@ export function createDocifyPublisher(configInput, deps = {}) {
182
558
  return {
183
559
  ...verified,
184
560
  providerUrl: providerUrl ?? verified.providerUrl,
185
- message: r.stdout.trim(),
561
+ message: r.stdout.trim() || verified.message,
562
+ dashboardUrl: dashboardFor(project),
186
563
  };
187
564
  },
188
565
  async deployPreview(branch = "preview") {
189
- preflight();
190
- const r = exec("npx", [
191
- "--yes",
192
- "wrangler",
566
+ try {
567
+ preflightBuildLimits();
568
+ }
569
+ catch (e) {
570
+ return baseResult("preview", {
571
+ status: "deploy-failed",
572
+ message: e instanceof Error ? e.message : String(e),
573
+ });
574
+ }
575
+ const accountFail = ensureAccountOrFail("preview");
576
+ if (accountFail) {
577
+ return {
578
+ ...accountFail,
579
+ status: "deploy-failed",
580
+ message: accountFail.message,
581
+ };
582
+ }
583
+ const r = wrangler([
193
584
  "pages",
194
585
  "deploy",
195
586
  outputDir,
@@ -197,11 +588,12 @@ export function createDocifyPublisher(configInput, deps = {}) {
197
588
  project,
198
589
  "--branch",
199
590
  branch,
591
+ "--commit-dirty=true",
200
592
  ]);
201
593
  if (r.status !== 0) {
202
594
  return baseResult("preview", {
203
595
  status: "deploy-failed",
204
- message: r.stderr || r.stdout,
596
+ message: failedMessage(r, "pages deploy failed"),
205
597
  });
206
598
  }
207
599
  const urlMatch = /https:\/\/[^\s]+/.exec(r.stdout);
@@ -210,12 +602,19 @@ export function createDocifyPublisher(configInput, deps = {}) {
210
602
  status: "deployed",
211
603
  providerUrl,
212
604
  message: r.stdout.trim(),
605
+ dashboardUrl: dashboardFor(project),
213
606
  });
214
607
  },
215
608
  async getStatus() {
216
- const r = exec("npx", [
217
- "--yes",
218
- "wrangler",
609
+ const accountFail = ensureAccountOrFail();
610
+ if (accountFail) {
611
+ return {
612
+ ...accountFail,
613
+ status: "status-failed",
614
+ message: accountFail.message,
615
+ };
616
+ }
617
+ const r = wrangler([
219
618
  "pages",
220
619
  "deployment",
221
620
  "list",
@@ -224,13 +623,23 @@ export function createDocifyPublisher(configInput, deps = {}) {
224
623
  ]);
225
624
  return baseResult("production", {
226
625
  status: r.status === 0 ? "ok" : "status-failed",
227
- message: (r.stdout || r.stderr).trim(),
626
+ message: r.status === 0
627
+ ? (r.stdout || r.stderr).trim() || "ok"
628
+ : failedMessage(r, "deployment list failed"),
629
+ dashboardUrl: dashboardFor(project),
228
630
  });
229
631
  },
230
632
  async listDeployments() {
231
- const r = exec("npx", [
232
- "--yes",
233
- "wrangler",
633
+ const accountFail = ensureAccountOrFail();
634
+ if (accountFail) {
635
+ return {
636
+ ...accountFail,
637
+ status: "list-failed",
638
+ message: accountFail.message,
639
+ deployments: [],
640
+ };
641
+ }
642
+ const r = wrangler([
234
643
  "pages",
235
644
  "deployment",
236
645
  "list",
@@ -240,7 +649,10 @@ export function createDocifyPublisher(configInput, deps = {}) {
240
649
  return {
241
650
  ...baseResult("production", {
242
651
  status: r.status === 0 ? "ok" : "list-failed",
243
- message: (r.stdout || r.stderr).trim(),
652
+ message: r.status === 0
653
+ ? (r.stdout || r.stderr).trim() || "ok"
654
+ : failedMessage(r, "deployment list failed"),
655
+ dashboardUrl: dashboardFor(project),
244
656
  }),
245
657
  deployments: [],
246
658
  };
@@ -250,14 +662,21 @@ export function createDocifyPublisher(configInput, deps = {}) {
250
662
  deploymentId: id,
251
663
  status: "ok",
252
664
  message: `Inspect deployment ${id} in the Cloudflare dashboard`,
253
- dashboardUrl: `https://dash.cloudflare.com/?to=/:account/pages/view/${project}`,
665
+ dashboardUrl: dashboardFor(project),
254
666
  });
255
667
  },
256
668
  verifyDeployment,
257
669
  async rollbackDeployment(id) {
258
- const r = exec("npx", [
259
- "--yes",
260
- "wrangler",
670
+ const accountFail = ensureAccountOrFail();
671
+ if (accountFail) {
672
+ return {
673
+ ...accountFail,
674
+ deploymentId: id,
675
+ status: "rollback-failed",
676
+ message: accountFail.message,
677
+ };
678
+ }
679
+ const r = wrangler([
261
680
  "pages",
262
681
  "deployment",
263
682
  "rollback",
@@ -269,14 +688,13 @@ export function createDocifyPublisher(configInput, deps = {}) {
269
688
  return baseResult("production", {
270
689
  deploymentId: id,
271
690
  status: "rollback-failed",
272
- message: (r.stderr || r.stdout).trim() ||
273
- "Rollback failed. Use the Cloudflare Pages Deployments API rollback endpoint with a scoped API token.",
691
+ message: failedMessage(r, "Rollback failed. Use the Cloudflare Pages Deployments API rollback endpoint with a scoped API token."),
274
692
  });
275
693
  }
276
694
  return baseResult("production", {
277
695
  deploymentId: id,
278
696
  status: "rolled-back",
279
- message: r.stdout.trim(),
697
+ message: r.stdout.trim() || `Rolled back ${id}`,
280
698
  });
281
699
  },
282
700
  async publish(opts = {}) {