@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 +1 -1
- package/src/gala-credential-health.js +34 -0
- package/src/gala-credential-store.js +11 -0
- package/src/gala-installation-client.js +16 -0
- package/src/index.js +31 -2
- package/src/open-browser.js +40 -0
- package/src/scaffold-preflight.js +60 -20
package/package.json
CHANGED
|
@@ -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(
|
|
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(
|
|
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({
|
|
47
|
-
|
|
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
|
-
/**
|
|
78
|
-
|
|
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
|
-
|
|
98
|
+
stored = await readGala();
|
|
81
99
|
} catch {
|
|
82
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
|