@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.
- package/README.md +100 -187
- package/package.json +3 -3
- package/src/api/gala.js +163 -0
- package/src/api/github.js +63 -0
- package/src/api/http.js +72 -0
- package/src/auth/gala.js +52 -0
- package/src/auth/github.js +78 -0
- package/src/auth/store.js +88 -0
- package/src/cli/args.js +56 -0
- package/src/cli/terminal.js +104 -0
- package/src/commands/auth.js +20 -0
- package/src/commands/doctor.js +98 -0
- package/src/commands/domain.js +124 -0
- package/src/commands/init.js +276 -0
- package/src/commands/new.js +76 -0
- package/src/commands/preview.js +92 -0
- package/src/commands/prism.js +360 -0
- package/src/commands/publish.js +57 -0
- package/src/commands/upgrade.js +173 -0
- package/src/commands-manifest.js +80 -0
- package/src/content.js +31 -0
- package/src/domain.js +33 -0
- package/src/git.js +160 -0
- package/src/index.js +44 -294
- package/src/publication.js +39 -0
- package/src/assign-content-ids.js +0 -1
- package/src/auth-command.js +0 -36
- package/src/configure-site.js +0 -102
- package/src/content-files.js +0 -1
- package/src/doctor-command.js +0 -214
- package/src/entitlement-client.js +0 -26
- package/src/entitlement-command.js +0 -74
- package/src/evaluation-date.js +0 -1
- package/src/gala-credential-health.js +0 -34
- package/src/gala-credential-store.js +0 -115
- package/src/gala-device-flow.js +0 -121
- package/src/git-credentials.js +0 -37
- package/src/github-auth-command.js +0 -50
- package/src/github-credential-store.js +0 -104
- package/src/github-device-flow.js +0 -153
- package/src/github-empty-repository.js +0 -89
- package/src/github-identity.js +0 -32
- package/src/github-pages-provisioning.js +0 -107
- package/src/github-repository-secret.js +0 -82
- package/src/github-repository-variable.js +0 -56
- package/src/github-template-repository.js +0 -171
- package/src/hook-command.js +0 -64
- package/src/http-failure.js +0 -55
- package/src/new-command.js +0 -54
- package/src/open-browser.js +0 -40
- package/src/preview-command.js +0 -60
- package/src/publication-creation-client.js +0 -155
- package/src/publication-state.js +0 -7
- package/src/publish-command.js +0 -37
- package/src/record-deployment-command.js +0 -147
- package/src/refresh-command.js +0 -104
- package/src/repository-limits.js +0 -94
- package/src/scaffold-git.js +0 -76
- package/src/scaffold-options.js +0 -58
- package/src/scaffold-preflight.js +0 -146
- package/src/scaffold-site.js +0 -185
- package/src/site-config-registration.js +0 -47
- package/src/site-registration-client.js +0 -138
- package/src/theme-package.js +0 -128
- package/src/topology-client.js +0 -43
- package/src/topology-command.js +0 -70
- package/src/upgrade-command.js +0 -81
- package/src/validate-command.js +0 -5
- package/src/workflow-command.js +0 -87
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { galaApi } from '../api/gala.js';
|
|
4
|
+
import { galaCredential } from '../auth/gala.js';
|
|
5
|
+
import { UsageError } from '../cli/args.js';
|
|
6
|
+
import { customDomain } from '../domain.js';
|
|
7
|
+
import { readPublication } from '../publication.js';
|
|
8
|
+
|
|
9
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
10
|
+
|
|
11
|
+
export async function domain({ terminal, options, cwd = process.cwd() }) {
|
|
12
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
13
|
+
const publication = await readPublication(root);
|
|
14
|
+
if (!publication || !ULID.test(publication.siteId ?? '')) {
|
|
15
|
+
throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
|
|
16
|
+
}
|
|
17
|
+
const [action = 'status', value, ...extra] = options.positional;
|
|
18
|
+
if (extra.length > 0 || !['status', 'set', 'check', 'cancel', 'remove'].includes(action)) {
|
|
19
|
+
throw new UsageError('Use: gala domain [status|set <hostname>|check|cancel|remove]');
|
|
20
|
+
}
|
|
21
|
+
if (action === 'set' && value == null) throw new UsageError('domain set needs a hostname');
|
|
22
|
+
if (action !== 'set' && value != null) throw new UsageError(`domain ${action} takes no hostname`);
|
|
23
|
+
|
|
24
|
+
const credential = await galaCredential({
|
|
25
|
+
terminal, apiBaseUrl: options.value('api-base-url'),
|
|
26
|
+
});
|
|
27
|
+
const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
|
|
28
|
+
|
|
29
|
+
if (action === 'status') {
|
|
30
|
+
const pending = await api.pendingTopologyChange(publication.siteId);
|
|
31
|
+
if (!pending) {
|
|
32
|
+
terminal.result((await ownedSite(api, publication.siteId)).publicationUrl);
|
|
33
|
+
terminal.note('No domain change is pending.');
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
showPending(terminal, pending);
|
|
37
|
+
return pending;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (action === 'set') {
|
|
41
|
+
const checked = customDomain(value);
|
|
42
|
+
if (checked.error) throw new UsageError(checked.error);
|
|
43
|
+
const change = await api.prepareTopologyChange(publication.siteId, {
|
|
44
|
+
canonicalBaseUrl: `https://${checked.host}`,
|
|
45
|
+
pathPrefix: '/',
|
|
46
|
+
});
|
|
47
|
+
terminal.done(`Reserved ${checked.host}`);
|
|
48
|
+
terminal.note('Verify it in the repository owner’s GitHub account, then run: gala domain check');
|
|
49
|
+
terminal.openUrl('https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/verifying-your-custom-domain-for-github-pages');
|
|
50
|
+
return change;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (action === 'remove') {
|
|
54
|
+
const existing = await api.pendingTopologyChange(publication.siteId);
|
|
55
|
+
if (existing) {
|
|
56
|
+
throw new UsageError('No second domain change can start while another one is pending; cancel it first.');
|
|
57
|
+
}
|
|
58
|
+
const site = await ownedSite(api, publication.siteId);
|
|
59
|
+
const [owner, repository] = site.repository.split('/');
|
|
60
|
+
const host = `${owner.toLowerCase()}.github.io`;
|
|
61
|
+
const prefix = repository.toLowerCase() === host ? '/' : `/${repository}`;
|
|
62
|
+
const change = await api.prepareTopologyChange(publication.siteId, {
|
|
63
|
+
canonicalBaseUrl: `https://${host}`,
|
|
64
|
+
pathPrefix: prefix,
|
|
65
|
+
});
|
|
66
|
+
const committed = await api.commitTopologyChange(publication.siteId, change.changeId);
|
|
67
|
+
terminal.done(`Returned to ${committed.canonicalBaseUrl}${prefix === '/' ? '/' : `${prefix}/`}`);
|
|
68
|
+
terminal.note('Remove the old custom-domain records from your DNS provider.');
|
|
69
|
+
return committed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pending = await api.pendingTopologyChange(publication.siteId);
|
|
73
|
+
if (!pending) throw new UsageError('No domain change is pending.');
|
|
74
|
+
|
|
75
|
+
if (action === 'cancel') {
|
|
76
|
+
await api.discardTopologyChange(publication.siteId, pending.changeId);
|
|
77
|
+
terminal.done('Cancelled the pending domain change');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (action === 'check') {
|
|
82
|
+
if (pending.cname && pending.state === 'PREPARED') {
|
|
83
|
+
const configured = await api.configureTopologyChange(publication.siteId, pending.changeId);
|
|
84
|
+
terminal.done(`GitHub verified ${configured.cname}`);
|
|
85
|
+
terminal.note(dnsInstruction(configured.cname, await providerHost(api, publication.siteId)));
|
|
86
|
+
terminal.note('After DNS propagates, run: gala domain check');
|
|
87
|
+
return configured;
|
|
88
|
+
}
|
|
89
|
+
const committed = await api.commitTopologyChange(publication.siteId, pending.changeId);
|
|
90
|
+
terminal.done(committed.cname
|
|
91
|
+
? `${committed.cname} is live with enforced HTTPS`
|
|
92
|
+
: 'The GitHub Pages address is live again');
|
|
93
|
+
if (!committed.cname) {
|
|
94
|
+
terminal.note('Remove the old custom-domain records from your DNS provider.');
|
|
95
|
+
}
|
|
96
|
+
return committed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new UsageError(`Unsupported domain action: ${action}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function ownedSite(api, siteId) {
|
|
103
|
+
const sites = await api.listPublications();
|
|
104
|
+
const site = Array.isArray(sites) ? sites.find((candidate) => candidate.siteId === siteId) : null;
|
|
105
|
+
if (!site || typeof site.repository !== 'string' || typeof site.publicationUrl !== 'string') {
|
|
106
|
+
throw new Error('Publication is not available');
|
|
107
|
+
}
|
|
108
|
+
return site;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function providerHost(api, siteId) {
|
|
112
|
+
return (await ownedSite(api, siteId)).repository.split('/')[0].toLowerCase() + '.github.io';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function dnsInstruction(host, target) {
|
|
116
|
+
return `For a subdomain: CNAME ${host} → ${target}. For an apex domain, use GitHub’s documented A/AAAA records.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function showPending(terminal, pending) {
|
|
120
|
+
terminal.result(`${pending.canonicalBaseUrl}${pending.pathPrefix}`);
|
|
121
|
+
terminal.note(`State: ${pending.state}`);
|
|
122
|
+
if (pending.state === 'PREPARED') terminal.note('Next: verify ownership, then run gala domain check.');
|
|
123
|
+
else terminal.note('Next: configure DNS, then run gala domain check.');
|
|
124
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { galaApi } from '../api/gala.js';
|
|
5
|
+
import { githubApi } from '../api/github.js';
|
|
6
|
+
import { galaCredential } from '../auth/gala.js';
|
|
7
|
+
import { githubCredential } from '../auth/github.js';
|
|
8
|
+
import { cloneRepository, createGit, populateEmptyRepository } from '../git.js';
|
|
9
|
+
import { UsageError } from '../cli/args.js';
|
|
10
|
+
import { customDomain } from '../domain.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a publication and leaves a working checkout behind.
|
|
14
|
+
*
|
|
15
|
+
* The shape of this command is the lesson of v0. Everything it used to do itself now belongs to
|
|
16
|
+
* whoever can do it correctly:
|
|
17
|
+
*
|
|
18
|
+
* - The **server** creates the repository, because it is the same call the browser editor makes:
|
|
19
|
+
* it falls back from template to empty-and-seed, waits for the App installation to actually
|
|
20
|
+
* reach the result, and reports the installation id. The CLI's own version had none of that,
|
|
21
|
+
* which is why repositories it created never appeared in the web UI.
|
|
22
|
+
* - The **server** writes `site.config.yml` and the publish workflow during registration. The CLI
|
|
23
|
+
* used to write its own versions afterwards and commit them, producing a second commit whose
|
|
24
|
+
* entire content was rewriting one line and stripping comments — and a second workflow run that
|
|
25
|
+
* collided with the first one's deployment record and failed.
|
|
26
|
+
* - **GitHub** turns on Pages by itself once publishing creates a `gh-pages` branch. The CLI used
|
|
27
|
+
* to poll ten minutes for a run it had caused, then call an API that changed nothing.
|
|
28
|
+
*
|
|
29
|
+
* What is left is genuinely the CLI's: asking what to call it, cloning, and reporting the address.
|
|
30
|
+
*/
|
|
31
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
|
+
const GITHUB_APP_INSTALLATION_URL = 'https://github.com/apps/gala67-app/installations/new';
|
|
33
|
+
|
|
34
|
+
export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
35
|
+
const explicitName = options.value('name');
|
|
36
|
+
if (options.positional.length > 1) {
|
|
37
|
+
throw new UsageError('init accepts at most one destination directory');
|
|
38
|
+
}
|
|
39
|
+
const directory = path.resolve(cwd, options.positional[0] ?? '.');
|
|
40
|
+
const requestedDomain = options.value('domain');
|
|
41
|
+
const checkedDomain = requestedDomain == null ? null : customDomain(requestedDomain);
|
|
42
|
+
if (checkedDomain?.error) throw new UsageError(checkedDomain.error);
|
|
43
|
+
|
|
44
|
+
const destination = await inspectDestination(directory);
|
|
45
|
+
|
|
46
|
+
const gala = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
|
|
47
|
+
const github = await githubCredential({ terminal });
|
|
48
|
+
|
|
49
|
+
const name = await publicationName({ terminal, explicitName, directory });
|
|
50
|
+
|
|
51
|
+
const api = galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken });
|
|
52
|
+
const capability = await api.githubCapability(github.accessToken);
|
|
53
|
+
const installation = await publicationAccount({ terminal, api, capability });
|
|
54
|
+
|
|
55
|
+
terminal.step(`Creating ${name}`);
|
|
56
|
+
const created = await createPublication({
|
|
57
|
+
terminal, api, capability, name, github, installationId: installation.installationId
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
terminal.step('Waiting for GitHub to copy the template');
|
|
61
|
+
await waitForContent(githubApi(github.accessToken), created.owner, created.name);
|
|
62
|
+
|
|
63
|
+
terminal.step('Cloning');
|
|
64
|
+
const checkout = {
|
|
65
|
+
url: `https://github.com/${created.owner}/${created.name}.git`,
|
|
66
|
+
target: directory,
|
|
67
|
+
token: github.accessToken
|
|
68
|
+
};
|
|
69
|
+
if (destination === 'empty-git') await populateEmptyRepository(checkout);
|
|
70
|
+
else await cloneRepository(checkout);
|
|
71
|
+
|
|
72
|
+
terminal.step('Registering the publication');
|
|
73
|
+
const git = createGit({ root: directory, token: github.accessToken });
|
|
74
|
+
const registration = await api.registerSite({
|
|
75
|
+
capability,
|
|
76
|
+
idempotencyKey: idempotencyKey(created.owner, created.name),
|
|
77
|
+
repositoryOwner: created.owner,
|
|
78
|
+
repositoryName: created.name,
|
|
79
|
+
topology: 'PROVIDER_DEFAULT',
|
|
80
|
+
canonicalBaseUrl: `https://${created.owner.toLowerCase()}.github.io`
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await githubApi(github.accessToken)
|
|
84
|
+
.setVariable(created.owner, created.name, 'GALA_API_BASE_URL', api.baseUrl);
|
|
85
|
+
|
|
86
|
+
// Registration wrote the managed files into the repository; bring them into the checkout so the
|
|
87
|
+
// writer's copy is the publication as it actually exists.
|
|
88
|
+
await git.takeRemote();
|
|
89
|
+
|
|
90
|
+
terminal.done(`Created ${created.owner}/${created.name}`);
|
|
91
|
+
terminal.result(publicationUrl(registration, created));
|
|
92
|
+
terminal.note(path.relative(cwd, directory) || '.');
|
|
93
|
+
|
|
94
|
+
let domainChange;
|
|
95
|
+
if (checkedDomain?.host) {
|
|
96
|
+
terminal.step(`Reserving ${checkedDomain.host}`);
|
|
97
|
+
try {
|
|
98
|
+
domainChange = await api.prepareTopologyChange(registration.siteId, {
|
|
99
|
+
canonicalBaseUrl: `https://${checkedDomain.host}`,
|
|
100
|
+
pathPrefix: '/'
|
|
101
|
+
});
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${created.owner}/${created.name} was created, but ${checkedDomain.host} was not reserved: `
|
|
105
|
+
+ `${failure instanceof Error ? failure.message : 'unknown error'}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
terminal.blank();
|
|
111
|
+
terminal.note('gala new "Your first post"');
|
|
112
|
+
if (domainChange) terminal.note('gala domain check');
|
|
113
|
+
|
|
114
|
+
return { owner: created.owner, name: created.name, siteId: registration.siteId, root: directory };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Creation is a conversation, not a single call.
|
|
119
|
+
*
|
|
120
|
+
* `NEEDS_SHARING` means the repository exists with the right content and the App installation
|
|
121
|
+
* simply cannot see it — an installation scoped to selected repositories, which is the right way to
|
|
122
|
+
* have it. That is one grant away from working, and GitHub offers no API to do it on the writer's
|
|
123
|
+
* behalf: adding a repository to an installation is documented as classic-PAT-only. So it is asked
|
|
124
|
+
* for, with a link to the one page that grants it.
|
|
125
|
+
*/
|
|
126
|
+
async function createPublication({ terminal, api, capability, name, github, installationId }) {
|
|
127
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
128
|
+
const result = await api.createPublication({ capability, name, installationId });
|
|
129
|
+
if (result?.status === 'READY') {
|
|
130
|
+
if (typeof result.owner !== 'string' || typeof result.name !== 'string') {
|
|
131
|
+
throw new Error('Gala created the publication but did not say where');
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result?.status === 'SETUP_PENDING') {
|
|
137
|
+
terminal.note(`${result.owner ?? ''}/${result.name ?? name} exists; GitHub is still copying it`);
|
|
138
|
+
await new Promise((resolve) => { setTimeout(resolve, 1000); });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (result?.status !== 'NEEDS_SHARING') {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Gala could not create the publication (${result?.outcome ?? result?.status}). `
|
|
145
|
+
+ `Continue at ${result?.recoveryUrl ?? GITHUB_APP_INSTALLATION_URL} `
|
|
146
|
+
+ 'and try again.'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const owner = result?.owner ?? '';
|
|
151
|
+
terminal.blank();
|
|
152
|
+
terminal.step(`${owner}/${result?.name ?? name} exists, but Gala cannot reach it yet`);
|
|
153
|
+
terminal.note('its installation covers only selected repositories — add this one');
|
|
154
|
+
terminal.openUrl(result?.recoveryUrl
|
|
155
|
+
?? installationUrl(result?.installationId, owner, await viewerOf(github)));
|
|
156
|
+
if (!await terminal.waitForEnter('Once Gala can access it')) {
|
|
157
|
+
throw new Error(`Add ${owner}/${result?.name ?? name} to the Gala GitHub App, then run this again.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Gala still cannot reach the repository for ${name}.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function publicationAccount({ terminal, api, capability }) {
|
|
164
|
+
const state = await api.githubInstallationAccounts({ capability });
|
|
165
|
+
const accounts = Array.isArray(state?.accounts) ? state.accounts : [];
|
|
166
|
+
if (accounts.length === 0) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Install or request the Gala GitHub App at ${state?.installationUrl
|
|
169
|
+
?? GITHUB_APP_INSTALLATION_URL} and try again.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const personal = accounts.find((account) => account?.organization === false);
|
|
173
|
+
if (personal) return personal;
|
|
174
|
+
if (accounts.length === 1) return accounts[0];
|
|
175
|
+
|
|
176
|
+
const choices = accounts.map((account) => account.login).join(', ');
|
|
177
|
+
const selected = (await terminal.ask(`Which GitHub account should own it? (${choices})`)).trim();
|
|
178
|
+
const account = accounts.find((candidate) =>
|
|
179
|
+
candidate.login.toLowerCase() === selected.toLowerCase());
|
|
180
|
+
if (!account) throw new UsageError(`Choose one of these GitHub accounts: ${choices}`);
|
|
181
|
+
return account;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let viewerCache;
|
|
185
|
+
async function viewerOf(github) {
|
|
186
|
+
viewerCache ??= await githubApi(github.accessToken).viewer();
|
|
187
|
+
return viewerCache;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** User and organisation installations live on different settings paths. */
|
|
191
|
+
export function installationUrl(installationId, owner, selfLogin) {
|
|
192
|
+
const id = Number(installationId);
|
|
193
|
+
if (!Number.isSafeInteger(id) || id <= 0) return 'https://github.com/settings/installations';
|
|
194
|
+
return owner && selfLogin && owner.toLowerCase() !== selfLogin.toLowerCase()
|
|
195
|
+
? `https://github.com/organizations/${encodeURIComponent(owner)}/settings/installations/${id}`
|
|
196
|
+
: `https://github.com/settings/installations/${id}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* GitHub answers the creation call before the template content lands. Cloning into that window
|
|
201
|
+
* gives an empty checkout and a missing site.config.yml — a confusing error about a file the
|
|
202
|
+
* template certainly contains.
|
|
203
|
+
*/
|
|
204
|
+
async function waitForContent(github, owner, name, { attempts = 30, intervalMs = 1000 } = {}) {
|
|
205
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
206
|
+
if (await github.hasContent(owner, name)) return;
|
|
207
|
+
await new Promise((resolve) => { setTimeout(resolve, intervalMs); });
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`${owner}/${name} was created but is still empty. Try again in a moment.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function publicationName({ terminal, explicitName, directory }) {
|
|
213
|
+
const proposed = explicitName ?? path.basename(directory);
|
|
214
|
+
const answer = proposed ?? await terminal.ask('What should this publication be called?');
|
|
215
|
+
const name = slugify(answer);
|
|
216
|
+
if (name == null) {
|
|
217
|
+
throw new UsageError(`"${answer}" cannot be a repository name — use letters, numbers and hyphens`);
|
|
218
|
+
}
|
|
219
|
+
return name;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function slugify(value) {
|
|
223
|
+
if (typeof value !== 'string') return null;
|
|
224
|
+
const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
225
|
+
return NAME.test(slug) ? slug : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function inspectDestination(directory) {
|
|
229
|
+
const { readFile, readdir, stat } = await import('node:fs/promises');
|
|
230
|
+
let entries;
|
|
231
|
+
try {
|
|
232
|
+
entries = await readdir(directory);
|
|
233
|
+
} catch (missing) {
|
|
234
|
+
if (missing?.code === 'ENOENT') return 'missing';
|
|
235
|
+
throw missing;
|
|
236
|
+
}
|
|
237
|
+
if (entries.length === 0) return 'empty';
|
|
238
|
+
if (entries.length === 1 && entries[0] === '.git') {
|
|
239
|
+
const gitDirectory = path.join(directory, '.git');
|
|
240
|
+
if (!(await stat(gitDirectory)).isDirectory()) {
|
|
241
|
+
throw new UsageError('The destination has a linked .git file; use a new empty directory.');
|
|
242
|
+
}
|
|
243
|
+
const head = (await readFile(path.join(gitDirectory, 'HEAD'), 'utf8')).trim();
|
|
244
|
+
const branch = /^ref: (refs\/heads\/.+)$/.exec(head)?.[1];
|
|
245
|
+
if (branch) {
|
|
246
|
+
const packed = await readFile(path.join(gitDirectory, 'packed-refs'), 'utf8')
|
|
247
|
+
.then((source) => source.split('\n').some((line) => line && !line.startsWith('#')),
|
|
248
|
+
(failure) => failure?.code === 'ENOENT' ? false : Promise.reject(failure));
|
|
249
|
+
if (!await hasReference(path.join(gitDirectory, 'refs')) && !packed) return 'empty-git';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw new UsageError('Gala needs an empty destination directory (an empty git repository is allowed).');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function hasReference(directory) {
|
|
256
|
+
const { readdir } = await import('node:fs/promises');
|
|
257
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((failure) => {
|
|
258
|
+
if (failure?.code === 'ENOENT') return [];
|
|
259
|
+
throw failure;
|
|
260
|
+
});
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (!entry.isDirectory()) return true;
|
|
263
|
+
if (await hasReference(path.join(directory, entry.name))) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function publicationUrl(registration, created) {
|
|
269
|
+
const base = registration?.canonicalBaseUrl ?? `https://${created.owner.toLowerCase()}.github.io`;
|
|
270
|
+
const prefix = registration?.pathPrefix ?? `/${created.name}`;
|
|
271
|
+
return `${base}${prefix === '/' ? '' : prefix}/`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function idempotencyKey(owner, name) {
|
|
275
|
+
return `init-${createHash('sha256').update(`${owner.toLowerCase()}/${name.toLowerCase()}`).digest('hex')}`;
|
|
276
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
repositoryEvaluationDate,
|
|
9
|
+
slugifyTitle
|
|
10
|
+
} from '@rathnasgala/content-validation';
|
|
11
|
+
import { stringify } from 'yaml';
|
|
12
|
+
|
|
13
|
+
import { UsageError } from '../cli/args.js';
|
|
14
|
+
import { postUrl, readPublication } from '../publication.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Starts a post.
|
|
18
|
+
*
|
|
19
|
+
* The article id is the part worth being careful about: every language variant of one post shares
|
|
20
|
+
* it, and it is what lets a folder be renamed without the published URL moving. So an existing
|
|
21
|
+
* variant's id is adopted rather than a new one minted, and conflicting ids are refused instead of
|
|
22
|
+
* guessed at.
|
|
23
|
+
*/
|
|
24
|
+
export async function createPost({ terminal, options, cwd = process.cwd(), now = Date.now }) {
|
|
25
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
26
|
+
const title = options.positional[0] ?? await terminal.ask('What is this post called?');
|
|
27
|
+
if (typeof title !== 'string' || title.trim() === '') throw new UsageError('a post needs a title');
|
|
28
|
+
|
|
29
|
+
const timestamp = now();
|
|
30
|
+
const language = options.value('language') ?? 'en';
|
|
31
|
+
const publishAfterDate = options.value('today')
|
|
32
|
+
?? await repositoryEvaluationDate({ root, now: () => timestamp });
|
|
33
|
+
|
|
34
|
+
const metadata = createPostMetadata({ title, language, today: publishAfterDate, timestamp });
|
|
35
|
+
const directory = path.join(root, 'content', 'posts', slugifyTitle(title));
|
|
36
|
+
const file = path.join(directory, `index.${metadata.language}.md`);
|
|
37
|
+
|
|
38
|
+
await mkdir(directory, { recursive: true });
|
|
39
|
+
const existing = await siblingId(directory);
|
|
40
|
+
if (existing != null) metadata.id = existing;
|
|
41
|
+
await mkdir(path.join(directory, 'media'), { recursive: true });
|
|
42
|
+
|
|
43
|
+
// `wx` so an existing variant is never overwritten by a second run.
|
|
44
|
+
await writeFile(file, `---\n${stringify(metadata).trimEnd()}\n---\n\n# ${title}\n`, {
|
|
45
|
+
encoding: 'utf8', flag: 'wx'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
terminal.done('Post created');
|
|
49
|
+
terminal.result(path.relative(cwd, file));
|
|
50
|
+
|
|
51
|
+
// Where it will be, once published. The language segment is not obvious from anything the writer
|
|
52
|
+
// typed, and hunting for your own post is a poor first minute with a publishing tool.
|
|
53
|
+
const address = postUrl(await readPublication(root), path.basename(directory), metadata.language);
|
|
54
|
+
if (address != null) terminal.note(`will appear at ${address}`);
|
|
55
|
+
|
|
56
|
+
terminal.blank();
|
|
57
|
+
terminal.note('write below the second --- line, then: gala preview');
|
|
58
|
+
return { file, metadata };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The id shared by every language variant already in this folder. */
|
|
62
|
+
async function siblingId(directory) {
|
|
63
|
+
const variants = (await readdir(directory, { withFileTypes: true }))
|
|
64
|
+
.filter((entry) => entry.isFile() && /^index\.[^.]+\.md$/.test(entry.name));
|
|
65
|
+
|
|
66
|
+
const ids = new Set();
|
|
67
|
+
for (const variant of variants) {
|
|
68
|
+
const file = path.join(directory, variant.name);
|
|
69
|
+
const parsed = parseFrontmatter(await readFile(file, 'utf8'));
|
|
70
|
+
if (parsed.errors.length > 0) throw new Error(`${file} has invalid frontmatter`);
|
|
71
|
+
if (!isContentId(parsed.data.id)) throw new Error(`${file} is missing a valid article id`);
|
|
72
|
+
ids.add(parsed.data.id);
|
|
73
|
+
}
|
|
74
|
+
if (ids.size > 1) throw new Error(`${directory} has variants with conflicting article ids`);
|
|
75
|
+
return ids.size === 1 ? ids.values().next().value : null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { checkContent } from '../content.js';
|
|
6
|
+
import { readPublication } from '../publication.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds the site and serves it locally.
|
|
10
|
+
*
|
|
11
|
+
* Eleventy is run from the publication's own `node_modules`, so the preview uses the exact
|
|
12
|
+
* framework version the repository is pinned to — the same one the publish workflow will use. A
|
|
13
|
+
* preview that agrees with the local machine but not with production is worse than no preview.
|
|
14
|
+
*
|
|
15
|
+
* That pin is also why this installs dependencies when they are missing. A freshly cloned
|
|
16
|
+
* publication has no `node_modules`, and v0 spawned eleventy from it regardless: the writer's first
|
|
17
|
+
* preview died with a raw Node module-resolution stack, which says nothing about what to do. They
|
|
18
|
+
* should not need to know npm is involved at all.
|
|
19
|
+
*/
|
|
20
|
+
export async function preview({
|
|
21
|
+
terminal, options, cwd = process.cwd(), spawnProcess = spawn, regenerate
|
|
22
|
+
}) {
|
|
23
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
24
|
+
const today = options.value('today');
|
|
25
|
+
|
|
26
|
+
terminal.step('Checking content');
|
|
27
|
+
await checkContent({ terminal, root, today, ...(regenerate == null ? {} : { regenerate }) });
|
|
28
|
+
terminal.done('Content is valid');
|
|
29
|
+
|
|
30
|
+
const eleventy = path.join(root, 'node_modules', '@11ty', 'eleventy', 'cmd.cjs');
|
|
31
|
+
if (!await exists(eleventy)) {
|
|
32
|
+
terminal.step('Installing what this publication needs — first time only');
|
|
33
|
+
await install(root, spawnProcess);
|
|
34
|
+
if (!await exists(eleventy)) {
|
|
35
|
+
throw new Error('The preview tooling is still missing after installing. Check package.json.');
|
|
36
|
+
}
|
|
37
|
+
terminal.done('Installed');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const publication = await readPublication(root);
|
|
41
|
+
terminal.step('Starting the preview — stop it with Ctrl-C');
|
|
42
|
+
if (publication != null) terminal.note(`this is ${publication.name ?? 'your publication'} as it will look`);
|
|
43
|
+
terminal.blank();
|
|
44
|
+
|
|
45
|
+
const child = spawnProcess(process.execPath, [eleventy, '--serve', '--watch'], {
|
|
46
|
+
cwd: root,
|
|
47
|
+
env: { ...process.env, ...(today == null ? {} : { GALA_EVALUATION_DATE: today }) },
|
|
48
|
+
shell: false,
|
|
49
|
+
stdio: 'inherit'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
child.once('error', reject);
|
|
54
|
+
child.once('exit', (code, signal) => {
|
|
55
|
+
// Ctrl-C is how a writer stops a preview; it is not a failure to report.
|
|
56
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM' || code === 0 || code === 130) resolve();
|
|
57
|
+
else if (signal) reject(new Error(`Preview stopped by ${signal}`));
|
|
58
|
+
else reject(new Error(`Preview exited with ${code}`));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function exists(target) {
|
|
64
|
+
try {
|
|
65
|
+
await access(target);
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Output is captured; it is npm's, not the writer's, unless something goes wrong. */
|
|
73
|
+
function install(root, spawnProcess) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawnProcess('npm', ['install', '--no-audit', '--no-fund'], {
|
|
76
|
+
cwd: root, shell: false, stdio: ['ignore', 'pipe', 'pipe']
|
|
77
|
+
});
|
|
78
|
+
let said = '';
|
|
79
|
+
child.stdout?.on('data', (chunk) => { said += chunk; });
|
|
80
|
+
child.stderr?.on('data', (chunk) => { said += chunk; });
|
|
81
|
+
child.once('error', reject);
|
|
82
|
+
child.once('exit', (code) => {
|
|
83
|
+
if (code === 0) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const failure = new Error('Installing the preview tooling failed');
|
|
88
|
+
failure.detail = said.trim();
|
|
89
|
+
reject(failure);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|