@rathnasgala/cli 0.0.8 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.8",
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.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
+ }
package/src/index.js CHANGED
@@ -20,9 +20,12 @@ import { createInterface } from 'node:readline/promises';
20
20
  import { upgradeTheme } from './upgrade-command.js';
21
21
  import { authenticateGithub } from './github-auth-command.js';
22
22
  import { scaffoldSite } from './scaffold-site.js';
23
+ import { refreshEngagementSnapshot } from './refresh-command.js';
24
+ import { switchTopology } from './topology-command.js';
25
+ import { acquireAttributionEntitlement } from './entitlement-command.js';
23
26
 
24
27
  const [command, ...args] = process.argv.slice(2);
25
- const usage = 'Usage: gala <auth|configure|scaffold|validate|new|doctor|hook|preview|publish|record-deployment|upgrade|workflow> [options]';
28
+ const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
26
29
 
27
30
  if (command === 'help' || command === '--help' || command === '-h'
28
31
  || args.includes('--help') || args.includes('-h')) {
@@ -32,7 +35,7 @@ if (command === 'help' || command === '--help' || command === '-h'
32
35
 
33
36
  const recognizedCommands = new Set([
34
37
  'auth', 'configure', 'validate', 'new', 'doctor', 'preview',
35
- 'workflow', 'publish', 'record-deployment', 'hook', 'upgrade'
38
+ 'workflow', 'publish', 'record-deployment', 'refresh', 'hook', 'upgrade', 'topology', 'entitlement'
36
39
  ]);
37
40
  function commandRoot() {
38
41
  const rootIndex = args.indexOf('--root');
@@ -97,6 +100,27 @@ if (command === 'auth') {
97
100
  const options = parseScaffoldOptions(args);
98
101
  const config = await configureSite(root, options);
99
102
  process.stdout.write(`${JSON.stringify(config.design, null, 2)}\n`);
103
+ } else if (command === 'topology') {
104
+ const valueFor = (name) => {
105
+ const index = args.indexOf(name);
106
+ return index === -1 ? undefined : args[index + 1];
107
+ };
108
+ const result = await switchTopology({
109
+ root: valueFor('--root') ?? process.cwd(),
110
+ owner: valueFor('--owner'),
111
+ repository: valueFor('--repository'),
112
+ canonicalBaseUrl: valueFor('--canonical-base-url'),
113
+ pathPrefix: valueFor('--path-prefix') ?? '/'
114
+ });
115
+ process.stdout.write(`Committed topology ${result.changeId} at ${result.commitSha}.\n`);
116
+ } else if (command === 'entitlement') {
117
+ const rootIndex = args.indexOf('--root');
118
+ const result = await acquireAttributionEntitlement({
119
+ root: rootIndex === -1 ? process.cwd() : args[rootIndex + 1]
120
+ });
121
+ process.stdout.write(result.changed
122
+ ? `Stored the signed attribution entitlement for ${result.siteId}.\n`
123
+ : `Attribution entitlement for ${result.siteId} is current.\n`);
100
124
  } else if (command === 'validate') {
101
125
  const todayIndex = args.indexOf('--today');
102
126
  const today = todayIndex === -1 ? undefined : args[todayIndex + 1];
@@ -191,6 +215,13 @@ if (command === 'auth') {
191
215
  + `of ${result.state.posts.length} article(s).\n`
192
216
  + `Recorded state SHA: ${result.recordedStateSha}\n`
193
217
  );
218
+ } else if (command === 'refresh') {
219
+ const rootIndex = args.indexOf('--root');
220
+ const root = rootIndex === -1 ? process.cwd() : args[rootIndex + 1];
221
+ const result = await refreshEngagementSnapshot({ root });
222
+ process.stdout.write(result.changed
223
+ ? 'Refreshed, committed, and pushed the engagement snapshot.\n'
224
+ : 'Engagement snapshot is already current.\n');
194
225
  } else if (command === 'upgrade') {
195
226
  const valueFor = (name) => {
196
227
  const index = args.indexOf(name);
@@ -0,0 +1,104 @@
1
+ import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { parse } from 'yaml';
5
+
6
+ import { readGalaCredential } from './gala-credential-store.js';
7
+
8
+ const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
9
+ const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
10
+ const SNAPSHOT_PATH = '.engagement-snapshot.json';
11
+
12
+ async function runGit(root, args) {
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn('git', ['-C', root, ...args], { shell: false, stdio: 'inherit' });
15
+ child.once('error', reject);
16
+ child.once('exit', (code, signal) => {
17
+ if (signal) reject(new Error(`git terminated by signal ${signal}`));
18
+ else if (code !== 0) reject(new Error(`git ${args[0]} exited with code ${code}`));
19
+ else resolve();
20
+ });
21
+ });
22
+ }
23
+
24
+ async function commitRefreshedSnapshot(root, relativePath) {
25
+ await runGit(root, [
26
+ 'commit', '--only', '--message', 'chore(gala): refresh engagement snapshot', '--', relativePath
27
+ ]);
28
+ await runGit(root, ['push']);
29
+ }
30
+
31
+ function validateSnapshot(payload) {
32
+ if (payload?.schemaVersion !== 1 || !UTC_INSTANT.test(payload.refreshedAt)
33
+ || payload.articles == null || Array.isArray(payload.articles)
34
+ || typeof payload.articles !== 'object') {
35
+ throw new TypeError('Engagement snapshot response is invalid');
36
+ }
37
+ for (const [articleId, counts] of Object.entries(payload.articles)) {
38
+ if (!ULID.test(articleId) || counts == null || Array.isArray(counts)
39
+ || typeof counts !== 'object'
40
+ || Object.keys(counts).sort().join(',') !== 'comments,reactions,views'
41
+ || !['reactions', 'comments', 'views'].every(
42
+ (field) => Number.isSafeInteger(counts[field]) && counts[field] >= 0
43
+ )) {
44
+ throw new TypeError('Engagement snapshot response is invalid');
45
+ }
46
+ }
47
+ return payload;
48
+ }
49
+
50
+ async function requireRegularFile(target, label, { allowMissing = false } = {}) {
51
+ try {
52
+ const metadata = await lstat(target);
53
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
54
+ throw new TypeError(`${label} must be a regular file`);
55
+ }
56
+ return true;
57
+ } catch (error) {
58
+ if (allowMissing && error.code === 'ENOENT') return false;
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ export async function refreshEngagementSnapshot({
64
+ root = process.cwd(),
65
+ readCredential = readGalaCredential,
66
+ fetchImpl = fetch,
67
+ commitSnapshot = commitRefreshedSnapshot
68
+ } = {}) {
69
+ const siteRoot = path.resolve(root);
70
+ const configPath = path.join(siteRoot, 'site.config.yml');
71
+ await requireRegularFile(configPath, 'site.config.yml');
72
+ const config = parse(await readFile(configPath, 'utf8'));
73
+ const siteId = config?.site?.id;
74
+ if (!ULID.test(siteId)) throw new TypeError('site.config.yml site.id must be a canonical ULID');
75
+
76
+ const credential = await readCredential();
77
+ const endpoint = new URL(`/v1/sites/${siteId}/engagement-snapshot`, credential.apiBaseUrl);
78
+ const loopback = endpoint.protocol === 'http:'
79
+ && ['127.0.0.1', 'localhost', '::1'].includes(endpoint.hostname);
80
+ if ((endpoint.protocol !== 'https:' && !loopback) || endpoint.username || endpoint.password) {
81
+ throw new TypeError('Gala API URL must be credential-free HTTPS or HTTP loopback');
82
+ }
83
+ const response = await fetchImpl(endpoint, {
84
+ method: 'GET',
85
+ headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' }
86
+ });
87
+ if (!response.ok) throw new Error(`Engagement snapshot refresh failed with HTTP ${response.status}`);
88
+ const snapshot = validateSnapshot(await response.json());
89
+ const next = `${JSON.stringify(snapshot, null, 2)}\n`;
90
+ const target = path.join(siteRoot, SNAPSHOT_PATH);
91
+ const exists = await requireRegularFile(target, 'Engagement snapshot', { allowMissing: true });
92
+ if (exists && await readFile(target, 'utf8') === next) return Object.freeze({ changed: false });
93
+
94
+ const temporary = `${target}.gala-${process.pid}`;
95
+ try {
96
+ await writeFile(temporary, next, { flag: 'wx' });
97
+ await rename(temporary, target);
98
+ } catch (error) {
99
+ await rm(temporary, { force: true });
100
+ throw error;
101
+ }
102
+ await commitSnapshot(siteRoot, SNAPSHOT_PATH);
103
+ return Object.freeze({ changed: true });
104
+ }
@@ -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 })