shakers-cli 0.0.0-stage → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +59 -0
- package/README.md +1233 -2
- 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 +99 -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 +124 -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 +216 -0
- package/bin/mcp.js +124 -0
- package/bin/mcpb.js +5 -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 +337 -0
- package/bin/report-html.js +134 -0
- package/bin/save.js +77 -0
- package/bin/shakers.js +363 -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/bin/update-check-worker.js +70 -0
- package/bin/update.js +111 -0
- package/package.json +40 -4
- 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 +215 -0
- package/src/authorship-mismatch.js +62 -0
- package/src/authorship.js +204 -0
- package/src/automations-detector.js +170 -0
- package/src/availability-client.js +93 -0
- package/src/availability-flow.js +124 -0
- package/src/backend-request.js +191 -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 +163 -0
- package/src/certify-dimension-flow.js +135 -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/command-audit.js +105 -0
- package/src/config-dir.js +120 -0
- package/src/config.js +877 -0
- package/src/consent-flow.js +115 -0
- package/src/consent-skip.js +27 -0
- package/src/cv-file.js +62 -0
- package/src/cv-text.js +92 -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 +120 -0
- package/src/email-verification.js +152 -0
- package/src/env-paths.js +30 -0
- package/src/find-projects-cache.js +89 -0
- package/src/find-projects-client.js +148 -0
- package/src/find-projects-flow.js +164 -0
- package/src/git-activity.js +124 -0
- package/src/git-log-prefetch.js +53 -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 +3341 -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 +72 -0
- package/src/markdown-ansi.js +50 -0
- package/src/maturity.js +111 -0
- package/src/mcp-ai-usage-result-tool.js +111 -0
- package/src/mcp-ai-usage-tool.js +401 -0
- package/src/mcp-alma-tools.js +95 -0
- package/src/mcp-applications-tools.js +44 -0
- package/src/mcp-auth-form.js +480 -0
- package/src/mcp-availability-tools.js +45 -0
- package/src/mcp-certifications-tools.js +44 -0
- package/src/mcp-certify-dimension-tools.js +66 -0
- package/src/mcp-choice.js +95 -0
- package/src/mcp-detector.js +223 -0
- package/src/mcp-device-login-tools.js +177 -0
- package/src/mcp-find-projects-tools.js +69 -0
- package/src/mcp-install.js +275 -0
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-invitations-tools.js +44 -0
- package/src/mcp-lang-tools.js +41 -0
- package/src/mcp-onboarding-tools.js +379 -0
- package/src/mcp-portfolios-tools.js +50 -0
- package/src/mcp-profile-tools.js +48 -0
- package/src/mcp-prompts.js +73 -0
- package/src/mcp-rate-tools.js +44 -0
- package/src/mcp-register-tools.js +441 -0
- package/src/mcp-report-tools.js +105 -0
- package/src/mcp-role-tools.js +372 -0
- package/src/mcp-saved-positions-tools.js +59 -0
- package/src/mcp-server.js +290 -0
- package/src/mcp-show-project-tools.js +52 -0
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +1171 -0
- package/src/mcp-socials-tools.js +41 -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 +374 -0
- package/src/onboarding-flow.js +1162 -0
- package/src/onboarding-funnel.js +272 -0
- package/src/onboarding-livekit-client.js +102 -0
- package/src/onboarding-status-client.js +57 -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 +133 -0
- package/src/profile-flow.js +115 -0
- package/src/profile-switch.js +46 -0
- package/src/prompt-select.js +66 -0
- package/src/rate-client.js +81 -0
- package/src/rate-flow.js +137 -0
- package/src/register-context.js +145 -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 +112 -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 +624 -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-normalizers.js +174 -0
- package/src/session-refresh.js +51 -0
- package/src/session-scan.js +475 -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 +185 -0
- package/src/show-project-client.js +58 -0
- package/src/show-project-flow.js +119 -0
- package/src/signup-client.js +103 -0
- package/src/signup-copy.js +450 -0
- package/src/signup-language.js +21 -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 +207 -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/update-flow.js +223 -0
- package/src/update-notifier.js +113 -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 +354 -0
- package/src/work-streams.js +79 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { getCatalog } = require('./i18n');
|
|
7
|
+
const { setupLevelForTier } = require('./tier-engine');
|
|
8
|
+
const { renderDocument } = require('./report-theme');
|
|
9
|
+
const { loadState, configDir, projectSlug, fileUrl } = require('./report-store');
|
|
10
|
+
const { WORDMARK_VIEWBOX, WORDMARK_PATHS } = require('./shakers-wordmark');
|
|
11
|
+
|
|
12
|
+
// LinkedIn shares render best at 1.91:1 (1200×627). Fixed so the browser-side
|
|
13
|
+
// canvas export writes exactly these pixels.
|
|
14
|
+
const CARD_W = 1200;
|
|
15
|
+
const CARD_H = 627;
|
|
16
|
+
|
|
17
|
+
// Brand palette (from shakers-design-system/design-spec/tokens.css).
|
|
18
|
+
const BRAND = {
|
|
19
|
+
lime: '#d8e637', // hero accent (tier, score arc, stat numbers)
|
|
20
|
+
dark: '#181B1A', // card background
|
|
21
|
+
white: '#ffffff',
|
|
22
|
+
zinc300: '#d4d4d8', // stat labels / technology names (light, secondary)
|
|
23
|
+
zinc400: '#a1a1aa', // "/ 100", muted labels
|
|
24
|
+
zinc500: '#71717a', // shakersworks.com, separators
|
|
25
|
+
teal500: '#0e7d69', // teal-500 — accent stripe + eyebrow
|
|
26
|
+
teal700: '#08473c', // teal-700 — band pill + score-ring track
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Font stack shared with the reports (Inter -> system fallback).
|
|
30
|
+
const FONT_STACK =
|
|
31
|
+
"Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
|
|
32
|
+
|
|
33
|
+
function esc(s) {
|
|
34
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => ({
|
|
35
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
36
|
+
}[ch]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadProjectFootprint(root, { load = loadState } = {}) {
|
|
40
|
+
const absRoot = path.resolve(root || process.cwd());
|
|
41
|
+
let state;
|
|
42
|
+
try {
|
|
43
|
+
state = load();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const project = state && state.projects && state.projects[absRoot];
|
|
48
|
+
const fp = project && project.footprint;
|
|
49
|
+
const m = fp && fp.maturity;
|
|
50
|
+
if (!m || m.tierKey == null || typeof m.score !== 'number') return null;
|
|
51
|
+
|
|
52
|
+
// Derived signals from the persisted report (defensive: any missing piece
|
|
53
|
+
// reads as 0 / empty — an older stored report never crashes the card).
|
|
54
|
+
const report = (fp && fp.report) || {};
|
|
55
|
+
const counts = report.agentCounts || {};
|
|
56
|
+
const toolsDetected = Array.isArray(report.tools)
|
|
57
|
+
? report.tools.filter((tl) => tl && tl.detected).length
|
|
58
|
+
: 0;
|
|
59
|
+
const mcpTotal = report.mcp && typeof report.mcp.total === 'number'
|
|
60
|
+
? report.mcp.total
|
|
61
|
+
: (typeof counts.mcpServers === 'number' ? counts.mcpServers : 0);
|
|
62
|
+
const technologies = Array.isArray(report.technologies)
|
|
63
|
+
? report.technologies.filter((s) => typeof s === 'string' && s.trim())
|
|
64
|
+
: [];
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
root: absRoot,
|
|
68
|
+
tier: m.tier,
|
|
69
|
+
tierKey: m.tierKey,
|
|
70
|
+
score: m.score,
|
|
71
|
+
level: m.level,
|
|
72
|
+
levelKey: m.key,
|
|
73
|
+
// ADR-016: Setup Level for the card pill (replaces the 0-4 band). Prefer the
|
|
74
|
+
// persisted `setupLevel`; derive from the tier for older stored reports.
|
|
75
|
+
setupLevelKey: (m.setupLevel && m.setupLevel.key)
|
|
76
|
+
|| (typeof m.tier === 'number' ? setupLevelForTier(m.tier).key : 'none'),
|
|
77
|
+
generatedAt: fp.generatedAt || null,
|
|
78
|
+
signals: {
|
|
79
|
+
toolsDetected,
|
|
80
|
+
mcpServers: mcpTotal,
|
|
81
|
+
agents: typeof counts.agents === 'number' ? counts.agents : 0,
|
|
82
|
+
skills: typeof counts.skills === 'number' ? counts.skills : 0,
|
|
83
|
+
commands: typeof counts.commands === 'number' ? counts.commands : 0,
|
|
84
|
+
hooks: typeof counts.hooks === 'number' ? counts.hooks : 0,
|
|
85
|
+
technologies,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// How many top technologies to name on the card (kept small — social card).
|
|
91
|
+
const MAX_TECHNOLOGIES = 4;
|
|
92
|
+
|
|
93
|
+
// Maps the raw footprint into the card's display model.
|
|
94
|
+
function buildCardModel(fp) {
|
|
95
|
+
const en = getCatalog('en');
|
|
96
|
+
const tierKey = fp.tierKey;
|
|
97
|
+
const tierName = (en.tierNames && en.tierNames[tierKey]) || tierKey;
|
|
98
|
+
// ADR-016: Setup Level label for the pill (English-fixed like the tier name).
|
|
99
|
+
const setupKey = fp.setupLevelKey || 'none';
|
|
100
|
+
const bandName = (en.setupLevels && en.setupLevels[setupKey] && en.setupLevels[setupKey].label) || '';
|
|
101
|
+
|
|
102
|
+
const s = fp.signals || {};
|
|
103
|
+
const n = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
|
|
104
|
+
// Only stats with a value > 0 make the card — never a "0 skills" filler.
|
|
105
|
+
// Order is the reading order the label list implies (tools -> hooks).
|
|
106
|
+
const stats = [
|
|
107
|
+
{ value: n(s.toolsDetected), label: 'AI tools' },
|
|
108
|
+
{ value: n(s.mcpServers), label: 'MCP' },
|
|
109
|
+
{ value: n(s.agents), label: 'agents' },
|
|
110
|
+
{ value: n(s.skills), label: 'skills' },
|
|
111
|
+
{ value: n(s.commands), label: 'commands' },
|
|
112
|
+
{ value: n(s.hooks), label: 'hooks' },
|
|
113
|
+
].filter((st) => st.value > 0);
|
|
114
|
+
const technologies = Array.isArray(s.technologies) ? s.technologies.slice(0, MAX_TECHNOLOGIES) : [];
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
tierKey,
|
|
118
|
+
tierName,
|
|
119
|
+
bandName,
|
|
120
|
+
score: Math.max(0, Math.min(100, Math.round(fp.score))),
|
|
121
|
+
stats,
|
|
122
|
+
technologies,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Suggested LinkedIn caption, built deterministically from the model. English.
|
|
127
|
+
function buildSuggestedText(model) {
|
|
128
|
+
return (
|
|
129
|
+
`I just measured my AI tooling maturity with the Shakers AI Usage Evaluator: `
|
|
130
|
+
+ `tier ${model.tierKey} (${model.tierName}), scoring ${model.score}/100. `
|
|
131
|
+
+ `How do you work with AI? Measure yours at shakersworks.com `
|
|
132
|
+
+ `#AI #DeveloperExperience #AITooling #Shakers`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Builds the branded card as a SELF-CONTAINED SVG (1200×627).
|
|
137
|
+
function renderCardSvg(model, { id = 'share-card-svg' } = {}) {
|
|
138
|
+
const { tierKey, tierName, bandName, score, stats, technologies } = model;
|
|
139
|
+
|
|
140
|
+
// Logo: the REAL hand-drawn "shakers" wordmark (87×18 asset), ~44px tall, top-left, in white.
|
|
141
|
+
const wordmarkScale = 44 / WORDMARK_VIEWBOX.h; // ≈ 2.44
|
|
142
|
+
const wordmark = WORDMARK_PATHS
|
|
143
|
+
.map((d) => `<path d="${d}" fill="${BRAND.white}"/>`)
|
|
144
|
+
.join('');
|
|
145
|
+
|
|
146
|
+
// Score donut ring (right). Deterministic geometry from the score.
|
|
147
|
+
const cx = 980;
|
|
148
|
+
const cy = 262;
|
|
149
|
+
const r = 118;
|
|
150
|
+
const circ = 2 * Math.PI * r;
|
|
151
|
+
const dashOffset = (circ * (1 - score / 100)).toFixed(2);
|
|
152
|
+
|
|
153
|
+
// Stats: a single LIGHT line — only signals with a value > 0 (buildCardModel
|
|
154
|
+
// already filtered zeros out). Number in lime, label muted, " · " separators.
|
|
155
|
+
const SEP = `<tspan fill="${BRAND.zinc500}"> · </tspan>`;
|
|
156
|
+
const statsInline = (Array.isArray(stats) ? stats : [])
|
|
157
|
+
.map((st) => `<tspan font-weight="800" fill="${BRAND.lime}">${esc(st.value)}</tspan><tspan fill="${BRAND.zinc300}"> ${esc(st.label)}</tspan>`)
|
|
158
|
+
.join(SEP);
|
|
159
|
+
const statsLine = stats && stats.length
|
|
160
|
+
? `<text x="82" y="500" font-size="27" fill="${BRAND.zinc300}">${statsInline}</text>`
|
|
161
|
+
: '';
|
|
162
|
+
|
|
163
|
+
// Technologies: one muted line ("Top: A · B · C"), only when present.
|
|
164
|
+
const techLine = Array.isArray(technologies) && technologies.length
|
|
165
|
+
? `<text x="82" y="546" font-size="22" fill="${BRAND.zinc400}">Top: <tspan fill="${BRAND.white}">${esc(technologies.join(' · '))}</tspan></text>`
|
|
166
|
+
: '';
|
|
167
|
+
|
|
168
|
+
// Band pill (teal, secondary) with a lime status dot before the band name.
|
|
169
|
+
const pillWidth = Math.max(150, 58 + bandName.length * 12);
|
|
170
|
+
|
|
171
|
+
return `<svg id="${id}" width="${CARD_W}" height="${CARD_H}" viewBox="0 0 ${CARD_W} ${CARD_H}"`
|
|
172
|
+
+ ` xmlns="http://www.w3.org/2000/svg" font-family="${esc(FONT_STACK)}">
|
|
173
|
+
<rect width="${CARD_W}" height="${CARD_H}" fill="${BRAND.dark}"/>
|
|
174
|
+
<rect x="0" y="0" width="12" height="${CARD_H}" fill="${BRAND.teal500}"/>
|
|
175
|
+
|
|
176
|
+
<!-- logo: real hand-drawn shakers wordmark (inline vector paths, self-contained) -->
|
|
177
|
+
<g transform="translate(80,56) scale(${wordmarkScale.toFixed(4)})">${wordmark}</g>
|
|
178
|
+
<text x="1160" y="92" text-anchor="end" font-size="19" fill="${BRAND.zinc500}">shakersworks.com</text>
|
|
179
|
+
|
|
180
|
+
<!-- eyebrow (teal, letterspaced) -->
|
|
181
|
+
<text x="82" y="176" font-size="25" letter-spacing="4" font-weight="700" fill="${BRAND.teal500}">AI TOOLING MATURITY</text>
|
|
182
|
+
|
|
183
|
+
<!-- tier (hero, lime) + tier name -->
|
|
184
|
+
<text x="76" y="330" font-size="150" font-weight="800" fill="${BRAND.lime}">${esc(tierKey)}</text>
|
|
185
|
+
<text x="82" y="392" font-size="40" font-weight="700" fill="${BRAND.white}">${esc(tierName)}</text>
|
|
186
|
+
|
|
187
|
+
<!-- maturity band pill (teal, secondary) with a lime status dot -->
|
|
188
|
+
<g transform="translate(82,414)">
|
|
189
|
+
<rect x="0" y="0" rx="15" ry="15" width="${pillWidth}" height="40" fill="${BRAND.teal700}"/>
|
|
190
|
+
<circle cx="24" cy="20" r="6" fill="${BRAND.lime}"/>
|
|
191
|
+
<text x="42" y="27" font-size="19" font-weight="700" fill="${BRAND.white}">${esc(bandName)}</text>
|
|
192
|
+
</g>
|
|
193
|
+
|
|
194
|
+
<!-- score donut (hero, lime arc on a teal track) -->
|
|
195
|
+
<g transform="translate(${cx},${cy})">
|
|
196
|
+
<circle r="${r}" fill="none" stroke="${BRAND.teal700}" stroke-width="22"/>
|
|
197
|
+
<circle r="${r}" fill="none" stroke="${BRAND.lime}" stroke-width="22" stroke-linecap="round"
|
|
198
|
+
stroke-dasharray="${circ.toFixed(2)}" stroke-dashoffset="${dashOffset}" transform="rotate(-90)"/>
|
|
199
|
+
<text text-anchor="middle" y="14" font-size="86" font-weight="800" fill="${BRAND.white}">${score}</text>
|
|
200
|
+
<text text-anchor="middle" y="56" font-size="24" letter-spacing="2" fill="${BRAND.zinc400}">/ 100</text>
|
|
201
|
+
</g>
|
|
202
|
+
|
|
203
|
+
<!-- light stats line + technologies (no panel, no footer; bottom edge breathes) -->
|
|
204
|
+
${statsLine}
|
|
205
|
+
${techLine}
|
|
206
|
+
</svg>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Component CSS for the HTML wrapper (the page around the card). Reuses the
|
|
210
|
+
// report theme tokens (var(--...)) from report-theme.js's renderDocument.
|
|
211
|
+
const SHARE_CSS = `
|
|
212
|
+
.card-frame{margin:8px 0 22px}
|
|
213
|
+
.card-svg-wrap{max-width:760px;border-radius:var(--r-xl);overflow:hidden;
|
|
214
|
+
box-shadow:var(--shadow-lg);border:1px solid var(--border);background:#181B1A}
|
|
215
|
+
.card-svg-wrap svg{display:block;width:100%;height:auto}
|
|
216
|
+
.actions{display:flex;flex-wrap:wrap;gap:12px;margin:0 0 18px}
|
|
217
|
+
.btn{display:inline-flex;align-items:center;gap:8px;font:inherit;font-size:15px;
|
|
218
|
+
font-weight:600;padding:11px 20px;border-radius:var(--r-md);border:1px solid transparent;
|
|
219
|
+
cursor:pointer;text-decoration:none;line-height:1}
|
|
220
|
+
.btn-primary{background:var(--primary);color:var(--primary-fg)}
|
|
221
|
+
.btn-primary:hover{background:var(--emphasis-strong)}
|
|
222
|
+
.btn-secondary{background:var(--accent-lime);color:var(--accent-lime-fg)}
|
|
223
|
+
.btn-secondary:hover{filter:brightness(.96)}
|
|
224
|
+
.flow-note{display:flex;gap:12px;padding:16px 18px;border-radius:var(--r-lg);
|
|
225
|
+
background:var(--secondary);color:var(--secondary-fg);margin:0 0 22px;font-size:14px;line-height:1.55}
|
|
226
|
+
.flow-note ol{margin:6px 0 0;padding-left:20px}
|
|
227
|
+
.flow-note li{margin:2px 0}
|
|
228
|
+
.suggested{margin:0 0 8px}
|
|
229
|
+
.sug-head{display:flex;align-items:center;justify-content:space-between;margin:0 0 8px}
|
|
230
|
+
.sug-head span{font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--faint)}
|
|
231
|
+
.copy{font:inherit;font-size:12.5px;font-weight:600;padding:6px 12px;border-radius:var(--r-sm);
|
|
232
|
+
border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer}
|
|
233
|
+
.copy.copied{background:var(--secondary);color:var(--secondary-fg);border-color:var(--secondary)}
|
|
234
|
+
#li-text{margin:0;padding:16px 18px;border:1px solid var(--border);border-radius:var(--r-md);
|
|
235
|
+
background:var(--track);color:var(--fg);font-size:14.5px;line-height:1.6}
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
// Browser-side PNG export + LinkedIn share wiring.
|
|
239
|
+
const SHARE_SCRIPT = `
|
|
240
|
+
(function(){
|
|
241
|
+
var CARD_W=${CARD_W}, CARD_H=${CARD_H};
|
|
242
|
+
var svg=document.getElementById('share-card-svg');
|
|
243
|
+
var dl=document.getElementById('dl-png');
|
|
244
|
+
var li=document.getElementById('li-share');
|
|
245
|
+
var liText=document.getElementById('li-text');
|
|
246
|
+
if(li){
|
|
247
|
+
var text=liText?liText.textContent:'';
|
|
248
|
+
li.setAttribute('href','https://www.linkedin.com/feed/?shareActive=true&text='+encodeURIComponent(text));
|
|
249
|
+
}
|
|
250
|
+
if(dl&&svg){
|
|
251
|
+
dl.addEventListener('click',function(){
|
|
252
|
+
// Serialize the inline SVG and load it as an image. encodeURIComponent
|
|
253
|
+
// (not btoa) so any non-ASCII survives; charset is declared for safety.
|
|
254
|
+
var xml=new XMLSerializer().serializeToString(svg);
|
|
255
|
+
var src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(xml);
|
|
256
|
+
var img=new Image();
|
|
257
|
+
img.onload=function(){
|
|
258
|
+
var canvas=document.createElement('canvas');
|
|
259
|
+
canvas.width=CARD_W; canvas.height=CARD_H;
|
|
260
|
+
var ctx=canvas.getContext('2d');
|
|
261
|
+
ctx.fillStyle='#181B1A'; ctx.fillRect(0,0,CARD_W,CARD_H);
|
|
262
|
+
ctx.drawImage(img,0,0,CARD_W,CARD_H);
|
|
263
|
+
var png;
|
|
264
|
+
try{ png=canvas.toDataURL('image/png'); }
|
|
265
|
+
catch(e){ /* self-contained SVG shouldn't taint; bail quietly if it did */ return; }
|
|
266
|
+
var a=document.createElement('a');
|
|
267
|
+
a.href=png; a.download='shakers-ai-maturity.png';
|
|
268
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
269
|
+
};
|
|
270
|
+
img.onerror=function(){};
|
|
271
|
+
img.src=src;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
// Assembles the full, self-contained HTML page around the card, using the shared Shakers report theme (report-theme.js#renderDocument).
|
|
278
|
+
function renderShareCardHtml(model) {
|
|
279
|
+
const svg = renderCardSvg(model);
|
|
280
|
+
const suggested = buildSuggestedText(model);
|
|
281
|
+
|
|
282
|
+
const body = `<header>
|
|
283
|
+
<span class="badge"><span class="spark"></span>SHAKERS</span>
|
|
284
|
+
<h1>Share your AI maturity</h1>
|
|
285
|
+
<p class="sub">A branded card to post on LinkedIn.</p>
|
|
286
|
+
</header>
|
|
287
|
+
|
|
288
|
+
<div class="card-frame">
|
|
289
|
+
<div class="card-svg-wrap">${svg}</div>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div class="actions">
|
|
293
|
+
<button type="button" id="dl-png" class="btn btn-primary">Download PNG</button>
|
|
294
|
+
<a id="li-share" class="btn btn-secondary" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener">Share on LinkedIn</a>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div class="flow-note">
|
|
298
|
+
<span aria-hidden="true">💡</span>
|
|
299
|
+
<div>
|
|
300
|
+
<strong>LinkedIn can't attach an image from a URL.</strong> To post the card:
|
|
301
|
+
<ol>
|
|
302
|
+
<li>Click <strong>Download PNG</strong> to save the image.</li>
|
|
303
|
+
<li>Click <strong>Share on LinkedIn</strong> to open the composer (with the suggested text).</li>
|
|
304
|
+
<li>Attach the downloaded PNG to your post.</li>
|
|
305
|
+
</ol>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<section class="suggested">
|
|
310
|
+
<div class="sug-head">
|
|
311
|
+
<span>Suggested text</span>
|
|
312
|
+
<button type="button" class="copy" data-copy-target="li-text" data-copied-label="Copied ✓">Copy</button>
|
|
313
|
+
</div>
|
|
314
|
+
<p id="li-text">${esc(suggested)}</p>
|
|
315
|
+
</section>
|
|
316
|
+
|
|
317
|
+
<footer>
|
|
318
|
+
<div class="priv">
|
|
319
|
+
<span class="lock" aria-hidden="true">🔒</span>
|
|
320
|
+
<span>Generated locally on your machine. The card and the PNG are built entirely in your browser — nothing is uploaded.</span>
|
|
321
|
+
</div>
|
|
322
|
+
</footer>`;
|
|
323
|
+
|
|
324
|
+
return renderDocument({
|
|
325
|
+
lang: 'en',
|
|
326
|
+
title: 'Shakers — Share your AI maturity',
|
|
327
|
+
componentCss: SHARE_CSS,
|
|
328
|
+
body,
|
|
329
|
+
script: SHARE_SCRIPT,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Per-project card file, alongside the reports in ~/.config/ai-footprint/ (override via SHAKERS_CLI_CONFIG_DIR).
|
|
334
|
+
function cardPathFor(absRoot) {
|
|
335
|
+
return path.join(configDir(), `share-${projectSlug(path.resolve(absRoot))}.html`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Orchestrates the command: load the project's footprint, and either return a "no footprint" signal or render + write the card and return its file:// link.
|
|
339
|
+
function generateShareCard({ root, load } = {}) {
|
|
340
|
+
const absRoot = path.resolve(root || process.cwd());
|
|
341
|
+
const fp = loadProjectFootprint(absRoot, load ? { load } : {});
|
|
342
|
+
if (!fp) return { ok: false, reason: 'no-footprint', root: absRoot };
|
|
343
|
+
|
|
344
|
+
const model = buildCardModel(fp);
|
|
345
|
+
const html = renderShareCardHtml(model);
|
|
346
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
347
|
+
const htmlPath = cardPathFor(absRoot);
|
|
348
|
+
fs.writeFileSync(htmlPath, html);
|
|
349
|
+
return { ok: true, root: absRoot, model, html, htmlPath, fileUrl: fileUrl(htmlPath) };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
module.exports = {
|
|
353
|
+
CARD_W,
|
|
354
|
+
CARD_H,
|
|
355
|
+
loadProjectFootprint,
|
|
356
|
+
buildCardModel,
|
|
357
|
+
buildSuggestedText,
|
|
358
|
+
renderCardSvg,
|
|
359
|
+
renderShareCardHtml,
|
|
360
|
+
cardPathFor,
|
|
361
|
+
generateShareCard,
|
|
362
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Certification-persistence payload (skill-code-certification, issue 005), extracted from src/share.js (structure refactor, issue 020).
|
|
4
|
+
|
|
5
|
+
const { scrubSecrets } = require('./agent-synthesis');
|
|
6
|
+
|
|
7
|
+
const CERTIFY_SEND_THROTTLE_MS = 60 * 60 * 1000;
|
|
8
|
+
const CERT_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
// Strict whitelist: the analyzed RESULT + observability provenance (ADR-017), rebuilt field-by-field (never spread).
|
|
11
|
+
const CERT_DIMENSION_KEYS = ['idiomatic', 'correctness', 'depth', 'structure', 'testing'];
|
|
12
|
+
function deriveDimensionScores(dimensions) {
|
|
13
|
+
if (!dimensions || typeof dimensions !== 'object') return null;
|
|
14
|
+
const out = {};
|
|
15
|
+
let any = false;
|
|
16
|
+
for (const key of CERT_DIMENSION_KEYS) {
|
|
17
|
+
const v = dimensions[key];
|
|
18
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 4) {
|
|
19
|
+
out[key] = v;
|
|
20
|
+
any = true;
|
|
21
|
+
} else {
|
|
22
|
+
out[key] = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return any ? out : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_INTERVIEW_EVIDENCE_TOKEN_CHARS = 4096;
|
|
29
|
+
function interviewEvidenceTokenOf(item) {
|
|
30
|
+
const t = item && item.interviewEvidenceToken;
|
|
31
|
+
return typeof t === 'string' && t ? t.slice(0, MAX_INTERVIEW_EVIDENCE_TOKEN_CHARS) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_CERTIFY_EVIDENCE_TOKEN_CHARS = 4096;
|
|
35
|
+
function certifyEvidenceTokenOf(item) {
|
|
36
|
+
const t = item && item.result && item.result.evidenceToken;
|
|
37
|
+
return typeof t === 'string' && t ? t.slice(0, MAX_CERTIFY_EVIDENCE_TOKEN_CHARS) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deriveCertificationPayload(items, { repository = null, commitRange = null, toolVersion = null } = {}) {
|
|
41
|
+
const list = Array.isArray(items) ? items : [];
|
|
42
|
+
const skillCodeAssessments = list
|
|
43
|
+
.filter((i) => i && i.result && typeof i.result === 'object')
|
|
44
|
+
.map((i) => {
|
|
45
|
+
const assessment = {
|
|
46
|
+
skillId: i.skillId != null ? i.skillId : null,
|
|
47
|
+
skillName: typeof i.skillName === 'string' ? i.skillName : null,
|
|
48
|
+
technology: typeof i.technology === 'string' ? i.technology : null,
|
|
49
|
+
score: typeof i.result.score === 'number' ? i.result.score : null,
|
|
50
|
+
rationale: scrubSecrets(typeof i.result.rationale === 'string' ? i.result.rationale : ''),
|
|
51
|
+
improvements: Array.isArray(i.result.improvements)
|
|
52
|
+
? i.result.improvements.filter((x) => typeof x === 'string' && x).map((x) => scrubSecrets(x))
|
|
53
|
+
: [],
|
|
54
|
+
sampled: !!(i.sampling && i.sampling.sampleable),
|
|
55
|
+
model: typeof i.result.model === 'string' && i.result.model ? i.result.model : null,
|
|
56
|
+
// Paths only (never content). The attribution gate already filtered
|
|
57
|
+
// these to the Talent's own files (bin/certify.js).
|
|
58
|
+
sampledFiles: Array.isArray(i.sampledFiles)
|
|
59
|
+
? i.sampledFiles.filter((p) => typeof p === 'string' && p)
|
|
60
|
+
: [],
|
|
61
|
+
// Verified-authorship evidence: every considered author-email + match flag.
|
|
62
|
+
authorEmails: Array.isArray(i.authorEmails)
|
|
63
|
+
? i.authorEmails
|
|
64
|
+
.filter((a) => a && typeof a.email === 'string' && a.email)
|
|
65
|
+
.map((a) => ({ email: a.email, matched: !!a.matched }))
|
|
66
|
+
: [],
|
|
67
|
+
// Model's per-file scores; `[]` when the model degraded to aggregate-only.
|
|
68
|
+
perFileBreakdown: Array.isArray(i.result.perFileBreakdown)
|
|
69
|
+
? i.result.perFileBreakdown
|
|
70
|
+
.filter((f) => f && typeof f.path === 'string' && typeof f.score === 'number')
|
|
71
|
+
.map((f) => ({
|
|
72
|
+
path: f.path,
|
|
73
|
+
score: f.score,
|
|
74
|
+
note: typeof f.note === 'string' && f.note ? scrubSecrets(f.note) : null,
|
|
75
|
+
}))
|
|
76
|
+
: [],
|
|
77
|
+
// ADR-024 rubric dimensions (0-4 or null) — the inputs the server used to
|
|
78
|
+
// compute the deterministic score. Rebuilt key-by-key, never spread.
|
|
79
|
+
dimensionScores: deriveDimensionScores(i.result.dimensions),
|
|
80
|
+
};
|
|
81
|
+
const certifyToken = certifyEvidenceTokenOf(i);
|
|
82
|
+
if (certifyToken) assessment.evidenceToken = certifyToken;
|
|
83
|
+
// ADR-049 / issue 128: the attested combined level, bound ONLY when an
|
|
84
|
+
// interview ran. Added conditionally so an item without one is byte-identical.
|
|
85
|
+
const token = interviewEvidenceTokenOf(i);
|
|
86
|
+
if (token) assessment.interviewEvidenceToken = token;
|
|
87
|
+
return assessment;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: CERT_SCHEMA_VERSION,
|
|
91
|
+
generatedAt: new Date().toISOString(),
|
|
92
|
+
kind: 'skill-code-assessment',
|
|
93
|
+
skillCodeAssessments,
|
|
94
|
+
toolVersion: typeof toolVersion === 'string' && toolVersion ? toolVersion : null,
|
|
95
|
+
repository: typeof repository === 'string' && repository ? repository : null,
|
|
96
|
+
commitRange: typeof commitRange === 'string' && commitRange ? commitRange : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isCertifyThrottled(state, now = Date.now()) {
|
|
101
|
+
if (!state || !state.lastCertifySentAt) return false;
|
|
102
|
+
const last = new Date(state.lastCertifySentAt).getTime();
|
|
103
|
+
if (Number.isNaN(last)) return false;
|
|
104
|
+
return now - last < CERTIFY_SEND_THROTTLE_MS;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
deriveCertificationPayload,
|
|
109
|
+
isCertifyThrottled,
|
|
110
|
+
CERTIFY_SEND_THROTTLE_MS,
|
|
111
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Consent state + email identity + client-side throttle, extracted from src/share.js (structure refactor, issue 020).
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { getConfigDir } = require('./config-dir');
|
|
8
|
+
|
|
9
|
+
function configDir() {
|
|
10
|
+
return getConfigDir(process.env);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function consentPath() {
|
|
14
|
+
return path.join(configDir(), 'consent.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Client-side throttle: don't retry a submission if the last one was less than 1h ago.
|
|
18
|
+
const SEND_THROTTLE_MS = 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// consent state (~/.config/ai-footprint/consent.json) Replaces the old `credentials.json` (token model).
|
|
21
|
+
|
|
22
|
+
function loadConsentState() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(consentPath(), 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveConsentState(state) {
|
|
31
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
32
|
+
fs.writeFileSync(consentPath(), JSON.stringify(state, null, 2));
|
|
33
|
+
try { fs.chmodSync(consentPath(), 0o600); } catch { /* e.g. Windows */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Returns 'granted' | 'denied' | null ("no decision persisted yet").
|
|
37
|
+
function getConsentDecision(state) {
|
|
38
|
+
if (!state || state.consent === undefined || state.consent === null) return null;
|
|
39
|
+
return state.consent;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasTraceContentConsent() {
|
|
43
|
+
return getConsentDecision(loadConsentState()) === 'granted';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---------- email ---------- */
|
|
47
|
+
|
|
48
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
49
|
+
|
|
50
|
+
// Basic format validation only (specs.md: "validación de formato básica").
|
|
51
|
+
function isValidEmail(value) {
|
|
52
|
+
return typeof value === 'string' && EMAIL_RE.test(value.trim());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Same normalization the backend applies (specs.md Data model): trim +
|
|
56
|
+
// lowercase, so client and server agree on what "the same email" means.
|
|
57
|
+
function normalizeEmail(value) {
|
|
58
|
+
return String(value).trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Persists a consent decision.
|
|
62
|
+
function recordConsent(decision, email = null, { verified } = {}) {
|
|
63
|
+
if (decision !== 'granted' && decision !== 'denied') {
|
|
64
|
+
throw new Error(`Decisión de consentimiento no válida: ${decision}`);
|
|
65
|
+
}
|
|
66
|
+
if (decision === 'granted' && !isValidEmail(email)) {
|
|
67
|
+
throw new Error('No se puede conceder consentimiento sin un correo válido.');
|
|
68
|
+
}
|
|
69
|
+
const state = loadConsentState() || {};
|
|
70
|
+
state.consent = decision;
|
|
71
|
+
if (email !== null) state.email = normalizeEmail(email);
|
|
72
|
+
if (state.lastSentAt === undefined) state.lastSentAt = null;
|
|
73
|
+
// Email-ownership verification flag (skill-code-certification / ADR-006).
|
|
74
|
+
if (decision === 'granted' && verified !== undefined) {
|
|
75
|
+
state.emailVerified = verified === true;
|
|
76
|
+
}
|
|
77
|
+
saveConsentState(state);
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// consent management (issue 007: status / revoke / change email) One-shot actions (do not scan), same pattern the retired `--enroll` used.
|
|
82
|
+
|
|
83
|
+
// Read-only snapshot for `--consent-status`. Never throws.
|
|
84
|
+
function getConsentStatus() {
|
|
85
|
+
const state = loadConsentState();
|
|
86
|
+
return {
|
|
87
|
+
consent: getConsentDecision(state),
|
|
88
|
+
email: state && state.email ? state.email : null,
|
|
89
|
+
emailVerified: state ? state.emailVerified !== false : false,
|
|
90
|
+
lastSentAt: state && state.lastSentAt ? state.lastSentAt : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Revokes consent unconditionally (works even with no prior decision, or after a prior `denied` — idempotent): from this point on `autoShare` skips with `consent-denied`.
|
|
95
|
+
function revokeConsent() {
|
|
96
|
+
const state = recordConsent('denied');
|
|
97
|
+
return { ok: true, state };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resetConsent() {
|
|
101
|
+
const state = loadConsentState() || {};
|
|
102
|
+
delete state.consent;
|
|
103
|
+
// A re-grant is "concede de cero" (ADR-006): drop the verification flag so the OTP runs again from scratch and nothing is sent under the old email until it's re-verified.
|
|
104
|
+
delete state.emailVerified;
|
|
105
|
+
if (state.lastSentAt === undefined) state.lastSentAt = null;
|
|
106
|
+
saveConsentState(state);
|
|
107
|
+
return { ok: true, state };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clearConsentState() {
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(consentPath(), { force: true });
|
|
113
|
+
} catch { /* no-op */ }
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Changes the persisted email WITHOUT touching the consent decision (specs.md: "sin tocar la decisión de consentimiento").
|
|
118
|
+
function setEmail(newEmail) {
|
|
119
|
+
if (!isValidEmail(newEmail)) {
|
|
120
|
+
return { ok: false, reason: 'invalid-email' };
|
|
121
|
+
}
|
|
122
|
+
const state = loadConsentState() || {};
|
|
123
|
+
state.email = normalizeEmail(newEmail);
|
|
124
|
+
// A new email is unverified until proven (ADR-006): reset the flag so
|
|
125
|
+
// autoShare won't send under an address whose ownership wasn't checked.
|
|
126
|
+
state.emailVerified = false;
|
|
127
|
+
saveConsentState(state);
|
|
128
|
+
return { ok: true, state };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ---------- client-side throttle ---------- */
|
|
132
|
+
|
|
133
|
+
function isThrottled(state, now = Date.now()) {
|
|
134
|
+
if (!state || !state.lastSentAt) return false;
|
|
135
|
+
const last = new Date(state.lastSentAt).getTime();
|
|
136
|
+
if (Number.isNaN(last)) return false;
|
|
137
|
+
return now - last < SEND_THROTTLE_MS;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
configDir,
|
|
142
|
+
consentPath,
|
|
143
|
+
SEND_THROTTLE_MS,
|
|
144
|
+
loadConsentState,
|
|
145
|
+
saveConsentState,
|
|
146
|
+
getConsentDecision,
|
|
147
|
+
hasTraceContentConsent,
|
|
148
|
+
isValidEmail,
|
|
149
|
+
normalizeEmail,
|
|
150
|
+
recordConsent,
|
|
151
|
+
getConsentStatus,
|
|
152
|
+
revokeConsent,
|
|
153
|
+
resetConsent,
|
|
154
|
+
clearConsentState,
|
|
155
|
+
setEmail,
|
|
156
|
+
isThrottled,
|
|
157
|
+
};
|