@rathnasgala/cli 0.0.15 → 0.0.17

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.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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) {
@@ -61,7 +61,8 @@ export async function registerSite({
61
61
  required(idempotencyKey, 'idempotencyKey', IDEMPOTENCY_KEY);
62
62
  required(repositoryOwner, 'repositoryOwner', REPOSITORY_PART);
63
63
  required(repositoryName, 'repositoryName', REPOSITORY_PART);
64
- if (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0) {
64
+ if (githubInstallationId != null
65
+ && (!Number.isSafeInteger(githubInstallationId) || githubInstallationId <= 0)) {
65
66
  throw new TypeError('githubInstallationId must be a positive integer');
66
67
  }
67
68
  if (!['PROVIDER_DEFAULT', 'CUSTOM_DOMAIN'].includes(topology)) {
@@ -77,7 +78,8 @@ export async function registerSite({
77
78
  'idempotency-key': idempotencyKey
78
79
  },
79
80
  body: JSON.stringify({
80
- githubInstallationId,
81
+ // Omitted rather than sent as null: the server resolves it from the owner.
82
+ ...(githubInstallationId == null ? {} : { githubInstallationId }),
81
83
  repositoryOwner,
82
84
  repositoryName,
83
85
  topology,
@@ -91,6 +93,14 @@ export async function registerSite({
91
93
  throw new Error(`GitHub App installation does not cover ${repositoryOwner}/${repositoryName}`);
92
94
  }
93
95
  if (response.status === 409) {
96
+ const failure = await response.clone().json().catch(() => null);
97
+ if (failure?.code === 'GITHUB_APP_NOT_INSTALLED') {
98
+ throw new Error(
99
+ `The Gala GitHub App is not installed on ${repositoryOwner}. Install it at `
100
+ + 'https://github.com/apps/gala67-app/installations/new for that account, then run scaffold '
101
+ + 'again.'
102
+ );
103
+ }
94
104
  throw new Error('Site registration conflicts with existing protected state; use the recovery command');
95
105
  }
96
106
  if (response.status !== 201) throw new Error(`Gala site registration failed with HTTP ${response.status}`);
@@ -1,91 +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
- export async function listAuthorizedRepositories({ apiBaseUrl, authorization, fetchImpl = fetch }) {
56
- const response = await fetchImpl(endpoint(apiBaseUrl, '/v1/auth/github/repositories'), {
57
- headers: { accept: 'application/json', 'GitHub-Authorization': authorization }
58
- });
59
- if (!response.ok) {
60
- throw new Error(`Authorized repository lookup failed with HTTP ${response.status}`);
61
- }
62
- const payload = await response.json();
63
- if (!Array.isArray(payload)) throw new TypeError('Authorized repository lookup returned no list');
64
- return payload;
65
- }
66
-
67
- /**
68
- * Returns the installation covering `owner`, or null when the App is not installed there.
69
- *
70
- * An installation belongs to an account, not to one repository, so any repository the App can
71
- * already see under that owner carries the id the new one will use. Null is an ordinary answer —
72
- * it means "not installed yet" — and the caller turns it into an instruction, not an error.
73
- */
74
- export async function resolveInstallationId({
75
- apiBaseUrl, galaAccessToken, githubAccessToken, owner, fetchImpl = fetch,
76
- exchange = exchangeGithubAuthorization, list = listAuthorizedRepositories
77
- }) {
78
- const authorization = await exchange({
79
- apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
80
- });
81
- // null means the App is not installed yet, which the caller turns into an instruction.
82
- if (authorization == null) return null;
83
- const repositories = await list({ apiBaseUrl, authorization, fetchImpl });
84
- const wanted = String(owner).toLowerCase();
85
- for (const repository of repositories) {
86
- if (String(repository?.owner).toLowerCase() !== wanted) continue;
87
- const installationId = Number(repository?.installationId);
88
- if (Number.isSafeInteger(installationId) && installationId > 0) return installationId;
89
- }
90
- return null;
91
- }