shakers-cli 0.17.0 → 0.18.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.
Files changed (59) hide show
  1. package/README.md +15 -5
  2. package/bin/command-audit-worker.js +19 -0
  3. package/bin/login.js +4 -8
  4. package/bin/mcp.js +29 -19
  5. package/bin/register.js +10 -29
  6. package/bin/shakers-cli.js +14 -9
  7. package/package.json +1 -1
  8. package/src/agents-client.js +1 -0
  9. package/src/ai-usage-review.js +5 -3
  10. package/src/auth-client.js +7 -107
  11. package/src/auth-session-store.js +6 -12
  12. package/src/backend-request.js +1 -24
  13. package/src/cli-skill.js +77 -0
  14. package/src/command-audit.js +47 -4
  15. package/src/config.js +0 -16
  16. package/src/consent-flow.js +7 -2
  17. package/src/detect-repos.js +4 -8
  18. package/src/detectors.js +23 -0
  19. package/src/device-auth-store.js +2 -2
  20. package/src/device-login.js +11 -9
  21. package/src/env-paths.js +9 -1
  22. package/src/i18n.js +162 -88
  23. package/src/mcp-ai-usage-result-tool.js +6 -4
  24. package/src/mcp-ai-usage-tool.js +72 -46
  25. package/src/mcp-choice.js +171 -34
  26. package/src/mcp-detector.js +26 -2
  27. package/src/mcp-device-login-tools.js +40 -8
  28. package/src/mcp-install.js +15 -11
  29. package/src/mcp-instructions.js +43 -17
  30. package/src/mcp-onboarding-tools.js +1 -3
  31. package/src/mcp-prompts.js +6 -4
  32. package/src/mcp-register-tools.js +25 -14
  33. package/src/mcp-role-tools.js +35 -17
  34. package/src/mcp-server-name.js +7 -0
  35. package/src/mcp-server.js +35 -7
  36. package/src/mcp-signup-client.js +3 -6
  37. package/src/mcp-signup-flow.js +389 -0
  38. package/src/mcp-signup-tools.js +237 -126
  39. package/src/onboarding-flow.js +30 -125
  40. package/src/opencode-sessions.js +92 -0
  41. package/src/profile-agents.js +26 -0
  42. package/src/saved-positions-flow.js +3 -3
  43. package/src/scanner.js +104 -12
  44. package/src/session-gate.js +1 -4
  45. package/src/session-normalizers.js +114 -8
  46. package/src/session-refresh.js +5 -16
  47. package/src/session-scan.js +167 -22
  48. package/src/share-payload.js +4 -1
  49. package/src/share.js +2 -2
  50. package/src/signup-client.js +12 -39
  51. package/src/signup-copy.js +380 -68
  52. package/src/skill-install.js +102 -0
  53. package/src/tier-engine.js +14 -2
  54. package/src/trust-narration.js +32 -0
  55. package/src/usage-run-steps.js +12 -3
  56. package/src/usage-scan.js +3 -0
  57. package/src/brand-svg.js +0 -9
  58. package/src/inter-fonts.js +0 -6
  59. package/src/mcp-auth-form.js +0 -484
