@rathnasgala/cli 0.0.16 → 0.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala/cli",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { describeHttpFailure } from './http-failure.js';
2
3
 
3
4
  const API_VERSION = '2026-03-10';
4
5
 
@@ -11,7 +12,7 @@ export async function verifyEmptyRepository({ owner, repository, accessToken, fe
11
12
  const response = await fetchImpl(repositoryUrl, {
12
13
  headers
13
14
  });
14
- if (!response.ok) throw new Error(`GitHub repository lookup failed with HTTP ${response.status}`);
15
+ if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub repository lookup'));
15
16
  const payload = await response.json();
16
17
  if (payload.full_name?.toLowerCase() !== `${owner}/${repository}`.toLowerCase()) {
17
18
  throw new TypeError('GitHub returned an unexpected repository');
@@ -21,7 +22,7 @@ export async function verifyEmptyRepository({ owner, repository, accessToken, fe
21
22
  ...headers
22
23
  }
23
24
  });
24
- if (!branchesResponse.ok) throw new Error(`GitHub branch lookup failed with HTTP ${branchesResponse.status}`);
25
+ if (!branchesResponse.ok) throw new Error(await describeHttpFailure(branchesResponse, 'GitHub branch lookup'));
25
26
  const branches = await branchesResponse.json();
26
27
  if (payload.size !== 0 || !Array.isArray(branches) || branches.length !== 0) {
27
28
  throw new Error('Existing repository is not empty; explicit non-empty adoption is not implemented');
@@ -1,3 +1,4 @@
1
+ import { describeHttpFailure } from './http-failure.js';
1
2
  const GITHUB_API_VERSION = '2026-03-10';
2
3
 
3
4
  /**
@@ -19,7 +20,7 @@ export async function resolveGithubLogin({ accessToken, fetchImpl = fetch }) {
19
20
  'x-github-api-version': GITHUB_API_VERSION
20
21
  }
21
22
  });
22
- if (!response.ok) throw new Error(`GitHub account lookup failed with HTTP ${response.status}`);
23
+ if (!response.ok) throw new Error(await describeHttpFailure(response, 'GitHub account lookup'));
23
24
  const payload = await response.json();
24
25
  const login = payload?.login;
25
26
  // The same shape `scaffold` demands of `--owner`. Refusing here beats a confusing failure four
@@ -1,3 +1,4 @@
1
+ import { describeHttpFailure } from './http-failure.js';
1
2
  const GITHUB_API_VERSION = '2026-03-10';
2
3
  const SEGMENT = /^[A-Za-z0-9_.-]+$/;
3
4
  const SHA = /^[0-9a-f]{40}$/;
@@ -19,7 +20,7 @@ function headers(accessToken) {
19
20
  }
20
21
 
21
22
  async function json(response, operation) {
22
- if (!response.ok) throw new Error(`GitHub ${operation} failed with HTTP ${response.status}`);
23
+ if (!response.ok) throw new Error(await describeHttpFailure(response, `GitHub ${operation}`));
23
24
  return response.json();
24
25
  }
25
26
 
@@ -86,7 +87,7 @@ export async function provisionGithubPages({
86
87
  return Object.freeze({ created: false, url: configuration.html_url, runUrl: run.html_url });
87
88
  }
88
89
  if (current.status !== 404) {
89
- throw new Error(`GitHub Pages configuration request failed with HTTP ${current.status}`);
90
+ throw new Error(await describeHttpFailure(current, 'GitHub Pages configuration request'));
90
91
  }
91
92
  const created = await fetchImpl(`${repositoryUrl}/pages`, {
92
93
  method: 'POST', headers: requestHeaders,
@@ -1,3 +1,4 @@
1
+ import { describeHttpFailure } from './http-failure.js';
1
2
  const GITHUB_API_VERSION = '2026-03-10';
2
3
  const OWNER_OR_REPOSITORY = /^[A-Za-z0-9_.-]+$/;
3
4
  const VARIABLE_NAME = /^[A-Z_][A-Z0-9_]*$/;
@@ -42,7 +43,7 @@ export async function installRepositoryVariable({
42
43
  });
43
44
  if (update.ok) return;
44
45
  if (update.status !== 404) {
45
- throw new Error(`GitHub repository variable update failed with HTTP ${update.status}`);
46
+ throw new Error(await describeHttpFailure(update, 'GitHub repository variable update'));
46
47
  }
47
48
  const create = await fetchImpl(baseUrl, {
48
49
  method: 'POST',
@@ -50,6 +51,6 @@ export async function installRepositoryVariable({
50
51
  body: JSON.stringify({ name: normalizedName, value })
51
52
  });
52
53
  if (!create.ok) {
53
- throw new Error(`GitHub repository variable creation failed with HTTP ${create.status}`);
54
+ throw new Error(await describeHttpFailure(create, 'GitHub repository variable creation'));
54
55
  }
55
56
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
+ import { describeHttpFailure } from './http-failure.js';
3
4
 
4
5
  const GITHUB_API_VERSION = '2026-03-10';
5
6
  const REPOSITORY_IDENTITY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -57,7 +58,7 @@ export async function generateRepositoryFromTemplate({
57
58
  }
58
59
  );
59
60
  if (response.status !== 201) {
60
- throw new Error(`GitHub template generation failed with HTTP ${response.status}`);
61
+ throw new Error(await describeHttpFailure(response, 'GitHub template generation'));
61
62
  }
62
63
  const payload = await response.json();
63
64
  if (payload == null || Array.isArray(payload) || typeof payload !== 'object') {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What the server actually said, instead of only what it scored.
3
+ *
4
+ * Every failure in this CLI reported `failed with HTTP 403` and discarded the response body. GitHub
5
+ * puts the reason there and nowhere else — an organisation's OAuth App restrictions, a missing
6
+ * scope, a rename, a rate limit all arrive as 403 with a sentence explaining which — so the one
7
+ * fact worth having was the one being thrown away. Diagnosing anything meant guessing between
8
+ * causes the server had already distinguished.
9
+ *
10
+ * Only the response is read. Request bodies, tokens and secrets never pass through here.
11
+ */
12
+ const MAX_DETAIL = 400;
13
+
14
+ export async function describeHttpFailure(response, action) {
15
+ const status = response?.status ?? 0;
16
+ let detail = '';
17
+ try {
18
+ // Cloned so a caller that also reads the body still can; a response whose body is already
19
+ // consumed simply yields no detail rather than a second failure on top of the first.
20
+ const text = await (typeof response.clone === 'function' ? response.clone() : response).text();
21
+ detail = extract(text);
22
+ } catch {
23
+ detail = '';
24
+ }
25
+ return `${action} failed with HTTP ${status}${detail === '' ? '' : `: ${detail}`}`;
26
+ }
27
+
28
+ function extract(text) {
29
+ if (typeof text !== 'string' || text.trim() === '') return '';
30
+ let payload;
31
+ try {
32
+ payload = JSON.parse(text);
33
+ } catch {
34
+ return truncate(text);
35
+ }
36
+ if (payload == null || typeof payload !== 'object') return truncate(text);
37
+ const parts = [];
38
+ if (typeof payload.message === 'string' && payload.message.trim() !== '') parts.push(payload.message.trim());
39
+ // GitHub's `errors` array carries the specific field or reason behind a generic message.
40
+ if (Array.isArray(payload.errors)) {
41
+ for (const error of payload.errors) {
42
+ const reason = typeof error === 'string' ? error : error?.message ?? error?.code;
43
+ if (typeof reason === 'string' && reason.trim() !== '') parts.push(reason.trim());
44
+ }
45
+ }
46
+ if (typeof payload.error_description === 'string') parts.push(payload.error_description.trim());
47
+ if (typeof payload.code === 'string' && parts.length === 0) parts.push(payload.code);
48
+ if (typeof payload.documentation_url === 'string') parts.push(`See ${payload.documentation_url}`);
49
+ return truncate(parts.length === 0 ? text : parts.join(' — '));
50
+ }
51
+
52
+ function truncate(value) {
53
+ const flattened = value.replace(/\s+/g, ' ').trim();
54
+ return flattened.length > MAX_DETAIL ? `${flattened.slice(0, MAX_DETAIL)}…` : flattened;
55
+ }
@@ -5,7 +5,6 @@ import { authenticateGithub } from './github-auth-command.js';
5
5
  import { readGalaCredential } from './gala-credential-store.js';
6
6
  import { readGithubCredential } from './github-credential-store.js';
7
7
  import { resolveGithubLogin } from './github-identity.js';
8
- import { resolveInstallationId } from './gala-installation-client.js';
9
8
  import { galaCredentialAccepted } from './gala-credential-health.js';
10
9
  import { openInBrowser } from './open-browser.js';
11
10
  import { forgetGalaCredential } from './gala-credential-store.js';
@@ -36,8 +35,6 @@ export async function prepareScaffold({
36
35
  cwd = process.cwd(),
37
36
  notify = () => {},
38
37
  ask,
39
- installUrl = GITHUB_APP_INSTALL_URL,
40
- installAttempts = 3,
41
38
  openUrl = openInBrowser,
42
39
  readGala = readGalaCredential,
43
40
  readGithub = readGithubCredential,
@@ -46,7 +43,6 @@ export async function prepareScaffold({
46
43
  signInGala = authenticateGala,
47
44
  signInGithub = authenticateGithub,
48
45
  resolveLogin = resolveGithubLogin,
49
- resolveInstallation = resolveInstallationId,
50
46
  apiBaseUrl = DEFAULT_API_BASE_URL
51
47
  } = {}) {
52
48
  const gala = await ensureGala({
@@ -65,20 +61,18 @@ export async function prepareScaffold({
65
61
  // directory the writer is standing in. Everywhere else the repository names its own folder.
66
62
  const resolvedTarget = target ?? `./${resolvedRepository}`;
67
63
 
68
- const resolvedInstallation = githubInstallationId
69
- ?? await ensureInstallation({
70
- apiBaseUrl: gala.apiBaseUrl ?? apiBaseUrl,
71
- galaAccessToken: gala.accessToken,
72
- githubAccessToken: github.accessToken,
73
- owner: resolvedOwner,
74
- notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
75
- });
76
-
64
+ /*
65
+ * No installation lookup. The id is an internal GitHub identifier for an App the server owns, and
66
+ * nothing here can discover it reliably: a GitHub App token could list installations and the CLI
67
+ * cannot hold one, while the repository inventory only carries the id once a repository exists —
68
+ * which, in the flow that creates the first repository, is never. The server resolves it during
69
+ * registration. `--installation-id` still overrides, for an account with several.
70
+ */
77
71
  return Object.freeze({
78
72
  owner: resolvedOwner,
79
73
  repository: resolvedRepository,
80
74
  target: resolvedTarget,
81
- githubInstallationId: resolvedInstallation
75
+ githubInstallationId: githubInstallationId ?? null
82
76
  });
83
77
  }
84
78
 
@@ -133,47 +127,6 @@ async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
133
127
  }
134
128
  }
135
129
 
136
- /**
137
- * Confirms the Gala GitHub App is installed, walking the writer through installing it if not.
138
- *
139
- * This is the step that used to be a manual detour through GitHub's settings to copy a number out
140
- * of a redirect URL. The loop is what makes it a step rather than a failure: the writer installs
141
- * the App in the browser, comes back, presses enter, and the run continues.
142
- */
143
- async function ensureInstallation({
144
- apiBaseUrl, galaAccessToken, githubAccessToken, owner,
145
- notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
146
- }) {
147
- for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
148
- const installationId = await resolveInstallation({
149
- apiBaseUrl, galaAccessToken, githubAccessToken, owner
150
- });
151
- if (installationId != null) return installationId;
152
-
153
- if (typeof ask !== 'function') {
154
- throw new Error(
155
- `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run `
156
- + 'scaffold again, or pass --installation-id explicitly.'
157
- );
158
- }
159
-
160
- // An installation belongs to one account. Installing it on a personal account when the
161
- // publication is meant for an organisation looks like it worked and changes nothing here, so
162
- // the account being checked is named every time rather than assumed.
163
- notify(attempt === 0
164
- ? `The Gala GitHub App is not installed on ${owner} yet.`
165
- : `Still not seeing the App on ${owner}. Check that you installed it on ${owner} itself `
166
- + 'and not on another account or organisation you belong to.');
167
- notify(`${openUrl(installUrl) ? 'Opened' : 'Open'} ${installUrl}`);
168
- await ask('Press enter once the App is installed. ');
169
- }
170
- throw new Error(
171
- `The Gala GitHub App still does not cover ${owner}. Install it at ${installUrl} for ${owner} `
172
- + 'specifically, then run scaffold again. If the App is installed under a different account, '
173
- + 'pass --owner for that account, or --installation-id to name the installation directly.'
174
- );
175
- }
176
-
177
130
  /** GitHub repository names allow letters, digits, dot, underscore and hyphen, and nothing else. */
178
131
  export function repositoryNameFrom(siteName) {
179
132
  if (typeof siteName !== 'string') return null;
@@ -62,7 +62,10 @@ export async function scaffoldSite({
62
62
  const repositoryOwner = segment(owner, 'owner');
63
63
  const repositoryName = segment(repository, 'repository');
64
64
  const location = registrationLocation(repositoryOwner, topology, canonicalBaseUrl);
65
- if (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0) {
65
+ // Optional: the server resolves the installation from the owner when none is supplied. An
66
+ // explicit value is still validated, because a wrong one fails much later and less clearly.
67
+ if (githubInstallationId != null
68
+ && (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
66
69
  throw new TypeError('githubInstallationId must be a positive integer');
67
70
  }
68
71
  if (target == null || path.resolve(target) === path.parse(path.resolve(target)).root) {
@@ -1,3 +1,4 @@
1
+ import { describeHttpFailure } from './http-failure.js';
1
2
  const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
2
3
  const REPOSITORY_PART = /^[A-Za-z0-9_.-]+$/;
3
4
  const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/;
@@ -34,7 +35,7 @@ async function authorizeGitHub({ apiBaseUrl, galaAccessToken, githubAccessToken,
34
35
  throw new Error('GitHub or Gala authentication expired; run `gala auth` again');
35
36
  }
36
37
  if (response.status !== 200) {
37
- throw new Error(`GitHub repository authorization failed with HTTP ${response.status}`);
38
+ throw new Error(await describeHttpFailure(response, 'GitHub repository authorization'));
38
39
  }
39
40
  const payload = await response.json();
40
41
  return required(payload?.authorization, 'GitHub authorization', /^[A-Za-z0-9_-]{43}$/);
@@ -61,7 +62,8 @@ export async function registerSite({
61
62
  required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
62
63
  required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
63
64
  required(repositoryName, 'repositoryName', REPOSITORY_PART);
64
- if (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0) {
65
+ if (githubInstallationId != null
66
+ && (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
65
67
  throw new TypeError('githubInstallationId must be a positive integer');
66
68
  }
67
69
  if (!['PROVIDER_DEFAULT', 'CUSTOM_DOMAIN'].includes(topology)) {
@@ -77,7 +79,8 @@ export async function registerSite({
77
79
  'idempotency-key': idempotencyKey
78
80
  },
79
81
  body: JSON.stringify({
80
- githubInstallationId,
82
+ // Omitted rather than sent as null: the server resolves it from the owner.
83
+ ...(githubInstallationId == null ? {} : { githubInstallationId }),
81
84
  repositoryOwner,
82
85
  repositoryName,
83
86
  topology,
@@ -91,9 +94,17 @@ export async function registerSite({
91
94
  throw new Error(`GitHub App installation does not cover ${repositoryOwner}/${repositoryName}`);
92
95
  }
93
96
  if (response.status === 409) {
97
+ const failure = await response.clone().json().catch(() => null);
98
+ if (failure?.code === 'GITHUB_APP_NOT_INSTALLED') {
99
+ throw new Error(
100
+ `The Gala GitHub App is not installed on ${repositoryOwner}. Install it at `
101
+ + 'https://github.com/apps/gala67-app/installations/new for that account, then run scaffold '
102
+ + 'again.'
103
+ );
104
+ }
94
105
  throw new Error('Site registration conflicts with existing protected state; use the recovery command');
95
106
  }
96
- if (response.status !== 201) throw new Error(`Gala site registration failed with HTTP ${response.status}`);
107
+ if (response.status !== 201) throw new Error(await describeHttpFailure(response, 'Gala site registration'));
97
108
  const payload = await response.json();
98
109
  if (!ULID.test(payload?.siteId) || typeof payload.siteSecret !== 'string' || payload.siteSecret === '') {
99
110
  throw new TypeError('Gala site registration response is invalid');
@@ -1,105 +0,0 @@
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.status === 409) {
32
- // The API distinguishes "the App is not installed on this account" from "your credential is
33
- // finished". Only the first is something the writer can fix in a browser, so it is signalled
34
- // rather than thrown: the caller offers the installation page and waits.
35
- return null;
36
- }
37
- if (response.status === 401) {
38
- // Either credential can be the one at fault and the caller cannot tell them apart, so say so
39
- // rather than printing a status code the writer has no way to interpret.
40
- throw new Error(
41
- 'Gala refused the GitHub authorization. Run `npx --yes @rathnasgala/cli@latest auth` and '
42
- + '`auth github` again, then retry.'
43
- );
44
- }
45
- if (!response.ok) {
46
- throw new Error(`GitHub authorization exchange failed with HTTP ${response.status}`);
47
- }
48
- const payload = await response.json();
49
- if (typeof payload?.authorization !== 'string') {
50
- throw new TypeError('GitHub authorization exchange returned no capability');
51
- }
52
- return payload.authorization;
53
- }
54
-
55
- /**
56
- * Both credentials are required, and they are not interchangeable.
57
- *
58
- * The endpoint is `.authenticated()` and its handler takes the Gala principal, so the bearer says
59
- * who is asking; `GitHub-Authorization` is the short-lived capability that says what they may see.
60
- * Sending only the capability gets a bare 401 from the security filter, before any handler runs —
61
- * which reads as "your GitHub authorization was refused" and is nothing of the kind.
62
- */
63
- export async function listAuthorizedRepositories({
64
- apiBaseUrl, authorization, galaAccessToken, fetchImpl = fetch
65
- }) {
66
- const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/repositories'), {
67
- headers: {
68
- accept: 'application/json',
69
- authorization: `Bearer ${galaAccessToken}`,
70
- 'GitHub-Authorization': authorization
71
- }
72
- });
73
- if (!response.ok) {
74
- throw new Error(`Authorized repository lookup failed with HTTP ${response.status}`);
75
- }
76
- const payload = await response.json();
77
- if (!Array.isArray(payload)) throw new TypeError('Authorized repository lookup returned no list');
78
- return payload;
79
- }
80
-
81
- /**
82
- * Returns the installation covering `owner`, or null when the App is not installed there.
83
- *
84
- * An installation belongs to an account, not to one repository, so any repository the App can
85
- * already see under that owner carries the id the new one will use. Null is an ordinary answer —
86
- * it means "not installed yet" — and the caller turns it into an instruction, not an error.
87
- */
88
- export async function resolveInstallationId({
89
- apiBaseUrl, galaAccessToken, githubAccessToken, owner, fetchImpl = fetch,
90
- exchange = exchangeGithubAuthorization, list = listAuthorizedRepositories
91
- }) {
92
- const authorization = await exchange({
93
- apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
94
- });
95
- // null means the App is not installed yet, which the caller turns into an instruction.
96
- if (authorization == null) return null;
97
- const repositories = await list({ apiBaseUrl, authorization, galaAccessToken, fetchImpl });
98
- const wanted = String(owner).toLowerCase();
99
- for (const repository of repositories) {
100
- if (String(repository?.owner).toLowerCase() !== wanted) continue;
101
- const installationId = Number(repository?.installationId);
102
- if (Number.isSafeInteger(installationId) && installationId > 0) return installationId;
103
- }
104
- return null;
105
- }