@rathnasgala/cli 0.0.14 → 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.14",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ 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';
@@ -44,6 +45,15 @@ function reportAndExit(failure) {
44
45
  process.exit(1);
45
46
  }
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
+
47
57
  const [command, ...args] = process.argv.slice(2);
48
58
  const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
49
59
 
@@ -77,7 +87,7 @@ if (command === 'auth') {
77
87
  const result = await authenticateGithub({
78
88
  showScopeWarning: ({ explanation }) => process.stdout.write(`GitHub authorization: ${explanation}\n`),
79
89
  showInstructions: ({ verificationUri, userCode }) => {
80
- process.stdout.write(`Open ${verificationUri}\nEnter code: ${userCode}\n`);
90
+ process.stdout.write(announce(verificationUri, userCode));
81
91
  }
82
92
  });
83
93
  process.stdout.write(`GitHub authentication stored securely with scopes: ${result.scopes.join(', ')}.\n`);
@@ -87,7 +97,7 @@ if (command === 'auth') {
87
97
  const result = await authenticateGala({
88
98
  apiBaseUrl,
89
99
  showInstructions: ({ verificationUri, userCode }) => {
90
- process.stdout.write(`Open ${verificationUri}\nEnter code: ${userCode}\n`);
100
+ process.stdout.write(announce(verificationUri, userCode));
91
101
  }
92
102
  });
93
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
+ }
@@ -7,6 +7,7 @@ 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
9
  import { galaCredentialAccepted } from './gala-credential-health.js';
10
+ import { openInBrowser } from './open-browser.js';
10
11
  import { forgetGalaCredential } from './gala-credential-store.js';
11
12
 
12
13
  export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
@@ -37,6 +38,7 @@ export async function prepareScaffold({
37
38
  ask,
38
39
  installUrl = GITHUB_APP_INSTALL_URL,
39
40
  installAttempts = 3,
41
+ openUrl = openInBrowser,
40
42
  readGala = readGalaCredential,
41
43
  readGithub = readGithubCredential,
42
44
  credentialAccepted = galaCredentialAccepted,
@@ -48,9 +50,9 @@ export async function prepareScaffold({
48
50
  apiBaseUrl = DEFAULT_API_BASE_URL
49
51
  } = {}) {
50
52
  const gala = await ensureGala({
51
- apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala
53
+ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
52
54
  });
53
- const github = await ensureGithub({ notify, readGithub, signInGithub });
55
+ const github = await ensureGithub({ notify, readGithub, signInGithub, openUrl });
54
56
 
55
57
  const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
56
58
 
@@ -69,7 +71,7 @@ export async function prepareScaffold({
69
71
  galaAccessToken: gala.accessToken,
70
72
  githubAccessToken: github.accessToken,
71
73
  owner: resolvedOwner,
72
- notify, ask, installUrl, installAttempts, resolveInstallation
74
+ notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
73
75
  });
74
76
 
75
77
  return Object.freeze({
@@ -88,7 +90,9 @@ export async function prepareScaffold({
88
90
  * out four calls later, as an opaque 401 from whichever endpoint got there first, is how a
89
91
  * "sign in again" turned into a stack trace.
90
92
  */
91
- async function ensureGala({ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala }) {
93
+ async function ensureGala({
94
+ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala, openUrl
95
+ }) {
92
96
  let stored = null;
93
97
  try {
94
98
  stored = await readGala();
@@ -110,12 +114,12 @@ async function ensureGala({ apiBaseUrl, notify, readGala, signInGala, credential
110
114
  await signInGala({
111
115
  apiBaseUrl,
112
116
  showInstructions: ({ verificationUri, userCode }) =>
113
- notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
117
+ notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
114
118
  });
115
119
  return readGala();
116
120
  }
117
121
 
118
- async function ensureGithub({ notify, readGithub, signInGithub }) {
122
+ async function ensureGithub({ notify, readGithub, signInGithub, openUrl }) {
119
123
  try {
120
124
  return await readGithub();
121
125
  } catch {
@@ -123,7 +127,7 @@ async function ensureGithub({ notify, readGithub, signInGithub }) {
123
127
  await signInGithub({
124
128
  showScopeWarning: ({ explanation }) => notify(`GitHub authorization: ${explanation}`),
125
129
  showInstructions: ({ verificationUri, userCode }) =>
126
- notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
130
+ notify(`${openUrl(verificationUri) ? 'Opened' : 'Open'} ${verificationUri}\nEnter code: ${userCode}`)
127
131
  });
128
132
  return readGithub();
129
133
  }
@@ -138,7 +142,7 @@ async function ensureGithub({ notify, readGithub, signInGithub }) {
138
142
  */
139
143
  async function ensureInstallation({
140
144
  apiBaseUrl, galaAccessToken, githubAccessToken, owner,
141
- notify, ask, installUrl, installAttempts, resolveInstallation
145
+ notify, ask, installUrl, installAttempts, resolveInstallation, openUrl
142
146
  }) {
143
147
  for (let attempt = 0; attempt < Math.max(1, installAttempts); attempt += 1) {
144
148
  const installationId = await resolveInstallation({
@@ -148,15 +152,25 @@ async function ensureInstallation({
148
152
 
149
153
  if (typeof ask !== 'function') {
150
154
  throw new Error(
151
- `The Gala GitHub App is not installed on ${owner}. Install it at ${installUrl} and run scaffold again, `
152
- + '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.'
153
157
  );
154
158
  }
155
- 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}`);
156
168
  await ask('Press enter once the App is installed. ');
157
169
  }
158
170
  throw new Error(
159
- `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.'
160
174
  );
161
175
  }
162
176