shakers-cli-beta 0.13.0 → 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 (39) hide show
  1. package/README.md +28 -3
  2. package/bin/mcp.js +13 -7
  3. package/bin/register.js +5 -2
  4. package/package.json +1 -1
  5. package/src/backend-request.js +12 -8
  6. package/src/config.js +17 -5
  7. package/src/consent-flow.js +10 -10
  8. package/src/i18n.js +6 -6
  9. package/src/mcp-ai-usage-result-tool.js +4 -2
  10. package/src/mcp-ai-usage-tool.js +3 -1
  11. package/src/mcp-alma-tools.js +7 -3
  12. package/src/mcp-applications-tools.js +4 -2
  13. package/src/mcp-availability-tools.js +4 -2
  14. package/src/mcp-certifications-tools.js +4 -2
  15. package/src/mcp-certify-dimension-tools.js +4 -2
  16. package/src/mcp-choice.js +27 -1
  17. package/src/mcp-device-login-tools.js +6 -2
  18. package/src/mcp-find-projects-tools.js +4 -2
  19. package/src/mcp-instructions.js +1 -1
  20. package/src/mcp-invitations-tools.js +4 -2
  21. package/src/mcp-lang-tools.js +4 -2
  22. package/src/mcp-onboarding-tools.js +41 -44
  23. package/src/mcp-portfolios-tools.js +7 -3
  24. package/src/mcp-profile-tools.js +4 -2
  25. package/src/mcp-prompts.js +2 -2
  26. package/src/mcp-rate-tools.js +4 -2
  27. package/src/mcp-register-tools.js +52 -19
  28. package/src/mcp-report-tools.js +7 -3
  29. package/src/mcp-role-tools.js +23 -8
  30. package/src/mcp-saved-positions-tools.js +7 -3
  31. package/src/mcp-server.js +8 -2
  32. package/src/mcp-show-project-tools.js +4 -2
  33. package/src/mcp-signup-tools.js +153 -20
  34. package/src/mcp-socials-tools.js +4 -2
  35. package/src/onboarding-flow.js +112 -13
  36. package/src/onboarding-funnel.js +272 -0
  37. package/src/share.js +4 -3
  38. package/src/signup-copy.js +55 -1
  39. package/src/mcp-ask-tools.js +0 -50
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { NOOP_FUNNEL, sourceTypeFor, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
4
+
3
5
  const FREELANCE_TYPES = ['FREELANCE', 'EMPLOYEE', 'AGENCY', 'POTENTIAL_FREELANCE'];
4
6
  const CURRENCIES = ['USD', 'EUR', 'GBP'];
5
7
  const PRICE_MIN = 0;
@@ -669,12 +671,33 @@ function interviewDisclaimers(lang) {
669
671
  return { infoAccessed: c.interviewInfoAccessed, goalDuration: c.interviewGoalDuration };
670
672
  }
671
673
 
674
+ function trackImport(funnel, attempted, importRes, durationMs) {
675
+ const bySource = new Map();
676
+ const report = importRes.ok && importRes.report ? importRes.report.sources : [];
677
+ for (const src of report) bySource.set(sourceTypeFor(src.source), src);
678
+ const connected = [];
679
+ for (const type of attempted) {
680
+ const src = bySource.get(type);
681
+ const ok = importRes.ok && (!src || src.status === 'imported');
682
+ if (ok) {
683
+ connected.push(type);
684
+ funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
685
+ } else {
686
+ funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
687
+ }
688
+ }
689
+ return connected;
690
+ }
691
+
672
692
  // Blocking orchestrator used by the CLI (`bin/onboarding.js`).
673
- async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile } = {}) {
693
+ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile, funnel = NOOP_FUNNEL } = {}) {
674
694
  const { getCatalog } = require('./i18n');
675
695
  const c = getCatalog(io.lang).onboarding;
676
696
 
697
+ funnel.begin();
698
+ funnel.completed('start');
677
699
  io.section(c.signupTitle);
700
+ funnel.viewed('account_creation');
678
701
 
679
702
  if (method === 'google' || method === 'linkedin') {
680
703
  // The provider supplies name/email; freelanceType is derived later from work situation.
@@ -688,6 +711,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
688
711
  io.error(c.signupError);
689
712
  return { ok: false, reason: reg.reason, step: 'signup' };
690
713
  }
714
+ funnel.completed('account_creation', { auth_method: method, is_returning_account: reg.kind === 'account-already-exists' });
715
+ funnel.identified();
691
716
  if (reg.kind === 'account-already-exists') {
692
717
  // Existing account: already signed in — stop, no wizard re-run.
693
718
  io.warn(c.accountExists);
@@ -713,6 +738,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
713
738
  io.warn(c.accountExists);
714
739
  const logged = await io.login(account.email);
715
740
  if (!logged) return { ok: false, reason: 'login-failed', step: 'signup' };
741
+ funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
742
+ funnel.identified();
716
743
  return { ok: true, alreadyRegistered: true };
717
744
  }
718
745
  const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
@@ -720,12 +747,19 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
720
747
  io.error(c.sessionError);
721
748
  return { ok: false, reason: sess.reason, step: 'signup' };
722
749
  }
750
+ funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
751
+ funnel.identified();
723
752
  notifyOk(io, c.accountReady);
724
753
  }
725
754
 
726
755
  io.section(c.title);
