@rathnasgala/cli 0.0.8 → 0.0.13

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 ADDED
@@ -0,0 +1,275 @@
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) — `scaffold` walks you through installing it if it is not already
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
+ One command, run inside an empty folder named after the publication you want:
32
+
33
+ ```console
34
+ mkdir field-notes && cd field-notes
35
+ npx --yes @rathnasgala/cli@latest scaffold --target ./ --mode build-and-deploy
36
+ ```
37
+
38
+ That single command does all of the following, and asks only for what it cannot work out:
39
+
40
+ 1. **Signs you in to Gala** if no valid token is stored, showing a code to enter in the browser.
41
+ 2. **Signs you in to GitHub** the same way, requesting `repo` to create the publication and install
42
+ its Actions secret, and `workflow` for the initial scaffold. Ordinary publishing needs neither.
43
+ 3. **Reads your GitHub account** from that token, so there is no username to type.
44
+ 4. **Finds the Gala GitHub App installation** for your account. If the App is not installed yet it
45
+ prints the installation page, waits while you install it, and carries on — the installation ID
46
+ is never something you have to read out of a URL.
47
+ 5. **Names the publication** after the folder you are standing in.
48
+ 6. **Creates the repository** from the site template, registers it, installs its one-time secret,
49
+ writes the publication workflow, commits, and enables GitHub Pages.
50
+
51
+ Both sign-ins are skipped when a valid credential is already stored, so re-running is cheap.
52
+
53
+ After scaffolding succeeds, open [GitHub App settings](https://github.com/settings/installations)
54
+ and restrict the App to the publication repository if you installed it against all of them.
55
+
56
+ ### Write, preview, and publish
57
+
58
+ ```console
59
+ npx --yes @rathnasgala/cli@latest new --title "My first post" --language en
60
+ npx --yes @rathnasgala/cli@latest preview
61
+ npx --yes @rathnasgala/cli@latest publish
62
+ ```
63
+
64
+ `new` prints the Markdown file it created. Write below the second `---` line, save the file,
65
+ preview it locally, then publish it through GitHub.
66
+
67
+ ### Overriding what scaffold works out
68
+
69
+ Every derived value is still an explicit flag, for the cases where the default is wrong — a
70
+ publication owned by an organisation, a folder named differently from the repository, or more than
71
+ one App installation on the account:
72
+
73
+ ```console
74
+ npx --yes @rathnasgala/cli@latest scaffold \
75
+ --owner YOUR_GITHUB_USERNAME \
76
+ --repository YOUR_REPOSITORY_NAME \
77
+ --target ./YOUR_REPOSITORY_NAME \
78
+ --installation-id YOUR_INSTALLATION_ID \
79
+ --mode build-and-deploy
80
+ ```
81
+
82
+ `--repository` is otherwise taken from `--target`, then from `--site-name`, and only then asked
83
+ for. Outside a terminal — in CI — nothing is ever prompted for: a value that cannot be derived is
84
+ an error, so an automated run fails fast instead of waiting for an answer that will not come.
85
+
86
+ ## Command reference
87
+
88
+ Run commands through `npx` without installing a global package:
89
+
90
+ ```console
91
+ npx --yes @rathnasgala/cli@latest COMMAND [options]
92
+ ```
93
+
94
+ Inside the table below, `gala` is shorthand for that prefix.
95
+
96
+ | Command | Purpose | Common options |
97
+ | --- | --- | --- |
98
+ | `gala auth` | Authenticate the author with Gala | `--api-base-url URL` for a non-production API |
99
+ | `gala auth github` | Authenticate the CLI with GitHub | Browser device flow; requests `repo workflow` |
100
+ | `gala scaffold` | Sign in if needed, then create and register a publication | All derived; override with `--owner`, `--repository`, `--target`, `--installation-id`, `--mode` |
101
+ | `gala configure` | Update author-owned site and design settings | `--root`, plus the configuration options below |
102
+ | `gala new` | Create a Markdown post variant | `--root`, `--title`, `--language`, `--today` |
103
+ | `gala validate` | Validate repository content without publishing | optional root path, `--today` |
104
+ | `gala preview` | Validate and run the local Eleventy preview | `--root`, `--today` |
105
+ | `gala publish` | Validate, commit, and push publication changes | `--root`, `--today`, `--force` |
106
+ | `gala doctor` | Report managed-framework drift and publication-state validity | optional root path; `--fix --source TRUSTED_ROOT` |
107
+ | `gala hook install` | Install the pre-push validation hook | `--root` |
108
+ | `gala refresh` | Refresh and commit the engagement snapshot | `--root` |
109
+ | `gala upgrade` | Verify and install an exact theme-package release | `--root`, `--channel`, `--yes` |
110
+ | `gala topology` | Switch canonical origin/path topology transactionally | `--root`, `--owner`, `--repository`, `--canonical-base-url`, `--path-prefix` |
111
+ | `gala entitlement` | Retrieve and commit the current paid attribution artifact | `--root` |
112
+ | `gala workflow` | Write the reusable GitHub Actions workflow | `--root`, `--site-id`, `--timezone`, `--action-ref`, `--default-branch`, `--mode` |
113
+ | `gala record-deployment` | Record state after a successful deployment | `--root`, `--today`, `--commit-sha` |
114
+
115
+ ### Scaffold and configure options
116
+
117
+ The same author-owned options are accepted by `scaffold` and `configure`:
118
+
119
+ ```text
120
+ --site-name
121
+ --author
122
+ --language
123
+ --timezone
124
+ --theme
125
+ --layout
126
+ --palette
127
+ --typography
128
+ --spacing
129
+ --radius
130
+ --density
131
+ --motion
132
+ --componentStyle
133
+ --share-target repeatable
134
+ --social-profile repeatable
135
+ ```
136
+
137
+ Use only identities supported by the installed theme package. Validation rejects unavailable layout, palette, and theme identities instead of silently substituting another design.
138
+
139
+ ### Scaffold an existing empty repository
140
+
141
+ Use this only when the exact GitHub repository already exists and has no branches or content:
142
+
143
+ ```console
144
+ npx --yes @rathnasgala/cli@latest scaffold \
145
+ --repository YOUR_REPOSITORY_NAME \
146
+ --empty-existing-repository
147
+ ```
148
+
149
+ ### Resume interrupted scaffolding
150
+
151
+ The target must already be a checkout whose HTTPS origin exactly matches the requested repository:
152
+
153
+ ```console
154
+ npx --yes @rathnasgala/cli@latest scaffold \
155
+ --repository YOUR_REPOSITORY_NAME \
156
+ --target ./YOUR_REPOSITORY_NAME \
157
+ --resume
158
+ ```
159
+
160
+ Scaffolding is designed to converge after partial failure. It will not adopt a non-empty unrelated repository.
161
+
162
+ ### Build without deploying
163
+
164
+ ```console
165
+ npx --yes @rathnasgala/cli@latest scaffold ... --mode build-only
166
+ ```
167
+
168
+ `build-only` writes and validates the site but does not provision GitHub Pages. `build-and-deploy` is the default.
169
+
170
+ ## Everyday workflow
171
+
172
+ Create another post:
173
+
174
+ ```console
175
+ npx --yes @rathnasgala/cli@latest new --title "A durable idea" --language en
176
+ ```
177
+
178
+ Validate without running a preview server:
179
+
180
+ ```console
181
+ npx --yes @rathnasgala/cli@latest validate
182
+ ```
183
+
184
+ Preview locally:
185
+
186
+ ```console
187
+ npx --yes @rathnasgala/cli@latest preview
188
+ ```
189
+
190
+ Publish:
191
+
192
+ ```console
193
+ npx --yes @rathnasgala/cli@latest publish
194
+ ```
195
+
196
+ Check managed files and recorded publication state:
197
+
198
+ ```console
199
+ npx --yes @rathnasgala/cli@latest doctor
200
+ ```
201
+
202
+ ## Security and ownership
203
+
204
+ - Your repository remains the canonical source for publication content and configuration.
205
+ - Gala credentials and GitHub OAuth credentials are stored outside the repository.
206
+ - Credential directories are created with private permissions; credential files use mode `0600` on operating systems that support POSIX modes.
207
+ - The site signing secret is returned once by the API and sealed directly into GitHub Actions secrets.
208
+ - Do not copy credential files into the repository, dotfiles, cloud-sync folders, or `/tmp`.
209
+ - The generated workflow pins the public Gala Action contract; managed framework files are integrity-checked before repair or upgrade.
210
+
211
+ ## Troubleshooting
212
+
213
+ ### `GitHub authentication is missing`
214
+
215
+ Run:
216
+
217
+ ```console
218
+ npx --yes @rathnasgala/cli@latest auth github
219
+ ```
220
+
221
+ ### Gala authentication expired
222
+
223
+ Gala author tokens expire and do not use a refresh token. Run:
224
+
225
+ ```console
226
+ npx --yes @rathnasgala/cli@latest auth
227
+ ```
228
+
229
+ ### `The Gala GitHub App is not installed on YOUR_ACCOUNT`
230
+
231
+ `scaffold` could not find an installation covering that account. At a terminal it prints the
232
+ installation page and waits; in CI it stops, because there is nobody to install it. Install the App
233
+ at [the installation page](https://github.com/apps/gala67-app/installations/new) and run `scaffold`
234
+ again, or pass `--installation-id` explicitly.
235
+
236
+ ### The App cannot access the new repository
237
+
238
+ 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.
239
+
240
+ ### The target folder already exists
241
+
242
+ 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.
243
+
244
+ ### Validation refuses a post
245
+
246
+ The error includes the source file and violated rule. Correct the file and run:
247
+
248
+ ```console
249
+ npx --yes @rathnasgala/cli@latest validate
250
+ ```
251
+
252
+ Do not use `publish --force` as a routine bypass. It skips content validation but does not force-push Git history.
253
+
254
+ ### Managed files have drifted
255
+
256
+ Inspect first:
257
+
258
+ ```console
259
+ npx --yes @rathnasgala/cli@latest doctor
260
+ ```
261
+
262
+ Repair requires a trusted, hash-verified theme source:
263
+
264
+ ```console
265
+ npx --yes @rathnasgala/cli@latest doctor --fix --source PATH_TO_TRUSTED_THEME
266
+ ```
267
+
268
+ ## Package and source
269
+
270
+ - npm: [`@rathnasgala/cli`](https://www.npmjs.com/package/@rathnasgala/cli)
271
+ - source: [`rathnasgala/cli`](https://github.com/rathnasgala/cli)
272
+
273
+ ## License
274
+
275
+ 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.8",
3
+ "version": "0.0.13",
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.2",
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"
@@ -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
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Which Gala GitHub App installation covers this writer's account.
3
+ *
4
+ * The installation ID is an internal GitHub identifier that `scaffold` has to send when it
5
+ * registers a site. Until now the writer supplied it by installing the App, watching GitHub
6
+ * redirect to `https://github.com/settings/installations/153144989`, and copying the number out of
7
+ * the address bar — an internal identifier, read out of a URL, by hand.
8
+ *
9
+ * The Gala API already knows it. `GET /v1/auth/github/repositories` answers with
10
+ * `{ installationId, owner, name, status }` per repository, so the CLI can ask the same service it
11
+ * is about to register with rather than guess. Reaching that endpoint needs the bounded capability
12
+ * from `POST /v1/auth/github/device-authorizations`, which is bound to the Gala user and takes the
13
+ * GitHub token the CLI already holds.
14
+ */
15
+ function endpoint(apiBaseUrl, path) {
16
+ return `${String(apiBaseUrl).replace(/\/$/, '')}${path}`;
17
+ }
18
+
19
+ export async function exchangeGithubAuthorization({
20
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl = fetch
21
+ }) {
22
+ const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/device-authorizations'), {
23
+ method: 'POST',
24
+ headers: {
25
+ accept: 'application/json',
26
+ authorization: `Bearer ${galaAccessToken}`,
27
+ 'content-type': 'application/json'
28
+ },
29
+ body: JSON.stringify({ accessToken: githubAccessToken })
30
+ });
31
+ if (!response.ok) {
32
+ throw new Error(`GitHub authorization exchange failed with HTTP ${response.status}`);
33
+ }
34
+ const payload = await response.json();
35
+ if (typeof payload?.authorization !== 'string') {
36
+ throw new TypeError('GitHub authorization exchange returned no capability');
37
+ }
38
+ return payload.authorization;
39
+ }
40
+
41
+ export async function listAuthorizedRepositories({ apiBaseUrl, authorization, fetchImpl = fetch }) {
42
+ const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/repositories'), {
43
+ headers: { accept: 'application/json', 'GitHub-Authorization': authorization }
44
+ });
45
+ if (!response.ok) {
46
+ throw new Error(`Authorized repository lookup failed with HTTP ${response.status}`);
47
+ }
48
+ const payload = await response.json();
49
+ if (!Array.isArray(payload)) throw new TypeError('Authorized repository lookup returned no list');
50
+ return payload;
51
+ }
52
+
53
+ /**
54
+ * Returns the installation covering `owner`, or null when the App is not installed there.
55
+ *
56
+ * An installation belongs to an account, not to one repository, so any repository the App can
57
+ * already see under that owner carries the id the new one will use. Null is an ordinary answer —
58
+ * it means "not installed yet" — and the caller turns it into an instruction, not an error.
59
+ */
60
+ export async function resolveInstallationId({
61
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner, fetchImpl = fetch,
62
+ exchange = exchangeGithubAuthorization, list = listAuthorizedRepositories
63
+ }) {
64
+ const authorization = await exchange({
65
+ apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
66
+ });
67
+ const repositories = await list({ apiBaseUrl, authorization, fetchImpl });
68
+ const wanted = String(owner).toLowerCase();
69
+ for (const repository of repositories) {
70
+ if (String(repository?.owner).toLowerCase() !== wanted) continue;
71
+ const installationId = Number(repository?.installationId);
72
+ if (Number.isSafeInteger(installationId) && installationId > 0) return installationId;
73
+ }
74
+ return null;
75
+ }
@@ -0,0 +1,31 @@
1
+ const GITHUB_API_VERSION = '2026-03-10';
2
+
3
+ /**
4
+ * The GitHub account the stored credential belongs to.
5
+ *
6
+ * `scaffold` used to make the writer pass `--owner`, which is a value the token already knows and
7
+ * they can only get wrong. The credential file holds the token and its scopes and nothing else, so
8
+ * this is a live lookup rather than something cached at `auth github` time — a login can be
9
+ * changed, and a stale one would create the repository under a name that no longer exists.
10
+ */
11
+ export async function resolveGithubLogin({ accessToken, fetchImpl = fetch }) {
12
+ if (typeof accessToken !== 'string' || accessToken === '') {
13
+ throw new TypeError('accessToken is required');
14
+ }
15
+ const response = await fetchImpl('https://api.github.com/user', {
16
+ headers: {
17
+ accept: 'application/vnd.github+json',
18
+ authorization: `Bearer ${accessToken}`,
19
+ 'x-github-api-version': GITHUB_API_VERSION
20
+ }
21
+ });
22
+ if (!response.ok) throw new Error(`GitHub account lookup failed with HTTP ${response.status}`);
23
+ const payload = await response.json();
24
+ const login = payload?.login;
25
+ // The same shape `scaffold` demands of `--owner`. Refusing here beats a confusing failure four
26
+ // API calls later.
27
+ if (typeof login !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) {
28
+ throw new TypeError('GitHub returned an unusable account login');
29
+ }
30
+ return login;
31
+ }
package/src/index.js CHANGED
@@ -20,9 +20,13 @@ 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 { prepareScaffold } from './scaffold-preflight.js';
24
+ import { refreshEngagementSnapshot } from './refresh-command.js';
25
+ import { switchTopology } from './topology-command.js';
26
+ import { acquireAttributionEntitlement } from './entitlement-command.js';
23
27
 
