@rathnasgala/cli 0.0.1
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/package.json +32 -0
- package/src/assign-content-ids.js +1 -0
- package/src/auth-command.js +36 -0
- package/src/configure-site.js +77 -0
- package/src/content-files.js +1 -0
- package/src/doctor-command.js +214 -0
- package/src/evaluation-date.js +1 -0
- package/src/gala-credential-store.js +104 -0
- package/src/gala-device-flow.js +121 -0
- package/src/github-auth-command.js +29 -0
- package/src/github-credential-store.js +65 -0
- package/src/github-device-flow.js +130 -0
- package/src/github-empty-repository.js +63 -0
- package/src/github-pages-provisioning.js +83 -0
- package/src/github-repository-secret.js +82 -0
- package/src/github-repository-variable.js +55 -0
- package/src/github-template-repository.js +117 -0
- package/src/hook-command.js +64 -0
- package/src/index.js +224 -0
- package/src/new-command.js +54 -0
- package/src/preview-command.js +60 -0
- package/src/publication-state.js +7 -0
- package/src/publish-command.js +37 -0
- package/src/record-deployment-command.js +147 -0
- package/src/repository-limits.js +94 -0
- package/src/scaffold-git.js +41 -0
- package/src/scaffold-options.js +58 -0
- package/src/scaffold-site.js +110 -0
- package/src/site-config-registration.js +37 -0
- package/src/site-registration-client.js +87 -0
- package/src/theme-package.js +128 -0
- package/src/upgrade-command.js +73 -0
- package/src/validate-command.js +5 -0
- package/src/workflow-command.js +85 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseScaffoldOptions } from './scaffold-options.js';
|
|
4
|
+
import { regenerateBuildManifest } from './validate-command.js';
|
|
5
|
+
import { createPost } from './new-command.js';
|
|
6
|
+
import {
|
|
7
|
+
diagnoseFramework,
|
|
8
|
+
diagnosePublicationState,
|
|
9
|
+
repairFramework
|
|
10
|
+
} from './doctor-command.js';
|
|
11
|
+
import { configureSite } from './configure-site.js';
|
|
12
|
+
import { previewSite } from './preview-command.js';
|
|
13
|
+
import { writePublishWorkflow } from './workflow-command.js';
|
|
14
|
+
import { publishSite } from './publish-command.js';
|
|
15
|
+
import { installPrePushHook } from './hook-command.js';
|
|
16
|
+
import { reportRepositoryLimitWarnings } from './repository-limits.js';
|
|
17
|
+
import { recordDeployment } from './record-deployment-command.js';
|
|
18
|
+
import { authenticateGala } from './auth-command.js';
|
|
19
|
+
import { createInterface } from 'node:readline/promises';
|
|
20
|
+
import { upgradeTheme } from './upgrade-command.js';
|
|
21
|
+
import { authenticateGithub } from './github-auth-command.js';
|
|
22
|
+
import { scaffoldSite } from './scaffold-site.js';
|
|
23
|
+
|
|
24
|
+
const [command, ...args] = process.argv.slice(2);
|
|
25
|
+
const usage = 'Usage: gala <auth|configure|scaffold|validate|new|doctor|hook|preview|publish|record-deployment|upgrade|workflow> [options]';
|
|
26
|
+
|
|
27
|
+
if (command === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
28
|
+
process.stdout.write(`${usage}\n`);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const recognizedCommands = new Set([
|
|
33
|
+
'auth', 'configure', 'validate', 'new', 'doctor', 'preview',
|
|
34
|
+
'workflow', 'publish', 'record-deployment', 'hook', 'upgrade'
|
|
35
|
+
]);
|
|
36
|
+
function commandRoot() {
|
|
37
|
+
const rootIndex = args.indexOf('--root');
|
|
38
|
+
if (rootIndex !== -1) return args[rootIndex + 1];
|
|
39
|
+
if (command === 'doctor' || command === 'validate') {
|
|
40
|
+
return args.find((argument, index) =>
|
|
41
|
+
!argument.startsWith('--')
|
|
42
|
+
&& !['--today', '--source'].includes(args[index - 1])
|
|
43
|
+
) ?? process.cwd();
|
|
44
|
+
}
|
|
45
|
+
return process.cwd();
|
|
46
|
+
}
|
|
47
|
+
if (recognizedCommands.has(command)) {
|
|
48
|
+
await reportRepositoryLimitWarnings(commandRoot());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (command === 'auth') {
|
|
52
|
+
if (args[0] === 'github') {
|
|
53
|
+
const result = await authenticateGithub({
|
|
54
|
+
showScopeWarning: ({ explanation }) => process.stdout.write(`GitHub authorization: ${explanation}\n`),
|
|
55
|
+
showInstructions: ({ verificationUri, userCode }) => {
|
|
56
|
+
process.stdout.write(`Open ${verificationUri}\nEnter code: ${userCode}\n`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
process.stdout.write(`GitHub authentication stored securely with scopes: ${result.scopes.join(', ')}.\n`);
|
|
60
|
+
} else {
|
|
61
|
+
const apiIndex = args.indexOf('--api-base-url');
|
|
62
|
+
const apiBaseUrl = apiIndex === -1 ? 'https://api.gala67.com' : args[apiIndex + 1];
|
|
63
|
+
const result = await authenticateGala({
|
|
64
|
+
apiBaseUrl,
|
|
65
|
+
showInstructions: ({ verificationUri, userCode }) => {
|
|
66
|
+
process.stdout.write(`Open ${verificationUri}\nEnter code: ${userCode}\n`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
process.stdout.write(`Gala authentication stored securely until ${result.expiresAt.toISOString()}.\n`);
|
|
70
|
+
}
|
|
71
|
+
} else if (command === 'scaffold') {
|
|
72
|
+
const valueFor = (name) => {
|
|
73
|
+
const index = args.indexOf(name);
|
|
74
|
+
return index === -1 ? undefined : args[index + 1];
|
|
75
|
+
};
|
|
76
|
+
const installationId = Number(valueFor('--installation-id'));
|
|
77
|
+
const result = await scaffoldSite({
|
|
78
|
+
owner: valueFor('--owner'),
|
|
79
|
+
repository: valueFor('--repository'),
|
|
80
|
+
target: valueFor('--target'),
|
|
81
|
+
githubInstallationId: installationId,
|
|
82
|
+
siteOptions: parseScaffoldOptions(args),
|
|
83
|
+
buildMode: valueFor('--mode') ?? 'build-and-deploy',
|
|
84
|
+
emptyExistingRepository: args.includes('--empty-existing-repository'),
|
|
85
|
+
resumeExistingCheckout: args.includes('--resume')
|
|
86
|
+
});
|
|
87
|
+
await reportRepositoryLimitWarnings(result.root);
|
|
88
|
+
process.stdout.write(`Scaffolded ${result.fullName} as Gala site ${result.siteId} in ${result.root}.\n`);
|
|
89
|
+
} else if (command === 'configure') {
|
|
90
|
+
const rootIndex = args.indexOf('--root');
|
|
91
|
+
const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
|
|
92
|
+
const options = parseScaffoldOptions(args);
|
|
93
|
+
const config = await configureSite(root, options);
|
|
94
|
+
process.stdout.write(`${JSON.stringify(config.design, null, 2)}\n`);
|
|
95
|
+
} else if (command === 'validate') {
|
|
96
|
+
const todayIndex = args.indexOf('--today');
|
|
97
|
+
const today = todayIndex === -1 ? undefined : args[todayIndex + 1];
|
|
98
|
+
const root = args.find((argument) => !argument.startsWith('--') && argument !== today) ?? process.cwd();
|
|
99
|
+
const { results } = await regenerateBuildManifest({ root, today });
|
|
100
|
+
const failures = results.filter(({ errors }) => errors.length > 0);
|
|
101
|
+
|
|
102
|
+
for (const result of failures) {
|
|
103
|
+
for (const error of result.errors) process.stderr.write(`${result.file}: ${error}\n`);
|
|
104
|
+
}
|
|
105
|
+
for (const result of results) {
|
|
106
|
+
for (const warning of result.warnings) process.stderr.write(`${result.file}: warning: ${warning}\n`);
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write(`Validated ${results.length} post variant(s); ${failures.length} failed.\n`);
|
|
109
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
110
|
+
} else if (command === 'new') {
|
|
111
|
+
const valueFor = (name) => {
|
|
112
|
+
const index = args.indexOf(name);
|
|
113
|
+
return index === -1 ? undefined : args[index + 1];
|
|
114
|
+
};
|
|
115
|
+
const title = valueFor('--title');
|
|
116
|
+
const language = valueFor('--language');
|
|
117
|
+
const today = valueFor('--today');
|
|
118
|
+
const root = valueFor('--root') ?? process.cwd();
|
|
119
|
+
const result = await createPost({ root, title, language, today });
|
|
120
|
+
process.stdout.write(`Created ${result.postPath}\n`);
|
|
121
|
+
} else if (command === 'doctor') {
|
|
122
|
+
const positional = args.filter((argument, index) =>
|
|
123
|
+
!argument.startsWith('--') && args[index - 1] !== '--source'
|
|
124
|
+
);
|
|
125
|
+
const root = positional[0] ?? process.cwd();
|
|
126
|
+
if (args.includes('--fix')) {
|
|
127
|
+
const sourceIndex = args.indexOf('--source');
|
|
128
|
+
const sourceRoot = sourceIndex === -1 ? undefined : args[sourceIndex + 1];
|
|
129
|
+
if (!sourceRoot) throw new Error('doctor --fix requires --source <trusted-template-root>');
|
|
130
|
+
const repaired = await repairFramework(root, sourceRoot);
|
|
131
|
+
process.stdout.write(`Repaired ${repaired.length} managed file(s).\n`);
|
|
132
|
+
}
|
|
133
|
+
const findings = await diagnoseFramework(root);
|
|
134
|
+
const drift = findings.filter(({ status }) => status !== 'intact');
|
|
135
|
+
findings.forEach(({ path: file, status }) => process.stdout.write(`${status}\t${file}\n`));
|
|
136
|
+
const publicationState = await diagnosePublicationState(root);
|
|
137
|
+
process.stdout.write(`${publicationState.status}\t${publicationState.path}\n`);
|
|
138
|
+
if (publicationState.status === 'invalid') process.exitCode = 1;
|
|
139
|
+
if (drift.length > 0) process.exitCode = 1;
|
|
140
|
+
} else if (command === 'preview') {
|
|
141
|
+
const valueFor = (name) => {
|
|
142
|
+
const index = args.indexOf(name);
|
|
143
|
+
return index === -1 ? undefined : args[index + 1];
|
|
144
|
+
};
|
|
145
|
+
await previewSite({
|
|
146
|
+
root: valueFor('--root') ?? process.cwd(),
|
|
147
|
+
today: valueFor('--today')
|
|
148
|
+
});
|
|
149
|
+
} else if (command === 'workflow') {
|
|
150
|
+
const valueFor = (name) => {
|
|
151
|
+
const index = args.indexOf(name);
|
|
152
|
+
return index === -1 ? undefined : args[index + 1];
|
|
153
|
+
};
|
|
154
|
+
const result = await writePublishWorkflow({
|
|
155
|
+
root: valueFor('--root') ?? process.cwd(),
|
|
156
|
+
siteId: valueFor('--site-id'),
|
|
157
|
+
timezone: valueFor('--timezone'),
|
|
158
|
+
actionRef: valueFor('--action-ref'),
|
|
159
|
+
defaultBranch: valueFor('--default-branch') ?? 'main',
|
|
160
|
+
buildMode: valueFor('--mode') ?? 'build-and-deploy'
|
|
161
|
+
});
|
|
162
|
+
process.stdout.write(`Wrote ${result.target} (${result.minute} ${result.hour} * * *)\n`);
|
|
163
|
+
} else if (command === 'publish') {
|
|
164
|
+
const valueFor = (name) => {
|
|
165
|
+
const index = args.indexOf(name);
|
|
166
|
+
return index === -1 ? undefined : args[index + 1];
|
|
167
|
+
};
|
|
168
|
+
await publishSite({
|
|
169
|
+
root: valueFor('--root') ?? process.cwd(),
|
|
170
|
+
today: valueFor('--today'),
|
|
171
|
+
force: args.includes('--force')
|
|
172
|
+
});
|
|
173
|
+
} else if (command === 'record-deployment') {
|
|
174
|
+
const valueFor = (name) => {
|
|
175
|
+
const index = args.indexOf(name);
|
|
176
|
+
return index === -1 ? undefined : args[index + 1];
|
|
177
|
+
};
|
|
178
|
+
const root = valueFor('--root') ?? process.cwd();
|
|
179
|
+
const result = await recordDeployment({
|
|
180
|
+
root,
|
|
181
|
+
deployedOn: valueFor('--today'),
|
|
182
|
+
deployedCommitSha: valueFor('--commit-sha')
|
|
183
|
+
});
|
|
184
|
+
process.stdout.write(
|
|
185
|
+
`${result.pushed ? 'Pushed' : 'No change for'} successful deployment `
|
|
186
|
+
+ `of ${result.state.posts.length} article(s).\n`
|
|
187
|
+
+ `Recorded state SHA: ${result.recordedStateSha}\n`
|
|
188
|
+
);
|
|
189
|
+
} else if (command === 'upgrade') {
|
|
190
|
+
const valueFor = (name) => {
|
|
191
|
+
const index = args.indexOf(name);
|
|
192
|
+
return index === -1 ? undefined : args[index + 1];
|
|
193
|
+
};
|
|
194
|
+
const terminalConfirm = async ({ installed, version, channel }) => {
|
|
195
|
+
if (args.includes('--yes')) return true;
|
|
196
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
197
|
+
try {
|
|
198
|
+
const answer = await terminal.question(`Upgrade theme ${installed} -> ${version} (${channel})? [y/N] `);
|
|
199
|
+
return /^(?:y|yes)$/i.test(answer.trim());
|
|
200
|
+
} finally { terminal.close(); }
|
|
201
|
+
};
|
|
202
|
+
const result = await upgradeTheme({
|
|
203
|
+
root: valueFor('--root') ?? process.cwd(),
|
|
204
|
+
channel: valueFor('--channel'),
|
|
205
|
+
confirm: terminalConfirm
|
|
206
|
+
});
|
|
207
|
+
process.stdout.write(result.cancelled ? 'Theme upgrade cancelled.\n'
|
|
208
|
+
: result.changed ? `Upgraded theme to ${result.version}.\n`
|
|
209
|
+
: `Theme ${result.version} is already installed.\n`);
|
|
210
|
+
process.stdout.write(
|
|
211
|
+
`Action major v${result.action.currentMajor}; `
|
|
212
|
+
+ (result.action.newerAvailable
|
|
213
|
+
? `v${result.action.latestMajor} is available.\n`
|
|
214
|
+
: 'no newer major is available.\n')
|
|
215
|
+
);
|
|
216
|
+
} else if (command === 'hook' && args[0] === 'install') {
|
|
217
|
+
const rootIndex = args.indexOf('--root');
|
|
218
|
+
const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
|
|
219
|
+
const result = await installPrePushHook(root);
|
|
220
|
+
process.stdout.write(`${result.installed ? 'Installed' : 'Already installed'} ${result.target}\n`);
|
|
221
|
+
} else {
|
|
222
|
+
process.stderr.write(`${usage}\n`);
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createPostMetadata,
|
|
6
|
+
isContentId,
|
|
7
|
+
parseFrontmatter,
|
|
8
|
+
slugifyTitle
|
|
9
|
+
} from '@rathnasgala/content-validation';
|
|
10
|
+
import { stringify } from 'yaml';
|
|
11
|
+
import { repositoryEvaluationDate } from './evaluation-date.js';
|
|
12
|
+
|
|
13
|
+
export async function createPost({ root, title, language, today, now = Date.now }) {
|
|
14
|
+
const siteRoot = path.resolve(root);
|
|
15
|
+
const creationTimestamp = now();
|
|
16
|
+
const publishAfterDate = today ?? await repositoryEvaluationDate({
|
|
17
|
+
root: siteRoot,
|
|
18
|
+
now: () => creationTimestamp
|
|
19
|
+
});
|
|
20
|
+
const metadata = createPostMetadata({
|
|
21
|
+
title,
|
|
22
|
+
language,
|
|
23
|
+
today: publishAfterDate,
|
|
24
|
+
timestamp: creationTimestamp
|
|
25
|
+
});
|
|
26
|
+
const postDirectory = path.join(siteRoot, 'content', 'posts', slugifyTitle(title));
|
|
27
|
+
const mediaDirectory = path.join(postDirectory, 'media');
|
|
28
|
+
const postPath = path.join(postDirectory, `index.${metadata.language}.md`);
|
|
29
|
+
|
|
30
|
+
await mkdir(postDirectory, { recursive: true });
|
|
31
|
+
const variants = (await readdir(postDirectory, { withFileTypes: true }))
|
|
32
|
+
.filter((entry) => entry.isFile() && /^index\.[^.]+\.md$/.test(entry.name));
|
|
33
|
+
const existingIds = new Set();
|
|
34
|
+
for (const variant of variants) {
|
|
35
|
+
const variantPath = path.join(postDirectory, variant.name);
|
|
36
|
+
const parsed = parseFrontmatter(await readFile(variantPath, 'utf8'));
|
|
37
|
+
if (parsed.errors.length > 0) {
|
|
38
|
+
throw new Error(`Existing variant has invalid frontmatter: ${variantPath}`);
|
|
39
|
+
}
|
|
40
|
+
if (!isContentId(parsed.data.id)) {
|
|
41
|
+
throw new Error(`Existing variant is missing a valid article id: ${variantPath}`);
|
|
42
|
+
}
|
|
43
|
+
existingIds.add(parsed.data.id);
|
|
44
|
+
}
|
|
45
|
+
if (existingIds.size > 1) {
|
|
46
|
+
throw new Error(`Existing variants have conflicting article ids: ${postDirectory}`);
|
|
47
|
+
}
|
|
48
|
+
if (existingIds.size === 1) metadata.id = existingIds.values().next().value;
|
|
49
|
+
|
|
50
|
+
await mkdir(mediaDirectory, { recursive: true });
|
|
51
|
+
const source = `---\n${stringify(metadata).trimEnd()}\n---\n\n# ${title}\n`;
|
|
52
|
+
await writeFile(postPath, source, { encoding: 'utf8', flag: 'wx' });
|
|
53
|
+
return { metadata, postPath, mediaDirectory };
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
function run(root, args, spawnProcess, acceptedExitCodes = [0]) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawnProcess('git', ['-C', root, ...args], { cwd: root, shell: false, stdio: 'inherit' });
|
|
6
|
+
child.once('error', reject);
|
|
7
|
+
child.once('exit', (code, signal) => {
|
|
8
|
+
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
9
|
+
else if (!acceptedExitCodes.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
|
|
10
|
+
else resolve(code);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function capture(root, args, spawnProcess) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawnProcess('git', ['-C', root, ...args], {
|
|
18
|
+
cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit']
|
|
19
|
+
});
|
|
20
|
+
let stdout = '';
|
|
21
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
22
|
+
child.once('error', reject);
|
|
23
|
+
child.once('exit', (code, signal) => {
|
|
24
|
+
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
25
|
+
else if (code !== 0) reject(new Error(`Git ${args[0]} exited with code ${code}`));
|
|
26
|
+
else resolve(stdout.trim());
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function commitScaffold(root, { spawnProcess = spawn } = {}) {
|
|
32
|
+
await run(root, ['add', '--', 'site.config.yml', '.github/workflows/publish.yml'], spawnProcess);
|
|
33
|
+
const unchanged = await run(root, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
|
|
34
|
+
if (unchanged === 1) {
|
|
35
|
+
await run(root, ['commit', '-m', 'chore(gala): configure site'], spawnProcess);
|
|
36
|
+
}
|
|
37
|
+
await run(root, ['push', 'origin', 'HEAD'], spawnProcess);
|
|
38
|
+
const commitSha = await capture(root, ['rev-parse', 'HEAD'], spawnProcess);
|
|
39
|
+
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid scaffold commit SHA');
|
|
40
|
+
return commitSha;
|
|
41
|
+
}
|