shakers-cli 0.0.0-stage → 0.14.1

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.
Files changed (237) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1233 -2
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +99 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +124 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +216 -0
  20. package/bin/mcp.js +124 -0
  21. package/bin/mcpb.js +5 -0
  22. package/bin/me.js +69 -0
  23. package/bin/onboarding.js +134 -0
  24. package/bin/portfolios.js +69 -0
  25. package/bin/rate.js +70 -0
  26. package/bin/register.js +337 -0
  27. package/bin/report-html.js +134 -0
  28. package/bin/save.js +77 -0
  29. package/bin/shakers.js +363 -0
  30. package/bin/share.js +72 -0
  31. package/bin/show-project.js +72 -0
  32. package/bin/socials.js +70 -0
  33. package/bin/superadmin.js +351 -0
  34. package/bin/update-check-worker.js +70 -0
  35. package/bin/update.js +111 -0
  36. package/package.json +40 -4
  37. package/src/agent-category.js +13 -0
  38. package/src/agent-evaluation.js +177 -0
  39. package/src/agent-org-chart.js +355 -0
  40. package/src/agent-synthesis.js +132 -0
  41. package/src/agent-usage.js +118 -0
  42. package/src/agents-client.js +165 -0
  43. package/src/ai-fluency-cell.js +72 -0
  44. package/src/ai-profile-client.js +45 -0
  45. package/src/ai-profile-preview.js +154 -0
  46. package/src/ai.js +66 -0
  47. package/src/alma-client.js +123 -0
  48. package/src/alma-flow.js +32 -0
  49. package/src/alma-repo-context.js +47 -0
  50. package/src/alma-shell.js +161 -0
  51. package/src/ansi.js +62 -0
  52. package/src/applications-client.js +79 -0
  53. package/src/applications-flow.js +58 -0
  54. package/src/auth-client.js +144 -0
  55. package/src/auth-device-client.js +118 -0
  56. package/src/auth-method-picker.js +44 -0
  57. package/src/auth-methods.js +35 -0
  58. package/src/auth-session-store.js +215 -0
  59. package/src/authorship-mismatch.js +62 -0
  60. package/src/authorship.js +204 -0
  61. package/src/automations-detector.js +170 -0
  62. package/src/availability-client.js +93 -0
  63. package/src/availability-flow.js +124 -0
  64. package/src/backend-request.js +191 -0
  65. package/src/blind-approval-extract.js +48 -0
  66. package/src/brand-ansi.js +14 -0
  67. package/src/brand-svg.js +9 -0
  68. package/src/browser-tools-detector.js +21 -0
  69. package/src/build-next-level.js +56 -0
  70. package/src/certifications-client.js +89 -0
  71. package/src/certifications-flow.js +75 -0
  72. package/src/certify-dimension-client.js +163 -0
  73. package/src/certify-dimension-flow.js +135 -0
  74. package/src/certify-disclaimer.js +51 -0
  75. package/src/certify-remediation-prompt.js +23 -0
  76. package/src/cli-args.js +83 -0
  77. package/src/command-audit.js +105 -0
  78. package/src/config-dir.js +120 -0
  79. package/src/config.js +877 -0
  80. package/src/consent-flow.js +115 -0
  81. package/src/consent-skip.js +27 -0
  82. package/src/cv-file.js +62 -0
  83. package/src/cv-text.js +92 -0
  84. package/src/decisions-extract.js +43 -0
  85. package/src/detect-repos.js +145 -0
  86. package/src/detected-tools.js +15 -0
  87. package/src/detection-evidence.js +105 -0
  88. package/src/detection-noise.js +132 -0
  89. package/src/detectors.js +221 -0
  90. package/src/device-auth-store.js +100 -0
  91. package/src/device-login.js +120 -0
  92. package/src/email-verification.js +152 -0
  93. package/src/env-paths.js +30 -0
  94. package/src/find-projects-cache.js +89 -0
  95. package/src/find-projects-client.js +148 -0
  96. package/src/find-projects-flow.js +164 -0
  97. package/src/git-activity.js +124 -0
  98. package/src/git-log-prefetch.js +53 -0
  99. package/src/graph-certs.js +58 -0
  100. package/src/graph-generator.js +230 -0
  101. package/src/graph-scan.js +239 -0
  102. package/src/i18n.js +3341 -0
  103. package/src/inter-fonts.js +6 -0
  104. package/src/interactive-select.js +183 -0
  105. package/src/interview-client.js +226 -0
  106. package/src/interview-transcript.js +56 -0
  107. package/src/inventory-client.js +90 -0
  108. package/src/invitations-client.js +66 -0
  109. package/src/invitations-flow.js +50 -0
  110. package/src/lang-client.js +147 -0
  111. package/src/lang-codes.js +14 -0
  112. package/src/lang-flow.js +85 -0
  113. package/src/legal-notice.js +53 -0
  114. package/src/locale.js +72 -0
  115. package/src/markdown-ansi.js +50 -0
  116. package/src/maturity.js +111 -0
  117. package/src/mcp-ai-usage-result-tool.js +111 -0
  118. package/src/mcp-ai-usage-tool.js +401 -0
  119. package/src/mcp-alma-tools.js +95 -0
  120. package/src/mcp-applications-tools.js +44 -0
  121. package/src/mcp-auth-form.js +480 -0
  122. package/src/mcp-availability-tools.js +45 -0
  123. package/src/mcp-certifications-tools.js +44 -0
  124. package/src/mcp-certify-dimension-tools.js +66 -0
  125. package/src/mcp-choice.js +95 -0
  126. package/src/mcp-detector.js +223 -0
  127. package/src/mcp-device-login-tools.js +177 -0
  128. package/src/mcp-find-projects-tools.js +69 -0
  129. package/src/mcp-install.js +275 -0
  130. package/src/mcp-instructions.js +27 -0
  131. package/src/mcp-invitations-tools.js +44 -0
  132. package/src/mcp-lang-tools.js +41 -0
  133. package/src/mcp-onboarding-tools.js +379 -0
  134. package/src/mcp-portfolios-tools.js +50 -0
  135. package/src/mcp-profile-tools.js +48 -0
  136. package/src/mcp-prompts.js +73 -0
  137. package/src/mcp-rate-tools.js +44 -0
  138. package/src/mcp-register-tools.js +441 -0
  139. package/src/mcp-report-tools.js +105 -0
  140. package/src/mcp-role-tools.js +372 -0
  141. package/src/mcp-saved-positions-tools.js +59 -0
  142. package/src/mcp-server.js +290 -0
  143. package/src/mcp-show-project-tools.js +52 -0
  144. package/src/mcp-signup-client.js +118 -0
  145. package/src/mcp-signup-tools.js +1171 -0
  146. package/src/mcp-socials-tools.js +41 -0
  147. package/src/memory-structure-detector.js +108 -0
  148. package/src/model-call-record.js +198 -0
  149. package/src/onboarding-availability.js +26 -0
  150. package/src/onboarding-client.js +374 -0
  151. package/src/onboarding-flow.js +1162 -0
  152. package/src/onboarding-funnel.js +272 -0
  153. package/src/onboarding-livekit-client.js +102 -0
  154. package/src/onboarding-status-client.js +57 -0
  155. package/src/open-file.js +32 -0
  156. package/src/osc-link.js +16 -0
  157. package/src/portfolio-client.js +164 -0
  158. package/src/portfolio-git-info.js +61 -0
  159. package/src/portfolios-client.js +85 -0
  160. package/src/portfolios-flow.js +79 -0
  161. package/src/profile-client.js +133 -0
  162. package/src/profile-flow.js +115 -0
  163. package/src/profile-switch.js +46 -0
  164. package/src/prompt-select.js +66 -0
  165. package/src/rate-client.js +81 -0
  166. package/src/rate-flow.js +137 -0
  167. package/src/register-context.js +145 -0
  168. package/src/render-ai-fluency.js +266 -0
  169. package/src/render-certification.js +177 -0
  170. package/src/render-html.js +140 -0
  171. package/src/render-report-text.js +27 -0
  172. package/src/render-sheet.js +261 -0
  173. package/src/render-terminal.js +109 -0
  174. package/src/render-usage-report-text.js +155 -0
  175. package/src/repo-context.js +215 -0
  176. package/src/report-gating.js +49 -0
  177. package/src/report-store.js +226 -0
  178. package/src/report-theme.js +177 -0
  179. package/src/report-view-client.js +50 -0
  180. package/src/reveal-output.js +29 -0
  181. package/src/roadmap-content.js +453 -0
  182. package/src/roadmap-personalization.js +132 -0
  183. package/src/roadmap-prompt.js +154 -0
  184. package/src/roles-client.js +112 -0
  185. package/src/roles-flow.js +192 -0
  186. package/src/sanitize-network-text.js +36 -0
  187. package/src/saved-positions-client.js +71 -0
  188. package/src/saved-positions-flow.js +50 -0
  189. package/src/scan-exclusions.js +50 -0
  190. package/src/scanner.js +624 -0
  191. package/src/scope-signals.js +148 -0
  192. package/src/secret-prompt.js +72 -0
  193. package/src/session-gate.js +30 -0
  194. package/src/session-normalizers.js +174 -0
  195. package/src/session-refresh.js +51 -0
  196. package/src/session-scan.js +475 -0
  197. package/src/session-signals.js +36 -0
  198. package/src/shakers-wordmark.js +17 -0
  199. package/src/share-card.js +362 -0
  200. package/src/share-certification.js +111 -0
  201. package/src/share-consent-state.js +157 -0
  202. package/src/share-payload.js +300 -0
  203. package/src/share.js +185 -0
  204. package/src/show-project-client.js +58 -0
  205. package/src/show-project-flow.js +119 -0
  206. package/src/signup-client.js +103 -0
  207. package/src/signup-copy.js +450 -0
  208. package/src/signup-language.js +21 -0
  209. package/src/skills-client.js +102 -0
  210. package/src/socials-client.js +91 -0
  211. package/src/socials-flow.js +83 -0
  212. package/src/specificity-extract.js +41 -0
  213. package/src/start-add-agents.js +306 -0
  214. package/src/start-add-portfolio.js +388 -0
  215. package/src/start-add-skills.js +260 -0
  216. package/src/stdin-ask.js +129 -0
  217. package/src/steering-extract.js +43 -0
  218. package/src/strip-internal-comments.js +45 -0
  219. package/src/tech-detector.js +295 -0
  220. package/src/templates/report-sheet.html +572 -0
  221. package/src/terminal-agents.js +207 -0
  222. package/src/terminal-format.js +85 -0
  223. package/src/terminal-progress.js +106 -0
  224. package/src/terminal-sections.js +219 -0
  225. package/src/tier-analysis.js +126 -0
  226. package/src/tier-engine.js +114 -0
  227. package/src/tier-projection.js +117 -0
  228. package/src/transcript-turns.js +49 -0
  229. package/src/translations-client.js +84 -0
  230. package/src/typewriter.js +154 -0
  231. package/src/update-flow.js +223 -0
  232. package/src/update-notifier.js +113 -0
  233. package/src/usage-model-calls.js +119 -0
  234. package/src/usage-oneshot-commands.js +180 -0
  235. package/src/usage-repo-scope.js +162 -0
  236. package/src/usage-run-steps.js +354 -0
  237. package/src/work-streams.js +79 -0
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ const { postJsonWithTimeout, extractSetCookie } = require('./backend-request');
4
+
5
+ // Login / token acquisition against the Hub's better-auth email engine (owner decision, supersedes ADR-031 for the email path).
6
+
7
+ const DEFAULT_TIMEOUT_MS = 20000;
8
+ const SESSION_COOKIE_NAME = 'better-auth.session_token';
9
+ // Refresh the cookie session's own validity from the Set-Cookie Max-Age; fall
10
+ // back to 7 days (better-auth's default) when the attribute is absent.
11
+ const DEFAULT_COOKIE_TTL_SECONDS = 7 * 24 * 60 * 60;
12
+
13
+ function parseJson(raw) {
14
+ try { return raw ? JSON.parse(raw) : null; } catch { return null; }
15
+ }
16
+
17
+ // `exp` (JWT seconds) -> ISO, or null. Never throws.
18
+ function expiresAtFromJwt(token) {
19
+ try {
20
+ const parts = String(token).split('.');
21
+ if (parts.length < 2) return null;
22
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
23
+ const exp = typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp : null;
24
+ return exp === null ? null : new Date(exp * 1000).toISOString();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // ISO expiry for the session cookie, read from the Set-Cookie `Max-Age` (or
31
+ // `Expires`) of the named cookie; defaults to +7 days when neither is present.
32
+ function cookieExpiresAtFromHeaders(headers, name = SESSION_COOKIE_NAME) {
33
+ const raw = headers && headers['set-cookie'];
34
+ const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
35
+ // Match the base name with or without better-auth's HTTPS `__Secure-`/`__Host-` prefixes.
36
+ const names = [name, `__Secure-${name}`, `__Host-${name}`];
37
+ const entry = list.find((e) => typeof e === 'string' && names.some((n) => e.startsWith(`${n}=`)));
38
+ if (entry) {
39
+ const maxAge = /(?:^|;\s*)max-age=(\d+)/i.exec(entry);
40
+ if (maxAge) return new Date(Date.now() + Number(maxAge[1]) * 1000).toISOString();
41
+ const expires = /(?:^|;\s*)expires=([^;]+)/i.exec(entry);
42
+ if (expires) {
43
+ const t = Date.parse(expires[1]);
44
+ if (Number.isFinite(t)) return new Date(t).toISOString();
45
+ }
46
+ }
47
+ return new Date(Date.now() + DEFAULT_COOKIE_TTL_SECONDS * 1000).toISOString();
48
+ }
49
+
50
+ function normalizeResponse(status, raw) {
51
+ if (status >= 200 && status < 300) {
52
+ let json = null;
53
+ try { json = raw ? JSON.parse(raw) : null; } catch { json = null; }
54
+ const payload = json && typeof json.data === 'object' && json.data !== null ? json.data : json;
55
+ const accessToken = payload && typeof payload.accessToken === 'string' && payload.accessToken ? payload.accessToken : null;
56
+ if (!accessToken) return { ok: false, reason: 'bad-response', status };
57
+ const expiresAt = payload && typeof payload.expiresAt === 'string' && payload.expiresAt ? payload.expiresAt : null;
58
+ const emailRaw = payload && (payload.userEmail || payload.email);
59
+ const email = typeof emailRaw === 'string' && emailRaw ? emailRaw : null;
60
+ const hubAccessToken = payload && typeof payload.hubAccessToken === 'string' && payload.hubAccessToken ? payload.hubAccessToken : null;
61
+ return { ok: true, accessToken, expiresAt, email, hubAccessToken };
62
+ }
63
+ if (status === 401) return { ok: false, reason: 'invalid-credentials', status };
64
+ if (status === 409 || status === 422) return { ok: false, reason: 'no-email-password', status };
65
+ if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
66
+ return { ok: false, reason: 'http-error', status };
67
+ }
68
+
69
+ // Maps the sign-in POST outcome to a captured cookie + email, or a named reason.
70
+ function normalizeSignIn(status, raw, headers) {
71
+ if (status >= 200 && status < 300) {
72
+ const cookie = extractSetCookie(headers, SESSION_COOKIE_NAME);
73
+ if (!cookie) return { ok: false, reason: 'bad-response', status };
74
+ const body = parseJson(raw);
75
+ const user = body && typeof body === 'object' ? body.user : null;
76
+ const emailRaw = user && typeof user.email === 'string' ? user.email : null;
77
+ return {
78
+ ok: true,
79
+ cookie,
80
+ cookieExpiresAt: cookieExpiresAtFromHeaders(headers),
81
+ email: emailRaw || null,
82
+ };
83
+ }
84
+ if (status === 401) return { ok: false, reason: 'invalid-credentials', status };
85
+ if (status === 409 || status === 422) return { ok: false, reason: 'no-email-password', status };
86
+ if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
87
+ return { ok: false, reason: 'http-error', status };
88
+ }
89
+
90
+ // Mints a hub JWT from a better-auth session cookie: `GET {tokenEndpoint}` with the `Cookie` header.
91
+ async function requestAuthToken({ cookie } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
92
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
93
+ if (!cookie) return { ok: false, reason: 'no-cookie' };
94
+ let res;
95
+ try {
96
+ res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, { Cookie: cookie });
97
+ } catch (e) {
98
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
99
+ }
100
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: 'http-error', status: res.status };
101
+ const body = parseJson(res.raw);
102
+ const token = body && typeof body.token === 'string' && body.token ? body.token : null;
103
+ if (!token) return { ok: false, reason: 'bad-response', status: res.status };
104
+ return { ok: true, accessToken: token, expiresAt: expiresAtFromJwt(token) };
105
+ }
106
+
107
+ // The full email login: sign-in (capture cookie) -> mint JWT.
108
+ async function requestLogin(
109
+ { email, password },
110
+ { signInEndpoint, tokenEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
111
+ ) {
112
+ if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
113
+ let signIn;
114
+ try {
115
+ const { status, raw, headers } = await postJsonWithTimeout(signInEndpoint, { email, password }, timeoutMs);
116
+ signIn = normalizeSignIn(status, raw, headers);
117
+ } catch (e) {
118
+ return { ok: false, reason: (e && e.kind) || 'local-error' };
119
+ }
120
+ if (!signIn.ok) return signIn;
121
+
122
+ const tok = await requestAuthToken({ cookie: signIn.cookie }, { endpoint: tokenEndpoint, timeoutMs });
123
+ if (!tok.ok) return { ok: false, reason: 'token-fetch-failed', status: tok.status || null };
124
+
125
+ return {
126
+ ok: true,
127
+ accessToken: tok.accessToken,
128
+ hubAccessToken: tok.accessToken,
129
+ expiresAt: tok.expiresAt,
130
+ email: signIn.email || (typeof email === 'string' ? email : null),
131
+ cookie: signIn.cookie,
132
+ cookieExpiresAt: signIn.cookieExpiresAt,
133
+ };
134
+ }
135
+
136
+ module.exports = {
137
+ requestLogin,
138
+ requestAuthToken,
139
+ normalizeSignIn,
140
+ normalizeResponse,
141
+ expiresAtFromJwt,
142
+ SESSION_COOKIE_NAME,
143
+ DEFAULT_TIMEOUT_MS,
144
+ };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const { postJsonWithTimeout } = require('./backend-request');
4
+
5
+ // OAuth 2.0 Device Authorization Grant (RFC 8628) client against the Hub broker.
6
+
7
+ const DEFAULT_TIMEOUT_MS = 20000;
8
+
9
+ const CLIENT_ID = 'shakers-cli';
10
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
11
+
12
+ function parseJson(raw) {
13
+ try {
14
+ return raw ? JSON.parse(raw) : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function unwrap(json) {
21
+ if (json && typeof json === 'object' && json.data && typeof json.data === 'object') return json.data;
22
+ return json && typeof json === 'object' ? json : null;
23
+ }
24
+
25
+ function str(value) {
26
+ return typeof value === 'string' && value ? value : null;
27
+ }
28
+
29
+ function posInt(value) {
30
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
31
+ }
32
+
33
+ async function requestDeviceAuthorization({ endpoint, clientId = CLIENT_ID, scope = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
34
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
35
+ const body = { client_id: clientId };
36
+ if (scope) body.scope = scope;
37
+ let res;
38
+ try {
39
+ res = await postJsonWithTimeout(endpoint, body, timeoutMs);
40
+ } catch (e) {
41
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
42
+ }
43
+ const { status, raw } = res;
44
+ if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
45
+ if (status < 200 || status >= 300) return { ok: false, reason: 'http-error', status };
46
+
47
+ const data = unwrap(parseJson(raw));
48
+ const deviceCode = data && str(data.device_code);
49
+ const userCode = data && str(data.user_code);
50
+ const verificationUri = data && str(data.verification_uri);
51
+ if (!deviceCode || !userCode || !verificationUri) return { ok: false, reason: 'bad-response', status };
52
+
53
+ return {
54
+ ok: true,
55
+ deviceCode,
56
+ userCode,
57
+ verificationUri,
58
+ verificationUriComplete: (data && str(data.verification_uri_complete)) || verificationUri,
59
+ expiresIn: (data && posInt(data.expires_in)) || 600,
60
+ interval: (data && posInt(data.interval)) || 5,
61
+ };
62
+ }
63
+
64
+ // One poll. Success carries the better-auth SESSION token (access_token), NOT the API JWT.
65
+ async function requestDeviceToken({ deviceCode, clientId = CLIENT_ID }, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
66
+ if (!endpoint) return { status: 'error', reason: 'no-endpoint' };
67
+ if (!deviceCode) return { status: 'error', reason: 'no-device-code' };
68
+ const body = { grant_type: DEVICE_GRANT_TYPE, device_code: deviceCode, client_id: clientId };
69
+ let res;
70
+ try {
71
+ res = await postJsonWithTimeout(endpoint, body, timeoutMs);
72
+ } catch (e) {
73
+ return { status: 'error', reason: (e && e.kind) || 'network-error' };
74
+ }
75
+ const { status, raw } = res;
76
+ const data = unwrap(parseJson(raw));
77
+
78
+ if (status >= 200 && status < 300) {
79
+ const accessToken = data && (str(data.access_token) || str(data.accessToken) || str(data.token));
80
+ if (!accessToken) return { status: 'error', reason: 'bad-response', http: status };
81
+ return {
82
+ status: 'authorized',
83
+ accessToken,
84
+ tokenType: (data && str(data.token_type)) || 'Bearer',
85
+ expiresIn: data && posInt(data.expires_in),
86
+ isNewUser: !!(data && (data.isNewUser === true || data.is_new_user === true)),
87
+ email: data && (str(data.email) || str(data.userEmail)),
88
+ };
89
+ }
90
+
91
+ const code = data && (str(data.error) || str(data.code));
92
+ if (code === 'authorization_pending') return { status: 'pending' };
93
+ if (code === 'slow_down') return { status: 'slow_down' };
94
+ if (code === 'access_denied') return { status: 'denied' };
95
+ if (code === 'expired_token') return { status: 'expired' };
96
+ if (status === 502 || status === 503) return { status: 'error', reason: 'upstream', http: status };
97
+ return { status: 'error', reason: code || 'http-error', http: status };
98
+ }
99
+
100
+ async function requestApiToken({ accessToken }, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
101
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
102
+ if (!accessToken) return { ok: false, reason: 'no-access-token' };
103
+ let res;
104
+ try {
105
+ res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, { Authorization: `Bearer ${accessToken}` });
106
+ } catch (e) {
107
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
108
+ }
109
+ const { status, raw } = res;
110
+ if (status === 502 || status === 503) return { ok: false, reason: 'upstream', status };
111
+ if (status < 200 || status >= 300) return { ok: false, reason: 'http-error', status };
112
+ const data = unwrap(parseJson(raw));
113
+ const token = data && str(data.token);
114
+ if (!token) return { ok: false, reason: 'bad-response', status };
115
+ return { ok: true, token };
116
+ }
117
+
118
+ module.exports = { requestDeviceAuthorization, requestDeviceToken, requestApiToken, CLIENT_ID, DEVICE_GRANT_TYPE, DEFAULT_TIMEOUT_MS };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ const { runInteractiveMultiSelect } = require('./interactive-select');
4
+ const { methodsForFlow, methodLabel } = require('./auth-methods');
5
+
6
+ // The shared interactive method picker for the CLI (talents-ai-score).
7
+ async function askNumberedChoice(ask, promptText, count) {
8
+ const raw = (await ask(promptText)).trim();
9
+ if (!raw) return null;
10
+ const n = Number.parseInt(raw, 10);
11
+ if (!Number.isInteger(n) || n < 1 || n > count) return null;
12
+ return n - 1;
13
+ }
14
+
15
+ async function chooseAuthMethod({ flow, ask, stdinIsTTY, catalog, input, output } = {}) {
16
+ const methods = methodsForFlow(flow);
17
+ if (methods.length === 0) return null;
18
+ if (methods.length === 1) return methods[0].id;
19
+ if (!stdinIsTTY) return methods[0].id;
20
+
21
+ if (ask && typeof ask.suspend === 'function') {
22
+ ask.suspend();
23
+ const picked = await runInteractiveMultiSelect({
24
+ items: methods,
25
+ labelFor: (m) => methodLabel(m, catalog),
26
+ header: catalog.chooseMethodHeading,
27
+ hint: catalog.selectHint,
28
+ single: true,
29
+ ...(input ? { input } : {}),
30
+ ...(output ? { output } : {}),
31
+ });
32
+ if (typeof ask.resume === 'function') ask.resume();
33
+ if (!picked || picked.length === 0) return null;
34
+ return picked[0].id;
35
+ }
36
+
37
+ const stream = output || process.stdout;
38
+ stream.write(`\n ${catalog.chooseMethodHeading}\n`);
39
+ methods.forEach((m, i) => stream.write(` ${i + 1}) ${methodLabel(m, catalog)}\n`));
40
+ const idx = await askNumberedChoice(ask, ` ${catalog.chooseMethodPrompt(methods.length)}`, methods.length);
41
+ return idx === null ? null : methods[idx].id;
42
+ }
43
+
44
+ module.exports = { chooseAuthMethod, askNumberedChoice };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ // The SINGLE source of truth for which auth methods exist and where each one is offered (talents-ai-score).
4
+ const AUTH_METHODS = [
5
+ { id: 'email', labelKey: 'methodEmail', availableFor: { login: true, register: true } },
6
+ { id: 'google', labelKey: 'methodGoogle', availableFor: { login: true, register: true } },
7
+ { id: 'linkedin', labelKey: 'methodLinkedin', availableFor: { login: true, register: true } },
8
+ ];
9
+
10
+ // Social methods share ONE device flow; only the `provider` query param differs.
11
+ const SOCIAL_METHODS = new Set(['google', 'linkedin']);
12
+ function isSocialMethod(id) {
13
+ return SOCIAL_METHODS.has(id);
14
+ }
15
+
16
+ function methodsForFlow(flow) {
17
+ return AUTH_METHODS.filter((m) => m.availableFor && m.availableFor[flow] === true);
18
+ }
19
+
20
+ function methodIdsForFlow(flow) {
21
+ return methodsForFlow(flow).map((m) => m.id);
22
+ }
23
+
24
+ function isMethodAvailable(id, flow) {
25
+ return methodIdsForFlow(flow).includes(id);
26
+ }
27
+
28
+ function methodLabel(method, catalog) {
29
+ const entry = typeof method === 'string' ? AUTH_METHODS.find((m) => m.id === method) : method;
30
+ const key = entry && entry.labelKey;
31
+ if (catalog && key && catalog[key]) return catalog[key];
32
+ return entry ? entry.id : String(method);
33
+ }
34
+
35
+ module.exports = { AUTH_METHODS, SOCIAL_METHODS, isSocialMethod, methodsForFlow, methodIdsForFlow, isMethodAvailable, methodLabel };
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { getConfigDir } = require('./config-dir');
7
+
8
+ // `~/.config/shakers/auth-session.json` — the logged-in Talent's session (talents-ai-score, issue 122 / ADR-044).
9
+
10
+ // Hub's `UserSubtypeEnum` (shared/domain/enum/user-subtype.enum.ts), copied VERBATIM — this repo never invents a value the wire does not send.
11
+ const PLATFORM_TALENT_USER_TYPES = new Set(['WORKS_TALENT', 'WORKS_PARTNER']);
12
+ const KNOWN_USER_TYPES = new Set(['HUB', 'WORKS_CLIENT', 'WORKS_TALENT', 'WORKS_PARTNER']);
13
+
14
+ // Decodes (NEVER verifies — this module has no secret to verify with, and the claim only ever picks which COPY prints) a JWT's payload segment.
15
+ function decodeJwtPayload(token) {
16
+ if (typeof token !== 'string' || !token) return null;
17
+ const parts = token.split('.');
18
+ if (parts.length !== 3) return null;
19
+ try {
20
+ const json = Buffer.from(parts[1], 'base64url').toString('utf8');
21
+ const parsed = JSON.parse(json);
22
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ // The one place `hubAccessToken` -> `userType` happens.
29
+ function userTypeFromHubToken(hubAccessToken) {
30
+ const payload = decodeJwtPayload(hubAccessToken);
31
+ const userType = payload && typeof payload.userType === 'string' ? payload.userType : null;
32
+ return userType && KNOWN_USER_TYPES.has(userType) ? userType : null;
33
+ }
34
+
35
+ function emailFromHubToken(hubAccessToken) {
36
+ const payload = decodeJwtPayload(hubAccessToken);
37
+ const email = payload && typeof payload.email === 'string' ? payload.email : null;
38
+ return email || null;
39
+ }
40
+
41
+ function firstNameFromSession(session) {
42
+ if (!session || typeof session !== 'object') return null;
43
+ const token = (typeof session.hubAccessToken === 'string' && session.hubAccessToken)
44
+ ? session.hubAccessToken
45
+ : (typeof session.accessToken === 'string' ? session.accessToken : null);
46
+ const payload = decodeJwtPayload(token);
47
+ if (!payload) return null;
48
+ const raw = [payload.name, payload.given_name, payload.firstName, payload.first_name]
49
+ .find((v) => typeof v === 'string' && v.trim());
50
+ if (!raw) return null;
51
+ const first = raw.trim().split(/\s+/)[0];
52
+ return first || null;
53
+ }
54
+
55
+ // `exp` (JWT seconds-since-epoch) -> ISO string, or null.
56
+ function expiresAtFromToken(token) {
57
+ const payload = decodeJwtPayload(token);
58
+ const exp = payload && typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp : null;
59
+ if (exp === null) return null;
60
+ try {
61
+ return new Date(exp * 1000).toISOString();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // The ONE predicate every gate reads (src/report-gating.js), so two call
68
+ // sites can never disagree about which `userType` values count as a Talent.
69
+ function isPlatformTalent(session) {
70
+ return !!(session && PLATFORM_TALENT_USER_TYPES.has(session.userType));
71
+ }
72
+
73
+ const SESSION_FILE_NAME = 'auth-session.json';
74
+
75
+ // Bumped when the shape changes in a way an older/newer CLI cannot read.
76
+ const SESSION_FILE_VERSION = 2;
77
+
78
+ function configDir(env = process.env) {
79
+ return getConfigDir(env);
80
+ }
81
+
82
+ function authSessionPath(env = process.env) {
83
+ return path.join(configDir(env), SESSION_FILE_NAME);
84
+ }
85
+
86
+ // ATOMIC 0600 write.
87
+ function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
88
+ const explicitHubToken = typeof session.hubAccessToken === 'string' && session.hubAccessToken ? session.hubAccessToken : null;
89
+ const hubAccessToken = explicitHubToken || (typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null);
90
+
91
+ const cookie = typeof session.cookie === 'string' && session.cookie ? session.cookie : null;
92
+ let expiresAt;
93
+ let accessTokenExpiresAt;
94
+ if (cookie) {
95
+ expiresAt = (typeof session.cookieExpiresAt === 'string' && session.cookieExpiresAt)
96
+ ? session.cookieExpiresAt
97
+ : (typeof session.expiresAt === 'string' && session.expiresAt ? session.expiresAt : null);
98
+ accessTokenExpiresAt = (typeof session.accessTokenExpiresAt === 'string' && session.accessTokenExpiresAt)
99
+ ? session.accessTokenExpiresAt
100
+ : expiresAtFromToken(session.accessToken);
101
+ } else {
102
+ expiresAt = (typeof session.expiresAt === 'string' && session.expiresAt) ? session.expiresAt : expiresAtFromToken(session.accessToken);
103
+ accessTokenExpiresAt = expiresAt;
104
+ }
105
+
106
+ const payload = `${JSON.stringify(
107
+ {
108
+ accessToken: typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null,
109
+ expiresAt: expiresAt ?? null,
110
+ accessTokenExpiresAt: accessTokenExpiresAt ?? null,
111
+ cookie,
112
+ email: (typeof session.email === 'string' && session.email ? session.email : null)
113
+ || emailFromHubToken(hubAccessToken),
114
+ hubAccessToken,
115
+ // Decoded once from the SAME `hubAccessToken` this session carries (see the
116
+ // module docblock) — never a second round-trip or source of truth.
117
+ userType: userTypeFromHubToken(hubAccessToken),
118
+ version: SESSION_FILE_VERSION,
119
+ },
120
+ null,
121
+ 2,
122
+ )}\n`;
123
+
124
+ const target = authSessionPath(env);
125
+ const dir = path.dirname(target);
126
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+
128
+ const tmp = path.join(dir, `.${SESSION_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
129
+ let fd = null;
130
+ try {
131
+ fd = fsImpl.openSync(tmp, 'wx', 0o600);
132
+ fsImpl.writeFileSync(fd, payload);
133
+ try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
134
+ fsImpl.closeSync(fd);
135
+ fd = null;
136
+ fsImpl.renameSync(tmp, target);
137
+ } catch (e) {
138
+ if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
139
+ // A stale temp would accumulate one bearer-token-holding file per failure.
140
+ try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
141
+ throw e;
142
+ }
143
+ return target;
144
+ }
145
+
146
+ // Reads the file, or `null` for "no session": missing, unreadable, malformed JSON, unrecognized `version`, or missing the one field without which there is no session (`accessToken`).
147
+ function loadAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(fsImpl.readFileSync(authSessionPath(env), 'utf8'));
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
155
+ if (parsed.version !== SESSION_FILE_VERSION) return null;
156
+ // A usable session needs at least ONE credential: the durable cookie (email/
157
+ // better-auth) or a JWT (Google/legacy, or an email session whose JWT is cached).
158
+ const hasCookie = typeof parsed.cookie === 'string' && parsed.cookie;
159
+ const hasToken = typeof parsed.accessToken === 'string' && parsed.accessToken;
160
+ if (!hasCookie && !hasToken) return null;
161
+ const hubAccessToken = typeof parsed.hubAccessToken === 'string' && parsed.hubAccessToken ? parsed.hubAccessToken : null;
162
+ const email = (typeof parsed.email === 'string' && parsed.email ? parsed.email : null) || emailFromHubToken(hubAccessToken);
163
+ return {
164
+ accessToken: hasToken ? parsed.accessToken : null,
165
+ expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null,
166
+ accessTokenExpiresAt: typeof parsed.accessTokenExpiresAt === 'string' ? parsed.accessTokenExpiresAt : null,
167
+ cookie: hasCookie ? parsed.cookie : null,
168
+ email,
169
+ hubAccessToken,
170
+ userType: typeof parsed.userType === 'string' && KNOWN_USER_TYPES.has(parsed.userType) ? parsed.userType : null,
171
+ };
172
+ }
173
+
174
+ // Removes the file. Idempotent and never throws — `logout` on no session is a
175
+ // no-op, not a failure.
176
+ function clearAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
177
+ try {
178
+ fsImpl.unlinkSync(authSessionPath(env));
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ // `none | active | expired`, the three states the caller needs to give the Talent a true message.
186
+ function sessionStatus(session, now = Date.now()) {
187
+ // A credential is either the durable cookie (email) or a JWT (Google/legacy).
188
+ const hasCred = !!(session && ((typeof session.accessToken === 'string' && session.accessToken) || (typeof session.cookie === 'string' && session.cookie)));
189
+ if (!hasCred) return 'none';
190
+ const expiry = Date.parse(String(session.expiresAt || ''));
191
+ if (!Number.isFinite(expiry)) return 'expired';
192
+ return expiry > now ? 'active' : 'expired';
193
+ }
194
+
195
+ // The single predicate the rest of the CLI asks: is there a usable session right
196
+ // now? Everything gating reads reduces to this.
197
+ function isLoggedIn(env = process.env, now = Date.now(), deps = {}) {
198
+ return sessionStatus(loadAuthSession(env, deps), now) === 'active';
199
+ }
200
+
201
+ module.exports = {
202
+ SESSION_FILE_NAME,
203
+ SESSION_FILE_VERSION,
204
+ authSessionPath,
205
+ saveAuthSession,
206
+ loadAuthSession,
207
+ clearAuthSession,
208
+ sessionStatus,
209
+ isLoggedIn,
210
+ isPlatformTalent,
211
+ PLATFORM_TALENT_USER_TYPES,
212
+ decodeJwtPayload,
213
+ emailFromHubToken,
214
+ firstNameFromSession,
215
+ };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ // Display-only authorship-transparency check (talents-ai-score).
4
+ //
5
+ // Warns the talent when their commits in the selected repos are authored under
6
+ // an email that is not their Shakers account email, so their own work would be
7
+ // attributed to nobody. This is a WARNING surface ONLY: it reads each repo's
8
+ // CONFIGURED commit identity (`git config user.email`) and compares counts the
9
+ // attribution path already produced — it NEVER feeds attribution/scoring.
10
+ // git-activity.js owns `authoredCommitCount`; nothing here changes any count.
11
+
12
+ const { execFileSync } = require('child_process');
13
+
14
+ // The repo's locally configured commit identity. A plain config read, NOT the
15
+ // authorship log parse — it only names who new commits here would be attributed to.
16
+ function readConfiguredEmail(root) {
17
+ try {
18
+ const out = execFileSync('git', ['-C', root, 'config', '--get', 'user.email'], {
19
+ encoding: 'utf8',
20
+ timeout: 5000,
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ });
23
+ const value = String(out || '').trim();
24
+ return value || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function normalize(email) {
31
+ return String(email || '').trim().toLowerCase();
32
+ }
33
+
34
+ // Returns { accountEmail, emails: [configured emails that differ from the account] }
35
+ // when the talent's own commits are plausibly going unattributed, else null.
36
+ //
37
+ // Conservative on purpose (a false warning erodes trust more than a missed one):
38
+ // it fires only when the account email is known, the selection HAS commits, NONE
39
+ // are attributed to the account, and at least one repo is configured under a
40
+ // different email we can name.
41
+ function detectAuthorshipMismatch(toplevels, accountEmail, gitActivity, { readEmail = readConfiguredEmail } = {}) {
42
+ const account = normalize(accountEmail);
43
+ if (!account) return null;
44
+ if (!gitActivity) return null;
45
+ const commits = gitActivity.commitCount || 0;
46
+ const authored = gitActivity.authoredCommitCount || 0;
47
+ if (commits === 0 || authored > 0) return null;
48
+
49
+ const emails = [];
50
+ const seen = new Set();
51
+ for (const root of Array.isArray(toplevels) ? toplevels : []) {
52
+ const configured = readEmail(root);
53
+ const key = normalize(configured);
54
+ if (!key || key === account || seen.has(key)) continue;
55
+ seen.add(key);
56
+ emails.push(String(configured).trim());
57
+ }
58
+ if (emails.length === 0) return null;
59
+ return { accountEmail: String(accountEmail).trim(), emails };
60
+ }
61
+
62
+ module.exports = { detectAuthorshipMismatch, readConfiguredEmail };