@rathnasgala/cli 0.0.21 → 1.0.0

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.
Files changed (64) hide show
  1. package/README.md +66 -212
  2. package/package.json +3 -3
  3. package/src/api/gala.js +126 -0
  4. package/src/api/github.js +63 -0
  5. package/src/api/http.js +72 -0
  6. package/src/auth/gala.js +52 -0
  7. package/src/auth/github.js +78 -0
  8. package/src/auth/store.js +88 -0
  9. package/src/cli/args.js +56 -0
  10. package/src/cli/terminal.js +104 -0
  11. package/src/commands/auth.js +20 -0
  12. package/src/commands/doctor.js +98 -0
  13. package/src/commands/init.js +197 -0
  14. package/src/commands/new.js +76 -0
  15. package/src/commands/preview.js +92 -0
  16. package/src/commands/publish.js +57 -0
  17. package/src/commands-manifest.js +58 -0
  18. package/src/content.js +31 -0
  19. package/src/git.js +143 -0
  20. package/src/index.js +44 -297
  21. package/src/publication.js +37 -0
  22. package/src/assign-content-ids.js +0 -1
  23. package/src/auth-command.js +0 -36
  24. package/src/configure-site.js +0 -102
  25. package/src/content-files.js +0 -1
  26. package/src/doctor-command.js +0 -214
  27. package/src/entitlement-client.js +0 -26
  28. package/src/entitlement-command.js +0 -74
  29. package/src/evaluation-date.js +0 -1
  30. package/src/gala-credential-health.js +0 -34
  31. package/src/gala-credential-store.js +0 -115
  32. package/src/gala-device-flow.js +0 -121
  33. package/src/github-auth-command.js +0 -29
  34. package/src/github-credential-store.js +0 -65
  35. package/src/github-device-flow.js +0 -130
  36. package/src/github-empty-repository.js +0 -76
  37. package/src/github-identity.js +0 -32
  38. package/src/github-pages-provisioning.js +0 -107
  39. package/src/github-repository-secret.js +0 -82
  40. package/src/github-repository-variable.js +0 -56
  41. package/src/github-template-repository.js +0 -165
  42. package/src/hook-command.js +0 -64
  43. package/src/http-failure.js +0 -55
  44. package/src/new-command.js +0 -54
  45. package/src/open-browser.js +0 -40
  46. package/src/preview-command.js +0 -60
  47. package/src/publication-creation-client.js +0 -144
  48. package/src/publication-state.js +0 -7
  49. package/src/publish-command.js +0 -37
  50. package/src/record-deployment-command.js +0 -147
  51. package/src/refresh-command.js +0 -104
  52. package/src/repository-limits.js +0 -94
  53. package/src/scaffold-git.js +0 -41
  54. package/src/scaffold-options.js +0 -58
  55. package/src/scaffold-preflight.js +0 -147
  56. package/src/scaffold-site.js +0 -162
  57. package/src/site-config-registration.js +0 -47
  58. package/src/site-registration-client.js +0 -138
  59. package/src/theme-package.js +0 -128
  60. package/src/topology-client.js +0 -43
  61. package/src/topology-command.js +0 -70
  62. package/src/upgrade-command.js +0 -81
  63. package/src/validate-command.js +0 -5
  64. package/src/workflow-command.js +0 -87
package/README.md CHANGED
@@ -1,275 +1,129 @@
1
1
  # Gala CLI
2
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.
3
+ Create a publication, write posts, preview them, and publish from your terminal.
6
4
 
7
5
  ## Requirements
8
6
 