756
+ funnel.viewed('import_sources');
727
757
  const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
728
- if (!linkedinUrl) return { ok: false, reason: 'no-linkedin', step: 'import' };
758
+ if (!linkedinUrl) {
759
+ funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
760
+ funnel.flush();
761
+ return { ok: false, reason: 'no-linkedin', step: 'import' };
762
+ }
729
763
  const cvPath = (await io.ask(c.askCv) || '').trim();
730
764
  const githubUrl = (await io.ask(c.askGithub) || '').trim();
731
765
  const websiteUrl = (await io.ask(c.askWebsite) || '').trim();
@@ -733,45 +767,95 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
733
767
  // The hub import is SYNCHRONOUS (the POST returns once it finishes), so show the
734
768
  // loader AROUND that wait, then the real outcome. No background job to poll.
735
769
  const withProgress = typeof io.withProgress === 'function' ? io.withProgress : (_l, task) => task();
770
+ const attempted = [linkedinUrl && 'linkedin', cvPath && 'cv', githubUrl && 'github', websiteUrl && 'web_page'].filter(Boolean);
771
+ for (const type of attempted) funnel.track('source_import_started', 'import_sources', { source_type: type });
772
+ const importStartedAt = Date.now();
736
773
  const importRes = await withProgress(c.importChecking, () =>
737
774
  startProfileImport(deps, { linkedinUrl, cvPath, githubUrl, websiteUrl, language: io.lang }));
775
+ const connected = trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
776
+ funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: connected, sources_connected_count: connected.length });
777
+ funnel.flush();
738
778
  if (importRes.ok) notifyOk(io, c.importDone);
739
779
  else io.warn(c.importUnavailable);
740
780
 
781
+ // Steps 4-8 are saved out of funnel order: sent together once all are answered.
741
782
  io.section(c.professionalTitle);
783
+ funnel.viewed('work_situation');
742
784
  const workSituationInput = await io.askWorkSituation(c);
743
785
  const workSituationRes = await saveWorkSituation(deps, workSituationInput);
744
- if (!workSituationRes.ok) return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
786
+ if (!workSituationRes.ok) {
787
+ funnel.errored('work_situation', workSituationRes.reason);
788
+ funnel.flush();
789
+ return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
790
+ }
791
+ funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
745
792
  notifyOk(io, c.professionalSaved);
746
793
 
747
794
  io.section(c.pricingTitle);
795
+ funnel.viewed('rates');
748
796
  const pricingInput = await io.askPricing(c);
749
797
  const priceRes = await savePricing(deps, pricingInput);
750
- if (!priceRes.ok) return { ok: false, reason: priceRes.reason, step: 'pricing' };
798
+ if (!priceRes.ok) {
799
+ funnel.errored('rates', priceRes.reason);
800
+ funnel.flush();
801
+ return { ok: false, reason: priceRes.reason, step: 'pricing' };
802
+ }
803
+ const selectedRates = rateTypes(pricingInput);
804
+ funnel.completed('rates', {
805
+ rate_types_selected: selectedRates,
806
+ rate_type: selectedRates[0],
807
+ rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
808
+ });
751
809
  notifyOk(io, c.pricingSaved);
752
810
 
753
811
  // Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
754
812
  io.section(c.availabilityTitle);
813
+ funnel.viewed('availability');
755
814
  if (await io.confirm(c.availabilityAsk)) {
756
815
  const availabilityInput = await io.askAvailability(c);
757
816
  const availRes = await saveAvailability(deps, availabilityInput);
758
- if (availRes.ok) notifyOk(io, c.availabilitySaved); else io.warn(c.availabilityUnavailable);
817
+ if (availRes.ok) {
818
+ funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
819
+ const country = normalizeCountry(availabilityInput.country);
820
+ const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
821
+ funnel.completed('location', {
822
+ location_country: country ? country.toLowerCase() : undefined,
823
+ location_method: 'manual',
824
+ is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
825
+ });
826
+ notifyOk(io, c.availabilitySaved);
827
+ } else {
828
+ funnel.errored('availability', availRes.reason);
829
+ io.warn(c.availabilityUnavailable);
830
+ }
759
831
  if (availabilityInput.telephoneCode || availabilityInput.telephoneNumber) {
760
832
  const phoneRes = await savePhone(deps, availabilityInput);
761
833
  if (phoneRes.ok) notifyOk(io, c.phoneSaved); else io.warn(c.phoneUnavailable);
762
834
  }
763
835
  } else {
836
+ funnel.skipped('availability');
837
+ funnel.skipped('location');
764
838
  io.notify(c.availabilitySkipped);
765
839
  }
766
840
 
767
841
  io.section(c.languagesTitle);
842
+ funnel.viewed('languages');
768
843
  if (await io.confirm(c.languagesAsk)) {
769
844
  const languageRows = await io.askLanguages(c);
770
845
  const langRes = await saveLanguages(deps, languageRows);
771
- if (langRes.ok) notifyOk(io, c.languagesSaved); else io.warn(c.languagesUnavailable);
846
+ if (langRes.ok) {
847
+ const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
848
+ funnel.completed('languages', { languages_count: codes.length, languages: codes });
849
+ notifyOk(io, c.languagesSaved);
850
+ } else {
851
+ funnel.errored('languages', langRes.reason);
852
+ io.warn(c.languagesUnavailable);
853
+ }
772
854
  } else {
855
+ funnel.skipped('languages');
773
856
  io.notify(c.languagesSkipped);
774
857
  }
