shakers-cli-beta 0.8.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +59 -0
- package/README.md +1175 -0
- package/bin/add-agent.js +88 -0
- package/bin/add-project.js +88 -0
- package/bin/add-role.js +61 -0
- package/bin/add-skill.js +88 -0
- package/bin/ai-usage.js +81 -0
- package/bin/alma.js +99 -0
- package/bin/applications.js +69 -0
- package/bin/availability.js +70 -0
- package/bin/certifications.js +69 -0
- package/bin/certify.js +73 -0
- package/bin/change-role.js +61 -0
- package/bin/config.js +120 -0
- package/bin/experiences.js +69 -0
- package/bin/find-projects.js +89 -0
- package/bin/invitations.js +69 -0
- package/bin/lang.js +70 -0
- package/bin/login.js +283 -0
- package/bin/mcp.js +98 -0
- package/bin/me.js +69 -0
- package/bin/onboarding.js +134 -0
- package/bin/portfolios.js +69 -0
- package/bin/rate.js +70 -0
- package/bin/register.js +325 -0
- package/bin/report-html.js +134 -0
- package/bin/save.js +77 -0
- package/bin/shakers.js +344 -0
- package/bin/share.js +72 -0
- package/bin/show-project.js +72 -0
- package/bin/socials.js +70 -0
- package/bin/superadmin.js +351 -0
- package/package.json +40 -0
- package/src/agent-category.js +13 -0
- package/src/agent-evaluation.js +177 -0
- package/src/agent-org-chart.js +355 -0
- package/src/agent-synthesis.js +132 -0
- package/src/agent-usage.js +118 -0
- package/src/agents-client.js +165 -0
- package/src/ai-fluency-cell.js +72 -0
- package/src/ai-profile-client.js +45 -0
- package/src/ai-profile-preview.js +154 -0
- package/src/ai.js +66 -0
- package/src/alma-client.js +123 -0
- package/src/alma-flow.js +32 -0
- package/src/alma-repo-context.js +47 -0
- package/src/alma-shell.js +161 -0
- package/src/ansi.js +62 -0
- package/src/applications-client.js +79 -0
- package/src/applications-flow.js +58 -0
- package/src/auth-client.js +144 -0
- package/src/auth-device-client.js +118 -0
- package/src/auth-method-picker.js +44 -0
- package/src/auth-methods.js +35 -0
- package/src/auth-session-store.js +214 -0
- package/src/authorship.js +203 -0
- package/src/automations-detector.js +170 -0
- package/src/availability-client.js +92 -0
- package/src/availability-flow.js +105 -0
- package/src/backend-request.js +170 -0
- package/src/blind-approval-extract.js +48 -0
- package/src/brand-ansi.js +14 -0
- package/src/brand-svg.js +9 -0
- package/src/browser-tools-detector.js +21 -0
- package/src/build-next-level.js +56 -0
- package/src/certifications-client.js +89 -0
- package/src/certifications-flow.js +75 -0
- package/src/certify-dimension-client.js +217 -0
- package/src/certify-dimension-flow.js +193 -0
- package/src/certify-disclaimer.js +51 -0
- package/src/certify-remediation-prompt.js +23 -0
- package/src/cli-args.js +83 -0
- package/src/config-dir.js +120 -0
- package/src/config.js +778 -0
- package/src/consent-flow.js +115 -0
- package/src/consent-skip.js +27 -0
- package/src/decisions-extract.js +43 -0
- package/src/detect-repos.js +145 -0
- package/src/detected-tools.js +15 -0
- package/src/detection-evidence.js +105 -0
- package/src/detection-noise.js +132 -0
- package/src/detectors.js +221 -0
- package/src/device-auth-store.js +100 -0
- package/src/device-login.js +118 -0
- package/src/email-verification.js +152 -0
- package/src/env-paths.js +16 -0
- package/src/find-projects-cache.js +89 -0
- package/src/find-projects-client.js +138 -0
- package/src/find-projects-flow.js +164 -0
- package/src/git-activity.js +123 -0
- package/src/graph-certs.js +58 -0
- package/src/graph-generator.js +230 -0
- package/src/graph-scan.js +239 -0
- package/src/i18n.js +3264 -0
- package/src/inter-fonts.js +6 -0
- package/src/interactive-select.js +183 -0
- package/src/interview-client.js +226 -0
- package/src/interview-transcript.js +56 -0
- package/src/inventory-client.js +90 -0
- package/src/invitations-client.js +66 -0
- package/src/invitations-flow.js +50 -0
- package/src/lang-client.js +147 -0
- package/src/lang-codes.js +14 -0
- package/src/lang-flow.js +85 -0
- package/src/legal-notice.js +53 -0
- package/src/locale.js +51 -0
- package/src/markdown-ansi.js +50 -0
- package/src/maturity.js +111 -0
- package/src/mcp-ai-usage-result-tool.js +109 -0
- package/src/mcp-ai-usage-tool.js +333 -0
- package/src/mcp-alma-tools.js +91 -0
- package/src/mcp-applications-tools.js +42 -0
- package/src/mcp-ask-tools.js +50 -0
- package/src/mcp-auth-form.js +282 -0
- package/src/mcp-availability-tools.js +43 -0
- package/src/mcp-certifications-tools.js +42 -0
- package/src/mcp-certify-dimension-tools.js +58 -0
- package/src/mcp-detector.js +223 -0
- package/src/mcp-device-login-tools.js +173 -0
- package/src/mcp-find-projects-tools.js +67 -0
- package/src/mcp-install.js +245 -0
- package/src/mcp-invitations-tools.js +42 -0
- package/src/mcp-lang-tools.js +39 -0
- package/src/mcp-onboarding-tools.js +388 -0
- package/src/mcp-portfolios-tools.js +46 -0
- package/src/mcp-profile-tools.js +46 -0
- package/src/mcp-rate-tools.js +42 -0
- package/src/mcp-register-tools.js +517 -0
- package/src/mcp-report-tools.js +101 -0
- package/src/mcp-role-tools.js +112 -0
- package/src/mcp-saved-positions-tools.js +55 -0
- package/src/mcp-server.js +170 -0
- package/src/mcp-show-project-tools.js +50 -0
- package/src/mcp-socials-tools.js +39 -0
- package/src/memory-structure-detector.js +108 -0
- package/src/model-call-record.js +198 -0
- package/src/onboarding-availability.js +26 -0
- package/src/onboarding-client.js +364 -0
- package/src/onboarding-flow.js +1078 -0
- package/src/onboarding-livekit-client.js +102 -0
- package/src/onboarding-status-client.js +42 -0
- package/src/open-file.js +32 -0
- package/src/osc-link.js +16 -0
- package/src/portfolio-client.js +164 -0
- package/src/portfolio-git-info.js +61 -0
- package/src/portfolios-client.js +85 -0
- package/src/portfolios-flow.js +79 -0
- package/src/profile-client.js +125 -0
- package/src/profile-flow.js +115 -0
- package/src/profile-switch.js +46 -0
- package/src/prompt-select.js +29 -0
- package/src/rate-client.js +82 -0
- package/src/rate-flow.js +137 -0
- package/src/render-ai-fluency.js +266 -0
- package/src/render-certification.js +177 -0
- package/src/render-html.js +140 -0
- package/src/render-report-text.js +27 -0
- package/src/render-sheet.js +261 -0
- package/src/render-terminal.js +109 -0
- package/src/render-usage-report-text.js +155 -0
- package/src/repo-context.js +215 -0
- package/src/report-gating.js +49 -0
- package/src/report-store.js +226 -0
- package/src/report-theme.js +177 -0
- package/src/report-view-client.js +50 -0
- package/src/reveal-output.js +29 -0
- package/src/roadmap-content.js +453 -0
- package/src/roadmap-personalization.js +132 -0
- package/src/roadmap-prompt.js +154 -0
- package/src/roles-client.js +114 -0
- package/src/roles-flow.js +192 -0
- package/src/sanitize-network-text.js +36 -0
- package/src/saved-positions-client.js +71 -0
- package/src/saved-positions-flow.js +50 -0
- package/src/scan-exclusions.js +50 -0
- package/src/scanner.js +612 -0
- package/src/scope-signals.js +148 -0
- package/src/secret-prompt.js +72 -0
- package/src/session-gate.js +30 -0
- package/src/session-refresh.js +51 -0
- package/src/session-scan.js +336 -0
- package/src/session-signals.js +36 -0
- package/src/shakers-wordmark.js +17 -0
- package/src/share-card.js +362 -0
- package/src/share-certification.js +111 -0
- package/src/share-consent-state.js +157 -0
- package/src/share-payload.js +300 -0
- package/src/share.js +184 -0
- package/src/show-project-client.js +58 -0
- package/src/show-project-flow.js +119 -0
- package/src/signup-client.js +91 -0
- package/src/skills-client.js +102 -0
- package/src/socials-client.js +91 -0
- package/src/socials-flow.js +83 -0
- package/src/specificity-extract.js +41 -0
- package/src/start-add-agents.js +306 -0
- package/src/start-add-portfolio.js +388 -0
- package/src/start-add-skills.js +260 -0
- package/src/stdin-ask.js +129 -0
- package/src/steering-extract.js +43 -0
- package/src/strip-internal-comments.js +45 -0
- package/src/tech-detector.js +295 -0
- package/src/templates/report-sheet.html +572 -0
- package/src/terminal-agents.js +199 -0
- package/src/terminal-format.js +85 -0
- package/src/terminal-progress.js +106 -0
- package/src/terminal-sections.js +219 -0
- package/src/tier-analysis.js +126 -0
- package/src/tier-engine.js +114 -0
- package/src/tier-projection.js +117 -0
- package/src/transcript-turns.js +49 -0
- package/src/translations-client.js +84 -0
- package/src/typewriter.js +154 -0
- package/src/usage-model-calls.js +119 -0
- package/src/usage-oneshot-commands.js +180 -0
- package/src/usage-repo-scope.js +162 -0
- package/src/usage-run-steps.js +338 -0
- package/src/work-streams.js +74 -0
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const FREELANCE_TYPES = ['FREELANCE', 'EMPLOYEE', 'AGENCY', 'POTENTIAL_FREELANCE'];
|
|
4
|
+
const CURRENCIES = ['USD', 'EUR', 'GBP'];
|
|
5
|
+
const PRICE_MIN = 0;
|
|
6
|
+
const PRICE_MAX = 999999.99;
|
|
7
|
+
const MONTHLY_HOURS = ['40', '60', '80', '100', '120', '160', '> 160'];
|
|
8
|
+
|
|
9
|
+
const WORK_SITUATIONS = ['FREELANCE', 'EMPLOYED', 'BETWEEN_JOBS', 'STUDYING', 'OTHER'];
|
|
10
|
+
const EMPLOYMENT_PARTICIPATIONS = ['FULL_TIME', 'PART_TIME'];
|
|
11
|
+
const FREELANCE_OPINIONS = ['WAS_FREELANCE_BEFORE', 'OPEN_TO_FREELANCE', 'NOT_INTERESTED'];
|
|
12
|
+
const CHANGE_MOTIVATORS = ['HIGHER_RATE', 'SPECIFIC_PROJECT', 'LEARNING_CERTIFICATION', 'FLEXIBILITY', 'COMMUNITY', 'LIFESTYLE_CHANGE'];
|
|
13
|
+
const LANGUAGE_LEVELS = ['INTERMEDIATE_WRITTEN', 'INTERMEDIATE', 'ADVANCED', 'NATIVE'];
|
|
14
|
+
const LANGUAGE_CODES = ['es', 'en', 'fr', 'ca', 'de', 'eu', 'it', 'pt'];
|
|
15
|
+
|
|
16
|
+
// Success/confirmation line: green via io.success when available, plain notify otherwise.
|
|
17
|
+
function notifyOk(io, text) {
|
|
18
|
+
(io.success ? io.success : io.notify).call(io, text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeMonthlyHours(value) {
|
|
22
|
+
if (typeof value !== 'string') return null;
|
|
23
|
+
const s = value.trim();
|
|
24
|
+
return MONTHLY_HOURS.includes(s) ? s : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeCountry(value) {
|
|
28
|
+
if (typeof value !== 'string') return null;
|
|
29
|
+
const s = value.trim().toUpperCase();
|
|
30
|
+
return /^[A-Z]{2}$/.test(s) ? s : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeFreelanceType(value) {
|
|
34
|
+
if (typeof value !== 'string') return null;
|
|
35
|
+
const upper = value.trim().toUpperCase();
|
|
36
|
+
return FREELANCE_TYPES.includes(upper) ? upper : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function freelanceIntentRequired(type) {
|
|
40
|
+
return type === 'EMPLOYEE' || type === 'POTENTIAL_FREELANCE';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deriveCurrentEmploymentStatus({ situation, participation } = {}) {
|
|
44
|
+
if (situation === 'EMPLOYED') {
|
|
45
|
+
if (participation === 'FULL_TIME') return 'IN_HOUSE_FULL_TIME';
|
|
46
|
+
if (participation === 'PART_TIME') return 'IN_HOUSE_PART_TIME';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return WORK_SITUATIONS.includes(situation) && situation !== 'EMPLOYED' ? situation : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function showsFreelanceOpinion(situation) {
|
|
53
|
+
return WORK_SITUATIONS.includes(situation) && situation !== 'FREELANCE';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deriveFreelanceIntent({ situation, opinion } = {}) {
|
|
57
|
+
if (situation === 'FREELANCE') return 'ALREADY_FREELANCE';
|
|
58
|
+
return FREELANCE_OPINIONS.includes(opinion) ? opinion : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function showsChangeMotivators(freelanceIntent) {
|
|
62
|
+
return freelanceIntent === 'ALREADY_FREELANCE' || freelanceIntent === 'WAS_FREELANCE_BEFORE' || freelanceIntent === 'OPEN_TO_FREELANCE';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The single freelanceType signal, derived from the work situation (asked once).
|
|
66
|
+
// AGENCY is not a work-situation option, so agencies map to FREELANCE (granularity lost, by decision).
|
|
67
|
+
const SITUATION_TO_FREELANCE_TYPE = {
|
|
68
|
+
FREELANCE: 'FREELANCE',
|
|
69
|
+
EMPLOYED: 'EMPLOYEE',
|
|
70
|
+
BETWEEN_JOBS: 'POTENTIAL_FREELANCE',
|
|
71
|
+
STUDYING: 'POTENTIAL_FREELANCE',
|
|
72
|
+
OTHER: 'POTENTIAL_FREELANCE',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function deriveFreelanceTypeFromSituation(situation) {
|
|
76
|
+
return SITUATION_TO_FREELANCE_TYPE[situation] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Transient default sent at signup (the account-creation DTO still requires
|
|
80
|
+
// freelanceType); the real value is set from the work situation right after.
|
|
81
|
+
const DEFAULT_SIGNUP_FREELANCE_TYPE = 'POTENTIAL_FREELANCE';
|
|
82
|
+
|
|
83
|
+
function validateWorkSituation(input = {}) {
|
|
84
|
+
const situation = typeof input.situation === 'string' ? input.situation.trim().toUpperCase() : '';
|
|
85
|
+
if (!WORK_SITUATIONS.includes(situation)) return { ok: false, reason: 'bad-situation' };
|
|
86
|
+
|
|
87
|
+
let currentEmploymentStatus;
|
|
88
|
+
if (situation === 'EMPLOYED') {
|
|
89
|
+
const participation = typeof input.participation === 'string' ? input.participation.trim().toUpperCase() : '';
|
|
90
|
+
if (!EMPLOYMENT_PARTICIPATIONS.includes(participation)) return { ok: false, reason: 'bad-participation' };
|
|
91
|
+
currentEmploymentStatus = deriveCurrentEmploymentStatus({ situation, participation });
|
|
92
|
+
} else {
|
|
93
|
+
currentEmploymentStatus = deriveCurrentEmploymentStatus({ situation });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let freelanceIntent;
|
|
97
|
+
if (situation === 'FREELANCE') {
|
|
98
|
+
freelanceIntent = 'ALREADY_FREELANCE';
|
|
99
|
+
} else {
|
|
100
|
+
const opinion = typeof input.opinion === 'string' ? input.opinion.trim().toUpperCase() : '';
|
|
101
|
+
if (!FREELANCE_OPINIONS.includes(opinion)) return { ok: false, reason: 'bad-opinion' };
|
|
102
|
+
freelanceIntent = deriveFreelanceIntent({ situation, opinion });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const professionalDetails = { currentEmploymentStatus, freelanceIntent };
|
|
106
|
+
const freelanceType = deriveFreelanceTypeFromSituation(situation);
|
|
107
|
+
if (freelanceType) professionalDetails.freelanceType = freelanceType;
|
|
108
|
+
if (showsChangeMotivators(freelanceIntent)) {
|
|
109
|
+
const motivator = typeof input.changeMotivators === 'string' ? input.changeMotivators.trim().toUpperCase() : '';
|
|
110
|
+
if (!CHANGE_MOTIVATORS.includes(motivator)) return { ok: false, reason: 'bad-motivation' };
|
|
111
|
+
professionalDetails.changeMotivators = motivator;
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, professionalDetails };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateLanguageRows(rows) {
|
|
117
|
+
if (!Array.isArray(rows) || rows.length === 0) return { ok: false, reason: 'no-languages' };
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
const language = typeof row.language === 'string' ? row.language.trim().toLowerCase() : '';
|
|
122
|
+
const level = typeof row.level === 'string' ? row.level.trim().toUpperCase() : '';
|
|
123
|
+
if (!language) return { ok: false, reason: 'bad-language' };
|
|
124
|
+
if (!LANGUAGE_LEVELS.includes(level)) return { ok: false, reason: 'bad-level' };
|
|
125
|
+
if (seen.has(language)) return { ok: false, reason: 'duplicate-language' };
|
|
126
|
+
seen.add(language);
|
|
127
|
+
out.push({ language, level });
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, rows: out };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validatePhone(input = {}) {
|
|
133
|
+
const code = typeof input.telephoneCode === 'string' ? input.telephoneCode.trim() : '';
|
|
134
|
+
const number = typeof input.telephoneNumber === 'string' ? input.telephoneNumber.trim() : '';
|
|
135
|
+
if (!code || !number) return { ok: false, reason: 'missing-phone' };
|
|
136
|
+
if (code.length > 10) return { ok: false, reason: 'bad-telephone-code' };
|
|
137
|
+
if (number.length > 20) return { ok: false, reason: 'bad-telephone-number' };
|
|
138
|
+
return { ok: true, telephoneCode: code, telephoneNumber: number };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeCurrency(value) {
|
|
142
|
+
if (typeof value !== 'string') return null;
|
|
143
|
+
const upper = value.trim().toUpperCase();
|
|
144
|
+
return CURRENCIES.includes(upper) ? upper : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const AMOUNT_RE = /^\d+(\.\d{1,2})?$/;
|
|
148
|
+
|
|
149
|
+
function normalizeAmount(value) {
|
|
150
|
+
let n;
|
|
151
|
+
if (typeof value === 'number') {
|
|
152
|
+
if (!Number.isFinite(value)) return null;
|
|
153
|
+
n = value;
|
|
154
|
+
} else if (typeof value === 'string') {
|
|
155
|
+
const s = value.trim();
|
|
156
|
+
if (!AMOUNT_RE.test(s)) return null;
|
|
157
|
+
n = Number(s);
|
|
158
|
+
} else {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (n < PRICE_MIN || n > PRICE_MAX) return null;
|
|
162
|
+
const cents = Math.round(n * 100);
|
|
163
|
+
if (Math.abs(n * 100 - cents) > 1e-6) return null;
|
|
164
|
+
return cents / 100;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Pricing input (ADR-024).
|
|
168
|
+
function validatePricing(input = {}) {
|
|
169
|
+
const fullSelected = input.fullTimeSelected === true;
|
|
170
|
+
const partSelected = input.partTimeSelected === true;
|
|
171
|
+
if (!fullSelected && !partSelected) return { ok: false, reason: 'no-variant' };
|
|
172
|
+
|
|
173
|
+
const fullCurrency = normalizeCurrency(input.fullTimeCurrency);
|
|
174
|
+
const partCurrency = normalizeCurrency(input.partTimeCurrency);
|
|
175
|
+
const fallbackCurrency = fullCurrency || partCurrency || 'EUR';
|
|
176
|
+
|
|
177
|
+
let fullAmount = normalizeAmount(input.fullTimeAmount);
|
|
178
|
+
let partAmount = normalizeAmount(input.partTimeAmount);
|
|
179
|
+
|
|
180
|
+
if (fullSelected) {
|
|
181
|
+
if (fullAmount === null) return { ok: false, reason: 'bad-full-amount' };
|
|
182
|
+
if (!fullCurrency) return { ok: false, reason: 'bad-full-currency' };
|
|
183
|
+
}
|
|
184
|
+
if (partSelected) {
|
|
185
|
+
if (partAmount === null) return { ok: false, reason: 'bad-part-amount' };
|
|
186
|
+
if (!partCurrency) return { ok: false, reason: 'bad-part-currency' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
pricing: buildPricingUpsert({
|
|
192
|
+
fullSelected,
|
|
193
|
+
fullAmount: fullSelected ? fullAmount : (fullAmount === null ? 0 : fullAmount),
|
|
194
|
+
fullCurrency: fullCurrency || fallbackCurrency,
|
|
195
|
+
partSelected,
|
|
196
|
+
partAmount: partSelected ? partAmount : (partAmount === null ? 0 : partAmount),
|
|
197
|
+
partCurrency: partCurrency || fallbackCurrency,
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildPricingUpsert({ fullSelected, fullAmount, fullCurrency, partSelected, partAmount, partCurrency }) {
|
|
203
|
+
return {
|
|
204
|
+
fullTimeProjectSelected: fullSelected === true,
|
|
205
|
+
fullTimeProjectPrice: { amount: fullAmount, currency: fullCurrency },
|
|
206
|
+
partTimeProjectSelected: partSelected === true,
|
|
207
|
+
partTimeProjectPrice: { amount: partAmount, currency: partCurrency },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Availability + location (ADR-028).
|
|
212
|
+
function validateAvailability(input = {}) {
|
|
213
|
+
const body = { confirmed: true };
|
|
214
|
+
if (typeof input.available === 'boolean') body.available = input.available;
|
|
215
|
+
if (input.monthlyHours != null && input.monthlyHours !== '') {
|
|
216
|
+
const mh = normalizeMonthlyHours(input.monthlyHours);
|
|
217
|
+
if (!mh) return { ok: false, reason: 'bad-monthly-hours' };
|
|
218
|
+
body.monthlyHours = mh;
|
|
219
|
+
}
|
|
220
|
+
if (typeof input.onlyRemote === 'boolean') body.onlyRemote = input.onlyRemote;
|
|
221
|
+
if (input.country != null && input.country !== '') {
|
|
222
|
+
const cc = normalizeCountry(input.country);
|
|
223
|
+
if (!cc) return { ok: false, reason: 'bad-country' };
|
|
224
|
+
body.country = cc;
|
|
225
|
+
}
|
|
226
|
+
if (typeof input.timezone === 'string' && input.timezone.trim()) body.timezone = input.timezone.trim().slice(0, 100);
|
|
227
|
+
if (typeof input.subdivision === 'string' && input.subdivision.trim()) body.subdivision = input.subdivision.trim().slice(0, 10);
|
|
228
|
+
if (typeof input.longFullTimeProjects === 'boolean') body.longFullTimeProjects = input.longFullTimeProjects;
|
|
229
|
+
return { ok: true, availability: body };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function makeDeps(overrides = {}) {
|
|
233
|
+
const config = require('./config');
|
|
234
|
+
const client = require('./onboarding-client');
|
|
235
|
+
const ai = require('./ai');
|
|
236
|
+
const inventory = require('./inventory-client');
|
|
237
|
+
const auth = require('./auth-session-store');
|
|
238
|
+
const authClient = require('./auth-client');
|
|
239
|
+
const signup = require('./signup-client');
|
|
240
|
+
const livekit = require('./onboarding-livekit-client');
|
|
241
|
+
return {
|
|
242
|
+
loadAuthSession: auth.loadAuthSession,
|
|
243
|
+
sessionStatus: auth.sessionStatus,
|
|
244
|
+
saveAuthSession: auth.saveAuthSession,
|
|
245
|
+
ensureFreshSession: require('./session-refresh').ensureFreshSession,
|
|
246
|
+
getSignUpEndpoint: config.getSignUpEndpoint,
|
|
247
|
+
getCompleteRegistrationEndpoint: config.getCompleteRegistrationEndpoint,
|
|
248
|
+
getLoginEndpoint: config.getLoginEndpoint,
|
|
249
|
+
getAuthTokenEndpoint: config.getAuthTokenEndpoint,
|
|
250
|
+
getDeviceAuthorizeEndpoint: config.getDeviceAuthorizeEndpoint,
|
|
251
|
+
getDeviceTokenEndpoint: config.getDeviceTokenEndpoint,
|
|
252
|
+
runDeviceLogin: require('./device-login').runDeviceLogin,
|
|
253
|
+
requestCompleteRegistration: signup.requestCompleteRegistration,
|
|
254
|
+
buildRegistrationContext: signup.buildRegistrationContext,
|
|
255
|
+
requestSignUp: signup.requestSignUp,
|
|
256
|
+
requestLogin: authClient.requestLogin,
|
|
257
|
+
getImportProfileEndpoint: config.getImportProfileEndpoint,
|
|
258
|
+
getProfessionalDetailsEndpoint: config.getProfessionalDetailsEndpoint,
|
|
259
|
+
getPricingRateEndpoint: config.getPricingRateEndpoint,
|
|
260
|
+
getSetAvailabilityEndpoint: config.getSetAvailabilityEndpoint,
|
|
261
|
+
getLanguagesEndpoint: config.getLanguagesEndpoint,
|
|
262
|
+
getLanguagesCatalogEndpoint: config.getLanguagesCatalogEndpoint,
|
|
263
|
+
getTalentMeEndpoint: config.getTalentMeEndpoint,
|
|
264
|
+
getOnboardingInterviewsEndpoint: config.getOnboardingInterviewsEndpoint,
|
|
265
|
+
getCompleteOnboardingEndpoint: config.getCompleteOnboardingEndpoint,
|
|
266
|
+
getTalentProfileUrl: config.getTalentProfileUrl,
|
|
267
|
+
getUsageDiscoveredInventoryEndpoint: config.getUsageDiscoveredInventoryEndpoint,
|
|
268
|
+
requestImportProfile: client.requestImportProfile,
|
|
269
|
+
requestImportStatus: client.requestImportStatus,
|
|
270
|
+
requestSetProfessionalDetails: client.requestSetProfessionalDetails,
|
|
271
|
+
requestSetPricingRate: client.requestSetPricingRate,
|
|
272
|
+
requestSetAvailability: client.requestSetAvailability,
|
|
273
|
+
requestLanguagesCatalog: client.requestLanguagesCatalog,
|
|
274
|
+
requestSetLanguages: client.requestSetLanguages,
|
|
275
|
+
requestSetTalentPhone: client.requestSetTalentPhone,
|
|
276
|
+
requestCreateOnboardingInterview: client.requestCreateOnboardingInterview,
|
|
277
|
+
requestStartLivekitSession: livekit.requestStartOnboardingLivekitSession,
|
|
278
|
+
requestCompleteLivekitSession: livekit.requestCompleteOnboardingLivekitSession,
|
|
279
|
+
requestStartTextSession: ai.requestStartTextSession,
|
|
280
|
+
requestOnboardingTurn: ai.requestOnboardingTurn,
|
|
281
|
+
requestCompleteTextSession: ai.requestCompleteTextSession,
|
|
282
|
+
requestCompleteOnboarding: client.requestCompleteOnboarding,
|
|
283
|
+
getOnboardingRestartEndpoint: config.getOnboardingRestartEndpoint,
|
|
284
|
+
requestRestartOnboardingInterview: client.requestRestartOnboardingInterview,
|
|
285
|
+
// Final onboarding step: pick the main role (roles-flow.js).
|
|
286
|
+
runMainRoleStep: require('./roles-flow').runOnboardingMainRoleStep,
|
|
287
|
+
makeRolesDeps: require('./roles-flow').makeRolesDeps,
|
|
288
|
+
requestDiscoveredInventory: inventory.requestDiscoveredInventory,
|
|
289
|
+
makeInterviewClient: (opts) => new (require('./interview-client').InterviewClient)(opts),
|
|
290
|
+
// Used by reattachConsentIdentity (A) to re-anchor the ingest identity.
|
|
291
|
+
recordConsent: require('./share-consent-state').recordConsent,
|
|
292
|
+
...overrides,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Refreshes the email session's hub JWT from its stored cookie when the cached one has lapsed (session-refresh.js), then gates on the durable session status.
|
|
297
|
+
async function requireActiveSession(deps) {
|
|
298
|
+
const session = typeof deps.ensureFreshSession === 'function'
|
|
299
|
+
? await deps.ensureFreshSession(process.env, { loadAuthSession: deps.loadAuthSession, saveAuthSession: deps.saveAuthSession })
|
|
300
|
+
: deps.loadAuthSession();
|
|
301
|
+
if (deps.sessionStatus(session) !== 'active') {
|
|
302
|
+
return { ok: false, reason: 'no-session' };
|
|
303
|
+
}
|
|
304
|
+
return { ok: true, session };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isAcceptablePassword(password) {
|
|
308
|
+
return typeof password === 'string' && password.length >= 8;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isNonEmptyString(value) {
|
|
312
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function validateRegisterFields(fields = {}) {
|
|
316
|
+
if (!isNonEmptyString(fields.name)) return { ok: false, reason: 'missing-name' };
|
|
317
|
+
if (!isNonEmptyString(fields.lastName)) return { ok: false, reason: 'missing-last-name' };
|
|
318
|
+
if (!isNonEmptyString(fields.email)) return { ok: false, reason: 'missing-email' };
|
|
319
|
+
const type = normalizeFreelanceType(fields.freelanceType);
|
|
320
|
+
if (!type) return { ok: false, reason: 'bad-freelance-type' };
|
|
321
|
+
return { ok: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function signUp(deps, fields = {}) {
|
|
325
|
+
// The CLI no longer asks freelanceType at signup → transient default (corrected later from the work situation via professional-details).
|
|
326
|
+
if (!isAcceptablePassword(fields.password)) return { ok: false, reason: 'weak-password' };
|
|
327
|
+
const signUpEndpoint = deps.getSignUpEndpoint();
|
|
328
|
+
if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
329
|
+
const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function'
|
|
330
|
+
? deps.getCompleteRegistrationEndpoint()
|
|
331
|
+
: null;
|
|
332
|
+
return deps.requestSignUp(
|
|
333
|
+
{
|
|
334
|
+
name: fields.name,
|
|
335
|
+
lastName: fields.lastName,
|
|
336
|
+
email: fields.email,
|
|
337
|
+
password: fields.password,
|
|
338
|
+
preferredLanguage: fields.preferredLanguage,
|
|
339
|
+
newsletterConsent: fields.newsletterConsent === true,
|
|
340
|
+
freelanceType: normalizeFreelanceType(fields.freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
|
|
341
|
+
freelanceIntent: fields.freelanceIntent,
|
|
342
|
+
},
|
|
343
|
+
{ signUpEndpoint, completeRegistrationEndpoint },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function establishSessionFromCredentials(deps, { email, password } = {}) {
|
|
348
|
+
const signInEndpoint = deps.getLoginEndpoint();
|
|
349
|
+
if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
350
|
+
const tokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
|
|
351
|
+
const result = await deps.requestLogin({ email, password }, { signInEndpoint, tokenEndpoint });
|
|
352
|
+
if (!result.ok) return { ok: false, reason: result.reason || 'login-failed', status: result.status || null };
|
|
353
|
+
try {
|
|
354
|
+
// For an email session the durable credential is the cookie; `accessToken` is
|
|
355
|
+
// the fresh JWT and `expiresAt` its own exp (stored as the refresh horizon).
|
|
356
|
+
deps.saveAuthSession({
|
|
357
|
+
accessToken: result.accessToken,
|
|
358
|
+
expiresAt: result.expiresAt,
|
|
359
|
+
accessTokenExpiresAt: result.expiresAt,
|
|
360
|
+
cookie: result.cookie || null,
|
|
361
|
+
cookieExpiresAt: result.cookieExpiresAt || null,
|
|
362
|
+
email: result.email || email || null,
|
|
363
|
+
hubAccessToken: result.hubAccessToken || null,
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
return { ok: false, reason: 'session-persist-failed' };
|
|
367
|
+
}
|
|
368
|
+
return { ok: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Register a talent via a social provider (device flow, RFC 8628); a live session results either way.
|
|
372
|
+
async function registerWithSocialAccount(deps, { provider = 'google', freelanceType, language } = {}, { openBrowser, onAuthUrl } = {}) {
|
|
373
|
+
const { appendProviderParam } = require('./device-login');
|
|
374
|
+
const authorizeEndpoint = deps.getDeviceAuthorizeEndpoint();
|
|
375
|
+
const tokenEndpoint = deps.getDeviceTokenEndpoint();
|
|
376
|
+
const authTokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
|
|
377
|
+
const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function' ? deps.getCompleteRegistrationEndpoint() : null;
|
|
378
|
+
if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
379
|
+
|
|
380
|
+
// freelanceType is a transient default (corrected later); feeds complete-registration for a new account.
|
|
381
|
+
const registrationContext = deps.buildRegistrationContext({
|
|
382
|
+
preferredLanguage: language,
|
|
383
|
+
newsletterConsent: false,
|
|
384
|
+
freelanceType: normalizeFreelanceType(freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const result = await deps.runDeviceLogin(
|
|
388
|
+
{ authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext },
|
|
389
|
+
{
|
|
390
|
+
onPrompt: ({ verificationUriComplete, userCode }) => {
|
|
391
|
+
const url = appendProviderParam(verificationUriComplete, provider);
|
|
392
|
+
if (typeof onAuthUrl === 'function') onAuthUrl(url, userCode);
|
|
393
|
+
if (typeof openBrowser === 'function') { try { openBrowser(url); } catch { /* printed URL covers it */ } }
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
if (!result.ok) return { ok: false, reason: result.reason || 'register-failed' };
|
|
398
|
+
|
|
399
|
+
// Persist the API JWT the exchange minted (no cookie), like the retired Google flow did.
|
|
400
|
+
try {
|
|
401
|
+
deps.saveAuthSession({
|
|
402
|
+
accessToken: result.token,
|
|
403
|
+
hubAccessToken: result.token,
|
|
404
|
+
email: result.email || null,
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
return { ok: false, reason: 'session-persist-failed' };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// isNewUser distinguishes a fresh registration from signing an existing account in.
|
|
411
|
+
return { ok: true, kind: result.isNewUser ? 'registered' : 'account-already-exists', email: result.email || null };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function startProfileImport(deps, sources = {}) {
|
|
415
|
+
const gate = await requireActiveSession(deps);
|
|
416
|
+
if (!gate.ok) return gate;
|
|
417
|
+
const endpoint = deps.getImportProfileEndpoint();
|
|
418
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
419
|
+
return deps.requestImportProfile(
|
|
420
|
+
{ ...sources, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
421
|
+
{ endpoint },
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function pollProfileImport(deps, { jobId } = {}) {
|
|
426
|
+
const gate = await requireActiveSession(deps);
|
|
427
|
+
if (!gate.ok) return gate;
|
|
428
|
+
const endpoint = deps.getImportProfileEndpoint();
|
|
429
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
430
|
+
return deps.requestImportStatus(
|
|
431
|
+
{ jobId, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
432
|
+
{ endpoint },
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// A: anchor the ingest identity to THIS talent's session email so a stale
|
|
437
|
+
// consent.json from a prior alta doesn't misattribute the footprint. Never throws.
|
|
438
|
+
function reattachConsentIdentity(deps) {
|
|
439
|
+
if (typeof deps.recordConsent !== 'function') return { ok: false, reason: 'unsupported' };
|
|
440
|
+
// Only needs the session EMAIL (no fresh JWT), so it stays sync and reads the
|
|
441
|
+
// session directly rather than through the async refreshing gate.
|
|
442
|
+
const session = deps.loadAuthSession();
|
|
443
|
+
if (deps.sessionStatus(session) !== 'active') return { ok: false, reason: 'no-session' };
|
|
444
|
+
const email = session && typeof session.email === 'string' ? session.email.trim() : '';
|
|
445
|
+
if (!email) return { ok: false, reason: 'no-session-email' };
|
|
446
|
+
try {
|
|
447
|
+
deps.recordConsent('granted', email, { verified: true });
|
|
448
|
+
return { ok: true, email };
|
|
449
|
+
} catch (e) {
|
|
450
|
+
return { ok: false, reason: 'record-failed', error: e && e.message };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function saveWorkSituation(deps, input = {}) {
|
|
455
|
+
const gate = await requireActiveSession(deps);
|
|
456
|
+
if (!gate.ok) return gate;
|
|
457
|
+
const validated = validateWorkSituation(input);
|
|
458
|
+
if (!validated.ok) return validated;
|
|
459
|
+
const endpoint = deps.getProfessionalDetailsEndpoint();
|
|
460
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
461
|
+
return deps.requestSetProfessionalDetails(
|
|
462
|
+
{ ...validated.professionalDetails, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
463
|
+
{ endpoint },
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function savePricing(deps, pricingInput = {}) {
|
|
468
|
+
const gate = await requireActiveSession(deps);
|
|
469
|
+
if (!gate.ok) return gate;
|
|
470
|
+
const validated = validatePricing(pricingInput);
|
|
471
|
+
if (!validated.ok) return validated;
|
|
472
|
+
const endpoint = deps.getPricingRateEndpoint();
|
|
473
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
474
|
+
return deps.requestSetPricingRate(
|
|
475
|
+
{ pricing: validated.pricing, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
476
|
+
{ endpoint },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function saveAvailability(deps, availabilityInput = {}) {
|
|
481
|
+
const gate = await requireActiveSession(deps);
|
|
482
|
+
if (!gate.ok) return gate;
|
|
483
|
+
const validated = validateAvailability(availabilityInput);
|
|
484
|
+
if (!validated.ok) return validated;
|
|
485
|
+
const endpoint = deps.getSetAvailabilityEndpoint();
|
|
486
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
487
|
+
return deps.requestSetAvailability(
|
|
488
|
+
{ availability: validated.availability, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
489
|
+
{ endpoint },
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function saveLanguages(deps, rows) {
|
|
494
|
+
const gate = await requireActiveSession(deps);
|
|
495
|
+
if (!gate.ok) return gate;
|
|
496
|
+
const validated = validateLanguageRows(rows);
|
|
497
|
+
if (!validated.ok) return validated;
|
|
498
|
+
|
|
499
|
+
const catalogEndpoint = deps.getLanguagesCatalogEndpoint();
|
|
500
|
+
if (!catalogEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
501
|
+
const catalog = await deps.requestLanguagesCatalog({ hubAccessToken: gate.session.hubAccessToken }, { endpoint: catalogEndpoint });
|
|
502
|
+
if (!catalog.ok) return { ok: false, reason: catalog.reason };
|
|
503
|
+
|
|
504
|
+
const byCode = new Map();
|
|
505
|
+
for (const entry of catalog.languages) {
|
|
506
|
+
if (entry && typeof entry.code === 'string' && typeof entry.numId === 'number') {
|
|
507
|
+
byCode.set(entry.code.toLowerCase(), entry.numId);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const resolved = [];
|
|
512
|
+
const unresolved = [];
|
|
513
|
+
for (const row of validated.rows) {
|
|
514
|
+
const id = byCode.get(row.language);
|
|
515
|
+
if (id === undefined) unresolved.push(row.language);
|
|
516
|
+
else resolved.push({ id, level: row.level });
|
|
517
|
+
}
|
|
518
|
+
if (unresolved.length > 0) return { ok: false, reason: 'unresolved-languages', unresolved };
|
|
519
|
+
|
|
520
|
+
const endpoint = deps.getLanguagesEndpoint();
|
|
521
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
522
|
+
return deps.requestSetLanguages(
|
|
523
|
+
{ languages: resolved, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
524
|
+
{ endpoint },
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function savePhone(deps, phoneInput = {}) {
|
|
529
|
+
const gate = await requireActiveSession(deps);
|
|
530
|
+
if (!gate.ok) return gate;
|
|
531
|
+
const validated = validatePhone(phoneInput);
|
|
532
|
+
if (!validated.ok) return validated;
|
|
533
|
+
const endpoint = deps.getTalentMeEndpoint();
|
|
534
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
535
|
+
return deps.requestSetTalentPhone(
|
|
536
|
+
{
|
|
537
|
+
telephoneCode: validated.telephoneCode,
|
|
538
|
+
telephoneNumber: validated.telephoneNumber,
|
|
539
|
+
accessToken: gate.session.accessToken,
|
|
540
|
+
hubAccessToken: gate.session.hubAccessToken,
|
|
541
|
+
},
|
|
542
|
+
{ endpoint },
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Whether to OFFER step 4 (usage consent).
|
|
547
|
+
async function detectPriorUsage(deps) {
|
|
548
|
+
const gate = await requireActiveSession(deps);
|
|
549
|
+
if (!gate.ok) return { ok: false, reason: 'no-session' };
|
|
550
|
+
const endpoint = deps.getUsageDiscoveredInventoryEndpoint();
|
|
551
|
+
if (!endpoint) return { ok: true, hasEvidence: null };
|
|
552
|
+
const inv = await deps.requestDiscoveredInventory({ accessToken: gate.session.accessToken }, { endpoint });
|
|
553
|
+
if (inv.ok) {
|
|
554
|
+
const hasEvidence = (Array.isArray(inv.skills) && inv.skills.length > 0)
|
|
555
|
+
|| (Array.isArray(inv.agents) && inv.agents.length > 0);
|
|
556
|
+
return { ok: true, hasEvidence };
|
|
557
|
+
}
|
|
558
|
+
if (inv.reason === 'no-inventory') return { ok: true, hasEvidence: false };
|
|
559
|
+
return { ok: true, hasEvidence: null };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
563
|
+
|
|
564
|
+
function decodeJwtSub(token) {
|
|
565
|
+
try {
|
|
566
|
+
const parts = String(token).split('.');
|
|
567
|
+
if (parts.length < 2) return null;
|
|
568
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
569
|
+
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null;
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function candidateIdFor(deps, override) {
|
|
576
|
+
if (typeof override === 'string' && UUID_RE.test(override)) return override;
|
|
577
|
+
const session = deps.loadAuthSession();
|
|
578
|
+
if (session && typeof session.userId === 'string' && UUID_RE.test(session.userId)) return session.userId;
|
|
579
|
+
return session && session.accessToken ? decodeJwtSub(session.accessToken) : null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function createOnboardingInterview(deps, { candidateId } = {}) {
|
|
583
|
+
const gate = await requireActiveSession(deps);
|
|
584
|
+
if (!gate.ok) return gate;
|
|
585
|
+
const base = deps.getOnboardingInterviewsEndpoint();
|
|
586
|
+
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
587
|
+
const id = candidateIdFor(deps, candidateId);
|
|
588
|
+
if (!id) return { ok: false, reason: 'no-candidate' };
|
|
589
|
+
return deps.requestCreateOnboardingInterview({ candidateId: id, accessToken: gate.session.accessToken }, { base });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// LiveKit credentials for an already-created interview (onboarding OR dimension);
|
|
593
|
+
// the `/interviews/:id/livekit-text-session` route is shared across kinds.
|
|
594
|
+
async function startLivekitForInterview(deps, { interviewId, language } = {}) {
|
|
595
|
+
if (!interviewId) return { ok: false, reason: 'no-interview', step: 'start' };
|
|
596
|
+
const gate = await requireActiveSession(deps);
|
|
597
|
+
if (!gate.ok) return gate;
|
|
598
|
+
const base = deps.getOnboardingInterviewsEndpoint();
|
|
599
|
+
if (!base) return { ok: false, reason: 'no-endpoint', step: 'start' };
|
|
600
|
+
|
|
601
|
+
const started = await deps.requestStartLivekitSession(
|
|
602
|
+
{ interviewId, language, accessToken: gate.session.accessToken },
|
|
603
|
+
{ base },
|
|
604
|
+
);
|
|
605
|
+
if (!started.ok) return { ok: false, reason: started.reason, step: 'start' };
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
interviewId: started.interviewId || interviewId,
|
|
609
|
+
livekitUrl: started.livekitUrl,
|
|
610
|
+
token: started.token,
|
|
611
|
+
roomName: started.roomName || null,
|
|
612
|
+
closingMessage: started.closingMessage || null,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function createAndStartLivekitOnboarding(deps, { candidateId, language } = {}) {
|
|
617
|
+
const created = await createOnboardingInterview(deps, { candidateId });
|
|
618
|
+
if (!created.ok) return { ok: false, reason: created.reason, step: 'create' };
|
|
619
|
+
return startLivekitForInterview(deps, { interviewId: created.interviewId, language });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Persist the LiveKit-transport onboarding interview's transcript on session end.
|
|
623
|
+
async function completeLivekitOnboarding(deps, { interviewId, durationSeconds, transcripts } = {}) {
|
|
624
|
+
const gate = await requireActiveSession(deps);
|
|
625
|
+
if (!gate.ok) return gate;
|
|
626
|
+
const base = deps.getOnboardingInterviewsEndpoint();
|
|
627
|
+
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
628
|
+
return deps.requestCompleteLivekitSession(
|
|
629
|
+
{ interviewId, durationSeconds, transcripts, accessToken: gate.session.accessToken },
|
|
630
|
+
{ base },
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function startOnboardingSession(deps, { interviewId, language } = {}) {
|
|
635
|
+
const gate = await requireActiveSession(deps);
|
|
636
|
+
if (!gate.ok) return gate;
|
|
637
|
+
const base = deps.getOnboardingInterviewsEndpoint();
|
|
638
|
+
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
639
|
+
return deps.requestStartTextSession({ interviewId, language, accessToken: gate.session.accessToken }, { base });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function submitOnboardingTurn(deps, { interviewId, message } = {}) {
|
|
643
|
+
const gate = await requireActiveSession(deps);
|
|
644
|
+
if (!gate.ok) return gate;
|
|
645
|
+
const base = deps.getOnboardingInterviewsEndpoint();
|
|
646
|
+
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
647
|
+
return deps.requestOnboardingTurn({ interviewId, message, accessToken: gate.session.accessToken }, { base });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function completeOnboarding(deps, { interviewId } = {}) {
|
|
651
|
+
const gate = await requireActiveSession(deps);
|
|
652
|
+
if (!gate.ok) return gate;
|
|
653
|
+
const base = deps.getOnboardingInterviewsEndpoint();
|
|
654
|
+
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
655
|
+
return deps.requestCompleteTextSession({ interviewId, accessToken: gate.session.accessToken }, { base });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Terminal step: flip onboarding_status -> COMPLETED via complete-onboarding, which moves registration_level to ONBOARDING_COMPLETED (the metrics Data consumes).
|
|
659
|
+
async function completeOnboardingRegistration(deps) {
|
|
660
|
+
const gate = await requireActiveSession(deps);
|
|
661
|
+
if (!gate.ok) return gate;
|
|
662
|
+
const endpoint = deps.getCompleteOnboardingEndpoint();
|
|
663
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
664
|
+
return deps.requestCompleteOnboarding(
|
|
665
|
+
{ accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
|
|
666
|
+
{ endpoint },
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function profileUrl(deps) {
|
|
671
|
+
return deps.getTalentProfileUrl() || null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function usageDisclaimers(lang) {
|
|
675
|
+
const { getCatalog } = require('./i18n');
|
|
676
|
+
const c = getCatalog(lang).onboarding;
|
|
677
|
+
return { infoAccessed: c.usageInfoAccessed, goalDuration: c.usageGoalDuration };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function interviewDisclaimers(lang) {
|
|
681
|
+
const { getCatalog } = require('./i18n');
|
|
682
|
+
const c = getCatalog(lang).onboarding;
|
|
683
|
+
return { infoAccessed: c.interviewInfoAccessed, goalDuration: c.interviewGoalDuration };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Blocking orchestrator used by the CLI (`bin/onboarding.js`).
|
|
687
|
+
async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile } = {}) {
|
|
688
|
+
const { getCatalog } = require('./i18n');
|
|
689
|
+
const c = getCatalog(io.lang).onboarding;
|
|
690
|
+
|
|
691
|
+
io.section(c.signupTitle);
|
|
692
|
+
|
|
693
|
+
if (method === 'google' || method === 'linkedin') {
|
|
694
|
+
// The provider supplies name/email; freelanceType is derived later from work situation.
|
|
695
|
+
await io.askGoogleSignUp(c);
|
|
696
|
+
const reg = await registerWithSocialAccount(
|
|
697
|
+
deps,
|
|
698
|
+
{ provider: method, language: io.lang },
|
|
699
|
+
{ openBrowser, onAuthUrl },
|
|
700
|
+
);
|
|
701
|
+
if (!reg.ok) {
|
|
702
|
+
io.error(c.signupError);
|
|
703
|
+
return { ok: false, reason: reg.reason, step: 'signup' };
|
|
704
|
+
}
|
|
705
|
+
if (reg.kind === 'account-already-exists') {
|
|
706
|
+
// Existing account: already signed in — stop, no wizard re-run.
|
|
707
|
+
io.warn(c.accountExists);
|
|
708
|
+
return { ok: true, alreadyRegistered: true };
|
|
709
|
+
}
|
|
710
|
+
notifyOk(io, c.accountReady);
|
|
711
|
+
} else {
|
|
712
|
+
const account = await io.askSignUp(c);
|
|
713
|
+
if (!account || !account.email) return { ok: false, reason: 'missing-fields', step: 'signup' };
|
|
714
|
+
if (!isAcceptablePassword(account.password)) {
|
|
715
|
+
io.error(c.weakPassword);
|
|
716
|
+
return { ok: false, reason: 'weak-password', step: 'signup' };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const su = await signUp(deps, { ...account, preferredLanguage: account.preferredLanguage || io.lang });
|
|
720
|
+
if (!su.ok) {
|
|
721
|
+
io.error(su.message || c.signupError);
|
|
722
|
+
return { ok: false, reason: su.reason, step: 'signup' };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (su.accountExists) {
|
|
726
|
+
// Existing account: sign them in, then STOP — no wizard re-run.
|
|
727
|
+
io.warn(c.accountExists);
|
|
728
|
+
const logged = await io.login(account.email);
|
|
729
|
+
if (!logged) return { ok: false, reason: 'login-failed', step: 'signup' };
|
|
730
|
+
return { ok: true, alreadyRegistered: true };
|
|
731
|
+
}
|
|
732
|
+
const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
|
|
733
|
+
if (!sess.ok) {
|
|
734
|
+
io.error(c.sessionError);
|
|
735
|
+
return { ok: false, reason: sess.reason, step: 'signup' };
|
|
736
|
+
}
|
|
737
|
+
notifyOk(io, c.accountReady);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
io.section(c.title);
|
|
741
|
+
const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
|
|
742
|
+
if (!linkedinUrl) return { ok: false, reason: 'no-linkedin', step: 'import' };
|
|
743
|
+
const cvPath = (await io.ask(c.askCv) || '').trim();
|
|
744
|
+
const githubUrl = (await io.ask(c.askGithub) || '').trim();
|
|
745
|
+
const websiteUrl = (await io.ask(c.askWebsite) || '').trim();
|
|
746
|
+
|
|
747
|
+
// The hub import is SYNCHRONOUS (the POST returns once it finishes), so show the
|
|
748
|
+
// loader AROUND that wait, then the real outcome. No background job to poll.
|
|
749
|
+
const withProgress = typeof io.withProgress === 'function' ? io.withProgress : (_l, task) => task();
|
|
750
|
+
const importRes = await withProgress(c.importChecking, () =>
|
|
751
|
+
startProfileImport(deps, { linkedinUrl, cvPath, githubUrl, websiteUrl, language: io.lang }));
|
|
752
|
+
if (importRes.ok) notifyOk(io, c.importDone);
|
|
753
|
+
else io.warn(c.importUnavailable);
|
|
754
|
+
|
|
755
|
+
io.section(c.professionalTitle);
|
|
756
|
+
const workSituationInput = await io.askWorkSituation(c);
|
|
757
|
+
const workSituationRes = await saveWorkSituation(deps, workSituationInput);
|
|
758
|
+
if (!workSituationRes.ok) return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
|
|
759
|
+
notifyOk(io, c.professionalSaved);
|
|
760
|
+
|
|
761
|
+
io.section(c.pricingTitle);
|
|
762
|
+
const pricingInput = await io.askPricing(c);
|
|
763
|
+
const priceRes = await savePricing(deps, pricingInput);
|
|
764
|
+
if (!priceRes.ok) return { ok: false, reason: priceRes.reason, step: 'pricing' };
|
|
765
|
+
notifyOk(io, c.pricingSaved);
|
|
766
|
+
|
|
767
|
+
// Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
|
|
768
|
+
io.section(c.availabilityTitle);
|
|
769
|
+
if (await io.confirm(c.availabilityAsk)) {
|
|
770
|
+
const availabilityInput = await io.askAvailability(c);
|
|
771
|
+
const availRes = await saveAvailability(deps, availabilityInput);
|
|
772
|
+
if (availRes.ok) notifyOk(io, c.availabilitySaved); else io.warn(c.availabilityUnavailable);
|
|
773
|
+
if (availabilityInput.telephoneCode || availabilityInput.telephoneNumber) {
|
|
774
|
+
const phoneRes = await savePhone(deps, availabilityInput);
|
|
775
|
+
if (phoneRes.ok) notifyOk(io, c.phoneSaved); else io.warn(c.phoneUnavailable);
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
io.notify(c.availabilitySkipped);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
io.section(c.languagesTitle);
|
|
782
|
+
if (await io.confirm(c.languagesAsk)) {
|
|
783
|
+
const languageRows = await io.askLanguages(c);
|
|
784
|
+
const langRes = await saveLanguages(deps, languageRows);
|
|
785
|
+
if (langRes.ok) notifyOk(io, c.languagesSaved); else io.warn(c.languagesUnavailable);
|
|
786
|
+
} else {
|
|
787
|
+
io.notify(c.languagesSkipped);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const prior = await detectPriorUsage(deps);
|
|
791
|
+
let didUsage = false;
|
|
792
|
+
const offerUsage = !(prior.ok && prior.hasEvidence === true);
|
|
793
|
+
if (offerUsage) {
|
|
794
|
+
io.section(c.usageTitle);
|
|
795
|
+
io.disclaimer(c.usageInfoAccessed, c.usageGoalDuration);
|
|
796
|
+
if (await io.confirm(c.usageAccept)) {
|
|
797
|
+
// A: attribute the footprint to THIS talent, not a stale consent.json.
|
|
798
|
+
reattachConsentIdentity(deps);
|
|
799
|
+
await io.runUsage();
|
|
800
|
+
didUsage = true;
|
|
801
|
+
}
|
|
802
|
+
} else {
|
|
803
|
+
io.notify(c.usageSkippedEvidence);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const finalize = async () => {
|
|
807
|
+
const fin = await completeOnboardingRegistration(deps);
|
|
808
|
+
if (!fin.ok) {
|
|
809
|
+
io.warn(c.finalizeWarn(fin.reason));
|
|
810
|
+
} else if (typeof fin.completedProfilePercentage === 'number') {
|
|
811
|
+
notifyOk(io, c.finalizedAt(fin.completedProfilePercentage));
|
|
812
|
+
} else {
|
|
813
|
+
notifyOk(io, c.finalizedGeneric);
|
|
814
|
+
}
|
|
815
|
+
return fin;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// Every ending points the Talent at their profile, and opens it when a browser is at hand.
|
|
819
|
+
const showProfile = () => {
|
|
820
|
+
const url = profileUrl(deps);
|
|
821
|
+
notifyOk(io, url ? c.profileReadyUrl(url) : c.profileReadyNoUrl);
|
|
822
|
+
if (url && typeof openProfile === 'function') {
|
|
823
|
+
try { openProfile(url); } catch { /* the printed link covers it */ }
|
|
824
|
+
}
|
|
825
|
+
return url;
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
io.section(c.interviewTitle);
|
|
829
|
+
io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
|
|
830
|
+
if (!(await io.confirm(c.interviewAccept))) {
|
|
831
|
+
await runMainRoleFinalStep(io, deps);
|
|
832
|
+
const fin = await finalize();
|
|
833
|
+
const url = showProfile();
|
|
834
|
+
return { ok: true, completed: 'skipped-interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const interview = await conductLivekitInterview(io, deps);
|
|
838
|
+
if (!interview.ok) {
|
|
839
|
+
await runMainRoleFinalStep(io, deps);
|
|
840
|
+
const fin = await finalize();
|
|
841
|
+
const url = showProfile();
|
|
842
|
+
return { ok: true, completed: 'interview-unavailable', reason: interview.reason, didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
|
|
843
|
+
}
|
|
844
|
+
notifyOk(io, c.interviewComplete);
|
|
845
|
+
await runMainRoleFinalStep(io, deps);
|
|
846
|
+
const fin = await finalize();
|
|
847
|
+
const url = showProfile();
|
|
848
|
+
return { ok: true, completed: 'interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Adapts an InterviewClient (LiveKit text-streams) to the { open, turn } shape io.interviewLoop consumes, so a LiveKit run reuses the shared interview UX.
|
|
852
|
+
function livekitTurnBridge(client, transcript) {
|
|
853
|
+
return {
|
|
854
|
+
open: async () => {
|
|
855
|
+
try {
|
|
856
|
+
const t = await client.receiveTurn();
|
|
857
|
+
transcript.recordAgent(t.text);
|
|
858
|
+
return { ok: true, greeting: t.text, ended: t.ended === true };
|
|
859
|
+
} catch (e) {
|
|
860
|
+
return { ok: false, reason: (e && e.kind) || 'no-greeting' };
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
turn: async (message) => {
|
|
864
|
+
transcript.recordUser(message);
|
|
865
|
+
try {
|
|
866
|
+
const t = await client.sendTurn(message);
|
|
867
|
+
transcript.recordAgent(t.text);
|
|
868
|
+
return { ok: true, response: t.text, ended: t.ended === true };
|
|
869
|
+
} catch (e) {
|
|
870
|
+
return { ok: false, reason: (e && e.kind) || 'turn-error' };
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// 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.
|
|
877
|
+
async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false } = {}) {
|
|
878
|
+
const { getCatalog } = require('./i18n');
|
|
879
|
+
const c = getCatalog(io.lang).onboarding;
|
|
880
|
+
const { TranscriptBuffer } = require('./interview-transcript');
|
|
881
|
+
|
|
882
|
+
// A pre-created interviewId (dimension) starts LiveKit directly; onboarding creates first.
|
|
883
|
+
const start = interviewId
|
|
884
|
+
? await startLivekitForInterview(deps, { interviewId, language: language || io.lang })
|
|
885
|
+
: await createAndStartLivekitOnboarding(deps, { language: language || io.lang });
|
|
886
|
+
if (!start.ok) return { ok: false, reason: start.reason, step: start.step || 'interview' };
|
|
887
|
+
|
|
888
|
+
const closing = start.closingMessage;
|
|
889
|
+
const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
|
|
890
|
+
const client = deps.makeInterviewClient({ isClosing });
|
|
891
|
+
const transcript = new TranscriptBuffer();
|
|
892
|
+
|
|
893
|
+
let connectAt = null;
|
|
894
|
+
try {
|
|
895
|
+
io.notify(c.interviewConnecting);
|
|
896
|
+
try {
|
|
897
|
+
await client.connect(start.livekitUrl, start.token);
|
|
898
|
+
connectAt = Date.now();
|
|
899
|
+
} catch (e) {
|
|
900
|
+
const kind = e && e.kind;
|
|
901
|
+
// Non-fatal: the interview is optional and the alta still finalizes → warning.
|
|
902
|
+
io.warn(
|
|
903
|
+
kind === 'livekit-not-installed' ? c.livekitMissing
|
|
904
|
+
: kind === 'livekit-no-text-streams' ? c.livekitOld
|
|
905
|
+
: c.livekitConnectError,
|
|
906
|
+
);
|
|
907
|
+
return { ok: false, reason: kind || 'connect-failed', step: 'interview' };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const loop = await io.interviewLoop({ ...livekitTurnBridge(client, transcript), plain });
|
|
911
|
+
|
|
912
|
+
if (transcript.length > 0) {
|
|
913
|
+
const durationSeconds = connectAt != null ? Math.max(0, Math.floor((Date.now() - connectAt) / 1000)) : 0;
|
|
914
|
+
const saved = await completeLivekitOnboarding(deps, {
|
|
915
|
+
interviewId: start.interviewId,
|
|
916
|
+
durationSeconds,
|
|
917
|
+
transcripts: transcript.toTranscripts(),
|
|
918
|
+
});
|
|
919
|
+
if (!saved.ok) io.warn(c.transcriptWarn(saved.reason));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (loop && loop.ok === false) return { ok: false, reason: loop.reason, step: 'interview' };
|
|
923
|
+
return { ok: true, completed: 'interview', interviewId: start.interviewId };
|
|
924
|
+
} finally {
|
|
925
|
+
try { await client.disconnect(); } catch {}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Runs the final main-role step, degrading softly (never aborts the flow / never throws).
|
|
930
|
+
async function runMainRoleFinalStep(io, deps, session = null) {
|
|
931
|
+
const active = session || (typeof deps.loadAuthSession === 'function' ? deps.loadAuthSession() : null);
|
|
932
|
+
if (typeof deps.runMainRoleStep !== 'function' || !active) return;
|
|
933
|
+
const { getCatalog } = require('./i18n');
|
|
934
|
+
const catalog = getCatalog(io.lang);
|
|
935
|
+
try {
|
|
936
|
+
const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
|
|
937
|
+
await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
|
|
938
|
+
} catch { /* the alta stands even if the role step fails */ }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Overwrite-repeat: PATCH /interviews/onboarding/restart. Per contract the Bearer is
|
|
942
|
+
// the talent hub JWT (same token roles use); falls back to accessToken when equal.
|
|
943
|
+
async function restartOnboarding(deps) {
|
|
944
|
+
const gate = await requireActiveSession(deps);
|
|
945
|
+
if (!gate.ok) return gate;
|
|
946
|
+
const endpoint = deps.getOnboardingRestartEndpoint();
|
|
947
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
948
|
+
const token = gate.session.hubAccessToken || gate.session.accessToken;
|
|
949
|
+
return deps.requestRestartOnboardingInterview({ accessToken: token }, { endpoint });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// `shakers onboarding --repeat`: warn + explicit confirm, then overwrite the prior
|
|
953
|
+
// onboarding interview and run it again (interview only — no main-role step).
|
|
954
|
+
async function runRepeatOnboardingInterview(io, deps = makeDeps(), { confirmed = false } = {}) {
|
|
955
|
+
const { getCatalog } = require('./i18n');
|
|
956
|
+
const c = getCatalog(io.lang).onboarding;
|
|
957
|
+
|
|
958
|
+
const gate = await requireActiveSession(deps);
|
|
959
|
+
if (!gate.ok) return { ok: false, reason: 'no-session' };
|
|
960
|
+
|
|
961
|
+
io.section(c.repeatWarnTitle);
|
|
962
|
+
io.warn(c.repeatWarnBody);
|
|
963
|
+
let ok = confirmed;
|
|
964
|
+
if (!ok && typeof io.select === 'function') {
|
|
965
|
+
const choice = await io.select({
|
|
966
|
+
header: c.repeatConfirmPrompt,
|
|
967
|
+
items: [{ id: 'yes', label: c.repeatConfirmYes }, { id: 'no', label: c.repeatConfirmNo }],
|
|
968
|
+
labelFor: (it) => it.label,
|
|
969
|
+
});
|
|
970
|
+
ok = !!(choice && choice.id === 'yes');
|
|
971
|
+
}
|
|
972
|
+
if (!ok) {
|
|
973
|
+
io.notify(c.repeatAborted);
|
|
974
|
+
return { ok: true, repeated: false };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const reset = await restartOnboarding(deps);
|
|
978
|
+
if (!reset.ok) {
|
|
979
|
+
if (reset.reason === 'onboarding-not-found') io.error(c.repeatNotFound);
|
|
980
|
+
else if (reset.reason === 'http-401' || reset.reason === 'http-403') io.error(c.repeatAuthError);
|
|
981
|
+
else io.error(c.repeatFailed(reset.reason));
|
|
982
|
+
return { ok: false, reason: reset.reason };
|
|
983
|
+
}
|
|
984
|
+
io.notify(c.repeatRestarted);
|
|
985
|
+
|
|
986
|
+
// Standalone repeat runs the interview only — main-role belongs to `register`.
|
|
987
|
+
const interview = await conductLivekitInterview(io, deps);
|
|
988
|
+
if (interview.ok) notifyOk(io, c.interviewComplete);
|
|
989
|
+
return interview.ok ? { ok: true, repeated: true, completed: 'interview' } : { ok: true, repeated: true, completed: 'interview-unavailable', reason: interview.reason };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Standalone `shakers onboarding`: the interview ONLY. The main-role step is part of
|
|
993
|
+
// registration (`runOnboarding`), never of running the interview on its own.
|
|
994
|
+
async function runOnboardingInterview(io, deps = makeDeps()) {
|
|
995
|
+
const { getCatalog } = require('./i18n');
|
|
996
|
+
const c = getCatalog(io.lang).onboarding;
|
|
997
|
+
|
|
998
|
+
const gate = await requireActiveSession(deps);
|
|
999
|
+
if (!gate.ok) return { ok: false, reason: 'no-session' };
|
|
1000
|
+
|
|
1001
|
+
io.section(c.interviewOnlyTitle);
|
|
1002
|
+
io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
|
|
1003
|
+
if (!(await io.confirm(c.interviewAccept))) {
|
|
1004
|
+
const url = profileUrl(deps);
|
|
1005
|
+
notifyOk(io, url ? c.profileReadyUrl(url) : c.profileReadyNoUrl);
|
|
1006
|
+
return { ok: true, completed: 'skipped' };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const interview = await conductLivekitInterview(io, deps);
|
|
1010
|
+
// An already-completed onboarding cannot be re-run without --repeat (certs reuses the
|
|
1011
|
+
// taken interview) — say so plainly instead of surfacing a raw 400.
|
|
1012
|
+
if (!interview.ok && interview.reason === 'interview-already-started') {
|
|
1013
|
+
io.notify(c.interviewAlreadyDone);
|
|
1014
|
+
return { ok: true, completed: 'already-done' };
|
|
1015
|
+
}
|
|
1016
|
+
if (interview.ok) notifyOk(io, c.interviewComplete);
|
|
1017
|
+
return interview;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
module.exports = {
|
|
1021
|
+
FREELANCE_TYPES,
|
|
1022
|
+
CURRENCIES,
|
|
1023
|
+
MONTHLY_HOURS,
|
|
1024
|
+
WORK_SITUATIONS,
|
|
1025
|
+
EMPLOYMENT_PARTICIPATIONS,
|
|
1026
|
+
FREELANCE_OPINIONS,
|
|
1027
|
+
CHANGE_MOTIVATORS,
|
|
1028
|
+
LANGUAGE_LEVELS,
|
|
1029
|
+
LANGUAGE_CODES,
|
|
1030
|
+
normalizeFreelanceType,
|
|
1031
|
+
freelanceIntentRequired,
|
|
1032
|
+
deriveFreelanceTypeFromSituation,
|
|
1033
|
+
normalizeCurrency,
|
|
1034
|
+
normalizeAmount,
|
|
1035
|
+
normalizeMonthlyHours,
|
|
1036
|
+
normalizeCountry,
|
|
1037
|
+
validatePricing,
|
|
1038
|
+
validateAvailability,
|
|
1039
|
+
buildPricingUpsert,
|
|
1040
|
+
isAcceptablePassword,
|
|
1041
|
+
validateRegisterFields,
|
|
1042
|
+
deriveCurrentEmploymentStatus,
|
|
1043
|
+
showsFreelanceOpinion,
|
|
1044
|
+
deriveFreelanceIntent,
|
|
1045
|
+
showsChangeMotivators,
|
|
1046
|
+
validateWorkSituation,
|
|
1047
|
+
validateLanguageRows,
|
|
1048
|
+
validatePhone,
|
|
1049
|
+
makeDeps,
|
|
1050
|
+
signUp,
|
|
1051
|
+
establishSessionFromCredentials,
|
|
1052
|
+
registerWithSocialAccount,
|
|
1053
|
+
startProfileImport,
|
|
1054
|
+
pollProfileImport,
|
|
1055
|
+
reattachConsentIdentity,
|
|
1056
|
+
saveWorkSituation,
|
|
1057
|
+
savePricing,
|
|
1058
|
+
saveAvailability,
|
|
1059
|
+
saveLanguages,
|
|
1060
|
+
savePhone,
|
|
1061
|
+
detectPriorUsage,
|
|
1062
|
+
createOnboardingInterview,
|
|
1063
|
+
startLivekitForInterview,
|
|
1064
|
+
createAndStartLivekitOnboarding,
|
|
1065
|
+
completeLivekitOnboarding,
|
|
1066
|
+
startOnboardingSession,
|
|
1067
|
+
submitOnboardingTurn,
|
|
1068
|
+
completeOnboarding,
|
|
1069
|
+
completeOnboardingRegistration,
|
|
1070
|
+
profileUrl,
|
|
1071
|
+
usageDisclaimers,
|
|
1072
|
+
interviewDisclaimers,
|
|
1073
|
+
conductLivekitInterview,
|
|
1074
|
+
restartOnboarding,
|
|
1075
|
+
runRepeatOnboardingInterview,
|
|
1076
|
+
runOnboarding,
|
|
1077
|
+
runOnboardingInterview,
|
|
1078
|
+
};
|