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 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 readFileSync3 } from "node:fs";
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 = lines.map((line) => {
144
+ const merged = [];
145
+ for (const line of lines) {
129
146
  const key = lineKey(line);
130
- if (key && desired.has(key) && !written.has(key)) {
147
+ if (key && desired.has(key)) {
148
+ if (written.has(key))
149
+ continue;
131
150
  written.add(key);
132
- return desired.get(key);
151
+ merged.push(desired.get(key));
152
+ continue;
133
153
  }
134
- return line;
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 (!loadUserCredentials(env.staging)) {
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
- say(`Signed out${env.staging ? " of staging" : ""}.`);
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 statusCommand() {
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.userEmail ?? creds.userId}`);
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(readFileSync3(pkgPath, "utf-8")).version;
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 = lines.map((line) => {
136
+ const merged = [];
137
+ for (const line of lines) {
121
138
  const key = lineKey(line);
122
- if (key && desired.has(key) && !written.has(key)) {
139
+ if (key && desired.has(key)) {
140
+ if (written.has(key))
141
+ continue;
123
142
  written.add(key);
124
- return desired.get(key);
143
+ merged.push(desired.get(key));
144
+ continue;
125
145
  }
126
- return line;
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 (!loadUserCredentials(env.staging)) {
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
- say(`Signed out${env.staging ? " of staging" : ""}.`);
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.userEmail ?? creds.userId}`);
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.1",
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": "UNLICENSED",
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"
@@ -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
- export declare function statusCommand(): void;
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;