@rathnasgala/cli 0.0.13 → 0.0.14
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 +19 -0
- package/src/scaffold-preflight.js +37 -11
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
|
@@ -25,6 +25,25 @@ import { refreshEngagementSnapshot } from './refresh-command.js';
|
|
|
25
25
|
import { switchTopology } from './topology-command.js';
|
|
26
26
|
import { acquireAttributionEntitlement } from './entitlement-command.js';
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* A failed command should say what went wrong and what to do about it. Node's default for a
|
|
30
|
+
* rejected top-level await is a stack trace through node_modules, which tells a writer nothing and
|
|
31
|
+
* buries the one line that matters. The stack is still available behind GALA_DEBUG for anyone
|
|
32
|
+
* debugging the CLI itself.
|
|
33
|
+
*/
|
|
34
|
+
process.on('uncaughtException', reportAndExit);
|
|
35
|
+
process.on('unhandledRejection', reportAndExit);
|
|
36
|
+
|
|
37
|
+
function reportAndExit(failure) {
|
|
38
|
+
if (process.env.GALA_DEBUG) {
|
|
39
|
+
process.stderr.write(`${failure instanceof Error ? failure.stack : String(failure)}\n`);
|
|
40
|
+
} else {
|
|
41
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
42
|
+
process.stderr.write(`${message}\n`);
|
|
43
|
+
}
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
const [command, ...args] = process.argv.slice(2);
|
|
29
48
|
const usage = 'Usage: gala <auth|configure|entitlement|scaffold|topology|validate|new|doctor|hook|preview|publish|record-deployment|refresh|upgrade|workflow> [options]';
|
|
30
49
|
|
|
@@ -6,6 +6,8 @@ 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 { forgetGalaCredential } from './gala-credential-store.js';
|
|
9
11
|
|
|
10
12
|
export const GITHUB_APP_INSTALL_URL = 'https://github.com/apps/gala67-app/installations/new';
|
|
11
13
|
|
|
@@ -37,13 +39,17 @@ export async function prepareScaffold({
|
|
|
37
39
|
installAttempts = 3,
|
|
38
40
|
readGala = readGalaCredential,
|
|
39
41
|
readGithub = readGithubCredential,
|
|
42
|
+
credentialAccepted = galaCredentialAccepted,
|
|
43
|
+
forgetGala = forgetGalaCredential,
|
|
40
44
|
signInGala = authenticateGala,
|
|
41
45
|
signInGithub = authenticateGithub,
|
|
42
46
|
resolveLogin = resolveGithubLogin,
|
|
43
47
|
resolveInstallation = resolveInstallationId,
|
|
44
48
|
apiBaseUrl = DEFAULT_API_BASE_URL
|
|
45
49
|
} = {}) {
|
|
46
|
-
const gala = await ensureGala({
|
|
50
|
+
const gala = await ensureGala({
|
|
51
|
+
apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala
|
|
52
|
+
});
|
|
47
53
|
const github = await ensureGithub({ notify, readGithub, signInGithub });
|
|
48
54
|
|
|
49
55
|
const resolvedOwner = owner ?? await resolveLogin({ accessToken: github.accessToken });
|
|
@@ -74,19 +80,39 @@ export async function prepareScaffold({
|
|
|
74
80
|
});
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
83
|
+
/**
|
|
84
|
+
* A missing, expired or refused credential is a step to take, not an error to report.
|
|
85
|
+
*
|
|
86
|
+
* The stored file is checked against the server before anything depends on it, because a
|
|
87
|
+
* credential that parses and has not expired can still be one the API refuses — and finding that
|
|
88
|
+
* out four calls later, as an opaque 401 from whichever endpoint got there first, is how a
|
|
89
|
+
* "sign in again" turned into a stack trace.
|
|
90
|
+
*/
|
|
91
|
+
async function ensureGala({ apiBaseUrl, notify, readGala, signInGala, credentialAccepted, forgetGala }) {
|
|
92
|
+
let stored = null;
|
|
79
93
|
try {
|
|
80
|
-
|
|
94
|
+
stored = await readGala();
|
|
81
95
|
} catch {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})
|
|
88
|
-
|
|
96
|
+
stored = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (stored != null) {
|
|
100
|
+
const base = stored.apiBaseUrl ?? apiBaseUrl;
|
|
101
|
+
if (await credentialAccepted({ apiBaseUrl: base, accessToken: stored.accessToken })) {
|
|
102
|
+
return stored;
|
|
103
|
+
}
|
|
104
|
+
// Leaving it on disk would make every later command repeat this discovery.
|
|
105
|
+
await forgetGala();
|
|
106
|
+
notify('Your Gala sign-in is no longer valid.');
|
|
89
107
|
}
|
|
108
|
+
|
|
109
|
+
notify('Signing in to Gala.');
|
|
110
|
+
await signInGala({
|
|
111
|
+
apiBaseUrl,
|
|
112
|
+
showInstructions: ({ verificationUri, userCode }) =>
|
|
113
|
+
notify(`Open ${verificationUri}\nEnter code: ${userCode}`)
|
|
114
|
+
});
|
|
115
|
+
return readGala();
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
async function ensureGithub({ notify, readGithub, signInGithub }) {
|