24
28
  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]';
29
+ const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
26
30
 
27
31
  if (command === 'help' || command === '--help' || command === '-h'
28
32
  || args.includes('--help') || args.includes('-h')) {
@@ -32,7 +36,7 @@ if (command === 'help' || command === '--help' || command === '-h'
32
36
 
33
37
  const recognizedCommands = new Set([
34
38
  'auth', 'configure', 'validate', 'new', 'doctor', 'preview',
35
- 'workflow', 'publish', 'record-deployment', 'hook', 'upgrade'
39
+ 'workflow', 'publish', 'record-deployment', 'refresh', 'hook', 'upgrade', 'topology', 'entitlement'
36
40
  ]);
37
41
  function commandRoot() {
38
42
  const rootIndex = args.indexOf('--root');
@@ -74,17 +78,37 @@ if (command === 'auth') {
74
78
  const index = args.indexOf(name);
75
79
  return index === -1 ? undefined : args[index + 1];
76
80
  };
77
- const installationId = Number(valueFor('--installation-id'));
81
+ const explicitInstallationId = valueFor('--installation-id');
78
82
  const topology = valueFor('--topology') ?? 'provider-default';
79
- const result = await scaffoldSite({
83
+ const siteOptions = parseScaffoldOptions(args);
84
+ // Prompting only makes sense at a terminal. In CI there is nobody to answer, so a missing value
85
+ // has to stay a clear error rather than a process that hangs waiting for enter.
86
+ const interactive = process.stdin.isTTY === true;
87
+ const ask = interactive
88
+ ? async (question) => {
89
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
90
+ try { return await terminal.question(question); } finally { terminal.close(); }
91
+ }
92
+ : undefined;
93
+ const prepared = await prepareScaffold({
80
94
  owner: valueFor('--owner'),
81
95
  repository: valueFor('--repository'),
82
96
  target: valueFor('--target'),
83
- githubInstallationId: installationId,
97
+ githubInstallationId: explicitInstallationId == null ? undefined : Number(explicitInstallationId),
98
+ siteName: siteOptions.siteName,
99
+ apiBaseUrl: valueFor('--api-base-url') ?? 'https://api.gala67.com',
100
+ notify: (message) => process.stdout.write(`${message}\n`),
101
+ ask
102
+ });
103
+ const result = await scaffoldSite({
104
+ owner: prepared.owner,
105
+ repository: prepared.repository,
106
+ target: prepared.target,
107
+ githubInstallationId: prepared.githubInstallationId,
84
108
  topology,
85
109
  canonicalBaseUrl: valueFor('--canonical-base-url'),
86
110
  actionRef: valueFor('--action-ref'),
87
- siteOptions: parseScaffoldOptions(args),
111
+ siteOptions,
88
112
  buildMode: valueFor('--mode') ?? 'build-and-deploy',
89
113
  emptyExistingRepository: args.includes('--empty-existing-repository'),
90
114
  resumeExistingCheckout: args.includes('--resume')
@@ -97,6 +121,27 @@ if (command === 'auth') {
97
121
  const options = parseScaffoldOptions(args);
98
122
  const config = await configureSite(root, options);
99
123
  process.stdout.write(`${JSON.stringify(config.design, null, 2)}\n`);
124
+ } else if (command === 'topology') {
125
+ const valueFor = (name) => {
126
+ const index = args.indexOf(name);
127
+ return index === -1 ? undefined : args[index + 1];
128
+ };
129
+ const result = await switchTopology({
130
+ root: valueFor('--root') ?? process.cwd(),
131
+ owner: valueFor('--owner'),
132
+ repository: valueFor('--repository'),
133
+ canonicalBaseUrl: valueFor('--canonical-base-url'),
134
+ pathPrefix: valueFor('--path-prefix') ?? '/'
135
+ });
136
+ process.stdout.write(`Committed topology ${result.changeId} at ${result.commitSha}.\n`);
137
+ } else if (command === 'entitlement') {
138
+ const rootIndex = args.indexOf('--root');
139
+ const result = await acquireAttributionEntitlement({
140
+ root: rootIndex === -1 ? process.cwd() : args[rootIndex + 1]
141
+ });
142
+ process.stdout.write(result.changed
143
+ ? `Stored the signed attribution entitlement for ${result.siteId}.\n`
144
+ : `Attribution entitlement for ${result.siteId} is current.\n`);
100
145
  } else if (command === 'validate') {
101
146
  const todayIndex = args.indexOf('--today');
102
147
  const today = todayIndex === -1 ? undefined : args[todayIndex + 1];
@@ -191,6 +236,13 @@ if (command === 'auth') {
191
236
  + `of ${result.state.posts.length} article(s).\n`
192
237
  + `Recorded state SHA: ${result.recordedStateSha}\n`
193
238
  );
239
+ } else if (command === 'refresh') {
240
+ const rootIndex = args.indexOf('--root');
241
+ const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
242
+ const result = await refreshEngagementSnapshot({ root });
243
+ process.stdout.write(result.changed
244
+ ? 'Refreshed, committed, and pushed the engagement snapshot.\n'
245
+ : 'Engagement snapshot is already current.\n');
194
246
  } else if (command === 'upgrade') {
195
247
  const valueFor = (name) => {
196
248
  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
+ }
@@ -0,0 +1,154 @@
1
+ import path from 'node:path';
2
+
3
+ import { authenticateGala } from './auth-command.js';
4
+ import { authenticateGithub } from './github-auth-command.js';
5
+ import { readGalaCredential } from './gala-credential-store.js';
6
+ import { readGithubCredential } from './github-credential-store.js';
7
+ import { resolveGithubLogin } from './github-identity.js';
8
+ import { resolveInstallationId } from './gala-installation-client.js';
9
+
10
+ export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
11
+
12
+ const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
13
+
14
+ /**
15
+ * Everything `scaffold` needs, worked out rather than demanded.
16
+ *
17
+ * `scaffold` used to require four values up front — `--owner`, `--repository`, `--target` and
18
+ * `--installation-id` — and it failed outright if `auth` or `auth github` had not been run first,
19
+ * telling the writer to go and run them. Three of those four are derivable and the two sign-ins
20
+ * can simply happen. Every one of them is still accepted as an explicit override; nothing that
21
+ * worked before stops working.
22
+ *
23
+ * The steps are ordered so nothing is created until everything is known: the App installation is
24
+ * confirmed before a repository exists, rather than after, so an interrupted run leaves no
25
+ * half-connected repository behind.
26
+ */
27
+ export async function prepareScaffold({
28
+ owner,
29
+ repository,
30
+ target,
31
+ githubInstallationId,
32
+ siteName,
33
+ cwd = process.cwd(),
34
+ notify = () => {},
35
+ ask,
36
+ installUrl = GITHUB_APP_INSTALL_URL,
37
+ installAttempts = 3,
38
+ readGala = readGalaCredential,
39
+ readGithub = readGithubCredential,
40
+ signInGala = authenticateGala,
41
+ signInGithub = authenticateGithub,
42
+ resolveLogin = resolveGithubLogin,
43
+ resolveInstallation = resolveInstallationId,
44
+ apiBaseUrl = DEFAULT_API_BASE_URL
45
+ } = {}) {
46
+ const gala = await ensureGala({ apiBaseUrl, notify, readGala, signInGala });
47
+ const github = await ensureGithub({ notify, readGithub, signInGithub });
48
+
49
+ const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
50
+
51
+ const resolvedRepository = repository
52
+ ?? (target == null ? null : path.basename(path.resolve(cwd, target)))
53
+ ?? repositoryNameFrom(siteName)
54
+ ?? await askForRepository(ask);
55
+
56
+ // `--target ./` is the common case and means "here", so the repository takes its name from the
57
+ // directory the writer is standing in. Everywhere else the repository names its own folder.
58
+ const resolvedTarget = target ?? `./${resolvedRepository}`;
59
+
60
+ const resolvedInstallation = githubInstallationId
61
+ ?? await ensureInstallation({
62
+ apiBaseUrl: gala.apiBaseUrl ?? apiBaseUrl,
63
+ galaAccessToken: gala.accessToken,
64
+ githubAccessToken: github.accessToken,
65
+ owner: resolvedOwner,
66
+ notify, ask, installUrl, installAttempts, resolveInstallation
67
+ });
68
+
69
+ return Object.freeze({
70
+ owner: resolvedOwner,
71
+ repository: resolvedRepository,
72
+ target: resolvedTarget,
73
+ githubInstallationId: resolvedInstallation
74
+ });
75
+ }
76
+
77
+ /** A missing or expired credential is a step to take, not an error to report. */
78
+ async function ensureGala({ apiBaseUrl, notify, readGala, signInGala }) {
79
+ try {
80
+ return await readGala();
81
+ } catch {
82
+ notify('Signing in to Gala.');
83
+ await signInGala({
84
+ apiBaseUrl,
85
+ showInstructions: ({ verificationUri, userCode }) =>
86
+ notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
87
+ });
88
+ return readGala();
89
+ }
90
+ }
91
+
92
+ async function ensureGithub({ notify, readGithub, signInGithub }) {
93
+ try {
94
+ return await readGithub();
95
+ } catch {
96
+ notify('Signing in to GitHub.');
97
+ await signInGithub({
98
+ showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
99
+ showInstructions: ({ verificationUri, userCode }) =>
100
+ notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
101
+ });
102
+ return readGithub();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Confirms the Gala GitHub App is installed, walking the writer through installing it if not.
108
+ *
109
+ * This is the step that used to be a manual detour through GitHub's settings to copy a number out
110
+ * of a redirect URL. The loop is what makes it a step rather than a failure: the writer installs
111
+ * the App in the browser, comes back, presses enter, and the run continues.
112
+ */
113
+ async function ensureInstallation({
114
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner,
115
+ notify, ask, installUrl, installAttempts, resolveInstallation
116
+ }) {
117
+ for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
118
+ const installationId = await resolveInstallation({
119
+ apiBaseUrl, galaAccessToken, githubAccessToken, owner
120
+ });
121
+ if (installationId != null) return installationId;
122
+
123
+ if (typeof ask !== 'function') {
124
+ throw new Error(
125
+ `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run scaffold again, `
126
+ + 'or pass --installation-id explicitly.'
127
+ );
128
+ }
129
+ notify(`The Gala GitHub App is not installed on ${owner} yet.\nOpen ${installUrl}`);
130
+ await ask('Press enter once the App is installed. ');
131
+ }
132
+ throw new Error(
133
+ `The Gala GitHub App still does not cover ${owner}. Install it at ${installUrl}, then run scaffold again.`
134
+ );
135
+ }
136
+
137
+ /** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
138
+ export function repositoryNameFrom(siteName) {
139
+ if (typeof siteName !== 'string') return null;
140
+ const slug = siteName
141
+ .trim().toLowerCase()
142
+ .replace(/[^a-z0-9._-]+/g, '-')
143
+ .replace(/^-+|-+$/g, '');
144
+ return slug === '' ? null : slug;
145
+ }
146
+
147
+ async function askForRepository(ask) {
148
+ if (typeof ask !== 'function') {
149
+ throw new TypeError('repository is required; pass --repository or --site-name');
150
+ }
151
+ const answer = repositoryNameFrom(await ask('What should the publication repository be called? '));
152
+ if (answer == null) throw new TypeError('A repository name is required');
153
+ return answer;
154
+ }
@@ -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, installSecret = installRepositorySecret,
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, idempotencyKey,
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
+ }
@@ -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 })