@rathnasgala/cli 0.0.22 → 1.1.4

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 (69) hide show
  1. package/README.md +100 -187
  2. package/package.json +3 -3
  3. package/src/api/gala.js +163 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/domain.js +124 -0
  14. package/src/commands/init.js +276 -0
  15. package/src/commands/new.js +76 -0
  16. package/src/commands/preview.js +92 -0
  17. package/src/commands/prism.js +360 -0
  18. package/src/commands/publish.js +57 -0
  19. package/src/commands/upgrade.js +173 -0
  20. package/src/commands-manifest.js +80 -0
  21. package/src/content.js +31 -0
  22. package/src/domain.js +33 -0
  23. package/src/git.js +160 -0
  24. package/src/index.js +44 -294
  25. package/src/publication.js +39 -0
  26. package/src/assign-content-ids.js +0 -1
  27. package/src/auth-command.js +0 -36
  28. package/src/configure-site.js +0 -102
  29. package/src/content-files.js +0 -1
  30. package/src/doctor-command.js +0 -214
  31. package/src/entitlement-client.js +0 -26
  32. package/src/entitlement-command.js +0 -74
  33. package/src/evaluation-date.js +0 -1
  34. package/src/gala-credential-health.js +0 -34
  35. package/src/gala-credential-store.js +0 -115
  36. package/src/gala-device-flow.js +0 -121
  37. package/src/git-credentials.js +0 -37
  38. package/src/github-auth-command.js +0 -50
  39. package/src/github-credential-store.js +0 -104
  40. package/src/github-device-flow.js +0 -153
  41. package/src/github-empty-repository.js +0 -89
  42. package/src/github-identity.js +0 -32
  43. package/src/github-pages-provisioning.js +0 -107
  44. package/src/github-repository-secret.js +0 -82
  45. package/src/github-repository-variable.js +0 -56
  46. package/src/github-template-repository.js +0 -171
  47. package/src/hook-command.js +0 -64
  48. package/src/http-failure.js +0 -55
  49. package/src/new-command.js +0 -54
  50. package/src/open-browser.js +0 -40
  51. package/src/preview-command.js +0 -60
  52. package/src/publication-creation-client.js +0 -155
  53. package/src/publication-state.js +0 -7
  54. package/src/publish-command.js +0 -37
  55. package/src/record-deployment-command.js +0 -147
  56. package/src/refresh-command.js +0 -104
  57. package/src/repository-limits.js +0 -94
  58. package/src/scaffold-git.js +0 -76
  59. package/src/scaffold-options.js +0 -58
  60. package/src/scaffold-preflight.js +0 -146
  61. package/src/scaffold-site.js +0 -185
  62. package/src/site-config-registration.js +0 -47
  63. package/src/site-registration-client.js +0 -138
  64. package/src/theme-package.js +0 -128
  65. package/src/topology-client.js +0 -43
  66. package/src/topology-command.js +0 -70
  67. package/src/upgrade-command.js +0 -81
  68. package/src/validate-command.js +0 -5
  69. package/src/workflow-command.js +0 -87
