@postedin/cms-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +66 -0
  2. package/bin/dissect/cli.mjs +138 -0
  3. package/bin/dissect/dissect.mjs +290 -0
  4. package/bin/profile/build.mjs +106 -0
  5. package/bin/profile/fetch-log.mjs +298 -0
  6. package/bin/profile/format.mjs +90 -0
  7. package/bin/profile/interference-summary.mjs +558 -0
  8. package/bin/profile/interference.mjs +604 -0
  9. package/bin/profile/measure.mjs +137 -0
  10. package/bin/profile/report.mjs +90 -0
  11. package/bin/profile/site-env.mjs +16 -0
  12. package/bin/profile/summarize.mjs +429 -0
  13. package/dist/browser.d.ts +145 -0
  14. package/dist/browser.js +11 -0
  15. package/dist/browser.js.map +1 -0
  16. package/dist/chunk-6V54ITTK.js +197 -0
  17. package/dist/chunk-6V54ITTK.js.map +1 -0
  18. package/dist/chunk-MNZ7DIGC.js +51 -0
  19. package/dist/chunk-MNZ7DIGC.js.map +1 -0
  20. package/dist/form-proxy/upload-policy.d.ts +40 -0
  21. package/dist/form-proxy/upload-policy.js +17 -0
  22. package/dist/form-proxy/upload-policy.js.map +1 -0
  23. package/dist/index.d.ts +570 -0
  24. package/dist/index.js +1636 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/payload-types.d.ts +8985 -0
  27. package/dist/payload-types.js +1 -0
  28. package/dist/payload-types.js.map +1 -0
  29. package/package.json +74 -0
  30. package/src/api.ts +387 -0
  31. package/src/blog-listing.ts +75 -0
  32. package/src/browser.ts +24 -0
  33. package/src/client.ts +144 -0
  34. package/src/cms-to-href.ts +70 -0
  35. package/src/cms.ts +86 -0
  36. package/src/collections/appearance.ts +94 -0
  37. package/src/collections/areas.ts +29 -0
  38. package/src/collections/authors.ts +27 -0
  39. package/src/collections/banners.ts +14 -0
  40. package/src/collections/categories.ts +111 -0
  41. package/src/collections/forms.ts +29 -0
  42. package/src/collections/header-footer.ts +19 -0
  43. package/src/collections/image-links.ts +14 -0
  44. package/src/collections/media.ts +18 -0
  45. package/src/collections/options.ts +10 -0
  46. package/src/collections/pages.ts +83 -0
  47. package/src/collections/posts.ts +249 -0
  48. package/src/collections/project.ts +16 -0
  49. package/src/collections/questions.ts +35 -0
  50. package/src/collections/seo.ts +10 -0
  51. package/src/collections/tags.ts +25 -0
  52. package/src/collections/team-members.ts +79 -0
  53. package/src/config-time.ts +98 -0
  54. package/src/context.ts +12 -0
  55. package/src/decode-html.ts +8 -0
  56. package/src/form-proxy/cms-client.ts +95 -0
  57. package/src/form-proxy/cms-errors.ts +73 -0
  58. package/src/form-proxy/cms-write.ts +44 -0
  59. package/src/form-proxy/http.ts +96 -0
  60. package/src/form-proxy/index.ts +73 -0
  61. package/src/form-proxy/rate-limit.ts +46 -0
  62. package/src/form-proxy/submissions.ts +88 -0
  63. package/src/form-proxy/types.ts +23 -0
  64. package/src/form-proxy/upload-policy.ts +92 -0
  65. package/src/form-proxy/uploads.ts +81 -0
  66. package/src/home-page.ts +83 -0
  67. package/src/index.ts +68 -0
  68. package/src/loader.ts +83 -0
  69. package/src/locales.ts +80 -0
  70. package/src/payload-types.ts +10854 -0
  71. package/src/placeholder.ts +9 -0
  72. package/src/resolve-menu-items.ts +184 -0
  73. package/src/routes.ts +184 -0
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Puts a build and an editor on the same clock.
4
+ *
5
+ * pnpm profile:interference
6
+ * pnpm profile:interference --tag "m10 cluster"
7
+ * pnpm profile:interference --idle 30 --collection posts
8
+ *
9
+ * "The admin is slower while a build runs" is the reported symptom and the one
10
+ * nothing measures. This polls a fixed set of REST requests every two seconds
11
+ * through three phases — idle, a profiled build, idle again — and reports what
12
+ * each one cost in each phase, joined to how many build requests were in
13
+ * flight at that moment.
14
+ *
15
+ * REST latency stands in for admin latency: the admin UI is served by the same
16
+ * functions against the same database. Timing the admin UI itself — list view,
17
+ * open document, autosave, publish — belongs on the CMS side.
18
+ *
19
+ * The default target is whatever `API_BASE_URL` resolves to, which is the demo
20
+ * CMS in this repository's `.env`. Pointing it at a client CMS means editing
21
+ * that variable, which is a deliberate act rather than a flag default.
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import {
26
+ appendFileSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ readFileSync,
30
+ writeFileSync,
31
+ } from 'node:fs';
32
+ import { dirname, join, resolve } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { loadSiteEnv } from './site-env.mjs';
35
+ import {
36
+ parsePollLog,
37
+ renderInterferenceMarkdown,
38
+ summarizeInterference,
39
+ } from './interference-summary.mjs';
40
+ import { LOG_DIR } from './report.mjs';
41
+ import { parseLog } from './summarize.mjs';
42
+
43
+ const here = dirname(fileURLToPath(import.meta.url));
44
+
45
+ /** A page of the list view, as the admin pages it. */
46
+ const LIST_LIMIT = 10;
47
+
48
+ /** What the site fetches a collection at, and what the edit view resolves. */
49
+ const DOCUMENT_DEPTH = 2;
50
+
51
+ /**
52
+ * The CMS's own id for the request it just served, echoed on the response by a
53
+ * deployment running with `CMS_PROFILE=1` (`PROFILE_ID_HEADER` in the CMS's
54
+ * `src/profiling/requestProfile.ts`). Recording it is what lets a slow poll be
55
+ * looked up as a `cms.profile.request` line and opened into the commands and
56
+ * hooks behind it, rather than staying a number with no explanation. Null when
57
+ * the target is not profiling, which is the normal case.
58
+ */
59
+ const PROFILE_ID_HEADER = 'x-cms-profile-id';
60
+
61
+ const DEFAULTS = {
62
+ /** Every 2s. Short enough to see a burst, long enough not to be load. */
63
+ intervalMs: 2000,
64
+ /** Each idle phase. One minute of quiet on either side of the build. */
65
+ idleMs: 60_000,
66
+ /** The collection whose list and whose newest document stand in for the admin's. */
67
+ collection: 'posts',
68
+ };
69
+
70
+ const options = parseArgs(process.argv.slice(2));
71
+
72
+ /**
73
+ * The phase the run is in: written by `phase`, read by the poll loop.
74
+ *
75
+ * A poll is labelled with the phase it started in rather than the one it
76
+ * finished in. A poll that goes out during the build and lands after it is a
77
+ * measurement of the build.
78
+ */
79
+ let currentPhase = 'before';
80
+
81
+ // Resolved in the mode the build will resolve it in — `astro build` runs Vite
82
+ // in `production` — so the harness and the build it spawns are pointed at one
83
+ // CMS even on a site that keeps an `.env.production`. `build.mjs` reads it the
84
+ // same way for the same reason.
85
+ const env = await loadSiteEnv(process.env.NODE_ENV ?? 'production');
86
+ const { API_BASE_URL, CMS_API_KEY, PROJECT_SLUG } = env;
87
+
88
+ if (!API_BASE_URL || !CMS_API_KEY || !PROJECT_SLUG) {
89
+ fail(
90
+ "API_BASE_URL, CMS_API_KEY and PROJECT_SLUG all have to be set. A local .env.local wins over .env in Vite's precedence, so check which one is in play.",
91
+ );
92
+ }
93
+
94
+ // The scheme names the collection the key belongs to, and the harness holds
95
+ // the same credential the site builds with: an `api-keys` document, scoped to
96
+ // one project and able to write nothing. `@postedin/cms-client`'s `api.ts` is where
97
+ // that decision lives; sending `users` here would either fail to authenticate
98
+ // or, with somebody's personal key, measure a principal no build ever uses.
99
+ const CMS_API_KEY_COLLECTION = 'api-keys';
100
+
101
+ const headers = {
102
+ Authorization: `${CMS_API_KEY_COLLECTION} API-Key ${CMS_API_KEY}`,
103
+ ...(env.CMS_VERCEL_AUTOMATION_BYPASS_SECRET
104
+ ? { 'x-vercel-protection-bypass': env.CMS_VERCEL_AUTOMATION_BYPASS_SECRET }
105
+ : {}),
106
+ };
107
+
108
+ const stamp = new Date().toISOString().slice(0, 19).replaceAll(':', '-');
109
+ const pollLogPath = resolve(LOG_DIR, `interference-${stamp}.ndjson`);
110
+ const buildLogPath = resolve(LOG_DIR, `interference-${stamp}-build.ndjson`);
111
+ const reportPath = pollLogPath.replace(/\.ndjson$/, '.md');
112
+
113
+ mkdirSync(dirname(pollLogPath), { recursive: true });
114
+
115
+ await main();
116
+
117
+ async function main() {
118
+ const probes = await buildProbes();
119
+
120
+ console.log(`Polling ${API_BASE_URL} every ${options.intervalMs / 1000}s.`);
121
+ console.log(`Log: ${pollLogPath}\n`);
122
+
123
+ writeLine({
124
+ kind: 'meta',
125
+ event: 'start',
126
+ startedAt: Date.now(),
127
+ cmsOrigin: new URL(API_BASE_URL).origin,
128
+ project: PROJECT_SLUG,
129
+ collection: options.collection,
130
+ documentId: probes.documentId,
131
+ tag: options.tag,
132
+ intervalMs: options.intervalMs,
133
+ idleMs: options.idleMs,
134
+ probes: probes.list.map(({ name, request, description }) => ({
135
+ name,
136
+ request,
137
+ description,
138
+ })),
139
+ });
140
+
141
+ const poller = startPolling(probes.list);
142
+
143
+ await phase('before', () => idle(options.idleMs));
144
+
145
+ const build = await phase('during', () => runBuild());
146
+
147
+ await phase('after', () => idle(options.idleMs));
148
+
149
+ poller.stop();
150
+ await poller.drained();
151
+
152
+ writeLine({
153
+ kind: 'meta',
154
+ event: 'end',
155
+ endedAt: Date.now(),
156
+ polls: poller.polls(),
157
+ });
158
+
159
+ report();
160
+
161
+ if (!build.ok) {
162
+ // A build that failed still produced latency worth reading, so the
163
+ // report is written either way — but the run did not measure what it
164
+ // set out to, and the exit code has to say so.
165
+ console.error(
166
+ `\nThe build failed: ${build.signal ? `signal ${build.signal}` : `exit ${build.exitCode}`}. The report covers what was polled, not a successful build.`,
167
+ );
168
+ process.exit(1);
169
+ }
170
+ }
171
+
172
+ /** Announces a phase, runs it, and labels every poll that starts inside it. */
173
+ async function phase(name, run) {
174
+ currentPhase = name;
175
+ writeLine({ kind: 'meta', event: 'phase', phase: name, at: Date.now() });
176
+ console.log(`[${name}]`);
177
+
178
+ return await run();
179
+ }
180
+
181
+ /**
182
+ * The three requests the admin issues most, resolved against real data and
183
+ * checked before the run starts.
184
+ *
185
+ * The document probe needs an id, and the honest one is whatever an editor
186
+ * touched last — the same document the admin's list view puts at the top. It
187
+ * costs one request before the run, outside every phase, and so does the check
188
+ * each probe then gets.
189
+ *
190
+ * Every probe has to be a request this credential genuinely performs. An
191
+ * `api-keys` document is not a person, so `/api/users/me` answers it `200
192
+ * {"user":null}` — a shape that looks like a measurement and is really an
193
+ * unauthenticated stub, contributing a few hundred milliseconds of "admin
194
+ * latency" that no admin ever waits. The floor probe below is what replaced
195
+ * it: the same question — what does a request cost before it does any work —
196
+ * asked in a way that 403s when the credential is wrong.
197
+ */
198
+ async function buildProbes() {
199
+ const listRequest = listUrl();
200
+ const newest = await probeJson(listRequest, 'the list-view probe');
201
+ const documentId = newest.docs?.[0]?.id;
202
+
203
+ if (!documentId) {
204
+ fail(
205
+ `No documents in \`${options.collection}\` for project \`${PROJECT_SLUG}\`, so there is no edit view to time. Pass --collection.`,
206
+ );
207
+ }
208
+
209
+ const documentRequest = `${API_BASE_URL}/api/${options.collection}/${documentId}?depth=${DOCUMENT_DEPTH}`;
210
+ const floorRequest = floorUrl();
211
+
212
+ const probes = [
213
+ {
214
+ name: 'list',
215
+ request: pathAndQuery(listRequest),
216
+ url: listRequest,
217
+ description: `The list view: ${LIST_LIMIT} documents, newest first, filtered to the project`,
218
+ // Already in hand: this is the request the document id came from.
219
+ answered: newest,
220
+ check: (body) =>
221
+ body.docs?.length ? null : 'it carried no `docs` to have cost anything',
222
+ },
223
+ {
224
+ name: 'document',
225
+ request: pathAndQuery(documentRequest),
226
+ url: documentRequest,
227
+ description: `The edit view: one document by id at depth ${DOCUMENT_DEPTH}, relationships resolved`,
228
+ check: (body) =>
229
+ body.id === documentId
230
+ ? null
231
+ : `it carried \`${body.id ?? 'no id'}\` rather than the document that was asked for`,
232
+ },
233
+ {
234
+ name: 'floor',
235
+ request: pathAndQuery(floorRequest),
236
+ url: floorRequest,
237
+ description:
238
+ 'The fixed cost of an authenticated, project-scoped read: one document, nothing resolved',
239
+ check: (body) =>
240
+ body.docs?.length ? null : 'it carried no `docs` to have cost anything',
241
+ },
242
+ ];
243
+
244
+ for (const probe of probes) {
245
+ await verifyProbe(probe);
246
+ }
247
+
248
+ return { documentId, list: probes };
249
+ }
250
+
251
+ /**
252
+ * Refuses to start a run whose probe answers without measuring anything.
253
+ *
254
+ * A 200 is not enough: an endpoint can answer an unauthenticated caller with an
255
+ * empty shape, at full latency, and every table downstream will report those
256
+ * milliseconds as what an editor waits. The check is per probe because what
257
+ * counts as an answer differs — a page of documents, one document by id — and
258
+ * it runs once, before the first phase, so a misconfigured run costs a request
259
+ * rather than three minutes. A probe whose answer is already in hand — the
260
+ * list, which is where the document id came from — is not asked twice.
261
+ */
262
+ async function verifyProbe(probe) {
263
+ const body =
264
+ probe.answered ??
265
+ (await probeJson(probe.url, `the \`${probe.name}\` probe`));
266
+ const complaint = probe.check(body);
267
+
268
+ if (complaint) {
269
+ fail(
270
+ `The \`${probe.name}\` probe (${probe.request}) answered 200 but ${complaint}. Timing that would measure an empty round trip, so the run stops here.`,
271
+ );
272
+ }
273
+ }
274
+
275
+ /**
276
+ * The list view's query, in the shape the admin sends it: a page of ten,
277
+ * newest edit first, scoped to one project. `qs-esm` is not imported here
278
+ * because this script has to run before anything is built.
279
+ *
280
+ * `depth=0` because a list view resolves no relationships. The edit view does,
281
+ * and it is a different endpoint — `/api/{collection}/{id}`, which takes no
282
+ * `where` — so it needs no builder at all.
283
+ */
284
+ function listUrl() {
285
+ const params = new URLSearchParams({
286
+ limit: String(LIST_LIMIT),
287
+ depth: '0',
288
+ sort: '-updatedAt',
289
+ 'where[and][0][project.slug][equals]': PROJECT_SLUG,
290
+ });
291
+
292
+ return `${API_BASE_URL}/api/${options.collection}?${params}`;
293
+ }
294
+
295
+ /**
296
+ * The same query asked for a single document, unsorted and unresolved.
297
+ *
298
+ * What is left when a read returns almost nothing is what every read pays
299
+ * whatever it asks for: the round trip, authenticating the key, resolving the
300
+ * project it is scoped to. Against the list probe's ten documents it is also
301
+ * the one comparison this harness can make on its own — how much of a list view
302
+ * is the page of documents, and how much is the request itself.
303
+ */
304
+ function floorUrl() {
305
+ const params = new URLSearchParams({
306
+ limit: '1',
307
+ depth: '0',
308
+ 'where[and][0][project.slug][equals]': PROJECT_SLUG,
309
+ });
310
+
311
+ return `${API_BASE_URL}/api/${options.collection}?${params}`;
312
+ }
313
+
314
+ /** Everything after the origin, which is what the report prints. */
315
+ function pathAndQuery(url) {
316
+ const parsed = new URL(url);
317
+
318
+ return `${parsed.pathname}${parsed.search}`;
319
+ }
320
+
321
+ /**
322
+ * Polls every probe on a fixed cadence for the life of the run.
323
+ *
324
+ * A tick that is still in flight when the next one is due is not doubled up on:
325
+ * the harness is meant to measure the CMS under a build's load, not to add its
326
+ * own. The skip is logged, because it only happens when latency has grown past
327
+ * the interval and that is a fact about the run.
328
+ */
329
+ function startPolling(probes) {
330
+ let stopped = false;
331
+ let ticks = 0;
332
+ let polls = 0;
333
+ let busy = false;
334
+ const settled = [];
335
+
336
+ const timer = setInterval(() => {
337
+ if (stopped) {
338
+ return;
339
+ }
340
+
341
+ if (busy) {
342
+ writeLine({
343
+ kind: 'meta',
344
+ event: 'skipped',
345
+ phase: currentPhase,
346
+ at: Date.now(),
347
+ });
348
+
349
+ return;
350
+ }
351
+
352
+ const tick = ++ticks;
353
+ const phase = currentPhase;
354
+
355
+ busy = true;
356
+
357
+ settled.push(
358
+ Promise.all(
359
+ probes.map((probe) =>
360
+ pollOnce(probe, tick, phase).then(() => {
361
+ polls += 1;
362
+ }),
363
+ ),
364
+ ).finally(() => {
365
+ busy = false;
366
+ }),
367
+ );
368
+ }, options.intervalMs);
369
+
370
+ // The harness's own clock must not be what holds the process open.
371
+ timer.unref?.();
372
+
373
+ return {
374
+ stop() {
375
+ stopped = true;
376
+ clearInterval(timer);
377
+ },
378
+ drained: () => Promise.allSettled(settled),
379
+ polls: () => polls,
380
+ };
381
+ }
382
+
383
+ /** One probe, one line: latency to the last byte, and what came back. */
384
+ async function pollOnce(probe, tick, phase) {
385
+ const startedAt = Date.now();
386
+ const mark = performance.now();
387
+
388
+ try {
389
+ const response = await fetch(probe.url, { headers });
390
+ const ttfbMs = round(performance.now() - mark);
391
+ const body = await response.arrayBuffer();
392
+
393
+ writeLine({
394
+ kind: 'poll',
395
+ tick,
396
+ phase,
397
+ probe: probe.name,
398
+ startedAt,
399
+ endedAt: Date.now(),
400
+ ttfbMs,
401
+ ms: round(performance.now() - mark),
402
+ status: response.status,
403
+ ok: response.ok,
404
+ bytes: body.byteLength,
405
+ profileId: response.headers.get(PROFILE_ID_HEADER),
406
+ });
407
+ } catch (error) {
408
+ writeLine({
409
+ kind: 'poll',
410
+ tick,
411
+ phase,
412
+ probe: probe.name,
413
+ startedAt,
414
+ endedAt: Date.now(),
415
+ ttfbMs: null,
416
+ ms: round(performance.now() - mark),
417
+ status: null,
418
+ ok: false,
419
+ bytes: null,
420
+ profileId: null,
421
+ error: String(error?.message ?? error),
422
+ });
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Runs the build `pnpm build:profile` runs, with its fetch log named here so
428
+ * the report can join the two by timestamp.
429
+ *
430
+ * `pnpm` is not spawned; `build:profile` is `cms-profile-build`, which is
431
+ * `build.mjs` beside this file,
432
+ * and calling it directly keeps the harness independent of PATH.
433
+ */
434
+ function runBuild() {
435
+ const script = join(here, 'build.mjs');
436
+ const command = `node ${relativeToCwd(script)}`;
437
+ const startedAt = Date.now();
438
+
439
+ console.log(`Starting the build: ${command}\n`);
440
+
441
+ return new Promise((settle) => {
442
+ const build = spawn(process.execPath, [script, ...options.buildArgs], {
443
+ stdio: 'inherit',
444
+ env: {
445
+ ...process.env,
446
+ PROFILE_FETCH_LOG: buildLogPath,
447
+ },
448
+ });
449
+
450
+ build.on('error', (error) => {
451
+ writeLine({
452
+ kind: 'meta',
453
+ event: 'build',
454
+ command,
455
+ logPath: buildLogPath,
456
+ startedAt,
457
+ endedAt: Date.now(),
458
+ exitCode: null,
459
+ signal: null,
460
+ error: String(error?.message ?? error),
461
+ });
462
+
463
+ settle({ ok: false, exitCode: null, signal: null });
464
+ });
465
+
466
+ build.on('exit', (exitCode, signal) => {
467
+ writeLine({
468
+ kind: 'meta',
469
+ event: 'build',
470
+ command,
471
+ logPath: buildLogPath,
472
+ startedAt,
473
+ endedAt: Date.now(),
474
+ exitCode,
475
+ signal,
476
+ });
477
+
478
+ settle({ ok: exitCode === 0 && !signal, exitCode, signal });
479
+ });
480
+ });
481
+ }
482
+
483
+ function idle(ms) {
484
+ return new Promise((settle) => {
485
+ setTimeout(settle, ms);
486
+ });
487
+ }
488
+
489
+ /** Renders the poll log against the build's fetch log. */
490
+ function report() {
491
+ const { polls, meta } = parsePollLog(readFileSync(pollLogPath, 'utf-8'));
492
+ const buildRequests = existsSync(buildLogPath)
493
+ ? parseLog(readFileSync(buildLogPath, 'utf-8')).requests
494
+ : [];
495
+
496
+ const markdown = renderInterferenceMarkdown(
497
+ summarizeInterference({ polls, meta, buildRequests }),
498
+ { logPath: pollLogPath, generatedAt: new Date().toISOString() },
499
+ );
500
+
501
+ writeFileSync(reportPath, markdown);
502
+
503
+ console.log(`\nPoll log: ${pollLogPath}`);
504
+ console.log(`Build log: ${buildLogPath}`);
505
+ console.log(`Report: ${reportPath}`);
506
+ }
507
+
508
+ function writeLine(record) {
509
+ try {
510
+ appendFileSync(pollLogPath, `${JSON.stringify(record)}\n`);
511
+ } catch (error) {
512
+ process.emitWarning(
513
+ `interference: could not append to ${pollLogPath}: ${error.message}`,
514
+ );
515
+ }
516
+ }
517
+
518
+ async function probeJson(url, what) {
519
+ let response;
520
+
521
+ try {
522
+ response = await fetch(url, { headers });
523
+ } catch (error) {
524
+ fail(`Could not reach the CMS for ${what}: ${error?.message ?? error}`);
525
+ }
526
+
527
+ if (!response.ok) {
528
+ fail(
529
+ `The CMS answered ${response.status} ${response.statusText} for ${what} (${pathAndQuery(url)}).`,
530
+ );
531
+ }
532
+
533
+ return await response.json();
534
+ }
535
+
536
+ function parseArgs(argv) {
537
+ const parsed = {
538
+ ...DEFAULTS,
539
+ tag: null,
540
+ buildArgs: [],
541
+ };
542
+
543
+ for (let index = 0; index < argv.length; index += 1) {
544
+ const argument = argv[index];
545
+
546
+ if (argument === '--tag') {
547
+ parsed.tag = argv[++index] ?? null;
548
+ } else if (argument === '--collection') {
549
+ parsed.collection = argv[++index] ?? parsed.collection;
550
+ } else if (argument === '--idle') {
551
+ parsed.idleMs = seconds('--idle', argv[++index]);
552
+ } else if (argument === '--interval') {
553
+ parsed.intervalMs = seconds('--interval', argv[++index]);
554
+ } else if (argument === '--') {
555
+ parsed.buildArgs.push(...argv.slice(index + 1));
556
+ break;
557
+ } else if (argument === '--help' || argument === '-h') {
558
+ console.log(usage());
559
+ process.exit(0);
560
+ } else {
561
+ // A run costs three minutes, so a typo says so now rather than
562
+ // after the build has failed on an argument astro never wanted.
563
+ fail(
564
+ `Unknown argument \`${argument}\`. Build arguments go after \`--\`.\n\n${usage()}`,
565
+ );
566
+ }
567
+ }
568
+
569
+ return parsed;
570
+ }
571
+
572
+ function seconds(flag, value) {
573
+ const parsed = Number(value);
574
+
575
+ if (!Number.isFinite(parsed) || parsed <= 0) {
576
+ fail(`${flag} wants a number of seconds, not \`${value}\`.`);
577
+ }
578
+
579
+ return parsed * 1000;
580
+ }
581
+
582
+ function usage() {
583
+ return `Usage: cms-profile-interference [options] [-- astro build args]
584
+
585
+ --tag <label> Recorded in the report, for runs that vary one thing
586
+ --collection <name> The collection to poll (default ${DEFAULTS.collection})
587
+ --idle <seconds> Each idle phase (default ${DEFAULTS.idleMs / 1000})
588
+ --interval <seconds> Poll interval (default ${DEFAULTS.intervalMs / 1000})`;
589
+ }
590
+
591
+ function relativeToCwd(path) {
592
+ const cwd = `${process.cwd()}/`;
593
+
594
+ return path.startsWith(cwd) ? path.slice(cwd.length) : path;
595
+ }
596
+
597
+ function round(value) {
598
+ return Math.round(value * 10) / 10;
599
+ }
600
+
601
+ function fail(message) {
602
+ console.error(message);
603
+ process.exit(1);
604
+ }