858
+ funnel.flush();
775
859
 
776
860
  const prior = await detectPriorUsage(deps);
777
861
  let didUsage = false;
@@ -811,24 +895,36 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
811
895
  return url;
812
896
  };
813
897
 
898
+ const mainRole = async () => {
899
+ const role = await runMainRoleFinalStep(io, deps);
900
+ if (role && role.ok && role.chosen) funnel.completed('supercluster', { supercluster: role.chosen });
901
+ else if (role && role.ok) funnel.skipped('supercluster');
902
+ funnel.flush();
903
+ };
904
+
814
905
  io.section(c.interviewTitle);
906
+ funnel.viewed('pre_interview');
815
907
  io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
816
908
  if (!(await io.confirm(c.interviewAccept))) {
817
- await runMainRoleFinalStep(io, deps);
909
+ funnel.completed('pre_interview', { interview_choice: 'skip' });
910
+ await mainRole();
818
911
  const fin = await finalize();
819
912
  const url = showProfile();
820
913
  return { ok: true, completed: 'skipped-interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
821
914
  }
915
+ funnel.completed('pre_interview', { interview_choice: 'start_now' });
822
916
 
823
- const interview = await conductLivekitInterview(io, deps);
917
+ const interview = await conductLivekitInterview(io, deps, { funnel });
824
918
  if (!interview.ok) {
825
- await runMainRoleFinalStep(io, deps);
919
+ funnel.errored('interview', interview.reason);
920
+ await mainRole();
826
921
  const fin = await finalize();
827
922
  const url = showProfile();
828
923
  return { ok: true, completed: 'interview-unavailable', reason: interview.reason, didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
829
924
  }
925
+ funnel.completed('interview', { interview_mode: 'text' });
830
926
  notifyOk(io, c.interviewComplete);
831
- await runMainRoleFinalStep(io, deps);
927
+ await mainRole();
832
928
  const fin = await finalize();
833
929
  const url = showProfile();
834
930
  return { ok: true, completed: 'interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
@@ -860,7 +956,7 @@ function livekitTurnBridge(client, transcript) {
860
956
  }
861
957
 
862
958
  // The onboarding/dimension interview over LiveKit text transport. LiveKit Node SDK is an optional lazily-required dep; a `livekit-not-installed` connect prints a hint, not a stack trace. Persists the transcript on session end even when interrupted; a failed PATCH warns, never crashes.
863
- async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false } = {}) {
959
+ async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false, funnel = null } = {}) {
864
960
  const { getCatalog } = require('./i18n');
865
961
  const c = getCatalog(io.lang).onboarding;
866
962
  const { TranscriptBuffer } = require('./interview-transcript');
@@ -870,6 +966,7 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
870
966
  ? await startLivekitForInterview(deps, { interviewId, language: language || io.lang })
871
967
  : await createAndStartLivekitOnboarding(deps, { language: language || io.lang });
872
968
  if (!start.ok) return { ok: false, reason: start.reason, step: start.step || 'interview' };
969
+ if (funnel) funnel.viewed('interview', { interview_mode: 'text' });
873
970
 
874
971
  const closing = start.closingMessage;
875
972
  const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
@@ -920,8 +1017,10 @@ async function runMainRoleFinalStep(io, deps, session = null) {
920
1017
  const catalog = getCatalog(io.lang);
921
1018
  try {
922
1019
  const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
923
- await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
924
- } catch { /* the alta stands even if the role step fails */ }
1020
+ return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
1021
+ } catch {
1022
+ return null;
1023
+ }
925
1024
  }
926
1025
 
927
1026
  // Overwrite-repeat: PATCH /interviews/onboarding/restart. Per contract the Bearer is
