@rathnasgala/cli 1.0.0 → 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 +54 -3
- package/package.json +1 -1
- package/src/api/gala.js +40 -3
- package/src/commands/domain.js +124 -0
- package/src/commands/init.js +108 -29
- package/src/commands/prism.js +360 -0
- package/src/commands/upgrade.js +173 -0
- package/src/commands-manifest.js +27 -5
- package/src/domain.js +33 -0
- package/src/git.js +17 -0
- package/src/publication.js +2 -0
package/README.md
CHANGED
|
@@ -12,13 +12,24 @@ Nothing to install. Every command runs through `npx`.
|
|
|
12
12
|
|
|
13
13
|
## Start a publication
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Create it in the current empty folder:
|
|
16
16
|
|
|
17
17
|
```console
|
|
18
18
|
mkdir field-notes && cd field-notes
|
|
19
|
-
npx --yes @rathnasgala/cli@latest init
|
|
19
|
+
npx --yes @rathnasgala/cli@latest init
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
Or name a new destination directly:
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
npx --yes @rathnasgala/cli@latest init field-notes
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The destination must be empty. An initialized Git repository with no commits and no files is also
|
|
29
|
+
accepted and its `.git` directory is preserved. To reserve a custom domain during setup, add
|
|
30
|
+
`--domain blog.example.com`; Gala still requires GitHub ownership verification and healthy DNS
|
|
31
|
+
before activating it.
|
|
32
|
+
|
|
22
33
|
It signs you in to Gala and to GitHub if you are not already, creates the repository, registers the
|
|
23
34
|
publication, and leaves a working checkout in the folder. When it finishes it prints the address
|
|
24
35
|
your publication will live at.
|
|
@@ -51,12 +62,49 @@ npx --yes @rathnasgala/cli@latest publish
|
|
|
51
62
|
Checks your content, records it, and sends it to GitHub. GitHub builds and deploys from there; the
|
|
52
63
|
site updates a minute or two later.
|
|
53
64
|
|
|
65
|
+
## Prism configurations
|
|
66
|
+
|
|
67
|
+
Prism keeps one canonical work while letting you explicitly approve alternate reading depths and
|
|
68
|
+
intents. The CLI uses Gala's public lifecycle API; it never writes approval artifacts itself.
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
npx --yes @rathnasgala/cli@latest prism status
|
|
72
|
+
npx --yes @rathnasgala/cli@latest prism create my-post --language en --depth brief --intent orientation
|
|
73
|
+
npx --yes @rathnasgala/cli@latest prism list my-post --language en
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use `prism edit`, `submit`, `approve`, `reject`, and `revoke` to advance a configuration. Approval,
|
|
77
|
+
rejection, revocation, and reducing the publication mode require terminal confirmation or `--yes`.
|
|
78
|
+
Configuration links default to `nofollow`; change the publication or one work with
|
|
79
|
+
`prism link-policy` when ordinary followed links are intentional. Commands that change repository
|
|
80
|
+
artifacts stay attached through Gala's materialization and GitHub Pages publication states, then
|
|
81
|
+
print the live publication address or a concrete terminal failure. Proposal generation likewise
|
|
82
|
+
waits until its revision is ready for review or generation fails.
|
|
83
|
+
|
|
84
|
+
## Custom domain
|
|
85
|
+
|
|
86
|
+
Reserve a domain after setup, then advance the verified GitHub Pages flow as DNS propagates:
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
npx --yes @rathnasgala/cli@latest domain set blog.example.com
|
|
90
|
+
npx --yes @rathnasgala/cli@latest domain check
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`domain status` resumes an interrupted change, `domain cancel` abandons it, and `domain remove`
|
|
94
|
+
returns the publication to its GitHub Pages address. After removal, delete the old DNS records.
|
|
95
|
+
|
|
54
96
|
## When something is wrong
|
|
55
97
|
|
|
56
98
|
```console
|
|
57
99
|
npx --yes @rathnasgala/cli@latest doctor
|
|
58
100
|
```
|
|
59
101
|
|
|
102
|
+
Inspect a verified managed-theme release without changing anything unless you confirm it:
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
npx --yes @rathnasgala/cli@latest upgrade
|
|
106
|
+
```
|
|
107
|
+
|
|
60
108
|
Reports on your sign-ins, the publication folder, the publishing workflow, and anything you have
|
|
61
109
|
written but not sent. Each check either passes, names what is wrong and how to fix it, or says it
|
|
62
110
|
could not be determined — never one of those disguised as another.
|
|
@@ -67,10 +115,13 @@ Run any command with `--help`.
|
|
|
67
115
|
|
|
68
116
|
| Command | What it does | Options |
|
|
69
117
|
| --- | --- | --- |
|
|
70
|
-
| `init` | Create a publication
|
|
118
|
+
| `init` | Create a publication in the current or named empty directory | `--name`, `--domain` |
|
|
119
|
+
| `domain` | Inspect or change the custom domain | `--root` |
|
|
71
120
|
| `new` | Start a post | `--language`, `--root`, `--today` |
|
|
72
121
|
| `preview` | Build and serve the publication locally | `--root`, `--today` |
|
|
73
122
|
| `publish` | Check, record and send your work to GitHub | `--root`, `--today`, `--skip-checks` |
|
|
123
|
+
| `prism` | Manage author-approved reading configurations | `--root`, `--language`, `--depth`, `--intent`, `--modality`, `--file`, `--reason`, `--yes` |
|
|
124
|
+
| `upgrade` | Inspect and apply a verified managed-theme update | `--root`, `--channel`, `--yes` |
|
|
74
125
|
| `doctor` | Check a publication and say what is wrong | `--root` |
|
|
75
126
|
| `auth` | Sign in to Gala and GitHub | — |
|
|
76
127
|
|
package/package.json
CHANGED
package/src/api/gala.js
CHANGED
|
@@ -59,12 +59,19 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
|
|
|
59
59
|
* wait for the App installation to reach the result, which is why repositories the CLI created
|
|
60
60
|
* never appeared in the web UI.
|
|
61
61
|
*/
|
|
62
|
-
createPublication({ capability, name }) {
|
|
62
|
+
createPublication({ capability, name, installationId }) {
|
|
63
63
|
return requestJson(`${root}/v1/auth/github/publications`,
|
|
64
64
|
authorized('Publication creation', {
|
|
65
65
|
method: 'POST',
|
|
66
66
|
headers: { 'content-type': 'application/json', 'GitHub-Authorization': capability },
|
|
67
|
-
body: JSON.stringify({ name })
|
|
67
|
+
body: JSON.stringify({ name, installationId })
|
|
68
|
+
}));
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
githubInstallationAccounts({ capability }) {
|
|
72
|
+
return requestJson(`${root}/v1/auth/github/accounts`,
|
|
73
|
+
authorized('GitHub installation accounts', {
|
|
74
|
+
headers: { 'GitHub-Authorization': capability }
|
|
68
75
|
}));
|
|
69
76
|
},
|
|
70
77
|
|
|
@@ -85,6 +92,35 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
|
|
|
85
92
|
return requestJson(`${root}/v1/me/sites`, authorized('Publication list'));
|
|
86
93
|
},
|
|
87
94
|
|
|
95
|
+
prepareTopologyChange(siteId, body) {
|
|
96
|
+
return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/prepare`,
|
|
97
|
+
authorized('Custom domain reservation', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify(body)
|
|
101
|
+
}));
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
pendingTopologyChange(siteId) {
|
|
105
|
+
return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/pending`,
|
|
106
|
+
authorized('Custom domain status'));
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
configureTopologyChange(siteId, changeId) {
|
|
110
|
+
return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/${encodeURIComponent(changeId)}/configure`,
|
|
111
|
+
authorized('GitHub Pages domain verification', { method: 'POST' }));
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
commitTopologyChange(siteId, changeId) {
|
|
115
|
+
return requestJson(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/${encodeURIComponent(changeId)}/commit`,
|
|
116
|
+
authorized('Custom domain activation', { method: 'POST' }));
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
discardTopologyChange(siteId, changeId) {
|
|
120
|
+
return request(`${root}/v1/sites/${encodeURIComponent(siteId)}/topology-changes/${encodeURIComponent(changeId)}`,
|
|
121
|
+
authorized('Custom domain cancellation', { method: 'DELETE' }));
|
|
122
|
+
},
|
|
123
|
+
|
|
88
124
|
/** Not in the OpenAPI document, though the endpoint exists and is public. */
|
|
89
125
|
async signInConfiguration() {
|
|
90
126
|
return requestJson(`${root}/v1/auth/configuration`, { action: 'Sign-in configuration' });
|
|
@@ -121,6 +157,7 @@ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
|
|
|
121
157
|
throw new Error(`Gala sign-in failed: ${body?.error_description ?? body?.error ?? response.status}`);
|
|
122
158
|
},
|
|
123
159
|
|
|
124
|
-
request: (path, options) => request(`${root}${path}`, authorized(options?.action ?? path, options))
|
|
160
|
+
request: (path, options) => request(`${root}${path}`, authorized(options?.action ?? path, options)),
|
|
161
|
+
json: (path, options) => requestJson(`${root}${path}`, authorized(options?.action ?? path, options))
|
|
125
162
|
};
|
|
126
163
|
}
|
|
@@ -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
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -5,8 +5,9 @@ import { galaApi } from '../api/gala.js';
|
|
|
5
5
|
import { githubApi } from '../api/github.js';
|
|
6
6
|
import { galaCredential } from '../auth/gala.js';
|
|
7
7
|
import { githubCredential } from '../auth/github.js';
|
|
8
|
-
import { cloneRepository, createGit } from '../git.js';
|
|
8
|
+
import { cloneRepository, createGit, populateEmptyRepository } from '../git.js';
|
|
9
9
|
import { UsageError } from '../cli/args.js';
|
|
10
|
+
import { customDomain } from '../domain.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Creates a publication and leaves a working checkout behind.
|
|
@@ -28,34 +29,45 @@ import { UsageError } from '../cli/args.js';
|
|
|
28
29
|
* What is left is genuinely the CLI's: asking what to call it, cloning, and reporting the address.
|
|
29
30
|
*/
|
|
30
31
|
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
|
+
const GITHUB_APP_INSTALLATION_URL = 'https://github.com/apps/gala67-app/installations/new';
|
|
31
33
|
|
|
32
34
|
export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
33
35
|
const explicitName = options.value('name');
|
|
34
|
-
|
|
35
|
-
|
|
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);
|
|
36
45
|
|
|
37
46
|
const gala = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
|
|
38
47
|
const github = await githubCredential({ terminal });
|
|
39
48
|
|
|
40
|
-
const name = await publicationName({ terminal, explicitName,
|
|
41
|
-
const directory = path.resolve(cwd, target ?? name);
|
|
42
|
-
await refuseOccupied(directory, here);
|
|
49
|
+
const name = await publicationName({ terminal, explicitName, directory });
|
|
43
50
|
|
|
44
51
|
const api = galaApi({ baseUrl: gala.apiBaseUrl, token: gala.accessToken });
|
|
45
52
|
const capability = await api.githubCapability(github.accessToken);
|
|
53
|
+
const installation = await publicationAccount({ terminal, api, capability });
|
|
46
54
|
|
|
47
55
|
terminal.step(`Creating ${name}`);
|
|
48
|
-
const created = await createPublication({
|
|
56
|
+
const created = await createPublication({
|
|
57
|
+
terminal, api, capability, name, github, installationId: installation.installationId
|
|
58
|
+
});
|
|
49
59
|
|
|
50
60
|
terminal.step('Waiting for GitHub to copy the template');
|
|
51
61
|
await waitForContent(githubApi(github.accessToken), created.owner, created.name);
|
|
52
62
|
|
|
53
63
|
terminal.step('Cloning');
|
|
54
|
-
|
|
64
|
+
const checkout = {
|
|
55
65
|
url: `https://github.com/${created.owner}/${created.name}.git`,
|
|
56
66
|
target: directory,
|
|
57
67
|
token: github.accessToken
|
|
58
|
-
}
|
|
68
|
+
};
|
|
69
|
+
if (destination === 'empty-git') await populateEmptyRepository(checkout);
|
|
70
|
+
else await cloneRepository(checkout);
|
|
59
71
|
|
|
60
72
|
terminal.step('Registering the publication');
|
|
61
73
|
const git = createGit({ root: directory, token: github.accessToken });
|
|
@@ -78,8 +90,26 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
|
78
90
|
terminal.done(`Created ${created.owner}/${created.name}`);
|
|
79
91
|
terminal.result(publicationUrl(registration, created));
|
|
80
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
|
+
|
|
81
110
|
terminal.blank();
|
|
82
111
|
terminal.note('gala new "Your first post"');
|
|
112
|
+
if (domainChange) terminal.note('gala domain check');
|
|
83
113
|
|
|
84
114
|
return { owner: created.owner, name: created.name, siteId: registration.siteId, root: directory };
|
|
85
115
|
}
|
|
@@ -93,10 +123,9 @@ export async function init({ terminal, options, cwd = process.cwd() }) {
|
|
|
93
123
|
* behalf: adding a repository to an installation is documented as classic-PAT-only. So it is asked
|
|
94
124
|
* for, with a link to the one page that grants it.
|
|
95
125
|
*/
|
|
96
|
-
async function createPublication({ terminal, api, capability, name, github }) {
|
|
97
|
-
let
|
|
98
|
-
|
|
99
|
-
const result = await api.createPublication({ capability, name });
|
|
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 });
|
|
100
129
|
if (result?.status === 'READY') {
|
|
101
130
|
if (typeof result.owner !== 'string' || typeof result.name !== 'string') {
|
|
102
131
|
throw new Error('Gala created the publication but did not say where');
|
|
@@ -104,22 +133,26 @@ async function createPublication({ terminal, api, capability, name, github }) {
|
|
|
104
133
|
return result;
|
|
105
134
|
}
|
|
106
135
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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') {
|
|
111
143
|
throw new Error(
|
|
112
144
|
`Gala could not create the publication (${result?.outcome ?? result?.status}). `
|
|
113
|
-
+
|
|
145
|
+
+ `Continue at ${result?.recoveryUrl ?? GITHUB_APP_INSTALLATION_URL} `
|
|
146
|
+
+ 'and try again.'
|
|
114
147
|
);
|
|
115
148
|
}
|
|
116
|
-
created = true;
|
|
117
149
|
|
|
118
150
|
const owner = result?.owner ?? '';
|
|
119
151
|
terminal.blank();
|
|
120
152
|
terminal.step(`${owner}/${result?.name ?? name} exists, but Gala cannot reach it yet`);
|
|
121
153
|
terminal.note('its installation covers only selected repositories — add this one');
|
|
122
|
-
terminal.openUrl(
|
|
154
|
+
terminal.openUrl(result?.recoveryUrl
|
|
155
|
+
?? installationUrl(result?.installationId, owner, await viewerOf(github)));
|
|
123
156
|
if (!await terminal.waitForEnter('Once Gala can access it')) {
|
|
124
157
|
throw new Error(`Add ${owner}/${result?.name ?? name} to the Gala GitHub App, then run this again.`);
|
|
125
158
|
}
|
|
@@ -127,6 +160,27 @@ async function createPublication({ terminal, api, capability, name, github }) {
|
|
|
127
160
|
throw new Error(`Gala still cannot reach the repository for ${name}.`);
|
|
128
161
|
}
|
|
129
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
|
+
|
|
130
184
|
let viewerCache;
|
|
131
185
|
async function viewerOf(github) {
|
|
132
186
|
viewerCache ??= await githubApi(github.accessToken).viewer();
|
|
@@ -155,8 +209,8 @@ async function waitForContent(github, owner, name, { attempts = 30, intervalMs =
|
|
|
155
209
|
throw new Error(`${owner}/${name} was created but is still empty. Try again in a moment.`);
|
|
156
210
|
}
|
|
157
211
|
|
|
158
|
-
async function publicationName({ terminal, explicitName,
|
|
159
|
-
const proposed = explicitName ??
|
|
212
|
+
async function publicationName({ terminal, explicitName, directory }) {
|
|
213
|
+
const proposed = explicitName ?? path.basename(directory);
|
|
160
214
|
const answer = proposed ?? await terminal.ask('What should this publication be called?');
|
|
161
215
|
const name = slugify(answer);
|
|
162
216
|
if (name == null) {
|
|
@@ -171,19 +225,44 @@ export function slugify(value) {
|
|
|
171
225
|
return NAME.test(slug) ? slug : null;
|
|
172
226
|
}
|
|
173
227
|
|
|
174
|
-
async function
|
|
175
|
-
const { readdir } = await import('node:fs/promises');
|
|
228
|
+
export async function inspectDestination(directory) {
|
|
229
|
+
const { readFile, readdir, stat } = await import('node:fs/promises');
|
|
176
230
|
let entries;
|
|
177
231
|
try {
|
|
178
232
|
entries = await readdir(directory);
|
|
179
233
|
} catch (missing) {
|
|
180
|
-
if (missing?.code === 'ENOENT') return;
|
|
234
|
+
if (missing?.code === 'ENOENT') return 'missing';
|
|
181
235
|
throw missing;
|
|
182
236
|
}
|
|
183
|
-
if (entries.length === 0) return;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
187
266
|
}
|
|
188
267
|
|
|
189
268
|
function publicationUrl(registration, created) {
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { galaApi } from '../api/gala.js';
|
|
5
|
+
import { galaCredential } from '../auth/gala.js';
|
|
6
|
+
import { UsageError } from '../cli/args.js';
|
|
7
|
+
import { createGit } from '../git.js';
|
|
8
|
+
import { readPublication } from '../publication.js';
|
|
9
|
+
|
|
10
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
11
|
+
const MODES = new Map([
|
|
12
|
+
['off', 'OFF'], ['presentation-only', 'PRESENTATION_ONLY'], ['manual', 'MANUAL'],
|
|
13
|
+
['assisted', 'ASSISTED'],
|
|
14
|
+
]);
|
|
15
|
+
const POLICIES = new Map([['nofollow', 'NOFOLLOW'], ['follow', 'FOLLOW']]);
|
|
16
|
+
|
|
17
|
+
export async function prism({ terminal, options, cwd = process.cwd() }) {
|
|
18
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
19
|
+
const publication = await readPublication(root);
|
|
20
|
+
if (!publication || !ULID.test(publication.siteId ?? '')) {
|
|
21
|
+
throw new UsageError('Run this inside a registered Gala publication, or pass --root.');
|
|
22
|
+
}
|
|
23
|
+
const credential = await galaCredential({ terminal, apiBaseUrl: options.value('api-base-url') });
|
|
24
|
+
const api = galaApi({ baseUrl: credential.apiBaseUrl, token: credential.accessToken });
|
|
25
|
+
const [action = 'status', ...args] = options.positional;
|
|
26
|
+
|
|
27
|
+
if (action === 'status') {
|
|
28
|
+
requireArgs(args, 0, 'gala prism status');
|
|
29
|
+
const state = await api.json(`/v1/sites/${publication.siteId}/prism`, { action: 'Prism status' });
|
|
30
|
+
terminal.result(`Prism ${state.publishedMode}`);
|
|
31
|
+
terminal.note(`Mode: requested ${state.requestedMode}; published ${state.publishedMode}`);
|
|
32
|
+
terminal.note(`Configuration links: requested ${state.requestedConfigurationLinkPolicy}; published ${state.publishedConfigurationLinkPolicy}`);
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const inventory = await api.json(`/v1/sites/${publication.siteId}/posts`, {
|
|
37
|
+
action: 'Published post inventory',
|
|
38
|
+
});
|
|
39
|
+
const expectedRepositoryHeadSha = inventory.headSha;
|
|
40
|
+
|
|
41
|
+
if (action === 'mode') {
|
|
42
|
+
requireArgs(args, 1, 'gala prism mode <off|presentation-only|manual|assisted>');
|
|
43
|
+
const mode = MODES.get(args[0]);
|
|
44
|
+
if (!mode) throw new UsageError('Prism mode must be off, presentation-only, manual, or assisted.');
|
|
45
|
+
if (mode === 'OFF' || mode === 'PRESENTATION_ONLY') await confirm(terminal, options, `Change Prism mode to ${args[0]}?`);
|
|
46
|
+
const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
|
|
47
|
+
mode, expectedRepositoryHeadSha,
|
|
48
|
+
}, 'Prism mode');
|
|
49
|
+
return settleMutation(api, terminal, publication, result);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'link-policy') {
|
|
53
|
+
const [scope, target, value] = args;
|
|
54
|
+
if (scope === 'site') {
|
|
55
|
+
requireArgs(args, 2, 'gala prism link-policy site <nofollow|follow>');
|
|
56
|
+
const policy = policyValue(target);
|
|
57
|
+
const result = await mutate(api, `/v1/sites/${publication.siteId}/prism`, 'PUT', {
|
|
58
|
+
configurationLinkPolicy: policy, expectedRepositoryHeadSha,
|
|
59
|
+
}, 'Prism link policy');
|
|
60
|
+
return settleMutation(api, terminal, publication, result);
|
|
61
|
+
}
|
|
62
|
+
if (scope === 'work') {
|
|
63
|
+
requireArgs(args, 3, 'gala prism link-policy work <slug> <inherit|nofollow|follow>');
|
|
64
|
+
const post = resolvePost(inventory, target, options.value('language'), publication.defaultLanguage);
|
|
65
|
+
if (value === 'inherit') {
|
|
66
|
+
const result = await mutate(api,
|
|
67
|
+
`/v1/sites/${publication.siteId}/articles/${post.articleId}/prism?fields=configurationLinkPolicy`,
|
|
68
|
+
'DELETE', undefined, 'Prism work link policy', {
|
|
69
|
+
'x-expected-repository-head': expectedRepositoryHeadSha,
|
|
70
|
+
});
|
|
71
|
+
return settleMutation(api, terminal, publication, result);
|
|
72
|
+
}
|
|
73
|
+
const result = await mutate(api,
|
|
74
|
+
`/v1/sites/${publication.siteId}/articles/${post.articleId}/prism`, 'PUT', {
|
|
75
|
+
configurationLinkPolicy: policyValue(value), expectedRepositoryHeadSha,
|
|
76
|
+
}, 'Prism work link policy');
|
|
77
|
+
return settleMutation(api, terminal, publication, result);
|
|
78
|
+
}
|
|
79
|
+
throw new UsageError('Use: gala prism link-policy site <nofollow|follow> or work <slug> <inherit|nofollow|follow>');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (action === 'list') {
|
|
83
|
+
requireArgs(args, 1, 'gala prism list <slug> [--language en]');
|
|
84
|
+
const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
|
|
85
|
+
const result = await configurations(api, publication.siteId, post);
|
|
86
|
+
terminal.result(`${result.configurations.length} configuration${result.configurations.length === 1 ? '' : 's'}`);
|
|
87
|
+
for (const item of result.configurations) {
|
|
88
|
+
terminal.note(`${item.configurationId} ${item.depth}/${item.intent}/${item.modality} ${item.lifecycle}/${item.deliveryState}`);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (action === 'create') {
|
|
94
|
+
requireArgs(args, 1, 'gala prism create <slug> --language en --depth brief --intent orientation');
|
|
95
|
+
const post = resolvePost(inventory, args[0], options.value('language'), publication.defaultLanguage);
|
|
96
|
+
const current = await configurations(api, publication.siteId, post);
|
|
97
|
+
const result = await mutate(api,
|
|
98
|
+
`/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations`, 'POST', {
|
|
99
|
+
language: post.language,
|
|
100
|
+
depth: enumValue(options.value('depth') ?? 'brief', ['signal', 'brief', 'standard', 'complete', 'methods-references'], 'depth'),
|
|
101
|
+
intent: enumValue(options.value('intent') ?? 'orientation', ['orientation', 'story', 'proof', 'practice'], 'intent'),
|
|
102
|
+
modality: enumValue(options.value('modality') ?? 'text', ['text'], 'modality'),
|
|
103
|
+
expectedSourceContentHash: current.sourceRevisionHash,
|
|
104
|
+
hashContract: current.hashContract,
|
|
105
|
+
}, 'Create Prism configuration');
|
|
106
|
+
terminal.result(result.configurationId);
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!['edit', 'generate', 'submit', 'approve', 'reject', 'revoke'].includes(action)) {
|
|
111
|
+
throw new UsageError('Unknown Prism action. Run gala prism --help.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const configurationId = args[0];
|
|
115
|
+
if (!ULID.test(configurationId ?? '')) throw new UsageError(`${action} needs a configuration ID.`);
|
|
116
|
+
const resolved = await findConfiguration(api, publication, inventory, configurationId,
|
|
117
|
+
options.value('language'));
|
|
118
|
+
const { post, collection, configuration } = resolved;
|
|
119
|
+
const base = `/v1/sites/${publication.siteId}/articles/${post.articleId}/configurations/${configurationId}`;
|
|
120
|
+
|
|
121
|
+
if (action === 'edit') {
|
|
122
|
+
requireArgs(args, 1, 'gala prism edit <configuration-id> --file proposal.md');
|
|
123
|
+
const filename = options.value('file');
|
|
124
|
+
if (!filename) throw new UsageError('Prism edit needs --file proposal.md.');
|
|
125
|
+
const markdown = await readFile(path.resolve(root, filename), 'utf8');
|
|
126
|
+
const result = await mutate(api, `${base}/revisions`, 'POST', {
|
|
127
|
+
markdown, expectedSourceContentHash: collection.sourceRevisionHash,
|
|
128
|
+
hashContract: collection.hashContract,
|
|
129
|
+
}, 'Save Prism revision');
|
|
130
|
+
terminal.result(result.revisionId);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (action === 'generate') {
|
|
135
|
+
requireArgs(args, 1, 'gala prism generate <configuration-id>');
|
|
136
|
+
const result = await mutate(api, `${base}/generation-jobs`, 'POST', {
|
|
137
|
+
expectedSourceContentHash: collection.sourceRevisionHash,
|
|
138
|
+
hashContract: collection.hashContract,
|
|
139
|
+
}, 'Generate Prism proposal');
|
|
140
|
+
return settleGeneration(api, terminal, publication.siteId, post.articleId,
|
|
141
|
+
configurationId, result);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const revisionId = args[1] ?? configuration.workingRevision?.revisionId;
|
|
145
|
+
if (action !== 'revoke' && !ULID.test(revisionId ?? '')) {
|
|
146
|
+
throw new UsageError(`${action} needs a revision ID when there is no working revision.`);
|
|
147
|
+
}
|
|
148
|
+
if (action === 'submit') {
|
|
149
|
+
if (args.length > 2) throw new UsageError('Use: gala prism submit <configuration-id> [revision-id]');
|
|
150
|
+
if (revisionId !== configuration.workingRevision?.revisionId) {
|
|
151
|
+
throw new UsageError('Only the current working revision can be submitted. Refresh the configuration and try again.');
|
|
152
|
+
}
|
|
153
|
+
const warnings = configuration.workingRevision.literalFindings
|
|
154
|
+
?.filter((item) => item.severity === 'WARNING') ?? [];
|
|
155
|
+
for (const warning of warnings) terminal.note(`Warning ${warning.id}: ${warning.message}`);
|
|
156
|
+
if (warnings.length > 0) {
|
|
157
|
+
await confirm(terminal, options, `Acknowledge all ${warnings.length} listed warning${warnings.length === 1 ? '' : 's'}?`);
|
|
158
|
+
}
|
|
159
|
+
const result = await mutate(api, `${base}/submit`, 'POST', expectation(collection, {
|
|
160
|
+
revisionId, acknowledgedWarningIds: warnings.map((warning) => warning.id),
|
|
161
|
+
}), 'Submit Prism revision');
|
|
162
|
+
terminal.result(`${result.revisionId} ${result.reviewState}`);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const reason = options.value('reason');
|
|
167
|
+
if (action === 'approve') {
|
|
168
|
+
if (args.length > 2) throw new UsageError('Use: gala prism approve <configuration-id> [revision-id] [--yes]');
|
|
169
|
+
if (revisionId !== configuration.workingRevision?.revisionId) {
|
|
170
|
+
throw new UsageError('Only the current working revision can be approved. Refresh the configuration and try again.');
|
|
171
|
+
}
|
|
172
|
+
const changed = await createGit({ root }).run(
|
|
173
|
+
['status', '--porcelain', '--', `content/posts/${post.slug}`], { capture: true });
|
|
174
|
+
if (changed) throw new UsageError('The canonical post has uncommitted edits. Publish or revert them before approval.');
|
|
175
|
+
const warnings = configuration.workingRevision?.literalFindings
|
|
176
|
+
?.filter((item) => item.severity === 'WARNING').map((item) => item.id) ?? [];
|
|
177
|
+
for (const warning of configuration.workingRevision?.literalFindings
|
|
178
|
+
?.filter((item) => item.severity === 'WARNING') ?? []) {
|
|
179
|
+
terminal.note(`Warning ${warning.id}: ${warning.message}`);
|
|
180
|
+
}
|
|
181
|
+
if (warnings.length > 0) {
|
|
182
|
+
await confirm(terminal, options, `Acknowledge all ${warnings.length} listed warning${warnings.length === 1 ? '' : 's'}?`);
|
|
183
|
+
}
|
|
184
|
+
await confirm(terminal, options, 'Approve this revision and publish it?');
|
|
185
|
+
const result = await mutate(api, `${base}/approve`, 'POST', expectation(collection, {
|
|
186
|
+
revisionId, expectedRepositoryHeadSha, acknowledgedWarningIds: warnings,
|
|
187
|
+
}), 'Approve Prism revision');
|
|
188
|
+
return settleMutation(api, terminal, publication, result);
|
|
189
|
+
}
|
|
190
|
+
if (!reason) throw new UsageError(`Prism ${action} needs --reason.`);
|
|
191
|
+
await confirm(terminal, options, `${action === 'reject' ? 'Reject this revision' : 'Revoke this configuration'}?`);
|
|
192
|
+
const suffix = action === 'reject' ? '/reject' : '/revoke';
|
|
193
|
+
const body = action === 'reject'
|
|
194
|
+
? expectation(collection, { revisionId, reason })
|
|
195
|
+
: expectation(collection, { expectedRepositoryHeadSha, reason });
|
|
196
|
+
const result = await mutate(api, `${base}${suffix}`, 'POST', body, `Prism ${action}`);
|
|
197
|
+
if (result.materialization) return settleMutation(api, terminal, publication, result);
|
|
198
|
+
terminal.result(result.reviewState ?? action);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function requireArgs(args, count, usage) {
|
|
203
|
+
if (args.length !== count) throw new UsageError(`Use: ${usage}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolvePost(inventory, slug, requestedLanguage, defaultLanguage) {
|
|
207
|
+
const variants = inventory.posts.filter((post) => post.slug === slug);
|
|
208
|
+
const post = variants.find((item) => item.language === requestedLanguage)
|
|
209
|
+
?? variants.find((item) => item.language === defaultLanguage)
|
|
210
|
+
?? variants[0];
|
|
211
|
+
if (!post) throw new UsageError(`No published work uses the slug ${slug}.`);
|
|
212
|
+
if (!post.articleId) throw new UsageError('Publish this work once before creating configurations.');
|
|
213
|
+
return post;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function findConfiguration(api, publication, inventory, id, language) {
|
|
217
|
+
for (const post of inventory.posts.filter((item) => item.articleId
|
|
218
|
+
&& (!language || item.language === language))) {
|
|
219
|
+
const collection = await configurations(api, publication.siteId, post);
|
|
220
|
+
const configuration = collection.configurations.find((item) => item.configurationId === id);
|
|
221
|
+
if (configuration) return { post, collection, configuration };
|
|
222
|
+
}
|
|
223
|
+
throw new UsageError(`Configuration ${id} is not part of this publication.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function configurations(api, siteId, post) {
|
|
227
|
+
return api.json(`/v1/sites/${siteId}/articles/${post.articleId}/configurations?language=${encodeURIComponent(post.language)}`, {
|
|
228
|
+
action: 'Prism configurations',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mutate(api, path, method, body, action, extraHeaders = {}) {
|
|
233
|
+
return api.json(path, {
|
|
234
|
+
action, method,
|
|
235
|
+
headers: {
|
|
236
|
+
'content-type': 'application/json', 'idempotency-key': crypto.randomUUID(), ...extraHeaders,
|
|
237
|
+
},
|
|
238
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function expectation(collection, extra) {
|
|
243
|
+
return {
|
|
244
|
+
...extra,
|
|
245
|
+
expectedSourceContentHash: collection.sourceRevisionHash,
|
|
246
|
+
hashContract: collection.hashContract,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function policyValue(value) {
|
|
251
|
+
const policy = POLICIES.get(value);
|
|
252
|
+
if (!policy) throw new UsageError('Link policy must be nofollow or follow.');
|
|
253
|
+
return policy;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function enumValue(value, allowed, name) {
|
|
257
|
+
if (!allowed.includes(value)) throw new UsageError(`${name} must be one of: ${allowed.join(', ')}.`);
|
|
258
|
+
return value.replaceAll('-', '_').toUpperCase();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function confirm(terminal, options, question) {
|
|
262
|
+
if (options.on('yes')) return;
|
|
263
|
+
const answer = await terminal.ask(`${question} Type yes to continue.`);
|
|
264
|
+
if (answer.toLowerCase() !== 'yes') throw new UsageError('Cancelled.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function showMutation(terminal, result) {
|
|
268
|
+
terminal.result(`Queued ${result.materialization?.materializationId ?? result.materializationId}`);
|
|
269
|
+
if (result.settings?.requestedEffectiveMode) {
|
|
270
|
+
terminal.note(`Effective when published: ${result.settings.requestedEffectiveMode} / ${result.settings.requestedEffectiveConfigurationLinkPolicy}`);
|
|
271
|
+
} else if (result.settings) {
|
|
272
|
+
terminal.note(`Requested: ${result.settings.requestedMode} / ${result.settings.requestedConfigurationLinkPolicy}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function settleMutation(api, terminal, publication, result) {
|
|
277
|
+
showMutation(terminal, result);
|
|
278
|
+
const initial = result.materialization;
|
|
279
|
+
if (!initial?.materializationId) return result;
|
|
280
|
+
|
|
281
|
+
let state = initial;
|
|
282
|
+
const deadline = Date.now() + 31 * 60_000;
|
|
283
|
+
while (!['COMMITTED', 'FAILED'].includes(state.status) && Date.now() < deadline) {
|
|
284
|
+
await delay(state.status === 'RETRY_WAIT' ? 10_000 : 3_000);
|
|
285
|
+
state = await api.json(
|
|
286
|
+
`/v1/sites/${publication.siteId}/prism/materializations/${initial.materializationId}`,
|
|
287
|
+
{ action: 'Prism repository materialization' },
|
|
288
|
+
);
|
|
289
|
+
terminal.note(`Repository: ${state.status} (attempt ${state.attemptCount})`);
|
|
290
|
+
}
|
|
291
|
+
if (state.status === 'FAILED') {
|
|
292
|
+
throw new UsageError(`Repository update failed (${state.errorCode ?? 'unknown error'}). Run the command again after correcting the cause.`);
|
|
293
|
+
}
|
|
294
|
+
if (state.status !== 'COMMITTED') {
|
|
295
|
+
throw new UsageError('Repository update did not finish before the 31-minute tracking deadline. Check gala prism status before retrying.');
|
|
296
|
+
}
|
|
297
|
+
if (!state.publicationAttemptSha) {
|
|
298
|
+
terminal.result('Repository updated. No publication attempt was returned.');
|
|
299
|
+
return { ...result, materialization: state };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
terminal.note(`Repository committed at ${state.commitSha ?? state.publicationAttemptSha}. Verifying publication.`);
|
|
303
|
+
const publicationState = await waitForPublication(api, terminal, publication.siteId,
|
|
304
|
+
state.publicationAttemptSha, deadline);
|
|
305
|
+
if (publicationState.status === 'FAILED') {
|
|
306
|
+
throw new UsageError(`Publication failed (${publicationState.errors?.[0]?.message ?? 'unknown error'}).`);
|
|
307
|
+
}
|
|
308
|
+
if (publicationState.status !== 'PUBLISHED') {
|
|
309
|
+
throw new UsageError('Publication did not finish before the 31-minute tracking deadline.');
|
|
310
|
+
}
|
|
311
|
+
const live = publicationUrl(publication);
|
|
312
|
+
terminal.result(live ? `Published at ${live}` : 'Published.');
|
|
313
|
+
return { ...result, materialization: state, publicationAttempt: publicationState };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function waitForPublication(api, terminal, siteId, commitSha, deadline) {
|
|
317
|
+
let state;
|
|
318
|
+
do {
|
|
319
|
+
state = await api.json(`/v1/sites/${siteId}/publication-attempts/${commitSha}`, {
|
|
320
|
+
action: 'Prism publication attempt',
|
|
321
|
+
});
|
|
322
|
+
terminal.note(`Publication: ${state.status}`);
|
|
323
|
+
if (['PUBLISHED', 'FAILED'].includes(state.status)) return state;
|
|
324
|
+
await delay(5_000);
|
|
325
|
+
} while (Date.now() < deadline);
|
|
326
|
+
return state;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function publicationUrl(publication) {
|
|
330
|
+
if (publication.url) return publication.url.replace(/\/$/, '');
|
|
331
|
+
const base = publication.canonicalBaseUrl ?? publication.hosting?.canonicalBaseUrl;
|
|
332
|
+
const prefix = publication.pathPrefix ?? publication.hosting?.pathPrefix ?? '';
|
|
333
|
+
return base ? `${base.replace(/\/$/, '')}/${prefix.replace(/^\//, '').replace(/\/$/, '')}`.replace(/\/$/, '') : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function delay(milliseconds) {
|
|
337
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function settleGeneration(api, terminal, siteId, articleId, configurationId, initial) {
|
|
341
|
+
terminal.result(`${initial.jobId} ${initial.status}`);
|
|
342
|
+
let state = initial;
|
|
343
|
+
const deadline = Date.now() + 31 * 60_000;
|
|
344
|
+
while (!['SUCCEEDED', 'FAILED'].includes(state.status) && Date.now() < deadline) {
|
|
345
|
+
await delay(3_000);
|
|
346
|
+
state = await api.json(
|
|
347
|
+
`/v1/sites/${siteId}/articles/${articleId}/configurations/${configurationId}/generation-jobs/${initial.jobId}`,
|
|
348
|
+
{ action: 'Prism generation job' },
|
|
349
|
+
);
|
|
350
|
+
terminal.note(`Generation: ${state.status} (attempt ${state.attemptCount})`);
|
|
351
|
+
}
|
|
352
|
+
if (state.status === 'FAILED') {
|
|
353
|
+
throw new UsageError(`Proposal generation failed (${state.errorCode ?? 'unknown error'}).`);
|
|
354
|
+
}
|
|
355
|
+
if (state.status !== 'SUCCEEDED') {
|
|
356
|
+
throw new UsageError('Proposal generation did not finish before the 31-minute tracking deadline.');
|
|
357
|
+
}
|
|
358
|
+
terminal.result(`Proposal revision ${state.revisionId} is ready for review.`);
|
|
359
|
+
return state;
|
|
360
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFile, lstat, mkdtemp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { x as extractTar } from 'tar';
|
|
6
|
+
|
|
7
|
+
const PACKAGE = '@rathnasgala/theme';
|
|
8
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
9
|
+
const PROTECTED = ['.git/', 'content/', 'custom.css', 'site.config.yml'];
|
|
10
|
+
|
|
11
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
const safeManagedPath = (value) => typeof value === 'string' && value !== ''
|
|
13
|
+
&& !path.isAbsolute(value) && !value.split('/').includes('..')
|
|
14
|
+
&& !PROTECTED.some((protectedPath) => value === protectedPath || value.startsWith(protectedPath));
|
|
15
|
+
|
|
16
|
+
async function exists(file) {
|
|
17
|
+
try { await stat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function verifyIntegrity(bytes, integrity) {
|
|
21
|
+
const [algorithm, expected] = String(integrity ?? '').split('-', 2);
|
|
22
|
+
if (!['sha512', 'sha256'].includes(algorithm) || !expected) {
|
|
23
|
+
throw new Error('The registry did not provide supported package integrity metadata');
|
|
24
|
+
}
|
|
25
|
+
const actual = createHash(algorithm).update(bytes).digest('base64');
|
|
26
|
+
if (actual !== expected) throw new Error('The downloaded theme failed registry integrity verification');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function registryRelease(channel, fetchImpl) {
|
|
30
|
+
const response = await fetchImpl(`${REGISTRY}/${encodeURIComponent(PACKAGE)}`, {
|
|
31
|
+
headers: { Accept: 'application/json' },
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) throw new Error(`Theme registry lookup failed with HTTP ${response.status}`);
|
|
34
|
+
const metadata = await response.json();
|
|
35
|
+
const version = metadata?.['dist-tags']?.[channel];
|
|
36
|
+
const release = metadata?.versions?.[version];
|
|
37
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version ?? '') || !release?.dist?.tarball) {
|
|
38
|
+
throw new Error(`Theme channel ${channel} has no valid release`);
|
|
39
|
+
}
|
|
40
|
+
if (release.scripts && Object.keys(release.scripts).length > 0) {
|
|
41
|
+
throw new Error('Theme release contains lifecycle scripts and was refused');
|
|
42
|
+
}
|
|
43
|
+
return { version, tarball: release.dist.tarball, integrity: release.dist.integrity };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function unpackRelease(release, fetchImpl) {
|
|
47
|
+
const response = await fetchImpl(release.tarball);
|
|
48
|
+
if (!response.ok) throw new Error(`Theme download failed with HTTP ${response.status}`);
|
|
49
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
50
|
+
verifyIntegrity(bytes, release.integrity);
|
|
51
|
+
const temporary = await mkdtemp(path.join(tmpdir(), 'gala-theme-upgrade-'));
|
|
52
|
+
const archive = path.join(temporary, 'theme.tgz');
|
|
53
|
+
const extracted = path.join(temporary, 'unpacked');
|
|
54
|
+
await mkdir(extracted);
|
|
55
|
+
await writeFile(archive, bytes);
|
|
56
|
+
await extractTar({ file: archive, cwd: extracted, strip: 1, strict: true });
|
|
57
|
+
return { temporary, payload: path.join(extracted, 'payload') };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readManifest(file) {
|
|
61
|
+
const manifest = JSON.parse(await readFile(file, 'utf8'));
|
|
62
|
+
if (manifest?.schemaVersion !== 1 || typeof manifest.files !== 'object') {
|
|
63
|
+
throw new Error('Theme managed-file manifest is invalid');
|
|
64
|
+
}
|
|
65
|
+
for (const managed of Object.keys(manifest.files)) {
|
|
66
|
+
if (!safeManagedPath(managed)) throw new Error(`Theme release contains protected path: ${managed}`);
|
|
67
|
+
}
|
|
68
|
+
return manifest;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function verifyPayload(payload, manifest) {
|
|
72
|
+
for (const [managed, expected] of Object.entries(manifest.files)) {
|
|
73
|
+
const artifact = manifest.artifactSources?.[managed] ?? managed;
|
|
74
|
+
if (!safeManagedPath(artifact)) throw new Error(`Theme artifact path is unsafe: ${artifact}`);
|
|
75
|
+
const bytes = await readFile(path.join(payload, artifact));
|
|
76
|
+
if (sha256(bytes) !== expected) throw new Error(`Theme payload hash mismatch: ${managed}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function assertNoManagedDrift(root, installed) {
|
|
81
|
+
for (const [managed, expected] of Object.entries(installed.files)) {
|
|
82
|
+
const file = path.join(root, managed);
|
|
83
|
+
if (!await exists(file) || (await lstat(file)).isSymbolicLink() || sha256(await readFile(file)) !== expected) {
|
|
84
|
+
throw new Error(`${managed} has local changes; restore it with gala doctor before upgrading`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function replaceFile(target, bytes) {
|
|
90
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
91
|
+
const temporary = `${target}.gala-upgrade-${process.pid}`;
|
|
92
|
+
await writeFile(temporary, bytes);
|
|
93
|
+
await rename(temporary, target);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function applyRelease(root, payload, installed, available) {
|
|
97
|
+
const configFile = path.join(root, 'site.config.yml');
|
|
98
|
+
const manifestFile = path.join(root, '.gala', 'managed-files.json');
|
|
99
|
+
const config = await readFile(configFile, 'utf8');
|
|
100
|
+
const updated = config.replace(
|
|
101
|
+
/(themePackage:\s*\n(?:\s+.*\n)*?\s+version:\s*)[^\s#]+/,
|
|
102
|
+
`$1${available.themePackage.version}`,
|
|
103
|
+
);
|
|
104
|
+
if (updated === config) throw new Error('site.config.yml has no framework.themePackage.version');
|
|
105
|
+
const backup = await mkdtemp(path.join(tmpdir(), 'gala-theme-rollback-'));
|
|
106
|
+
const previousFiles = Object.keys(installed.files);
|
|
107
|
+
try {
|
|
108
|
+
for (const managed of previousFiles) {
|
|
109
|
+
const target = path.join(root, managed);
|
|
110
|
+
const saved = path.join(backup, managed);
|
|
111
|
+
await mkdir(path.dirname(saved), { recursive: true });
|
|
112
|
+
await copyFile(target, saved);
|
|
113
|
+
}
|
|
114
|
+
await copyFile(configFile, path.join(backup, 'site.config.yml'));
|
|
115
|
+
await copyFile(manifestFile, path.join(backup, 'managed-files.json'));
|
|
116
|
+
for (const managed of previousFiles) {
|
|
117
|
+
if (available.files[managed] == null) await rm(path.join(root, managed));
|
|
118
|
+
}
|
|
119
|
+
for (const managed of Object.keys(available.files)) {
|
|
120
|
+
const artifact = available.artifactSources?.[managed] ?? managed;
|
|
121
|
+
await replaceFile(path.join(root, managed), await readFile(path.join(payload, artifact)));
|
|
122
|
+
}
|
|
123
|
+
await replaceFile(configFile, updated);
|
|
124
|
+
await replaceFile(manifestFile, `${JSON.stringify(available, null, 2)}\n`);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
for (const managed of Object.keys(available.files)) {
|
|
127
|
+
if (installed.files[managed] == null) await rm(path.join(root, managed), { force: true });
|
|
128
|
+
}
|
|
129
|
+
for (const managed of previousFiles) {
|
|
130
|
+
await replaceFile(path.join(root, managed), await readFile(path.join(backup, managed)));
|
|
131
|
+
}
|
|
132
|
+
await replaceFile(configFile, await readFile(path.join(backup, 'site.config.yml')));
|
|
133
|
+
await replaceFile(manifestFile, await readFile(path.join(backup, 'managed-files.json')));
|
|
134
|
+
throw error;
|
|
135
|
+
} finally {
|
|
136
|
+
await rm(backup, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function upgrade({ terminal, options, cwd = process.cwd(), fetchImpl = fetch }) {
|
|
141
|
+
const root = path.resolve(options.value('root') ?? cwd);
|
|
142
|
+
const channel = options.value('channel') ?? 'latest';
|
|
143
|
+
if (!['latest', 'next'].includes(channel)) throw new Error('channel must be latest or next');
|
|
144
|
+
const installed = await readManifest(path.join(root, '.gala', 'managed-files.json'));
|
|
145
|
+
const release = await registryRelease(channel, fetchImpl);
|
|
146
|
+
terminal.result(`Theme ${installed.themePackage.version} → ${release.version} (${channel})`);
|
|
147
|
+
if (installed.themePackage.version === release.version) {
|
|
148
|
+
terminal.note('Already current.');
|
|
149
|
+
return { changed: false, version: release.version };
|
|
150
|
+
}
|
|
151
|
+
if (!options.on('yes')) {
|
|
152
|
+
const answer = await terminal.ask('Apply this managed theme upgrade? [y/N]', { fallback: 'no' });
|
|
153
|
+
if (!/^y(?:es)?$/i.test(answer)) {
|
|
154
|
+
terminal.note('Nothing changed.');
|
|
155
|
+
return { changed: false, version: release.version };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await assertNoManagedDrift(root, installed);
|
|
159
|
+
const unpacked = await unpackRelease(release, fetchImpl);
|
|
160
|
+
try {
|
|
161
|
+
const available = await readManifest(path.join(unpacked.payload, '.gala', 'managed-files.json'));
|
|
162
|
+
if (available.themePackage.version !== release.version) {
|
|
163
|
+
throw new Error('Theme package version does not match its managed manifest');
|
|
164
|
+
}
|
|
165
|
+
await verifyPayload(unpacked.payload, available);
|
|
166
|
+
await applyRelease(root, unpacked.payload, installed, available);
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(unpacked.temporary, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
terminal.done(`Upgraded managed theme to ${release.version}`);
|
|
171
|
+
terminal.note('Run gala preview, then gala publish when the result is approved.');
|
|
172
|
+
return { changed: true, version: release.version };
|
|
173
|
+
}
|
package/src/commands-manifest.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { auth } from './commands/auth.js';
|
|
2
2
|
import { doctor } from './commands/doctor.js';
|
|
3
|
+
import { domain } from './commands/domain.js';
|
|
3
4
|
import { init } from './commands/init.js';
|
|
4
5
|
import { createPost } from './commands/new.js';
|
|
5
6
|
import { preview } from './commands/preview.js';
|
|
6
7
|
import { publish } from './commands/publish.js';
|
|
8
|
+
import { prism } from './commands/prism.js';
|
|
9
|
+
import { upgrade } from './commands/upgrade.js';
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
*
|
|
12
|
+
* Commands in the order a writer meets them.
|
|
10
13
|
*
|
|
11
14
|
* v0 had fifteen, and the extra nine were the ones nobody could keep working: a `validate` a hook
|
|
12
15
|
* ran behind the writer's back, a `workflow` writer for a file the server owns, a
|
|
@@ -27,12 +30,17 @@ export const COMMANDS = {
|
|
|
27
30
|
run: auth
|
|
28
31
|
},
|
|
29
32
|
init: {
|
|
30
|
-
summary: 'Create a publication
|
|
31
|
-
usage: 'gala init [--name my-notes] [--
|
|
32
|
-
flags: ['name', 'api-base-url'],
|
|
33
|
-
switches: ['here'],
|
|
33
|
+
summary: 'Create a publication in an empty directory',
|
|
34
|
+
usage: 'gala init [directory] [--name my-notes] [--domain blog.example.com]',
|
|
35
|
+
flags: ['name', 'domain', 'api-base-url'],
|
|
34
36
|
run: init
|
|
35
37
|
},
|
|
38
|
+
domain: {
|
|
39
|
+
summary: 'Inspect or change this publication’s custom domain',
|
|
40
|
+
usage: 'gala domain [status|set <hostname>|check|cancel|remove] [--root path]',
|
|
41
|
+
flags: ['root', 'api-base-url'],
|
|
42
|
+
run: domain
|
|
43
|
+
},
|
|
36
44
|
new: {
|
|
37
45
|
summary: 'Start a post',
|
|
38
46
|
usage: 'gala new "A durable idea" [--language en]',
|
|
@@ -50,6 +58,20 @@ export const COMMANDS = {
|
|
|
50
58
|
switches: ['skip-checks'],
|
|
51
59
|
run: publish
|
|
52
60
|
},
|
|
61
|
+
prism: {
|
|
62
|
+
summary: 'Manage author-approved Prism configurations',
|
|
63
|
+
usage: 'gala prism <status|mode|link-policy|list|create|edit|generate|submit|approve|reject|revoke> [arguments]',
|
|
64
|
+
flags: ['root', 'api-base-url', 'language', 'depth', 'intent', 'modality', 'file', 'reason'],
|
|
65
|
+
switches: ['yes'],
|
|
66
|
+
run: prism
|
|
67
|
+
},
|
|
68
|
+
upgrade: {
|
|
69
|
+
summary: 'Inspect and apply a verified managed-theme update',
|
|
70
|
+
usage: 'gala upgrade [--channel latest|next] [--yes]',
|
|
71
|
+
flags: ['root', 'channel'],
|
|
72
|
+
switches: ['yes'],
|
|
73
|
+
run: upgrade
|
|
74
|
+
},
|
|
53
75
|
doctor: {
|
|
54
76
|
summary: 'Check a publication and say what is wrong',
|
|
55
77
|
flags: ['root', 'api-base-url'],
|
package/src/domain.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Generated by scripts/generate-custom-domain.mjs. Do not edit.
|
|
2
|
+
const HOST = new RegExp("^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$");
|
|
3
|
+
const MAXIMUM_HOSTNAME_LENGTH = 63;
|
|
4
|
+
const FORBIDDEN_PREFIXES = ["www.www."];
|
|
5
|
+
const FORBIDDEN_SUFFIXES = [".github.io"];
|
|
6
|
+
const MESSAGE = {
|
|
7
|
+
"empty": "Enter a domain such as blog.example.com.",
|
|
8
|
+
"components": "Enter only the hostname, without a protocol, path, port or credentials.",
|
|
9
|
+
"policy": "Enter a valid custom hostname shorter than 64 characters and outside github.io.",
|
|
10
|
+
"labels": "Enter a complete domain with valid DNS labels.",
|
|
11
|
+
"invalid": "Enter a valid domain such as blog.example.com."
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function customDomain(value) {
|
|
15
|
+
const source = typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : '';
|
|
16
|
+
if (!source) return { error: MESSAGE.empty };
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(`https://${source}`);
|
|
19
|
+
if (parsed.username || parsed.password || parsed.port || parsed.pathname !== '/'
|
|
20
|
+
|| parsed.search || parsed.hash || parsed.hostname !== source) {
|
|
21
|
+
return { error: MESSAGE.components };
|
|
22
|
+
}
|
|
23
|
+
if (source.length > MAXIMUM_HOSTNAME_LENGTH
|
|
24
|
+
|| FORBIDDEN_PREFIXES.some((prefix) => source.startsWith(prefix))
|
|
25
|
+
|| FORBIDDEN_SUFFIXES.some((suffix) => source.endsWith(suffix))) {
|
|
26
|
+
return { error: MESSAGE.policy };
|
|
27
|
+
}
|
|
28
|
+
if (!HOST.test(source)) return { error: MESSAGE.labels };
|
|
29
|
+
return { host: source };
|
|
30
|
+
} catch {
|
|
31
|
+
return { error: MESSAGE.invalid };
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/git.js
CHANGED
|
@@ -141,3 +141,20 @@ export function cloneRepository({ url, target, token, spawnProcess = spawn }) {
|
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/** Populate a deliberately empty git repository without replacing its .git directory. */
|
|
146
|
+
export async function populateEmptyRepository({ url, target, token, spawnProcess = spawn }) {
|
|
147
|
+
const git = createGit({ root: target, token, spawnProcess });
|
|
148
|
+
const hasOrigin = await git.run(['remote', 'get-url', 'origin'], { allow: [0, 2] });
|
|
149
|
+
await git.run(hasOrigin === 0
|
|
150
|
+
? ['remote', 'set-url', 'origin', url]
|
|
151
|
+
: ['remote', 'add', 'origin', url]);
|
|
152
|
+
await git.run(['fetch', 'origin']);
|
|
153
|
+
const remoteHead = await git.run(['ls-remote', '--symref', 'origin', 'HEAD'], { capture: true });
|
|
154
|
+
const branch = /^ref: refs\/heads\/([^\s]+)\s+HEAD$/m.exec(remoteHead)?.[1];
|
|
155
|
+
if (!branch || !/^[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
156
|
+
throw new Error('GitHub did not report a usable default branch');
|
|
157
|
+
}
|
|
158
|
+
await git.run(['checkout', '-B', branch, '--track', `origin/${branch}`]);
|
|
159
|
+
return path.resolve(target);
|
|
160
|
+
}
|
package/src/publication.js
CHANGED
|
@@ -21,6 +21,8 @@ export async function readPublication(root) {
|
|
|
21
21
|
? configuration.hosting.pathPrefix
|
|
22
22
|
: '/';
|
|
23
23
|
return {
|
|
24
|
+
siteId: configuration?.site?.id,
|
|
25
|
+
repository: configuration?.site?.repository,
|
|
24
26
|
name: configuration?.site?.name,
|
|
25
27
|
defaultLanguage: configuration?.site?.defaultLanguage ?? 'en',
|
|
26
28
|
url: `${base.replace(/\/$/, '')}${prefix === '/' ? '' : prefix}/`
|