shakers-cli-beta 0.11.0 → 0.13.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/bin/certify.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // `certify` — dimension certification interview over LiveKit (src/certify-dimension-flow.js).
4
+ // `certify` — discover + pick a dimension, then hand over the web link (src/certify-dimension-flow.js).
5
5
 
6
6
  const { detectReportLang, getCatalog } = require('../src/i18n');
7
7
  const { loadAuthSession, sessionStatus } = require('../src/auth-session-store');
package/bin/login.js CHANGED
@@ -4,8 +4,7 @@
4
4
  // `login` / `logout` — a registered Talent identifies themselves to the CLI (talents-ai-score, issue 122 / ADR-044).
5
5
 
6
6
  const { detectReportLang, getCatalog } = require('../src/i18n');
7
- const { getLoginEndpoint, getAuthTokenEndpoint, getDeviceAuthorizeEndpoint, getDeviceTokenEndpoint, getCompleteRegistrationEndpoint } = require('../src/config');
8
- const { requestLogin } = require('../src/auth-client');
7
+ const { getAuthTokenEndpoint, getDeviceAuthorizeEndpoint, getDeviceTokenEndpoint, getCompleteRegistrationEndpoint } = require('../src/config');
9
8
  const { runDeviceLogin, appendProviderParam } = require('../src/device-login');
10
9
  const { buildRegistrationContext } = require('../src/signup-client');
11
10
  const { openPath } = require('../src/open-file');
@@ -15,7 +14,7 @@ const {
15
14
  loadAuthSession,
16
15
  sessionStatus,
17
16
  } = require('../src/auth-session-store');
18
- const { isValidEmail, normalizeEmail, loadConsentState, clearConsentState } = require('../src/share');
17
+ const { normalizeEmail, loadConsentState, clearConsentState } = require('../src/share');
19
18
  const { createStdinAsk } = require('../src/stdin-ask');
20
19
  const { chooseAuthMethod } = require('../src/auth-method-picker');
21
20
 
@@ -50,23 +49,6 @@ async function chooseLoginMethod({ ask, stdinIsTTY, c, input, output }) {
50
49
  return chooseAuthMethod({ flow: 'login', ask, stdinIsTTY, catalog: c, input, output });
51
50
  }
52
51
 
