cursedbelt 4.7.0 → 5.0.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/README.md +36 -0
- package/dist/react/chat/index.d.ts +1 -0
- package/dist/react/chat/index.d.ts.map +1 -1
- package/dist/react/chat/index.js +1 -0
- package/dist/react/chat/index.js.map +1 -1
- package/dist/react/chat/usePresence.d.ts +75 -0
- package/dist/react/chat/usePresence.d.ts.map +1 -0
- package/dist/react/chat/usePresence.js +109 -0
- package/dist/react/chat/usePresence.js.map +1 -0
- package/dist/react/components/SecretInput.d.ts +7 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +5 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +52 -3
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/emoji/EmojiPicker.d.ts.map +1 -1
- package/dist/react/emoji/EmojiPicker.js +49 -8
- package/dist/react/emoji/EmojiPicker.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +2 -2
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +4 -41
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/lib/zodResolver.d.ts +9 -0
- package/dist/react/lib/zodResolver.d.ts.map +1 -0
- package/dist/react/lib/zodResolver.js +105 -0
- package/dist/react/lib/zodResolver.js.map +1 -0
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +2 -1
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +14 -20
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/MediaUploadControl.d.ts +3 -7
- package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
- package/dist/react/media/MediaUploadControl.js +4 -13
- package/dist/react/media/MediaUploadControl.js.map +1 -1
- package/dist/react/media/fileUploadClient.d.ts +0 -8
- package/dist/react/media/fileUploadClient.d.ts.map +1 -1
- package/dist/react/media/fileUploadClient.js +0 -8
- package/dist/react/media/fileUploadClient.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +4 -2
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +0 -4
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +0 -4
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/sessionAuth.d.ts +1 -2
- package/dist/react/sessionAuth.d.ts.map +1 -1
- package/dist/react/sessionAuth.js +2 -3
- package/dist/react/sessionAuth.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/activity-bar.css +1 -1
- package/dist/styles-areas/analytics.css +1 -1
- package/dist/styles-areas/auth.css +1 -1
- package/dist/styles-areas/charts.css +1 -1
- package/dist/styles-areas/chat.css +1 -1
- package/dist/styles-areas/core.css +1 -1
- package/dist/styles-areas/dashboard-grid.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/deep-link.css +1 -1
- package/dist/styles-areas/disk-usage.css +1 -1
- package/dist/styles-areas/emoji.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/file-tree.css +1 -1
- package/dist/styles-areas/keep-awake.css +1 -1
- package/dist/styles-areas/layout-engine.css +1 -1
- package/dist/styles-areas/markdown.css +1 -1
- package/dist/styles-areas/master-detail.css +1 -1
- package/dist/styles-areas/master-lock.css +108 -2
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -17
- package/dist/styles-areas/nav.css +1 -1
- package/dist/styles-areas/notifications.css +1 -1
- package/dist/styles-areas/overlays.css +1 -1
- package/dist/styles-areas/palette.css +1 -1
- package/dist/styles-areas/spreadsheet.css +1 -1
- package/dist/styles-areas/virtual.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/dist/styles-areas/workbench.css +1 -1
- package/dist/styles-areas/workbook-viewer.css +1 -1
- package/dist/styles-utilities.css +0 -16
- package/dist/testing/_noBun.d.ts +2 -0
- package/dist/testing/_noBun.d.ts.map +1 -0
- package/dist/testing/_noBun.js +10 -0
- package/dist/testing/_noBun.js.map +1 -0
- package/package.json +54 -20
- package/scripts/checkAreaStyles.spec.ts +71 -0
- package/scripts/checkAreaStyles.ts +150 -0
- package/scripts/guardrailsEnforce.spec.ts +43 -0
- package/scripts/guardrailsEnforce.ts +46 -1
- package/scripts/styleAreas.ts +1 -2
- package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
- package/src/bundlerOnlyEntries.spec.ts +101 -0
- package/src/declaredImports.spec.ts +4 -0
- package/src/optionalPeersTable.spec.ts +127 -0
- package/src/react/chat/index.ts +8 -0
- package/src/react/chat/usePresence.spec.tsx +138 -0
- package/src/react/chat/usePresence.ts +185 -0
- package/src/react/components/SecretInput.tsx +12 -1
- package/src/react/components/TooltipImpl.tsx +54 -5
- package/src/react/emoji/EmojiPicker.tsx +70 -19
- package/src/react/index.ts +8 -0
- package/src/react/lib/form.ts +5 -48
- package/src/react/lib/zodResolver.spec.ts +82 -0
- package/src/react/lib/zodResolver.ts +136 -0
- package/src/react/master-lock/MasterLockDialog.tsx +13 -9
- package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
- package/src/react/master-lock/MasterLockProvider.tsx +16 -19
- package/src/react/media/MediaUploadControl.tsx +8 -29
- package/src/react/media/fileUploadClient.ts +7 -17
- package/src/react/media/hlsSource.ts +4 -1
- package/src/react/media/index.ts +0 -16
- package/src/react/sessionAuth.ts +2 -3
- package/src/scriptsResolve.spec.ts +66 -0
- package/src/styles-areas/activity-bar.css +1 -1
- package/src/styles-areas/analytics.css +1 -1
- package/src/styles-areas/auth.css +1 -1
- package/src/styles-areas/charts.css +1 -1
- package/src/styles-areas/chat.css +1 -1
- package/src/styles-areas/core.css +1 -1
- package/src/styles-areas/dashboard-grid.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/deep-link.css +1 -1
- package/src/styles-areas/disk-usage.css +1 -1
- package/src/styles-areas/emoji.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/file-tree.css +1 -1
- package/src/styles-areas/keep-awake.css +1 -1
- package/src/styles-areas/layout-engine.css +1 -1
- package/src/styles-areas/markdown.css +1 -1
- package/src/styles-areas/master-detail.css +1 -1
- package/src/styles-areas/master-lock.css +108 -2
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -17
- package/src/styles-areas/nav.css +1 -1
- package/src/styles-areas/notifications.css +1 -1
- package/src/styles-areas/overlays.css +1 -1
- package/src/styles-areas/palette.css +1 -1
- package/src/styles-areas/spreadsheet.css +1 -1
- package/src/styles-areas/virtual.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/styles-areas/workbench.css +1 -1
- package/src/styles-areas/workbook-viewer.css +1 -1
- package/src/styles-utilities.css +0 -16
- package/src/testing/_noBun.ts +12 -0
- package/src/unpeeredStaysOffTheBarrel.spec.ts +99 -0
- package/src/verifyGraph.spec.ts +11 -0
- package/dist/react/media/PrivateIndexView.d.ts +0 -6
- package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
- package/dist/react/media/PrivateIndexView.js +0 -44
- package/dist/react/media/PrivateIndexView.js.map +0 -1
- package/dist/react/media/PrivateModeBadge.d.ts +0 -17
- package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
- package/dist/react/media/PrivateModeBadge.js +0 -24
- package/dist/react/media/PrivateModeBadge.js.map +0 -1
- package/dist/react/media/privateClient.d.ts +0 -34
- package/dist/react/media/privateClient.d.ts.map +0 -1
- package/dist/react/media/privateClient.js +0 -36
- package/dist/react/media/privateClient.js.map +0 -1
- package/dist/react/media/usePrivateUnlock.d.ts +0 -25
- package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
- package/dist/react/media/usePrivateUnlock.js +0 -68
- package/dist/react/media/usePrivateUnlock.js.map +0 -1
- package/src/react/media/PrivateIndexView.tsx +0 -113
- package/src/react/media/PrivateModeBadge.tsx +0 -50
- package/src/react/media/privateClient.spec.ts +0 -113
- package/src/react/media/privateClient.ts +0 -82
- package/src/react/media/usePrivateUnlock.ts +0 -132
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 `./zodResolver.ts` must answer EXACTLY what `@hookform/resolvers/zod` answers — it replaced
|
|
3
|
+
* that package in 5.0.1 so that no consumer has to install it (see the module header for why
|
|
4
|
+
* every way of depending on it broke somebody). `@hookform/resolvers` stays a devDependency for
|
|
5
|
+
* one reason: to be the oracle here. Each case runs both resolvers over one schema and one input
|
|
6
|
+
* and compares the whole result, `ref`s and all.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { zodResolver as upstream } from '@hookform/resolvers/zod';
|
|
10
|
+
import type { FieldValues, ResolverOptions } from 'react-hook-form';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { zodResolver } from './zodResolver.js';
|
|
13
|
+
|
|
14
|
+
const options = (over: Partial<ResolverOptions<FieldValues>> = {}): ResolverOptions<FieldValues> =>
|
|
15
|
+
({ fields: {}, shouldUseNativeValidation: false, ...over }) as ResolverOptions<FieldValues>;
|
|
16
|
+
|
|
17
|
+
async function both(schema: z.ZodType, values: FieldValues, opts = options()) {
|
|
18
|
+
const ours = await zodResolver(schema)(values, undefined, opts);
|
|
19
|
+
const theirs = await (upstream as unknown as (s: z.ZodType) => typeof zodResolver extends (s: z.ZodType) => infer R ? R : never)(schema)(values, undefined, opts);
|
|
20
|
+
return { ours, theirs };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const account = z.object({
|
|
24
|
+
name: z.string().min(2, 'too short'),
|
|
25
|
+
email: z.email('not an email'),
|
|
26
|
+
age: z.number().int().min(18, 'adults only'),
|
|
27
|
+
address: z.object({ city: z.string().min(1, 'city required'), zip: z.string().regex(/^\d{5}$/, 'five digits') }),
|
|
28
|
+
tags: z.array(z.string().min(1, 'empty tag')).min(1, 'at least one tag'),
|
|
29
|
+
contact: z.union([z.object({ kind: z.literal('phone'), phone: z.string().min(7, 'phone too short') }), z.object({ kind: z.literal('mail'), mail: z.email('bad mail') })]),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('zodResolver matches @hookform/resolvers/zod', () => {
|
|
33
|
+
test('valid input: parsed output as values, no errors', async () => {
|
|
34
|
+
const schema = z.object({ n: z.coerce.number(), s: z.string().trim() });
|
|
35
|
+
const { ours, theirs } = await both(schema, { n: '42', s: ' x ' });
|
|
36
|
+
expect(ours).toEqual(theirs);
|
|
37
|
+
expect(ours.values).toEqual({ n: 42, s: 'x' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('flat errors', async () => {
|
|
41
|
+
const { ours, theirs } = await both(account, { name: 'a', email: 'nope', age: 12, address: { city: '', zip: '1' }, tags: [], contact: { kind: 'phone', phone: '1' } });
|
|
42
|
+
expect(ours).toEqual(theirs);
|
|
43
|
+
expect(Object.keys(ours.errors).sort()).toEqual(['address', 'age', 'contact', 'email', 'name', 'tags']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('nested paths and array items nest, not dot-join', async () => {
|
|
47
|
+
const { ours, theirs } = await both(account, { name: 'ok', email: 'a@b.co', age: 30, address: { city: 'x', zip: 'abc' }, tags: ['a', ''], contact: { kind: 'mail', mail: 'x' } });
|
|
48
|
+
expect(ours).toEqual(theirs);
|
|
49
|
+
expect((ours.errors as Record<string, Record<string, unknown>>).address?.zip).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('criteriaMode "all" collects every failing rule per field', async () => {
|
|
53
|
+
const schema = z.object({ pw: z.string().min(8, 'eight').regex(/\d/, 'a digit').regex(/[A-Z]/, 'a capital') });
|
|
54
|
+
const { ours, theirs } = await both(schema, { pw: 'abc' }, options({ criteriaMode: 'all' }));
|
|
55
|
+
expect(ours).toEqual(theirs);
|
|
56
|
+
expect(Object.keys((ours.errors as Record<string, { types: object }>).pw?.types ?? {}).length).toBeGreaterThan(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a field-array ROOT error lands on `.root` when its items are registered', async () => {
|
|
60
|
+
const schema = z.object({ items: z.array(z.object({ v: z.string() })).min(2, 'two please') });
|
|
61
|
+
const opts = options({ names: ['items.0.v'] });
|
|
62
|
+
const { ours, theirs } = await both(schema, { items: [{ v: 'a' }] }, opts);
|
|
63
|
+
expect(ours).toEqual(theirs);
|
|
64
|
+
expect((ours.errors as Record<string, { root?: unknown }>).items?.root).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a field ref is carried onto its error, as react-hook-form focuses it', async () => {
|
|
68
|
+
const ref = { name: 'name' };
|
|
69
|
+
const opts = options({ fields: { name: { ref, name: 'name' } } as unknown as ResolverOptions<FieldValues>['fields'] });
|
|
70
|
+
const { ours, theirs } = await both(account.pick({ name: true }), { name: '' }, opts);
|
|
71
|
+
expect(ours).toEqual(theirs);
|
|
72
|
+
expect((ours.errors as Record<string, { ref?: unknown }>).name?.ref).toBe(ref);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a non-zod throw from the schema is rethrown, by both', async () => {
|
|
76
|
+
const schema = z.string().transform(() => {
|
|
77
|
+
throw new TypeError('boom');
|
|
78
|
+
});
|
|
79
|
+
await expect(zodResolver(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
|
|
80
|
+
await expect((upstream as unknown as (s: z.ZodType) => ReturnType<typeof zodResolver>)(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { appendErrors, type FieldError, type FieldErrors, type FieldValues, get, type Resolver, type ResolverOptions, set } from 'react-hook-form';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 🔴 react-hook-form's zod resolver, owned here instead of imported from `@hookform/resolvers`
|
|
6
|
+
* (cursedbelt 5.0.1). What that package cost, and why neither way of depending on it worked:
|
|
7
|
+
*
|
|
8
|
+
* · As an optional PEER, `npm install cursedbelt` failed ERESOLVE (task 2087): npm checks an
|
|
9
|
+
* optional peer's own peer ranges, and `@hookform/resolvers@5` declares ~25 of them.
|
|
10
|
+
* · As `unpeered` (5.0.0), every consumer's clean-install `vite build` failed:
|
|
11
|
+
* `Rollup failed to resolve import "@hookform/resolvers/zod"`. Vite stubs a missing import
|
|
12
|
+
* only when it is a DECLARED optional peer, and a `import()` with a literal specifier is
|
|
13
|
+
* still resolved at build time. Primary checkouts hid it because their `node_modules` still
|
|
14
|
+
* held the 4.x copy.
|
|
15
|
+
* · A variable specifier (`import(/* @vite-ignore *\/ spec)`) builds, and then fails in the
|
|
16
|
+
* BROWSER for every app that does install the package — a bare specifier has nothing to
|
|
17
|
+
* resolve it at run time.
|
|
18
|
+
*
|
|
19
|
+
* So this is the zod-4 half of `@hookform/resolvers/zod` 5.9.1 and its `toNestErrors` /
|
|
20
|
+
* `validateFieldsNatively` (MIT, © 2019-present Beier (Bill) Luo), reduced to the one schema
|
|
21
|
+
* library this package peers on (`zod ^4.4.0`) and the one call shape {@link makeFormConfig}
|
|
22
|
+
* makes. `react-hook-form` is already a dependency, and supplies `get`, `set` and
|
|
23
|
+
* `appendErrors`. `zodResolver.spec.ts` pins the behaviours that matter: nested paths, the
|
|
24
|
+
* closest union branch, `criteriaMode: 'all'`, a field-array root error, and parsed output.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type Issue = z.core.$ZodIssue;
|
|
28
|
+
|
|
29
|
+
/** zod 4 marks its errors with a trait, not a prototype chain a bundler could duplicate. */
|
|
30
|
+
function isZodError(error: unknown): error is { issues: Issue[] } {
|
|
31
|
+
return !!(error as { _zod?: { traits?: Set<string> } } | null)?._zod?.traits?.has('$ZodError');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseIssues(input: Issue[], validateAllFieldCriteria: boolean): Record<string, FieldError> {
|
|
35
|
+
const issues = [...input];
|
|
36
|
+
// A null prototype, so a field named `toString` or `constructor` is not already "set".
|
|
37
|
+
const errors: Record<string, FieldError> = Object.create(null);
|
|
38
|
+
while (issues.length > 0) {
|
|
39
|
+
const issue = issues[0] as Issue;
|
|
40
|
+
const { code, message, path } = issue;
|
|
41
|
+
const key = path.join('.');
|
|
42
|
+
if (!errors[key]) {
|
|
43
|
+
if (issue.code === 'invalid_union' && issue.errors.length > 0) {
|
|
44
|
+
// The branch that came CLOSEST to matching speaks for the union, not the first one.
|
|
45
|
+
const closest = issue.errors.reduce((best, branch) => (branch.length < best.length ? branch : best));
|
|
46
|
+
const first = closest[0];
|
|
47
|
+
errors[key] = { message: first?.message ?? message, type: first?.code ?? code };
|
|
48
|
+
} else {
|
|
49
|
+
errors[key] = { message, type: code };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (issue.code === 'invalid_union') {
|
|
53
|
+
for (const branch of issue.errors) for (const nested of branch) issues.push({ ...nested, path: [...issue.path, ...nested.path] } as Issue);
|
|
54
|
+
}
|
|
55
|
+
if (validateAllFieldCriteria) {
|
|
56
|
+
const types = (errors[key] as FieldError).types;
|
|
57
|
+
const messages = types?.[issue.code];
|
|
58
|
+
errors[key] = appendErrors(
|
|
59
|
+
key,
|
|
60
|
+
validateAllFieldCriteria,
|
|
61
|
+
errors as Parameters<typeof appendErrors>[2],
|
|
62
|
+
code,
|
|
63
|
+
messages ? ([] as string[]).concat(messages as string[], issue.message) : issue.message,
|
|
64
|
+
) as FieldError;
|
|
65
|
+
}
|
|
66
|
+
issues.shift();
|
|
67
|
+
}
|
|
68
|
+
return errors;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Fields = ResolverOptions<FieldValues>['fields'];
|
|
72
|
+
type NativeRef = { reportValidity?: () => void; setCustomValidity?: (m: string) => void };
|
|
73
|
+
|
|
74
|
+
function reportNative(ref: unknown, name: string, errors: FieldErrors): void {
|
|
75
|
+
const el = ref as NativeRef | undefined;
|
|
76
|
+
if (el && typeof el.reportValidity === 'function' && typeof el.setCustomValidity === 'function') {
|
|
77
|
+
const error = get(errors, name) as FieldError | undefined;
|
|
78
|
+
el.setCustomValidity(error?.message || '');
|
|
79
|
+
el.reportValidity();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateFieldsNatively(errors: FieldErrors, fields: Fields): void {
|
|
84
|
+
for (const name in fields) {
|
|
85
|
+
const field = fields[name] as { ref?: unknown; refs?: unknown[] } | undefined;
|
|
86
|
+
if (field?.ref && typeof (field.ref as NativeRef).reportValidity === 'function') reportNative(field.ref, name, errors);
|
|
87
|
+
else for (const ref of field?.refs ?? []) reportNative(ref, name, errors);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `a[0].b` and `a.0.b` are one path. */
|
|
92
|
+
const dotted = (name: string): string => name.replace(/\[(\d+)]/g, '.$1').replace(/[[\]]/g, '');
|
|
93
|
+
|
|
94
|
+
/** Is `name` the ROOT of a field array — some registered name is `name.<index>…`? */
|
|
95
|
+
function isFieldArrayRoot(names: readonly string[], name: string): boolean {
|
|
96
|
+
const escaped = dotted(name).replace(/[.*+?^${}()|\\]/g, '\\$&');
|
|
97
|
+
return names.some((n) => new RegExp(`^${escaped}\\.\\d+`).test(dotted(n)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toNestErrors<T extends FieldValues>(flat: Record<string, FieldError>, options: ResolverOptions<T>): FieldErrors<T> {
|
|
101
|
+
if (options.shouldUseNativeValidation) validateFieldsNatively(flat as FieldErrors, options.fields);
|
|
102
|
+
const nested: Record<string, unknown> = {};
|
|
103
|
+
for (const path in flat) {
|
|
104
|
+
const field = get(options.fields, path) as { ref?: unknown; refs?: unknown[] } | undefined;
|
|
105
|
+
const error = Object.assign(flat[path] || {}, { ref: field?.refs ? field.refs[0] : field?.ref });
|
|
106
|
+
if (isFieldArrayRoot((options.names as string[] | undefined) ?? Object.keys(flat), path)) {
|
|
107
|
+
const existing = Object.assign({}, get(nested, path));
|
|
108
|
+
set(existing, 'root', error);
|
|
109
|
+
set(nested, path, existing);
|
|
110
|
+
} else {
|
|
111
|
+
set(nested, path, error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return nested as FieldErrors<T>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A react-hook-form {@link Resolver} for a zod 4 schema: `parseAsync`, parsed output as the
|
|
119
|
+
* form's values on success, react-hook-form's nested error shape on a `ZodError`. Anything
|
|
120
|
+
* else a schema throws is not a validation result and is rethrown.
|
|
121
|
+
*/
|
|
122
|
+
export function zodResolver<TFieldValues extends FieldValues>(schema: z.ZodType): Resolver<TFieldValues> {
|
|
123
|
+
return async (values, _context, options) => {
|
|
124
|
+
try {
|
|
125
|
+
const data = await schema.parseAsync(values);
|
|
126
|
+
if (options.shouldUseNativeValidation) validateFieldsNatively({}, options.fields);
|
|
127
|
+
return { errors: {}, values: data as TFieldValues };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isZodError(error)) throw error;
|
|
130
|
+
return {
|
|
131
|
+
values: {},
|
|
132
|
+
errors: toNestErrors(parseIssues(error.issues, !options.shouldUseNativeValidation && options.criteriaMode === 'all'), options),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -28,6 +28,7 @@ import { Button } from "../components/Button.js";
|
|
|
28
28
|
import { FieldLabel } from "../components/FieldLabel.js";
|
|
29
29
|
import { Input } from "../components/Input.js";
|
|
30
30
|
import { Modal } from "../components/Modal.js";
|
|
31
|
+
import { SecretInput } from "../components/SecretInput.js";
|
|
31
32
|
|
|
32
33
|
export interface MasterLockDialogProps {
|
|
33
34
|
open: boolean;
|
|
@@ -201,39 +202,42 @@ export function MasterLockDialog({
|
|
|
201
202
|
<label htmlFor="ml-current">
|
|
202
203
|
<FieldLabel>Current master password</FieldLabel>
|
|
203
204
|
</label>
|
|
204
|
-
<
|
|
205
|
+
<SecretInput
|
|
205
206
|
id="ml-current"
|
|
206
|
-
|
|
207
|
+
label="current master password"
|
|
207
208
|
autoComplete="current-password"
|
|
209
|
+
showCopy={false}
|
|
208
210
|
value={current}
|
|
209
211
|
disabled={busy}
|
|
210
|
-
onChange={
|
|
212
|
+
onChange={setCurrent}
|
|
211
213
|
/>
|
|
212
214
|
</div>
|
|
213
215
|
<div className="flex flex-col gap-1.5">
|
|
214
216
|
<label htmlFor="ml-next">
|
|
215
217
|
<FieldLabel>New master password</FieldLabel>
|
|
216
218
|
</label>
|
|
217
|
-
<
|
|
219
|
+
<SecretInput
|
|
218
220
|
id="ml-next"
|
|
219
|
-
|
|
221
|
+
label="new master password"
|
|
220
222
|
autoComplete="new-password"
|
|
223
|
+
showCopy={false}
|
|
221
224
|
value={next}
|
|
222
225
|
disabled={busy}
|
|
223
|
-
onChange={
|
|
226
|
+
onChange={setNext}
|
|
224
227
|
/>
|
|
225
228
|
</div>
|
|
226
229
|
<div className="flex flex-col gap-1.5">
|
|
227
230
|
<label htmlFor="ml-confirm">
|
|
228
231
|
<FieldLabel>Repeat the new one</FieldLabel>
|
|
229
232
|
</label>
|
|
230
|
-
<
|
|
233
|
+
<SecretInput
|
|
231
234
|
id="ml-confirm"
|
|
232
|
-
|
|
235
|
+
label="repeated new master password"
|
|
233
236
|
autoComplete="new-password"
|
|
237
|
+
showCopy={false}
|
|
234
238
|
value={confirm}
|
|
235
239
|
disabled={busy}
|
|
236
|
-
onChange={
|
|
240
|
+
onChange={setConfirm}
|
|
237
241
|
onEnter={() => void changePassword()}
|
|
238
242
|
/>
|
|
239
243
|
</div>
|
|
@@ -126,3 +126,20 @@ describe("🔴 the rotation never mints a KDF", () => {
|
|
|
126
126
|
expect(code).toContain("kdf: MasterLockKdfParams;");
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("the account-menu entries (task 106)", () => {
|
|
131
|
+
test("🔴 'Master passwords…' is a LINK to the accounts page, from the wire, not a string here", async () => {
|
|
132
|
+
const { MasterLockMenuItems } = await import("./MasterLockProvider.js");
|
|
133
|
+
const { findByText, getByText } = render(
|
|
134
|
+
<MasterLockProvider onLocked={() => {}}>
|
|
135
|
+
<MasterLockMenuItems itemClassName="sam-item" />
|
|
136
|
+
</MasterLockProvider>,
|
|
137
|
+
);
|
|
138
|
+
const link = await findByText("Master passwords…");
|
|
139
|
+
expect(link.tagName).toBe("A");
|
|
140
|
+
expect(link.getAttribute("href")).toBe(MASTER_LOCK_PATHS.accounts);
|
|
141
|
+
expect(link.className).toBe("sam-item");
|
|
142
|
+
// The idle timeout lives only in the dialog, so its entry stays.
|
|
143
|
+
expect(getByText("Lock settings…").tagName).toBe("BUTTON");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -237,31 +237,25 @@ export interface MasterLockMenuItemsProps {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
|
-
* The
|
|
240
|
+
* The entries for the account menu — *"a new option in the user dropdown … which is
|
|
241
241
|
* located near the sign out and switch user options"*. The owner chose that placement
|
|
242
242
|
* deliberately: *"this placement will help make sure it isn't confused with the private
|
|
243
243
|
* collections lock or other locks like that."*
|
|
244
244
|
*
|
|
245
245
|
* Renders nothing when no master password is configured for the app.
|
|
246
246
|
*
|
|
247
|
-
* ──
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* password, because it describes a broken CLIENT. So the entry did not merely look wrong; it
|
|
255
|
-
* refused, and the owner could lock a site and enroll a first password but could not CHANGE one.
|
|
247
|
+
* ── The second entry is a LINK to the accounts page (task 106, 2026-09-23) ─────────────────
|
|
248
|
+
* `cursedbelt-server` serves `GET /__lock/accounts` from the same guard that serves the lock
|
|
249
|
+
* page: server-rendered, listing the accounts and adding, renaming and rotating them. So the
|
|
250
|
+
* entry is an `<a href>`, not a button — the target is a same-origin page, so there is nothing
|
|
251
|
+
* for this bundle to open and nothing for it to keep in step — and the path is
|
|
252
|
+
* `MASTER_LOCK_PATHS.accounts` from `cursedbelt-core`'s wire, never a string here, because a
|
|
253
|
+
* second copy of the wire in the design system is what `wire.ts`'s header exists to prevent.
|
|
256
254
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* link is a task, not a one-liner (`tasks/Not_Started`, filed 2026-09-17). What IS fixed here
|
|
262
|
-
* is the dialog itself: `MasterLockDialog` no longer mints a salt, so this entry works again
|
|
263
|
-
* rather than 409ing. `apps/collections` links to the page directly from its own rail in the
|
|
264
|
-
* meantime, and says why in a comment there.
|
|
255
|
+
* The THIRD entry keeps the dialog, because the dialog also owns the idle timeout, which the
|
|
256
|
+
* accounts page does not. (History: until 2026-09-17 the dialog minted a fresh `kdf` salt on
|
|
257
|
+
* every rotation and the server refused it `409 stale-client`; it derives under the app's
|
|
258
|
+
* existing descriptor now, so both surfaces rotate correctly.)
|
|
265
259
|
*/
|
|
266
260
|
export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItemsProps) {
|
|
267
261
|
const lock = useMasterLock();
|
|
@@ -283,6 +277,9 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
|
|
|
283
277
|
>
|
|
284
278
|
🔒 Lock this site
|
|
285
279
|
</button>
|
|
280
|
+
<a href={MASTER_LOCK_PATHS.accounts} className={cls} onClick={() => close?.()}>
|
|
281
|
+
Master passwords…
|
|
282
|
+
</a>
|
|
286
283
|
<button
|
|
287
284
|
// guardrails-ignore no-raw-action-button: see the sibling above — it must match
|
|
288
285
|
// the host menu's own rows, not this design system's buttons.
|
|
@@ -293,7 +290,7 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
|
|
|
293
290
|
lock.openSettings();
|
|
294
291
|
}}
|
|
295
292
|
>
|
|
296
|
-
|
|
293
|
+
Lock settings…
|
|
297
294
|
</button>
|
|
298
295
|
</>
|
|
299
296
|
);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Upload } from 'lucide-react';
|
|
2
2
|
import { type ReactNode, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Button } from '../components/Button.js';
|
|
4
|
-
import { Checkbox } from '../components/Checkbox.js';
|
|
5
4
|
import { Input } from '../components/Input.js';
|
|
6
5
|
import { cn } from '../lib/cn.js';
|
|
7
6
|
import { createFileUploadClient } from './fileUploadClient.js';
|
|
@@ -12,8 +11,8 @@ import { createFileUploadClient } from './fileUploadClient.js';
|
|
|
12
11
|
// no ENGINE-model consumer, but three shipping plugins render it
|
|
13
12
|
// (`plugin-photo-gallery`, `plugin-video-gallery`, `plugin-media-gallery`), so
|
|
14
13
|
// deleting it would break them. It speaks the legacy chunked `/_files` protocol
|
|
15
|
-
// (`createFileUploadClient` + `createFilesStack`), including the tag list
|
|
16
|
-
//
|
|
14
|
+
// (`createFileUploadClient` + `createFilesStack`), including the tag list. (Its private
|
|
15
|
+
// mode went with the server's private-media module — task 108.)
|
|
17
16
|
//
|
|
18
17
|
// The ENGINE model uploads through `POST /api/asset/vault/upload` — raw bytes,
|
|
19
18
|
// `x-cc-filename`, checksum computed while streaming, content-addressed dedupe —
|
|
@@ -25,19 +24,16 @@ import { createFileUploadClient } from './fileUploadClient.js';
|
|
|
25
24
|
//
|
|
26
25
|
// ── Media upload control ─────────────────────────────────────────────────────────
|
|
27
26
|
// The one "⬆ Upload" affordance media galleries share: a disclosure button opening a
|
|
28
|
-
// compact form — comma-separated tags
|
|
29
|
-
//
|
|
30
|
-
// via X-Unlock-Token). Consumers own only WHERE it sits and what happens after.
|
|
27
|
+
// compact form — comma-separated tags and a file picker — feeding the shared upload client
|
|
28
|
+
// (chunked-aware). Consumers own only WHERE it sits and what happens after.
|
|
31
29
|
|
|
32
30
|
export interface MediaUploadControlProps {
|
|
33
31
|
/** File-picker accept filter, e.g. "video/*" or "image/*". */
|
|
34
32
|
accept: string;
|
|
35
33
|
/** Mount prefix of the host's file routes (createFilesStack filesBasePath). */
|
|
36
34
|
basePath: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
/** After every successful batch: `wasPrivate` says which list to refresh. */
|
|
40
|
-
onUploaded: (result: { wasPrivate: boolean }) => void;
|
|
35
|
+
/** After every successful batch. */
|
|
36
|
+
onUploaded: () => void;
|
|
41
37
|
label?: ReactNode;
|
|
42
38
|
className?: string;
|
|
43
39
|
}
|
|
@@ -45,14 +41,12 @@ export interface MediaUploadControlProps {
|
|
|
45
41
|
export function MediaUploadControl({
|
|
46
42
|
accept,
|
|
47
43
|
basePath,
|
|
48
|
-
privateToken = null,
|
|
49
44
|
onUploaded,
|
|
50
45
|
label = 'Upload',
|
|
51
46
|
className,
|
|
52
47
|
}: MediaUploadControlProps) {
|
|
53
48
|
const [open, setOpen] = useState(false);
|
|
54
49
|
const [tags, setTags] = useState('');
|
|
55
|
-
const [asPrivate, setAsPrivate] = useState(false);
|
|
56
50
|
const [busy, setBusy] = useState(false);
|
|
57
51
|
const [error, setError] = useState<string | null>(null);
|
|
58
52
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
@@ -62,19 +56,13 @@ export function MediaUploadControl({
|
|
|
62
56
|
const uploadFiles = async (list: FileList) => {
|
|
63
57
|
setBusy(true);
|
|
64
58
|
setError(null);
|
|
65
|
-
const wantPrivate = asPrivate && !!privateToken;
|
|
66
59
|
const parsedTags = tags
|
|
67
60
|
.split(',')
|
|
68
61
|
.map((t) => t.trim())
|
|
69
62
|
.filter(Boolean);
|
|
70
63
|
try {
|
|
71
64
|
for (const file of Array.from(list)) {
|
|
72
|
-
const res =
|
|
73
|
-
? await client.uploadPrivate(file, {
|
|
74
|
-
tags: parsedTags,
|
|
75
|
-
unlockToken: privateToken,
|
|
76
|
-
})
|
|
77
|
-
: await client.uploadAuto(file, { tags: parsedTags });
|
|
65
|
+
const res = await client.uploadAuto(file, { tags: parsedTags });
|
|
78
66
|
if (!res.ok) {
|
|
79
67
|
setError(res.error);
|
|
80
68
|
return;
|
|
@@ -82,8 +70,7 @@ export function MediaUploadControl({
|
|
|
82
70
|
}
|
|
83
71
|
setOpen(false);
|
|
84
72
|
setTags('');
|
|
85
|
-
|
|
86
|
-
onUploaded({ wasPrivate: wantPrivate });
|
|
73
|
+
onUploaded();
|
|
87
74
|
} finally {
|
|
88
75
|
setBusy(false);
|
|
89
76
|
}
|
|
@@ -113,14 +100,6 @@ export function MediaUploadControl({
|
|
|
113
100
|
className='max-w-[200px]'
|
|
114
101
|
aria-label='Upload tags'
|
|
115
102
|
/>
|
|
116
|
-
{privateToken ? (
|
|
117
|
-
<Checkbox
|
|
118
|
-
checked={asPrivate}
|
|
119
|
-
onCheckedChange={(checked) => setAsPrivate(checked === true)}
|
|
120
|
-
disabled={busy}
|
|
121
|
-
label='Private'
|
|
122
|
-
/>
|
|
123
|
-
) : null}
|
|
124
103
|
<Button
|
|
125
104
|
size='sm'
|
|
126
105
|
disabled={busy}
|
|
@@ -4,8 +4,13 @@ import { sessionAuthHeaders } from '../sessionAuth.js';
|
|
|
4
4
|
// ── File-upload client (hook-free) ───────────────────────────────────────────────
|
|
5
5
|
// The one client for mountFileRoutes' upload edge, shared by every consumer (the files
|
|
6
6
|
// plugin, gallery upload buttons, the video editor's save-back) so the header protocol
|
|
7
|
-
// (X-Filename / X-Tags
|
|
8
|
-
//
|
|
7
|
+
// (X-Filename / X-Tags) and the chunked-vs-streamed decision live in exactly one place.
|
|
8
|
+
// Hook-free: plugins hold no upload state.
|
|
9
|
+
//
|
|
10
|
+
// 🔴 There is no private upload any more (task 108, 2026-09-23). `uploadPrivate` PUT to
|
|
11
|
+
// `/_files/upload-private`, a route `cursedbelt-server` deleted with its private-media module
|
|
12
|
+
// (task 00-urgent-24), so the method was a 404 waiting for a caller. It went, with the rest of
|
|
13
|
+
// the private-media client tier, rather than staying as a method that cannot succeed.
|
|
9
14
|
|
|
10
15
|
/** The FileRecord subset consumers act on after an upload (full shape: server files/types). */
|
|
11
16
|
export type UploadedFile = {
|
|
@@ -35,12 +40,6 @@ export interface FileUploadClientConfig {
|
|
|
35
40
|
export interface FileUploadClient {
|
|
36
41
|
/** Streamed single-PUT public upload. */
|
|
37
42
|
upload(file: File | Blob, opts?: { name?: string; tags?: string[] }): Promise<UploadOutcome>;
|
|
38
|
-
/** Private (encrypted) upload — pass the unlock token of the already-unlocked session, or a
|
|
39
|
-
* password for a deniable set; neither ⇒ the standard server-key set. */
|
|
40
|
-
uploadPrivate(
|
|
41
|
-
file: File | Blob,
|
|
42
|
-
opts?: { name?: string; tags?: string[]; unlockToken?: string | null; password?: string },
|
|
43
|
-
): Promise<UploadOutcome>;
|
|
44
43
|
/** Overwrite an existing file's bytes in place (same id; tags/collections survive). */
|
|
45
44
|
replace(
|
|
46
45
|
fileId: string,
|
|
@@ -163,15 +162,6 @@ export function createFileUploadClient(cfg: FileUploadClientConfig = {}): FileUp
|
|
|
163
162
|
return {
|
|
164
163
|
upload,
|
|
165
164
|
|
|
166
|
-
async uploadPrivate(file, opts) {
|
|
167
|
-
const headers = commonHeaders(file, opts);
|
|
168
|
-
if (opts?.unlockToken) headers['X-Unlock-Token'] = opts.unlockToken;
|
|
169
|
-
else if (opts?.password) headers['X-Private-Password'] = opts.password;
|
|
170
|
-
return outcome(
|
|
171
|
-
await doFetch(`${base}/upload-private`, { method: 'PUT', headers, body: file }),
|
|
172
|
-
);
|
|
173
|
-
},
|
|
174
|
-
|
|
175
165
|
async replace(fileId, file, opts) {
|
|
176
166
|
return outcome(
|
|
177
167
|
await doFetch(`${base}/replace/${fileId}`, {
|
|
@@ -339,7 +339,10 @@ export function attachHlsSource(
|
|
|
339
339
|
// guidance: calling `recoverMediaError()` twice for the same stall recovers
|
|
340
340
|
// nothing, and the audio-codec swap is what clears the case that keeps recurring.
|
|
341
341
|
if (attempt === 0) hls.recoverMediaError();
|
|
342
|
-
else
|
|
342
|
+
else {
|
|
343
|
+
hls.swapAudioCodec?.();
|
|
344
|
+
hls.recoverMediaError();
|
|
345
|
+
}
|
|
343
346
|
return;
|
|
344
347
|
}
|
|
345
348
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
package/src/react/media/index.ts
CHANGED
|
@@ -145,15 +145,6 @@ export {
|
|
|
145
145
|
probeMediaDurationFromUrl,
|
|
146
146
|
useMediaDuration,
|
|
147
147
|
} from './mediaProbe.js';
|
|
148
|
-
export { PrivateIndexView } from './PrivateIndexView.js';
|
|
149
|
-
export { PrivateModeBadge, type PrivateModeBadgeProps } from './PrivateModeBadge.js';
|
|
150
|
-
export {
|
|
151
|
-
type PrivateClientConfig,
|
|
152
|
-
privateFileUrlWithToken,
|
|
153
|
-
privateHlsUrlWithToken,
|
|
154
|
-
revealPrivateWithPassword,
|
|
155
|
-
revealPrivateWithToken,
|
|
156
|
-
} from './privateClient.js';
|
|
157
148
|
export type {
|
|
158
149
|
ImageEditorMode,
|
|
159
150
|
ImageEditorOperation,
|
|
@@ -168,13 +159,6 @@ export type {
|
|
|
168
159
|
export { type UseAudioPeaksOptions, useAudioPeaks } from './useAudioPeaks.js';
|
|
169
160
|
export { useIntersectionObserver } from './useIntersectionObserver.js';
|
|
170
161
|
export { useObjectUrl } from './useObjectUrl.js';
|
|
171
|
-
export {
|
|
172
|
-
type PrivateIndexRow,
|
|
173
|
-
type PrivateUnlock,
|
|
174
|
-
type RevealedItem,
|
|
175
|
-
type UsePrivateUnlockOptions,
|
|
176
|
-
usePrivateUnlock,
|
|
177
|
-
} from './usePrivateUnlock.js';
|
|
178
162
|
// Attaching an adaptive ladder to a bare `<video>` — native HLS first, hls.js second, the
|
|
179
163
|
// progressive remux always. Exported because three surfaces in the fleet drive a plain
|
|
180
164
|
// element rather than Vidstack and each was carrying its own copy of the decision.
|
package/src/react/sessionAuth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ── Session auth headers (hook-free) ────────────────────────────────────────
|
|
2
2
|
// One source of truth for "turn the browser session into a Bearer header". Plugins
|
|
3
|
-
// that call guarded endpoints (uploads,
|
|
3
|
+
// that call guarded endpoints (uploads, RPC over fetch)
|
|
4
4
|
// all need the same header shape; before this they each re-rolled the cookie read.
|
|
5
5
|
//
|
|
6
6
|
// NOTE (genapp-34): a generated app's `session` cookie is now `HttpOnly`, so this
|
|
@@ -19,8 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* Shaped as a plain function (not a hook) so it can be passed straight to helpers
|
|
21
21
|
* that expect an `authHeaders: () => Record<string, string>` callback
|
|
22
|
-
* (`
|
|
23
|
-
* `fetch` init's headers.
|
|
22
|
+
* (`createFileUploadClient`'s, for one) and spread into a `fetch` init's headers.
|
|
24
23
|
*/
|
|
25
24
|
export function sessionAuthHeaders(): Record<string, string> {
|
|
26
25
|
const token =
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `bun run <x>` a script calls, and every command the app manifest names, is a script
|
|
3
|
+
* that EXISTS in this `package.json`.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 Task 102, 2026-09-18: `done:serial` was `typecheck && lint && test && build`, and there was
|
|
6
|
+
* no `lint` script — so it had never once reached `test` or `build`, and the 31 lint errors a
|
|
7
|
+
* `lint` would have shown sat unseen. `format` was named in `cursed.app.extraCommands` too.
|
|
8
|
+
* Neither was in the gate, which is exactly why nobody noticed; this spec is in the gate.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'bun:test';
|
|
11
|
+
import pkg from '../package.json';
|
|
12
|
+
|
|
13
|
+
const scripts = (pkg as { scripts: Record<string, string> }).scripts;
|
|
14
|
+
|
|
15
|
+
/** The script names a command line invokes through `bun run` / `npm run`. */
|
|
16
|
+
export function calledScripts(command: string): string[] {
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
for (const m of command.matchAll(/\b(?:bun|npm|pnpm|yarn) run\s+([A-Za-z0-9:_./-]+)/g)) {
|
|
19
|
+
// A path (`bun run scripts/x.ts`) is a file, not a script name.
|
|
20
|
+
if (m[1] && !m[1].includes('/') && !/\.[cm]?[jt]sx?$/.test(m[1])) out.push(m[1]);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('package.json scripts resolve', () => {
|
|
26
|
+
it('reads `bun run <name>` and skips `bun run <file>`', () => {
|
|
27
|
+
expect(calledScripts('bun run typecheck && bun run lint && bun run scripts/gate.ts')).toEqual([
|
|
28
|
+
'typecheck',
|
|
29
|
+
'lint',
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('🔴 fails on a script that calls one that is not there — the task-102 shape', () => {
|
|
34
|
+
const fake = { 'done:serial': 'bun run typecheck && bun run lint', typecheck: 'tsc' };
|
|
35
|
+
const dangling = Object.values(fake)
|
|
36
|
+
.flatMap(calledScripts)
|
|
37
|
+
.filter((name) => !(name in fake));
|
|
38
|
+
expect(dangling).toEqual(['lint']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('every `bun run <script>` inside a script names a script that exists', () => {
|
|
42
|
+
const dangling: string[] = [];
|
|
43
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
44
|
+
for (const called of calledScripts(command)) {
|
|
45
|
+
if (!(called in scripts)) dangling.push(`${name} → ${called}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
expect(dangling).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('every command the `cursed.app` manifest names is a script', () => {
|
|
52
|
+
const app = (pkg as { cursed?: { app?: Record<string, unknown> } }).cursed?.app ?? {};
|
|
53
|
+
const named: string[] = [];
|
|
54
|
+
for (const key of ['commands', 'extraCommands']) {
|
|
55
|
+
const block = app[key] as Record<string, string> | undefined;
|
|
56
|
+
if (block) named.push(...Object.values(block));
|
|
57
|
+
}
|
|
58
|
+
expect(named.length).toBeGreaterThan(0);
|
|
59
|
+
expect(named.filter((n) => !(n in scripts))).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('every gate stage is a script', () => {
|
|
63
|
+
const stages = (pkg as { gate?: { stages?: { script: string }[] } }).gate?.stages ?? [];
|
|
64
|
+
expect(stages.map((s) => s.script).filter((n) => !(n in scripts))).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 71 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|