@reddoorla/maintenance 0.55.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/airtable-2BJX4ATJ.js +9 -0
- package/dist/airtable-2BJX4ATJ.js.map +1 -0
- package/dist/announce-JEVCDDKL.js +171 -0
- package/dist/announce-JEVCDDKL.js.map +1 -0
- package/dist/bump-deps-O3SOLQYY.js +57 -0
- package/dist/bump-deps-O3SOLQYY.js.map +1 -0
- package/dist/chunk-2X322ZES.js +99 -0
- package/dist/chunk-2X322ZES.js.map +1 -0
- package/dist/chunk-4NAOR44V.js +153 -0
- package/dist/chunk-4NAOR44V.js.map +1 -0
- package/dist/chunk-4PY4I5OR.js +1167 -0
- package/dist/chunk-4PY4I5OR.js.map +1 -0
- package/dist/chunk-5WL3ET24.js +203 -0
- package/dist/chunk-5WL3ET24.js.map +1 -0
- package/dist/chunk-6LIWDIXU.js +38 -0
- package/dist/chunk-6LIWDIXU.js.map +1 -0
- package/dist/chunk-6VAL7XJT.js +52 -0
- package/dist/chunk-6VAL7XJT.js.map +1 -0
- package/dist/chunk-BWLUNY7L.js +585 -0
- package/dist/chunk-BWLUNY7L.js.map +1 -0
- package/dist/chunk-CASCWQQX.js +161 -0
- package/dist/chunk-CASCWQQX.js.map +1 -0
- package/dist/chunk-CLVTVDOU.js +274 -0
- package/dist/chunk-CLVTVDOU.js.map +1 -0
- package/dist/chunk-D3DQFC32.js +79 -0
- package/dist/chunk-D3DQFC32.js.map +1 -0
- package/dist/chunk-DIVB6ZF7.js +217 -0
- package/dist/chunk-DIVB6ZF7.js.map +1 -0
- package/dist/chunk-EYODPUHV.js +37 -0
- package/dist/chunk-EYODPUHV.js.map +1 -0
- package/dist/chunk-FDIFTIXC.js +70 -0
- package/dist/chunk-FDIFTIXC.js.map +1 -0
- package/dist/chunk-FUD4I2ZW.js +35 -0
- package/dist/chunk-FUD4I2ZW.js.map +1 -0
- package/dist/chunk-HG5DTFNF.js +62 -0
- package/dist/chunk-HG5DTFNF.js.map +1 -0
- package/dist/chunk-HN6I2XWI.js +65 -0
- package/dist/chunk-HN6I2XWI.js.map +1 -0
- package/dist/chunk-HVOOCK6L.js +84 -0
- package/dist/chunk-HVOOCK6L.js.map +1 -0
- package/dist/chunk-IMIR7SC2.js +51 -0
- package/dist/chunk-IMIR7SC2.js.map +1 -0
- package/dist/chunk-J3OPWFZE.js +64 -0
- package/dist/chunk-J3OPWFZE.js.map +1 -0
- package/dist/chunk-JNXCDUYB.js +211 -0
- package/dist/chunk-JNXCDUYB.js.map +1 -0
- package/dist/chunk-JQG222CI.js +49 -0
- package/dist/chunk-JQG222CI.js.map +1 -0
- package/dist/chunk-LKGVSM2O.js +43 -0
- package/dist/chunk-LKGVSM2O.js.map +1 -0
- package/dist/chunk-LYCKN7ZR.js +41 -0
- package/dist/chunk-LYCKN7ZR.js.map +1 -0
- package/dist/chunk-O65I7G7G.js +246 -0
- package/dist/chunk-O65I7G7G.js.map +1 -0
- package/dist/chunk-OCS7SZUZ.js +243 -0
- package/dist/chunk-OCS7SZUZ.js.map +1 -0
- package/dist/chunk-OGGRFBL4.js +14 -0
- package/dist/chunk-OGGRFBL4.js.map +1 -0
- package/dist/chunk-OQPMZ6BM.js +368 -0
- package/dist/chunk-OQPMZ6BM.js.map +1 -0
- package/dist/chunk-OTGB6YQO.js +52 -0
- package/dist/chunk-OTGB6YQO.js.map +1 -0
- package/dist/chunk-PF6BNYGE.js +162 -0
- package/dist/chunk-PF6BNYGE.js.map +1 -0
- package/dist/chunk-QTHKOIFJ.js +13 -0
- package/dist/chunk-QTHKOIFJ.js.map +1 -0
- package/dist/chunk-U5SZ4FOP.js +26 -0
- package/dist/chunk-U5SZ4FOP.js.map +1 -0
- package/dist/chunk-VXSX2FHO.js +269 -0
- package/dist/chunk-VXSX2FHO.js.map +1 -0
- package/dist/chunk-W3G7XFBZ.js +444 -0
- package/dist/chunk-W3G7XFBZ.js.map +1 -0
- package/dist/chunk-WEKL2HYJ.js +31 -0
- package/dist/chunk-WEKL2HYJ.js.map +1 -0
- package/dist/chunk-WVRPOXKH.js +74 -0
- package/dist/chunk-WVRPOXKH.js.map +1 -0
- package/dist/chunk-XKOZUVK2.js +121 -0
- package/dist/chunk-XKOZUVK2.js.map +1 -0
- package/dist/chunk-XO2RUYVM.js +138 -0
- package/dist/chunk-XO2RUYVM.js.map +1 -0
- package/dist/chunk-XTK5VIQB.js +97 -0
- package/dist/chunk-XTK5VIQB.js.map +1 -0
- package/dist/chunk-XXTZBPUY.js +9 -0
- package/dist/chunk-XXTZBPUY.js.map +1 -0
- package/dist/chunk-ZYBOT5PM.js +153 -0
- package/dist/chunk-ZYBOT5PM.js.map +1 -0
- package/dist/cli/bin.js +75 -7785
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +37 -2336
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-EPVO3FEH.js +10 -0
- package/dist/client-EPVO3FEH.js.map +1 -0
- package/dist/client-JA6QLEDW.js +48 -0
- package/dist/client-JA6QLEDW.js.map +1 -0
- package/dist/configs/lighthouse.js +4 -31
- package/dist/configs/lighthouse.js.map +1 -1
- package/dist/configs/playwright-a11y.js +6 -33
- package/dist/configs/playwright-a11y.js.map +1 -1
- package/dist/convert-to-pnpm-UR64X3OX.js +51 -0
- package/dist/convert-to-pnpm-UR64X3OX.js.map +1 -0
- package/dist/db-AXKMRTVA.js +23 -0
- package/dist/db-AXKMRTVA.js.map +1 -0
- package/dist/digest-YTO73BEO.js +408 -0
- package/dist/digest-YTO73BEO.js.map +1 -0
- package/dist/forms/index.js +3 -8
- package/dist/forms/index.js.map +1 -1
- package/dist/github-signals-YD64R2AF.js +122 -0
- package/dist/github-signals-YD64R2AF.js.map +1 -0
- package/dist/index.js +102 -4757
- package/dist/index.js.map +1 -1
- package/dist/init-JITQXFVL.js +85 -0
- package/dist/init-JITQXFVL.js.map +1 -0
- package/dist/launch-HXHEL6ID.js +207 -0
- package/dist/launch-HXHEL6ID.js.map +1 -0
- package/dist/lighthouse-airtable-4FKYKFH6.js +12 -0
- package/dist/lighthouse-airtable-4FKYKFH6.js.map +1 -0
- package/dist/migrate-P2LS4WXI.js +7 -0
- package/dist/migrate-P2LS4WXI.js.map +1 -0
- package/dist/onboard-TCQOP2W4.js +69 -0
- package/dist/onboard-TCQOP2W4.js.map +1 -0
- package/dist/orchestrate-RMSBHLQK.js +19 -0
- package/dist/orchestrate-RMSBHLQK.js.map +1 -0
- package/dist/recipes/sync-configs.js +9 -505
- package/dist/recipes/sync-configs.js.map +1 -1
- package/dist/renovate-dispatch-ZGLXNPEK.js +97 -0
- package/dist/renovate-dispatch-ZGLXNPEK.js.map +1 -0
- package/dist/report-SD3CNBOQ.js +240 -0
- package/dist/report-SD3CNBOQ.js.map +1 -0
- package/dist/resend-G2BJMAQV.js +7 -0
- package/dist/resend-G2BJMAQV.js.map +1 -0
- package/dist/self-updating-CIUMOMYI.js +61 -0
- package/dist/self-updating-CIUMOMYI.js.map +1 -0
- package/dist/svelte-codemods-BYRJAU7I.js +51 -0
- package/dist/svelte-codemods-BYRJAU7I.js.map +1 -0
- package/dist/sync-configs-JPRLAW5J.js +109 -0
- package/dist/sync-configs-JPRLAW5J.js.map +1 -0
- package/dist/upgrade-ERA45EVR.js +64 -0
- package/dist/upgrade-ERA45EVR.js.map +1 -0
- package/dist/util/git.js +19 -76
- package/dist/util/git.js.map +1 -1
- package/dist/util/pkg.js +5 -45
- package/dist/util/pkg.js.map +1 -1
- package/dist/websites-X5DLC2PC.js +43 -0
- package/dist/websites-X5DLC2PC.js.map +1 -0
- package/dist/write-audits-to-airtable-CE4POBAF.js +13 -0
- package/dist/write-audits-to-airtable-CE4POBAF.js.map +1 -0
- package/package.json +13 -12
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ reddoor-maint upgrade svelte-4-to-5 [site]
|
|
|
86
86
|
|
|
87
87
|
## Recipes
|
|
88
88
|
|
|
89
|
-
Each recipe is `(site, opts?) => Promise<RecipeResult>` and is also exported from the package entry as a library function:
|
|
89
|
+
Each recipe is `(site, opts?) => Promise<RecipeResult>` and is also exported from the package entry as a library function (see [Library usage](#library-usage) for the dependency caveat on the bare entry):
|
|
90
90
|
|
|
91
91
|
```ts
|
|
92
92
|
import {
|
|
@@ -204,6 +204,8 @@ Pass `--fleet <path>` to run a command against multiple sites declared in an inv
|
|
|
204
204
|
|
|
205
205
|
The package's main entry exports every recipe, audit, and report function so you can wire them into custom tooling (CI jobs, scheduled scripts, alternative CLIs):
|
|
206
206
|
|
|
207
|
+
> **These bare-entry (`@reddoorla/maintenance`) library exports require the package's full dependency set.** The report/audit/dashboard stack — `mjml`, `airtable`, `resend`, the Google Analytics + libSQL/Kysely libraries, `sharp`, `svix`, `@lhci/cli` — ships as **devDependencies**, so a plain `pnpm add @reddoorla/maintenance` does **not** install them. Import these functions only from an environment that installs this package's dev dependencies (this repo's own CLI/Netlify functions, or your own tooling that installs them). **Fleet sites never use this entry** — they run the `reddoor-maint` CLI and import the dependency-light `@reddoorla/maintenance/forms` + `@reddoorla/maintenance/configs/*` subpaths, which by design pull none of that chain.
|
|
208
|
+
|
|
207
209
|
```ts
|
|
208
210
|
import {
|
|
209
211
|
// recipes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchGaUsers,
|
|
3
|
+
fetchSearch
|
|
4
|
+
} from "./chunk-W3G7XFBZ.js";
|
|
5
|
+
import {
|
|
6
|
+
queueDraft
|
|
7
|
+
} from "./chunk-6LIWDIXU.js";
|
|
8
|
+
import {
|
|
9
|
+
announcementSiteExtras,
|
|
10
|
+
renderReportHtml,
|
|
11
|
+
resolveCopy,
|
|
12
|
+
uploadAttachment
|
|
13
|
+
} from "./chunk-BWLUNY7L.js";
|
|
14
|
+
import {
|
|
15
|
+
createDraft,
|
|
16
|
+
findReportByPeriod,
|
|
17
|
+
updateReportScores
|
|
18
|
+
} from "./chunk-OCS7SZUZ.js";
|
|
19
|
+
import "./chunk-U5SZ4FOP.js";
|
|
20
|
+
import {
|
|
21
|
+
listWebsites,
|
|
22
|
+
siteSlug
|
|
23
|
+
} from "./chunk-CLVTVDOU.js";
|
|
24
|
+
import {
|
|
25
|
+
openBase,
|
|
26
|
+
readAirtableConfig
|
|
27
|
+
} from "./chunk-HN6I2XWI.js";
|
|
28
|
+
import "./chunk-6VAL7XJT.js";
|
|
29
|
+
|
|
30
|
+
// src/recipes/announce.ts
|
|
31
|
+
async function announce(deps) {
|
|
32
|
+
const base = deps?.base ?? openBase(readAirtableConfig());
|
|
33
|
+
const now = deps?.now ?? /* @__PURE__ */ new Date();
|
|
34
|
+
const websites = await listWebsites(base);
|
|
35
|
+
let targets = websites.filter((w) => w.status === "maintenance");
|
|
36
|
+
if (deps?.site) {
|
|
37
|
+
const wanted = siteSlug(deps.site);
|
|
38
|
+
targets = targets.filter((w) => siteSlug(w.name) === wanted);
|
|
39
|
+
}
|
|
40
|
+
const period = now.toISOString().slice(0, 7);
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const w of targets) {
|
|
43
|
+
try {
|
|
44
|
+
const scores = scoresFromRow(w);
|
|
45
|
+
if (scores === null) {
|
|
46
|
+
results.push({ site: w.name, status: "skipped-no-scores" });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const periodEnd = now;
|
|
50
|
+
const periodStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
51
|
+
const gaUsers = (await fetchGaUsers(w, periodStart, periodEnd)).value;
|
|
52
|
+
const search = (await fetchSearch(w, periodStart, periodEnd)).value;
|
|
53
|
+
const enrichment = {
|
|
54
|
+
...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
|
|
55
|
+
...search ? { searchFoundPage1: search.foundOnPage1 } : {},
|
|
56
|
+
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
|
|
57
|
+
};
|
|
58
|
+
let report;
|
|
59
|
+
let statusKind;
|
|
60
|
+
const existing = await findReportByPeriod(base, w.id, "Announcement", period);
|
|
61
|
+
if (existing) {
|
|
62
|
+
await updateReportScores(base, existing.id, scores, now, enrichment);
|
|
63
|
+
report = existing;
|
|
64
|
+
statusKind = "reused";
|
|
65
|
+
} else {
|
|
66
|
+
report = await createDraft(base, draftInputFor(w, scores, now, period, enrichment));
|
|
67
|
+
statusKind = "drafted";
|
|
68
|
+
}
|
|
69
|
+
const slug = siteSlug(w.name);
|
|
70
|
+
const { html } = await renderReportHtml({
|
|
71
|
+
siteName: w.name,
|
|
72
|
+
siteUrl: w.url,
|
|
73
|
+
reportType: "Announcement",
|
|
74
|
+
completedOn: now,
|
|
75
|
+
lighthouse: scores,
|
|
76
|
+
gaUsersCurrent: gaUsers?.current,
|
|
77
|
+
gaUsersPrevious: gaUsers?.previous,
|
|
78
|
+
searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
|
|
79
|
+
lastTestedDate: null,
|
|
80
|
+
commentary: null,
|
|
81
|
+
copy: resolveCopy(w),
|
|
82
|
+
headerImageCid: `${slug}-header`,
|
|
83
|
+
// cadence (the client's go-forward pace, "None" omitted) + default-on improvement
|
|
84
|
+
// callouts. Shared with the send re-render via announcementSiteExtras so the sent
|
|
85
|
+
// email matches this reviewed preview.
|
|
86
|
+
...announcementSiteExtras(w)
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
await uploadAttachment(
|
|
90
|
+
report.id,
|
|
91
|
+
"Rendered HTML",
|
|
92
|
+
html,
|
|
93
|
+
`${slug}-${now.toISOString().slice(0, 10)}.html`,
|
|
94
|
+
"text/html"
|
|
95
|
+
);
|
|
96
|
+
} catch (uploadErr) {
|
|
97
|
+
console.warn(
|
|
98
|
+
`\u26A0 Announcement preview upload skipped for ${w.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const queue = await queueDraft(base, {
|
|
102
|
+
id: report.id,
|
|
103
|
+
siteId: w.id,
|
|
104
|
+
reportType: "Announcement"
|
|
105
|
+
});
|
|
106
|
+
const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());
|
|
107
|
+
results.push({
|
|
108
|
+
site: w.name,
|
|
109
|
+
status: statusKind,
|
|
110
|
+
reportId: report.id,
|
|
111
|
+
recipientMissing,
|
|
112
|
+
queued: queue.queued
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
results.push({
|
|
116
|
+
site: w.name,
|
|
117
|
+
status: "error",
|
|
118
|
+
message: err instanceof Error ? err.message : String(err)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { results };
|
|
123
|
+
}
|
|
124
|
+
function scoresFromRow(w) {
|
|
125
|
+
if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
performance: w.pScore,
|
|
130
|
+
accessibility: w.rScore,
|
|
131
|
+
bestPractices: w.bpScore,
|
|
132
|
+
seo: w.seoScore
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function draftInputFor(w, scores, now, period, enrichment) {
|
|
136
|
+
return {
|
|
137
|
+
reportId: `${w.name} \u2014 Announcement \u2014 ${now.toISOString().slice(0, 10)}`,
|
|
138
|
+
siteId: w.id,
|
|
139
|
+
reportType: "Announcement",
|
|
140
|
+
period,
|
|
141
|
+
periodStart: now,
|
|
142
|
+
periodEnd: now,
|
|
143
|
+
completedOn: now,
|
|
144
|
+
lighthouse: scores,
|
|
145
|
+
lastTestedDate: null,
|
|
146
|
+
subjectOverride: `Your testing & maintenance schedule for ${w.name}`,
|
|
147
|
+
...enrichment
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/cli/commands/announce.ts
|
|
152
|
+
function formatSiteResult(r) {
|
|
153
|
+
if (r.status === "skipped-no-scores") return `[${r.site}] skipped-no-scores`;
|
|
154
|
+
if (r.status === "error") return `[${r.site}] error: ${r.message}`;
|
|
155
|
+
const note = r.recipientMissing ? " \u26A0 recipient missing" : "";
|
|
156
|
+
return `[${r.site}] ${r.status}${note}`;
|
|
157
|
+
}
|
|
158
|
+
function formatAnnounceResult(result) {
|
|
159
|
+
if (result.results.length === 0) return "No maintenance sites to announce.";
|
|
160
|
+
return result.results.map(formatSiteResult).join("\n");
|
|
161
|
+
}
|
|
162
|
+
async function runAnnounceCommand(site, _opts) {
|
|
163
|
+
const result = await announce(site ? { site } : {});
|
|
164
|
+
const hadError = result.results.some((r) => r.status === "error");
|
|
165
|
+
return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };
|
|
166
|
+
}
|
|
167
|
+
export {
|
|
168
|
+
formatAnnounceResult,
|
|
169
|
+
runAnnounceCommand
|
|
170
|
+
};
|
|
171
|
+
//# sourceMappingURL=announce-JEVCDDKL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/recipes/announce.ts","../src/cli/commands/announce.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport {\n createDraft,\n findReportByPeriod,\n updateReportScores,\n type ReportEnrichment,\n} from \"../reports/airtable/reports.js\";\nimport { queueDraft } from \"../reports/queue.js\";\nimport { uploadAttachment } from \"../reports/airtable/attachments.js\";\nimport { renderReportHtml } from \"../reports/render.js\";\nimport { resolveCopy } from \"../reports/copy.js\";\nimport { fetchGaUsers, fetchSearch } from \"../reports/draft.js\";\nimport { announcementSiteExtras } from \"../reports/announcement-email/template.js\";\nimport type { LighthouseScores } from \"../reports/types.js\";\n\nexport type AnnounceSiteResult =\n | {\n site: string;\n status: \"drafted\" | \"reused\";\n reportId: string;\n recipientMissing: boolean;\n /** False when a higher-or-equal-tier report was already queued (single-queue rule). */\n queued: boolean;\n }\n | { site: string; status: \"skipped-no-scores\" }\n | { site: string; status: \"error\"; message: string };\n\nexport type AnnounceResult = { results: AnnounceSiteResult[] };\n\nexport type AnnounceDeps = {\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n /** When set, restrict to the single site whose slug matches. Default: all maintenance sites. */\n site?: string;\n /** Single timestamp driving the period key, render, draft, and preview filename. */\n now?: Date;\n};\n\n/**\n * Draft the monthly-report ANNOUNCEMENT email for every `maintenance` site (or one,\n * via `deps.site`). Airtable-driven and fleet-wide: unlike `launch`, it runs no audits\n * and takes no `Site`/inventory object — it reads the Lighthouse scores already stored\n * on each Websites row. DRAFTS ONLY; the M3 approve loop sends.\n *\n * One bad site must never abort the run: each site is wrapped in its own try/catch that\n * records an `error` result and continues.\n */\nexport async function announce(deps?: AnnounceDeps): Promise<AnnounceResult> {\n const base = deps?.base ?? openBase(readAirtableConfig());\n const now = deps?.now ?? new Date();\n\n const websites = await listWebsites(base);\n let targets = websites.filter((w) => w.status === \"maintenance\");\n if (deps?.site) {\n const wanted = siteSlug(deps.site);\n targets = targets.filter((w) => siteSlug(w.name) === wanted);\n }\n\n const period = now.toISOString().slice(0, 7);\n const results: AnnounceSiteResult[] = [];\n\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (scores === null) {\n results.push({ site: w.name, status: \"skipped-no-scores\" });\n continue;\n }\n\n // Traffic + search snapshot over a ~30-day window ending now (fetchPeriodUsers derives\n // the equal-length previous window for the trend). Reuses the report pipeline's\n // soft-failing enrichment: GA/search unconfigured or an API error leaves the numbers\n // null and the email simply omits the traffic section — it never blocks the draft.\n const periodEnd = now;\n const periodStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);\n const gaUsers = (await fetchGaUsers(w, periodStart, periodEnd)).value;\n const search = (await fetchSearch(w, periodStart, periodEnd)).value;\n const enrichment: ReportEnrichment = {\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n };\n\n // Dedupe: reuse an existing Announcement row for this (site, period) rather than\n // stacking a second draft. The reuse path refreshes the stored scores + traffic/search\n // (and Completed on) so the eventually-sent email — which reads the row — isn't stale.\n // The create path writes them via createDraft.\n let report;\n let statusKind: \"drafted\" | \"reused\";\n const existing = await findReportByPeriod(base, w.id, \"Announcement\", period);\n if (existing) {\n await updateReportScores(base, existing.id, scores, now, enrichment);\n report = existing;\n statusKind = \"reused\";\n } else {\n report = await createDraft(base, draftInputFor(w, scores, now, period, enrichment));\n statusKind = \"drafted\";\n }\n\n const slug = siteSlug(w.name);\n const { html } = await renderReportHtml({\n siteName: w.name,\n siteUrl: w.url,\n reportType: \"Announcement\",\n completedOn: now,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate: null,\n commentary: null,\n copy: resolveCopy(w),\n headerImageCid: `${slug}-header`,\n // cadence (the client's go-forward pace, \"None\" omitted) + default-on improvement\n // callouts. Shared with the send re-render via announcementSiteExtras so the sent\n // email matches this reviewed preview.\n ...announcementSiteExtras(w),\n });\n\n // A preview-upload hiccup must NOT fail the site — log and continue.\n try {\n await uploadAttachment(\n report.id,\n \"Rendered HTML\",\n html,\n `${slug}-${now.toISOString().slice(0, 10)}.html`,\n \"text/html\",\n );\n } catch (uploadErr) {\n console.warn(\n `⚠ Announcement preview upload skipped for ${w.name}: ${\n uploadErr instanceof Error ? uploadErr.message : String(uploadErr)\n }`,\n );\n }\n\n // Critical: NOT wrapped — without queueing, the draft never enters the approve queue,\n // so a failure here must surface as an error result for the site. queueDraft also\n // supersedes any lower-tier (Maintenance/Testing) drafts queued for this site, and\n // stands down if an equal-or-higher report is already queued (single-queue rule).\n const queue = await queueDraft(base, {\n id: report.id,\n siteId: w.id,\n reportType: \"Announcement\",\n });\n\n const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());\n results.push({\n site: w.name,\n status: statusKind,\n reportId: report.id,\n recipientMissing,\n queued: queue.queued,\n });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n\n/** The four stored Lighthouse scores off a Websites row, or null if ANY is missing. */\nfunction scoresFromRow(w: WebsiteRow): LighthouseScores | null {\n if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {\n return null;\n }\n return {\n performance: w.pScore,\n accessibility: w.rScore,\n bestPractices: w.bpScore,\n seo: w.seoScore,\n };\n}\n\n/** Build the Announcement `DraftInput`. Announcements have no period window and no prior\n * maintenance test, so periodStart/periodEnd/completedOn all collapse to `now` and\n * `lastTestedDate` is null. The subject override gives the email a purpose-built line. */\nfunction draftInputFor(\n w: WebsiteRow,\n scores: LighthouseScores,\n now: Date,\n period: string,\n enrichment: ReportEnrichment,\n): Parameters<typeof createDraft>[1] {\n return {\n reportId: `${w.name} — Announcement — ${now.toISOString().slice(0, 10)}`,\n siteId: w.id,\n reportType: \"Announcement\",\n period,\n periodStart: now,\n periodEnd: now,\n completedOn: now,\n lighthouse: scores,\n lastTestedDate: null,\n subjectOverride: `Your testing & maintenance schedule for ${w.name}`,\n ...enrichment,\n };\n}\n","import { announce, type AnnounceResult, type AnnounceSiteResult } from \"../../recipes/announce.js\";\n\nexport type AnnounceCommandOptions = {\n cwd?: string;\n};\n\nfunction formatSiteResult(r: AnnounceSiteResult): string {\n if (r.status === \"skipped-no-scores\") return `[${r.site}] skipped-no-scores`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n const note = r.recipientMissing ? \" ⚠ recipient missing\" : \"\";\n return `[${r.site}] ${r.status}${note}`;\n}\n\nexport function formatAnnounceResult(result: AnnounceResult): string {\n if (result.results.length === 0) return \"No maintenance sites to announce.\";\n return result.results.map(formatSiteResult).join(\"\\n\");\n}\n\n/**\n * `announce [site]` — Airtable-driven and fleet-wide. Draft the monthly-report\n * announcement email for every `maintenance` site (or one, when `site` is given) into\n * the M3 approve queue. Never sends; the operator approves each draft and the next send\n * run delivers it. Reads the Lighthouse scores already stored on each Websites row —\n * no audits are run.\n */\nexport async function runAnnounceCommand(\n site: string | undefined,\n _opts: AnnounceCommandOptions,\n): Promise<{ output: string; code: number }> {\n const result = await announce(site ? { site } : {});\n const hadError = result.results.some((r) => r.status === \"error\");\n return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendSkipNotice,
|
|
3
|
+
fleetWorkdir,
|
|
4
|
+
prepareFleetSites
|
|
5
|
+
} from "./chunk-4NAOR44V.js";
|
|
6
|
+
import {
|
|
7
|
+
resolveSites
|
|
8
|
+
} from "./chunk-J3OPWFZE.js";
|
|
9
|
+
import {
|
|
10
|
+
bumpDeps
|
|
11
|
+
} from "./chunk-D3DQFC32.js";
|
|
12
|
+
import "./chunk-XKOZUVK2.js";
|
|
13
|
+
import "./chunk-FDIFTIXC.js";
|
|
14
|
+
import "./chunk-HVOOCK6L.js";
|
|
15
|
+
import "./chunk-XXTZBPUY.js";
|
|
16
|
+
import "./chunk-XTK5VIQB.js";
|
|
17
|
+
import "./chunk-U5SZ4FOP.js";
|
|
18
|
+
|
|
19
|
+
// src/cli/commands/bump-deps.ts
|
|
20
|
+
import { resolve } from "path";
|
|
21
|
+
var GROUPS = ["patch", "minor", "major"];
|
|
22
|
+
function formatResult(r) {
|
|
23
|
+
if (r.status === "noop") return `[${r.site}] noop: ${r.notes ?? ""}`;
|
|
24
|
+
return `[${r.site}] applied: ${r.commits.length} commit(s)
|
|
25
|
+
${r.notes ?? ""}`;
|
|
26
|
+
}
|
|
27
|
+
async function runBumpDepsCommand(site, opts) {
|
|
28
|
+
const group = opts.group ?? "minor";
|
|
29
|
+
if (!GROUPS.includes(group)) {
|
|
30
|
+
throw Object.assign(
|
|
31
|
+
new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(", ")}`),
|
|
32
|
+
{ exitCode: 2 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
36
|
+
let sites = await resolveSites({
|
|
37
|
+
...site !== void 0 ? { site } : {},
|
|
38
|
+
...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
|
|
39
|
+
cwd
|
|
40
|
+
});
|
|
41
|
+
let skipped = [];
|
|
42
|
+
if (opts.fleet) {
|
|
43
|
+
const workdir = opts.workdir ?? fleetWorkdir();
|
|
44
|
+
const prep = await prepareFleetSites(sites, { workdir });
|
|
45
|
+
sites = prep.prepared;
|
|
46
|
+
skipped = prep.skipped;
|
|
47
|
+
}
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const s of sites) results.push(await bumpDeps(s, { group }));
|
|
50
|
+
const output = results.map(formatResult).join("\n");
|
|
51
|
+
const code = results.some((r) => r.status === "failed") ? 1 : 0;
|
|
52
|
+
return { output: appendSkipNotice(output, skipped), code };
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
runBumpDepsCommand
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=bump-deps-O3SOLQYY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli/commands/bump-deps.ts"],"sourcesContent":["import { resolve } from \"node:path\";\nimport { bumpDeps, type BumpDepsGroup } from \"../../recipes/bump-deps.js\";\nimport type { RecipeResult } from \"../../types.js\";\nimport { resolveSites } from \"../fleet/resolve-sites.js\";\nimport { prepareFleetSites, appendSkipNotice, type SkippedSite } from \"../fleet/prepare-sites.js\";\nimport { fleetWorkdir } from \"../../util/fleet-workdir.js\";\n\nconst GROUPS: BumpDepsGroup[] = [\"patch\", \"minor\", \"major\"];\n\nexport type BumpDepsCommandOptions = {\n group?: string;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n};\n\nfunction formatResult(r: RecipeResult): string {\n if (r.status === \"noop\") return `[${r.site}] noop: ${r.notes ?? \"\"}`;\n return `[${r.site}] applied: ${r.commits.length} commit(s)\\n${r.notes ?? \"\"}`;\n}\n\nexport async function runBumpDepsCommand(\n site: string | undefined,\n opts: BumpDepsCommandOptions,\n): Promise<{ output: string; code: number }> {\n const group = (opts.group ?? \"minor\") as BumpDepsGroup;\n if (!GROUPS.includes(group)) {\n throw Object.assign(\n new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(\", \")}`),\n { exitCode: 2 },\n );\n }\n\n const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();\n\n let sites = await resolveSites({\n ...(site !== undefined ? { site } : {}),\n ...(opts.fleet !== undefined ? { fleet: opts.fleet } : {}),\n cwd,\n });\n\n let skipped: SkippedSite[] = [];\n if (opts.fleet) {\n const workdir = opts.workdir ?? fleetWorkdir();\n const prep = await prepareFleetSites(sites, { workdir });\n sites = prep.prepared;\n skipped = prep.skipped;\n }\n\n const results: RecipeResult[] = [];\n for (const s of sites) results.push(await bumpDeps(s, { group }));\n\n const output = results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"failed\") ? 1 : 0;\n return { output: appendSkipNotice(output, skipped), code };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAAA,SAAS,eAAe;AAOxB,IAAM,SAA0B,CAAC,SAAS,SAAS,OAAO;AAS1D,SAAS,aAAa,GAAyB;AAC7C,MAAI,EAAE,WAAW,OAAQ,QAAO,IAAI,EAAE,IAAI,WAAW,EAAE,SAAS,EAAE;AAClE,SAAO,IAAI,EAAE,IAAI,cAAc,EAAE,QAAQ,MAAM;AAAA,EAAe,EAAE,SAAS,EAAE;AAC7E;AAEA,eAAsB,mBACpB,MACA,MAC2C;AAC3C,QAAM,QAAS,KAAK,SAAS;AAC7B,MAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,UAAM,OAAO;AAAA,MACX,IAAI,MAAM,oBAAoB,KAAK,qBAAqB,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,MAC3E,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,MAAM,KAAK,MAAM,QAAQ,KAAK,GAAG,IAAI,QAAQ,IAAI;AAEvD,MAAI,QAAQ,MAAM,aAAa;AAAA,IAC7B,GAAI,SAAS,SAAY,EAAE,KAAK,IAAI,CAAC;AAAA,IACrC,GAAI,KAAK,UAAU,SAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;AAAA,IACxD;AAAA,EACF,CAAC;AAED,MAAI,UAAyB,CAAC;AAC9B,MAAI,KAAK,OAAO;AACd,UAAM,UAAU,KAAK,WAAW,aAAa;AAC7C,UAAM,OAAO,MAAM,kBAAkB,OAAO,EAAE,QAAQ,CAAC;AACvD,YAAQ,KAAK;AACb,cAAU,KAAK;AAAA,EACjB;AAEA,QAAM,UAA0B,CAAC;AACjC,aAAW,KAAK,MAAO,SAAQ,KAAK,MAAM,SAAS,GAAG,EAAE,MAAM,CAAC,CAAC;AAEhE,QAAM,SAAS,QAAQ,IAAI,YAAY,EAAE,KAAK,IAAI;AAClD,QAAM,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,IAAI,IAAI;AAC9D,SAAO,EAAE,QAAQ,iBAAiB,QAAQ,OAAO,GAAG,KAAK;AAC3D;","names":[]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
withRecipe
|
|
3
|
+
} from "./chunk-XKOZUVK2.js";
|
|
4
|
+
import {
|
|
5
|
+
readPackageJson,
|
|
6
|
+
writePackageJson
|
|
7
|
+
} from "./chunk-OTGB6YQO.js";
|
|
8
|
+
import {
|
|
9
|
+
defaultSpawn
|
|
10
|
+
} from "./chunk-HVOOCK6L.js";
|
|
11
|
+
|
|
12
|
+
// src/recipes/convert-to-pnpm.ts
|
|
13
|
+
import { rm, stat } from "fs/promises";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
// src/recipes/convert-to-pnpm/script-rewrites.ts
|
|
17
|
+
function rewriteScriptForPnpm(script) {
|
|
18
|
+
let out = script;
|
|
19
|
+
out = out.replace(/\bnpm run(?=\s)/g, "pnpm run");
|
|
20
|
+
out = out.replace(/\bnpx(?=\s)/g, "pnpm dlx");
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function rewriteScriptsForPnpm(scripts) {
|
|
24
|
+
const next = {};
|
|
25
|
+
let changedCount = 0;
|
|
26
|
+
for (const [name, value] of Object.entries(scripts)) {
|
|
27
|
+
const rewritten = rewriteScriptForPnpm(value);
|
|
28
|
+
next[name] = rewritten;
|
|
29
|
+
if (rewritten !== value) changedCount++;
|
|
30
|
+
}
|
|
31
|
+
return { scripts: next, changedCount };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/recipes/convert-to-pnpm.ts
|
|
35
|
+
var DEFAULT_PNPM_VERSION = "10.33.1";
|
|
36
|
+
async function exists(path) {
|
|
37
|
+
try {
|
|
38
|
+
await stat(path);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function convertToPnpm(site, opts = {}) {
|
|
45
|
+
const spawn = opts.spawn ?? defaultSpawn;
|
|
46
|
+
const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
|
|
47
|
+
const pnpmLockPath = join(site.path, "pnpm-lock.yaml");
|
|
48
|
+
const npmLockPath = join(site.path, "package-lock.json");
|
|
49
|
+
const yarnLockPath = join(site.path, "yarn.lock");
|
|
50
|
+
return withRecipe({
|
|
51
|
+
name: "convert-to-pnpm",
|
|
52
|
+
site,
|
|
53
|
+
plan: async () => {
|
|
54
|
+
if (await exists(pnpmLockPath)) {
|
|
55
|
+
return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
|
|
56
|
+
}
|
|
57
|
+
const hasNpmLock = await exists(npmLockPath);
|
|
58
|
+
const hasYarnLock = await exists(yarnLockPath);
|
|
59
|
+
if (!hasNpmLock && !hasYarnLock) {
|
|
60
|
+
return {
|
|
61
|
+
kind: "noop",
|
|
62
|
+
notes: "no convertible lockfile (package-lock.json or yarn.lock) at site root"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { kind: "apply", plan: { hasNpmLock, hasYarnLock } };
|
|
66
|
+
},
|
|
67
|
+
apply: async ({ hasNpmLock, hasYarnLock }, { commit, cwd }) => {
|
|
68
|
+
if (hasNpmLock) await rm(npmLockPath, { force: true });
|
|
69
|
+
if (hasYarnLock) await rm(yarnLockPath, { force: true });
|
|
70
|
+
const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
71
|
+
await commit(`chore(pnpm): remove ${sourceLock}`);
|
|
72
|
+
const pkgPath = join(cwd, "package.json");
|
|
73
|
+
const pkg = await readPackageJson(pkgPath);
|
|
74
|
+
const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
|
|
75
|
+
if (pkg.scripts && typeof pkg.scripts === "object") {
|
|
76
|
+
const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(
|
|
77
|
+
pkg.scripts
|
|
78
|
+
);
|
|
79
|
+
if (changedCount > 0) {
|
|
80
|
+
next.scripts = rewritten;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await writePackageJson(pkgPath, next);
|
|
84
|
+
await commit("chore(pnpm): pin packageManager + rewrite npm scripts");
|
|
85
|
+
await rm(join(cwd, "node_modules"), { recursive: true, force: true });
|
|
86
|
+
const installResult = await spawn("pnpm", ["install"], { cwd, streaming: true });
|
|
87
|
+
if (installResult.code !== 0) {
|
|
88
|
+
return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
|
|
89
|
+
}
|
|
90
|
+
await commit("chore(pnpm): add pnpm-lock.yaml");
|
|
91
|
+
return { kind: "ok" };
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
convertToPnpm
|
|
98
|
+
};
|
|
99
|
+
//# sourceMappingURL=chunk-2X322ZES.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/recipes/convert-to-pnpm.ts","../src/recipes/convert-to-pnpm/script-rewrites.ts"],"sourcesContent":["import { rm, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../types.js\";\nimport { readPackageJson, writePackageJson, type PackageJsonLike } from \"../util/pkg.js\";\nimport { defaultSpawn, type SpawnFn } from \"../audits/util/spawn.js\";\nimport { rewriteScriptsForPnpm } from \"./convert-to-pnpm/script-rewrites.js\";\nimport { withRecipe } from \"./_with-recipe.js\";\n\nexport type ConvertToPnpmOptions = {\n spawn?: SpawnFn;\n /** Version string written into package.json's `packageManager` field.\n * Defaults to the version baked into this package's own pnpm setup. */\n pnpmVersion?: string;\n};\n\n/** Pinned default — matches the `packageManager` field of this package\n * (kept in sync with package.json). Sites can override per-recipe. */\nconst DEFAULT_PNPM_VERSION = \"10.33.1\";\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await stat(path);\n return true;\n } catch {\n return false;\n }\n}\n\ntype Plan = { hasNpmLock: boolean; hasYarnLock: boolean };\n\nexport async function convertToPnpm(\n site: Site,\n opts: ConvertToPnpmOptions = {},\n): Promise<RecipeResult> {\n const spawn = opts.spawn ?? defaultSpawn;\n const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;\n\n const pnpmLockPath = join(site.path, \"pnpm-lock.yaml\");\n const npmLockPath = join(site.path, \"package-lock.json\");\n const yarnLockPath = join(site.path, \"yarn.lock\");\n\n return withRecipe<Plan>({\n name: \"convert-to-pnpm\",\n site,\n plan: async () => {\n if (await exists(pnpmLockPath)) {\n return { kind: \"noop\", notes: \"site already has pnpm-lock.yaml\" };\n }\n const hasNpmLock = await exists(npmLockPath);\n const hasYarnLock = await exists(yarnLockPath);\n if (!hasNpmLock && !hasYarnLock) {\n return {\n kind: \"noop\",\n notes: \"no convertible lockfile (package-lock.json or yarn.lock) at site root\",\n };\n }\n return { kind: \"apply\", plan: { hasNpmLock, hasYarnLock } };\n },\n apply: async ({ hasNpmLock, hasYarnLock }, { commit, cwd }) => {\n // Step 1: remove the npm/yarn lockfile(s).\n if (hasNpmLock) await rm(npmLockPath, { force: true });\n if (hasYarnLock) await rm(yarnLockPath, { force: true });\n const sourceLock = hasNpmLock ? \"package-lock.json\" : \"yarn.lock\";\n await commit(`chore(pnpm): remove ${sourceLock}`);\n\n // Step 2: pin packageManager + rewrite scripts (single commit — they\n // both touch package.json).\n const pkgPath = join(cwd, \"package.json\");\n const pkg = await readPackageJson(pkgPath);\n const next: PackageJsonLike = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };\n\n if (pkg.scripts && typeof pkg.scripts === \"object\") {\n const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(\n pkg.scripts as Record<string, string>,\n );\n if (changedCount > 0) {\n next.scripts = rewritten;\n }\n }\n\n await writePackageJson(pkgPath, next);\n await commit(\"chore(pnpm): pin packageManager + rewrite npm scripts\");\n\n // Step 3: remove any existing flat node_modules from a prior npm/yarn run\n // before pnpm installs. Sharing a node_modules across package managers\n // produces phantom-dep resolution issues (pnpm's nested layout disagrees\n // with what's already on disk). node_modules is gitignored on every\n // reddoor site so this doesn't dirty the tree.\n await rm(join(cwd, \"node_modules\"), { recursive: true, force: true });\n\n // Step 4: run pnpm install to materialize pnpm-lock.yaml.\n const installResult = await spawn(\"pnpm\", [\"install\"], { cwd, streaming: true });\n if (installResult.code !== 0) {\n return { kind: \"failed\", notes: `pnpm install failed (exit ${installResult.code})` };\n }\n\n await commit(\"chore(pnpm): add pnpm-lock.yaml\");\n return { kind: \"ok\" };\n },\n });\n}\n","/**\n * Rewrite a single package.json script value to use pnpm equivalents\n * where the substitution is safe. Conservative on purpose: we only touch\n * patterns whose semantics are identical under pnpm.\n *\n * - `npm run <token>` → `pnpm run <token>` (identical behavior)\n * - `npx <token>` → `pnpm dlx <token>` (identical behavior in pnpm 7+)\n *\n * Intentionally NOT rewritten:\n * - `npm install`, `npm install <pkg>`, `npm install --save-dev <pkg>` —\n * subtle flag mapping (e.g. `--save-dev` → `-D`) and edge cases like\n * `--save-exact` / `--save-optional`. Better to leave for an operator\n * eyeball than to silently mis-translate.\n * - Hyphenated identifiers like `npm-check-updates` (word-boundary protected).\n * - `concurrently \"npm:scriptName\"` shorthand syntax — it isn't actually\n * running npm; it's a concurrently-specific script reference.\n */\nexport function rewriteScriptForPnpm(script: string): string {\n let out = script;\n // `npm run <name>` → `pnpm run <name>`. \\b before npm prevents\n // matching inside hyphenated identifiers. Lookahead `(?=\\s)` after run\n // ensures we don't match `runner`.\n out = out.replace(/\\bnpm run(?=\\s)/g, \"pnpm run\");\n // `npx ` → `pnpm dlx `. \\b before npx prevents matching `npx-something`.\n out = out.replace(/\\bnpx(?=\\s)/g, \"pnpm dlx\");\n return out;\n}\n\n/**\n * Rewrite every entry in a package.json `scripts` map. Returns the new\n * map alongside a count of scripts that were actually changed.\n */\nexport function rewriteScriptsForPnpm(scripts: Record<string, string>): {\n scripts: Record<string, string>;\n changedCount: number;\n} {\n const next: Record<string, string> = {};\n let changedCount = 0;\n for (const [name, value] of Object.entries(scripts)) {\n const rewritten = rewriteScriptForPnpm(value);\n next[name] = rewritten;\n if (rewritten !== value) changedCount++;\n }\n return { scripts: next, changedCount };\n}\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,IAAI,YAAY;AACzB,SAAS,YAAY;;;ACgBd,SAAS,qBAAqB,QAAwB;AAC3D,MAAI,MAAM;AAIV,QAAM,IAAI,QAAQ,oBAAoB,UAAU;AAEhD,QAAM,IAAI,QAAQ,gBAAgB,UAAU;AAC5C,SAAO;AACT;AAMO,SAAS,sBAAsB,SAGpC;AACA,QAAM,OAA+B,CAAC;AACtC,MAAI,eAAe;AACnB,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,UAAM,YAAY,qBAAqB,KAAK;AAC5C,SAAK,IAAI,IAAI;AACb,QAAI,cAAc,MAAO;AAAA,EAC3B;AACA,SAAO,EAAE,SAAS,MAAM,aAAa;AACvC;;;AD3BA,IAAM,uBAAuB;AAE7B,eAAe,OAAO,MAAgC;AACpD,MAAI;AACF,UAAM,KAAK,IAAI;AACf,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAIA,eAAsB,cACpB,MACA,OAA6B,CAAC,GACP;AACvB,QAAM,QAAQ,KAAK,SAAS;AAC5B,QAAM,cAAc,KAAK,eAAe;AAExC,QAAM,eAAe,KAAK,KAAK,MAAM,gBAAgB;AACrD,QAAM,cAAc,KAAK,KAAK,MAAM,mBAAmB;AACvD,QAAM,eAAe,KAAK,KAAK,MAAM,WAAW;AAEhD,SAAO,WAAiB;AAAA,IACtB,MAAM;AAAA,IACN;AAAA,IACA,MAAM,YAAY;AAChB,UAAI,MAAM,OAAO,YAAY,GAAG;AAC9B,eAAO,EAAE,MAAM,QAAQ,OAAO,kCAAkC;AAAA,MAClE;AACA,YAAM,aAAa,MAAM,OAAO,WAAW;AAC3C,YAAM,cAAc,MAAM,OAAO,YAAY;AAC7C,UAAI,CAAC,cAAc,CAAC,aAAa;AAC/B,eAAO;AAAA,UACL,MAAM;AAAA,UACN,OAAO;AAAA,QACT;AAAA,MACF;AACA,aAAO,EAAE,MAAM,SAAS,MAAM,EAAE,YAAY,YAAY,EAAE;AAAA,IAC5D;AAAA,IACA,OAAO,OAAO,EAAE,YAAY,YAAY,GAAG,EAAE,QAAQ,IAAI,MAAM;AAE7D,UAAI,WAAY,OAAM,GAAG,aAAa,EAAE,OAAO,KAAK,CAAC;AACrD,UAAI,YAAa,OAAM,GAAG,cAAc,EAAE,OAAO,KAAK,CAAC;AACvD,YAAM,aAAa,aAAa,sBAAsB;AACtD,YAAM,OAAO,uBAAuB,UAAU,EAAE;AAIhD,YAAM,UAAU,KAAK,KAAK,cAAc;AACxC,YAAM,MAAM,MAAM,gBAAgB,OAAO;AACzC,YAAM,OAAwB,EAAE,GAAG,KAAK,gBAAgB,QAAQ,WAAW,GAAG;AAE9E,UAAI,IAAI,WAAW,OAAO,IAAI,YAAY,UAAU;AAClD,cAAM,EAAE,SAAS,WAAW,aAAa,IAAI;AAAA,UAC3C,IAAI;AAAA,QACN;AACA,YAAI,eAAe,GAAG;AACpB,eAAK,UAAU;AAAA,QACjB;AAAA,MACF;AAEA,YAAM,iBAAiB,SAAS,IAAI;AACpC,YAAM,OAAO,uDAAuD;AAOpE,YAAM,GAAG,KAAK,KAAK,cAAc,GAAG,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAGpE,YAAM,gBAAgB,MAAM,MAAM,QAAQ,CAAC,SAAS,GAAG,EAAE,KAAK,WAAW,KAAK,CAAC;AAC/E,UAAI,cAAc,SAAS,GAAG;AAC5B,eAAO,EAAE,MAAM,UAAU,OAAO,6BAA6B,cAAc,IAAI,IAAI;AAAA,MACrF;AAEA,YAAM,OAAO,iCAAiC;AAC9C,aAAO,EAAE,MAAM,KAAK;AAAA,IACtB;AAAA,EACF,CAAC;AACH;","names":[]}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultSpawn
|
|
3
|
+
} from "./chunk-HVOOCK6L.js";
|
|
4
|
+
import {
|
|
5
|
+
sameOwnerRepo
|
|
6
|
+
} from "./chunk-XTK5VIQB.js";
|
|
7
|
+
|
|
8
|
+
// src/cli/fleet/clone-if-needed.ts
|
|
9
|
+
import { stat, readdir, mkdir } from "fs/promises";
|
|
10
|
+
import { isAbsolute, join } from "path";
|
|
11
|
+
function deriveNameFromRepoUrl(repoUrl) {
|
|
12
|
+
const slash = repoUrl.split("/").pop() ?? repoUrl;
|
|
13
|
+
return slash.replace(/\.git$/, "");
|
|
14
|
+
}
|
|
15
|
+
function assertSafeName(name) {
|
|
16
|
+
if (name.length === 0) {
|
|
17
|
+
throw new Error("unsafe site name (empty)");
|
|
18
|
+
}
|
|
19
|
+
if (isAbsolute(name)) {
|
|
20
|
+
throw new Error(`unsafe site name (absolute path not allowed): ${name}`);
|
|
21
|
+
}
|
|
22
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
23
|
+
throw new Error(`unsafe site name (path separator not allowed): ${name}`);
|
|
24
|
+
}
|
|
25
|
+
if (name.split(/[\\/]/).some((seg) => seg === "..")) {
|
|
26
|
+
throw new Error(`unsafe site name (traversal segment not allowed): ${name}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function assertSafeRepoUrl(repoUrl) {
|
|
30
|
+
if (!/^(https?|ssh|git|file):\/\//.test(repoUrl) && !/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/.test(repoUrl)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`unsafe repoUrl: must start with a scheme (https://, ssh://, git://, file://) or use scp-style "user@host:path" (got: ${JSON.stringify(repoUrl)})`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function isNonEmptyDir(path) {
|
|
37
|
+
try {
|
|
38
|
+
const s = await stat(path);
|
|
39
|
+
if (!s.isDirectory()) return false;
|
|
40
|
+
const entries = await readdir(path);
|
|
41
|
+
return entries.length > 0;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function expectedRepoRef(site) {
|
|
47
|
+
return site.gitRepo ?? site.repoUrl ?? void 0;
|
|
48
|
+
}
|
|
49
|
+
async function assertCheckoutMatches(site, path, spawn) {
|
|
50
|
+
const expected = expectedRepoRef(site);
|
|
51
|
+
if (!expected) return;
|
|
52
|
+
const r = await spawn("git", ["-C", path, "remote", "get-url", "origin"], {
|
|
53
|
+
timeoutMs: 3e4
|
|
54
|
+
});
|
|
55
|
+
if (r.code !== 0) return;
|
|
56
|
+
const origin = r.stdout.trim();
|
|
57
|
+
if (origin.length === 0) return;
|
|
58
|
+
if (!sameOwnerRepo(origin, expected)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`checkout at ${path} is the wrong repo: origin is ${JSON.stringify(origin)} but site expects ${JSON.stringify(expected)} (slug collision?) \u2014 refusing to reuse it`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
var GIT_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
65
|
+
function resolveCloneUrl(site) {
|
|
66
|
+
if (site.repoUrl) return site.repoUrl;
|
|
67
|
+
if (!site.gitRepo) return void 0;
|
|
68
|
+
if (!GIT_REPO_RE.test(site.gitRepo)) {
|
|
69
|
+
throw new Error(`unsafe gitRepo: expected "owner/repo" (got: ${JSON.stringify(site.gitRepo)})`);
|
|
70
|
+
}
|
|
71
|
+
return `https://github.com/${site.gitRepo}.git`;
|
|
72
|
+
}
|
|
73
|
+
async function cloneIfNeeded(site, opts) {
|
|
74
|
+
const spawn = opts.spawn ?? defaultSpawn;
|
|
75
|
+
if (await isNonEmptyDir(site.path)) {
|
|
76
|
+
await assertCheckoutMatches(site, site.path, spawn);
|
|
77
|
+
return site;
|
|
78
|
+
}
|
|
79
|
+
const repoUrl = resolveCloneUrl(site);
|
|
80
|
+
if (!repoUrl) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`site path does not exist (${site.path}) and no repoUrl or gitRepo is set \u2014 cannot clone`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const name = site.name || deriveNameFromRepoUrl(repoUrl);
|
|
86
|
+
assertSafeName(name);
|
|
87
|
+
assertSafeRepoUrl(repoUrl);
|
|
88
|
+
const target = join(opts.workdir, name);
|
|
89
|
+
await mkdir(opts.workdir, { recursive: true });
|
|
90
|
+
if (await isNonEmptyDir(target)) {
|
|
91
|
+
await assertCheckoutMatches(site, target, spawn);
|
|
92
|
+
return { ...site, name, path: target };
|
|
93
|
+
}
|
|
94
|
+
const result = await spawn("git", ["clone", "--", repoUrl, target], {
|
|
95
|
+
cwd: opts.workdir,
|
|
96
|
+
timeoutMs: 5 * 6e4
|
|
97
|
+
});
|
|
98
|
+
if (result.code !== 0) {
|
|
99
|
+
throw new Error(`git clone failed (code ${result.code}): ${result.stderr}`);
|
|
100
|
+
}
|
|
101
|
+
return { ...site, name, path: target };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/cli/fleet/prepare-sites.ts
|
|
105
|
+
async function prepareFleetSites(sites, opts) {
|
|
106
|
+
const clone = opts.clone ?? cloneIfNeeded;
|
|
107
|
+
const needsCheckout = opts.needsCheckout ?? (() => true);
|
|
108
|
+
const settled = await Promise.all(
|
|
109
|
+
sites.map(async (site) => {
|
|
110
|
+
if (!needsCheckout(site)) return { ok: true, site };
|
|
111
|
+
try {
|
|
112
|
+
return { ok: true, site: await clone(site, { workdir: opts.workdir }) };
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return { ok: false, site: site.name || site.path, reason: e.message };
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
const prepared = [];
|
|
119
|
+
const skipped = [];
|
|
120
|
+
for (const r of settled) {
|
|
121
|
+
if (r.ok) prepared.push(r.site);
|
|
122
|
+
else skipped.push({ site: r.site, reason: r.reason });
|
|
123
|
+
}
|
|
124
|
+
return { prepared, skipped };
|
|
125
|
+
}
|
|
126
|
+
function formatSkippedNotice(skipped) {
|
|
127
|
+
if (skipped.length === 0) return null;
|
|
128
|
+
const detail = skipped.map((s) => `${s.site} (${s.reason})`).join("; ");
|
|
129
|
+
return `\u26A0 ${skipped.length} site(s) skipped (could not prepare): ${detail}`;
|
|
130
|
+
}
|
|
131
|
+
function appendSkipNotice(output, skipped) {
|
|
132
|
+
const notice = formatSkippedNotice(skipped);
|
|
133
|
+
return notice ? `${output}
|
|
134
|
+
|
|
135
|
+
${notice}` : output;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/util/fleet-workdir.ts
|
|
139
|
+
import { tmpdir } from "os";
|
|
140
|
+
import { join as join2 } from "path";
|
|
141
|
+
function fleetWorkdir() {
|
|
142
|
+
const home = process.env.HOME?.trim();
|
|
143
|
+
const base = home ? home : tmpdir();
|
|
144
|
+
return join2(base, ".reddoor-maint", "sites");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
prepareFleetSites,
|
|
149
|
+
formatSkippedNotice,
|
|
150
|
+
appendSkipNotice,
|
|
151
|
+
fleetWorkdir
|
|
152
|
+
};
|
|
153
|
+
//# sourceMappingURL=chunk-4NAOR44V.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli/fleet/clone-if-needed.ts","../src/cli/fleet/prepare-sites.ts","../src/util/fleet-workdir.ts"],"sourcesContent":["import { stat, readdir, mkdir } from \"node:fs/promises\";\nimport { isAbsolute, join } from \"node:path\";\nimport type { Site } from \"../../types.js\";\nimport { defaultSpawn, type SpawnFn } from \"../../audits/util/spawn.js\";\nimport { sameOwnerRepo } from \"../../util/git.js\";\n\nexport type CloneIfNeededOptions = {\n workdir: string;\n spawn?: SpawnFn;\n};\n\nfunction deriveNameFromRepoUrl(repoUrl: string): string {\n const slash = repoUrl.split(\"/\").pop() ?? repoUrl;\n return slash.replace(/\\.git$/, \"\");\n}\n\n/** Reject names that would let an inventory entry write outside `workdir`. */\nfunction assertSafeName(name: string): void {\n if (name.length === 0) {\n // `join(workdir, \"\")` collapses to the workdir root — the clone would land\n // there (and two empty-named sites would collide). An empty name is never a\n // legitimate checkout dir.\n throw new Error(\"unsafe site name (empty)\");\n }\n if (isAbsolute(name)) {\n throw new Error(`unsafe site name (absolute path not allowed): ${name}`);\n }\n if (name.includes(\"/\") || name.includes(\"\\\\\")) {\n throw new Error(`unsafe site name (path separator not allowed): ${name}`);\n }\n if (name.split(/[\\\\/]/).some((seg) => seg === \"..\")) {\n throw new Error(`unsafe site name (traversal segment not allowed): ${name}`);\n }\n}\n\n/**\n * Reject repo URLs that don't look like a normal clone target. Without this,\n * a `repoUrl` starting with `-` would be interpreted by git as a flag\n * (CVE-2017-1000117 family) — e.g. `--upload-pack=…` allows arbitrary command\n * execution. Inventory files are usually trusted, but `.mjs`/`.js` inventories\n * could pull from environments or external sources, so harden the boundary.\n *\n * Accepts: `https://`, `http://`, `ssh://`, `git://`, `file://`, and\n * `[user@]host:path` shorthand (e.g. `git@github.com:org/repo.git`).\n */\nfunction assertSafeRepoUrl(repoUrl: string): void {\n if (\n !/^(https?|ssh|git|file):\\/\\//.test(repoUrl) &&\n !/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/.test(repoUrl)\n ) {\n throw new Error(\n `unsafe repoUrl: must start with a scheme (https://, ssh://, git://, file://) ` +\n `or use scp-style \"user@host:path\" (got: ${JSON.stringify(repoUrl)})`,\n );\n }\n}\n\nasync function isNonEmptyDir(path: string): Promise<boolean> {\n try {\n const s = await stat(path);\n if (!s.isDirectory()) return false;\n const entries = await readdir(path);\n return entries.length > 0;\n } catch {\n return false;\n }\n}\n\n/**\n * The repo identity (`owner/repo` or a clone URL) the site is *expected* to be.\n * `gitRepo` (Airtable's `owner/repo`) is canonical; fall back to `repoUrl`.\n * `undefined` when the inventory carries neither — there's nothing to verify\n * against, so a reused checkout can't be checked (we keep current behavior).\n */\nfunction expectedRepoRef(site: Site): string | undefined {\n return site.gitRepo ?? site.repoUrl ?? undefined;\n}\n\n/**\n * Verify an existing checkout at `path` is the SAME repo as the site expects.\n * The `siteSlug` that derives the path is lossy (distinct site names can\n * collapse to one slug → one path), so without this a fleet run could read,\n * audit, or commit against the WRONG repo's working tree.\n *\n * Reads `git -C <path> remote get-url origin` (via the injected spawn so tests\n * can fake it) and compares `owner/repo` on both sides, normalizing scheme,\n * host, `.git`, and case. Throws on a mismatch — never silently proceeds.\n * No-ops when the site carries no expected identity, or the dir isn't a git\n * checkout (no origin) — those are handled by the normal clone path.\n */\nasync function assertCheckoutMatches(site: Site, path: string, spawn: SpawnFn): Promise<void> {\n const expected = expectedRepoRef(site);\n if (!expected) return; // nothing to verify against\n\n const r = await spawn(\"git\", [\"-C\", path, \"remote\", \"get-url\", \"origin\"], {\n timeoutMs: 30_000,\n });\n // Not a git checkout / no origin remote → can't verify; let the clone path\n // (or the caller) deal with a non-repo directory rather than guessing.\n if (r.code !== 0) return;\n const origin = r.stdout.trim();\n if (origin.length === 0) return;\n\n if (!sameOwnerRepo(origin, expected)) {\n throw new Error(\n `checkout at ${path} is the wrong repo: origin is ${JSON.stringify(origin)} ` +\n `but site expects ${JSON.stringify(expected)} (slug collision?) — refusing to reuse it`,\n );\n }\n}\n\n/** GitHub repo identity `owner/repo`: exactly two `[\\w.-]` segments. Constrained\n * so it can't smuggle a scheme, host, extra path segment, traversal, or an argv\n * flag into the derived clone URL below. */\nconst GIT_REPO_RE = /^[\\w.-]+\\/[\\w.-]+$/;\n\n/**\n * Resolve the URL to clone from. An explicit `repoUrl` wins; otherwise derive\n * one from `gitRepo` (`owner/repo` → `https://github.com/owner/repo.git`). The\n * Airtable inventory deliberately sets `gitRepo` and NOT `repoUrl` (a clone\n * source must never be the production `url`), so without this derivation every\n * checkout-based fleet recipe throws on the first site with an empty workdir.\n *\n * Returns `undefined` when neither is set; throws on a malformed `gitRepo`.\n */\nfunction resolveCloneUrl(site: Site): string | undefined {\n if (site.repoUrl) return site.repoUrl;\n if (!site.gitRepo) return undefined;\n if (!GIT_REPO_RE.test(site.gitRepo)) {\n throw new Error(`unsafe gitRepo: expected \"owner/repo\" (got: ${JSON.stringify(site.gitRepo)})`);\n }\n return `https://github.com/${site.gitRepo}.git`;\n}\n\nexport async function cloneIfNeeded(site: Site, opts: CloneIfNeededOptions): Promise<Site> {\n const spawn = opts.spawn ?? defaultSpawn;\n\n if (await isNonEmptyDir(site.path)) {\n // Reusing an existing checkout: confirm it's actually this site's repo\n // before any audit/recipe operates on it (slug collisions can point two\n // sites at the same path). Throws on mismatch.\n await assertCheckoutMatches(site, site.path, spawn);\n return site;\n }\n\n const repoUrl = resolveCloneUrl(site);\n if (!repoUrl) {\n throw new Error(\n `site path does not exist (${site.path}) and no repoUrl or gitRepo is set — cannot clone`,\n );\n }\n\n const name = site.name || deriveNameFromRepoUrl(repoUrl); // `||`: empty name derives from the repo\n assertSafeName(name);\n assertSafeRepoUrl(repoUrl);\n const target = join(opts.workdir, name);\n await mkdir(opts.workdir, { recursive: true });\n\n if (await isNonEmptyDir(target)) {\n // Same guard for the workdir/<name> reuse path.\n await assertCheckoutMatches(site, target, spawn);\n return { ...site, name, path: target };\n }\n\n // `--` separator so git won't treat repoUrl as a flag if validation slips.\n const result = await spawn(\"git\", [\"clone\", \"--\", repoUrl, target], {\n cwd: opts.workdir,\n timeoutMs: 5 * 60_000,\n });\n if (result.code !== 0) {\n throw new Error(`git clone failed (code ${result.code}): ${result.stderr}`);\n }\n return { ...site, name, path: target };\n}\n","import type { Site } from \"../../types.js\";\nimport { cloneIfNeeded } from \"./clone-if-needed.js\";\n\n/** A fleet site that couldn't be prepared for a command, with the reason. */\nexport type SkippedSite = { site: string; reason: string };\nexport type FleetPrepResult = { prepared: Site[]; skipped: SkippedSite[] };\n\ntype CloneFn = (site: Site, opts: { workdir: string }) => Promise<Site>;\n\n/**\n * Prepare every fleet site for a command, tolerating per-site failures.\n *\n * A site is cloned into `workdir` unless `needsCheckout` says otherwise (the\n * default is \"always clone\" — recipe commands operate on a local checkout; the\n * audit command overrides this so a deployed-URL site skips the clone). The\n * critical property is ISOLATION: a per-site prep failure (no clone source,\n * clone error, the wrong-repo guard) is CAUGHT and recorded in `skipped` rather\n * than thrown. A single malformed inventory row can therefore never abort the\n * whole fleet run — a bare `Promise.all(map(cloneIfNeeded))` used to (one site\n * with no repo threw and rejected prep for EVERY site, so nothing ran). Each\n * site is prepared independently (parallel) so one slow/failed clone can't\n * serialize or sink the rest.\n *\n * The skipped-site label prefers `site.name` but uses a truthiness check (not\n * `??`) so an empty-string name (an Airtable row whose slug came out empty)\n * still falls back to the path instead of rendering blank.\n */\nexport async function prepareFleetSites(\n sites: Site[],\n opts: { workdir: string; needsCheckout?: (site: Site) => boolean; clone?: CloneFn },\n): Promise<FleetPrepResult> {\n const clone = opts.clone ?? cloneIfNeeded;\n const needsCheckout = opts.needsCheckout ?? (() => true);\n const settled = await Promise.all(\n sites.map(async (site): Promise<{ ok: true; site: Site } | ({ ok: false } & SkippedSite)> => {\n if (!needsCheckout(site)) return { ok: true, site };\n try {\n return { ok: true, site: await clone(site, { workdir: opts.workdir }) };\n } catch (e) {\n return { ok: false, site: site.name || site.path, reason: (e as Error).message };\n }\n }),\n );\n const prepared: Site[] = [];\n const skipped: SkippedSite[] = [];\n for (const r of settled) {\n if (r.ok) prepared.push(r.site);\n else skipped.push({ site: r.site, reason: r.reason });\n }\n return { prepared, skipped };\n}\n\n/** One-line operator notice for sites dropped during fleet prep; null when none\n * were skipped. The leading \"⚠ … site(s) skipped (could not prepare)\" token is\n * what the nightly fleet-lighthouse workflow greps to raise a `::warning::`, so\n * a tolerated skip stays visible in the Actions UI rather than buried. */\nexport function formatSkippedNotice(skipped: SkippedSite[]): string | null {\n if (skipped.length === 0) return null;\n const detail = skipped.map((s) => `${s.site} (${s.reason})`).join(\"; \");\n return `⚠ ${skipped.length} site(s) skipped (could not prepare): ${detail}`;\n}\n\n/** Append the skip notice to a command's output on a blank line, or return the\n * output unchanged when nothing was skipped. Keeps every fleet command's\n * return paths uniform. */\nexport function appendSkipNotice(output: string, skipped: SkippedSite[]): string {\n const notice = formatSkippedNotice(skipped);\n return notice ? `${output}\\n\\n${notice}` : output;\n}\n","import { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\n/** The default clone target for `--fleet` mode. Normally `~/.reddoor-maint/sites`.\n *\n * When `HOME` is unset or empty (cron, minimal CI containers) the old\n * `${HOME}/.reddoor-maint/sites` expression collapsed to `/.reddoor-maint/sites`\n * — a filesystem-root path the process almost never has permission to write,\n * and a dangerous one if it did. Fall back to a tmpdir-based path instead so a\n * HOME-less environment clones somewhere writable rather than at `/`.\n *\n * An explicit `--workdir` always wins; callers pass `opts.workdir ?? fleetWorkdir()`. */\nexport function fleetWorkdir(): string {\n const home = process.env.HOME?.trim();\n const base = home ? home : tmpdir();\n return join(base, \".reddoor-maint\", \"sites\");\n}\n"],"mappings":"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","names":["join"]}
|