53
- // The SECRET prompt (issue 102) — falls back to the ordinary line read on a pipe, which `ask()` still services.
54
- async function promptSecret(ask, label) {
55
- const read = typeof ask.secret === 'function' ? ask.secret.bind(ask) : ask;
56
- return String(await read(label)).trim();
57
- }
58
-
59
- async function resolveEmail(opts, c, ask) {
60
- if (opts.email) return String(opts.email).trim();
61
- for (let attempt = 0; attempt < 5; attempt++) {
62
- const raw = String(await ask(c.emailPrompt)).trim();
63
- if (raw === '') return null; // EOF / empty line / cancel
64
- if (isValidEmail(raw)) return raw;
65
- process.stdout.write(` ${c.emailInvalid}\n`);
66
- }
67
- return null;
68
- }
69
-
70
52
  async function run(
71
53
  argv = process.argv.slice(2),
72
54
  { ask: injectedAsk = null, openBrowser = undefined, stdinIsTTY: ttyOverride = undefined, input = undefined, output = undefined, sleep = undefined } = {},
@@ -75,75 +57,42 @@ async function run(
75
57
  const lang = opts.lang || detectReportLang();
76
58
  const c = getCatalog(lang).login;
77
59
 
78
- if (opts.google) return runSocial('google', c, { openBrowser, sleep, lang });
79
- if (opts.linkedin) return runSocial('linkedin', c, { openBrowser, sleep, lang });
60
+ // Every method signs in through the real web device flow (RFC 8628): the
61
+ // Talent reuses an existing web session or signs in on the `/cli-login` page,
62
+ // so no password is ever read in the terminal. Email now travels the same
63
+ // path Google/LinkedIn already use; only the `provider` query differs.
64
+ let method = null;
65
+ if (opts.google) method = 'google';
66
+ else if (opts.linkedin) method = 'linkedin';
67
+ else if (opts.provider === 'email' || !!opts.email) method = 'email';
80
68
 
81
- // No method flag: offer the picker (ADR-041/046 — Email, Google and LinkedIn; LinkedIn pending Hub OAuth provisioning).
82
- if (!hasMethodFlag(opts)) {
69
+ if (!method) {
83
70
  const stdinIsTTY = ttyOverride !== undefined ? ttyOverride : !!process.stdin.isTTY;
71
+ // ADR-044: an ACTIVE session is not silently overwritten, and the picker never shows.
72
+ if (sessionStatus(loadAuthSession()) === 'active') {
73
+ (output || process.stdout).write(`\n ${c.alreadyLoggedIn}\n\n`);
74
+ return;
75
+ }
84
76
  if (stdinIsTTY) {
85
- if (sessionStatus(loadAuthSession()) === 'active') {
86
- process.stdout.write(`\n ${c.alreadyLoggedIn}\n\n`);
87
- return;
88
- }
77
+ // ADR-041/046 — the picker offers Email, Google and LinkedIn.
89
78
  const pickerAsk = injectedAsk || createStdinAsk();
90
- let method;
91
79
  try {
92
80
  method = await chooseLoginMethod({ ask: pickerAsk, stdinIsTTY, c, input, output });
93
81
  } finally {
94
82
  if (!injectedAsk) pickerAsk.close();
95
83
  }
96
84
  if (method === null) return; // esc/ctrl-c: cancelled, nothing sent
97
- if (method === 'google' || method === 'linkedin') return runSocial(method, c, { openBrowser, sleep, lang });
98
- // method === 'email' falls through to the unchanged flow below.
85
+ } else {
86
+ method = 'email'; // non-interactive default (matches the picker's own non-TTY default)
99
87
  }
100
88
  }
101
89
 
102
- const endpoint = getLoginEndpoint();
103
- if (!endpoint) {
104
- process.stderr.write(`\n ${c.errorNoEndpoint}\n\n`);
105
- process.exitCode = 1;
106
- return;
107
- }
108
-
109
- // ADR-044: an ACTIVE session is not silently overwritten — the Talent is told, and pointed at `logout` to switch accounts.
110
- const status = sessionStatus(loadAuthSession());
111
- if (status === 'active') {
112
- process.stdout.write(`\n ${c.alreadyLoggedIn}\n\n`);
113
- return;
114
- }
115
- process.stdout.write(`\n ${status === 'expired' ? c.sessionExpiredIntro : c.intro}\n`);
116
-
117
- const ask = injectedAsk || createStdinAsk();
118
- try {
119
- const email = await resolveEmail(opts, c, ask);
120
- if (!email || !isValidEmail(email)) {
121
- process.stderr.write(`\n ${c.needInput}\n\n`);
122
- process.exitCode = 1;
123
- return;
124
- }
125
- const password = await promptSecret(ask, c.passwordPrompt);
126
- if (!password) {
127
- process.stderr.write(`\n ${c.needInput}\n\n`);
128
- process.exitCode = 1;
129
- return;
130
- }
131
-
132
- const normalizedEmail = normalizeEmail(email);
133
- // better-auth email login: sign-in captures the session cookie, then a hub
134
- // JWT is minted from it (auth-client.js). `endpoint` is the sign-in URL.
135
- const result = await requestLogin(
136
- { email: normalizedEmail, password },
137
- { signInEndpoint: endpoint, tokenEndpoint: getAuthTokenEndpoint() },
138
- );
139
- handleResult(result, c, normalizedEmail);
140
- } finally {
141
- if (!injectedAsk) ask.close();
142
- }
90
+ return runDeviceFlow(method, c, { openBrowser, sleep, lang, output });
143
91
  }
144
92
 
145
93
  // Shared success path: persist the session (atomic 0600 store) and report.
146
- function persistAndReport(result, c, email = null) {
94
+ // `output` lets a caller (and tests) redirect the human-facing lines; errors stay on stderr.
95
+ function persistAndReport(result, c, email = null, output = process.stdout) {
147
96
  if (email) {
148
97
  const prior = loadConsentState();
149
98
  if (prior && prior.email && normalizeEmail(prior.email) !== normalizeEmail(email)) {
@@ -167,37 +116,16 @@ function persistAndReport(result, c, email = null) {
167
116
  process.exitCode = 1;
168
117
  return false;
169
118
  }
170
- process.stdout.write(`\n ${c.success}\n`);
171
- if (result.expiresAt) process.stdout.write(` ${c.expiresAt(result.expiresAt)}\n`);
172
- process.stdout.write('\n');
119
+ output.write(`\n ${c.success}\n`);
120
+ if (result.expiresAt) output.write(` ${c.expiresAt(result.expiresAt)}\n`);
121
+ output.write('\n');
173
122
  return true;
174
123
  }
175
124
 
176
- // `typedEmail` is what the CALLER typed/passed as `--email` for THIS attempt.
177
- function handleResult(result, c, typedEmail = null) {
178
- if (result.ok) return void persistAndReport(result, c, result.email || typedEmail);
179
-
180
- // One message per contract reason (ADR-044) — each names the cause (issue 109).
181
- const messageFor = {
182
- 'invalid-credentials': c.errorInvalidCredentials,
183
- 'no-email-password': c.errorNoEmailPassword,
184
- upstream: c.errorUpstream,
185
- // Creds were good but the hub JWT could not be minted from the cookie.
186
- 'token-fetch-failed': c.errorUpstream,
187
- timeout: c.errorUnreachable,
188
- 'network-error': c.errorUnreachable,
189
- 'invalid-url': c.errorConfig,
190
- 'no-endpoint': c.errorNoEndpoint,
191
- 'bad-response': c.errorGeneric,
192
- 'http-error': c.errorGeneric,
193
- 'local-error': c.errorGeneric,
194
- };
195
- process.stderr.write(`\n ${messageFor[result.reason] || c.errorGeneric}\n\n`);
196
- process.exitCode = 1;
197
- }
198
-
199
- // `login --google` / `--linkedin` — one device flow (RFC 8628); only the `provider` query on the URL differs.
200
- async function runSocial(provider, c, { openBrowser = openPath, sleep = undefined, lang = 'en' } = {}) {
125
+ // `login` for one method — one device flow (RFC 8628); only the `provider` query
126
+ // on the `/cli-login` URL differs (google | linkedin | email). Email reuses an
127
+ // existing web session or signs in on the page, with no password in the terminal.
128
+ async function runDeviceFlow(provider, c, { openBrowser = openPath, sleep = undefined, lang = 'en', output = process.stdout } = {}) {
201
129
  const authorizeEndpoint = getDeviceAuthorizeEndpoint();
202
130
  const tokenEndpoint = getDeviceTokenEndpoint();
203
131
  const authTokenEndpoint = getAuthTokenEndpoint();
@@ -206,18 +134,21 @@ async function runSocial(provider, c, { openBrowser = openPath, sleep = undefine
206
134
  process.exitCode = 1;
207
135
  return;
208
136
  }
209
- // A new social account also needs complete-registration before the JWT (freelanceType is a transient default).
137
+ // A new account also needs complete-registration before the JWT (freelanceType is a transient default).
210
138
  const completeRegistrationEndpoint = getCompleteRegistrationEndpoint();
211
139
  const registrationContext = buildRegistrationContext({ preferredLanguage: lang, newsletterConsent: false, freelanceType: 'POTENTIAL_FREELANCE' });
212
- const providerName = provider === 'linkedin' ? c.methodLinkedin : c.methodGoogle;
140
+ const providerName = provider === 'linkedin' ? c.methodLinkedin : provider === 'email' ? c.methodEmail : c.methodGoogle;
213
141
 
214
- // Same as email: an ACTIVE session is not silently overwritten.
142
+ // An ACTIVE session is not silently overwritten.
215
143
  const status = sessionStatus(loadAuthSession());
216
144
  if (status === 'active') {
217
- process.stdout.write(`\n ${c.alreadyLoggedIn}\n\n`);
145
+ output.write(`\n ${c.alreadyLoggedIn}\n\n`);
218
146
  return;
219
147
  }
220
- process.stdout.write(`\n ${status === 'expired' ? c.sessionExpiredIntro : c.deviceIntro(providerName)}\n`);
148
+ const intro = status === 'expired'
149
+ ? c.sessionExpiredIntro
150
+ : (provider === 'email' ? c.emailDeviceIntro : c.deviceIntro(providerName));
151
+ output.write(`\n ${intro}\n`);
221
152
 
222
153
  const result = await runDeviceLogin(
223
154
  { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext },
@@ -225,21 +156,23 @@ async function runSocial(provider, c, { openBrowser = openPath, sleep = undefine
225
156
  ...(sleep ? { sleep } : {}),
226
157
  onPrompt: ({ verificationUriComplete, userCode }) => {
227
158
  const url = appendProviderParam(verificationUriComplete, provider);
228
- process.stdout.write(`\n ${c.deviceVisit}\n ${url}\n\n ${c.deviceCodeLabel(userCode)}\n\n ${c.deviceWaiting}\n`);
159
+ output.write(`\n ${c.deviceVisit}\n ${url}\n\n ${c.deviceCodeLabel(userCode)}\n\n ${c.deviceWaiting}\n`);
160
+ if (provider === 'email') output.write(` ${c.deviceApproveHint}\n`);
229
161
  try { openBrowser(url); } catch { /* the printed URL covers it */ }
230
162
  },
231
163
  },
232
164
  );
233
- handleSocialResult(result, c);
165
+ handleDeviceResult(result, c, output);
234
166
  }
235
167
 
236
- function handleSocialResult(result, c) {
168
+ function handleDeviceResult(result, c, output = process.stdout) {
237
169
  if (result.ok) {
238
170
  // Map the device-flow JWT to the shared persist shape (no cookie).
239
171
  return void persistAndReport(
240
172
  { accessToken: result.token, hubAccessToken: result.token, email: result.email || null },
241
173
  c,
242
174
  result.email || null,
175
+ output,
243
176
  );
244
177
  }
245
178
 
@@ -264,14 +197,14 @@ function handleSocialResult(result, c) {
264
197
  }
265
198
 
266
199
  // `logout` — local only (no endpoint, no network): forget the stored session.
267
- async function runLogout(argv = process.argv.slice(2)) {
200
+ async function runLogout(argv = process.argv.slice(2), { output = process.stdout } = {}) {
268
201
  const opts = parseArgs(argv);
269
202
  const lang = opts.lang || detectReportLang();
270
203
  const c = getCatalog(lang).login;
271
204
  const had = loadAuthSession() !== null;
272
205
  clearAuthSession();
273
206
  clearConsentState();
274
- process.stdout.write(`\n ${had ? c.loggedOut : c.notLoggedIn}\n\n`);
207
+ output.write(`\n ${had ? c.loggedOut : c.notLoggedIn}\n\n`);
275
208
  }
276
209
 
277
210
  module.exports = { run, runLogout, chooseLoginMethod, parseArgs, hasMethodFlag };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -95,7 +95,7 @@ async function requestDeclareAgent(
95
95
  return { ok: true, agentId };
96
96
  }
97
97
 
98
- // `PATCH {hub}/works/me/agents/:id/portfolios` — DIRECT to Hub (ADR-041), replace-set.
98
+ // DIRECT to Hub (ADR-041), replace-set.
99
99
  async function requestRelateAgentPortfolios(
100
100
  { agentId, items, hubAccessToken } = {},
101
101
  { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // GET hub `works/me/ai-profile` — the same read model the "My work with AI" front
3
+ // The same read model the "My work with AI" front
4
4
  // paints: { setup, usage, cell, isAiNative, traction, vision, howIWork }.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // My-applications client (hub `works/candidatures/me`, talent Bearer). Lists the
3
+ // My-applications client. Lists the
4
4
  // positions the talent applied to, with the candidature status.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ // Display-only authorship-transparency check (talents-ai-score).
4
+ //
5
+ // Warns the talent when their commits in the selected repos are authored under
6
+ // an email that is not their Shakers account email, so their own work would be
7
+ // attributed to nobody. This is a WARNING surface ONLY: it reads each repo's
8
+ // CONFIGURED commit identity (`git config user.email`) and compares counts the
9
+ // attribution path already produced — it NEVER feeds attribution/scoring.
10
+ // git-activity.js owns `authoredCommitCount`; nothing here changes any count.
11
+
12
+ const { execFileSync } = require('child_process');
13
+
14
+ // The repo's locally configured commit identity. A plain config read, NOT the
15
+ // authorship log parse — it only names who new commits here would be attributed to.
16
+ function readConfiguredEmail(root) {
17
+ try {
18
+ const out = execFileSync('git', ['-C', root, 'config', '--get', 'user.email'], {
19
+ encoding: 'utf8',
20
+ timeout: 5000,
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ });
23
+ const value = String(out || '').trim();
24
+ return value || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function normalize(email) {
31
+ return String(email || '').trim().toLowerCase();
32
+ }
33
+
34
+ // Returns { accountEmail, emails: [configured emails that differ from the account] }
35
+ // when the talent's own commits are plausibly going unattributed, else null.
36
+ //
37
+ // Conservative on purpose (a false warning erodes trust more than a missed one):
38
+ // it fires only when the account email is known, the selection HAS commits, NONE
39
+ // are attributed to the account, and at least one repo is configured under a
40
+ // different email we can name.
41
+ function detectAuthorshipMismatch(toplevels, accountEmail, gitActivity, { readEmail = readConfiguredEmail } = {}) {
42
+ const account = normalize(accountEmail);
43
+ if (!account) return null;
44
+ if (!gitActivity) return null;
45
+ const commits = gitActivity.commitCount || 0;
46
+ const authored = gitActivity.authoredCommitCount || 0;
47
+ if (commits === 0 || authored > 0) return null;
48
+
49
+ const emails = [];
50
+ const seen = new Set();
51
+ for (const root of Array.isArray(toplevels) ? toplevels : []) {
52
+ const configured = readEmail(root);
53
+ const key = normalize(configured);
54
+ if (!key || key === account || seen.has(key)) continue;
55
+ seen.add(key);
56
+ emails.push(String(configured).trim());
57
+ }
58
+ if (emails.length === 0) return null;
59
+ return { accountEmail: String(accountEmail).trim(), emails };
60
+ }
61
+
62
+ module.exports = { detectAuthorshipMismatch, readConfiguredEmail };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // Availability client. VIEW = GET `works/me/availability`; SET = PUT
4
- // `works/talents/me/work-details/availability` (the same endpoint register uses).
4
+ // (the same endpoint register uses).
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
7
7
  const { reasonForError } = require('./onboarding-client');
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // My-certifications client (hub `works/certifications/me`, talent Bearer). The
3
+ // My-certifications client. The
4
4
  // "By role" page: main-role cluster + its dimension rows (state/band) + ratios.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -4,7 +4,6 @@
4
4
 
5
5
  const { postJsonWithTimeout } = require('./backend-request');
6
6
  const { reasonForError } = require('./onboarding-client');
7
- const { toUpperLang } = require('./lang-codes');
8
7
 
9
8
  const DEFAULT_TIMEOUT_MS = 20000;
10
9
 
@@ -154,87 +153,9 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
154
153
  return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
155
154
  }
156
155
 
157
- async function requestCreateCertificationInterview(
158
- { dimensionKey, languageCode, accessToken } = {},
159
- { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
160
- ) {
161
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
162
- if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
163
- const body = { dimensionKey };
164
- if (languageCode) body.languageCode = toUpperLang(languageCode);
165
- let res;
166
- try {
167
- res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, bearerHeaders(accessToken));
168
- } catch (e) {
169
- return { ok: false, reason: (e && e.kind) || 'network-error' };
170
- }
171
- if (res.status < 200 || res.status >= 300) {
172
- const reason = reasonForError(res.status, res.raw);
173
- if (reason !== 'dimension-on-cooldown') return { ok: false, reason };
174
- // certs puts the date in the message only: "... available on 2026-10-28T14:50:38.265Z".
175
- const available = String(res.raw || '').match(/available on (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
176
- return { ok: false, reason, availableOn: available ? available[1] : null };
177
- }
178
- const data = payloadOf(res.raw);
179
- const interviewId = pick(data || {}, ['interviewId', 'id']);
180
- if (!interviewId) return { ok: false, reason: 'bad-response' };
181
- return { ok: true, interviewId, language: pick(data || {}, ['language', 'languageCode']) || null };
182
- }
183
-
184
- // Poll the verdict; a 2xx/404/425/202 with no band yet resolves `ready:false` (retriable).
185
- async function requestCertificationReport(
186
- { interviewId, accessToken } = {},
187
- { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
188
- ) {
189
- if (!base) return { ok: false, reason: 'no-endpoint' };
190
- if (!interviewId) return { ok: false, reason: 'no-interview' };
191
- const url = `${base.replace(/\/+$/, '')}/${encodeURIComponent(interviewId)}/report`;
192
- let res;
193
- try {
194
- res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
195
- } catch (e) {
196
- return { ok: false, reason: (e && e.kind) || 'network-error' };
197
- }
198
- if (res.status === 404 || res.status === 425 || res.status === 202) return { ok: true, ready: false, report: null };
199
- if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
200
- const data = payloadOf(res.raw);
201
- if (data === undefined || data === null || typeof data !== 'object') return { ok: true, ready: false, report: null };
202
- const band = pick(data, ['band', 'level', 'combinedLevel']);
203
- // A report with too little evidence for a level has band null; it is still finished.
204
- if (!band && !pick(data, ['evaluationId'])) return { ok: true, ready: false, report: null };
205
- return { ok: true, ready: true, report: data };
206
- }
207
-
208
- // The case the web preview shows before starting (works `get-dimension-case`): the exercise statement, else the
209
- // version's brief. `case: null` for a template with neither.
210
- async function requestDimensionCase(
211
- { dimensionKey, accessToken } = {},
212
- { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
213
- ) {
214
- if (!base) return { ok: false, reason: 'no-endpoint' };
215
- if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
216
- const path = String(dimensionKey).split('/').map(encodeURIComponent).join('/');
217
- const url = `${base.replace(/\/+$/, '')}/dimension/${path}/case`;
218
- let res;
219
- try {
220
- res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
221
- } catch (e) {
222
- return { ok: false, reason: (e && e.kind) || 'network-error' };
223
- }
224
- if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
225
- const data = payloadOf(res.raw);
226
- if (!data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
227
- const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
228
- const brief = text(data.statement) || text(data.candidateBrief);
229
- return { ok: true, case: brief ? { brief, testType: text(data.testType) } : null };
230
- }
231
-
232
156
  module.exports = {
233
157
  requestMyCertifications,
234
158
  requestTemplatesByDimension,
235
- requestCreateCertificationInterview,
236
- requestCertificationReport,
237
- requestDimensionCase,
238
159
  composeDimensionKey,
239
160
  composeOfferableDimensions,
240
161
  discoverOfferableDimensions,
@@ -1,28 +1,33 @@
1
1
  'use strict';
2
2
 
3
- // `certify` dimension flow: discover -> pick -> create -> LiveKit interview -> poll verdict.
3
+ // `certify` dimension flow: discover -> pick -> hand over the web deep link. The interview runs on the web.
4
4
 
5
5
  const { runInteractiveMultiSelect } = require('./interactive-select');
6
6
 
7
7
  function makeCertifyDimensionDeps(overrides = {}) {
8
8
  const config = require('./config');
9
9
  const client = require('./certify-dimension-client');
10
- const onboardingFlow = require('./onboarding-flow');
11
10
  return {
12
- getCertificationInterviewsEndpoint: config.getCertificationInterviewsEndpoint,
13
- // `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
14
- getInterviewsBase: config.getOnboardingInterviewsEndpoint,
11
+ getTalentProfileUrl: config.getTalentProfileUrl,
15
12
  discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
16
- requestCreateCertificationInterview: client.requestCreateCertificationInterview,
17
- requestCertificationReport: client.requestCertificationReport,
18
- requestDimensionCase: client.requestDimensionCase,
19
- conductLivekitInterview: onboardingFlow.conductLivekitInterview,
20
- onboardingDeps: onboardingFlow.makeDeps(),
21
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
13
+ buildWebLink,
22
14
  ...overrides,
23
15
  };
24
16
  }
25
17
 
18
+ // The web route validates the decoded `dimension` param against `^[^/]+\/[^/]+$`,
19
+ // so the separator slash stays literal and each segment is url-encoded.
20
+ function buildWebLink(profileUrl, { clusterId, slug } = {}) {
21
+ if (!profileUrl || !clusterId || !slug) return null;
22
+ let origin;
23
+ try {
24
+ origin = new URL(profileUrl).origin;
25
+ } catch {
26
+ return null;
27
+ }
28
+ return `${origin}/certifications?dimension=${encodeURIComponent(clusterId)}/${encodeURIComponent(slug)}`;
29
+ }
30
+
26
31
  // Numbered fallback when `ask` can't release stdin for raw mode.
27
32
  async function askNumberedChoice(ask, promptText, count) {
28
33
  const raw = (await ask(promptText)).trim();
@@ -62,53 +67,6 @@ function resolveDimensionArg(arg, items) {
62
67
  return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
63
68
  }
64
69
 
65
- // The evaluation lands one to two minutes after the interview ends, four when the queue is busy.
66
- async function pollReport(deps, { interviewId, accessToken }, { base, attempts = 60, intervalMs = 5000 } = {}) {
67
- for (let i = 0; i < attempts; i++) {
68
- const res = await deps.requestCertificationReport({ interviewId, accessToken }, { base });
69
- if (!res.ok) return { ok: false, reason: res.reason };
70
- if (res.ready) return { ok: true, report: res.report };
71
- if (i < attempts - 1) await deps.sleep(intervalMs);
72
- }
73
- return { ok: false, reason: 'timeout' };
74
- }
75
-
76
- function formatDay(iso, lang) {
77
- const d = iso ? new Date(iso) : null;
78
- if (!d || Number.isNaN(d.getTime())) return null;
79
- return d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
80
- }
81
-
82
- function renderVerdict(report, cd) {
83
- const lines = [];
84
- const band = report.band || report.level || report.combinedLevel;
85
- const dimension = report.dimensionName || report.dimension || report.dimensionKey;
86
- if (dimension) lines.push(cd.verdictDimension(dimension));
87
- if (band) lines.push(cd.verdictBand(band));
88
- if (typeof report.certified === 'boolean') lines.push(report.certified ? cd.verdictCertified : cd.verdictNotCertified);
89
- const summary = report.summary || report.rationale || report.feedback;
90
- if (typeof summary === 'string' && summary.trim()) lines.push(summary.trim());
91
- const areas = Array.isArray(report.areas) ? report.areas : null;
92
- if (areas && areas.length) {
93
- for (const a of areas) {
94
- const name = a && (a.name || a.area || a.tag);
95
- const note = a && (a.note || a.comment);
96
- if (name) lines.push(`- ${name}${note ? `: ${note}` : ''}`);
97
- }
98
- }
99
- return lines.join('\n ');
100
- }
101
-
102
- // The statement is Markdown; the terminal shows it without the emphasis markers.
103
- function renderCase(dimensionCase, cd) {
104
- const brief = dimensionCase.brief.replace(/(\*\*|__)(.+?)\1/g, '$2');
105
- const expectations = cd.caseExpectations[dimensionCase.testType];
106
- const lines = brief.split('\n');
107
- if (expectations) lines.push('', cd.caseExpectationsTitle, ...Object.values(expectations).map((e) => `- ${e}`));
108
- lines.push('', cd.caseBeforeStart);
109
- return lines.join('\n ');
110
- }
111
-
112
70
  async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
113
71
  const cd = catalog.certifyDimension;
114
72
  const accessToken = session ? session.accessToken : null;
@@ -157,60 +115,21 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
157
115
  }
158
116
  }
159
117
 
160
- const createEndpoint = deps.getCertificationInterviewsEndpoint();
161
- const created = await io.withProgress(cd.creating, () =>
162
- deps.requestCreateCertificationInterview(
163
- { dimensionKey: chosen.dimensionKey, languageCode: lang, accessToken },
164
- { endpoint: createEndpoint },
165
- ));
166
- if (!created.ok) {
167
- io.error(created.reason === 'dimension-on-cooldown'
168
- ? cd.onCooldown(formatDay(created.availableOn, lang))
169
- : cd.createFailed(created.reason));
170
- return { ok: false, reason: created.reason, step: 'create' };
171
- }
172
-
173
- // A failed read leaves the run as it was before the case existed, like the web preview does.
174
- const caseRead = await deps.requestDimensionCase(
175
- { dimensionKey: chosen.dimensionKey, accessToken },
176
- { base: deps.getInterviewsBase() },
177
- );
178
- if (caseRead.ok && caseRead.case) {
179
- io.section(cd.caseTitle);
180
- io.notify(renderCase(caseRead.case, cd));
181
- if (stdinIsTTY) await ask(` ${cd.caseReadyPrompt}`);
182
- }
183
-
184
- const interview = await deps.conductLivekitInterview(io, deps.onboardingDeps, {
185
- interviewId: created.interviewId,
186
- language: lang,
187
- plain: true,
188
- });
189
- if (!interview.ok) {
190
- io.warn(interview.reason === 'needs-web' ? cd.needsWeb : cd.interviewUnavailable(interview.reason));
191
- return { ok: false, reason: interview.reason, step: 'interview' };
192
- }
193
-
194
- const reportBase = deps.getInterviewsBase();
195
- const polled = await io.withProgress(cd.reportPolling, () =>
196
- pollReport(deps, { interviewId: created.interviewId, accessToken }, { base: reportBase }));
197
- if (!polled.ok) {
198
- io.warn(polled.reason === 'timeout' ? cd.reportTimeout : cd.reportFailed(polled.reason));
199
- return { ok: true, completed: 'interview', verdict: null };
118
+ const link = deps.buildWebLink(deps.getTalentProfileUrl(), { clusterId: chosen.clusterId, slug: chosen.slug });
119
+ if (!link) {
120
+ io.warn(cd.webHandoffNoLink);
121
+ return { ok: true, handoff: null, dimension: chosen.dimensionKey };
200
122
  }
201
123
 
202
- io.section(cd.verdictTitle);
203
- io.notify(renderVerdict(polled.report, cd));
204
- io.success(cd.done);
205
- return { ok: true, completed: 'interview', verdict: polled.report };
124
+ io.notify(cd.webHandoff);
125
+ io.notify(cd.webHandoffLink(link));
126
+ return { ok: true, handoff: link, dimension: chosen.dimensionKey };
206
127
  }
207
128
 
208
129
  module.exports = {
209
130
  makeCertifyDimensionDeps,
210
131
  runCertifyDimension,
132
+ buildWebLink,
211
133
  chooseDimension,
212
134
  resolveDimensionArg,
213
- pollReport,
214
- renderVerdict,
215
- renderCase,
216
135
  };