@birdapi/velinstyle 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +18 -4
- package/README.md +42 -7
- package/cli/cli-manifest.json +441 -174
- package/cli/docgen/extract-cli.js +2 -1
- package/cli/docs-generate.js +9 -0
- package/cli/experience.js +173 -0
- package/cli/index.js +1294 -417
- package/cli/motion.js +738 -0
- package/cli/production/extract.js +46 -6
- package/cli/production/report.js +104 -14
- package/cli/production/run.js +44 -1
- package/cli/security.js +190 -0
- package/cli/skills.js +53 -10
- package/cli/transparency.js +140 -41
- package/cli/workflow.js +32 -17
- package/components/velin-theme-toggle.js +15 -1
- package/core/a11y/component-contracts.json +391 -274
- package/core/attributes/registry.js +1 -1
- package/core/motion/analyze.js +38 -0
- package/core/motion/blueprint.js +51 -0
- package/core/motion/composer.js +114 -0
- package/core/motion/data/registry.json +352 -0
- package/core/motion/effects.js +17 -1
- package/core/motion/index.js +112 -9
- package/core/motion/optimize.js +44 -0
- package/core/motion/policy.js +77 -0
- package/core/motion/registry.js +131 -0
- package/core/motion/review.js +59 -0
- package/core/motion/scan.js +150 -0
- package/core/motion/timeline.js +68 -0
- package/core/motion/transitions.js +49 -0
- package/core/motion/triggers.js +168 -0
- package/core/security/checks/fs.js +92 -0
- package/core/security/detect.js +35 -0
- package/core/security/engine.js +116 -0
- package/core/security/index.js +18 -0
- package/core/security/registry.js +85 -0
- package/core/security/report/report.js +113 -0
- package/core/security/rules/ci/index.js +146 -0
- package/core/security/rules/consumer/index.js +174 -0
- package/core/security/rules/deps/index.js +182 -0
- package/core/security/rules/index.js +24 -0
- package/core/security/rules/publish/index.js +230 -0
- package/core/security/rules/repo/index.js +143 -0
- package/core/security/rules/secrets/index.js +133 -0
- package/core/security/score/dependency-health.js +53 -0
- package/core/security/score/release-health.js +66 -0
- package/core/security/score/score.js +102 -0
- package/core/security/timeline/timeline.js +76 -0
- package/dist/chunks/attributes-HK7VIOLA.js +1080 -0
- package/dist/chunks/attributes-VRHHVNDO.js +1080 -0
- package/dist/chunks/chunk-MYKUI364.js +116 -0
- package/dist/chunks/chunk-NEVBPD5T.js +116 -0
- package/dist/chunks/runtime-entry.js +1 -1
- package/dist/chunks/velin-theme-toggle-J2NHC7IM.js +304 -0
- package/dist/llms.txt +2 -2
- package/dist/search-index.json +166 -2
- package/dist/velin-agent.json +156 -32
- package/dist/velinstyle-components.iife.js +862 -17
- package/dist/velinstyle-components.js +862 -17
- package/dist/velinstyle-components.min.js +126 -126
- package/dist/velinstyle.css +42 -7
- package/dist/velinstyle.min.css +1 -1
- package/package.json +158 -143
- package/packages/velinstyle-cli-core/package.json +11 -0
- package/packages/velinstyle-cli-core/src/config.js +84 -0
- package/packages/velinstyle-cli-core/src/contract.js +56 -0
- package/packages/velinstyle-cli-core/src/event-bus.js +50 -0
- package/packages/velinstyle-cli-core/src/index.js +18 -0
- package/packages/velinstyle-cli-core/src/lifecycle.js +33 -0
- package/packages/velinstyle-cli-core/src/runner.js +129 -0
- package/packages/velinstyle-cli-registry/data/commands.json +1216 -0
- package/packages/velinstyle-cli-registry/data/layouts.json +33 -0
- package/packages/velinstyle-cli-registry/data/widgets.json +35 -0
- package/packages/velinstyle-cli-registry/package.json +13 -0
- package/packages/velinstyle-cli-registry/src/index.js +88 -0
- package/packages/velinstyle-cli-renderer/package.json +8 -0
- package/packages/velinstyle-cli-renderer/src/index.js +286 -0
- package/packages/velinstyle-cli-theme/package.json +8 -0
- package/packages/velinstyle-cli-theme/src/index.js +84 -0
- package/packages/velinstyle-cli-ui/package.json +8 -0
- package/packages/velinstyle-cli-ui/src/index.js +34 -0
- package/packages/velinstyle-motion/README.md +81 -0
- package/packages/velinstyle-motion/package.json +37 -0
- package/packages/velinstyle-motion/src/ai/mini-prompt.js +119 -0
- package/packages/velinstyle-motion/src/ai/provider.js +59 -0
- package/packages/velinstyle-motion/src/ai/providers/pixverse-setup.js +402 -0
- package/packages/velinstyle-motion/src/ai/providers/pixverse.js +245 -0
- package/packages/velinstyle-motion/src/config.js +257 -0
- package/packages/velinstyle-motion/src/export/html-clip.js +141 -0
- package/packages/velinstyle-motion/src/index.js +304 -0
- package/packages/velinstyle-motion/src/jobs/cache.js +30 -0
- package/packages/velinstyle-motion/src/jobs/queue.js +69 -0
- package/packages/velinstyle-motion/src/jobs/usage.js +53 -0
- package/packages/velinstyle-motion/src/local/effects.js +139 -0
- package/packages/velinstyle-motion/src/local/engine.js +96 -0
- package/packages/velinstyle-motion/src/providers-catalog.js +44 -0
- package/packages/velinstyle-motion/src/registry/presets.js +25 -0
- package/packages/velinstyle-motion/src/registry/presets.json +85 -0
- package/packages/velinstyle-motion/src/ux/cost-gate.js +37 -0
- package/packages/velinstyle-motion/src/ux/pixverse-gate.js +153 -0
- package/packages/velinstyle-motion/src/ux/wizard.js +171 -0
- package/packages/velinstyle-motion/src/validate-image.js +91 -0
- package/packages/velinstyle-skills/catalog.json +1 -1
- package/packages/velinstyle-skills/registry.json +1 -142
- package/packages/velinstyle-skills/skills/velin-motion-reduced-safe/SKILL.md +1 -1
- package/packages/velinstyle-skills/skills/velin-motion-reveal-feedback/SKILL.md +1 -1
- package/src/tokens/motion.css +7 -0
- package/src/utilities/scroll-animation.css +66 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline Engine — Scene → Timeline → Steps (Registry-backed).
|
|
3
|
+
*/
|
|
4
|
+
import { getTimeline } from './registry.js';
|
|
5
|
+
import { markVisible, prefersReducedMotion } from './effects.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a registered timeline against a root element.
|
|
9
|
+
* Steps resolve `[data-velin-step="id"]` or `#id` or `[velin-step="id"]`.
|
|
10
|
+
* @param {string} timelineId
|
|
11
|
+
* @param {ParentNode} root
|
|
12
|
+
* @param {{ stepMs?: number, policyDisabled?: boolean }} [opts]
|
|
13
|
+
* @returns {{ ok: boolean, error?: string, ran: string[] }}
|
|
14
|
+
*/
|
|
15
|
+
export function runTimeline(timelineId, root, opts = {}) {
|
|
16
|
+
const tl = getTimeline(timelineId);
|
|
17
|
+
if (!tl) return { ok: false, error: `Unknown timeline "${timelineId}"`, ran: [] };
|
|
18
|
+
const stepMs = opts.stepMs ?? 120;
|
|
19
|
+
const ran = [];
|
|
20
|
+
|
|
21
|
+
if (opts.policyDisabled || prefersReducedMotion()) {
|
|
22
|
+
for (const step of tl.steps || []) {
|
|
23
|
+
const el = resolveStep(root, step);
|
|
24
|
+
if (el) markVisible(el);
|
|
25
|
+
ran.push(step);
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, ran };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let delay = 0;
|
|
31
|
+
for (const step of tl.steps || []) {
|
|
32
|
+
const el = resolveStep(root, step);
|
|
33
|
+
if (!el) continue;
|
|
34
|
+
const d = delay;
|
|
35
|
+
delay += stepMs;
|
|
36
|
+
if (typeof window !== 'undefined' && window.setTimeout) {
|
|
37
|
+
window.setTimeout(() => markVisible(el), d);
|
|
38
|
+
} else {
|
|
39
|
+
markVisible(el);
|
|
40
|
+
}
|
|
41
|
+
ran.push(step);
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, ran, timeline: tl };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveStep(root, step) {
|
|
47
|
+
if (!root?.querySelector) return null;
|
|
48
|
+
return (
|
|
49
|
+
root.querySelector(`[data-velin-step="${step}"]`)
|
|
50
|
+
|| root.querySelector(`[velin-step="${step}"]`)
|
|
51
|
+
|| root.querySelector(`#${CSS.escape?.(step) || step}`)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enhance `[velin-timeline="id"]` roots.
|
|
57
|
+
*/
|
|
58
|
+
export function enhanceTimelines(root) {
|
|
59
|
+
if (!root?.querySelectorAll) return () => {};
|
|
60
|
+
const nodes = root.querySelectorAll('[velin-timeline]');
|
|
61
|
+
const teardowns = [];
|
|
62
|
+
for (const el of nodes) {
|
|
63
|
+
const id = el.getAttribute('velin-timeline');
|
|
64
|
+
const result = runTimeline(id, el);
|
|
65
|
+
teardowns.push(() => result);
|
|
66
|
+
}
|
|
67
|
+
return () => {};
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transition helpers (View Transition + overlay hooks).
|
|
3
|
+
*/
|
|
4
|
+
import { getMotion } from './registry.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run a document view transition if supported.
|
|
8
|
+
* @param {() => void|Promise<void>} updateCallback
|
|
9
|
+
* @param {{ name?: string }} [opts]
|
|
10
|
+
*/
|
|
11
|
+
export async function runViewTransition(updateCallback, opts = {}) {
|
|
12
|
+
const doc = typeof document !== 'undefined' ? document : null;
|
|
13
|
+
if (!doc) {
|
|
14
|
+
await updateCallback();
|
|
15
|
+
return { ok: true, mode: 'direct' };
|
|
16
|
+
}
|
|
17
|
+
if (typeof doc.startViewTransition === 'function') {
|
|
18
|
+
const vt = doc.startViewTransition(async () => {
|
|
19
|
+
if (opts.name && doc.documentElement) {
|
|
20
|
+
doc.documentElement.style.viewTransitionName = opts.name;
|
|
21
|
+
}
|
|
22
|
+
await updateCallback();
|
|
23
|
+
});
|
|
24
|
+
await vt.finished.catch(() => {});
|
|
25
|
+
return { ok: true, mode: 'view-transition', id: opts.name || null };
|
|
26
|
+
}
|
|
27
|
+
await updateCallback();
|
|
28
|
+
return { ok: true, mode: 'fallback' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTransitionPreset(id) {
|
|
32
|
+
return getMotion('transitions', id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Apply transition class hints for modal/drawer hosts.
|
|
37
|
+
* @param {HTMLElement} el
|
|
38
|
+
* @param {'modal-enter'|'drawer-enter'|'cross-fade'} id
|
|
39
|
+
*/
|
|
40
|
+
export function applyTransitionHint(el, id) {
|
|
41
|
+
if (!el) return;
|
|
42
|
+
const map = {
|
|
43
|
+
'modal-enter': 'velin-animate-fade-in',
|
|
44
|
+
'drawer-enter': 'velin-animate-on-scroll--slide-right',
|
|
45
|
+
'cross-fade': 'velin-animate-fade-in',
|
|
46
|
+
};
|
|
47
|
+
const cls = map[id];
|
|
48
|
+
if (cls) el.classList.add(cls);
|
|
49
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion Trigger Engine — when motion starts (First-Class, Studio-ready).
|
|
3
|
+
*/
|
|
4
|
+
import { observeInView } from './scheduler.js';
|
|
5
|
+
import { markVisible, prefersReducedMotion } from './effects.js';
|
|
6
|
+
import { getTrigger } from './registry.js';
|
|
7
|
+
|
|
8
|
+
export const TRIGGER_V1 = ['load', 'visible', 'hover', 'focus', 'click', 'scroll'];
|
|
9
|
+
export const TRIGGER_V2 = ['hidden', 'idle', 'media', 'container', 'theme', 'custom'];
|
|
10
|
+
export const ALL_TRIGGERS = [...TRIGGER_V1, ...TRIGGER_V2];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read trigger id from element (velin-trigger or default).
|
|
14
|
+
* @param {HTMLElement} el
|
|
15
|
+
* @param {string} [fallback='visible']
|
|
16
|
+
*/
|
|
17
|
+
export function readTrigger(el, fallback = 'visible') {
|
|
18
|
+
const raw = (el.getAttribute('velin-trigger') || fallback || 'visible').trim().toLowerCase();
|
|
19
|
+
return raw || fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bind a trigger to run `onFire` once (or per event).
|
|
24
|
+
* @param {HTMLElement} el
|
|
25
|
+
* @param {string} triggerId
|
|
26
|
+
* @param {() => void} onFire
|
|
27
|
+
* @param {{ once?: boolean, reduced?: boolean }} [opts]
|
|
28
|
+
* @returns {() => void} teardown
|
|
29
|
+
*/
|
|
30
|
+
export function bindTrigger(el, triggerId, onFire, opts = {}) {
|
|
31
|
+
const once = opts.once !== false;
|
|
32
|
+
const reduced = opts.reduced ?? prefersReducedMotion();
|
|
33
|
+
const id = (triggerId || 'visible').toLowerCase();
|
|
34
|
+
const meta = getTrigger(id);
|
|
35
|
+
|
|
36
|
+
if (reduced || el.dataset.velinMotion === 'off') {
|
|
37
|
+
onFire();
|
|
38
|
+
return () => {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (meta?.status === 'planned' && !TRIGGER_V1.includes(id)) {
|
|
42
|
+
// Planned V2 triggers are not first-class yet: fall back to visible for runtime safety
|
|
43
|
+
return bindTrigger(el, 'visible', onFire, opts);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let fired = false;
|
|
47
|
+
const fire = () => {
|
|
48
|
+
if (once && fired) return;
|
|
49
|
+
fired = true;
|
|
50
|
+
onFire();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (id === 'load') {
|
|
54
|
+
fire();
|
|
55
|
+
return () => {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (id === 'visible' || id === 'scroll') {
|
|
59
|
+
return observeInView(el, () => fire());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (id === 'hover') {
|
|
63
|
+
const enter = () => fire();
|
|
64
|
+
el.addEventListener('pointerenter', enter);
|
|
65
|
+
return () => el.removeEventListener('pointerenter', enter);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (id === 'focus') {
|
|
69
|
+
const onFocus = () => fire();
|
|
70
|
+
el.addEventListener('focusin', onFocus);
|
|
71
|
+
return () => el.removeEventListener('focusin', onFocus);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (id === 'click') {
|
|
75
|
+
const onClick = () => fire();
|
|
76
|
+
el.addEventListener('click', onClick);
|
|
77
|
+
return () => el.removeEventListener('click', onClick);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (id === 'hidden') {
|
|
81
|
+
const stop = observeInView(el, () => {
|
|
82
|
+
/* enter */
|
|
83
|
+
});
|
|
84
|
+
// Fallback: use IntersectionObserver with leave
|
|
85
|
+
if (typeof IntersectionObserver === 'undefined') return () => {};
|
|
86
|
+
const io = new IntersectionObserver((entries) => {
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (!entry.isIntersecting) fire();
|
|
89
|
+
}
|
|
90
|
+
}, { threshold: 0 });
|
|
91
|
+
io.observe(el);
|
|
92
|
+
return () => {
|
|
93
|
+
stop();
|
|
94
|
+
io.disconnect();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (id === 'idle') {
|
|
99
|
+
const ms = Number(el.getAttribute('velin-trigger-delay') || 3000);
|
|
100
|
+
const t = setTimeout(fire, ms);
|
|
101
|
+
return () => clearTimeout(t);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (id === 'media') {
|
|
105
|
+
const q = el.getAttribute('velin-trigger-media') || '(min-width: 768px)';
|
|
106
|
+
if (typeof window === 'undefined' || !window.matchMedia) {
|
|
107
|
+
fire();
|
|
108
|
+
return () => {};
|
|
109
|
+
}
|
|
110
|
+
const mql = window.matchMedia(q);
|
|
111
|
+
const handler = () => {
|
|
112
|
+
if (mql.matches) fire();
|
|
113
|
+
};
|
|
114
|
+
handler();
|
|
115
|
+
mql.addEventListener?.('change', handler);
|
|
116
|
+
return () => mql.removeEventListener?.('change', handler);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (id === 'theme') {
|
|
120
|
+
const root = el.ownerDocument?.documentElement;
|
|
121
|
+
if (!root || typeof MutationObserver === 'undefined') {
|
|
122
|
+
fire();
|
|
123
|
+
return () => {};
|
|
124
|
+
}
|
|
125
|
+
const mo = new MutationObserver(() => fire());
|
|
126
|
+
mo.observe(root, { attributes: true, attributeFilter: ['data-velin-theme', 'class'] });
|
|
127
|
+
return () => mo.disconnect();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (id === 'custom') {
|
|
131
|
+
const evt = el.getAttribute('velin-trigger-event') || 'velin:motion';
|
|
132
|
+
const handler = () => fire();
|
|
133
|
+
el.addEventListener(evt, handler);
|
|
134
|
+
return () => el.removeEventListener(evt, handler);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (id === 'container') {
|
|
138
|
+
// Approximate with visible until container queries runtime ships
|
|
139
|
+
return observeInView(el, () => fire());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Unknown → visible
|
|
143
|
+
return observeInView(el, () => fire());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Apply motion classes then bind trigger to markVisible.
|
|
148
|
+
* @param {HTMLElement} el
|
|
149
|
+
* @param {() => void} applyPreset
|
|
150
|
+
*/
|
|
151
|
+
export function armMotionElement(el, applyPreset) {
|
|
152
|
+
applyPreset();
|
|
153
|
+
const trigger = readTrigger(el, el.hasAttribute('velin-hover') ? 'hover' : 'visible');
|
|
154
|
+
if (prefersReducedMotion() || el.dataset.velinMotion === 'off') {
|
|
155
|
+
markVisible(el);
|
|
156
|
+
return () => {};
|
|
157
|
+
}
|
|
158
|
+
if (trigger === 'hover' || trigger === 'focus') {
|
|
159
|
+
// CSS hover classes already apply; still mark visible for scroll classes
|
|
160
|
+
markVisible(el);
|
|
161
|
+
return () => {};
|
|
162
|
+
}
|
|
163
|
+
if (trigger === 'load') {
|
|
164
|
+
markVisible(el);
|
|
165
|
+
return () => {};
|
|
166
|
+
}
|
|
167
|
+
return bindTrigger(el, trigger, () => markVisible(el));
|
|
168
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared FS helpers for Security Engine rules.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
5
|
+
import { join, relative } from 'path';
|
|
6
|
+
|
|
7
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'vendor', 'coverage', '.velin']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} root
|
|
11
|
+
* @param {(name: string, abs: string) => boolean} [filter]
|
|
12
|
+
* @param {number} [max=400]
|
|
13
|
+
*/
|
|
14
|
+
export function walkFiles(root, filter, max = 400) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const walk = (dir) => {
|
|
17
|
+
if (out.length >= max) return;
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = readdirSync(dir);
|
|
21
|
+
} catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const name of entries) {
|
|
25
|
+
if (SKIP.has(name)) continue;
|
|
26
|
+
const abs = join(dir, name);
|
|
27
|
+
let st;
|
|
28
|
+
try {
|
|
29
|
+
st = statSync(abs);
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (st.isDirectory()) walk(abs);
|
|
34
|
+
else if (!filter || filter(name, abs)) out.push(abs);
|
|
35
|
+
if (out.length >= max) return;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
if (existsSync(root)) walk(root);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readText(path) {
|
|
43
|
+
try {
|
|
44
|
+
return readFileSync(path, 'utf-8');
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readJson(path) {
|
|
51
|
+
const t = readText(path);
|
|
52
|
+
if (!t) return null;
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(t);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rel(root, abs) {
|
|
61
|
+
return relative(root, abs).replace(/\\/g, '/');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function fileExists(path) {
|
|
65
|
+
return existsSync(path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lightweight YAML-ish peek for GitHub Actions (no full parser).
|
|
70
|
+
* @param {string} text
|
|
71
|
+
*/
|
|
72
|
+
export function workflowHints(text) {
|
|
73
|
+
const lines = text.split(/\r?\n/);
|
|
74
|
+
const uses = [];
|
|
75
|
+
let hasPullRequestTarget = /pull_request_target\s*:/.test(text);
|
|
76
|
+
let hasPermissions = /^\s*permissions\s*:/m.test(text);
|
|
77
|
+
let hasIdToken = /id-token\s*:\s*write/.test(text);
|
|
78
|
+
let mentionsNpmToken = /NPM_TOKEN|NODE_AUTH_TOKEN/.test(text);
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const m = line.match(/^\s*-\s*uses:\s*(.+)\s*$/);
|
|
81
|
+
if (m) uses.push(m[1].trim().replace(/['"]/g, ''));
|
|
82
|
+
}
|
|
83
|
+
return { uses, hasPullRequestTarget, hasPermissions, hasIdToken, mentionsNpmToken };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Pin check: owner/action@sha40 vs @v1 */
|
|
87
|
+
export function isShaPinned(usesRef) {
|
|
88
|
+
const at = usesRef.lastIndexOf('@');
|
|
89
|
+
if (at < 0) return false;
|
|
90
|
+
const ref = usesRef.slice(at + 1);
|
|
91
|
+
return /^[0-9a-f]{40}$/i.test(ref);
|
|
92
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect repo vs consumer vs both for Security Engine.
|
|
3
|
+
*/
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { fileExists, readJson } from './checks/fs.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} root
|
|
9
|
+
* @returns {'repo'|'consumer'|'both'}
|
|
10
|
+
*/
|
|
11
|
+
export function detectSecurityMode(root) {
|
|
12
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
13
|
+
const isFramework =
|
|
14
|
+
pkg?.name === '@birdapi/velinstyle'
|
|
15
|
+
|| fileExists(join(root, '.github', 'workflows', 'publish-npm.yml'))
|
|
16
|
+
|| (fileExists(join(root, 'SECURITY.md')) && fileExists(join(root, 'cli', 'index.js')));
|
|
17
|
+
|
|
18
|
+
const hasVelinDep =
|
|
19
|
+
Boolean(pkg?.dependencies?.['@birdapi/velinstyle'] || pkg?.devDependencies?.['@birdapi/velinstyle'])
|
|
20
|
+
|| fileExists(join(root, 'vendor', 'velinstyle'))
|
|
21
|
+
|| fileExists(join(root, 'vendor', 'velinstyle.min.css'));
|
|
22
|
+
|
|
23
|
+
const consumerMarkers =
|
|
24
|
+
fileExists(join(root, 'artisan'))
|
|
25
|
+
|| fileExists(join(root, 'composer.json'))
|
|
26
|
+
|| hasVelinDep
|
|
27
|
+
|| Boolean(pkg && pkg.name !== '@birdapi/velinstyle');
|
|
28
|
+
|
|
29
|
+
if (isFramework && consumerMarkers && hasVelinDep && pkg?.name !== '@birdapi/velinstyle') {
|
|
30
|
+
return 'both';
|
|
31
|
+
}
|
|
32
|
+
if (isFramework) return 'repo';
|
|
33
|
+
if (consumerMarkers || pkg) return 'consumer';
|
|
34
|
+
return 'consumer';
|
|
35
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Velin Security Engine — run registered rules.
|
|
3
|
+
*/
|
|
4
|
+
import { selectSecurityRules } from './registry.js';
|
|
5
|
+
import { loadBuiltinSecurityRules } from './rules/index.js';
|
|
6
|
+
import { detectSecurityMode } from './detect.js';
|
|
7
|
+
import { scoreFindings } from './score/score.js';
|
|
8
|
+
import { computeDependencyHealth } from './score/dependency-health.js';
|
|
9
|
+
import { computeReleaseHealth } from './score/release-health.js';
|
|
10
|
+
import { loadTimeline, touchTimeline } from './timeline/timeline.js';
|
|
11
|
+
import { resolve } from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {string} [opts.root]
|
|
16
|
+
* @param {string} [opts.mode] — doctor|repo|consumer|ci|publish|deps|secrets|scan|audit
|
|
17
|
+
* @param {string[]} [opts.categories]
|
|
18
|
+
* @param {boolean} [opts.updateTimeline=true]
|
|
19
|
+
* @param {boolean} [opts.includeReleaseHealth]
|
|
20
|
+
* @param {boolean} [opts.includeDependencyHealth]
|
|
21
|
+
*/
|
|
22
|
+
export async function runSecurityEngine(opts = {}) {
|
|
23
|
+
loadBuiltinSecurityRules();
|
|
24
|
+
const root = resolve(opts.root || '.');
|
|
25
|
+
let mode = opts.mode || 'doctor';
|
|
26
|
+
|
|
27
|
+
let detected = detectSecurityMode(root);
|
|
28
|
+
if (mode === 'doctor') {
|
|
29
|
+
mode = detected === 'both' ? 'audit' : detected;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const categoryMap = {
|
|
33
|
+
deps: ['deps', 'malware'],
|
|
34
|
+
secrets: ['secrets'],
|
|
35
|
+
ci: ['ci'],
|
|
36
|
+
publish: ['publish', 'repo', 'secrets', 'deps', 'malware'],
|
|
37
|
+
scan: ['consumer', 'secrets', 'repo', 'malware'],
|
|
38
|
+
repo: ['repo', 'ci', 'secrets', 'deps', 'publish', 'malware'],
|
|
39
|
+
consumer: ['consumer', 'secrets', 'deps', 'malware'],
|
|
40
|
+
audit: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const categories = opts.categories || categoryMap[mode] || null;
|
|
44
|
+
const rules = selectSecurityRules({
|
|
45
|
+
mode: mode === 'audit' ? 'audit' : mode,
|
|
46
|
+
categories: categories || undefined,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const ctx = { root, mode, categories: categories || undefined, meta: { detected } };
|
|
50
|
+
/** @type {import('./registry.js').SecurityFinding[]} */
|
|
51
|
+
const findings = [];
|
|
52
|
+
|
|
53
|
+
for (const rule of rules) {
|
|
54
|
+
try {
|
|
55
|
+
const out = await rule.run(ctx);
|
|
56
|
+
for (const f of out || []) {
|
|
57
|
+
findings.push({
|
|
58
|
+
category: rule.category,
|
|
59
|
+
...f,
|
|
60
|
+
ruleId: f.ruleId || rule.id,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
findings.push({
|
|
65
|
+
ruleId: rule.id,
|
|
66
|
+
category: rule.category,
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
message: `Rule failed: ${err?.message || err}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const scored = scoreFindings(findings);
|
|
74
|
+
const includeDep = opts.includeDependencyHealth !== false;
|
|
75
|
+
const includeRel =
|
|
76
|
+
opts.includeReleaseHealth === true
|
|
77
|
+
|| mode === 'publish'
|
|
78
|
+
|| (mode === 'audit' && detected === 'repo');
|
|
79
|
+
|
|
80
|
+
const dependencyHealth = includeDep ? computeDependencyHealth(root, findings) : null;
|
|
81
|
+
const releaseHealth = includeRel ? computeReleaseHealth(root, findings) : null;
|
|
82
|
+
|
|
83
|
+
const touched = [];
|
|
84
|
+
if (mode === 'ci' || categories?.includes('ci')) touched.push('ci');
|
|
85
|
+
if (mode === 'deps' || categories?.includes('deps')) touched.push('dependencies');
|
|
86
|
+
if (mode === 'secrets' || categories?.includes('secrets')) touched.push('secrets');
|
|
87
|
+
if (mode === 'publish') touched.push('publish');
|
|
88
|
+
if (mode === 'repo' || detected === 'repo') touched.push('repository');
|
|
89
|
+
if (mode === 'consumer' || detected === 'consumer') touched.push('consumer');
|
|
90
|
+
if (mode === 'audit' || mode === 'scan') touched.push('audit');
|
|
91
|
+
|
|
92
|
+
let timeline = loadTimeline(root);
|
|
93
|
+
if (opts.updateTimeline !== false && touched.length) {
|
|
94
|
+
timeline = touchTimeline(root, touched);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...scored,
|
|
99
|
+
root,
|
|
100
|
+
mode,
|
|
101
|
+
detected,
|
|
102
|
+
rulesRun: rules.length,
|
|
103
|
+
dependencyHealth,
|
|
104
|
+
releaseHealth,
|
|
105
|
+
timeline,
|
|
106
|
+
ok: scored.overallGate !== 'fail',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createSecurityEngine(options = {}) {
|
|
111
|
+
loadBuiltinSecurityRules();
|
|
112
|
+
return {
|
|
113
|
+
detect: (root) => detectSecurityMode(resolve(root || options.root || '.')),
|
|
114
|
+
run: (opts = {}) => runSecurityEngine({ ...options, ...opts }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Velin Security Engine — public API.
|
|
3
|
+
*/
|
|
4
|
+
export {
|
|
5
|
+
registerSecurityRule,
|
|
6
|
+
clearSecurityRules,
|
|
7
|
+
listSecurityRules,
|
|
8
|
+
getSecurityRule,
|
|
9
|
+
selectSecurityRules,
|
|
10
|
+
} from './registry.js';
|
|
11
|
+
export { detectSecurityMode } from './detect.js';
|
|
12
|
+
export { runSecurityEngine, createSecurityEngine } from './engine.js';
|
|
13
|
+
export { scoreFindings, gateLabel } from './score/score.js';
|
|
14
|
+
export { computeDependencyHealth } from './score/dependency-health.js';
|
|
15
|
+
export { computeReleaseHealth } from './score/release-health.js';
|
|
16
|
+
export { loadTimeline, touchTimeline, timelineRows, timelinePath } from './timeline/timeline.js';
|
|
17
|
+
export { toExperiencePayload, toMarkdownReport } from './report/report.js';
|
|
18
|
+
export { loadBuiltinSecurityRules } from './rules/index.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Velin Security Engine — rule registry (Studio-/CLI-ready).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @typedef {'ci'|'secrets'|'deps'|'publish'|'repo'|'consumer'|'malware'} SecurityCategory */
|
|
6
|
+
/** @typedef {'info'|'warning'|'error'} SecuritySeverity */
|
|
7
|
+
/** @typedef {'repo'|'consumer'|'ci'|'publish'|'deps'|'secrets'|'scan'|'audit'} SecurityMode */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} SecurityFinding
|
|
11
|
+
* @property {string} ruleId
|
|
12
|
+
* @property {SecuritySeverity} severity
|
|
13
|
+
* @property {string} message
|
|
14
|
+
* @property {string} [path]
|
|
15
|
+
* @property {string} [fix]
|
|
16
|
+
* @property {string} [code]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} SecurityRule
|
|
21
|
+
* @property {string} id
|
|
22
|
+
* @property {SecurityCategory} category
|
|
23
|
+
* @property {SecuritySeverity} severity
|
|
24
|
+
* @property {SecurityMode[]} [modes]
|
|
25
|
+
* @property {string} [title]
|
|
26
|
+
* @property {string} [description]
|
|
27
|
+
* @property {(ctx: SecurityContext) => Promise<SecurityFinding[]>|SecurityFinding[]} run
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} SecurityContext
|
|
32
|
+
* @property {string} root
|
|
33
|
+
* @property {SecurityMode} mode
|
|
34
|
+
* @property {string[]} [categories]
|
|
35
|
+
* @property {import('fs').PathLike} [fs]
|
|
36
|
+
* @property {Record<string, unknown>} [meta]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @type {Map<string, SecurityRule>} */
|
|
40
|
+
const RULES = new Map();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {SecurityRule} rule
|
|
44
|
+
*/
|
|
45
|
+
export function registerSecurityRule(rule) {
|
|
46
|
+
if (!rule?.id || typeof rule.run !== 'function') {
|
|
47
|
+
throw new Error('registerSecurityRule: id and run() required');
|
|
48
|
+
}
|
|
49
|
+
RULES.set(rule.id, {
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
modes: ['repo', 'consumer', 'audit'],
|
|
52
|
+
title: rule.id,
|
|
53
|
+
description: '',
|
|
54
|
+
...rule,
|
|
55
|
+
});
|
|
56
|
+
return rule.id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearSecurityRules() {
|
|
60
|
+
RULES.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listSecurityRules() {
|
|
64
|
+
return [...RULES.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getSecurityRule(id) {
|
|
68
|
+
return RULES.get(id) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {{ mode?: SecurityMode, categories?: string[] }} [filter]
|
|
73
|
+
*/
|
|
74
|
+
export function selectSecurityRules(filter = {}) {
|
|
75
|
+
const mode = filter.mode;
|
|
76
|
+
const cats = filter.categories?.length ? new Set(filter.categories) : null;
|
|
77
|
+
return listSecurityRules().filter((r) => {
|
|
78
|
+
if (cats && !cats.has(r.category)) return false;
|
|
79
|
+
if (!mode) return true;
|
|
80
|
+
const modes = r.modes || [];
|
|
81
|
+
if (mode === 'audit' || mode === 'doctor') return true;
|
|
82
|
+
if (mode === 'scan') return modes.includes('scan') || modes.includes('consumer') || modes.includes('secrets');
|
|
83
|
+
return modes.includes(mode) || modes.includes('audit');
|
|
84
|
+
});
|
|
85
|
+
}
|