@rathnasgala/cli 0.0.7 → 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/github-pages-provisioning.js +23 -0
- package/src/index.js +37 -2
- package/src/refresh-command.js +104 -0
- package/src/scaffold-site.js +32 -13
- package/src/site-config-registration.js +3 -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/src/workflow-command.js +4 -2
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
|
+
}
|
|
@@ -25,6 +25,7 @@ async function json(response, operation) {
|
|
|
25
25
|
|
|
26
26
|
export async function provisionGithubPages({
|
|
27
27
|
owner, repository, accessToken, commitSha, fetchImpl = fetch,
|
|
28
|
+
customDomain = null,
|
|
28
29
|
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
29
30
|
pollIntervalMs = 5_000, maxPolls = 120
|
|
30
31
|
}) {
|
|
@@ -32,6 +33,10 @@ export async function provisionGithubPages({
|
|
|
32
33
|
const normalizedRepository = required(repository, 'repository', SEGMENT);
|
|
33
34
|
const token = required(accessToken, 'accessToken');
|
|
34
35
|
const sha = required(commitSha, 'commitSha', SHA);
|
|
36
|
+
if (customDomain != null && (typeof customDomain !== 'string'
|
|
37
|
+
|| !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(customDomain))) {
|
|
38
|
+
throw new TypeError('customDomain is invalid');
|
|
39
|
+
}
|
|
35
40
|
if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 0) {
|
|
36
41
|
throw new TypeError('pollIntervalMs must be a non-negative safe integer');
|
|
37
42
|
}
|
|
@@ -69,6 +74,15 @@ export async function provisionGithubPages({
|
|
|
69
74
|
if (configuration.source?.branch !== 'gh-pages' || configuration.source?.path !== '/') {
|
|
70
75
|
throw new Error('Existing GitHub Pages configuration does not use gh-pages at /');
|
|
71
76
|
}
|
|
77
|
+
if ((configuration.cname ?? null) !== customDomain) {
|
|
78
|
+
const updated = await fetchImpl(`${repositoryUrl}/pages`, {
|
|
79
|
+
method: 'PUT', headers: requestHeaders,
|
|
80
|
+
body: JSON.stringify({ cname: customDomain, source: { branch: 'gh-pages', path: '/' } })
|
|
81
|
+
});
|
|
82
|
+
if (updated.status !== 204) {
|
|
83
|
+
throw new Error(`GitHub Pages custom-domain update failed with HTTP ${updated.status}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
72
86
|
return Object.freeze({ created: false, url: configuration.html_url, runUrl: run.html_url });
|
|
73
87
|
}
|
|
74
88
|
if (current.status !== 404) {
|
|
@@ -79,5 +93,14 @@ export async function provisionGithubPages({
|
|
|
79
93
|
body: JSON.stringify({ source: { branch: 'gh-pages', path: '/' } })
|
|
80
94
|
});
|
|
81
95
|
const configuration = await json(created, 'Pages activation');
|
|
96
|
+
if (customDomain != null) {
|
|
97
|
+
const updated = await fetchImpl(`${repositoryUrl}/pages`, {
|
|
98
|
+
method: 'PUT', headers: requestHeaders,
|
|
99
|
+
body: JSON.stringify({ cname: customDomain, source: { branch: 'gh-pages', path: '/' } })
|
|
100
|
+
});
|
|
101
|
+
if (updated.status !== 204) {
|
|
102
|
+
throw new Error(`GitHub Pages custom-domain update failed with HTTP ${updated.status}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
82
105
|
return Object.freeze({ created: true, url: configuration.html_url, runUrl: run.html_url });
|
|
83
106
|
}
|
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');
|
|
@@ -75,11 +78,15 @@ if (command === 'auth') {
|
|
|
75
78
|
return index === -1 ? undefined : args[index + 1];
|
|
76
79
|
};
|
|
77
80
|
const installationId = Number(valueFor('--installation-id'));
|
|
81
|
+
const topology = valueFor('--topology') ?? 'provider-default';
|
|
78
82
|
const result = await scaffoldSite({
|
|
79
83
|
owner: valueFor('--owner'),
|
|
80
84
|
repository: valueFor('--repository'),
|
|
81
85
|
target: valueFor('--target'),
|
|
82
86
|
githubInstallationId: installationId,
|
|
87
|
+
topology,
|
|
88
|
+
canonicalBaseUrl: valueFor('--canonical-base-url'),
|
|
89
|
+
actionRef: valueFor('--action-ref'),
|
|
83
90
|
siteOptions: parseScaffoldOptions(args),
|
|
84
91
|
buildMode: valueFor('--mode') ?? 'build-and-deploy',
|
|
85
92
|
emptyExistingRepository: args.includes('--empty-existing-repository'),
|
|
@@ -93,6 +100,27 @@ if (command === 'auth') {
|
|
|
93
100
|
const options = parseScaffoldOptions(args);
|
|
94
101
|
const config = await configureSite(root, options);
|
|
95
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`);
|
|
96
124
|
} else if (command === 'validate') {
|
|
97
125
|
const todayIndex = args.indexOf('--today');
|
|
98
126
|
const today = todayIndex === -1 ? undefined : args[todayIndex + 1];
|
|
@@ -187,6 +215,13 @@ if (command === 'auth') {
|
|
|
187
215
|
+ `of ${result.state.posts.length} article(s).\n`
|
|
188
216
|
+ `Recorded state SHA: ${result.recordedStateSha}\n`
|
|
189
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');
|
|
190
225
|
} else if (command === 'upgrade') {
|
|
191
226
|
const valueFor = (name) => {
|
|
192
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';
|
|
@@ -25,15 +24,36 @@ function providerDefaultBase(owner) {
|
|
|
25
24
|
return `https://${owner.toLowerCase()}.github.io`;
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
function registrationLocation(owner, topology, canonicalBaseUrl) {
|
|
28
|
+
if (topology === 'provider-default') {
|
|
29
|
+
if (canonicalBaseUrl != null) {
|
|
30
|
+
throw new TypeError('--canonical-base-url is valid only with --topology custom-domain');
|
|
31
|
+
}
|
|
32
|
+
return { topology: 'PROVIDER_DEFAULT', canonicalBaseUrl: providerDefaultBase(owner) };
|
|
33
|
+
}
|
|
34
|
+
if (topology !== 'custom-domain') {
|
|
35
|
+
throw new TypeError('topology must be provider-default or custom-domain');
|
|
36
|
+
}
|
|
37
|
+
if (typeof canonicalBaseUrl !== 'string') {
|
|
38
|
+
throw new TypeError('--canonical-base-url is required with --topology custom-domain');
|
|
39
|
+
}
|
|
40
|
+
const canonical = new URL(canonicalBaseUrl);
|
|
41
|
+
if (canonical.protocol !== 'https:' || canonical.username || canonical.password
|
|
42
|
+
|| canonical.port || canonical.search || canonical.hash || canonical.pathname !== '/') {
|
|
43
|
+
throw new TypeError('canonicalBaseUrl must be a credential-free HTTPS origin');
|
|
44
|
+
}
|
|
45
|
+
return { topology: 'CUSTOM_DOMAIN', canonicalBaseUrl: canonical.origin };
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
export async function scaffoldSite({
|
|
29
49
|
owner, repository, target, githubInstallationId, siteOptions, emptyExistingRepository = false,
|
|
30
|
-
resumeExistingCheckout = false,
|
|
50
|
+
resumeExistingCheckout = false, topology = 'provider-default', canonicalBaseUrl, actionRef,
|
|
31
51
|
buildMode = 'build-and-deploy', templateOwner = 'rathnasgala',
|
|
32
52
|
templateRepository = 'site-template',
|
|
33
53
|
readGithub = readGithubCredential, readGala = readGalaCredential,
|
|
34
54
|
generate = generateRepositoryFromTemplate, clone = cloneRepository,
|
|
35
55
|
configure = configureSite, register = registerSite, finalize = writeRegisteredSiteConfiguration,
|
|
36
|
-
writeWorkflow = writePublishWorkflow,
|
|
56
|
+
writeWorkflow = writePublishWorkflow,
|
|
37
57
|
installVariable = installRepositoryVariable,
|
|
38
58
|
provisionPages = provisionGithubPages,
|
|
39
59
|
commit = commitScaffold, verifyEmpty = verifyEmptyRepository, setOrigin = setRepositoryOrigin,
|
|
@@ -41,6 +61,7 @@ export async function scaffoldSite({
|
|
|
41
61
|
}) {
|
|
42
62
|
const repositoryOwner = segment(owner, 'owner');
|
|
43
63
|
const repositoryName = segment(repository, 'repository');
|
|
64
|
+
const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
|
|
44
65
|
if (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0) {
|
|
45
66
|
throw new TypeError('githubInstallationId must be a positive integer');
|
|
46
67
|
}
|
|
@@ -76,25 +97,22 @@ export async function scaffoldSite({
|
|
|
76
97
|
if (emptyExistingRepository) await setOrigin({ root, owner: repositoryOwner, repository: repositoryName });
|
|
77
98
|
}
|
|
78
99
|
const configured = await configure(root, siteOptions ?? {});
|
|
79
|
-
const canonicalBaseUrl = providerDefaultBase(repositoryOwner);
|
|
80
100
|
const idempotencyKey = `scaffold-${createHash('sha256').update(`${repositoryOwner.toLowerCase()}/${repositoryName.toLowerCase()}`).digest('hex')}`;
|
|
81
101
|
const registration = await register({
|
|
82
|
-
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
102
|
+
apiBaseUrl: gala.apiBaseUrl, galaAccessToken: gala.accessToken,
|
|
103
|
+
githubAccessToken: github.accessToken, idempotencyKey,
|
|
83
104
|
githubInstallationId, repositoryOwner, repositoryName,
|
|
84
|
-
topology:
|
|
105
|
+
topology: location.topology, canonicalBaseUrl: location.canonicalBaseUrl
|
|
85
106
|
});
|
|
86
107
|
await finalize(root, {
|
|
87
108
|
siteId: registration.siteId,
|
|
88
109
|
canonicalBaseUrl: registration.canonicalBaseUrl,
|
|
89
110
|
pathPrefix: registration.pathPrefix,
|
|
90
|
-
topology
|
|
111
|
+
topology
|
|
91
112
|
});
|
|
92
113
|
await writeWorkflow({
|
|
93
|
-
root, siteId: registration.siteId, timezone: configured.site.timezone, buildMode
|
|
94
|
-
|
|
95
|
-
await installSecret({
|
|
96
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
97
|
-
secretName: 'GALA_SITE_SECRET', secretValue: registration.siteSecret
|
|
114
|
+
root, siteId: registration.siteId, timezone: configured.site.timezone, buildMode,
|
|
115
|
+
...(actionRef == null ? {} : { actionRef })
|
|
98
116
|
});
|
|
99
117
|
await installVariable({
|
|
100
118
|
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken,
|
|
@@ -102,7 +120,8 @@ export async function scaffoldSite({
|
|
|
102
120
|
});
|
|
103
121
|
const commitSha = await commit(root);
|
|
104
122
|
const pages = buildMode === 'build-and-deploy' ? await provisionPages({
|
|
105
|
-
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha
|
|
123
|
+
owner: repositoryOwner, repository: repositoryName, accessToken: github.accessToken, commitSha,
|
|
124
|
+
customDomain: location.topology === 'CUSTOM_DOMAIN' ? new URL(location.canonicalBaseUrl).hostname : null
|
|
106
125
|
}) : null;
|
|
107
126
|
return Object.freeze({
|
|
108
127
|
root, fullName: generated.fullName, siteId: registration.siteId, commitSha, pages
|
|
@@ -6,7 +6,9 @@ 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 (
|
|
9
|
+
if (!['provider-default', 'custom-domain', 'domain-root', 'domain-subpath'].includes(topology)) {
|
|
10
|
+
throw new TypeError('topology is invalid');
|
|
11
|
+
}
|
|
10
12
|
const canonical = new URL(canonicalBaseUrl);
|
|
11
13
|
if (canonical.protocol !== 'https:' || canonical.username || canonical.password || canonical.search
|
|
12
14
|
|| canonical.hash || canonical.pathname !== '/') {
|
|
@@ -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 })
|
package/src/workflow-command.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
const ACTION_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml@v[1-9][0-9]
|
|
5
|
+
const ACTION_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml@v(?:[1-9][0-9]*|[0-9]+\.[0-9]+\.[0-9]+)$/;
|
|
6
6
|
const BRANCH = /^(?![./])(?!.*\.\.)(?!.*[~^:?*\[\\])[A-Za-z0-9._/-]+(?<![/.])$/;
|
|
7
7
|
|
|
8
8
|
export function deriveNightlySchedule(siteId) {
|
|
@@ -28,7 +28,9 @@ export async function writePublishWorkflow({
|
|
|
28
28
|
buildMode = 'build-and-deploy'
|
|
29
29
|
}) {
|
|
30
30
|
validateTimezone(timezone);
|
|
31
|
-
if (!ACTION_REF.test(actionRef))
|
|
31
|
+
if (!ACTION_REF.test(actionRef)) {
|
|
32
|
+
throw new TypeError('actionRef must pin a reusable workflow to a major or immutable semver tag');
|
|
33
|
+
}
|
|
32
34
|
if (!BRANCH.test(defaultBranch)) throw new TypeError('Invalid default branch');
|
|
33
35
|
if (!['build-only', 'build-and-deploy'].includes(buildMode)) throw new TypeError('Invalid build mode');
|
|
34
36
|
|