shakers-cli-beta 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
3
  const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
6
4
  const { WORKS_CODE_TO_REASON } = require('./skills-client');
7
5
  const { toLowerLang, toUpperLang } = require('./lang-codes');
8
- const { expandHome } = require('./env-paths');
6
+ const { readLocalCv } = require('./cv-file');
9
7
 
10
8
  const DEFAULT_TIMEOUT_MS = 20000;
11
9
  const IMPORT_KICKOFF_TIMEOUT_MS = 90000;
@@ -42,46 +40,48 @@ function envelopeData(raw) {
42
40
  return json.data;
43
41
  }
44
42
 
45
- function buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language } = {}) {
43
+ function buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language, userQuery, fillEmptyOnly } = {}) {
46
44
  const sources = { language: toLowerLang(language) };
47
45
  if (typeof linkedinUrl === 'string' && linkedinUrl) sources.linkedinUrl = linkedinUrl;
48
46
  if (typeof cvPath === 'string' && cvPath) sources.cvPath = cvPath;
49
47
  if (typeof githubUrl === 'string' && githubUrl) sources.githubUrl = githubUrl;
50
48
  if (typeof websiteUrl === 'string' && websiteUrl) sources.websiteUrl = websiteUrl;
49
+ if (typeof userQuery === 'string' && userQuery) sources.userQuery = userQuery;
50
+ if (fillEmptyOnly === true) sources.fillEmptyOnly = true;
51
51
  return sources;
52
52
  }
53
53
 
54
- // The CV formats and size hub accepts on import-profile.
55
- const CV_CONTENT_TYPES = {
56
- '.pdf': 'application/pdf',
57
- '.doc': 'application/msword',
58
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
59
- };
60
- const CV_MAX_BYTES = 10 * 1024 * 1024;
54
+ // Hub answers import-profile synchronously with a per-source report; null when the AI omitted it.
55
+ function importReportFrom(data) {
56
+ const report = data && typeof data === 'object' ? data.importReport : null;
57
+ if (!report || !Array.isArray(report.sources)) return null;
58
+ return {
59
+ outcome: typeof report.outcome === 'string' ? report.outcome : null,
60
+ sources: report.sources
61
+ .filter((src) => src && typeof src.source === 'string')
62
+ .map((src) => ({
63
+ source: src.source,
64
+ status: typeof src.status === 'string' ? src.status : null,
65
+ ...(typeof src.code === 'string' ? { code: src.code } : {}),
66
+ ...(typeof src.retryable === 'boolean' ? { retryable: src.retryable } : {}),
67
+ })),
68
+ };
69
+ }
61
70
 
71
+ // The CV formats hub accepts on import-profile.
62
72
  function readCvUpload(input) {
63
- const cvPath = expandHome(input);
64
- const contentType = CV_CONTENT_TYPES[path.extname(cvPath).toLowerCase()];
65
- if (!contentType) return { ok: false, reason: 'cv-bad-format' };
66
- let data;
67
- try {
68
- data = fs.readFileSync(cvPath);
69
- } catch {
70
- return { ok: false, reason: 'cv-not-found' };
71
- }
72
- if (data.length > CV_MAX_BYTES) return { ok: false, reason: 'cv-too-large' };
73
- return { ok: true, file: { filename: path.basename(cvPath), contentType, data } };
73
+ return readLocalCv(input, { extensions: ['.pdf', '.doc', '.docx'], badFormat: 'cv-bad-format' });
74
74
  }
75
75
 
76
76
  async function requestImportProfile(
77
- { linkedinUrl, cvPath, githubUrl, websiteUrl, language, accessToken, hubAccessToken } = {},
77
+ { linkedinUrl, cvPath, githubUrl, websiteUrl, language, userQuery, fillEmptyOnly, accessToken, hubAccessToken } = {},
78
78
  { endpoint, timeoutMs = IMPORT_KICKOFF_TIMEOUT_MS } = {},
79
79
  ) {
80
80
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
81
- if (!linkedinUrl) return { ok: false, reason: 'no-linkedin' };
81
+ if (!linkedinUrl && !cvPath && !githubUrl && !websiteUrl) return { ok: false, reason: 'no-source' };
82
82
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
83
83
 
84
- const body = buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language });
84
+ const body = buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language, userQuery, fillEmptyOnly });
85
85
  let res;
