@rathnasgala/cli 0.0.8 → 0.0.12
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 +283 -0
- package/package.json +2 -2
- package/src/configure-site.js +8 -0
- package/src/entitlement-client.js +26 -0
- package/src/entitlement-command.js +74 -0
- package/src/index.js +33 -2
- package/src/refresh-command.js +104 -0
- package/src/scaffold-site.js +3 -7
- package/src/site-config-registration.js +1 -1
- package/src/site-registration-client.js +28 -0
- package/src/topology-client.js +43 -0
- package/src/topology-command.js +70 -0
- package/src/upgrade-command.js +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Gala CLI
|
|
2
|
+
|
|
3
|
+
Create, validate, preview, publish, and maintain a GitHub-backed Gala publication from your terminal.
|
|
4
|
+
|
|
5
|
+
The quick start below begins with the required accounts and tools and does not assume a global CLI installation.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- [Git](https://git-scm.com/downloads)
|
|
10
|
+
- [Node.js 24](https://nodejs.org/en/download) recommended; the CLI package supports Node.js 18 or newer
|
|
11
|
+
- A [GitHub account](https://github.com/signup)
|
|
12
|
+
- The [Gala GitHub App](https://github.com/apps/gala67-app/installations/new) installed for the account that will own the publication
|
|
13
|
+
|
|
14
|
+
Check your local tools:
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
node --version
|
|
18
|
+
npm --version
|
|
19
|
+
git --version
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
You should see something like this:
|
|
23
|
+
```console
|
|
24
|
+
v22.18.0
|
|
25
|
+
10.9.3
|
|
26
|
+
git version 2.50.1 (Apple Git-155)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
### 1. Authenticate with Gala
|
|
32
|
+
|
|
33
|
+
```console
|
|
34
|
+
npx --yes @rathnasgala/cli@latest auth
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The CLI displays a short code and opens the platform authorization page. The resulting Gala token is stored in your operating system's application-config directory, never in the publication repository.
|
|
38
|
+
|
|
39
|
+
### 2. Authenticate with GitHub
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
npx --yes @rathnasgala/cli@latest auth github
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
GitHub OAuth Apps cannot restrict `repo` access to one repository. The CLI therefore requests:
|
|
46
|
+
|
|
47
|
+
- `repo` to create the publication repository and install its Actions secret
|
|
48
|
+
- `workflow` for initial scaffolding and explicit Action-major migrations
|
|
49
|
+
|
|
50
|
+
The GitHub token is stored outside the repository with private file permissions. Gala does not require `workflow` for ordinary publishing or patch upgrades.
|
|
51
|
+
|
|
52
|
+
### 3. Install the GitHub App
|
|
53
|
+
|
|
54
|
+
Open the [Gala GitHub App installation page](https://github.com/apps/gala67-app/installations/new). For a new publication, select **All repositories** temporarily because the target repository does not exist yet.
|
|
55
|
+
|
|
56
|
+
After installation, GitHub redirects to a URL ending in a number, for example:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
https://github.com/settings/installations/153144989
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That final number is the installation ID required by `scaffold`.
|
|
63
|
+
|
|
64
|
+
### 4. Scaffold the publication
|
|
65
|
+
|
|
66
|
+
Replace every capitalized placeholder:
|
|
67
|
+
|
|
68
|
+
```console
|
|
69
|
+
npx --yes @rathnasgala/cli@latest scaffold \
|
|
70
|
+
--owner YOUR_GITHUB_USERNAME \
|
|
71
|
+
--repository YOUR_REPOSITORY_NAME \
|
|
72
|
+
--target ./YOUR_REPOSITORY_NAME \
|
|
73
|
+
--installation-id YOUR_INSTALLATION_ID \
|
|
74
|
+
--mode build-and-deploy
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Scaffolding creates a public repository from `rathnasgala/site-template`, registers the site, installs the one-time site secret as a GitHub Actions secret, writes the publication workflow, commits the generated configuration, and enables GitHub Pages.
|
|
78
|
+
|
|
79
|
+
After scaffolding succeeds, open [GitHub App settings](https://github.com/settings/installations) and restrict the App to the publication repository.
|
|
80
|
+
|
|
81
|
+
### 5. Write, preview, and publish
|
|
82
|
+
|
|
83
|
+
```console
|
|
84
|
+
cd YOUR_REPOSITORY_NAME
|
|
85
|
+
npx --yes @rathnasgala/cli@latest new --title "My first post" --language en
|
|
86
|
+
npx --yes @rathnasgala/cli@latest preview
|
|
87
|
+
npx --yes @rathnasgala/cli@latest publish
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`new` prints the Markdown file it created. Write below the second `---` line, save the file, preview it locally, then publish it through GitHub.
|
|
91
|
+
|
|
92
|
+
## Command reference
|
|
93
|
+
|
|
94
|
+
Run commands through `npx` without installing a global package:
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
npx --yes @rathnasgala/cli@latest COMMAND [options]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Inside the table below, `gala` is shorthand for that prefix.
|
|
101
|
+
|
|
102
|
+
| Command | Purpose | Common options |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `gala auth` | Authenticate the author with Gala | `--api-base-url URL` for a non-production API |
|
|
105
|
+
| `gala auth github` | Authenticate the CLI with GitHub | Browser device flow; requests `repo workflow` |
|
|
106
|
+
| `gala scaffold` | Create and register a publication | `--owner`, `--repository`, `--target`, `--installation-id`, `--mode` |
|
|
107
|
+
| `gala configure` | Update author-owned site and design settings | `--root`, plus the configuration options below |
|
|
108
|
+
| `gala new` | Create a Markdown post variant | `--root`, `--title`, `--language`, `--today` |
|
|
109
|
+
| `gala validate` | Validate repository content without publishing | optional root path, `--today` |
|
|
110
|
+
| `gala preview` | Validate and run the local Eleventy preview | `--root`, `--today` |
|
|
111
|
+
| `gala publish` | Validate, commit, and push publication changes | `--root`, `--today`, `--force` |
|
|
112
|
+
| `gala doctor` | Report managed-framework drift and publication-state validity | optional root path; `--fix --source TRUSTED_ROOT` |
|
|
113
|
+
| `gala hook install` | Install the pre-push validation hook | `--root` |
|
|
114
|
+
| `gala refresh` | Refresh and commit the engagement snapshot | `--root` |
|
|
115
|
+
| `gala upgrade` | Verify and install an exact theme-package release | `--root`, `--channel`, `--yes` |
|
|
116
|
+
| `gala topology` | Switch canonical origin/path topology transactionally | `--root`, `--owner`, `--repository`, `--canonical-base-url`, `--path-prefix` |
|
|
117
|
+
| `gala entitlement` | Retrieve and commit the current paid attribution artifact | `--root` |
|
|
118
|
+
| `gala workflow` | Write the reusable GitHub Actions workflow | `--root`, `--site-id`, `--timezone`, `--action-ref`, `--default-branch`, `--mode` |
|
|
119
|
+
| `gala record-deployment` | Record state after a successful deployment | `--root`, `--today`, `--commit-sha` |
|
|
120
|
+
|
|
121
|
+
### Scaffold and configure options
|
|
122
|
+
|
|
123
|
+
The same author-owned options are accepted by `scaffold` and `configure`:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
--site-name
|
|
127
|
+
--author
|
|
128
|
+
--language
|
|
129
|
+
--timezone
|
|
130
|
+
--theme
|
|
131
|
+
--layout
|
|
132
|
+
--palette
|
|
133
|
+
--typography
|
|
134
|
+
--spacing
|
|
135
|
+
--radius
|
|
136
|
+
--density
|
|
137
|
+
--motion
|
|
138
|
+
--componentStyle
|
|
139
|
+
--share-target repeatable
|
|
140
|
+
--social-profile repeatable
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use only identities supported by the installed theme package. Validation rejects unavailable layout, palette, and theme identities instead of silently substituting another design.
|
|
144
|
+
|
|
145
|
+
### Scaffold an existing empty repository
|
|
146
|
+
|
|
147
|
+
Use this only when the exact GitHub repository already exists and has no branches or content:
|
|
148
|
+
|
|
149
|
+
```console
|
|
150
|
+
npx --yes @rathnasgala/cli@latest scaffold \
|
|
151
|
+
--owner YOUR_GITHUB_USERNAME \
|
|
152
|
+
--repository YOUR_REPOSITORY_NAME \
|
|
153
|
+
--target ./YOUR_REPOSITORY_NAME \
|
|
154
|
+
--installation-id YOUR_INSTALLATION_ID \
|
|
155
|
+
--empty-existing-repository
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Resume interrupted scaffolding
|
|
159
|
+
|
|
160
|
+
The target must already be a checkout whose HTTPS origin exactly matches the requested repository:
|
|
161
|
+
|
|
162
|
+
```console
|
|
163
|
+
npx --yes @rathnasgala/cli@latest scaffold \
|
|
164
|
+
--owner YOUR_GITHUB_USERNAME \
|
|
165
|
+
--repository YOUR_REPOSITORY_NAME \
|
|
166
|
+
--target ./YOUR_REPOSITORY_NAME \
|
|
167
|
+
--installation-id YOUR_INSTALLATION_ID \
|
|
168
|
+
--resume
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Scaffolding is designed to converge after partial failure. It will not adopt a non-empty unrelated repository.
|
|
172
|
+
|
|
173
|
+
### Build without deploying
|
|
174
|
+
|
|
175
|
+
```console
|
|
176
|
+
npx --yes @rathnasgala/cli@latest scaffold ... --mode build-only
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`build-only` writes and validates the site but does not provision GitHub Pages. `build-and-deploy` is the default.
|
|
180
|
+
|
|
181
|
+
## Everyday workflow
|
|
182
|
+
|
|
183
|
+
Create another post:
|
|
184
|
+
|
|
185
|
+
```console
|
|
186
|
+
npx --yes @rathnasgala/cli@latest new --title "A durable idea" --language en
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Validate without running a preview server:
|
|
190
|
+
|
|
191
|
+
```console
|
|
192
|
+
npx --yes @rathnasgala/cli@latest validate
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Preview locally:
|
|
196
|
+
|
|
197
|
+
```console
|
|
198
|
+
npx --yes @rathnasgala/cli@latest preview
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Publish:
|
|
202
|
+
|
|
203
|
+
```console
|
|
204
|
+
npx --yes @rathnasgala/cli@latest publish
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Check managed files and recorded publication state:
|
|
208
|
+
|
|
209
|
+
```console
|
|
210
|
+
npx --yes @rathnasgala/cli@latest doctor
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Security and ownership
|
|
214
|
+
|
|
215
|
+
- Your repository remains the canonical source for publication content and configuration.
|
|
216
|
+
- Gala credentials and GitHub OAuth credentials are stored outside the repository.
|
|
217
|
+
- Credential directories are created with private permissions; credential files use mode `0600` on operating systems that support POSIX modes.
|
|
218
|
+
- The site signing secret is returned once by the API and sealed directly into GitHub Actions secrets.
|
|
219
|
+
- Do not copy credential files into the repository, dotfiles, cloud-sync folders, or `/tmp`.
|
|
220
|
+
- The generated workflow pins the public Gala Action contract; managed framework files are integrity-checked before repair or upgrade.
|
|
221
|
+
|
|
222
|
+
## Troubleshooting
|
|
223
|
+
|
|
224
|
+
### `GitHub authentication is missing`
|
|
225
|
+
|
|
226
|
+
Run:
|
|
227
|
+
|
|
228
|
+
```console
|
|
229
|
+
npx --yes @rathnasgala/cli@latest auth github
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Gala authentication expired
|
|
233
|
+
|
|
234
|
+
Gala author tokens expire and do not use a refresh token. Run:
|
|
235
|
+
|
|
236
|
+
```console
|
|
237
|
+
npx --yes @rathnasgala/cli@latest auth
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### `githubInstallationId must be a positive integer`
|
|
241
|
+
|
|
242
|
+
Open [GitHub App settings](https://github.com/settings/installations), select Gala, and copy the number at the end of the browser URL.
|
|
243
|
+
|
|
244
|
+
### The App cannot access the new repository
|
|
245
|
+
|
|
246
|
+
Open [GitHub App settings](https://github.com/settings/installations) and add the publication repository to the Gala installation. The platform verifies access to the exact repository; the existence of an installation alone is insufficient.
|
|
247
|
+
|
|
248
|
+
### The target folder already exists
|
|
249
|
+
|
|
250
|
+
Do not delete or overwrite it blindly. Use `--resume` only when it is the intended repository checkout. Use `--empty-existing-repository` only when the remote GitHub repository is genuinely empty.
|
|
251
|
+
|
|
252
|
+
### Validation refuses a post
|
|
253
|
+
|
|
254
|
+
The error includes the source file and violated rule. Correct the file and run:
|
|
255
|
+
|
|
256
|
+
```console
|
|
257
|
+
npx --yes @rathnasgala/cli@latest validate
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Do not use `publish --force` as a routine bypass. It skips content validation but does not force-push Git history.
|
|
261
|
+
|
|
262
|
+
### Managed files have drifted
|
|
263
|
+
|
|
264
|
+
Inspect first:
|
|
265
|
+
|
|
266
|
+
```console
|
|
267
|
+
npx --yes @rathnasgala/cli@latest doctor
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Repair requires a trusted, hash-verified theme source:
|
|
271
|
+
|
|
272
|
+
```console
|
|
273
|
+
npx --yes @rathnasgala/cli@latest doctor --fix --source PATH_TO_TRUSTED_THEME
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Package and source
|
|
277
|
+
|
|
278
|
+
- npm: [`@rathnasgala/cli`](https://www.npmjs.com/package/@rathnasgala/cli)
|
|
279
|
+
- source: [`rathnasgala/cli`](https://github.com/rathnasgala/cli)
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
The repository does not currently declare a license. Copyright remains with its owner unless and until a license is added.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rathnasgala/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"url": "git+https://github.com/rathnasgala/cli.git"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@rathnasgala/content-validation": "0.0.
|
|
29
|
+
"@rathnasgala/content-validation": "0.0.8",
|
|
30
30
|
"libsodium-wrappers": "0.8.4",
|
|
31
31
|
"tar": "7.5.22",
|
|
32
32
|
"yaml": "2.9.0"
|
package/src/configure-site.js
CHANGED
|
@@ -5,6 +5,11 @@ import { parseDocument } from 'yaml';
|
|
|
5
5
|
|
|
6
6
|
import { scaffoldOptionNames } from './scaffold-options.js';
|
|
7
7
|
|
|
8
|
+
const IMPLEMENTED_DESIGN_VALUES = Object.freeze({
|
|
9
|
+
layout: Object.freeze(['article-first', 'portfolio']),
|
|
10
|
+
palette: Object.freeze(['default', 'ocean'])
|
|
11
|
+
});
|
|
12
|
+
|
|
8
13
|
function nonEmptyString(value, field) {
|
|
9
14
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
10
15
|
throw new TypeError(`${field} must be a non-empty string`);
|
|
@@ -46,6 +51,9 @@ export async function configureSite(root, designOptions) {
|
|
|
46
51
|
for (const [name, value] of Object.entries(designOptions)) {
|
|
47
52
|
if (scaffoldOptionNames.includes(name)) {
|
|
48
53
|
config.design[name] = nonEmptyString(value, `Design option ${name}`);
|
|
54
|
+
if (IMPLEMENTED_DESIGN_VALUES[name]?.includes(config.design[name]) === false) {
|
|
55
|
+
throw new TypeError(`Unsupported design ${name}: ${config.design[name]}`);
|
|
56
|
+
}
|
|
49
57
|
document.setIn(['design', name], config.design[name]);
|
|
50
58
|
}
|
|
51
59
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
2
|
+
const FIELDS = ['expiresAt', 'issuedAt', 'keyId', 'signature', 'siteId', 'tier'];
|
|
3
|
+
|
|
4
|
+
export async function fetchAttributionEntitlement({ siteId, credential, fetchImpl = fetch }) {
|
|
5
|
+
if (!ULID.test(siteId)) throw new TypeError('siteId must be a canonical ULID');
|
|
6
|
+
const endpoint = new URL(`/v1/sites/${siteId}/attribution-entitlement`, credential.apiBaseUrl);
|
|
7
|
+
const loopback = endpoint.protocol === 'http:'
|
|
8
|
+
&& ['127.0.0.1', 'localhost', '::1'].includes(endpoint.hostname);
|
|
9
|
+
if ((endpoint.protocol !== 'https:' && !loopback) || endpoint.username || endpoint.password) {
|
|
10
|
+
throw new TypeError('Gala API URL must be credential-free HTTPS or HTTP loopback');
|
|
11
|
+
}
|
|
12
|
+
const response = await fetchImpl(endpoint, {
|
|
13
|
+
headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' }
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) throw new Error(`Attribution entitlement retrieval failed with HTTP ${response.status}`);
|
|
16
|
+
const artifact = await response.json();
|
|
17
|
+
if (artifact == null || Array.isArray(artifact) || typeof artifact !== 'object'
|
|
18
|
+
|| Object.keys(artifact).sort().join('\0') !== FIELDS.join('\0')
|
|
19
|
+
|| artifact.siteId !== siteId || artifact.tier !== 'PAID'
|
|
20
|
+
|| !['issuedAt', 'expiresAt', 'keyId', 'signature'].every(
|
|
21
|
+
(field) => typeof artifact[field] === 'string' && artifact[field].length > 0
|
|
22
|
+
)) {
|
|
23
|
+
throw new TypeError('Attribution entitlement response is invalid');
|
|
24
|
+
}
|
|
25
|
+
return artifact;
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
|
+
import { fetchAttributionEntitlement } from './entitlement-client.js';
|
|
7
|
+
|
|
8
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
9
|
+
const ARTIFACT = '.gala/entitlement.json';
|
|
10
|
+
|
|
11
|
+
async function runGit(root, args) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn('git', ['-C', root, ...args], { shell: false, stdio: 'inherit' });
|
|
14
|
+
child.once('error', reject);
|
|
15
|
+
child.once('exit', (code, signal) => {
|
|
16
|
+
if (signal) reject(new Error(`git terminated by signal ${signal}`));
|
|
17
|
+
else if (code !== 0) reject(new Error(`git ${args[0]} exited with code ${code}`));
|
|
18
|
+
else resolve();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function commitArtifact(root) {
|
|
24
|
+
await runGit(root, ['add', '--', ARTIFACT]);
|
|
25
|
+
await runGit(root, ['commit', '--message', 'chore(gala): update attribution entitlement', '--', ARTIFACT]);
|
|
26
|
+
await runGit(root, ['push']);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function acquireAttributionEntitlement({
|
|
30
|
+
root = process.cwd(), readCredential = readGalaCredential,
|
|
31
|
+
fetchEntitlement = fetchAttributionEntitlement, commit = commitArtifact
|
|
32
|
+
} = {}) {
|
|
33
|
+
const siteRoot = path.resolve(root);
|
|
34
|
+
const configTarget = path.join(siteRoot, 'site.config.yml');
|
|
35
|
+
const metadata = await lstat(configTarget);
|
|
36
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
37
|
+
throw new TypeError('site.config.yml must be a regular file');
|
|
38
|
+
}
|
|
39
|
+
const config = parse(await readFile(configTarget, 'utf8'));
|
|
40
|
+
const siteId = config?.site?.id;
|
|
41
|
+
if (!ULID.test(siteId)) throw new TypeError('site.config.yml site.id must be a canonical ULID');
|
|
42
|
+
const artifact = await fetchEntitlement({ siteId, credential: await readCredential() });
|
|
43
|
+
const directory = path.join(siteRoot, '.gala');
|
|
44
|
+
await mkdir(directory, { recursive: true });
|
|
45
|
+
const directoryMetadata = await lstat(directory);
|
|
46
|
+
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
|
|
47
|
+
throw new TypeError('.gala must be a real directory');
|
|
48
|
+
}
|
|
49
|
+
const target = path.join(siteRoot, ARTIFACT);
|
|
50
|
+
try {
|
|
51
|
+
const current = await lstat(target);
|
|
52
|
+
if (!current.isFile() || current.isSymbolicLink()) {
|
|
53
|
+
throw new TypeError('Attribution entitlement must be a regular file');
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT') throw error;
|
|
57
|
+
}
|
|
58
|
+
const serialized = `${JSON.stringify(artifact, null, 2)}\n`;
|
|
59
|
+
try {
|
|
60
|
+
if (await readFile(target, 'utf8') === serialized) return Object.freeze({ changed: false, siteId });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error.code !== 'ENOENT') throw error;
|
|
63
|
+
}
|
|
64
|
+
const temporary = `${target}.gala-${process.pid}`;
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(temporary, serialized, { flag: 'wx' });
|
|
67
|
+
await rename(temporary, target);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await rm(temporary, { force: true });
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
await commit(siteRoot);
|
|
73
|
+
return Object.freeze({ changed: true, siteId });
|
|
74
|
+
}
|
package/src/index.js
CHANGED
|
@@ -20,9 +20,12 @@ import { createInterface } from 'node:readline/promises';
|
|
|
20
20
|
import { upgradeTheme } from './upgrade-command.js';
|
|
21
21
|
import { authenticateGithub } from './github-auth-command.js';
|
|
22
22
|
import { scaffoldSite } from './scaffold-site.js';
|
|
23
|
+
import { refreshEngagementSnapshot } from './refresh-command.js';
|
|
24
|
+
import { switchTopology } from './topology-command.js';
|
|
25
|
+
import { acquireAttributionEntitlement } from './entitlement-command.js';
|
|
23
26
|
|
|
24
27
|
const [command, ...args] = process.argv.slice(2);
|
|
25
|
-
const usage = 'Usage: gala <auth|configure|scaffold|validate|new|doctor|hook|preview|publish|record-deployment|upgrade|workflow> [options]';
|
|
28
|
+
const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
|
|
26
29
|
|
|
27
30
|
if (command === 'help' || command === '--help' || command === '-h'
|
|
28
31
|
|| args.includes('--help') || args.includes('-h')) {
|
|
@@ -32,7 +35,7 @@ if (command === 'help' || command === '--help' || command === '-h'
|
|
|
32
35
|
|
|
33
36
|
const recognizedCommands = new Set([
|
|
34
37
|
'auth', 'configure', 'validate', 'new', 'doctor', 'preview',
|
|
35
|
-
'workflow', 'publish', 'record-deployment', 'hook', 'upgrade'
|
|
38
|
+
'workflow', 'publish', 'record-deployment', 'refresh', 'hook', 'upgrade', 'topology', 'entitlement'
|
|
36
39
|
]);
|
|
37
40
|
function commandRoot() {
|
|
38
41
|
const rootIndex = args.indexOf('--root');
|
|
@@ -97,6 +100,27 @@ if (command === 'auth') {
|
|
|
97
100
|
const options = parseScaffoldOptions(args);
|
|
98
101
|
const config = await configureSite(root, options);
|
|
99
102
|
process.stdout.write(`${JSON.stringify(config.design, null, 2)}\n`);
|
|
103
|
+
} else if (command === 'topology') {
|
|
104
|
+
const valueFor = (name) => {
|
|
105
|
+
const index = args.indexOf(name);
|
|
106
|
+
return index === -1 ? undefined : args[index + 1];
|
|
107
|
+
};
|
|
108
|
+
const result = await switchTopology({
|
|
109
|
+
root: valueFor('--root') ?? process.cwd(),
|
|
110
|
+
owner: valueFor('--owner'),
|
|
111
|
+
repository: valueFor('--repository'),
|
|
112
|
+
canonicalBaseUrl: valueFor('--canonical-base-url'),
|
|
113
|
+
pathPrefix: valueFor('--path-prefix') ?? '/'
|
|
114
|
+
});
|
|
115
|
+
process.stdout.write(`Committed topology ${result.changeId} at ${result.commitSha}.\n`);
|
|
116
|
+
} else if (command === 'entitlement') {
|
|
117
|
+
const rootIndex = args.indexOf('--root');
|
|
118
|
+
const result = await acquireAttributionEntitlement({
|
|
119
|
+
root: rootIndex === -1 ? process.cwd() : args[rootIndex + 1]
|
|
120
|
+
});
|
|
121
|
+
process.stdout.write(result.changed
|
|
122
|
+
? `Stored the signed attribution entitlement for ${result.siteId}.\n`
|
|
123
|
+
: `Attribution entitlement for ${result.siteId} is current.\n`);
|
|
100
124
|
} else if (command === 'validate') {
|
|
101
125
|
const todayIndex = args.indexOf('--today');
|
|
102
126
|
const today = todayIndex === -1 ? undefined : args[todayIndex + 1];
|
|
@@ -191,6 +215,13 @@ if (command === 'auth') {
|
|
|
191
215
|
+ `of ${result.state.posts.length} article(s).\n`
|
|
192
216
|
+ `Recorded state SHA: ${result.recordedStateSha}\n`
|
|
193
217
|
);
|
|
218
|
+
} else if (command === 'refresh') {
|
|
219
|
+
const rootIndex = args.indexOf('--root');
|
|
220
|
+
const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
|
|
221
|
+
const result = await refreshEngagementSnapshot({ root });
|
|
222
|
+
process.stdout.write(result.changed
|
|
223
|
+
? 'Refreshed, committed, and pushed the engagement snapshot.\n'
|
|
224
|
+
: 'Engagement snapshot is already current.\n');
|
|
194
225
|
} else if (command === 'upgrade') {
|
|
195
226
|
const valueFor = (name) => {
|
|
196
227
|
const index = args.indexOf(name);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
|
|
6
|
+
import { readGalaCredential } from './gala-credential-store.js';
|
|
7
|
+
|
|
8
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
9
|
+
const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
|
10
|
+
const SNAPSHOT_PATH = '.engagement-snapshot.json';
|
|
11
|
+
|
|
12
|
+
async function runGit(root, args) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn('git', ['-C', root, ...args], { shell: false, stdio: 'inherit' });
|
|
15
|
+
child.once('error', reject);
|
|
16
|
+
child.once('exit', (code, signal) => {
|
|
17
|
+
if (signal) reject(new Error(`git terminated by signal ${signal}`));
|
|
18
|
+
else if (code !== 0) reject(new Error(`git ${args[0]} exited with code ${code}`));
|
|
19
|
+
else resolve();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function commitRefreshedSnapshot(root, relativePath) {
|
|
25
|
+
await runGit(root, [
|
|
26
|
+
'commit', '--only', '--message', 'chore(gala): refresh engagement snapshot', '--', relativePath
|
|
27
|
+
]);
|
|
28
|
+
await runGit(root, ['push']);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateSnapshot(payload) {
|
|
32
|
+
if (payload?.schemaVersion !== 1 || !UTC_INSTANT.test(payload.refreshedAt)
|
|
33
|
+
|| payload.articles == null || Array.isArray(payload.articles)
|
|
34
|
+
|| typeof payload.articles !== 'object') {
|
|
35
|
+
throw new TypeError('Engagement snapshot response is invalid');
|
|
36
|
+
}
|
|
37
|
+
for (const [articleId, counts] of Object.entries(payload.articles)) {
|
|
38
|
+
if (!ULID.test(articleId) || counts == null || Array.isArray(counts)
|
|
39
|
+
|| typeof counts !== 'object'
|
|
40
|
+
|| Object.keys(counts).sort().join(',') !== 'comments,reactions,views'
|
|
41
|
+
|| !['reactions', 'comments', 'views'].every(
|
|
42
|
+
(field) => Number.isSafeInteger(counts[field]) && counts[field] >= 0
|
|
43
|
+
)) {
|
|
44
|
+
throw new TypeError('Engagement snapshot response is invalid');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return payload;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function requireRegularFile(target, label, { allowMissing = false } = {}) {
|
|
51
|
+
try {
|
|
52
|
+
const metadata = await lstat(target);
|
|
53
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
54
|
+
throw new TypeError(`${label} must be a regular file`);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (allowMissing && error.code === 'ENOENT') return false;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function refreshEngagementSnapshot({
|
|
64
|
+
root = process.cwd(),
|
|
65
|
+
readCredential = readGalaCredential,
|
|
66
|
+
fetchImpl = fetch,
|
|
67
|
+
commitSnapshot = commitRefreshedSnapshot
|
|
68
|
+
} = {}) {
|
|
69
|
+
const siteRoot = path.resolve(root);
|
|
70
|
+
const configPath = path.join(siteRoot, 'site.config.yml');
|
|
71
|
+
await requireRegularFile(configPath, 'site.config.yml');
|
|
72
|
+
const config = parse(await readFile(configPath, 'utf8'));
|
|
73
|
+
const siteId = config?.site?.id;
|
|
74
|
+
if (!ULID.test(siteId)) throw new TypeError('site.config.yml site.id must be a canonical ULID');
|
|
75
|
+
|
|
76
|
+
const credential = await readCredential();
|
|
77
|
+
const endpoint = new URL(`/v1/sites/${siteId}/engagement-snapshot`, credential.apiBaseUrl);
|
|
78
|
+
const loopback = endpoint.protocol === 'http:'
|
|
79
|
+
&& ['127.0.0.1', 'localhost', '::1'].includes(endpoint.hostname);
|
|
80
|
+
if ((endpoint.protocol !== 'https:' && !loopback) || endpoint.username || endpoint.password) {
|
|
81
|
+
throw new TypeError('Gala API URL must be credential-free HTTPS or HTTP loopback');
|
|
82
|
+
}
|
|
83
|
+
const response = await fetchImpl(endpoint, {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' }
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) throw new Error(`Engagement snapshot refresh failed with HTTP ${response.status}`);
|
|
88
|
+
const snapshot = validateSnapshot(await response.json());
|
|
89
|
+
const next = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
90
|
+
const target = path.join(siteRoot, SNAPSHOT_PATH);
|
|
91
|
+
const exists = await requireRegularFile(target, 'Engagement snapshot', { allowMissing: true });
|
|
92
|
+
if (exists && await readFile(target, 'utf8') === next) return Object.freeze({ changed: false });
|
|
93
|
+
|
|
94
|
+
const temporary = `${target}.gala-${process.pid}`;
|
|
95
|
+
try {
|
|
96
|
+
await writeFile(temporary, next, { flag: 'wx' });
|
|
97
|
+
await rename(temporary, target);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
await rm(temporary, { force: true });
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
await commitSnapshot(siteRoot, SNAPSHOT_PATH);
|
|
103
|
+
return Object.freeze({ changed: true });
|
|
104
|
+
}
|
package/src/scaffold-site.js
CHANGED
|
@@ -5,7 +5,6 @@ import { configureSite } from './configure-site.js';
|
|
|
5
5
|
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
6
|
import { readGithubCredential } from './github-credential-store.js';
|
|
7
7
|
import { cloneRepository, generateRepositoryFromTemplate } from './github-template-repository.js';
|
|
8
|
-
import { installRepositorySecret } from './github-repository-secret.js';
|
|
9
8
|
import { installRepositoryVariable } from './github-repository-variable.js';
|
|
10
9
|
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
11
10
|
import { registerSite } from './site-registration-client.js';
|
|
@@ -54,7 +53,7 @@ export async function scaffoldSite({
|
|
|
54
53
|
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
55
54
|
generate = generateRepositoryFromTemplate, clone = cloneRepository,
|
|
56
55
|
configure = configureSite, register = registerSite, finalize = writeRegisteredSiteConfiguration,
|
|
57
|
-
writeWorkflow = writePublishWorkflow,
|
|
56
|
+
writeWorkflow = writePublishWorkflow,
|
|
58
57
|
installVariable = installRepositoryVariable,
|
|
59
58
|
provisionPages = provisionGithubPages,
|
|
60
59
|
commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
@@ -100,7 +99,8 @@ export async function scaffoldSite({
|
|
|
100
99
|
const configured = await configure(root, siteOptions ?? {});
|
|
101
100
|
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
102
101
|
const registration = await register({
|
|
103
|
-
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
102
|
+
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
103
|
+
githubAccessToken: github.accessToken, idempotencyKey,
|
|
104
104
|
githubInstallationId, repositoryOwner, repositoryName,
|
|
105
105
|
topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
|
|
106
106
|
});
|
|
@@ -114,10 +114,6 @@ export async function scaffoldSite({
|
|
|
114
114
|
root, siteId: registration.siteId, timezone: configured.site.timezone, buildMode,
|
|
115
115
|
...(actionRef == null ? {} : { actionRef })
|
|
116
116
|
});
|
|
117
|
-
await installSecret({
|
|
118
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
119
|
-
secretName: 'GALA_SITE_SECRET', secretValue: registration.siteSecret
|
|
120
|
-
});
|
|
121
117
|
await installVariable({
|
|
122
118
|
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
123
119
|
variableName: 'GALA_API_BASE_URL', variableValue: gala.apiBaseUrl
|
|
@@ -6,7 +6,7 @@ export async function writeRegisteredSiteConfiguration(root, {
|
|
|
6
6
|
siteId, canonicalBaseUrl, pathPrefix, topology
|
|
7
7
|
}) {
|
|
8
8
|
if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('siteId is invalid');
|
|
9
|
-
if (!['provider-default', 'custom-domain'].includes(topology)) {
|
|
9
|
+
if (!['provider-default', 'custom-domain', 'domain-root', 'domain-subpath'].includes(topology)) {
|
|
10
10
|
throw new TypeError('topology is invalid');
|
|
11
11
|
}
|
|
12
12
|
const canonical = new URL(canonicalBaseUrl);
|
|
@@ -17,9 +17,33 @@ function apiUrl(apiBaseUrl) {
|
|
|
17
17
|
return new URL('/v1/sites', base).href;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
async function authorizeGitHub({ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl }) {
|
|
21
|
+
if (typeof githubAccessToken !== 'string' || githubAccessToken === '') {
|
|
22
|
+
throw new Error('GitHub authentication is missing; run `gala auth`');
|
|
23
|
+
}
|
|
24
|
+
const response = await fetchImpl(new URL('/v1/auth/github/device-authorizations', apiBaseUrl), {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
accept: 'application/json',
|
|
28
|
+
authorization: `Bearer ${galaAccessToken}`,
|
|
29
|
+
'content-type': 'application/json'
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({ accessToken: githubAccessToken })
|
|
32
|
+
});
|
|
33
|
+
if (response.status === 401) {
|
|
34
|
+
throw new Error('GitHub or Gala authentication expired; run `gala auth` again');
|
|
35
|
+
}
|
|
36
|
+
if (response.status !== 200) {
|
|
37
|
+
throw new Error(`GitHub repository authorization failed with HTTP ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
const payload = await response.json();
|
|
40
|
+
return required(payload?.authorization, 'GitHub authorization', /^[A-Za-z0-9_-]{43}$/);
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
export async function registerSite({
|
|
21
44
|
apiBaseUrl = 'https://api.gala67.com',
|
|
22
45
|
galaAccessToken,
|
|
46
|
+
githubAccessToken,
|
|
23
47
|
idempotencyKey,
|
|
24
48
|
githubInstallationId,
|
|
25
49
|
repositoryOwner,
|
|
@@ -31,6 +55,9 @@ export async function registerSite({
|
|
|
31
55
|
if (typeof galaAccessToken !== 'string' || galaAccessToken === '') {
|
|
32
56
|
throw new Error('Gala authentication is missing; run `gala auth`');
|
|
33
57
|
}
|
|
58
|
+
const githubAuthorization = await authorizeGitHub({
|
|
59
|
+
apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
|
|
60
|
+
});
|
|
34
61
|
required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
|
|
35
62
|
required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
|
|
36
63
|
required(repositoryName, 'repositoryName', REPOSITORY_PART);
|
|
@@ -46,6 +73,7 @@ export async function registerSite({
|
|
|
46
73
|
accept: 'application/json',
|
|
47
74
|
authorization: `Bearer ${galaAccessToken}`,
|
|
48
75
|
'content-type': 'application/json',
|
|
76
|
+
'github-authorization': githubAuthorization,
|
|
49
77
|
'idempotency-key': idempotencyKey
|
|
50
78
|
},
|
|
51
79
|
body: JSON.stringify({
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
2
|
+
|
|
3
|
+
function endpoint(apiBaseUrl, siteId, suffix) {
|
|
4
|
+
if (!ULID.test(siteId)) throw new TypeError('siteId is invalid');
|
|
5
|
+
const base = new URL(apiBaseUrl);
|
|
6
|
+
const loopback = ['localhost', '127.0.0.1', '::1'].includes(base.hostname);
|
|
7
|
+
if ((base.protocol !== 'https:' && !(loopback && base.protocol === 'http:'))
|
|
8
|
+
|| base.username || base.password || base.search || base.hash) {
|
|
9
|
+
throw new TypeError('apiBaseUrl must be a credential-free HTTPS URL (or HTTP loopback for testing)');
|
|
10
|
+
}
|
|
11
|
+
return new URL(`/v1/sites/${siteId}/topology-changes/${suffix}`, base).href;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function response(response, operation) {
|
|
15
|
+
if (response.status === 401) throw new Error('Gala authentication expired; run `gala auth` again');
|
|
16
|
+
if (response.status === 404) throw new Error('Site is unavailable');
|
|
17
|
+
if (response.status === 409) throw new Error(`Topology ${operation} conflicts with protected state`);
|
|
18
|
+
if (!response.ok) throw new Error(`Topology ${operation} failed with HTTP ${response.status}`);
|
|
19
|
+
const payload = await response.json();
|
|
20
|
+
if (!ULID.test(payload?.changeId)) throw new TypeError('Topology response is invalid');
|
|
21
|
+
return Object.freeze(payload);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function prepareTopologyChange({
|
|
25
|
+
apiBaseUrl, accessToken, siteId, canonicalBaseUrl, pathPrefix, fetchImpl = fetch
|
|
26
|
+
}) {
|
|
27
|
+
const result = await fetchImpl(endpoint(apiBaseUrl, siteId, 'prepare'), {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { accept: 'application/json', authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ canonicalBaseUrl, pathPrefix })
|
|
31
|
+
});
|
|
32
|
+
return response(result, 'prepare');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function commitTopologyChange({
|
|
36
|
+
apiBaseUrl, accessToken, siteId, changeId, fetchImpl = fetch
|
|
37
|
+
}) {
|
|
38
|
+
if (!ULID.test(changeId)) throw new TypeError('changeId is invalid');
|
|
39
|
+
const result = await fetchImpl(endpoint(apiBaseUrl, siteId, `${changeId}/commit`), {
|
|
40
|
+
method: 'POST', headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` }
|
|
41
|
+
});
|
|
42
|
+
return response(result, 'commit');
|
|
43
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { readGalaCredential } from './gala-credential-store.js';
|
|
6
|
+
import { readGithubCredential } from './github-credential-store.js';
|
|
7
|
+
import { writeRegisteredSiteConfiguration } from './site-config-registration.js';
|
|
8
|
+
import { prepareTopologyChange, commitTopologyChange } from './topology-client.js';
|
|
9
|
+
import { provisionGithubPages } from './github-pages-provisioning.js';
|
|
10
|
+
|
|
11
|
+
function run(root, args, spawnProcess, accepted = [0]) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawnProcess('git', ['-C', root, ...args], { cwd: root, shell: false, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
14
|
+
let output = '';
|
|
15
|
+
child.stdout?.on('data', (chunk) => { output += chunk; });
|
|
16
|
+
child.once('error', reject);
|
|
17
|
+
child.once('exit', (code, signal) => {
|
|
18
|
+
if (signal) reject(new Error(`Git ${args[0]} terminated by signal ${signal}`));
|
|
19
|
+
else if (!accepted.includes(code)) reject(new Error(`Git ${args[0]} exited with code ${code}`));
|
|
20
|
+
else resolve({ code, output: output.trim() });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function switchTopology({
|
|
26
|
+
root, owner, repository, canonicalBaseUrl, pathPrefix = '/',
|
|
27
|
+
readGala = readGalaCredential, readGithub = readGithubCredential,
|
|
28
|
+
prepare = prepareTopologyChange, commit = commitTopologyChange,
|
|
29
|
+
provisionPages = provisionGithubPages, spawnProcess = spawn
|
|
30
|
+
}) {
|
|
31
|
+
if (typeof owner !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(owner)
|
|
32
|
+
|| typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(repository)) {
|
|
33
|
+
throw new TypeError('owner and repository are required GitHub path segments');
|
|
34
|
+
}
|
|
35
|
+
const siteRoot = path.resolve(root);
|
|
36
|
+
const config = parse(await readFile(path.join(siteRoot, 'site.config.yml'), 'utf8'));
|
|
37
|
+
const siteId = config?.site?.id;
|
|
38
|
+
if (!/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/.test(siteId)) throw new TypeError('site.config.yml has no valid site id');
|
|
39
|
+
const [gala, github] = await Promise.all([readGala(), readGithub()]);
|
|
40
|
+
const pending = await prepare({
|
|
41
|
+
apiBaseUrl: gala.apiBaseUrl, accessToken: gala.accessToken, siteId, canonicalBaseUrl, pathPrefix
|
|
42
|
+
});
|
|
43
|
+
// A site served under a path holds no domain of its own — GitHub lends it the one on the
|
|
44
|
+
// owner's main site — so the absence of a cname no longer means the provider address.
|
|
45
|
+
const topology = pending.canonicalBaseUrl === `https://${owner.toLowerCase()}.github.io`
|
|
46
|
+
? 'provider-default' : (pending.pathPrefix === '/' ? 'domain-root' : 'domain-subpath');
|
|
47
|
+
await writeRegisteredSiteConfiguration(siteRoot, {
|
|
48
|
+
siteId, canonicalBaseUrl: pending.canonicalBaseUrl,
|
|
49
|
+
pathPrefix: pending.pathPrefix, topology
|
|
50
|
+
});
|
|
51
|
+
const cnamePath = path.join(siteRoot, 'CNAME');
|
|
52
|
+
if (pending.cname == null) await rm(cnamePath, { force: true });
|
|
53
|
+
else await writeFile(cnamePath, `${pending.cname}\n`, { encoding: 'utf8' });
|
|
54
|
+
await run(siteRoot, ['add', '-A', '--', 'site.config.yml', 'CNAME'], spawnProcess);
|
|
55
|
+
const unchanged = await run(siteRoot, ['diff', '--cached', '--quiet', '--exit-code'], spawnProcess, [0, 1]);
|
|
56
|
+
if (unchanged.code === 1) {
|
|
57
|
+
await run(siteRoot, ['commit', '-m', `chore(gala): switch topology to ${topology}`], spawnProcess);
|
|
58
|
+
}
|
|
59
|
+
await run(siteRoot, ['push', 'origin', 'HEAD'], spawnProcess);
|
|
60
|
+
const { output: commitSha } = await run(siteRoot, ['rev-parse', 'HEAD'], spawnProcess);
|
|
61
|
+
if (!/^[0-9a-f]{40}$/.test(commitSha)) throw new Error('Git returned an invalid topology commit SHA');
|
|
62
|
+
await provisionPages({
|
|
63
|
+
owner, repository, accessToken: github.accessToken, commitSha, customDomain: pending.cname
|
|
64
|
+
});
|
|
65
|
+
const committed = await commit({
|
|
66
|
+
apiBaseUrl: gala.apiBaseUrl, accessToken: gala.accessToken,
|
|
67
|
+
siteId, changeId: pending.changeId
|
|
68
|
+
});
|
|
69
|
+
return Object.freeze({ ...committed, commitSha });
|
|
70
|
+
}
|
package/src/upgrade-command.js
CHANGED
|
@@ -45,6 +45,14 @@ export async function inspectActionUpgrade({ root, fetchImpl = fetch }) {
|
|
|
45
45
|
export async function upgradeTheme({ root, channel, confirm, fetchImpl = fetch }) {
|
|
46
46
|
const configPath = path.resolve(root, 'site.config.yml');
|
|
47
47
|
const config = parse(await readFile(configPath, 'utf8'));
|
|
48
|
+
if (config?.canonicalPolicy != null) {
|
|
49
|
+
if (config.canonicalPolicy !== 'self' || config.hosting == null || Array.isArray(config.hosting)
|
|
50
|
+
|| (config.hosting.canonicalPolicy != null && config.hosting.canonicalPolicy !== 'self')) {
|
|
51
|
+
throw new TypeError('Legacy canonicalPolicy cannot be migrated safely');
|
|
52
|
+
}
|
|
53
|
+
config.hosting.canonicalPolicy = 'self';
|
|
54
|
+
delete config.canonicalPolicy;
|
|
55
|
+
}
|
|
48
56
|
const installed = config?.framework?.themePackage?.version;
|
|
49
57
|
const [metadata, action] = await Promise.all([
|
|
50
58
|
registryMetadata(fetchImpl), inspectActionUpgrade({ root, fetchImpl })
|