@@ -1,484 +0,0 @@
1
- 'use strict';
2
-
3
- const http = require('http');
4
- const crypto = require('crypto');
5
- const { openPath } = require('./open-file');
6
- const flow = require('./onboarding-flow');
7
- const { methodIdsForFlow } = require('./auth-methods');
8
- const { INTER_FONT_FACE_CSS } = require('./inter-fonts');
9
- const { SHAKERS_MARK_WHITE_SVG, SHAKERS_WORDMARK_SVG } = require('./brand-svg');
10
-
11
- const DEFAULT_TIMEOUT_MS = 180000;
12
- const MAX_BODY_BYTES = 64 * 1024;
13
-
14
- function esc(s) {
15
- return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
16
- }
17
-
18
- const FONT_STACK = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
19
-
20
- const COPY = {
21
- en: {
22
- signin: 'Sign in', create: 'Create account', welcome: 'Welcome to Shakers. Create your talent account to get started.',
23
- email: 'Email', password: 'Password', repeat: 'Repeat password', first: 'First name', last: 'Last name',
24
- newsletter: 'I want to receive content about the future of work',
25
- hint: 'Your password stays on this machine. It is never sent to the chat.',
26
- done: 'You are signed in.', doneSub: 'You can close this tab and return to your AI app.',
27
- chooseTitleLogin: 'How do you want to sign in?', chooseTitleRegister: 'How do you want to create your account?',
28
- continueGoogle: 'Continue with Google', continueLinkedin: 'Continue with LinkedIn', continueEmail: 'Email and password', continueEmailRegister: 'Continue with email',
29
- socialOpening: (name) => `Opening sign-in with ${name}…`,
30
- socialOpeningSub: 'A sign-in window just opened. Finish there and come back to your AI app, it will carry on.',
31
- existing: 'You already have a Shakers account with this email. Sign in with your password or with Google.',
32
- errors: {
33
- 'password-mismatch': 'Passwords do not match.', 'weak-password': 'The password needs at least 8 characters.',
34
- 'missing-fields': 'Fill in every field.', 'invalid-credentials': 'Wrong email or password.',
35
- 'legal-not-shown': 'Something went wrong. Start again from the first screen.', other: 'Something went wrong. Try again in a moment.',
36
- },
37
- },
38
- es: {
39
- signin: 'Iniciar sesión', create: 'Crear cuenta', welcome: 'Bienvenido a Shakers. Crea tu cuenta de talento para empezar.',
40
- email: 'Email', password: 'Contraseña', repeat: 'Repite la contraseña', first: 'Nombre', last: 'Apellidos',
41
- newsletter: 'Quiero recibir contenido sobre el futuro del trabajo',
42
- hint: 'Tu contraseña se queda en este equipo. Nunca se envía al chat.',
43
- done: 'Sesión iniciada.', doneSub: 'Puedes cerrar esta pestaña y volver a tu app de IA.',
44
- chooseTitleLogin: '¿Cómo quieres iniciar sesión?', chooseTitleRegister: '¿Cómo quieres crear tu cuenta?',
45
- continueGoogle: 'Continuar con Google', continueLinkedin: 'Continuar con LinkedIn', continueEmail: 'Email y contraseña', continueEmailRegister: 'Continuar con email',
46
- socialOpening: (name) => `Abriendo el acceso con ${name}…`,
47
- socialOpeningSub: 'Se acaba de abrir una ventana para terminar el acceso. Cuando acabes, vuelve a tu app de IA y seguirá por su cuenta.',
48
- existing: 'Ya tienes una cuenta en Shakers con este email. Entra con tu contraseña o con Google.',
49
- errors: {
50
- 'password-mismatch': 'Las contraseñas no coinciden.', 'weak-password': 'La contraseña necesita al menos 8 caracteres.',
51
- 'missing-fields': 'Rellena todos los campos.', 'invalid-credentials': 'Email o contraseña incorrectos.',
52
- 'legal-not-shown': 'Algo ha fallado. Empieza otra vez desde la primera pantalla.', other: 'Algo ha fallado. Inténtalo de nuevo en un momento.',
53
- },
54
- },
55
- it: {
56
- signin: 'Accedi', create: 'Crea account', welcome: 'Benvenuto in Shakers. Crea il tuo account talent per iniziare.',
57
- email: 'Email', password: 'Password', repeat: 'Ripeti la password', first: 'Nome', last: 'Cognome',
58
- newsletter: 'Voglio ricevere contenuti sul futuro del lavoro',
59
- hint: 'La tua password resta su questo computer. Non viene mai inviata alla chat.',
60
- done: 'Accesso effettuato.', doneSub: 'Puoi chiudere questa scheda e tornare alla tua app di IA.',
61
- chooseTitleLogin: 'Come vuoi accedere?', chooseTitleRegister: 'Come vuoi creare il tuo account?',
62
- continueGoogle: 'Continua con Google', continueLinkedin: 'Continua con LinkedIn', continueEmail: 'Email e password', continueEmailRegister: 'Continua con email',
63
- socialOpening: (name) => `Apertura dell’accesso con ${name}…`,
64
- socialOpeningSub: 'Si è appena aperta una finestra per completare l’accesso. Quando hai finito, torna alla tua app di IA: continuerà da sola.',
65
- existing: 'Hai già un account Shakers con questa email. Accedi con la tua password o con Google.',
66
- errors: {
67
- 'password-mismatch': 'Le password non coincidono.', 'weak-password': 'La password deve avere almeno 8 caratteri.',
68
- 'missing-fields': 'Compila tutti i campi.', 'invalid-credentials': 'Email o password errate.',
69
- 'legal-not-shown': 'Qualcosa è andato storto. Ricomincia dalla prima schermata.', other: 'Qualcosa è andato storto. Riprova tra un momento.',
70
- },
71
- },
72
- pt: {
73
- signin: 'Iniciar sessão', create: 'Criar conta', welcome: 'Bem-vindo à Shakers. Crie a sua conta de talento para começar.',
74
- email: 'Email', password: 'Palavra-passe', repeat: 'Repita a palavra-passe', first: 'Nome', last: 'Apelidos',
75
- newsletter: 'Quero receber conteúdos sobre o futuro do trabalho',
76
- hint: 'A sua palavra-passe fica neste computador. Nunca é enviada para o chat.',
77
- done: 'Sessão iniciada.', doneSub: 'Pode fechar este separador e voltar à sua app de IA.',
78
- chooseTitleLogin: 'Como quer iniciar sessão?', chooseTitleRegister: 'Como quer criar a sua conta?',
79
- continueGoogle: 'Continuar com Google', continueLinkedin: 'Continuar com LinkedIn', continueEmail: 'Email e palavra-passe', continueEmailRegister: 'Continuar com email',
80
- socialOpening: (name) => `A abrir o acesso com ${name}…`,
81
- socialOpeningSub: 'Acabou de abrir-se uma janela para terminar o acesso. Quando acabar, volte à sua app de IA, que continua sozinha.',
82
- existing: 'Já tem uma conta na Shakers com este email. Entre com a sua palavra-passe ou com Google.',
83
- errors: {
84
- 'password-mismatch': 'As palavras-passe não coincidem.', 'weak-password': 'A palavra-passe precisa de pelo menos 8 caracteres.',
85
- 'missing-fields': 'Preencha todos os campos.', 'invalid-credentials': 'Email ou palavra-passe incorretos.',
86
- 'legal-not-shown': 'Algo correu mal. Comece de novo a partir do primeiro ecrã.', other: 'Algo correu mal. Tente novamente dentro de momentos.',
87
- },
88
- },
89
- };
90
-
91
- function copy(lang) {
92
- return COPY[String(lang || 'en').toLowerCase().split('-')[0]] || COPY.en;
93
- }
94
-
95
- // Internal reasons never reach the talent: each maps to a sentence in their language.
96
- function errorText(c, reason) {
97
- return reason ? c.errors[reason] || c.errors.other : '';
98
- }
99
-
100
- // The web sign-up's legal block (new-sign-up legal-links.ts and authStep.legalText), copy and links verbatim; the newsletter label is its emailAuthForm one.
101
- const LEGAL_BASE_URL = 'https://www.shakersworks.com';
102
- const LEGAL_PREFIXES = { es: '', en: 'en/', pt: 'pt/', it: 'it/' };
103
- const LEGAL_TEXT = {
104
- es: (terms, privacy) => `Al continuar, aceptas los ${terms('Términos')} y la ${privacy('Política de Privacidad')}.`,
105
- en: (terms, privacy) => `By continuing, you accept the ${terms('Terms')} and the ${privacy('Privacy Policy')}.`,
106
- it: (terms, privacy) => `Continuando, accetti i ${terms('Termini')} e l'${privacy('Informativa sulla privacy')}.`,
107
- pt: (terms, privacy) => `Ao continuar, aceita os ${terms('Termos')} e a ${privacy('Política de Privacidade')}.`,
108
- };
109
-
110
- function legalLinks(lang) {
111
- const code = String(lang || 'en').toLowerCase().split('-')[0];
112
- const prefix = code in LEGAL_PREFIXES ? LEGAL_PREFIXES[code] : 'en/';
113
- return { terms: `${LEGAL_BASE_URL}/${prefix}condiciones-generales`, privacy: `${LEGAL_BASE_URL}/${prefix}politica-de-privacidad` };
114
- }
115
-
116
- function legalHtml(lang) {
117
- const code = String(lang || 'en').toLowerCase().split('-')[0];
118
- const links = legalLinks(lang);
119
- const link = (href) => (label) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`;
120
- return `<p class="legal">${(LEGAL_TEXT[code] || LEGAL_TEXT.en)(link(links.terms), link(links.privacy))}</p>`;
121
- }
122
-
123
- function header() {
124
- return `<div class="brand"><span class="appicon">${SHAKERS_MARK_WHITE_SVG}</span></div>`;
125
- }
126
-
127
- function panel(text) {
128
- return `<div class="panel"><div class="pbrand">${SHAKERS_MARK_WHITE_SVG}${SHAKERS_WORDMARK_SVG}</div><p>${text}</p></div>`;
129
- }
130
-
131
- function styles() {
132
- return `<style>${INTER_FONT_FACE_CSS}`
133
- + `:root{--background:#ffffff;--foreground:#18181b;--primary:#05342c;--primary-foreground:#fafafa;`
134
- + `--secondary:#e2f2f0;--secondary-foreground:#0b5a4c;--muted-foreground:#3f3f46;--accent:#e2f2f0;--accent-foreground:#08473c;`
135
- + `--input:#d4d4d8;--border:#e4e4e7;--ring:#0e7d69;--destructive:#e11d48;--radius:0.625rem;--radius-md:calc(var(--radius) - 2px);`
136
- + `--shadow-xs:0 1px 3px 0px rgb(0 0 0 / .05)}`
137
- + `*{box-sizing:border-box}`
138
- + `body{font-family:${FONT_STACK};margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:var(--foreground);padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}`
139
- + `.card{width:100%;max-width:26rem;background:var(--background);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 10px 30px rgb(3 33 28 / .08);overflow:hidden}`
140
- + `.body{padding:2rem}`
141
- + `.brand{display:flex;align-items:center;gap:.6rem;margin:0 0 1.25rem}`
142
- + `.appicon{display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:2.5rem;border-radius:.7rem;background:var(--primary);flex:0 0 auto}`
143
- + `.appicon .mk{height:1.35rem;width:auto;display:block}`
144
- + `.panel{background:var(--primary);color:var(--primary-foreground);padding:1.75rem 2rem}`
145
- + `.pbrand{display:flex;align-items:center;gap:.7rem;margin:0 0 .75rem}`
146
- + `.pbrand .mk{height:1.5rem;width:auto;display:block}`
147
- + `.pbrand .wm{height:1.05rem;width:auto;display:block;color:var(--primary-foreground)}`
148
- + `.panel p{margin:0;font-size:.9rem;color:#cfe6e2;line-height:1.45}`
149
- + `h1{font-size:1.15rem;font-weight:600;color:var(--foreground);margin:0 0 1rem;letter-spacing:-.01em}`
150
- + `label{display:block;margin:.85rem 0 .35rem;font-size:.875rem;font-weight:500;color:var(--foreground);line-height:1}`
151
- + `label.chk{display:flex;align-items:center;gap:.5rem;font-weight:400}`
152
- + `input,select{height:2.25rem;width:100%;min-width:0;padding:.25rem .75rem;border:1px solid var(--input);border-radius:var(--radius-md);font-size:.875rem;font-family:inherit;line-height:1.25rem;color:var(--foreground);background:var(--background);box-shadow:var(--shadow-xs);outline:none;transition:color .15s,box-shadow .15s,border-color .15s}`
153
- + `input::placeholder{color:var(--muted-foreground)}`
154
- + `input:focus-visible,select:focus-visible{border-color:var(--ring);box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}`
155
- + `input[type=checkbox]{height:auto;width:auto;box-shadow:none;accent-color:var(--primary)}`
156
- + `button{margin-top:1.4rem;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;width:100%;height:2.25rem;padding:.5rem 1rem;border:1px solid var(--primary);border-radius:var(--radius-md);background:var(--primary);color:var(--primary-foreground);font-size:.875rem;font-weight:500;font-family:inherit;cursor:pointer;transition:background .15s,box-shadow .15s}`
157
- + `button:hover{background:color-mix(in oklab,var(--primary) 90%,transparent)}`
158
- + `button:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}`
159
- + `button:disabled{opacity:.5;pointer-events:none}`
160
- + `.err{background:color-mix(in oklab,var(--destructive) 12%,transparent);color:var(--destructive);padding:.6rem .7rem;border-radius:var(--radius-md);margin:0 0 .75rem;font-size:.875rem}`
161
- + `.hint{color:var(--muted-foreground);font-size:.8rem;margin:.9rem 0 0;line-height:1.45}`
162
- + `.actions{display:flex;flex-direction:column;gap:.6rem;margin-top:1.25rem}`
163
- + `.mbtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;width:100%;height:2.5rem;padding:.5rem 1rem;border-radius:var(--radius-md);font-size:.9rem;font-weight:500;font-family:inherit;text-decoration:none;cursor:pointer;border:1px solid transparent;transition:background .15s,border-color .15s,box-shadow .15s}`
164
- + `.mbtn:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}`
165
- + `.mbtn.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}`
166
- + `.mbtn.primary:hover{background:color-mix(in oklab,var(--primary) 90%,transparent)}`
167
- + `.mbtn.secondary{background:var(--background);color:var(--foreground);border-color:var(--border);box-shadow:var(--shadow-xs)}`
168
- + `.mbtn.secondary:hover{background:var(--accent);color:var(--accent-foreground)}`
169
- + `.mbtn .g{width:18px;height:18px;flex:0 0 auto}`
170
- + `.legal{margin:1rem 0 0;text-align:center;font-size:.75rem;line-height:1rem;color:var(--muted-foreground)}`
171
- + `.legal a{font-weight:600;color:var(--secondary-foreground);text-decoration:none}.legal a:hover{text-decoration:underline}`
172
- + `label.news{display:flex;align-items:flex-start;gap:.5rem;margin:1rem 0 0;font-weight:400;line-height:1.25rem}</style>`;
173
- }
174
-
175
- function page(title, { banner = '', inner = '' } = {}) {
176
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>`
177
- + `${styles()}</head><body><main class="card">${banner}<div class="body">${header()}${inner}</div></main></body></html>`;
178
- }
179
-
180
- function errorBanner(text) {
181
- return text ? `<div class="err">${esc(text)}</div>` : '';
182
- }
183
-
184
- function loginFormHtml(lang, { error } = {}) {
185
- const c = copy(lang);
186
- return page(`Shakers · ${c.signin}`, {
187
- inner: `<h1>${c.signin}</h1>${errorBanner(errorText(c, error))}`
188
- + `<form method="post" action="submit">`
189
- + `<label>${c.email}</label><input name="email" type="email" required autofocus>`
190
- + `<label>${c.password}</label><input name="password" type="password" required>`
191
- + `<button type="submit">${c.signin}</button>`
192
- + `<p class="hint">${c.hint}</p>`
193
- + `</form>`,
194
- });
195
- }
196
-
197
- // Identity the AI did not know is asked here, never in the chat: only the missing fields render.
198
- function missingIdentityInputs(c, missing = {}) {
199
- return (missing.email ? `<label>${c.email}</label><input name="email" type="email" required autocomplete="email">` : '')
200
- + (missing.name ? `<label>${c.first}</label><input name="name" required autocomplete="given-name">` : '')
201
- + (missing.lastName ? `<label>${c.last}</label><input name="lastName" required autocomplete="family-name">` : '');
202
- }
203
-
204
- function registerFormHtml(lang, { error, missing } = {}) {
205
- const c = copy(lang);
206
- return page(`Shakers · ${c.create}`, {
207
- banner: panel(c.welcome),
208
- inner: `<h1>${c.create}</h1>${errorBanner(errorText(c, error))}`
209
- + `<form method="post" action="submit">`
210
- + missingIdentityInputs(c, missing).replace('required', 'required autofocus')
211
- + `<label>${c.password}</label><input name="password" type="password" required minlength="8"${missing && (missing.email || missing.name || missing.lastName) ? '' : ' autofocus'}>`
212
- + `<label>${c.repeat}</label><input name="passwordConfirm" type="password" required minlength="8">`
213
- + `<label class="news"><input type="checkbox" name="newsletterConsent"> ${c.newsletter}</label>`
214
- + `<input type="hidden" name="legal" value="1">`
215
- + `<button type="submit">${c.create}</button>`
216
- + `<p class="hint">${c.hint}</p>`
217
- + `</form>`
218
- + legalHtml(lang)
219
- + `<script>(function(){document.forms[0].addEventListener('submit',function(e){var f=this;if(f.password.value!==f.passwordConfirm.value){e.preventDefault();alert(${JSON.stringify(c.errors['password-mismatch'])});}});})();</script>`,
220
- });
221
- }
222
-
223
- function formHtml(mode, lang, opts) {
224
- return mode === 'register' ? registerFormHtml(lang, opts) : loginFormHtml(lang, opts);
225
- }
226
-
227
- function trimmed(value) {
228
- return typeof value === 'string' ? value.trim() : '';
229
- }
230
-
231
- function missingIdentity(registerFields = {}) {
232
- return { email: !trimmed(registerFields.email), name: !trimmed(registerFields.name), lastName: !trimmed(registerFields.lastName) };
233
- }
234
-
235
- const GOOGLE_G = '<svg class="g" viewBox="0 0 48 48" aria-hidden="true">'
236
- + '<path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9 3.6l6.7-6.7C35.6 2.6 30.2 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.8 6.1C12.2 13.3 17.6 9.5 24 9.5z"/>'
237
- + '<path fill="#4285F4" d="M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.6c-.5 3-2.2 5.5-4.7 7.2l7.3 5.7c4.3-4 6.3-9.8 6.3-17.4z"/>'
238
- + '<path fill="#FBBC05" d="M10.4 28.3c-.5-1.5-.8-3.1-.8-4.8s.3-3.3.8-4.8l-7.8-6.1C.9 15.9 0 19.8 0 23.5s.9 7.6 2.6 10.9l7.8-6.1z"/>'
239
- + '<path fill="#34A853" d="M24 47c6.5 0 11.9-2.1 15.9-5.8l-7.3-5.7c-2 1.4-4.7 2.3-8.6 2.3-6.4 0-11.8-3.8-13.6-9.8l-7.8 6.1C6.5 41.6 14.6 47 24 47z"/></svg>';
240
-
241
- const LINKEDIN_IN = '<svg class="g" viewBox="0 0 24 24" aria-hidden="true">'
242
- + '<path fill="#0A66C2" d="M20.45 20.45h-3.56v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"/></svg>';
243
-
244
- // Each window offers these methods, in this order. The sign-up window has no LinkedIn: only Google carries the claim code there.
245
- const WINDOW_METHODS = { login: ['google', 'linkedin', 'email'], register: ['google', 'email'] };
246
- const FORM_MISTAKES = new Set(['legal-not-shown', 'missing-fields', 'password-mismatch', 'weak-password']);
247
-
248
- // The method-choice screen — the FIRST screen of the loopback form when more than one method is offered for this flow.
249
- function methodChoiceHtml(mode, lang, methods) {
250
- const c = copy(lang);
251
- const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
252
- const emailLabel = mode === 'register' ? c.continueEmailRegister : c.continueEmail;
253
- const register = mode === 'register';
254
- const buttons = WINDOW_METHODS[mode].filter((id) => methods.includes(id)).map((id) => {
255
- if (id === 'google') return `<a class="mbtn primary" href="google${register ? '?legal=1' : ''}">${GOOGLE_G}${c.continueGoogle}</a>`;
256
- if (id === 'linkedin') return `<a class="mbtn secondary" href="linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
257
- return `<a class="mbtn secondary" href="email">${emailLabel}</a>`;
258
- }).join('');
259
- return page(`Shakers · ${title}`, {
260
- inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>${register ? legalHtml(lang) : ''}`,
261
- });
262
- }
263
-
264
- // The sign-up window learns the account exists only after the talent submits: it then asks them to sign in instead of creating one.
265
- function existingAccountHtml(lang, { email, error } = {}) {
266
- const c = copy(lang);
267
- return page(`Shakers · ${c.signin}`, {
268
- inner: `<h1>${c.signin}</h1>${errorBanner(error ? errorText(c, error) : c.existing)}`
269
- + `<form method="post" action="submit"><input type="hidden" name="intent" value="login">`
270
- + `<label>${c.email}</label><input name="email" type="email" required value="${esc(email || '')}">`
271
- + `<label>${c.password}</label><input name="password" type="password" required autofocus>`
272
- + `<button type="submit">${c.signin}</button>`
273
- + `</form>`
274
- + `<div class="actions"><a class="mbtn secondary" href="google?legal=1">${GOOGLE_G}${c.continueGoogle}</a></div>`
275
- + `<p class="hint">${c.hint}</p>`,
276
- });
277
- }
278
-
279
- function socialHandoffHtml(lang, provider) {
280
- const c = copy(lang);
281
- const name = provider === 'linkedin' ? 'LinkedIn' : 'Google';
282
- return page('Shakers', { inner: `<h1>${c.socialOpening(name)}</h1><p>${c.socialOpeningSub}</p>` });
283
- }
284
-
285
- function successHtml(lang) {
286
- const c = copy(lang);
287
- return page('Shakers', { inner: `<h1>${c.done}</h1><p>${c.doneSub}</p>` });
288
- }
289
-
290
- function parseBody(req) {
291
- return new Promise((resolve) => {
292
- let raw = '';
293
- let aborted = false;
294
- req.on('data', (chunk) => {
295
- raw += chunk;
296
- if (raw.length > MAX_BODY_BYTES) { aborted = true; req.destroy(); }
297
- });
298
- req.on('end', () => {
299
- if (aborted) return resolve({});
300
- const params = new URLSearchParams(raw);
301
- const out = {};
302
- for (const [k, v] of params) out[k] = v;
303
- resolve(out);
304
- });
305
- req.on('error', () => resolve({}));
306
- });
307
- }
308
-
309
- async function doAuth(mode, body, deps, lang, registerFields = {}) {
310
- const password = typeof body.password === 'string' ? body.password : '';
311
-
312
- if (mode === 'register' && body.intent === 'login') {
313
- const email = trimmed(body.email);
314
- if (!email || !password) return { ok: false, reason: 'missing-fields', existing: true, email };
315
- const sess = await flow.establishSessionFromCredentials(deps, { email, password });
316
- return sess.ok ? { ok: true, email, accountExists: true } : { ok: false, reason: 'invalid-credentials', existing: true, email };
317
- }
318
-
319
- if (mode === 'register') {
320
- // Only the form that shows the legal block posts legal=1: no account without it.
321
- if (body.legal !== '1') return { ok: false, reason: 'legal-not-shown' };
322
- if (!password) return { ok: false, reason: 'missing-fields' };
323
- if (password !== (body.passwordConfirm || '')) return { ok: false, reason: 'password-mismatch' };
324
- const email = trimmed(registerFields.email) || trimmed(body.email);
325
- const name = trimmed(registerFields.name) || trimmed(body.name);
326
- const lastName = trimmed(registerFields.lastName) || trimmed(body.lastName);
327
- if (!email || !name || !lastName) return { ok: false, reason: 'missing-fields' };
328
- // Read at submit time: the MCP sign-up may swap its claim code while the window is open.
329
- const claimCode = typeof registerFields.claimCode === 'function' ? registerFields.claimCode() : registerFields.claimCode;
330
- const su = await flow.signUp(deps, {
331
- name,
332
- lastName,
333
- email,
334
- password,
335
- preferredLanguage: registerFields.preferredLanguage || lang,
336
- newsletterConsent: registerFields.newsletterConsent === true || body.newsletterConsent === 'on',
337
- freelanceType: registerFields.freelanceType,
338
- freelanceIntent: registerFields.freelanceIntent,
339
- claimCode: claimCode || null,
340
- });
341
- if (!su.ok) return { ok: false, reason: su.reason };
342
- const sess = await flow.establishSessionFromCredentials(deps, { email, password });
343
- // An existing account with another password: the talent signs in to it instead.
344
- if (!sess.ok && su.accountExists === true) return { ok: false, reason: null, existing: true, email };
345
- if (!sess.ok) return { ok: false, reason: sess.reason };
346
- const out = { ok: true, email, accountExists: su.accountExists === true };
347
- if (claimCode) out.claimed = su.claimed === true;
348
- return out;
349
- }
350
-
351
- const email = typeof body.email === 'string' ? body.email.trim() : '';
352
- if (!email || !password) return { ok: false, reason: 'missing-fields' };
353
- const sess = await flow.establishSessionFromCredentials(deps, { email, password });
354
- return sess.ok ? { ok: true, email } : { ok: false, reason: sess.reason };
355
- }
356
-
357
- // `onGoogle` (MCP sign-up) returns the provider URL to redirect to; without it Google hands back to the caller for the device flow.
358
- function runLoopbackAuth(
359
- { mode = 'login', deps = null, lang = 'en', method = null, registerFields = null } = {},
360
- { openBrowser = openPath, timeoutMs = DEFAULT_TIMEOUT_MS, onUrl = null, onGoogle = null } = {},
361
- ) {
362
- const formOpts = mode === 'register' ? { missing: missingIdentity(registerFields || {}) } : {};
363
- const resolvedDeps = deps || flow.makeDeps();
364
- const methods = methodIdsForFlow(mode);
365
- const showMethodScreen = !method && methods.length > 1;
366
- return new Promise((resolve) => {
367
- let settled = false;
368
- let failedAttempts = 0;
369
- const finish = (result) => {
370
- if (settled) return;
371
- settled = true;
372
- clearTimeout(timer);
373
- try { server.close(); } catch { /* already closing */ }
374
- resolve(result && result.ok && mode === 'register' ? { ...result, attempts: failedAttempts + 1 } : result);
375
- };
376
-
377
- // Any page in the talent's browser can reach a loopback port, and a rebound DNS name can read it: only this
378
- // window's own host, its own origin and its unguessable path get an answer. Every link in the pages is relative.
379
- const secretPath = `/${crypto.randomBytes(32).toString('base64url')}`;
380
- let port = null;
381
- const ownHosts = () => [`127.0.0.1:${port}`, `localhost:${port}`];
382
- const forbidden = (req) => {
383
- if (!ownHosts().includes(String(req.headers.host || '').toLowerCase())) return true;
384
- if (req.method === 'GET') return false;
385
- const origin = req.headers.origin;
386
- if (origin !== undefined && !ownHosts().map((h) => `http://${h}`).includes(origin)) return true;
387
- const site = req.headers['sec-fetch-site'];
388
- return site !== undefined && site !== 'same-origin';
389
- };
390
-
391
- const server = http.createServer(async (req, res) => {
392
- if (forbidden(req)) {
393
- res.writeHead(403).end();
394
- return;
395
- }
396
- let url;
397
- try {
398
- url = new URL(req.url, 'http://127.0.0.1');
399
- } catch {
400
- res.writeHead(400).end();
401
- return;
402
- }
403
- if (url.pathname === secretPath) {
404
- res.writeHead(302, { Location: `${secretPath}/${url.search}` });
405
- res.end();
406
- return;
407
- }
408
- if (!url.pathname.startsWith(`${secretPath}/`)) {
409
- res.writeHead(404).end();
410
- return;
411
- }
412
- url.pathname = url.pathname.slice(secretPath.length);
413
- if (req.method === 'GET' && url.pathname === '/') {
414
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
415
- res.end(showMethodScreen ? methodChoiceHtml(mode, lang, methods) : formHtml(mode, lang, formOpts));
416
- return;
417
- }
418
- if (req.method === 'GET' && url.pathname === '/email') {
419
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
420
- res.end(formHtml(mode, lang, formOpts));
421
- return;
422
- }
423
- if (req.method === 'GET' && url.pathname === '/google' && mode === 'register' && url.searchParams.get('legal') !== '1') {
424
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
425
- res.end(methodChoiceHtml(mode, lang, methods));
426
- return;
427
- }
428
- if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google') && typeof onGoogle === 'function') {
429
- const target = await onGoogle();
430
- if (!target || !target.ok || !target.url) {
431
- failedAttempts += 1;
432
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
433
- res.end(formHtml(mode, lang, { ...formOpts, error: (target && target.reason) || 'google-unavailable' }));
434
- return;
435
- }
436
- res.writeHead(302, { Location: target.url });
437
- res.end();
438
- finish({ ok: true, viaSocial: true, provider: 'google' });
439
- return;
440
- }
441
- if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google')) {
442
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
443
- res.end(socialHandoffHtml(lang, 'google'));
444
- finish({ ok: true, viaSocial: true, provider: 'google' });
445
- return;
446
- }
447
- if (req.method === 'GET' && url.pathname === '/linkedin' && methods.includes('linkedin') && WINDOW_METHODS[mode].includes('linkedin')) {
448
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
449
- res.end(socialHandoffHtml(lang, 'linkedin'));
450
- finish({ ok: true, viaSocial: true, provider: 'linkedin' });
451
- return;
452
- }
453
- if (req.method === 'POST' && url.pathname === '/submit') {
454
- const body = await parseBody(req);
455
- const outcome = await doAuth(mode, body, resolvedDeps, lang, registerFields || {});
456
- if (outcome.ok) {
457
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
458
- res.end(successHtml(lang));
459
- finish(outcome);
460
- } else {
461
- if (outcome.reason && !FORM_MISTAKES.has(outcome.reason)) failedAttempts += 1;
462
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
463
- res.end(outcome.existing
464
- ? existingAccountHtml(lang, { email: outcome.email, error: outcome.reason })
465
- : formHtml(mode, lang, { ...formOpts, error: outcome.reason }));
466
- }
467
- return;
468
- }
469
- res.writeHead(404).end();
470
- });
471
-
472
- const timer = setTimeout(() => finish({ ok: false, reason: 'browser-timeout' }), timeoutMs);
473
- if (timer.unref) timer.unref();
474
- server.on('error', () => finish({ ok: false, reason: 'local-error' }));
475
- server.listen(0, '127.0.0.1', () => {
476
- ({ port } = server.address());
477
- const formUrl = `http://127.0.0.1:${port}${secretPath}/`;
478
- if (typeof onUrl === 'function') onUrl(formUrl);
479
- try { openBrowser(formUrl); } catch { /* headless: the onUrl fallback covers it */ }
480
- });
481
- });
482
- }
483
-
484
- module.exports = { runLoopbackAuth, WINDOW_METHODS, doAuth, loginFormHtml, registerFormHtml, existingAccountHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };