@rathnasgala/cli 0.0.13 → 0.0.15

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.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Whether a stored Gala credential is one the server will still accept.
3
+ *
4
+ * `readGalaCredential` can only check what is written in the file — schema and expiry — and a
5
+ * credential can satisfy both while the API refuses it outright. It happened: the API stopped
6
+ * putting a `tenant` claim in its tokens and now rejects any token that still carries one
7
+ * (Rs256JwtCodec: "Legacy tenant-bearing token requires reauthentication"). Tokens minted before
8
+ * that change have a month-long expiry, so every command using one sent a bearer the server had
9
+ * already decided to refuse, and reported it as whatever call happened to fail first.
10
+ *
11
+ * Expiry is not the only way a credential dies. It can be revoked, the signing key can rotate, the
12
+ * claim set can change again. So this does not special-case the `tenant` claim: it asks the server,
13
+ * once, and treats 401 as "this credential is finished" — which is true whatever the reason.
14
+ */
15
+ const PROBE_PATH = '/v1/me/sites';
16
+
17
+ /**
18
+ * Answers whether the API still accepts this credential.
19
+ *
20
+ * A network failure is deliberately not an answer: refusing to run because the machine is briefly
21
+ * offline, or forcing a sign-in the writer does not need, are both worse than letting the real
22
+ * call fail with its own error.
23
+ */
24
+ export async function galaCredentialAccepted({ apiBaseUrl, accessToken, fetchImpl = fetch }) {
25
+ let response;
26
+ try {
27
+ response = await fetchImpl(`${String(apiBaseUrl).replace(/\/$/, '')}${PROBE_PATH}`, {
28
+ headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` }
29
+ });
30
+ } catch {
31
+ return true;
32
+ }
33
+ return response.status !== 401;
34
+ }
@@ -102,3 +102,14 @@ export async function readGalaCredential({ target = galaCredentialPath(), now =
102
102
  expiresAt
103
103
  });
104
104
  }
105
+
106
+ /**
107
+ * Removes a credential the server no longer accepts.
108
+ *
109
+ * Leaving a refused token on disk means every later command rediscovers that it is refused, and
110
+ * `readGalaCredential` cannot tell the difference — the file is well-formed and unexpired. Deleting
111
+ * it is what makes the next run ask for a sign-in instead of failing again.
112
+ */
113
+ export async function forgetGalaCredential({ target = galaCredentialPath() } = {}) {
114
+ await rm(target, { force: true });
115
+ }
@@ -28,6 +28,20 @@ export async function exchangeGithubAuthorization({
28
28
  },
29
29
  body: JSON.stringify({ accessToken: githubAccessToken })
30
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
+ }
31
45
  if (!response.ok) {
32
46
  throw new Error(`GitHub authorization exchange failed with HTTP ${response.status}`);
33
47
  }
@@ -64,6 +78,8 @@ export async function resolveInstallationId({
64
78
  const authorization = await exchange({
65
79
  apiBaseUrl, galaAccessToken, githubAccessToken, fetchImpl
66
80
  });
81
+ // null means the App is not installed yet, which the caller turns into an instruction.
82
+ if (authorization == null) return null;
67
83
  const repositories = await list({ apiBaseUrl, authorization, fetchImpl });
68
84
  const wanted = String(owner).toLowerCase();
69
85
  for (const repository of repositories) {
package/src/index.js CHANGED
@@ -20,11 +20,40 @@ 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 { openInBrowser } from './open-browser.js';
23
24
  import { prepareScaffold } from './scaffold-preflight.js';
24
25
  import { refreshEngagementSnapshot } from './refresh-command.js';
25
26
  import { switchTopology } from './topology-command.js';
26
27
  import { acquireAttributionEntitlement } from './entitlement-command.js';
27
28
 
29
+ /*
30
+ * A failed command should say what went wrong and what to do about it. Node's default for a
31
+ * rejected top-level await is a stack trace through node_modules, which tells a writer nothing and
32
+ * buries the one line that matters. The stack is still available behind GALA_DEBUG for anyone
33
+ * debugging the CLI itself.
34
+ */
35
+ process.on('uncaughtException', reportAndExit);
36
+ process.on('unhandledRejection', reportAndExit);
37
+
38
+ function reportAndExit(failure) {
39
+ if (process.env.GALA_DEBUG) {
40
+ process.stderr.write(`${failure instanceof Error ? failure.stack : String(failure)}\n`);
41
+ } else {
42
+ const message = failure instanceof Error ? failure.message : String(failure);
43
+ process.stderr.write(`${message}\n`);
44
+ }
45
+ process.exit(1);
46
+ }
47
+
48
+ /**
49
+ * Opens the page and says so, or falls back to asking for it to be opened by hand. The URL is
50
+ * printed either way — it is the thing the writer may need to move to another device.
51
+ */
52
+ function announce(verificationUri, userCode) {
53
+ const opened = openInBrowser(verificationUri);
54
+ return `${opened ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}\n`;
55
+ }
56
+
28
57
  const [command, ...args] = process.argv.slice(2);
29
58
  const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
30
59
 
@@ -58,7 +87,7 @@ if (command === 'auth') {
58
87
  const result = await authenticateGithub({
59
88
  showScopeWarning: ({ explanation }) => process.stdout.write(`GitHub authorization: ${explanation}\n`),
60
89
  showInstructions: ({ verificationUri, userCode }) => {
61
- process.stdout.write(`Open ${verificationUri}\nEnter code: ${userCode}\n`);
90
+ process.stdout.write(announce(verificationUri, userCode));
62
91
  }
63
92
  });
64
93
  process.stdout.write(`GitHub authentication stored securely with scopes: ${result.scopes.join(', ')}.\n`);
@@ -68,7 +97,7 @@ if (command === 'auth') {
68
97
  const result = await authenticateGala({
69
98
  apiBaseUrl,
70
99
  showInstructions: ({ verificationUri, userCode }) => {
71
- process.stdout.write(`Open ${verificationUri}\nEnter code: ${userCode}\n`);
100
+ process.stdout.write(announce(verificationUri, userCode));
72
101
  }
73
102
  });
74
103
  process.stdout.write(`Gala authentication stored securely until ${result.expiresAt.toISOString()}.\n`);
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Opens a URL in the writer's browser, best effort.
5
+ *
6
+ * Printing "Open https://…" and a code asks someone to copy a URL out of a terminal by hand, three
7
+ * times over in one scaffold. The URL is still always printed — this only saves the copying, and it
8
+ * has to keep working when it cannot: over SSH, in a container, in CI, on a machine with no browser
9
+ * at all. So nothing here is allowed to fail the command.
10
+ *
11
+ * Deliberately not attempted when there is no terminal. A CI job that silently spawns a browser
12
+ * process is a hang waiting to happen, and there is nobody there to look at it.
13
+ */
14
+ export function openInBrowser(url, {
15
+ platform = process.platform,
16
+ environment = process.env,
17
+ interactive = process.stdin.isTTY === true,
18
+ spawnProcess = spawn
19
+ } = {}) {
20
+ if (!interactive) return false;
21
+ // Respected by convention across CLI tooling, and the escape hatch for anyone who does not want
22
+ // their browser taken over.
23
+ if (environment.GALA_NO_BROWSER || environment.CI || environment.NO_BROWSER) return false;
24
+ if (!/^https:\/\//.test(url)) return false;
25
+
26
+ const [command, args] = platform === 'darwin' ? ['open', [url]]
27
+ : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
28
+ : ['xdg-open', [url]];
29
+
30
+ try {
31
+ const child = spawnProcess(command, args, { stdio: 'ignore', detached: true, shell: false });
32
+ // Without this the CLI waits for the browser to exit before it can finish.
33
+ child.unref?.();
34
+ // A missing opener is an ordinary outcome on a headless box, not something to report.
35
+ child.on?.('error', () => {});
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
@@ -6,6 +6,9 @@ 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
8
  import { resolveInstallationId } from './gala-installation-client.js';
9
+ import { galaCredentialAccepted } from './gala-credential-health.js';
10
+ import { openInBrowser } from './open-browser.js';
11
+ import { forgetGalaCredential } from './gala-credential-store.js';
9
12
 
10
13
  export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
11
14
 
@@ -35,16 +38,21 @@ export async function prepareScaffold({
35
38
  ask,
36
39
  installUrl = GITHUB_APP_INSTALL_URL,
37
40
  installAttempts = 3,
41
+ openUrl = openInBrowser,
38
42
  readGala = readGalaCredential,
39
43
  readGithub = readGithubCredential,
44
+ credentialAccepted = galaCredentialAccepted,
45
+ forgetGala = forgetGalaCredential,
40
46
  signInGala = authenticateGala,
41
47
  signInGithub = authenticateGithub,
42
48
  resolveLogin = resolveGithubLogin,
43
49
  resolveInstallation = resolveInstallationId,
44
50
  apiBaseUrl = DEFAULT_API_BASE_URL
45
51
  } = {}) {
46
- const gala = await ensureGala({ apiBaseUrl, notify, readGala, signInGala });
47
- const github = await ensureGithub({ notify, readGithub, signInGithub });
52
+ const gala = await ensureGala({
53
+ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
54
+ });
55
+ const github = await ensureGithub({ notify, readGithub, signInGithub, openUrl });
48
56
 
49
57
  const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
50
58
 
@@ -63,7 +71,7 @@ export async function prepareScaffold({
63
71
  galaAccessToken: gala.accessToken,
64
72
  githubAccessToken: github.accessToken,
65
73
  owner: resolvedOwner,
66
- notify, ask, installUrl, installAttempts, resolveInstallation
74
+ notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
67
75
  });
68
76
 
69
77
  return Object.freeze({
@@ -74,22 +82,44 @@ export async function prepareScaffold({
74
82
  });
75
83
  }
76
84
 
77
- /** A missing or expired credential is a step to take, not an error to report. */
78
- async function ensureGala({ apiBaseUrl, notify, readGala, signInGala }) {
85
+ /**
86
+ * A missing, expired or refused credential is a step to take, not an error to report.
87
+ *
88
+ * The stored file is checked against the server before anything depends on it, because a
89
+ * credential that parses and has not expired can still be one the API refuses — and finding that
90
+ * out four calls later, as an opaque 401 from whichever endpoint got there first, is how a
91
+ * "sign in again" turned into a stack trace.
92
+ */
93
+ async function ensureGala({
94
+ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
95
+ }) {
96
+ let stored = null;
79
97
  try {
80
- return await readGala();
98
+ stored = await readGala();
81
99
  } catch {
82
- notify('Signing in to Gala.');
83
- await signInGala({
84
- apiBaseUrl,
85
- showInstructions: ({ verificationUri, userCode }) =>
86
- notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
87
- });
88
- return readGala();
100
+ stored = null;
89
101
  }
102
+
103
+ if (stored != null) {
104
+ const base = stored.apiBaseUrl ?? apiBaseUrl;
105
+ if (await credentialAccepted({ apiBaseUrl: base, accessToken: stored.accessToken })) {
106
+ return stored;
107
+ }
108
+ // Leaving it on disk would make every later command repeat this discovery.
109
+ await forgetGala();
110
+ notify('Your Gala sign-in is no longer valid.');
111
+ }
112
+
113
+ notify('Signing in to Gala.');
114
+ await signInGala({
115
+ apiBaseUrl,
116
+ showInstructions: ({ verificationUri, userCode }) =>
117
+ notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
118
+ });
119
+ return readGala();
90
120
  }
91
121
 
92
- async function ensureGithub({ notify, readGithub, signInGithub }) {
122
+ async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
93
123
  try {
94
124
  return await readGithub();
95
125
  } catch {
@@ -97,7 +127,7 @@ async function ensureGithub({ notify, readGithub, signInGithub }) {
97
127
  await signInGithub({
98
128
  showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
99
129
  showInstructions: ({ verificationUri, userCode }) =>
100
- notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
130
+ notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
101
131
  });
102
132
  return readGithub();
103
133
  }
@@ -112,7 +142,7 @@ async function ensureGithub({ notify, readGithub, signInGithub }) {
112
142
  */
113
143
  async function ensureInstallation({
114
144
  apiBaseUrl, galaAccessToken, githubAccessToken, owner,
115
- notify, ask, installUrl, installAttempts, resolveInstallation
145
+ notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
116
146
  }) {
117
147
  for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
118
148
  const installationId = await resolveInstallation({
@@ -122,15 +152,25 @@ async function ensureInstallation({
122
152
 
123
153
  if (typeof ask !== 'function') {
124
154
  throw new Error(
125
- `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run scaffold again, `
126
- + 'or pass --installation-id explicitly.'
155
+ `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run `
156
+ + 'scaffold again, or pass --installation-id explicitly.'
127
157
  );
128
158
  }
129
- notify(`The Gala GitHub App is not installed on ${owner} yet.\nOpen ${installUrl}`);
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}`);
130
168
  await ask('Press enter once the App is installed. ');
131
169
  }
132
170
  throw new Error(
133
- `The Gala GitHub App still does not cover ${owner}. Install it at ${installUrl}, then run scaffold again.`
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.'
134
174
  );
135
175
  }
136
176