@@ -1,60 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import path from 'node:path';
3
-
4
- import { regenerateBuildManifest } from './validate-command.js';
5
-
6
- const BUILD_WARNING_DELAY_MS = (5 * 60 * 1000) + 1;
7
- const INITIAL_BUILD_COMPLETE = /\bWrote \d+ files?\b/;
8
-
9
- export async function previewSite({
10
- root,
11
- today,
12
- spawnProcess = spawn,
13
- schedule = setTimeout,
14
- cancel = clearTimeout,
15
- output = process.stdout,
16
- warningOutput = process.stderr
17
- }) {
18
- const siteRoot = path.resolve(root);
19
- const { results: validation } = await regenerateBuildManifest({ root: siteRoot, today });
20
- const failures = validation.filter(({ errors }) => errors.length > 0);
21
- if (failures.length > 0) {
22
- throw new Error(`Preview refused: ${failures.length} post variant(s) failed validation`);
23
- }
24
-
25
- const cli = path.join(siteRoot, 'node_modules', '@11ty', 'eleventy', 'cmd.cjs');
26
- const child = spawnProcess(process.execPath, [cli, '--serve', '--watch'], {
27
- cwd: siteRoot,
28
- env: { ...process.env, GALA_EVALUATION_DATE: today },
29
- shell: false,
30
- stdio: ['inherit', 'pipe', 'pipe']
31
- });
32
-
33
- return new Promise((resolve, reject) => {
34
- let initialOutput = '';
35
- let warningTimer = schedule(() => {
36
- warningOutput.write('warning\tbuild-duration-5m\n');
37
- warningTimer = undefined;
38
- }, BUILD_WARNING_DELAY_MS);
39
- const stopTimer = () => {
40
- if (warningTimer !== undefined) cancel(warningTimer);
41
- warningTimer = undefined;
42
- };
43
- child.stdout?.on('data', (chunk) => {
44
- output.write(chunk);
45
- initialOutput = `${initialOutput}${chunk}`.slice(-256);
46
- if (INITIAL_BUILD_COMPLETE.test(initialOutput)) stopTimer();
47
- });
48
- child.stderr?.on('data', (chunk) => warningOutput.write(chunk));
49
- child.once('error', (error) => {
50
- stopTimer();
51
- reject(error);
52
- });
53
- child.once('exit', (code, signal) => {
54
- stopTimer();
55
- if (signal) reject(new Error(`Preview terminated by signal ${signal}`));
56
- else if (code !== 0) reject(new Error(`Preview exited with code ${code}`));
57
- else resolve();
58
- });
59
- });
60
- }
@@ -1,155 +0,0 @@
1
- /**
2
- * Creates the publication repository the way the browser editor does.
3
- *
4
- * The CLI used to call `POST /repos/{template}/generate` itself. That is a second, worse
5
- * implementation of something the API already does and does properly:
6
- *
7
- * - it tries the template, then falls back to creating an empty repository and seeding it, then
8
- * reports that the writer must do it by hand — the CLI's single attempt had no rung below it;
9
- * - it waits for the Gala App installation to actually reach the new repository before calling it
10
- * ready, which is why repositories the CLI created never appeared in the web UI;
11
- * - it returns the installation id, so registration cannot disagree with creation about which
12
- * installation owns the repository.
13
- *
14
- * One implementation, exercised by both clients, is the only way the two stay in step.
15
- */
16
- import { describeHttpFailure } from './http-failure.js';
17
- import { exchangeGithubAuthorization } from './site-registration-client.js';
18
-
19
- export async function createPublication({
20
- apiBaseUrl = 'https://api.gala67.com',
21
- galaAccessToken,
22
- githubAccessToken,
23
- name,
24
- fetchImpl = fetch,
25
- authorize = exchangeGithubAuthorization,
26
- notify = () => {},
27
- ask,
28
- openUrl = () => false,
29
- shareAttempts = 3,
30
- selfLogin
31
- }) {
32
- let created = false;
33
- let shareUrl = 'https://github.com/settings/installations';
34
- let repositoryOwner = selfLogin ?? '';
35
- let repositoryName = name;
36
- for (let attempt = 0; attempt < Math.max(1, shareAttempts); attempt += 1) {
37
- const result = await requestPublication({
38
- apiBaseUrl, galaAccessToken, githubAccessToken, name, fetchImpl, authorize
39
- });
40
- if (result.ready) return result.publication;
41
-
42
- /*
43
- * The repository exists with the right content; the App installation simply cannot see it,
44
- * because it is scoped to selected repositories rather than all of them. Sharing it is a click,
45
- * and asking again then returns READY — the server short-circuits on a repository it can
46
- * already see. The browser editor recovers the same way; without this the CLI dead-ends on a
47
- * state that is one click from working.
48
- *
49
- * After the first attempt the repository exists, so a further refusal reports UNSUPPORTED
50
- * rather than NEEDS_SHARING — the same situation under a different name.
51
- */
52
- const shareable = result.status === 'NEEDS_SHARING' || created;
53
- if (result.status === 'NEEDS_SHARING') {
54
- created = true;
55
- shareUrl = installationSettingsUrl(result.installationId, result.owner, selfLogin);
56
- repositoryName = result.repository ?? repositoryName;
57
- repositoryOwner = result.owner ?? repositoryOwner;
58
- }
59
- if (!shareable) throw result.failure;
60
- if (typeof ask !== 'function') {
61
- /*
62
- * No terminal to prompt at — CI, or a piped run. The repository exists and is one grant from
63
- * working, so the failure has to carry everything needed to finish it by hand. Falling back
64
- * to the generic message here threw away the deep link that had just been computed.
65
- */
66
- throw new Error(
67
- `${repositoryOwner}/${repositoryName} was created, but the Gala GitHub App cannot reach it `
68
- + `yet. Add that one repository to the installation at ${shareUrl}, then run scaffold again.`
69
- );
70
- }
71
-
72
- notify(`${repositoryOwner}/${repositoryName} was created, but the Gala GitHub App cannot reach `
73
- + 'it yet — its installation covers only selected repositories, which is the right way to '
74
- + 'have it. Add this one repository to the installation; nothing else needs granting.');
75
- notify(`${openUrl(shareUrl) ? 'Opened' : 'Open'} ${shareUrl}`);
76
- await ask('Press enter once the App can access that repository. ');
77
- }
78
- throw new Error(
79
- `The Gala GitHub App still cannot reach ${repositoryOwner}/${repositoryName}. Add that `
80
- + `repository to the installation at ${shareUrl}, then run scaffold again.`
81
- );
82
- }
83
-
84
- /**
85
- * The page that grants one repository, rather than the list of every app ever installed.
86
- *
87
- * GitHub keeps user and organisation installation settings on different paths, and only the caller
88
- * knows which this is: the created owner differing from the token's own account means the
89
- * installation lives on an organisation.
90
- */
91
- export function installationSettingsUrl(installationId, owner, selfLogin) {
92
- const generic = 'https://github.com/settings/installations';
93
- if (!Number.isSafeInteger(Number(installationId)) || Number(installationId) <= 0) return generic;
94
- const isOrganization = typeof owner === 'string' && typeof selfLogin === 'string'
95
- && owner.toLowerCase() !== selfLogin.toLowerCase();
96
- return isOrganization
97
- ? `https://github.com/organizations/${encodeURIComponent(owner)}/settings/installations/${installationId}`
98
- : `https://github.com/settings/installations/${installationId}`;
99
- }
100
-
101
- async function requestPublication({
102
- apiBaseUrl, galaAccessToken, githubAccessToken, name, fetchImpl, authorize
103
- }) {
104
- const authorization = await authorize({
105
- apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
106
- });
107
- const response = await fetchImpl(`${String(apiBaseUrl).replace(/\/$/, '')}/v1/auth/github/publications`, {
108
- method: 'POST',
109
- headers: {
110
- accept: 'application/json',
111
- authorization: `Bearer ${galaAccessToken}`,
112
- 'content-type': 'application/json',
113
- 'GitHub-Authorization': authorization
114
- },
115
- body: JSON.stringify({ name })
116
- });
117
- if (!response.ok) throw new Error(await describeHttpFailure(response, 'Gala publication creation'));
118
-
119
- const payload = await response.json();
120
- const status = payload?.status;
121
- const owner = payload?.owner;
122
- const repository = payload?.name;
123
-
124
- if (status !== 'READY') {
125
- return {
126
- ready: false, status, owner, repository,
127
- // Carried even though the repository is not in the installation yet: it is what makes the
128
- // grant a deep link rather than a hunt.
129
- installationId: payload?.installationId,
130
- failure: new Error(
131
- `Gala could not create the publication repository (${payload?.outcome ?? status}). `
132
- + 'Give the Gala GitHub App access to it at https://github.com/settings/installations, or '
133
- + 'create it from https://github.com/rathnasgala/site-template yourself and run scaffold '
134
- + 'with --empty-existing-repository.'
135
- )
136
- };
137
- }
138
- if (typeof owner !== 'string' || typeof repository !== 'string') {
139
- throw new TypeError('Gala publication creation returned no repository identity');
140
- }
141
- const installationId = Number(payload?.installationId);
142
- if (!Number.isSafeInteger(installationId) || installationId <= 0) {
143
- throw new TypeError('Gala publication creation returned no installation');
144
- }
145
-
146
- return { ready: true, status, owner, repository, publication: Object.freeze({
147
- owner,
148
- repository,
149
- installationId,
150
- outcome: payload?.outcome ?? null,
151
- // The server names the repository it actually made, which may differ from what was asked for.
152
- fullName: `${owner}/${repository}`,
153
- cloneUrl: `https://github.com/${owner}/${repository}.git`
154
- }) };
155
- }
@@ -1,7 +0,0 @@
1
- import {
2
- PUBLICATION_STATE_PATH,
3
- readPublicationState,
4
- recordSuccessfulDeployment
5
- } from '@rathnasgala/content-validation';
6
-
7
- export { PUBLICATION_STATE_PATH, readPublicationState, recordSuccessfulDeployment };
@@ -1,37 +0,0 @@
1
- import path from 'node:path';
2
- import { spawn } from 'node:child_process';
3
- import { regenerateBuildManifest } from './validate-command.js';
4
-
5
- export async function publishSite({
6
- root,
7
- today,
8
- force = false,
9
- spawnProcess = spawn,
10
- warn = (message) => process.stderr.write(`${message}\n`)
11
- }) {
12
- const siteRoot = path.resolve(root);
13
- if (!force) {
14
- const { results } = await regenerateBuildManifest({ root: siteRoot, today });
15
- const failures = results.filter(({ errors }) => errors.length > 0);
16
- if (failures.length > 0) {
17
- throw new Error(`Publish refused: ${failures.length} post variant(s) failed validation`);
18
- }
19
- for (const result of results) {
20
- for (const warning of result.warnings) warn(`${result.file}: warning: ${warning}`);
21
- }
22
- }
23
-
24
- return new Promise((resolve, reject) => {
25
- const child = spawnProcess('git', ['-C', siteRoot, 'push'], {
26
- cwd: siteRoot,
27
- shell: false,
28
- stdio: 'inherit'
29
- });
30
- child.once('error', reject);
31
- child.once('exit', (code, signal) => {
32
- if (signal) reject(new Error(`Publish terminated by signal ${signal}`));
33
- else if (code !== 0) reject(new Error(`Git push exited with code ${code}`));
34
- else resolve();
35
- });
36
- });
37
- }
@@ -1,147 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { lstat } from 'node:fs/promises';
3
- import { spawn } from 'node:child_process';
4
- import { createHash } from 'node:crypto';
5
- import path from 'node:path';
6
- import { parseFrontmatter } from '@rathnasgala/content-validation';
7
-
8
- import { repositoryEvaluationDate } from './evaluation-date.js';
9
- import { recordSuccessfulDeployment } from './publication-state.js';
10
- import { BUILD_MANIFEST_PATH } from './validate-command.js';
11
-
12
- function runGit(root, args, spawnProcess) {
13
- return new Promise((resolve, reject) => {
14
- const child = spawnProcess('git', ['-C', root, ...args], {
15
- cwd: root,
16
- shell: false,
17
- stdio: 'inherit'
18
- });
19
- child.once('error', reject);
20
- child.once('exit', (code, signal) => {
21
- if (signal) reject(new Error(`Git terminated by signal ${signal}`));
22
- else resolve(code);
23
- });
24
- });
25
- }
26
-
27
- function readHead(root, spawnProcess) {
28
- return new Promise((resolve, reject) => {
29
- const child = spawnProcess('git', ['-C', root, 'rev-parse', '--verify', 'HEAD'], {
30
- cwd: root,
31
- shell: false,
32
- stdio: ['ignore', 'pipe', 'inherit']
33
- });
34
- let output = '';
35
- child.stdout?.on('data', (chunk) => { output += chunk; });
36
- child.once('error', reject);
37
- child.once('exit', (code, signal) => {
38
- if (signal) reject(new Error(`Git terminated by signal ${signal}`));
39
- else if (code !== 0) reject(new Error(`Git rev-parse exited with code ${code}`));
40
- else {
41
- const sha = output.trim();
42
- if (!/^[0-9a-f]{40}$/.test(sha)) reject(new Error('Git returned an invalid HEAD SHA'));
43
- else resolve(sha);
44
- }
45
- });
46
- });
47
- }
48
-
49
- async function assignedContentPaths(root, manifest) {
50
- const assigned = manifest.assignedContentIds ?? [];
51
- if (!Array.isArray(assigned)) throw new TypeError('assignedContentIds must be a list');
52
- const sources = new Set();
53
- for (const item of assigned) {
54
- if (item == null
55
- || typeof item.source !== 'string'
56
- || !/^content\/posts\/[a-z0-9]+(?:-[a-z0-9]+)*\/index\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.md$/.test(item.source)
57
- || typeof item.id !== 'string'
58
- || !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(item.id)
59
- || typeof item.fileHash !== 'string'
60
- || !/^[a-f0-9]{64}$/.test(item.fileHash)
61
- || sources.has(item.source)) {
62
- throw new TypeError('assignedContentIds contains an invalid entry');
63
- }
64
- const file = path.resolve(root, item.source);
65
- const metadata = await lstat(file);
66
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
67
- throw new TypeError(`Assigned-ID source must be a regular file: ${item.source}`);
68
- }
69
- const bytes = await readFile(file);
70
- if (createHash('sha256').update(bytes).digest('hex') !== item.fileHash) {
71
- throw new Error(`Assigned-ID source changed after the deployed build: ${item.source}`);
72
- }
73
- const parsed = parseFrontmatter(bytes.toString('utf8'));
74
- if (parsed.errors.length > 0 || parsed.data.id !== item.id) {
75
- throw new Error(`Assigned-ID source no longer contains its deployed ULID: ${item.source}`);
76
- }
77
- sources.add(item.source);
78
- }
79
- return [...sources].sort();
80
- }
81
-
82
- export async function recordDeployment({
83
- root,
84
- deployedOn,
85
- now,
86
- deployedCommitSha,
87
- spawnProcess = spawn
88
- }) {
89
- const siteRoot = path.resolve(root);
90
- const manifestPath = path.join(siteRoot, BUILD_MANIFEST_PATH);
91
- let manifest;
92
- try {
93
- manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
94
- } catch (error) {
95
- if (error.code === 'ENOENT') {
96
- throw new Error('Current validated build manifest is missing; deployment cannot be recorded');
97
- }
98
- throw new TypeError(`Current validated build manifest is invalid: ${error.message}`);
99
- }
100
- const date = deployedOn ?? await repositoryEvaluationDate({ root: siteRoot, now });
101
- if (typeof deployedCommitSha !== 'string' || !/^[0-9a-f]{40}$/.test(deployedCommitSha)) {
102
- throw new TypeError('record-deployment requires --commit-sha <lowercase 40-character SHA>');
103
- }
104
- const head = await readHead(siteRoot, spawnProcess);
105
- if (deployedCommitSha !== head) {
106
- throw new Error(`Deployment SHA ${deployedCommitSha} does not match checkout HEAD ${head}`);
107
- }
108
- const state = await recordSuccessfulDeployment({
109
- root: siteRoot,
110
- manifest,
111
- deployedOn: date,
112
- deployedCommitSha
113
- });
114
- const statePath = '.gala/publication-state.yml';
115
- const contentPaths = await assignedContentPaths(siteRoot, manifest);
116
- const committedPaths = [statePath, ...contentPaths];
117
- const addCode = await runGit(siteRoot, ['add', '--', ...committedPaths], spawnProcess);
118
- if (addCode !== 0) throw new Error(`Git add exited with code ${addCode}`);
119
- const diffCode = await runGit(
120
- siteRoot,
121
- ['diff', '--cached', '--quiet', '--exit-code', '--', ...committedPaths],
122
- spawnProcess
123
- );
124
- if (diffCode === 0) return { state, pushed: false, recordedStateSha: head };
125
- if (diffCode !== 1) throw new Error(`Git diff exited with code ${diffCode}`);
126
- const assignmentTrailers = (manifest.assignedContentIds ?? []).map(
127
- ({ id, source }) => `Gala-Assigned-ID: ${id} ${source}`
128
- );
129
- const commitMessage = [
130
- 'chore(gala): record successful deployment [skip ci]',
131
- '',
132
- `Gala-Deployed-SHA: ${deployedCommitSha}`,
133
- ...assignmentTrailers
134
- ].join('\n');
135
- const commitCode = await runGit(siteRoot, [
136
- 'commit', '--only', '-m', commitMessage,
137
- '--', ...committedPaths
138
- ], spawnProcess);
139
- if (commitCode !== 0) throw new Error(`Git commit exited with code ${commitCode}`);
140
- const recordedStateSha = await readHead(siteRoot, spawnProcess);
141
- if (recordedStateSha === deployedCommitSha) {
142
- throw new Error('Git did not create a distinct recorded-state commit');
143
- }
144
- const pushCode = await runGit(siteRoot, ['push'], spawnProcess);
145
- if (pushCode !== 0) throw new Error(`Git push exited with code ${pushCode}`);
146
- return { state, pushed: true, recordedStateSha };
147
- }
@@ -1,104 +0,0 @@
1
- import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { parse } from 'yaml';
5
-
6
- import { readGalaCredential } from './gala-credential-store.js';
7
-
8
- const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
9
- const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
10
- const SNAPSHOT_PATH = '.engagement-snapshot.json';
11
-
12
- async function runGit(root, args) {
13
- return new Promise((resolve, reject) => {
14
- const child = spawn('git', ['-C', root, ...args], { shell: false, stdio: 'inherit' });
15
- child.once('error', reject);
16
- child.once('exit', (code, signal) => {
17
- if (signal) reject(new Error(`git terminated by signal ${signal}`));
18
- else if (code !== 0) reject(new Error(`git ${args[0]} exited with code ${code}`));
19
- else resolve();
20
- });
21
- });
22
- }
23
-
24
- async function commitRefreshedSnapshot(root, relativePath) {
25
- await runGit(root, [
26
- 'commit', '--only', '--message', 'chore(gala): refresh engagement snapshot', '--', relativePath
27
- ]);
28
- await runGit(root, ['push']);
29
- }
30
-
31
- function validateSnapshot(payload) {
32
- if (payload?.schemaVersion !== 1 || !UTC_INSTANT.test(payload.refreshedAt)
33
- || payload.articles == null || Array.isArray(payload.articles)
34
- || typeof payload.articles !== 'object') {
35
- throw new TypeError('Engagement snapshot response is invalid');
36
- }
37
- for (const [articleId, counts] of Object.entries(payload.articles)) {
38
- if (!ULID.test(articleId) || counts == null || Array.isArray(counts)
39
- || typeof counts !== 'object'
40
- || Object.keys(counts).sort().join(',') !== 'comments,reactions,views'
41
- || !['reactions', 'comments', 'views'].every(
42
- (field) => Number.isSafeInteger(counts[field]) && counts[field] >= 0
43
- )) {
44
- throw new TypeError('Engagement snapshot response is invalid');
45
- }
46
- }
47
- return payload;
48
- }
49
-
50
- async function requireRegularFile(target, label, { allowMissing = false } = {}) {
51
- try {
52
- const metadata = await lstat(target);
53
- if (!metadata.isFile() || metadata.isSymbolicLink()) {
54
- throw new TypeError(`${label} must be a regular file`);
55
- }
56
- return true;
57
- } catch (error) {
58
- if (allowMissing && error.code === 'ENOENT') return false;
59
- throw error;
60
- }
61
- }
62
-
63
- export async function refreshEngagementSnapshot({
64
- root = process.cwd(),
65
- readCredential = readGalaCredential,
66
- fetchImpl = fetch,
67
- commitSnapshot = commitRefreshedSnapshot
68
- } = {}) {
69
- const siteRoot = path.resolve(root);
70
- const configPath = path.join(siteRoot, 'site.config.yml');
71
- await requireRegularFile(configPath, 'site.config.yml');
72
- const config = parse(await readFile(configPath, 'utf8'));
73
- const siteId = config?.site?.id;
74
- if (!ULID.test(siteId)) throw new TypeError('site.config.yml site.id must be a canonical ULID');
75
-
76
- const credential = await readCredential();
77
- const endpoint = new URL(`/v1/sites/${siteId}/engagement-snapshot`, credential.apiBaseUrl);
78
- const loopback = endpoint.protocol === 'http:'
79
- && ['127.0.0.1', 'localhost', '::1'].includes(endpoint.hostname);
80
- if ((endpoint.protocol !== 'https:' && !loopback) || endpoint.username || endpoint.password) {
81
- throw new TypeError('Gala API URL must be credential-free HTTPS or HTTP loopback');
82
- }
83
- const response = await fetchImpl(endpoint, {
84
- method: 'GET',
85
- headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' }
86
- });
87
- if (!response.ok) throw new Error(`Engagement snapshot refresh failed with HTTP ${response.status}`);
88
- const snapshot = validateSnapshot(await response.json());
89
- const next = `${JSON.stringify(snapshot, null, 2)}\n`;
90
- const target = path.join(siteRoot, SNAPSHOT_PATH);
91
- const exists = await requireRegularFile(target, 'Engagement snapshot', { allowMissing: true });
92
- if (exists && await readFile(target, 'utf8') === next) return Object.freeze({ changed: false });
93
-
94
- const temporary = `${target}.gala-${process.pid}`;
95
- try {
96
- await writeFile(temporary, next, { flag: 'wx' });
97
- await rename(temporary, target);
98
- } catch (error) {
99
- await rm(temporary, { force: true });
100
- throw error;
101
- }
102
- await commitSnapshot(siteRoot, SNAPSHOT_PATH);
103
- return Object.freeze({ changed: true });
104
- }
@@ -1,94 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { lstat, readdir } from 'node:fs/promises';
3
- import path from 'node:path';
4
-
5
- const MEBIBYTE = 1024 * 1024;
6
-
7
- function gitRepositoryBytes(root, spawnProcess) {
8
- return new Promise((resolve, reject) => {
9
- const child = spawnProcess('git', ['-C', root, 'count-objects', '-v'], {
10
- cwd: root,
11
- shell: false,
12
- stdio: ['ignore', 'pipe', 'pipe']
13
- });
14
- let output = '';
15
- let errors = '';
16
- child.stdout.on('data', (chunk) => { output += chunk; });
17
- child.stderr.on('data', (chunk) => { errors += chunk; });
18
- child.once('error', reject);
19
- child.once('exit', (code, signal) => {
20
- if (signal) return reject(new Error(`Repository inspection terminated by signal ${signal}`));
21
- if (code !== 0) return reject(new Error(`Repository inspection failed: ${errors.trim()}`));
22
- const values = Object.fromEntries(output.trim().split('\n').map((line) => {
23
- const separator = line.indexOf(':');
24
- return [line.slice(0, separator), Number(line.slice(separator + 1).trim())];
25
- }));
26
- if (!Number.isFinite(values.size) || !Number.isFinite(values['size-pack'])) {
27
- return reject(new Error('Git returned invalid repository size metadata'));
28
- }
29
- resolve((values.size + values['size-pack']) * 1024);
30
- });
31
- });
32
- }
33
-
34
- async function countPosts(directory) {
35
- const entries = await readdir(directory, { withFileTypes: true });
36
- const counts = await Promise.all(entries.map((entry) => {
37
- if (entry.isDirectory()) return countPosts(path.join(directory, entry.name));
38
- return Promise.resolve(entry.isFile() && /^index\.[^.]+\.md$/.test(entry.name) ? 1 : 0);
39
- }));
40
- return counts.reduce((total, count) => total + count, 0);
41
- }
42
-
43
- export function repositoryLimitWarnings({ repositoryBytes, postCount, buildDurationMs }) {
44
- const warnings = [];
45
- if (repositoryBytes > 800 * MEBIBYTE) {
46
- warnings.push({ severity: 'critical', code: 'repository-size-800mb' });
47
- } else if (repositoryBytes > 500 * MEBIBYTE) {
48
- warnings.push({ severity: 'warning', code: 'repository-size-500mb' });
49
- }
50
- if (postCount > 1000) warnings.push({ severity: 'warning', code: 'post-count-1000' });
51
- if (buildDurationMs != null && buildDurationMs > 5 * 60 * 1000) {
52
- warnings.push({ severity: 'warning', code: 'build-duration-5m' });
53
- }
54
- return warnings;
55
- }
56
-
57
- export async function inspectRepositoryLimits(root, { spawnProcess = spawn, buildDurationMs } = {}) {
58
- const resolvedRoot = path.resolve(root);
59
- const [repositoryBytes, postCount] = await Promise.all([
60
- gitRepositoryBytes(resolvedRoot, spawnProcess),
61
- countPosts(path.join(resolvedRoot, 'content', 'posts'))
62
- ]);
63
- return {
64
- repositoryBytes,
65
- postCount,
66
- warnings: repositoryLimitWarnings({ repositoryBytes, postCount, buildDurationMs })
67
- };
68
- }
69
-
70
- export async function reportRepositoryLimitWarnings(
71
- root,
72
- { spawnProcess = spawn, output = process.stderr } = {}
73
- ) {
74
- const resolvedRoot = path.resolve(root);
75
- try {
76
- const [gitMetadata, config, posts] = await Promise.all([
77
- lstat(path.join(resolvedRoot, '.git')),
78
- lstat(path.join(resolvedRoot, 'site.config.yml')),
79
- lstat(path.join(resolvedRoot, 'content', 'posts'))
80
- ]);
81
- if ((!gitMetadata.isDirectory() && !gitMetadata.isFile())
82
- || !config.isFile() || config.isSymbolicLink()
83
- || !posts.isDirectory() || posts.isSymbolicLink()) {
84
- return [];
85
- }
86
- } catch (error) {
87
- if (error.code === 'ENOENT') return [];
88
- throw error;
89
- }
90
-
91
- const { warnings } = await inspectRepositoryLimits(resolvedRoot, { spawnProcess });
92
- for (const { severity, code } of warnings) output.write(`${severity}\t${code}\n`);
93
- return warnings;
94
- }