shakers-cli-beta 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/bin/mcp.js +13 -7
- package/bin/register.js +5 -2
- package/package.json +1 -1
- package/src/authorship-mismatch.js +62 -0
- package/src/backend-request.js +12 -8
- package/src/config.js +27 -6
- package/src/consent-flow.js +10 -10
- package/src/i18n.js +16 -12
- package/src/mcp-ai-usage-result-tool.js +4 -2
- package/src/mcp-ai-usage-tool.js +3 -1
- package/src/mcp-alma-tools.js +7 -3
- package/src/mcp-applications-tools.js +4 -2
- package/src/mcp-auth-form.js +39 -10
- package/src/mcp-availability-tools.js +4 -2
- package/src/mcp-certifications-tools.js +4 -2
- package/src/mcp-certify-dimension-tools.js +4 -2
- package/src/mcp-choice.js +30 -4
- package/src/mcp-device-login-tools.js +6 -2
- package/src/mcp-find-projects-tools.js +4 -2
- package/src/mcp-instructions.js +6 -6
- package/src/mcp-invitations-tools.js +4 -2
- package/src/mcp-lang-tools.js +4 -2
- package/src/mcp-onboarding-tools.js +41 -44
- package/src/mcp-portfolios-tools.js +7 -3
- package/src/mcp-profile-tools.js +4 -2
- package/src/mcp-prompts.js +3 -3
- package/src/mcp-rate-tools.js +4 -2
- package/src/mcp-register-tools.js +53 -20
- package/src/mcp-report-tools.js +7 -3
- package/src/mcp-role-tools.js +36 -13
- package/src/mcp-saved-positions-tools.js +7 -3
- package/src/mcp-server.js +17 -4
- package/src/mcp-show-project-tools.js +4 -2
- package/src/mcp-signup-tools.js +229 -62
- package/src/mcp-socials-tools.js +4 -2
- package/src/onboarding-flow.js +112 -13
- package/src/onboarding-funnel.js +272 -0
- package/src/profile-client.js +11 -0
- package/src/share.js +4 -3
- package/src/signup-copy.js +84 -26
- package/src/terminal-agents.js +8 -0
- package/src/usage-run-steps.js +3 -0
- package/src/mcp-ask-tools.js +0 -50
package/src/onboarding-flow.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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/profile-client.js
CHANGED
|
@@ -113,10 +113,21 @@ async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_T
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// `works/users/me` -> the stored phone, null when hub has none.
|
|
117
|
+
async function fetchTelephone(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
118
|
+
const getEndpoint = deps.getUserMeEndpoint || require('./config').getUserMeEndpoint;
|
|
119
|
+
const req = deps.getMapped || getMapped;
|
|
120
|
+
return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => {
|
|
121
|
+
const t = d && d.telephone;
|
|
122
|
+
return { telephone: t && str(t.code) && str(t.number) ? { code: str(t.code), number: str(t.number) } : null };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
module.exports = {
|
|
117
127
|
requestAiProfile,
|
|
118
128
|
fetchAiProfile,
|
|
119
129
|
fetchMeProfile,
|
|
120
130
|
fetchTalentMeProfile,
|
|
121
131
|
fetchLanguages,
|
|
132
|
+
fetchTelephone,
|
|
122
133
|
};
|
package/src/share.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
|
-
const { getIngestEndpoint
|
|
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
|
-
|
|
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)) {
|