9
7
  - [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
8
+ - [Node.js 20](https://nodejs.org/en/download) or newer
11
9
  - 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
10
 
14
- Check your local tools:
11
+ Nothing to install. Every command runs through `npx`.
15
12
 
16
- ```console
17
- node --version
18
- npm --version
19
- git --version
20
- ```
13
+ ## Start a publication
21
14
 
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:
15
+ Make a folder named after the publication you want, and run one command inside it:
32
16
 
33
17
  ```console
34
18
  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]
19
+ npx --yes @rathnasgala/cli@latest init --here
92
20
  ```
93
21
 
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
- ```
22
+ It signs you in to Gala and to GitHub if you are not already, creates the repository, registers the
23
+ publication, and leaves a working checkout in the folder. When it finishes it prints the address
24
+ your publication will live at.
136
25
 
137
- Use only identities supported by the installed theme package. Validation rejects unavailable layout, palette, and theme identities instead of silently substituting another design.
26
+ If the Gala GitHub App has not been given access to the new repository, it says so and links to the
27
+ one page that grants it — GitHub has no way for an app to grant itself access, so that click is
28
+ unavoidable. Everything else is automatic.
138
29
 
139
- ### Scaffold an existing empty repository
140
-
141
- Use this only when the exact GitHub repository already exists and has no branches or content:
30
+ ## Write
142
31
 
143
32
  ```console
144
- npx --yes @rathnasgala/cli@latest scaffold \
145
- --repository YOUR_REPOSITORY_NAME \
146
- --empty-existing-repository
33
+ npx --yes @rathnasgala/cli@latest new "The places we return to"
147
34
  ```
148
35
 
149
- ### Resume interrupted scaffolding
150
-
151
- The target must already be a checkout whose HTTPS origin exactly matches the requested repository:
36
+ This creates the Markdown file and tells you the address the post will appear at. Write below the
37
+ second `---` line.
152
38
 
153
39
  ```console
154
- npx --yes @rathnasgala/cli@latest scaffold \
155
- --repository YOUR_REPOSITORY_NAME \
156
- --target ./YOUR_REPOSITORY_NAME \
157
- --resume
40
+ npx --yes @rathnasgala/cli@latest preview
158
41
  ```
159
42
 
160
- Scaffolding is designed to converge after partial failure. It will not adopt a non-empty unrelated repository.
161
-
162
- ### Build without deploying
43
+ Builds the publication and serves it locally, using the exact framework version the repository is
44
+ pinned to — so what you see is what gets published. The first run installs that tooling, which takes
45
+ a moment. Stop it with Ctrl-C.
163
46
 
164
47
  ```console
165
- npx --yes @rathnasgala/cli@latest scaffold ... --mode build-only
48
+ npx --yes @rathnasgala/cli@latest publish
166
49
  ```
167
50
 
168
- `build-only` writes and validates the site but does not provision GitHub Pages. `build-and-deploy` is the default.
169
-
170
- ## Everyday workflow
51
+ Checks your content, records it, and sends it to GitHub. GitHub builds and deploys from there; the
52
+ site updates a minute or two later.
171
53
 
172
- Create another post:
54
+ ## When something is wrong
173
55
 
174
56
  ```console
175
- npx --yes @rathnasgala/cli@latest new --title "A durable idea" --language en
57
+ npx --yes @rathnasgala/cli@latest doctor
176
58
  ```
177
59
 
178
- Validate without running a preview server:
60
+ Reports on your sign-ins, the publication folder, the publishing workflow, and anything you have
61
+ written but not sent. Each check either passes, names what is wrong and how to fix it, or says it
62
+ could not be determined — never one of those disguised as another.
179
63
 
180
- ```console
181
- npx --yes @rathnasgala/cli@latest validate
182
- ```
64
+ ## Commands
183
65
 
184
- Preview locally:
66
+ Run any command with `--help`.
185
67
 
186
- ```console
187
- npx --yes @rathnasgala/cli@latest preview
188
- ```
68
+ | Command | What it does | Options |
69
+ | --- | --- | --- |
70
+ | `init` | Create a publication and clone it here | `--name`, `--here` |
71
+ | `new` | Start a post | `--language`, `--root`, `--today` |
72
+ | `preview` | Build and serve the publication locally | `--root`, `--today` |
73
+ | `publish` | Check, record and send your work to GitHub | `--root`, `--today`, `--skip-checks` |
74
+ | `doctor` | Check a publication and say what is wrong | `--root` |
75
+ | `auth` | Sign in to Gala and GitHub | — |
189
76
 
190
- Publish:
77
+ `auth` is never a prerequisite you have to remember: any command that needs a credential obtains
78
+ one. It exists for when you want to do it deliberately — a new machine, or a different account.
191
79
 
192
- ```console
193
- npx --yes @rathnasgala/cli@latest publish
194
- ```
80
+ Every command prompts for what it needs when run in a terminal, and every prompt has an option that
81
+ supplies it instead. With no terminal attached — in CI — nothing is ever prompted for: a value that
82
+ cannot be worked out is an error naming the option, so an automated run fails immediately rather
83
+ than waiting for an answer that will not come.
195
84
 
196
- Check managed files and recorded publication state:
85
+ ## Access
197
86
 
198
- ```console
199
- npx --yes @rathnasgala/cli@latest doctor
200
- ```
87
+ The CLI signs in as the **Gala GitHub App**. It reaches only the repositories you have given that
88
+ App, and never asks for the broad `repo` scope, which would have meant read and write access to
89
+ every repository you can see.
201
90
 
202
- ## Security and ownership
91
+ Credentials are stored outside the publication, in your operating system's application-config
92
+ directory, with private file permissions. Your GitHub sign-in expires after eight hours and the CLI
93
+ asks you to sign in again rather than quietly using a credential the server will refuse.
203
94
 
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.
95
+ Git operations use that same sign-in, not whatever credential your machine happens to have
96
+ configured so publishing works on a machine where those differ, or where none is configured.
210
97
 
211
98
  ## Troubleshooting
212
99
 
213
- ### `GitHub authentication is missing`
100
+ ### `GitHub authentication expired`
214
101
 
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:
102
+ Sign in again:
224
103
 
225
104
  ```console
226
105
  npx --yes @rathnasgala/cli@latest auth
227
106
  ```
228
107
 
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.
108
+ ### Gala cannot reach the repository
239
109
 
240
- ### The target folder already exists
110
+ The Gala GitHub App is installed but has not been given this repository. The command prints a link
111
+ to the installation that needs it; add the one repository and continue. Nothing else needs granting.
241
112
 
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.
113
+ ### A post is not appearing
243
114
 
244
- ### Validation refuses a post
115
+ Run `doctor`. The most common cause is work written but never sent, which it reports along with the
116
+ command to fix it.
245
117
 
246
- The error includes the source file and violated rule. Correct the file and run:
118
+ ### Something failed and the message was not enough
247
119
 
248
120
  ```console
249
- npx --yes @rathnasgala/cli@latest validate
121
+ GALA_DEBUG=1 npx --yes @rathnasgala/cli@latest publish
250
122
  ```
251
123
 
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
- ```
124
+ Prints the full stack. Without it, failures are one line you can act on.
267
125
 
268
126
  ## Package and source
269
127
 
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.
128
+ Published as [`@rathnasgala/cli`](https://www.npmjs.com/package/@rathnasgala/cli). Source at
129
+ [rathnasgala/cli](https://github.com/rathnasgala/cli).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "0.0.21",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -13,13 +13,13 @@
13
13
  "gala": "src/index.js"
14
14
  },
15
15
  "scripts": {
16
- "test": "node --test",
16
+ "test": "node --test test/*.test.js",
17
17
  "lint": "node scripts/lint.js",
18
18
  "preversion": "npm test && npm run lint",
19
19
  "push": "node scripts/push.js"
20
20
  },
21
21
  "engines": {
22
- "node": ">=18"
22
+ "node": ">=20"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -0,0 +1,126 @@
1
+ import { request, requestJson } from './http.js';
2
+
3
+ /**
4
+ * The Gala API.
5
+ *
6
+ * Only the calls the six commands make. v0 carried a generated client covering the whole surface —
7
+ * comments, reactions, admin, moderation — none of which a CLI ever touches.
8
+ */
9
+ export const DEFAULT_API_BASE_URL = 'https://api.gala67.com';
10
+
11
+ export function galaApi({ baseUrl = DEFAULT_API_BASE_URL, token } = {}) {
12
+ const root = String(baseUrl).replace(/\/$/, '');
13
+ const authorized = (action, extra = {}) => ({
14
+ action,
15
+ ...extra,
16
+ headers: {
17
+ accept: 'application/json',
18
+ ...(token == null ? {} : { authorization: `Bearer ${token}` }),
19
+ ...extra.headers
20
+ }
21
+ });
22
+
23
+ return {
24
+ baseUrl: root,
25
+
26
+ /** Cheap authenticated call, used to find out whether a stored credential is still accepted. */
27
+ async accepted() {
28
+ try {
29
+ const response = await fetch(`${root}/v1/me/sites`, {
30
+ headers: { accept: 'application/json', authorization: `Bearer ${token}` }
31
+ });
32
+ // 403 means "not an author yet", which is a stage of the product, not a dead credential.
33
+ return response.status !== 401;
34
+ } catch {
35
+ // Offline is not an answer. Forcing a sign-in the writer does not need is worse than
36
+ // letting the real call fail with its own error.
37
+ return true;
38
+ }
39
+ },
40
+
41
+ /** Exchanges the GitHub token for the short-lived capability the GitHub-scoped routes require. */
42
+ async githubCapability(githubToken) {
43
+ const body = await requestJson(`${root}/v1/auth/github/device-authorizations`,
44
+ authorized('GitHub authorization', {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({ accessToken: githubToken })
48
+ }));
49
+ if (typeof body?.authorization !== 'string') {
50
+ throw new TypeError('GitHub authorization returned no capability');
51
+ }
52
+ return body.authorization;
53
+ },
54
+
55
+ /**
56
+ * Creates the publication repository, the same call the browser editor makes.
57
+ *
58
+ * v0 called GitHub's template endpoint itself: a second implementation with no fallback and no
59
+ * wait for the App installation to reach the result, which is why repositories the CLI created
60
+ * never appeared in the web UI.
61
+ */
62
+ createPublication({ capability, name }) {
63
+ return requestJson(`${root}/v1/auth/github/publications`,
64
+ authorized('Publication creation', {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json', 'GitHub-Authorization': capability },
67
+ body: JSON.stringify({ name })
68
+ }));
69
+ },
70
+
71
+ registerSite({ capability, idempotencyKey, repositoryOwner, repositoryName, topology, canonicalBaseUrl }) {
72
+ return requestJson(`${root}/v1/sites`,
73
+ authorized('Site registration', {
74
+ method: 'POST',
75
+ headers: {
76
+ 'content-type': 'application/json',
77
+ 'GitHub-Authorization': capability,
78
+ 'idempotency-key': idempotencyKey
79
+ },
80
+ body: JSON.stringify({ repositoryOwner, repositoryName, topology, canonicalBaseUrl })
81
+ }));
82
+ },
83
+
84
+ listPublications() {
85
+ return requestJson(`${root}/v1/me/sites`, authorized('Publication list'));
86
+ },
87
+
88
+ /** Not in the OpenAPI document, though the endpoint exists and is public. */
89
+ async signInConfiguration() {
90
+ return requestJson(`${root}/v1/auth/configuration`, { action: 'Sign-in configuration' });
91
+ },
92
+
93
+ /*
94
+ * Form-encoded, not JSON. These are RFC 8628 device-flow endpoints and the spec declares them
95
+ * as `application/x-www-form-urlencoded`; sending JSON gets a bare "Authentication is required",
96
+ * which reads as a credential problem and is nothing of the kind.
97
+ */
98
+ async startDeviceAuthorization() {
99
+ return requestJson(`${root}/v1/auth/device/code`, {
100
+ action: 'Gala sign-in',
101
+ method: 'POST',
102
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
103
+ body: new URLSearchParams({ client_id: 'gala-cli' }).toString()
104
+ });
105
+ },
106
+
107
+ /** Returns the token, or null while the writer has not finished authorizing. */
108
+ async pollDeviceAuthorization(deviceCode) {
109
+ const response = await fetch(`${root}/v1/auth/device/token`, {
110
+ method: 'POST',
111
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
112
+ body: new URLSearchParams({
113
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
114
+ device_code: deviceCode,
115
+ client_id: 'gala-cli'
116
+ }).toString()
117
+ });
118
+ const body = await response.json().catch(() => null);
119
+ if (response.ok) return body;
120
+ if (body?.error === 'authorization_pending' || body?.error === 'slow_down') return null;
121
+ throw new Error(`Gala sign-in failed: ${body?.error_description ?? body?.error ?? response.status}`);
122
+ },
123
+
124
+ request: (path, options) => request(`${root}${path}`, authorized(options?.action ?? path, options))
125
+ };
126
+ }
@@ -0,0 +1,63 @@
1
+ import { requestJson } from './http.js';
2
+
3
+ /**
4
+ * GitHub, as the Gala App.
5
+ *
6
+ * The CLI holds a GitHub App user token, not an OAuth App token. That single difference is what
7
+ * separates this from v0: an App token can list installations, is not blocked by an organisation's
8
+ * OAuth App restrictions, and reaches only repositories the App has been given — rather than every
9
+ * repository the writer can see, which is what `repo` scope meant.
10
+ */
11
+ const API = 'https://api.github.com';
12
+ const API_VERSION = '2026-03-10';
13
+
14
+ export function githubApi(token) {
15
+ const headers = {
16
+ accept: 'application/vnd.github+json',
17
+ authorization: `Bearer ${token}`,
18
+ 'x-github-api-version': API_VERSION
19
+ };
20
+
21
+ return {
22
+ /** The account behind the token. Works for every token type and needs no permission. */
23
+ async viewer() {
24
+ const body = await requestJson(`${API}/user`, { action: 'GitHub account lookup', headers });
25
+ const login = body?.login;
26
+ if (typeof login !== 'string' || login === '') {
27
+ throw new TypeError('GitHub returned an unusable account login');
28
+ }
29
+ return login;
30
+ },
31
+
32
+ /**
33
+ * Whether a repository has content yet.
34
+ *
35
+ * Creating a repository is asynchronous: GitHub answers with a clone URL before the template
36
+ * lands. Cloning into that window produces an empty checkout and a missing site.config.yml —
37
+ * a confusing error about a file the template certainly contains. `size` is not usable as the
38
+ * signal; GitHub still reported 0 for a repository that already had commits.
39
+ */
40
+ async hasContent(owner, repository) {
41
+ const branches = await requestJson(
42
+ `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=1`,
43
+ { action: 'GitHub branch lookup', headers }
44
+ );
45
+ return Array.isArray(branches) && branches.length > 0;
46
+ },
47
+
48
+ setVariable(owner, repository, name, value) {
49
+ const base = `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/variables`;
50
+ return requestJson(`${base}/${encodeURIComponent(name)}`, {
51
+ action: 'GitHub repository variable',
52
+ method: 'PATCH',
53
+ headers: { ...headers, 'content-type': 'application/json' },
54
+ body: JSON.stringify({ name, value })
55
+ }).catch(() => requestJson(base, {
56
+ action: 'GitHub repository variable',
57
+ method: 'POST',
58
+ headers: { ...headers, 'content-type': 'application/json' },
59
+ body: JSON.stringify({ name, value })
60
+ }));
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Every HTTP call the CLI makes, and every failure it reports.
3
+ *
4
+ * v0 threw away the response body at all twenty failure sites — `failed with HTTP 403` and nothing
5
+ * else. GitHub explains which 403 it is in that body and nowhere else: an organisation's OAuth App
6
+ * restrictions, a missing permission, a rename and a rate limit all arrive as 403 with a sentence
7
+ * telling them apart. Diagnosing anything meant guessing between causes the server had already
8
+ * distinguished, and it cost days.
9
+ *
10
+ * Only the response is read here. Request bodies, tokens and secrets never pass through.
11
+ */
12
+ const MAX_DETAIL = 400;
13
+
14
+ export class HttpError extends Error {
15
+ constructor(status, action, detail, code) {
16
+ super(detail === '' ? `${action} failed with HTTP ${status}` : `${action} failed: ${detail}`);
17
+ this.name = 'HttpError';
18
+ this.status = status;
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ export async function request(url, { action, ...options } = {}) {
24
+ let response;
25
+ try {
26
+ response = await fetch(url, options);
27
+ } catch (unreachable) {
28
+ throw new Error(`${action} could not reach ${new URL(url).host}: ${unreachable.message}`);
29
+ }
30
+ if (response.ok) return response;
31
+
32
+ const body = await readBody(response);
33
+ throw new HttpError(response.status, action, describe(body), body?.code);
34
+ }
35
+
36
+ export async function requestJson(url, options) {
37
+ const response = await request(url, options);
38
+ if (response.status === 204) return undefined;
39
+ return response.json();
40
+ }
41
+
42
+ async function readBody(response) {
43
+ try {
44
+ const text = await response.text();
45
+ if (text.trim() === '') return null;
46
+ try {
47
+ return JSON.parse(text);
48
+ } catch {
49
+ return { raw: text };
50
+ }
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function describe(body) {
57
+ if (body == null) return '';
58
+ const parts = [];
59
+ if (typeof body.message === 'string' && body.message.trim() !== '') parts.push(body.message.trim());
60
+ // GitHub's `errors` array carries the specific field or reason behind a generic message.
61
+ for (const error of Array.isArray(body.errors) ? body.errors : []) {
62
+ const reason = typeof error === 'string' ? error : error?.message ?? error?.code;
63
+ if (typeof reason === 'string' && reason.trim() !== '') parts.push(reason.trim());
64
+ }
65
+ if (typeof body.error_description === 'string') parts.push(body.error_description.trim());
66
+ if (parts.length === 0 && typeof body.code === 'string') parts.push(body.code);
67
+ if (parts.length === 0 && typeof body.raw === 'string') parts.push(body.raw);
68
+ if (typeof body.documentation_url === 'string') parts.push(`See ${body.documentation_url}`);
69
+
70
+ const detail = parts.join(' — ').replace(/\s+/g, ' ').trim();
71
+ return detail.length > MAX_DETAIL ? `${detail.slice(0, MAX_DETAIL)}…` : detail;
72
+ }