@@ -0,0 +1,272 @@
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
+ // Names and values mirror works-frontend onboarding-funnel-enums.ts.
9
+
10
+ const FLOW_VERSION = 'signup_new_works_v1';
11
+
12
+ const STEP_INDEX = Object.freeze({
13
+ start: 1,
14
+ account_creation: 2,
15
+ import_sources: 3,
16
+ work_situation: 4,
17
+ availability: 5,
18
+ rates: 6,
19
+ languages: 7,
20
+ location: 8,
21
+ profile_summary: 9,
22
+ pre_interview: 10,
23
+ interview: 11,
24
+ supercluster: 12,
25
+ });
26
+
27
+ const EVENT_TYPES = Object.freeze([
28
+ 'onboarding_step_viewed',
29
+ 'onboarding_step_completed',
30
+ 'onboarding_step_skipped',
31
+ 'onboarding_step_errored',
32
+ 'onboarding_step_exited',
33
+ 'source_import_started',
34
+ 'source_import_completed',
35
+ 'source_import_failed',
36
+ ]);
37
+
38
+ const SURFACES = Object.freeze(['cli', 'mcp']);
39
+ const MCP_CLIENTS = Object.freeze(['claude', 'chatgpt', 'cursor', 'other']);
40
+ const SOURCE_TYPES = Object.freeze(['linkedin', 'cv', 'github', 'web_page']);
41
+ const ERROR_TYPES = Object.freeze(['validation', 'server', 'timeout', 'auth', 'import']);
42
+
43
+ const WORK_SITUATION_VALUES = Object.freeze({
44
+ FREELANCE: 'freelance',
45
+ EMPLOYED: 'employed',
46
+ BETWEEN_JOBS: 'between-jobs',
47
+ STUDYING: 'studying',
48
+ OTHER: 'other',
49
+ });
50
+
51
+ const ONCE_PER_STEP = new Set(['onboarding_step_viewed', 'onboarding_step_completed']);
52
+ const MAX_EVENTS_PER_REQUEST = 50;
53
+ const SEND_TIMEOUT_MS = 3000;
54
+ const DEVICE_FILE_NAME = 'analytics-device.json';
55
+
56
+ function mcpClientFor(clientName) {
57
+ const name = typeof clientName === 'string' ? clientName.toLowerCase() : '';
58
+ if (name.includes('claude')) return 'claude';
59
+ if (name.includes('chatgpt') || name.includes('openai')) return 'chatgpt';
60
+ if (name.includes('cursor')) return 'cursor';
61
+ return 'other';
62
+ }
63
+
64
+ function sourceTypeFor(source) {
65
+ if (source === 'linkedin' || source === 'cv' || source === 'github') return source;
66
+ return 'web_page';
67
+ }
68
+
69
+ function workSituationValue(code) {
70
+ return WORK_SITUATION_VALUES[code] || null;
71
+ }
72
+
73
+ // Same ÷4 as the web; '> 160' has no number.
74
+ function weeklyHours(monthlyHours) {
75
+ const n = Number(monthlyHours);
76
+ return monthlyHours != null && monthlyHours !== '' && Number.isFinite(n) ? Math.round(n / 4) : null;
77
+ }
78
+
79
+ // As on the web: part-time project price is hourly, full-time is annual.
80
+ function rateTypes({ partTimeSelected, fullTimeSelected } = {}) {
81
+ return [partTimeSelected === true && 'hourly', fullTimeSelected === true && 'annual'].filter(Boolean);
82
+ }
83
+
84
+ function errorTypeFor(reason) {
85
+ const r = String(reason || '');
86
+ if (r === 'no-session' || r === 'no-hub-token' || r === 'http-401' || r === 'http-403') return 'auth';
87
+ if (r === 'timeout') return 'timeout';
88
+ if (r === 'no-endpoint') return 'server';
89
+ if (r.startsWith('bad-') || r.startsWith('no-') || r === 'duplicate-language' || r === 'unresolved-languages') return 'validation';
90
+ return 'server';
91
+ }
92
+
93
+ function cleanProperties(properties) {
94
+ const ok = (v) => typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))
95
+ || (Array.isArray(v) && v.every((x) => typeof x === 'string'));
96
+ return Object.fromEntries(Object.entries(properties || {}).filter(([, v]) => ok(v)));
97
+ }
98
+
99
+ function deviceFilePath(env) {
100
+ return path.join(getConfigDir(env), DEVICE_FILE_NAME);
101
+ }
102
+
103
+ function loadOrCreateDeviceId(env = process.env, { fs: fsImpl = fs } = {}) {
104
+ const target = deviceFilePath(env);
105
+ try {
106
+ const parsed = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
107
+ if (parsed && typeof parsed.deviceId === 'string' && parsed.deviceId) return parsed.deviceId;
108
+ } catch { /* create one */ }
109
+ const deviceId = crypto.randomUUID();
110
+ const dir = path.dirname(target);
111
+ const tmp = path.join(dir, `.${DEVICE_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
112
+ let fd = null;
113
+ try {
114
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
115
+ fd = fsImpl.openSync(tmp, 'wx', 0o600);
116
+ fsImpl.writeFileSync(fd, `${JSON.stringify({ deviceId }, null, 2)}\n`);
117
+ try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
118
+ fsImpl.closeSync(fd);
119
+ fd = null;
120
+ fsImpl.renameSync(tmp, target);
121
+ return deviceId;
122
+ } catch {
123
+ if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
124
+ try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
125
+ return null;
126
+ }
127
+ }
128
+
129
+ async function defaultSend({ endpoint, events, hubAccessToken }) {
130
+ const { postJsonWithTimeout } = require('./backend-request');
131
+ const res = await postJsonWithTimeout(endpoint, { events }, SEND_TIMEOUT_MS, 'POST', null, { Authorization: `Bearer ${hubAccessToken}` });
132
+ return { ok: res.status >= 200 && res.status < 300, status: res.status };
133
+ }
134
+
135
+ function defaultHubToken() {
136
+ const { loadAuthSession, sessionStatus } = require('./auth-session-store');
137
+ const session = loadAuthSession();
138
+ return sessionStatus(session) === 'active' && session && session.hubAccessToken ? session.hubAccessToken : null;
139
+ }
140
+
141
+ function createFunnel({
142
+ surface,
143
+ hubToken = defaultHubToken,
144
+ deviceId = () => loadOrCreateDeviceId(),
145
+ endpoint = () => require('./config').getOnboardingEventsEndpoint(),
146
+ send = defaultSend,
147
+ now = () => Date.now(),
148
+ uuid = () => crypto.randomUUID(),
149
+ } = {}) {
150
+ if (!SURFACES.includes(surface)) throw new Error(`unknown funnel surface: ${surface}`);
151
+ let pending = [];
152
+ let lastTime = 0;
153
+ let seq = 0;
154
+ let ready = false;
155
+ let account = null;
156
+ let active = false;
157
+ let mcpClient = null;
158
+ let cachedDeviceId;
159
+ const seen = new Set();
160
+ const inflight = new Set();
161
+
162
+ function track(eventType, stepName, properties = {}) {
163
+ if (!active || !EVENT_TYPES.includes(eventType) || !STEP_INDEX[stepName]) return false;
164
+ if (ONCE_PER_STEP.has(eventType)) {
165
+ const key = `${eventType}:${stepName}`;
166
+ if (seen.has(key)) return false;
167
+ seen.add(key);
168
+ }
169
+ const common = { step_name: stepName, step_index: STEP_INDEX[stepName], flow_version: FLOW_VERSION, surface };
170
+ if (surface === 'mcp') common.mcp_client = mcpClient || 'other';
171
+ pending.push({ eventType, insertId: uuid(), at: now(), seq: seq++, properties: { ...cleanProperties(properties), ...common } });
172
+ return true;
173
+ }
174
+
175
+ // Funnel order, each time strictly after the previous one sent.
176
+ function stamp(batch) {
177
+ const ordered = [...batch].sort((a, b) => a.properties.step_index - b.properties.step_index || a.seq - b.seq);
178
+ const id = cachedDeviceId === undefined ? (cachedDeviceId = safe(deviceId)) : cachedDeviceId;
179
+ return ordered.map((e) => {
180
+ lastTime = Math.max(e.at, lastTime + 1);
181
+ return { event_type: e.eventType, insert_id: e.insertId, time: lastTime, ...(id ? { device_id: id } : {}), properties: e.properties };
182
+ });
183
+ }
184
+
185
+ function flush() {
186
+ if (!ready || !pending.length) return Promise.resolve();
187
+ const token = safe(hubToken);
188
+ const url = safe(endpoint);
189
+ // Never send under another account signed in since.
190
+ if (!token || !url || accountOf(token) !== account) return Promise.resolve();
191
+ const events = stamp(pending);
192
+ pending = [];
193
+ const sends = [];
194
+ for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
195
+ sends.push(Promise.resolve().then(() => send({ endpoint: url, events: events.slice(i, i + MAX_EVENTS_PER_REQUEST), hubAccessToken: token })).catch(() => null));
196
+ }
197
+ const done = Promise.all(sends).then(() => undefined, () => undefined);
198
+ inflight.add(done);
199
+ done.then(() => inflight.delete(done));
200
+ return done;
201
+ }
202
+
203
+ return {
204
+ surface,
205
+ begin() {
206
+ seen.clear();
207
+ active = true;
208
+ },
209
+ get active() { return active; },
210
+ setMcpClient(clientName) { mcpClient = mcpClientFor(clientName); },
211
+ track,
212
+ viewed: (step, props) => track('onboarding_step_viewed', step, props),
213
+ completed: (step, props) => track('onboarding_step_completed', step, props),
214
+ skipped: (step, props) => track('onboarding_step_skipped', step, props),
215
+ errored: (step, reason, props = {}) => track('onboarding_step_errored', step, { error_type: errorTypeFor(reason), ...props }),
216
+ identified() {
217
+ const token = safe(hubToken);
218
+ if (!token) return Promise.resolve();
219
+ account = accountOf(token);
220
+ ready = true;
221
+ return flush();
222
+ },
223
+ flush,
224
+ idle: () => Promise.all([...inflight]).then(() => undefined),
225
+ };
226
+ }
227
+
228
+ // A refreshed token keeps the same account.
229
+ function accountOf(token) {
230
+ const payload = require('./auth-session-store').decodeJwtPayload(token);
231
+ return (payload && (payload.sub || payload.email)) || token;
232
+ }
233
+
234
+ function safe(fn) {
235
+ try { return fn(); } catch { return null; }
236
+ }
237
+
238
+ const NOOP_FUNNEL = Object.freeze({
239
+ surface: null,
240
+ begin() {},
241
+ active: false,
242
+ setMcpClient() {},
243
+ track: () => false,
244
+ viewed: () => false,
245
+ completed: () => false,
246
+ skipped: () => false,
247
+ errored: () => false,
248
+ identified: () => Promise.resolve(),
249
+ flush: () => Promise.resolve(),
250
+ idle: () => Promise.resolve(),
251
+ });
252
+
253
+ module.exports = {
254
+ FLOW_VERSION,
255
+ STEP_INDEX,
256
+ EVENT_TYPES,
257
+ SURFACES,
258
+ MCP_CLIENTS,
259
+ SOURCE_TYPES,
260
+ ERROR_TYPES,
261
+ MAX_EVENTS_PER_REQUEST,
262
+ DEVICE_FILE_NAME,
263
+ NOOP_FUNNEL,
264
+ createFunnel,
265
+ mcpClientFor,
266
+ sourceTypeFor,
267
+ workSituationValue,
268
+ weeklyHours,
269
+ rateTypes,
270
+ errorTypeFor,
271
+ loadOrCreateDeviceId,
272
+ };
package/src/share.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
 
5
- const { getIngestEndpoint, isTalentProfile } = require('./config');
5
+ const { getIngestEndpoint } = require('./config');
6
6
  const { requestBackend } = require('./backend-request');
7
7
  const { recordBackendAcceptance } = require('./report-store');
8
8
  const {
@@ -53,8 +53,9 @@ async function autoShare(report, maturity, { root, bypassThrottle = false } = {}
53
53
  if (!state.email) {
54
54
  return { ok: false, skipped: true, reason: 'no-email' };
55
55
  }
56
- // ADR-006: never SEND under an email whose ownership wasn't proven.
57
- if (state.emailVerified === false && isTalentProfile()) {
56
+ // ADR-006: never SEND under an email whose ownership wasn't proven — every
57
+ // profile, `external` included (legal requirement), must have verified.
58
+ if (state.emailVerified === false) {
58
59
  return { ok: false, skipped: true, reason: 'email-unverified' };
59
60
  }
60
61
  if (!bypassThrottle && isThrottled(state)) {
@@ -52,6 +52,16 @@ function joinParts(parts) {
52
52
  return parts.filter((p) => p !== null && p !== undefined && String(p).trim() !== '').join(' · ');
53
53
  }
54
54
 
55
+ function countryName(lang, code) {
56
+ if (typeof code !== 'string' || !/^[A-Za-z]{2}$/.test(code.trim())) return null;
57
+ const upper = code.trim().toUpperCase();
58
+ try {
59
+ return new Intl.DisplayNames([LOCALES[toSignupLanguage(lang) || 'en']], { type: 'region' }).of(upper) || upper;
60
+ } catch {
61
+ return upper;
62
+ }
63
+ }
64
+
55
65
  function amount(lang, value) {
56
66
  return new Intl.NumberFormat(LOCALES[lang], { maximumFractionDigits: 0 }).format(Number(value));
57
67
  }
@@ -95,11 +105,21 @@ const COPY = {
95
105
  existingNo: 'No, déjalo como está',
96
106
  existingKept: 'Perfecto, dejo tu perfil como está.',
97
107
  keptLabels: { workSituation: 'Situación laboral', availability: 'Disponibilidad', languages: 'Idiomas', phone: 'Teléfono' },
108
+ situationLabels: { FREELANCE: 'Freelance', EMPLOYED: 'Empleado/a', BETWEEN_JOBS: 'Entre proyectos', STUDYING: 'Estudiando', OTHER: 'Otro' },
109
+ participationLabels: { FULL_TIME: 'jornada completa', PART_TIME: 'media jornada' },
98
110
  existingUnchanged: (items) => `Estos datos de tu perfil no los he cambiado, se quedan como estaban: ${items.join(' · ')}. Si quieres otros valores, cámbialos en la web.`,
99
111
  aiUsageQuestion: '¿Quieres que analice cómo trabajas con IA? Es opcional y tarda 1-2 min. Solo veo qué herramientas usas, tu código y tus prompts no salen de tu máquina. Miro el uso de IA de todo este equipo, que doy por tuyo. Te ayuda a destacar en proyectos de IA.',
100
112
  aiUsageYes: 'Sí, analízalo',
101
113
  aiUsageSkip: 'Saltar este paso',
102
114
  aiUsageSkipped: "Sin problema, seguimos sin él. Te aviso de que es lo que más pesa para que te lleguen proyectos: lo puedes lanzar cuando quieras diciéndome 'analiza mi uso de IA'.",
115
+ consent: {
116
+ yes: 'Sí, adelante',
117
+ no: 'No',
118
+ cvSearch: 'Shakers buscará en este ordenador archivos que puedan ser tu CV: mira los nombres de los archivos PDF y Word de tus carpetas Escritorio, Documentos, Descargas e iCloud Drive, y lee tu zona horaria y el nombre, el email y el usuario de GitHub que guardan tus herramientas de programación. No abre esos archivos y no envía nada. ¿Continuamos?',
119
+ cvRead: (file) => `Shakers leerá el archivo ${file} para preparar el borrador de tu perfil. Todavía no se envía nada. ¿Continuamos?`,
120
+ cvSend: (file) => `Shakers enviará el archivo ${file} a tu cuenta de Shakers para completar tu perfil. ¿Continuamos?`,
121
+ project: (p) => `Shakers añadirá el proyecto de la carpeta ${p.folder} a tu portfolio y enviará ${listOf('es', [`el nombre "${p.name}"`, p.url && `la dirección web ${p.url}`, p.startDate && p.endDate ? `las fechas del ${p.startDate} al ${p.endDate}` : (p.startDate || p.endDate) && `la fecha ${p.startDate || p.endDate}`, p.client && `el cliente ${p.client}`, p.description && 'la descripción que acordaste'].filter(Boolean))}. ¿Continuamos?`,
122
+ },
103
123
  importSources: { linkedin: 'tu LinkedIn', cv: 'tu CV', github: 'tu GitHub', web: 'tu web' },
104
124
  importFailed: (names) => `No he podido importar ${names.join(' y ')} ahora mismo. El resto de tu perfil está guardado; lo que falte lo puedes añadir en la web.`,
105
125
  interviewWhereQuestion: '¿Hacemos la entrevista de onboarding aquí ahora o luego en la web?',
@@ -169,11 +189,21 @@ const COPY = {
169
189
  existingNo: 'No, leave it as it is',
170
190
  existingKept: 'Sure, I am leaving your profile as it is.',
171
191
  keptLabels: { workSituation: 'Work situation', availability: 'Availability', languages: 'Languages', phone: 'Phone' },
192
+ situationLabels: { FREELANCE: 'Freelance', EMPLOYED: 'Employed', BETWEEN_JOBS: 'Between jobs', STUDYING: 'Studying', OTHER: 'Other' },
193
+ participationLabels: { FULL_TIME: 'full-time', PART_TIME: 'part-time' },
172
194
  existingUnchanged: (items) => `I did not change these details of your profile; they stay as they were: ${items.join(' · ')}. If you want other values, change them on the web.`,
173
195
  aiUsageQuestion: 'Do you want me to analyse how you work with AI? It is optional and takes 1-2 min. I only see which tools you use; your code and your prompts do not leave your machine. I look at the AI usage across this whole machine, which I take to be yours. It helps you stand out for AI projects.',
174
196
  aiUsageYes: 'Yes, analyse it',
175
197
  aiUsageSkip: 'Skip this step',
176
198
  aiUsageSkipped: "No problem, we carry on without it. Heads-up: it is what weighs most for projects to reach you. You can run it whenever you like by telling me 'analyse my AI usage'.",
199
+ consent: {
200
+ yes: 'Yes, go ahead',
201
+ no: 'No',
202
+ cvSearch: 'Shakers will look on this computer for files that may be your CV: it lists the names of the PDF and Word files in your Desktop, Documents, Downloads and iCloud Drive folders, and reads your time zone and the name, email and GitHub user saved by your coding tools. It does not open those files and nothing is sent. Continue?',
203
+ cvRead: (file) => `Shakers will read the file ${file} to prepare your profile draft. Nothing is sent yet. Continue?`,
204
+ cvSend: (file) => `Shakers will send the file ${file} to your Shakers account to fill in your profile. Continue?`,
205
+ project: (p) => `Shakers will add the project in the folder ${p.folder} to your portfolio and send ${listOf('en', [`the name "${p.name}"`, p.url && `the web address ${p.url}`, p.startDate && p.endDate ? `the dates ${p.startDate} to ${p.endDate}` : (p.startDate || p.endDate) && `the date ${p.startDate || p.endDate}`, p.client && `the client ${p.client}`, p.description && 'the description you agreed on'].filter(Boolean))}. Continue?`,
206
+ },
177
207
  importSources: { linkedin: 'your LinkedIn', cv: 'your CV', github: 'your GitHub', web: 'your website' },
178
208
  importFailed: (names) => `I couldn't import ${names.join(' and ')} right now. The rest of your profile is saved; you can add what is missing on the web.`,
179
209
  interviewWhereQuestion: 'Shall we do the onboarding interview here now or later on the web?',
@@ -243,11 +273,21 @@ const COPY = {
243
273
  existingNo: 'No, lascialo così',
244
274
  existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
245
275
  keptLabels: { workSituation: 'Situazione lavorativa', availability: 'Disponibilità', languages: 'Lingue', phone: 'Telefono' },
276
+ situationLabels: { FREELANCE: 'Freelance', EMPLOYED: 'Dipendente', BETWEEN_JOBS: "Tra un lavoro e l'altro", STUDYING: 'Studente', OTHER: 'Altro' },
277
+ participationLabels: { FULL_TIME: 'tempo pieno', PART_TIME: 'part-time' },
246
278
  existingUnchanged: (items) => `Questi dati del tuo profilo non li ho cambiati, restano com'erano: ${items.join(' · ')}. Se vuoi altri valori, cambiali sul sito.`,
247
279
  aiUsageQuestion: "Vuoi che analizzi come lavori con l'IA? È facoltativo e richiede 1-2 min. Vedo solo quali strumenti usi, il tuo codice e i tuoi prompt non escono dalla tua macchina. Guardo l'uso dell'IA su tutto questo computer, che considero tuo. Ti aiuta a distinguerti nei progetti di IA.",
248
280
  aiUsageYes: 'Sì, analizzalo',
249
281
  aiUsageSkip: 'Salta questo passaggio',
250
282
  aiUsageSkipped: "Nessun problema, andiamo avanti senza. Ti avviso che è ciò che pesa di più perché ti arrivino progetti: puoi lanciarlo quando vuoi dicendomi 'analizza il mio uso dell'IA'.",
283
+ consent: {
284
+ yes: 'Sì, procedi',
285
+ no: 'No',
286
+ cvSearch: "Shakers cercherà su questo computer i file che potrebbero essere il tuo CV: guarda i nomi dei file PDF e Word nelle cartelle Scrivania, Documenti, Download e iCloud Drive, e legge il tuo fuso orario e il nome, l'email e l'utente GitHub salvati dai tuoi strumenti di programmazione. Non apre quei file e non invia nulla. Continuiamo?",
287
+ cvRead: (file) => `Shakers leggerà il file ${file} per preparare la bozza del tuo profilo. Per ora non viene inviato nulla. Continuiamo?`,
288
+ cvSend: (file) => `Shakers invierà il file ${file} al tuo account Shakers per completare il tuo profilo. Continuiamo?`,
289
+ project: (p) => `Shakers aggiungerà il progetto della cartella ${p.folder} al tuo portfolio e invierà ${listOf('it', [`il nome "${p.name}"`, p.url && `l'indirizzo web ${p.url}`, p.startDate && p.endDate ? `le date dal ${p.startDate} al ${p.endDate}` : (p.startDate || p.endDate) && `la data ${p.startDate || p.endDate}`, p.client && `il cliente ${p.client}`, p.description && 'la descrizione che hai concordato'].filter(Boolean))}. Continuiamo?`,
290
+ },
251
291
  importSources: { linkedin: 'il tuo LinkedIn', cv: 'il tuo CV', github: 'il tuo GitHub', web: 'il tuo sito' },
252
292
  importFailed: (names) => `Non sono riuscito a importare ${names.join(' e ')} in questo momento. Il resto del tuo profilo è salvato; quello che manca puoi aggiungerlo sul sito.`,
253
293
  interviewWhereQuestion: 'Facciamo il colloquio di onboarding qui adesso o più tardi sul sito?',
@@ -317,11 +357,21 @@ const COPY = {
317
357
  existingNo: 'Não, deixe como está',
318
358
  existingKept: 'Perfeito, deixo o seu perfil como está.',
319
359
  keptLabels: { workSituation: 'Situação profissional', availability: 'Disponibilidade', languages: 'Idiomas', phone: 'Telefone' },
360
+ situationLabels: { FREELANCE: 'Freelancer', EMPLOYED: 'Por conta de outrem', BETWEEN_JOBS: 'Entre trabalhos', STUDYING: 'A estudar', OTHER: 'Outra' },
361
+ participationLabels: { FULL_TIME: 'tempo inteiro', PART_TIME: 'tempo parcial' },
320
362
  existingUnchanged: (items) => `Estes dados do seu perfil não os alterei, ficam como estavam: ${items.join(' · ')}. Se quiser outros valores, altere-os na web.`,
321
363
  aiUsageQuestion: 'Quer que analise como trabalha com IA? É opcional e demora 1-2 min. Só vejo que ferramentas usa; o seu código e os seus prompts não saem da sua máquina. Analiso o uso de IA em todo este computador, que considero seu. Ajuda-o a destacar-se em projetos de IA.',
322
364
  aiUsageYes: 'Sim, analise',
323
365
  aiUsageSkip: 'Saltar este passo',
324
366
  aiUsageSkipped: "Sem problema, seguimos sem ele. Aviso-o de que é o que mais pesa para lhe chegarem projetos: pode lançá-lo quando quiser dizendo-me 'analisa o meu uso de IA'.",
367
+ consent: {
368
+ yes: 'Sim, avançar',
369
+ no: 'Não',
370
+ cvSearch: 'A Shakers vai procurar neste computador ficheiros que possam ser o seu CV: vê os nomes dos ficheiros PDF e Word das pastas Secretária, Documentos, Transferências e iCloud Drive, e lê o seu fuso horário e o nome, o email e o utilizador de GitHub guardados pelas suas ferramentas de programação. Não abre esses ficheiros e não envia nada. Continuamos?',
371
+ cvRead: (file) => `A Shakers vai ler o ficheiro ${file} para preparar o rascunho do seu perfil. Ainda não é enviado nada. Continuamos?`,
372
+ cvSend: (file) => `A Shakers vai enviar o ficheiro ${file} para a sua conta Shakers para completar o seu perfil. Continuamos?`,
373
+ project: (p) => `A Shakers vai adicionar o projeto da pasta ${p.folder} ao seu portfólio e enviar ${listOf('pt', [`o nome "${p.name}"`, p.url && `o endereço web ${p.url}`, p.startDate && p.endDate ? `as datas de ${p.startDate} a ${p.endDate}` : (p.startDate || p.endDate) && `a data ${p.startDate || p.endDate}`, p.client && `o cliente ${p.client}`, p.description && 'a descrição que combinou'].filter(Boolean))}. Continuamos?`,
374
+ },
325
375
  importSources: { linkedin: 'o seu LinkedIn', cv: 'o seu CV', github: 'o seu GitHub', web: 'o seu site' },
326
376
  importFailed: (names) => `Não consegui importar ${names.join(' e ')} neste momento. O resto do seu perfil está guardado; o que faltar pode acrescentá-lo na web.`,
327
377
  interviewWhereQuestion: 'Fazemos a entrevista de onboarding aqui agora ou mais tarde na web?',
@@ -372,8 +422,12 @@ function legalCopy(lang) {
372
422
  function renderDraft(lang, d = {}) {
373
423
  const c = signupCopy(lang);
374
424
  const has = (v) => v !== null && v !== undefined && String(v).trim() !== '';
425
+ const place = [d.city, countryName(lang, d.country)].filter(has).join(', ');
426
+ const situation = c.situationLabels[d.situation];
427
+ const participation = d.situation === 'EMPLOYED' ? c.participationLabels[d.participation] : null;
375
428
  const lines = [
376
- joinParts([d.name, d.role, d.city]),
429
+ joinParts([d.name, d.role, place]),
430
+ situation ? `${c.keptLabels.workSituation}: ${participation ? `${situation} (${participation})` : situation}` : '',
377
431
  joinParts([has(d.yearsOfExperience) ? c.years(d.yearsOfExperience) : null, d.stack]),
378
432
  joinParts([d.languages, d.workMode, has(d.monthlyHours) ? c.hours(d.monthlyHours) : null]),
379
433
  has(d.hourlyRate) || has(d.annualRate) ? c.rates(has(d.hourlyRate) ? d.hourlyRate : null, has(d.annualRate) ? d.annualRate : null, d.ratesFromTalent !== true) : '',