86
86
  try {
87
87
  if (body.cvPath) {
@@ -90,7 +90,8 @@ async function requestImportProfile(
90
90
  const cv = readCvUpload(body.cvPath);
91
91
  if (!cv.ok) return cv;
92
92
  const { cvPath: _localPath, ...fields } = body;
93
- res = await postMultipartWithTimeout(endpoint, { ...fields, cv: cv.file }, timeoutMs, 'POST', hubHeaders({ hubAccessToken }));
93
+ const form = { ...fields, fillEmptyOnly: fields.fillEmptyOnly ? 'true' : undefined };
94
+ res = await postMultipartWithTimeout(endpoint, { ...form, cv: cv.file }, timeoutMs, 'POST', hubHeaders({ hubAccessToken }));
94
95
  } else {
95
96
  res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, hubHeaders({ hubAccessToken }));
96
97
  }
@@ -101,31 +102,7 @@ async function requestImportProfile(
101
102
 
102
103
  const data = envelopeData(res.raw);
103
104
  if (data === undefined) return { ok: false, reason: 'bad-response' };
104
- const jobId = data && typeof data === 'object' && (data.jobId || data.importId || data.id) || null;
105
- const state = data && typeof data === 'object' && typeof data.state === 'string' ? data.state : 'running';
106
- return { ok: true, jobId, state };
107
- }
108
-
109
- async function requestImportStatus(
110
- { jobId, accessToken, hubAccessToken } = {},
111
- { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
112
- ) {
113
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
114
- if (!jobId) return { ok: false, reason: 'no-job' };
115
-
116
- const url = `${endpoint.replace(/\/+$/, '')}/${encodeURIComponent(jobId)}`;
117
- let res;
118
- try {
119
- res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, hubHeaders({ hubAccessToken }));
120
- } catch (e) {
121
- return { ok: false, reason: (e && e.kind) || 'network-error' };
122
- }
123
- if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
124
-
125
- const data = envelopeData(res.raw);
126
- if (data === undefined || !data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
127
- const state = typeof data.state === 'string' ? data.state : 'running';
128
- return { ok: true, state };
105
+ return { ok: true, report: importReportFrom(data) };
129
106
  }
130
107
 
131
108
  async function requestSetProfessionalDetails(
@@ -380,7 +357,6 @@ async function requestCompleteTextSession(
380
357
 
381
358
  module.exports = {
382
359
  requestImportProfile,
383
- requestImportStatus,
384
360
  requestSetProfessionalDetails,
385
361
  requestSetPricingRate,
386
362
  requestSetAvailability,
@@ -241,120 +241,6 @@ function validateAvailability(input = {}) {
241
241
  return { ok: true, availability: body };
242
242
  }
243
243
 
244
- // Fields the talent must ALWAYS confirm explicitly — never inferred/invented by
245
- // the model (a wrong price or motivation is worse than a gap).
246
- const MUST_CONFIRM_FIELDS = ['pricing', 'workSituation.changeMotivators'];
247
-
248
- // PURE preview of a (possibly partial) register payload: what WOULD be written,
249
- // what is still missing, and what is invalid — reusing the same validators the
250
- // write path uses. Touches no network and needs no session, so the model can show
251
- // the talent a review before the single `register` write.
252
- function buildRegisterPreview(args = {}) {
253
- const wouldWrite = [];
254
- const missingFields = [];
255
- const warnings = [];
256
- const addMissing = (field, required) => missingFields.push({ field, required });
257
-
258
- // --- account (always written at registration; password never in the payload) ---
259
- const account = {
260
- name: args.name,
261
- lastName: args.lastName,
262
- email: args.email,
263
- freelanceType: args.freelanceType,
264
- };
265
- if (!isNonEmptyString(account.name)) addMissing('name', true);
266
- if (!isNonEmptyString(account.lastName)) addMissing('lastName', true);
267
- if (!isNonEmptyString(account.email)) addMissing('email', true);
268
- const ftype = normalizeFreelanceType(account.freelanceType);
269
- if (!ftype) {
270
- if (isNonEmptyString(account.freelanceType)) warnings.push({ field: 'freelanceType', reason: 'bad-freelance-type' });
271
- else addMissing('freelanceType', true);
272
- }
273
- wouldWrite.push({
274
- section: 'account',
275
- endpoint: 'hub: auth/sign-up/email + works/auth/complete-registration',
276
- data: {
277
- name: isNonEmptyString(account.name) ? account.name : null,
278
- lastName: isNonEmptyString(account.lastName) ? account.lastName : null,
279
- email: isNonEmptyString(account.email) ? account.email : null,
280
- freelanceType: ftype || null,
281
- preferredLanguage: isNonEmptyString(args.preferredLanguage) ? args.preferredLanguage : null,
282
- newsletterConsent: args.newsletterConsent === true,
283
- },
284
- note: 'password is never part of this payload — the talent types it in the local sign-up window',
285
- });
286
-
287
- // --- profile import (linkedinUrl is the required source) ---
288
- const profile = args.profile && typeof args.profile === 'object' ? args.profile : {};
289
- if (!isNonEmptyString(profile.linkedinUrl)) addMissing('profile.linkedinUrl', true);
290
- else {
291
- const pdata = { linkedinUrl: profile.linkedinUrl };
292
- for (const k of ['githubUrl', 'websiteUrl', 'cvPath']) if (isNonEmptyString(profile[k])) pdata[k] = profile[k];
293
- wouldWrite.push({ section: 'profile', endpoint: 'hub: works/me/import-profile', data: pdata });
294
- }
295
-
296
- // --- work situation (situation required) ---
297
- const ws = args.workSituation && typeof args.workSituation === 'object' ? args.workSituation : {};
298
- if (!isNonEmptyString(ws.situation)) addMissing('workSituation.situation', true);
299
- else {
300
- const vws = validateWorkSituation(ws);
301
- if (!vws.ok) warnings.push({ field: 'workSituation', reason: vws.reason });
302
- else wouldWrite.push({ section: 'workSituation', endpoint: 'hub: works/me/professional-details', data: vws.professionalDetails });
303
- }
304
-
305
- // --- pricing (at least one variant; ALWAYS confirm) ---
306
- const pricing = args.pricing && typeof args.pricing === 'object' ? args.pricing : {};
307
- const pricingEmpty = pricing.fullTimeSelected !== true && pricing.partTimeSelected !== true
308
- && pricing.fullTimeAmount == null && pricing.partTimeAmount == null;
309
- if (pricingEmpty) addMissing('pricing', true);
310
- else {
311
- const vp = validatePricing(pricing);
312
- if (!vp.ok) warnings.push({ field: 'pricing', reason: vp.reason });
313
- else wouldWrite.push({ section: 'pricing', endpoint: 'hub: works/talents/me/work-details/pricing-rate', data: vp.pricing });
314
- }
315
-
316
- // --- availability (optional; suggest the sub-fields the talent did not give) ---
317
- if (args.availability && typeof args.availability === 'object') {
318
- const va = validateAvailability(args.availability);
319
- if (!va.ok) warnings.push({ field: 'availability', reason: va.reason });
320
- else {
321
- wouldWrite.push({ section: 'availability', endpoint: 'hub: works/talents/me/work-details/availability', data: va.availability });
322
- for (const k of ['available', 'monthlyHours', 'workModes', 'country', 'timezone']) {
323
- if (!(k in va.availability)) addMissing(`availability.${k}`, false);
324
- }
325
- }
326
- } else {
327
- addMissing('availability', false);
328
- }
329
-
330
- // --- languages (optional; ids resolve at write-time, so the preview shows codes) ---
331
- if (Array.isArray(args.languages) && args.languages.length > 0) {
332
- const vl = validateLanguageRows(args.languages);
333
- if (!vl.ok) warnings.push({ field: 'languages', reason: vl.reason });
334
- else wouldWrite.push({ section: 'languages', endpoint: 'hub: works/talents/me/work-details/languages', data: vl.rows, note: 'language codes resolve to ids at write time' });
335
- } else {
336
- addMissing('languages', false);
337
- }
338
-
339
- // --- phone (optional) ---
340
- if (args.phone && typeof args.phone === 'object') {
341
- const vph = validatePhone(args.phone);
342
- if (!vph.ok) warnings.push({ field: 'phone', reason: vph.reason });
343
- else wouldWrite.push({ section: 'phone', endpoint: 'hub: works/me', data: { telephoneCode: vph.telephoneCode, telephoneNumber: vph.telephoneNumber } });
344
- } else {
345
- addMissing('phone', false);
346
- }
347
-
348
- return {
349
- ok: true,
350
- provenance: 'self-declared',
351
- wouldWrite,
352
- missingFields,
353
- warnings,
354
- mustConfirm: MUST_CONFIRM_FIELDS,
355
- };
356
- }
357
-
358
244
  function makeDeps(overrides = {}) {
359
245
  const config = require('./config');
360
246
  const client = require('./onboarding-client');
@@ -392,7 +278,6 @@ function makeDeps(overrides = {}) {
392
278
  getTalentProfileUrl: config.getTalentProfileUrl,
393
279
  getUsageDiscoveredInventoryEndpoint: config.getUsageDiscoveredInventoryEndpoint,
394
280
  requestImportProfile: client.requestImportProfile,
395
- requestImportStatus: client.requestImportStatus,
396
281
  requestSetProfessionalDetails: client.requestSetProfessionalDetails,
397
282
  requestSetPricingRate: client.requestSetPricingRate,
398
283
  requestSetAvailability: client.requestSetAvailability,
@@ -434,19 +319,6 @@ function isAcceptablePassword(password) {
434
319
  return typeof password === 'string' && password.length >= 8;
435
320
  }
436
321
 
437
- function isNonEmptyString(value) {
438
- return typeof value === 'string' && value.trim().length > 0;
439
- }
440
-
441
- function validateRegisterFields(fields = {}) {
442
- if (!isNonEmptyString(fields.name)) return { ok: false, reason: 'missing-name' };
443
- if (!isNonEmptyString(fields.lastName)) return { ok: false, reason: 'missing-last-name' };
444
- if (!isNonEmptyString(fields.email)) return { ok: false, reason: 'missing-email' };
445
- const type = normalizeFreelanceType(fields.freelanceType);
446
- if (!type) return { ok: false, reason: 'bad-freelance-type' };
447
- return { ok: true };
448
- }
449
-
450
322
  async function signUp(deps, fields = {}) {
451
323
  // The CLI no longer asks freelanceType at signup → transient default (corrected later from the work situation via professional-details).
452
324
  if (!isAcceptablePassword(fields.password)) return { ok: false, reason: 'weak-password' };
@@ -465,6 +337,7 @@ async function signUp(deps, fields = {}) {
465
337
  newsletterConsent: fields.newsletterConsent === true,
466
338
  freelanceType: normalizeFreelanceType(fields.freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
467
339
  freelanceIntent: fields.freelanceIntent,
340
+ claimCode: fields.claimCode || null,
468
341
  },
469
342
  { signUpEndpoint, completeRegistrationEndpoint },
470
343
  );
@@ -548,17 +421,6 @@ async function startProfileImport(deps, sources = {}) {
548
421
  );
549
422
  }
550
423
 
551
- async function pollProfileImport(deps, { jobId } = {}) {
552
- const gate = await requireActiveSession(deps);
553
- if (!gate.ok) return gate;
554
- const endpoint = deps.getImportProfileEndpoint();
555
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
556
- return deps.requestImportStatus(
557
- { jobId, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
558
- { endpoint },
559
- );
560
- }
561
-
562
424
  // A: anchor the ingest identity to THIS talent's session email so a stale
563
425
  // consent.json from a prior alta doesn't misattribute the footprint. Never throws.
564
426
  function reattachConsentIdentity(deps) {
@@ -798,14 +660,12 @@ function profileUrl(deps) {
798
660
  }
799
661
 
800
662
  function usageDisclaimers(lang) {
801
- const { getCatalog } = require('./i18n');
802
- const c = getCatalog(lang).onboarding;
663
+ const c = require('./signup-copy').legalCopy(lang);
803
664
  return { infoAccessed: c.usageInfoAccessed, goalDuration: c.usageGoalDuration };
804
665
  }
805
666
 
806
667
  function interviewDisclaimers(lang) {
807
- const { getCatalog } = require('./i18n');
808
- const c = getCatalog(lang).onboarding;
668
+ const c = require('./signup-copy').legalCopy(lang);
809
669
  return { infoAccessed: c.interviewInfoAccessed, goalDuration: c.interviewGoalDuration };
810
670
  }
811
671
 
@@ -1165,9 +1025,6 @@ module.exports = {
1165
1025
  validateAvailability,
1166
1026
  buildPricingUpsert,
1167
1027
  isAcceptablePassword,
1168
- validateRegisterFields,
1169
- buildRegisterPreview,
1170
- MUST_CONFIRM_FIELDS,
1171
1028
  deriveCurrentEmploymentStatus,
1172
1029
  showsFreelanceOpinion,
1173
1030
  deriveFreelanceIntent,
@@ -1180,7 +1037,6 @@ module.exports = {
1180
1037
  establishSessionFromCredentials,
1181
1038
  registerWithSocialAccount,
1182
1039
  startProfileImport,
1183
- pollProfileImport,
1184
1040
  reattachConsentIdentity,
1185
1041
  saveWorkSituation,
1186
1042
  savePricing,
@@ -39,4 +39,19 @@ async function requestOnboardingStatus({ accessToken } = {}, { endpoint, timeout
39
39
  return { ok: true, completed: data.completed === true };
40
40
  }
41
41
 
42
- module.exports = { requestOnboardingStatus };
42
+ // GET <certs>/interviews/:id/finalization -> COLLECTING | PROCESSING | READY | FAILED for a completed interview's evaluation.
43
+ async function requestInterviewFinalization({ interviewId, accessToken } = {}, { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
44
+ if (!base || !interviewId) return { ok: false, reason: 'no-endpoint' };
45
+ let res;
46
+ try {
47
+ res = await postJsonWithTimeout(`${base}/${encodeURIComponent(interviewId)}/finalization`, null, timeoutMs, 'GET', null, authHeaders({ accessToken }));
48
+ } catch (e) {
49
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
50
+ }
51
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: `http-${res.status}` };
52
+ const json = parseJson(res.raw);
53
+ const status = json && json.data && typeof json.data.status === 'string' ? json.data.status : null;
54
+ return status ? { ok: true, status } : { ok: false, reason: 'bad-response' };
55
+ }
56
+
57
+ module.exports = { requestOnboardingStatus, requestInterviewFinalization };
@@ -123,21 +123,6 @@ function findCvCandidates(home, personName) {
123
123
  .map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
124
124
  }
125
125
 
126
- // Which backend this install talks to, so the AI never calls a test sign-up
127
- // "production".
128
- function detectEnvironment(env) {
129
- let base = '';
130
- try {
131
- base = String(require('./config').getHubBase(env) || '');
132
- } catch {
133
- return 'unknown';
134
- }
135
- if (/localhost|127\.0\.0\.1/.test(base)) return 'local';
136
- if (/staging/.test(base)) return 'staging';
137
- if (/\.dev\.|new-works/.test(base)) return 'development';
138
- return base ? 'production' : 'unknown';
139
- }
140
-
141
126
  // Returns the signals found, each clearly a SUGGESTION to confirm. `talentName`
142
127
  // (what the AI already knows) sharpens the CV match beyond the git name.
143
128
  function readRegisterContextSuggestions({ cwd, env = process.env, talentName } = {}) {
@@ -146,7 +131,6 @@ function readRegisterContextSuggestions({ cwd, env = process.env, talentName } =
146
131
  const gitName = gitConfig('user.name', cwd);
147
132
  const personName = [talentName, gitName].filter((n) => typeof n === 'string' && n.trim()).join(' ');
148
133
  return {
149
- environment: detectEnvironment(env),
150
134
  timezone: detectTimezone(),
151
135
  gitName,
152
136
  gitEmail: gitConfig('user.email', cwd),
@@ -156,4 +140,4 @@ function readRegisterContextSuggestions({ cwd, env = process.env, talentName } =
156
140
  };
157
141
  }
158
142
 
159
- module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, detectEnvironment, nameTokens, fold };
143
+ module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, nameTokens, fold };
@@ -34,10 +34,12 @@ function normalizeRole(r) {
34
34
  if (!clusterId) return null;
35
35
  return {
36
36
  clusterId,
37
- name: typeof r.name === 'string' ? r.name : null,
37
+ // Hub's assigned-clusters rows name the role in clusterName.
38
+ name: typeof r.name === 'string' ? r.name : (typeof r.clusterName === 'string' ? r.clusterName : null),
38
39
  category: typeof r.category === 'string' ? r.category : null,
39
40
  proposalKind: typeof r.proposalKind === 'string' ? r.proposalKind : null,
40
41
  type: typeof r.type === 'string' ? r.type : null,
42
+ source: typeof r.source === 'string' ? r.source : null,
41
43
  };
42
44
  }
43
45
 
@@ -29,36 +29,48 @@ function buildRegistrationContext({ preferredLanguage, newsletterConsent, freela
29
29
  return context;
30
30
  }
31
31
 
32
- // Shared complete-registration call: authenticated by the email `cookie` or the device flow `bearerToken`.
33
- async function requestCompleteRegistration(
34
- context = {},
35
- { endpoint, cookie = null, bearerToken = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
36
- ) {
37
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
38
- const headers = {};
39
- if (cookie) headers.Cookie = cookie;
40
- if (bearerToken) headers.Authorization = `Bearer ${bearerToken}`;
32
+ // Hub refuses these claims with a coded error and creates nothing, so registering without the code is safe.
33
+ const UNCLAIMABLE_CODES = new Set(['auth-engine.claim_code_invalid', 'users.unregistered_talent_not_claimable']);
34
+
35
+ function codeFrom(raw) {
36
+ const body = parseJson(raw);
37
+ return body && typeof body.code === 'string' ? body.code : null;
38
+ }
39
+
40
+ async function postCompleteRegistration(body, { endpoint, headers, timeoutMs }) {
41
41
  let complete;
42
42
  try {
43
- complete = await postJsonWithTimeout(
44
- endpoint,
45
- { userType: 'WORKS_TALENT', context },
46
- timeoutMs,
47
- 'POST',
48
- null,
49
- Object.keys(headers).length ? headers : null,
50
- );
43
+ complete = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, Object.keys(headers).length ? headers : null);
51
44
  } catch (e) {
52
45
  return { ok: false, reason: (e && e.kind) || 'network-error' };
53
46
  }
54
47
  if (complete.status < 200 || complete.status >= 300) {
55
- return { ok: false, reason: `complete-registration-http-${complete.status}`, status: complete.status, message: messageFrom(complete.raw) };
48
+ return { ok: false, reason: `complete-registration-http-${complete.status}`, status: complete.status, code: codeFrom(complete.raw), message: messageFrom(complete.raw) };
56
49
  }
57
50
  return { ok: true };
58
51
  }
59
52
 
53
+ // Shared complete-registration call. With a claimCode it claims the MCP sign-up's UNREGISTERED profile; an unclaimable code still registers the account, with claimed:false.
54
+ async function requestCompleteRegistration(
55
+ context = {},
56
+ { endpoint, cookie = null, bearerToken = null, claimCode = null, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
57
+ ) {
58
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
59
+ const headers = {};
60
+ if (cookie) headers.Cookie = cookie;
61
+ if (bearerToken) headers.Authorization = `Bearer ${bearerToken}`;
62
+ const base = { userType: 'WORKS_TALENT', context };
63
+ if (typeof claimCode !== 'string' || !claimCode) return postCompleteRegistration(base, { endpoint, headers, timeoutMs });
64
+
65
+ const claimed = await postCompleteRegistration({ ...base, claimCode }, { endpoint, headers, timeoutMs });
66
+ if (claimed.ok) return { ok: true, claimed: true };
67
+ if (!UNCLAIMABLE_CODES.has(claimed.code)) return claimed;
68
+ const plain = await postCompleteRegistration(base, { endpoint, headers, timeoutMs });
69
+ return plain.ok ? { ok: true, claimed: false, claimError: claimed.code } : plain;
70
+ }
71
+
60
72
  async function requestSignUp(
61
- { name, lastName, email, password, preferredLanguage, newsletterConsent, freelanceType, freelanceIntent } = {},
73
+ { name, lastName, email, password, preferredLanguage, newsletterConsent, freelanceType, freelanceIntent, claimCode = null } = {},
62
74
  { signUpEndpoint, completeRegistrationEndpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
63
75
  ) {
64
76
  if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
@@ -83,9 +95,9 @@ async function requestSignUp(
83
95
  if (!completeRegistrationEndpoint) return { ok: false, reason: 'no-endpoint' };
84
96
 
85
97
  const context = buildRegistrationContext({ preferredLanguage, newsletterConsent, freelanceType, freelanceIntent });
86
- const complete = await requestCompleteRegistration(context, { endpoint: completeRegistrationEndpoint, cookie, timeoutMs });
98
+ const complete = await requestCompleteRegistration(context, { endpoint: completeRegistrationEndpoint, cookie, claimCode, timeoutMs });
87
99
  if (!complete.ok) return complete;
88
- return { ok: true, accountExists: false };
100
+ return claimCode ? { ok: true, accountExists: false, claimed: complete.claimed === true } : { ok: true, accountExists: false };
89
101
  }
90
102
 
91
103
  module.exports = { requestSignUp, requestCompleteRegistration, buildRegistrationContext };