gigabuddy 0.0.1 → 0.1.0
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/LICENSE +21 -0
- package/bin/cli.js +332 -18
- package/index.js +319 -14
- package/package.json +4 -3
- package/src/lib/commands.d.ts +8 -10
- package/src/lib/doctor.d.ts +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gigabuddy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin/cli.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// libs/gigabuddy/src/bin/cli.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
5
5
|
import { dirname, join as join3 } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
|
|
9
|
+
// libs/gigabuddy/src/lib/commands.ts
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
12
|
+
|
|
9
13
|
// libs/client-core/dist/lib/credentials.js
|
|
10
14
|
import * as fs from "node:fs";
|
|
11
15
|
import * as os from "node:os";
|
|
@@ -57,6 +61,17 @@ async function openBrowser(url) {
|
|
|
57
61
|
function sleep(ms) {
|
|
58
62
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
63
|
}
|
|
64
|
+
function emailFromPlatformToken(token) {
|
|
65
|
+
try {
|
|
66
|
+
const payload = token.split(".")[1];
|
|
67
|
+
if (!payload)
|
|
68
|
+
return void 0;
|
|
69
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
|
|
70
|
+
return typeof claims.email === "string" ? claims.email : void 0;
|
|
71
|
+
} catch {
|
|
72
|
+
return void 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
60
75
|
async function deviceLogin(options) {
|
|
61
76
|
const { authUrl } = options;
|
|
62
77
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -94,7 +109,8 @@ async function deviceLogin(options) {
|
|
|
94
109
|
return {
|
|
95
110
|
refreshToken: pollData.refreshToken,
|
|
96
111
|
platformToken: pollData.platformToken,
|
|
97
|
-
userId: pollData.userId
|
|
112
|
+
userId: pollData.userId,
|
|
113
|
+
userEmail: emailFromPlatformToken(pollData.platformToken)
|
|
98
114
|
};
|
|
99
115
|
}
|
|
100
116
|
if (pollData.status === "expired") {
|
|
@@ -125,14 +141,18 @@ function upsertNpmrc(existing, entries) {
|
|
|
125
141
|
]);
|
|
126
142
|
const lines = existing.length > 0 ? existing.split("\n") : [];
|
|
127
143
|
const written = /* @__PURE__ */ new Set();
|
|
128
|
-
const merged =
|
|
144
|
+
const merged = [];
|
|
145
|
+
for (const line of lines) {
|
|
129
146
|
const key = lineKey(line);
|
|
130
|
-
if (key && desired.has(key)
|
|
147
|
+
if (key && desired.has(key)) {
|
|
148
|
+
if (written.has(key))
|
|
149
|
+
continue;
|
|
131
150
|
written.add(key);
|
|
132
|
-
|
|
151
|
+
merged.push(desired.get(key));
|
|
152
|
+
continue;
|
|
133
153
|
}
|
|
134
|
-
|
|
135
|
-
}
|
|
154
|
+
merged.push(line);
|
|
155
|
+
}
|
|
136
156
|
const additions = [...desired.entries()].filter(([key]) => !written.has(key)).map(([, line]) => line);
|
|
137
157
|
if (additions.length === 0)
|
|
138
158
|
return merged.join("\n");
|
|
@@ -181,6 +201,19 @@ async function mintRegistryCredential(options) {
|
|
|
181
201
|
}
|
|
182
202
|
return { token: data.token, expiresIn: data.expiresIn ?? 0, registryUrl: data.registryUrl };
|
|
183
203
|
}
|
|
204
|
+
async function revokeSessionByRefreshToken(options) {
|
|
205
|
+
try {
|
|
206
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
207
|
+
const res = await fetchImpl(`${options.authUrl}/api/auth/signout`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": "application/json" },
|
|
210
|
+
body: JSON.stringify({ refreshToken: options.refreshToken })
|
|
211
|
+
});
|
|
212
|
+
return res.ok;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
184
217
|
function npmrcPath() {
|
|
185
218
|
return process.env["NPM_CONFIG_USERCONFIG"] ?? path2.join(os2.homedir(), ".npmrc");
|
|
186
219
|
}
|
|
@@ -209,6 +242,216 @@ function removeRegistryCredentialFromNpmrc(registryUrl, file = npmrcPath()) {
|
|
|
209
242
|
return file;
|
|
210
243
|
}
|
|
211
244
|
|
|
245
|
+
// libs/client-core/dist/lib/token-manager.js
|
|
246
|
+
var TokenManager = class {
|
|
247
|
+
refreshToken;
|
|
248
|
+
authUrl;
|
|
249
|
+
platformToken = null;
|
|
250
|
+
tokenExpiresAt = 0;
|
|
251
|
+
refreshPromise = null;
|
|
252
|
+
onRefreshTokenRotated;
|
|
253
|
+
fetchImpl;
|
|
254
|
+
constructor(options) {
|
|
255
|
+
this.refreshToken = options.refreshToken;
|
|
256
|
+
this.authUrl = options.authUrl;
|
|
257
|
+
this.onRefreshTokenRotated = options.onRefreshTokenRotated;
|
|
258
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
259
|
+
}
|
|
260
|
+
async getToken() {
|
|
261
|
+
if (this.platformToken && Date.now() < this.tokenExpiresAt - 6e4) {
|
|
262
|
+
return this.platformToken;
|
|
263
|
+
}
|
|
264
|
+
return this.refresh();
|
|
265
|
+
}
|
|
266
|
+
invalidate() {
|
|
267
|
+
this.platformToken = null;
|
|
268
|
+
this.tokenExpiresAt = 0;
|
|
269
|
+
}
|
|
270
|
+
async refresh() {
|
|
271
|
+
if (this.refreshPromise)
|
|
272
|
+
return this.refreshPromise;
|
|
273
|
+
this.refreshPromise = this.doRefresh().finally(() => {
|
|
274
|
+
this.refreshPromise = null;
|
|
275
|
+
});
|
|
276
|
+
return this.refreshPromise;
|
|
277
|
+
}
|
|
278
|
+
async doRefresh() {
|
|
279
|
+
const res = await this.fetchImpl(`${this.authUrl}/api/token/refresh`, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "Content-Type": "application/json" },
|
|
282
|
+
body: JSON.stringify({ refreshToken: this.refreshToken })
|
|
283
|
+
});
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
const text = await res.text();
|
|
286
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}. Sign in again with 'gigabuddy login'.`);
|
|
287
|
+
}
|
|
288
|
+
const data = await res.json();
|
|
289
|
+
this.platformToken = data.token;
|
|
290
|
+
this.tokenExpiresAt = Date.now() + data.expiresIn * 1e3;
|
|
291
|
+
if (data.refreshToken && data.refreshToken !== this.refreshToken) {
|
|
292
|
+
this.refreshToken = data.refreshToken;
|
|
293
|
+
this.onRefreshTokenRotated?.(data.refreshToken);
|
|
294
|
+
}
|
|
295
|
+
return data.token;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// libs/gigabuddy/src/lib/doctor.ts
|
|
300
|
+
var SCOPE = "@gigabuddy";
|
|
301
|
+
var MIN_NODE_MAJOR = 20;
|
|
302
|
+
function expiryOf(token) {
|
|
303
|
+
try {
|
|
304
|
+
const payload = token.split(".")[1];
|
|
305
|
+
if (!payload)
|
|
306
|
+
return null;
|
|
307
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
|
|
308
|
+
return typeof claims.exp === "number" ? new Date(claims.exp * 1e3) : null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function npmrcValue(contents, key) {
|
|
314
|
+
let found = null;
|
|
315
|
+
for (const line of contents.split("\n")) {
|
|
316
|
+
const trimmed = line.trim();
|
|
317
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";"))
|
|
318
|
+
continue;
|
|
319
|
+
const eq = trimmed.indexOf("=");
|
|
320
|
+
if (eq === -1)
|
|
321
|
+
continue;
|
|
322
|
+
if (trimmed.slice(0, eq).trim().toLowerCase() === key.toLowerCase()) {
|
|
323
|
+
found = trimmed.slice(eq + 1).trim();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return found;
|
|
327
|
+
}
|
|
328
|
+
async function runDoctor(deps, packageName) {
|
|
329
|
+
const checks = [];
|
|
330
|
+
const advice = [];
|
|
331
|
+
const add = (label, status, detail) => checks.push({ label, status, detail });
|
|
332
|
+
const major = Number(deps.nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
333
|
+
if (Number.isFinite(major) && major < MIN_NODE_MAJOR) {
|
|
334
|
+
add("Node version", "fail", `${deps.nodeVersion} \u2014 gigabuddy needs Node ${MIN_NODE_MAJOR} or newer`);
|
|
335
|
+
advice.push(`Upgrade Node to ${MIN_NODE_MAJOR}+ \u2014 nothing below that is supported.`);
|
|
336
|
+
} else {
|
|
337
|
+
add("Node version", "ok", deps.nodeVersion);
|
|
338
|
+
}
|
|
339
|
+
const credentials = deps.loadCredentials(false) ?? deps.loadCredentials(true);
|
|
340
|
+
if (!credentials) {
|
|
341
|
+
add("Signed in", "fail", "no credentials found");
|
|
342
|
+
advice.push("Run `gigabuddy login --npm` \u2014 you are not signed in, so the registry cannot know who you are.");
|
|
343
|
+
return { checks, advice };
|
|
344
|
+
}
|
|
345
|
+
const signedIn = { label: "Signed in", status: "ok", detail: credentials.userEmail ?? credentials.userId };
|
|
346
|
+
checks.push(signedIn);
|
|
347
|
+
let platformToken = null;
|
|
348
|
+
try {
|
|
349
|
+
platformToken = await new TokenManager({
|
|
350
|
+
refreshToken: credentials.refreshToken,
|
|
351
|
+
authUrl: credentials.authUrl,
|
|
352
|
+
fetchImpl: deps.fetchImpl
|
|
353
|
+
}).getToken();
|
|
354
|
+
const email = emailFromPlatformToken(platformToken);
|
|
355
|
+
if (email)
|
|
356
|
+
signedIn.detail = email;
|
|
357
|
+
add("Session", "ok", "live");
|
|
358
|
+
} catch (err) {
|
|
359
|
+
add("Session", "fail", `not accepted by auth \u2014 ${err instanceof Error ? err.message : String(err)}`);
|
|
360
|
+
advice.push("Run `gigabuddy login` \u2014 your session was revoked or expired, so every credential from it is dead.");
|
|
361
|
+
}
|
|
362
|
+
const npmrc = deps.readNpmrc();
|
|
363
|
+
const scopeRegistry = npmrcValue(npmrc, `${SCOPE}:registry`);
|
|
364
|
+
if (!scopeRegistry) {
|
|
365
|
+
add(`${SCOPE} registry`, "fail", `not set in ${npmrcPath()}`);
|
|
366
|
+
advice.push("Run `gigabuddy login --npm` \u2014 npm has no registry configured for @gigabuddy packages.");
|
|
367
|
+
} else {
|
|
368
|
+
add(`${SCOPE} registry`, "ok", scopeRegistry);
|
|
369
|
+
}
|
|
370
|
+
const authKey = scopeRegistry ? registryAuthKey(scopeRegistry) : null;
|
|
371
|
+
const authToken = authKey ? npmrcValue(npmrc, authKey) : null;
|
|
372
|
+
if (scopeRegistry && !authToken) {
|
|
373
|
+
add("Registry credential", "fail", `no ${authKey} in ${npmrcPath()}`);
|
|
374
|
+
advice.push("Run `gigabuddy login --npm` \u2014 the registry is configured but has no credential to authenticate with.");
|
|
375
|
+
} else if (authToken) {
|
|
376
|
+
const expiry = expiryOf(authToken);
|
|
377
|
+
if (expiry && expiry.getTime() < Date.now()) {
|
|
378
|
+
add("Registry credential", "fail", `expired ${expiry.toISOString().slice(0, 10)}`);
|
|
379
|
+
advice.push("Run `gigabuddy login --npm` \u2014 your npm credential expired. They are long-lived, not permanent.");
|
|
380
|
+
} else {
|
|
381
|
+
add("Registry credential", "ok", expiry ? `valid until ${expiry.toISOString().slice(0, 10)}` : "present");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const effective = deps.npmEffectiveRegistry();
|
|
385
|
+
if (effective && scopeRegistry && effective.replace(/\/+$/, "") !== scopeRegistry.replace(/\/+$/, "")) {
|
|
386
|
+
add("npm resolves @gigabuddy to", "fail", `${effective} \u2014 NOT what we configured`);
|
|
387
|
+
advice.push(
|
|
388
|
+
`Something is overriding your user-level .npmrc \u2014 most often a project .npmrc, or an npm_config_ env var. npm is sending @gigabuddy installs to ${effective}.`
|
|
389
|
+
);
|
|
390
|
+
} else if (effective) {
|
|
391
|
+
add("npm resolves @gigabuddy to", "ok", effective);
|
|
392
|
+
}
|
|
393
|
+
if (scopeRegistry && authToken) {
|
|
394
|
+
const base = scopeRegistry.replace(/\/+$/, "");
|
|
395
|
+
try {
|
|
396
|
+
const res = await deps.fetchImpl(`${base}/-/ping`, { headers: { authorization: `Bearer ${authToken}` } });
|
|
397
|
+
if (res.ok) {
|
|
398
|
+
add("Registry reachable", "ok", base);
|
|
399
|
+
} else if (res.status === 401) {
|
|
400
|
+
add("Registry reachable", "fail", "reachable, but rejected the credential (401)");
|
|
401
|
+
advice.push("Run `gigabuddy login --npm` \u2014 the registry did not accept your credential.");
|
|
402
|
+
} else {
|
|
403
|
+
add("Registry reachable", "warn", `responded ${res.status}`);
|
|
404
|
+
}
|
|
405
|
+
} catch (err) {
|
|
406
|
+
add("Registry reachable", "fail", `cannot reach ${base} \u2014 ${err instanceof Error ? err.message : String(err)}`);
|
|
407
|
+
advice.push("Check your network or proxy \u2014 the registry is unreachable from here.");
|
|
408
|
+
}
|
|
409
|
+
if (packageName) {
|
|
410
|
+
try {
|
|
411
|
+
const res = await deps.fetchImpl(`${base}/${packageName.replace("/", "%2F")}`, {
|
|
412
|
+
headers: { authorization: `Bearer ${authToken}` }
|
|
413
|
+
});
|
|
414
|
+
if (res.ok) {
|
|
415
|
+
add(`Access to ${packageName}`, "ok", "you can install it");
|
|
416
|
+
} else if (res.status === 404) {
|
|
417
|
+
add(`Access to ${packageName}`, "fail", "the registry will not serve it to you");
|
|
418
|
+
advice.push(
|
|
419
|
+
`The registry answers "no such package" for ${packageName}. That single answer covers BOTH "this name does not exist" and "you do not have access" \u2014 deliberately, so private names stay private. Everything else above is healthy, so: check the spelling first, and if it is right, ask whoever owns the package to grant you access.`
|
|
420
|
+
);
|
|
421
|
+
} else if (res.status === 401) {
|
|
422
|
+
add(`Access to ${packageName}`, "fail", "credential rejected (401)");
|
|
423
|
+
} else {
|
|
424
|
+
add(`Access to ${packageName}`, "warn", `registry responded ${res.status}`);
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
add(`Access to ${packageName}`, "fail", err instanceof Error ? err.message : String(err));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { checks, advice };
|
|
432
|
+
}
|
|
433
|
+
var MARK = { ok: "\u2713", warn: "!", fail: "\u2717", skip: "-" };
|
|
434
|
+
function formatDoctor(result, colour) {
|
|
435
|
+
const paint = (code, text) => colour ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
436
|
+
const colourFor = { ok: "32", warn: "33", fail: "31", skip: "90" };
|
|
437
|
+
const width = Math.max(...result.checks.map((c) => c.label.length));
|
|
438
|
+
const lines = result.checks.map(
|
|
439
|
+
(c) => ` ${paint(colourFor[c.status], MARK[c.status])} ${c.label.padEnd(width)} ${c.detail ?? ""}`.trimEnd()
|
|
440
|
+
);
|
|
441
|
+
if (result.advice.length === 0) {
|
|
442
|
+
lines.push("", ` ${paint("32", "Everything checks out.")}`);
|
|
443
|
+
return lines.join("\n");
|
|
444
|
+
}
|
|
445
|
+
lines.push("", paint("1", " What to do:"));
|
|
446
|
+
for (const item of result.advice) {
|
|
447
|
+
const wrapped = item.replace(/(.{1,74})(\s|$)/g, "$1\n").trimEnd().split("\n");
|
|
448
|
+
lines.push(` \u2022 ${wrapped[0]}`);
|
|
449
|
+
for (const rest of wrapped.slice(1))
|
|
450
|
+
lines.push(` ${rest}`);
|
|
451
|
+
}
|
|
452
|
+
return lines.join("\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
212
455
|
// libs/gigabuddy/src/lib/commands.ts
|
|
213
456
|
var PRODUCTION_AUTH_URL = "https://auth.gigabuddy.com";
|
|
214
457
|
var PRODUCTION_API_URL = "https://api.gigabuddy.com";
|
|
@@ -305,16 +548,77 @@ function npmLogoutCommand(env) {
|
|
|
305
548
|
}
|
|
306
549
|
say(`Removed ${GIGABUDDY_NPM_SCOPE} registry credentials from ${file}`);
|
|
307
550
|
}
|
|
308
|
-
function logoutCommand(env) {
|
|
551
|
+
async function logoutCommand(env) {
|
|
552
|
+
const credentials = loadUserCredentials(env.staging);
|
|
309
553
|
npmLogoutCommand(env);
|
|
310
|
-
if (!
|
|
554
|
+
if (!credentials) {
|
|
311
555
|
say("Not signed in.");
|
|
312
556
|
return;
|
|
313
557
|
}
|
|
558
|
+
const revoked = await revokeSessionByRefreshToken({
|
|
559
|
+
authUrl: credentials.authUrl,
|
|
560
|
+
refreshToken: credentials.refreshToken
|
|
561
|
+
});
|
|
314
562
|
clearUserCredentials(env.staging);
|
|
315
|
-
|
|
563
|
+
if (revoked) {
|
|
564
|
+
say(`Signed out${env.staging ? " of staging" : ""}. Any npm credential from this session stops working shortly.`);
|
|
565
|
+
} else {
|
|
566
|
+
say(
|
|
567
|
+
`Signed out locally${env.staging ? " of staging" : ""} \u2014 but could not reach the server to revoke the session.`
|
|
568
|
+
);
|
|
569
|
+
say(" Credentials already copied elsewhere may keep working. Sign out again when you are back online,");
|
|
570
|
+
say(" or revoke the session from your account settings.");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function resolveAccountLabel(creds, staging) {
|
|
574
|
+
if (creds.userEmail)
|
|
575
|
+
return creds.userEmail;
|
|
576
|
+
try {
|
|
577
|
+
const token = await new TokenManager({ refreshToken: creds.refreshToken, authUrl: creds.authUrl }).getToken();
|
|
578
|
+
const email = emailFromPlatformToken(token);
|
|
579
|
+
if (!email)
|
|
580
|
+
return creds.userId;
|
|
581
|
+
saveUserCredentials({ ...creds, userEmail: email }, staging);
|
|
582
|
+
return email;
|
|
583
|
+
} catch {
|
|
584
|
+
return creds.userId;
|
|
585
|
+
}
|
|
316
586
|
}
|
|
317
|
-
function
|
|
587
|
+
async function doctorCommand(env, packageName) {
|
|
588
|
+
const result = await runDoctor(
|
|
589
|
+
{
|
|
590
|
+
readNpmrc: () => {
|
|
591
|
+
try {
|
|
592
|
+
return readFileSync3(npmrcPath(), "utf-8");
|
|
593
|
+
} catch {
|
|
594
|
+
return "";
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
npmEffectiveRegistry: () => {
|
|
598
|
+
try {
|
|
599
|
+
const value = execFileSync("npm", ["config", "get", `${GIGABUDDY_NPM_SCOPE}:registry`], {
|
|
600
|
+
encoding: "utf-8",
|
|
601
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
602
|
+
}).trim();
|
|
603
|
+
return value && value !== "undefined" && value !== "null" ? value : null;
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
fetchImpl: fetch,
|
|
609
|
+
nodeVersion: process.version,
|
|
610
|
+
loadCredentials: (staging) => loadUserCredentials(staging)
|
|
611
|
+
},
|
|
612
|
+
packageName
|
|
613
|
+
);
|
|
614
|
+
const colour = process.stderr.isTTY === true && !process.env["NO_COLOR"];
|
|
615
|
+
say();
|
|
616
|
+
say(formatDoctor(result, colour));
|
|
617
|
+
say();
|
|
618
|
+
if (result.advice.length > 0)
|
|
619
|
+
process.exitCode = 1;
|
|
620
|
+
}
|
|
621
|
+
async function statusCommand() {
|
|
318
622
|
const production = loadUserCredentials(false);
|
|
319
623
|
const staging = loadUserCredentials(true);
|
|
320
624
|
if (!production && !staging) {
|
|
@@ -323,14 +627,14 @@ function statusCommand() {
|
|
|
323
627
|
say(" gigabuddy login");
|
|
324
628
|
return;
|
|
325
629
|
}
|
|
326
|
-
for (const [label, creds] of [
|
|
327
|
-
["Production", production],
|
|
328
|
-
["Staging", staging]
|
|
630
|
+
for (const [label, creds, isStaging] of [
|
|
631
|
+
["Production", production, false],
|
|
632
|
+
["Staging", staging, true]
|
|
329
633
|
]) {
|
|
330
634
|
if (!creds)
|
|
331
635
|
continue;
|
|
332
636
|
say(`${label}:`);
|
|
333
|
-
say(` Account: ${creds
|
|
637
|
+
say(` Account: ${await resolveAccountLabel(creds, isStaging)}`);
|
|
334
638
|
say(` Since: ${creds.createdAt}`);
|
|
335
639
|
}
|
|
336
640
|
}
|
|
@@ -339,7 +643,7 @@ function statusCommand() {
|
|
|
339
643
|
function version() {
|
|
340
644
|
try {
|
|
341
645
|
const pkgPath = join3(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
342
|
-
return JSON.parse(
|
|
646
|
+
return JSON.parse(readFileSync4(pkgPath, "utf-8")).version;
|
|
343
647
|
} catch {
|
|
344
648
|
return "unknown";
|
|
345
649
|
}
|
|
@@ -356,6 +660,7 @@ Commands:
|
|
|
356
660
|
login --npm Set up npm to install @gigabuddy packages as you
|
|
357
661
|
logout Sign out (--npm clears only the npm credential)
|
|
358
662
|
status Show who you are signed in as
|
|
663
|
+
doctor [pkg] Diagnose why installs are not working
|
|
359
664
|
|
|
360
665
|
Options:
|
|
361
666
|
--npm Act on npm registry access in ~/.npmrc
|
|
@@ -371,6 +676,12 @@ Installing packages:
|
|
|
371
676
|
|
|
372
677
|
The npm credential is registry-scoped: it installs what you can install,
|
|
373
678
|
and cannot do anything else on the platform.
|
|
679
|
+
|
|
680
|
+
If an install fails:
|
|
681
|
+
gigabuddy doctor @gigabuddy/some-package
|
|
682
|
+
|
|
683
|
+
npm answers a denied read with "not found", which looks the same as a typo.
|
|
684
|
+
doctor tells you which it is.
|
|
374
685
|
`);
|
|
375
686
|
}
|
|
376
687
|
async function main() {
|
|
@@ -411,10 +722,13 @@ async function main() {
|
|
|
411
722
|
if (values.npm)
|
|
412
723
|
npmLogoutCommand(env);
|
|
413
724
|
else
|
|
414
|
-
logoutCommand(env);
|
|
725
|
+
await logoutCommand(env);
|
|
415
726
|
return;
|
|
416
727
|
case "status":
|
|
417
|
-
statusCommand();
|
|
728
|
+
await statusCommand();
|
|
729
|
+
return;
|
|
730
|
+
case "doctor":
|
|
731
|
+
await doctorCommand(env, positionals[1]);
|
|
418
732
|
return;
|
|
419
733
|
default:
|
|
420
734
|
console.error(`Unknown command: ${command}`);
|
package/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// libs/gigabuddy/src/lib/commands.ts
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
4
|
+
|
|
1
5
|
// libs/client-core/dist/lib/credentials.js
|
|
2
6
|
import * as fs from "node:fs";
|
|
3
7
|
import * as os from "node:os";
|
|
@@ -49,6 +53,17 @@ async function openBrowser(url) {
|
|
|
49
53
|
function sleep(ms) {
|
|
50
54
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
55
|
}
|
|
56
|
+
function emailFromPlatformToken(token) {
|
|
57
|
+
try {
|
|
58
|
+
const payload = token.split(".")[1];
|
|
59
|
+
if (!payload)
|
|
60
|
+
return void 0;
|
|
61
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
|
|
62
|
+
return typeof claims.email === "string" ? claims.email : void 0;
|
|
63
|
+
} catch {
|
|
64
|
+
return void 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
52
67
|
async function deviceLogin(options) {
|
|
53
68
|
const { authUrl } = options;
|
|
54
69
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -86,7 +101,8 @@ async function deviceLogin(options) {
|
|
|
86
101
|
return {
|
|
87
102
|
refreshToken: pollData.refreshToken,
|
|
88
103
|
platformToken: pollData.platformToken,
|
|
89
|
-
userId: pollData.userId
|
|
104
|
+
userId: pollData.userId,
|
|
105
|
+
userEmail: emailFromPlatformToken(pollData.platformToken)
|
|
90
106
|
};
|
|
91
107
|
}
|
|
92
108
|
if (pollData.status === "expired") {
|
|
@@ -117,14 +133,18 @@ function upsertNpmrc(existing, entries) {
|
|
|
117
133
|
]);
|
|
118
134
|
const lines = existing.length > 0 ? existing.split("\n") : [];
|
|
119
135
|
const written = /* @__PURE__ */ new Set();
|
|
120
|
-
const merged =
|
|
136
|
+
const merged = [];
|
|
137
|
+
for (const line of lines) {
|
|
121
138
|
const key = lineKey(line);
|
|
122
|
-
if (key && desired.has(key)
|
|
139
|
+
if (key && desired.has(key)) {
|
|
140
|
+
if (written.has(key))
|
|
141
|
+
continue;
|
|
123
142
|
written.add(key);
|
|
124
|
-
|
|
143
|
+
merged.push(desired.get(key));
|
|
144
|
+
continue;
|
|
125
145
|
}
|
|
126
|
-
|
|
127
|
-
}
|
|
146
|
+
merged.push(line);
|
|
147
|
+
}
|
|
128
148
|
const additions = [...desired.entries()].filter(([key]) => !written.has(key)).map(([, line]) => line);
|
|
129
149
|
if (additions.length === 0)
|
|
130
150
|
return merged.join("\n");
|
|
@@ -173,6 +193,19 @@ async function mintRegistryCredential(options) {
|
|
|
173
193
|
}
|
|
174
194
|
return { token: data.token, expiresIn: data.expiresIn ?? 0, registryUrl: data.registryUrl };
|
|
175
195
|
}
|
|
196
|
+
async function revokeSessionByRefreshToken(options) {
|
|
197
|
+
try {
|
|
198
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
199
|
+
const res = await fetchImpl(`${options.authUrl}/api/auth/signout`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ refreshToken: options.refreshToken })
|
|
203
|
+
});
|
|
204
|
+
return res.ok;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
176
209
|
function npmrcPath() {
|
|
177
210
|
return process.env["NPM_CONFIG_USERCONFIG"] ?? path2.join(os2.homedir(), ".npmrc");
|
|
178
211
|
}
|
|
@@ -201,6 +234,216 @@ function removeRegistryCredentialFromNpmrc(registryUrl, file = npmrcPath()) {
|
|
|
201
234
|
return file;
|
|
202
235
|
}
|
|
203
236
|
|
|
237
|
+
// libs/client-core/dist/lib/token-manager.js
|
|
238
|
+
var TokenManager = class {
|
|
239
|
+
refreshToken;
|
|
240
|
+
authUrl;
|
|
241
|
+
platformToken = null;
|
|
242
|
+
tokenExpiresAt = 0;
|
|
243
|
+
refreshPromise = null;
|
|
244
|
+
onRefreshTokenRotated;
|
|
245
|
+
fetchImpl;
|
|
246
|
+
constructor(options) {
|
|
247
|
+
this.refreshToken = options.refreshToken;
|
|
248
|
+
this.authUrl = options.authUrl;
|
|
249
|
+
this.onRefreshTokenRotated = options.onRefreshTokenRotated;
|
|
250
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
251
|
+
}
|
|
252
|
+
async getToken() {
|
|
253
|
+
if (this.platformToken && Date.now() < this.tokenExpiresAt - 6e4) {
|
|
254
|
+
return this.platformToken;
|
|
255
|
+
}
|
|
256
|
+
return this.refresh();
|
|
257
|
+
}
|
|
258
|
+
invalidate() {
|
|
259
|
+
this.platformToken = null;
|
|
260
|
+
this.tokenExpiresAt = 0;
|
|
261
|
+
}
|
|
262
|
+
async refresh() {
|
|
263
|
+
if (this.refreshPromise)
|
|
264
|
+
return this.refreshPromise;
|
|
265
|
+
this.refreshPromise = this.doRefresh().finally(() => {
|
|
266
|
+
this.refreshPromise = null;
|
|
267
|
+
});
|
|
268
|
+
return this.refreshPromise;
|
|
269
|
+
}
|
|
270
|
+
async doRefresh() {
|
|
271
|
+
const res = await this.fetchImpl(`${this.authUrl}/api/token/refresh`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({ refreshToken: this.refreshToken })
|
|
275
|
+
});
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
const text = await res.text();
|
|
278
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}. Sign in again with 'gigabuddy login'.`);
|
|
279
|
+
}
|
|
280
|
+
const data = await res.json();
|
|
281
|
+
this.platformToken = data.token;
|
|
282
|
+
this.tokenExpiresAt = Date.now() + data.expiresIn * 1e3;
|
|
283
|
+
if (data.refreshToken && data.refreshToken !== this.refreshToken) {
|
|
284
|
+
this.refreshToken = data.refreshToken;
|
|
285
|
+
this.onRefreshTokenRotated?.(data.refreshToken);
|
|
286
|
+
}
|
|
287
|
+
return data.token;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// libs/gigabuddy/src/lib/doctor.ts
|
|
292
|
+
var SCOPE = "@gigabuddy";
|
|
293
|
+
var MIN_NODE_MAJOR = 20;
|
|
294
|
+
function expiryOf(token) {
|
|
295
|
+
try {
|
|
296
|
+
const payload = token.split(".")[1];
|
|
297
|
+
if (!payload)
|
|
298
|
+
return null;
|
|
299
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
|
|
300
|
+
return typeof claims.exp === "number" ? new Date(claims.exp * 1e3) : null;
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function npmrcValue(contents, key) {
|
|
306
|
+
let found = null;
|
|
307
|
+
for (const line of contents.split("\n")) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";"))
|
|
310
|
+
continue;
|
|
311
|
+
const eq = trimmed.indexOf("=");
|
|
312
|
+
if (eq === -1)
|
|
313
|
+
continue;
|
|
314
|
+
if (trimmed.slice(0, eq).trim().toLowerCase() === key.toLowerCase()) {
|
|
315
|
+
found = trimmed.slice(eq + 1).trim();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return found;
|
|
319
|
+
}
|
|
320
|
+
async function runDoctor(deps, packageName) {
|
|
321
|
+
const checks = [];
|
|
322
|
+
const advice = [];
|
|
323
|
+
const add = (label, status, detail) => checks.push({ label, status, detail });
|
|
324
|
+
const major = Number(deps.nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
325
|
+
if (Number.isFinite(major) && major < MIN_NODE_MAJOR) {
|
|
326
|
+
add("Node version", "fail", `${deps.nodeVersion} \u2014 gigabuddy needs Node ${MIN_NODE_MAJOR} or newer`);
|
|
327
|
+
advice.push(`Upgrade Node to ${MIN_NODE_MAJOR}+ \u2014 nothing below that is supported.`);
|
|
328
|
+
} else {
|
|
329
|
+
add("Node version", "ok", deps.nodeVersion);
|
|
330
|
+
}
|
|
331
|
+
const credentials = deps.loadCredentials(false) ?? deps.loadCredentials(true);
|
|
332
|
+
if (!credentials) {
|
|
333
|
+
add("Signed in", "fail", "no credentials found");
|
|
334
|
+
advice.push("Run `gigabuddy login --npm` \u2014 you are not signed in, so the registry cannot know who you are.");
|
|
335
|
+
return { checks, advice };
|
|
336
|
+
}
|
|
337
|
+
const signedIn = { label: "Signed in", status: "ok", detail: credentials.userEmail ?? credentials.userId };
|
|
338
|
+
checks.push(signedIn);
|
|
339
|
+
let platformToken = null;
|
|
340
|
+
try {
|
|
341
|
+
platformToken = await new TokenManager({
|
|
342
|
+
refreshToken: credentials.refreshToken,
|
|
343
|
+
authUrl: credentials.authUrl,
|
|
344
|
+
fetchImpl: deps.fetchImpl
|
|
345
|
+
}).getToken();
|
|
346
|
+
const email = emailFromPlatformToken(platformToken);
|
|
347
|
+
if (email)
|
|
348
|
+
signedIn.detail = email;
|
|
349
|
+
add("Session", "ok", "live");
|
|
350
|
+
} catch (err) {
|
|
351
|
+
add("Session", "fail", `not accepted by auth \u2014 ${err instanceof Error ? err.message : String(err)}`);
|
|
352
|
+
advice.push("Run `gigabuddy login` \u2014 your session was revoked or expired, so every credential from it is dead.");
|
|
353
|
+
}
|
|
354
|
+
const npmrc = deps.readNpmrc();
|
|
355
|
+
const scopeRegistry = npmrcValue(npmrc, `${SCOPE}:registry`);
|
|
356
|
+
if (!scopeRegistry) {
|
|
357
|
+
add(`${SCOPE} registry`, "fail", `not set in ${npmrcPath()}`);
|
|
358
|
+
advice.push("Run `gigabuddy login --npm` \u2014 npm has no registry configured for @gigabuddy packages.");
|
|
359
|
+
} else {
|
|
360
|
+
add(`${SCOPE} registry`, "ok", scopeRegistry);
|
|
361
|
+
}
|
|
362
|
+
const authKey = scopeRegistry ? registryAuthKey(scopeRegistry) : null;
|
|
363
|
+
const authToken = authKey ? npmrcValue(npmrc, authKey) : null;
|
|
364
|
+
if (scopeRegistry && !authToken) {
|
|
365
|
+
add("Registry credential", "fail", `no ${authKey} in ${npmrcPath()}`);
|
|
366
|
+
advice.push("Run `gigabuddy login --npm` \u2014 the registry is configured but has no credential to authenticate with.");
|
|
367
|
+
} else if (authToken) {
|
|
368
|
+
const expiry = expiryOf(authToken);
|
|
369
|
+
if (expiry && expiry.getTime() < Date.now()) {
|
|
370
|
+
add("Registry credential", "fail", `expired ${expiry.toISOString().slice(0, 10)}`);
|
|
371
|
+
advice.push("Run `gigabuddy login --npm` \u2014 your npm credential expired. They are long-lived, not permanent.");
|
|
372
|
+
} else {
|
|
373
|
+
add("Registry credential", "ok", expiry ? `valid until ${expiry.toISOString().slice(0, 10)}` : "present");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const effective = deps.npmEffectiveRegistry();
|
|
377
|
+
if (effective && scopeRegistry && effective.replace(/\/+$/, "") !== scopeRegistry.replace(/\/+$/, "")) {
|
|
378
|
+
add("npm resolves @gigabuddy to", "fail", `${effective} \u2014 NOT what we configured`);
|
|
379
|
+
advice.push(
|
|
380
|
+
`Something is overriding your user-level .npmrc \u2014 most often a project .npmrc, or an npm_config_ env var. npm is sending @gigabuddy installs to ${effective}.`
|
|
381
|
+
);
|
|
382
|
+
} else if (effective) {
|
|
383
|
+
add("npm resolves @gigabuddy to", "ok", effective);
|
|
384
|
+
}
|
|
385
|
+
if (scopeRegistry && authToken) {
|
|
386
|
+
const base = scopeRegistry.replace(/\/+$/, "");
|
|
387
|
+
try {
|
|
388
|
+
const res = await deps.fetchImpl(`${base}/-/ping`, { headers: { authorization: `Bearer ${authToken}` } });
|
|
389
|
+
if (res.ok) {
|
|
390
|
+
add("Registry reachable", "ok", base);
|
|
391
|
+
} else if (res.status === 401) {
|
|
392
|
+
add("Registry reachable", "fail", "reachable, but rejected the credential (401)");
|
|
393
|
+
advice.push("Run `gigabuddy login --npm` \u2014 the registry did not accept your credential.");
|
|
394
|
+
} else {
|
|
395
|
+
add("Registry reachable", "warn", `responded ${res.status}`);
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
add("Registry reachable", "fail", `cannot reach ${base} \u2014 ${err instanceof Error ? err.message : String(err)}`);
|
|
399
|
+
advice.push("Check your network or proxy \u2014 the registry is unreachable from here.");
|
|
400
|
+
}
|
|
401
|
+
if (packageName) {
|
|
402
|
+
try {
|
|
403
|
+
const res = await deps.fetchImpl(`${base}/${packageName.replace("/", "%2F")}`, {
|
|
404
|
+
headers: { authorization: `Bearer ${authToken}` }
|
|
405
|
+
});
|
|
406
|
+
if (res.ok) {
|
|
407
|
+
add(`Access to ${packageName}`, "ok", "you can install it");
|
|
408
|
+
} else if (res.status === 404) {
|
|
409
|
+
add(`Access to ${packageName}`, "fail", "the registry will not serve it to you");
|
|
410
|
+
advice.push(
|
|
411
|
+
`The registry answers "no such package" for ${packageName}. That single answer covers BOTH "this name does not exist" and "you do not have access" \u2014 deliberately, so private names stay private. Everything else above is healthy, so: check the spelling first, and if it is right, ask whoever owns the package to grant you access.`
|
|
412
|
+
);
|
|
413
|
+
} else if (res.status === 401) {
|
|
414
|
+
add(`Access to ${packageName}`, "fail", "credential rejected (401)");
|
|
415
|
+
} else {
|
|
416
|
+
add(`Access to ${packageName}`, "warn", `registry responded ${res.status}`);
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
add(`Access to ${packageName}`, "fail", err instanceof Error ? err.message : String(err));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { checks, advice };
|
|
424
|
+
}
|
|
425
|
+
var MARK = { ok: "\u2713", warn: "!", fail: "\u2717", skip: "-" };
|
|
426
|
+
function formatDoctor(result, colour) {
|
|
427
|
+
const paint = (code, text) => colour ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
428
|
+
const colourFor = { ok: "32", warn: "33", fail: "31", skip: "90" };
|
|
429
|
+
const width = Math.max(...result.checks.map((c) => c.label.length));
|
|
430
|
+
const lines = result.checks.map(
|
|
431
|
+
(c) => ` ${paint(colourFor[c.status], MARK[c.status])} ${c.label.padEnd(width)} ${c.detail ?? ""}`.trimEnd()
|
|
432
|
+
);
|
|
433
|
+
if (result.advice.length === 0) {
|
|
434
|
+
lines.push("", ` ${paint("32", "Everything checks out.")}`);
|
|
435
|
+
return lines.join("\n");
|
|
436
|
+
}
|
|
437
|
+
lines.push("", paint("1", " What to do:"));
|
|
438
|
+
for (const item of result.advice) {
|
|
439
|
+
const wrapped = item.replace(/(.{1,74})(\s|$)/g, "$1\n").trimEnd().split("\n");
|
|
440
|
+
lines.push(` \u2022 ${wrapped[0]}`);
|
|
441
|
+
for (const rest of wrapped.slice(1))
|
|
442
|
+
lines.push(` ${rest}`);
|
|
443
|
+
}
|
|
444
|
+
return lines.join("\n");
|
|
445
|
+
}
|
|
446
|
+
|
|
204
447
|
// libs/gigabuddy/src/lib/commands.ts
|
|
205
448
|
var PRODUCTION_AUTH_URL = "https://auth.gigabuddy.com";
|
|
206
449
|
var PRODUCTION_API_URL = "https://api.gigabuddy.com";
|
|
@@ -297,16 +540,77 @@ function npmLogoutCommand(env) {
|
|
|
297
540
|
}
|
|
298
541
|
say(`Removed ${GIGABUDDY_NPM_SCOPE} registry credentials from ${file}`);
|
|
299
542
|
}
|
|
300
|
-
function logoutCommand(env) {
|
|
543
|
+
async function logoutCommand(env) {
|
|
544
|
+
const credentials = loadUserCredentials(env.staging);
|
|
301
545
|
npmLogoutCommand(env);
|
|
302
|
-
if (!
|
|
546
|
+
if (!credentials) {
|
|
303
547
|
say("Not signed in.");
|
|
304
548
|
return;
|
|
305
549
|
}
|
|
550
|
+
const revoked = await revokeSessionByRefreshToken({
|
|
551
|
+
authUrl: credentials.authUrl,
|
|
552
|
+
refreshToken: credentials.refreshToken
|
|
553
|
+
});
|
|
306
554
|
clearUserCredentials(env.staging);
|
|
307
|
-
|
|
555
|
+
if (revoked) {
|
|
556
|
+
say(`Signed out${env.staging ? " of staging" : ""}. Any npm credential from this session stops working shortly.`);
|
|
557
|
+
} else {
|
|
558
|
+
say(
|
|
559
|
+
`Signed out locally${env.staging ? " of staging" : ""} \u2014 but could not reach the server to revoke the session.`
|
|
560
|
+
);
|
|
561
|
+
say(" Credentials already copied elsewhere may keep working. Sign out again when you are back online,");
|
|
562
|
+
say(" or revoke the session from your account settings.");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function resolveAccountLabel(creds, staging) {
|
|
566
|
+
if (creds.userEmail)
|
|
567
|
+
return creds.userEmail;
|
|
568
|
+
try {
|
|
569
|
+
const token = await new TokenManager({ refreshToken: creds.refreshToken, authUrl: creds.authUrl }).getToken();
|
|
570
|
+
const email = emailFromPlatformToken(token);
|
|
571
|
+
if (!email)
|
|
572
|
+
return creds.userId;
|
|
573
|
+
saveUserCredentials({ ...creds, userEmail: email }, staging);
|
|
574
|
+
return email;
|
|
575
|
+
} catch {
|
|
576
|
+
return creds.userId;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function doctorCommand(env, packageName) {
|
|
580
|
+
const result = await runDoctor(
|
|
581
|
+
{
|
|
582
|
+
readNpmrc: () => {
|
|
583
|
+
try {
|
|
584
|
+
return readFileSync3(npmrcPath(), "utf-8");
|
|
585
|
+
} catch {
|
|
586
|
+
return "";
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
npmEffectiveRegistry: () => {
|
|
590
|
+
try {
|
|
591
|
+
const value = execFileSync("npm", ["config", "get", `${GIGABUDDY_NPM_SCOPE}:registry`], {
|
|
592
|
+
encoding: "utf-8",
|
|
593
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
594
|
+
}).trim();
|
|
595
|
+
return value && value !== "undefined" && value !== "null" ? value : null;
|
|
596
|
+
} catch {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
fetchImpl: fetch,
|
|
601
|
+
nodeVersion: process.version,
|
|
602
|
+
loadCredentials: (staging) => loadUserCredentials(staging)
|
|
603
|
+
},
|
|
604
|
+
packageName
|
|
605
|
+
);
|
|
606
|
+
const colour = process.stderr.isTTY === true && !process.env["NO_COLOR"];
|
|
607
|
+
say();
|
|
608
|
+
say(formatDoctor(result, colour));
|
|
609
|
+
say();
|
|
610
|
+
if (result.advice.length > 0)
|
|
611
|
+
process.exitCode = 1;
|
|
308
612
|
}
|
|
309
|
-
function statusCommand() {
|
|
613
|
+
async function statusCommand() {
|
|
310
614
|
const production = loadUserCredentials(false);
|
|
311
615
|
const staging = loadUserCredentials(true);
|
|
312
616
|
if (!production && !staging) {
|
|
@@ -315,14 +619,14 @@ function statusCommand() {
|
|
|
315
619
|
say(" gigabuddy login");
|
|
316
620
|
return;
|
|
317
621
|
}
|
|
318
|
-
for (const [label, creds] of [
|
|
319
|
-
["Production", production],
|
|
320
|
-
["Staging", staging]
|
|
622
|
+
for (const [label, creds, isStaging] of [
|
|
623
|
+
["Production", production, false],
|
|
624
|
+
["Staging", staging, true]
|
|
321
625
|
]) {
|
|
322
626
|
if (!creds)
|
|
323
627
|
continue;
|
|
324
628
|
say(`${label}:`);
|
|
325
|
-
say(` Account: ${creds
|
|
629
|
+
say(` Account: ${await resolveAccountLabel(creds, isStaging)}`);
|
|
326
630
|
say(` Since: ${creds.createdAt}`);
|
|
327
631
|
}
|
|
328
632
|
}
|
|
@@ -331,6 +635,7 @@ export {
|
|
|
331
635
|
PRODUCTION_AUTH_URL,
|
|
332
636
|
STAGING_API_URL,
|
|
333
637
|
STAGING_AUTH_URL,
|
|
638
|
+
doctorCommand,
|
|
334
639
|
loginCommand,
|
|
335
640
|
logoutCommand,
|
|
336
641
|
npmLoginCommand,
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gigabuddy",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The Gigabuddy CLI — sign in, and install @gigabuddy packages as yourself.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "MIT",
|
|
7
7
|
"homepage": "https://gigabuddy.com",
|
|
8
8
|
"bin": {
|
|
9
9
|
"gigabuddy": "bin/cli.js"
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"bin/**/*.js",
|
|
17
17
|
"index.js",
|
|
18
18
|
"src/**/*.d.ts",
|
|
19
|
-
"README.md"
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": ">=20"
|
package/src/lib/commands.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `gigabuddy` commands. Kept separate from bin/cli.ts (which only parses
|
|
3
|
-
* argv and dispatches) so each one is testable without spawning a process.
|
|
4
|
-
*
|
|
5
|
-
* Everything here is deliberately thin: sign in, prove who you are to the npm
|
|
6
|
-
* door, say what state you're in, sign out. The platform's actual capabilities
|
|
7
|
-
* live behind hosted doors — this client never grows tool implementations.
|
|
8
|
-
*/
|
|
9
1
|
import { type UserCredentials } from '@gigabuddy/client-core';
|
|
10
2
|
export declare const PRODUCTION_AUTH_URL = "https://auth.gigabuddy.com";
|
|
11
3
|
export declare const PRODUCTION_API_URL = "https://api.gigabuddy.com";
|
|
@@ -33,5 +25,11 @@ export declare function loginCommand(env: Env): Promise<void>;
|
|
|
33
25
|
*/
|
|
34
26
|
export declare function npmLoginCommand(env: Env): Promise<void>;
|
|
35
27
|
export declare function npmLogoutCommand(env: Env): void;
|
|
36
|
-
export declare function logoutCommand(env: Env): void
|
|
37
|
-
|
|
28
|
+
export declare function logoutCommand(env: Env): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* `gigabuddy doctor [package]` — why isn't this working?
|
|
31
|
+
*
|
|
32
|
+
* Exits non-zero when it found something wrong, so CI can gate on it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function doctorCommand(env: Env, packageName?: string): Promise<void>;
|
|
35
|
+
export declare function statusCommand(): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gigabuddy doctor` — answer "why doesn't my install work?" in one command.
|
|
3
|
+
*
|
|
4
|
+
* This exists because of a deliberate property of the registry door: a denied
|
|
5
|
+
* read is answered as an npm-shaped `404 no such package`, so that private
|
|
6
|
+
* package names don't enumerate to people who can't read them. Correct for the
|
|
7
|
+
* door, miserable for the human — "not found" looks identical whether you typed
|
|
8
|
+
* the name wrong, aren't signed in, have an expired credential, or simply
|
|
9
|
+
* haven't been given access. npm cannot tell you which, and neither can the
|
|
10
|
+
* registry without leaking the thing it's protecting.
|
|
11
|
+
*
|
|
12
|
+
* So the client says it instead. It holds the pieces the door can't safely
|
|
13
|
+
* reveal — who you are signed in as, whether that session is live, what npm
|
|
14
|
+
* will actually resolve — and turns a bare 404 into a sentence with a next
|
|
15
|
+
* action in it.
|
|
16
|
+
*
|
|
17
|
+
* Every check is independent and never throws: a doctor that dies on the first
|
|
18
|
+
* problem is useless precisely when you need it.
|
|
19
|
+
*/
|
|
20
|
+
import { type UserCredentials } from '@gigabuddy/client-core';
|
|
21
|
+
export type CheckStatus = 'ok' | 'warn' | 'fail' | 'skip';
|
|
22
|
+
export interface Check {
|
|
23
|
+
label: string;
|
|
24
|
+
status: CheckStatus;
|
|
25
|
+
detail?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DoctorResult {
|
|
28
|
+
checks: Check[];
|
|
29
|
+
/** What to actually do next, in order. Empty when everything is healthy. */
|
|
30
|
+
advice: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface DoctorDeps {
|
|
33
|
+
readNpmrc: () => string;
|
|
34
|
+
/** `npm config get @gigabuddy:registry` — the EFFECTIVE value, all layers merged. */
|
|
35
|
+
npmEffectiveRegistry: () => string | null;
|
|
36
|
+
fetchImpl: typeof fetch;
|
|
37
|
+
nodeVersion: string;
|
|
38
|
+
loadCredentials: (staging: boolean) => UserCredentials | null;
|
|
39
|
+
}
|
|
40
|
+
export declare function runDoctor(deps: DoctorDeps, packageName?: string): Promise<DoctorResult>;
|
|
41
|
+
/** Render for a terminal. Colour only when one is attached and NO_COLOR is unset. */
|
|
42
|
+
export declare function formatDoctor(result: DoctorResult, colour: boolean): string;
|