@drhomet/mithaq 0.1.1 → 0.2.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/LICENSES/openstatus-time-picker.txt +21 -0
- package/README.md +161 -0
- package/dist/asas/build.d.ts +10 -2
- package/dist/asas/build.js +69 -22
- package/dist/asas/cli/mithaq.js +35 -3
- package/dist/asas/cli/upgrade.d.ts +22 -9
- package/dist/asas/cli/upgrade.js +187 -78
- package/dist/asas/http/fetch.d.ts +21 -3
- package/dist/asas/http/fetch.js +123 -60
- package/dist/asas/http/orpc.d.ts +5 -3
- package/dist/asas/http/orpc.js +126 -108
- package/dist/asas/runner/bulk.d.ts +1 -1
- package/dist/asas/runner/bulk.js +16 -11
- package/dist/asas/runner/errors.d.ts +5 -2
- package/dist/asas/runner/errors.js +18 -2
- package/dist/asas/runner/read.d.ts +6 -2
- package/dist/asas/runner/read.js +28 -6
- package/dist/asas/runner/steps.d.ts +22 -4
- package/dist/asas/runner/steps.js +15 -9
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +2 -2
- package/dist/asas/runner/tenant.js +78 -16
- package/dist/asas/runner/write.d.ts +173 -11
- package/dist/asas/runner/write.js +274 -46
- package/dist/auth/better-auth/access.d.ts +14 -0
- package/dist/auth/better-auth/access.js +32 -0
- package/dist/auth/better-auth/generate.d.ts +3 -1
- package/dist/auth/better-auth/generate.js +26 -8
- package/dist/auth/better-auth/index.d.ts +2 -0
- package/dist/auth/better-auth/tenant.d.ts +11 -3
- package/dist/auth/better-auth/tenant.js +10 -2
- package/dist/auth/local/index.js +5 -3
- package/dist/base/fields.d.ts +3 -2
- package/dist/base/fields.js +3 -2
- package/dist/base/index.d.ts +2 -1
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +21 -0
- package/dist/base/kinds.js +46 -1
- package/dist/base/rules.d.ts +4 -4
- package/dist/base/rules.js +4 -3
- package/dist/drizzle/index.d.ts +9 -139
- package/dist/drizzle/index.js +143 -24
- package/dist/drizzle/kinds.d.ts +1 -0
- package/dist/drizzle/kinds.js +3 -1
- package/dist/drizzle/migrate.d.ts +26 -0
- package/dist/drizzle/migrate.js +69 -26
- package/dist/drizzle/rules.d.ts +1 -1
- package/dist/index.d.ts +11 -21
- package/dist/index.js +8 -18
- package/dist/mithaq/define.d.ts +62 -15
- package/dist/mithaq/define.js +23 -9
- package/dist/mithaq/generate-messages.js +5 -4
- package/dist/mithaq/language.d.ts +43 -4
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.js +5 -0
- package/dist/mithaq/resolve.js +70 -10
- package/dist/mithaq/types.d.ts +29 -1
- package/dist/rbac/index.d.ts +2 -2
- package/dist/rbac/index.js +1 -1
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/rich-text.d.ts +2 -55
- package/dist/rich-text/rich-text.js +2 -125
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +5 -2
- package/dist/screen/client.js +5 -2
- package/dist/screen/generate.js +48 -6
- package/dist/screen/index.d.ts +18 -1
- package/dist/screen/index.js +24 -6
- package/dist/screen/react.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -3
- package/dist/storage/index.js +1 -2
- package/dist/storage/storage.d.ts +2 -0
- package/dist/storage/storage.js +9 -2
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +12 -3
- package/dist/zod/index.js +6 -5
- package/dist/zod/kinds.d.ts +3 -1
- package/dist/zod/kinds.js +11 -2
- package/dist/zod/rules.d.ts +1 -1
- package/docs/packages/mithaq/README.md +974 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +283 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1094 -0
- package/package.json +37 -8
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
- package/r/mithaq-cells.json +0 -20
- package/r/mithaq-controls.json +0 -37
- package/r/mithaq-directional-icon.json +0 -15
- package/r/mithaq-expected-updated-at.json +0 -15
- package/r/mithaq-field-shell.json +0 -18
- package/r/mithaq-filter-operators.json +0 -15
- package/r/mithaq-form.json +0 -33
- package/r/mithaq-list-columns.json +0 -23
- package/r/mithaq-list-filters.json +0 -24
- package/r/mithaq-list-pagination.json +0 -24
- package/r/mithaq-list-query.json +0 -18
- package/r/mithaq-list-selection.json +0 -19
- package/r/mithaq-list.json +0 -33
- package/r/mithaq-nav.json +0 -24
- package/r/mithaq-nodes.json +0 -34
- package/r/mithaq-relationship-cell.json +0 -20
- package/r/mithaq-relationship-control.json +0 -20
- package/r/mithaq-rich-text.json +0 -40
- package/r/mithaq-screens-host.json +0 -96
- package/r/mithaq-storage.json +0 -44
- package/r/mithaq-upload.json +0 -29
- package/r/registry.json +0 -550
package/dist/asas/cli/upgrade.js
CHANGED
|
@@ -1,59 +1,56 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { createHash } from 'node:crypto';
|
|
3
2
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
4
|
import { createRequire } from 'node:module';
|
|
6
|
-
import {
|
|
7
|
-
// The last line of every
|
|
8
|
-
// file above. Trailing, because `shadcn add`
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
// The last line of every ejected file: the version (or, for a copy `shadcn add` made, the commit) it came from and the
|
|
7
|
+
// hash of the file above. Trailing, because `shadcn add` dropped a file's leading comments only.
|
|
9
8
|
const marker = /\n\/\/ mithaq-admin (\S+) ([0-9a-f]{64})\n?$/;
|
|
10
|
-
// What `shadcn add`
|
|
9
|
+
// What `shadcn add` dropped, so a hash is the same over the package's text and a copy it made.
|
|
11
10
|
const leading = /^(?:\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
11
|
+
/** What an eject writes above the marker: the file's text, ending in a newline. */
|
|
12
|
+
const bodyOf = (text) => {
|
|
13
|
+
const body = text.replace(marker, '\n');
|
|
14
|
+
return body.endsWith('\n') ? body : `${body}\n`;
|
|
15
|
+
};
|
|
16
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
17
|
+
/** The hash of a copy `shadcn add` made, without its marker and the leading comments it dropped. */
|
|
18
|
+
export const hashOf = (text) => sha(bodyOf(text).replace(leading, ''));
|
|
19
|
+
/**
|
|
20
|
+
* The hash a marker from `from` holds: of the whole text above it for an eject, which writes byte for byte, so an edit
|
|
21
|
+
* to a leading pragma is an edit; without leading comments only for a copy made at a commit, as `shadcn add` wrote it.
|
|
22
|
+
*/
|
|
23
|
+
const madeByShadcn = (from) => /^(?:[0-9a-f]{40}|unknown)$/.test(from);
|
|
24
|
+
const hashFrom = (from) => (text) => (madeByShadcn(from) ? hashOf(text) : sha(bodyOf(text)));
|
|
25
|
+
/** A held file's state against the package's text of it, read as `upgrade --check` reads it. */
|
|
26
|
+
export const stateOf = (text, source) => {
|
|
27
|
+
const [, from, hash] = marker.exec(text) ?? [];
|
|
28
|
+
if (!hash)
|
|
29
|
+
return 'unknown';
|
|
30
|
+
const hashed = hashFrom(from);
|
|
31
|
+
return `${hash !== hashed(source) ? 'differs' : 'up to date'}${hash !== hashed(text) ? ', edited' : ''}`;
|
|
32
32
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// The installed `mithaq-admin`'s `r/`, else the one this mithaq ships, else none.
|
|
37
|
-
const registryOf = (project) => {
|
|
33
|
+
/** The `mithaq-admin` the project installed: its folder, its version and its items, or none. */
|
|
34
|
+
const installedOf = async (project) => {
|
|
35
|
+
let manifest;
|
|
38
36
|
try {
|
|
39
|
-
|
|
37
|
+
manifest = pathToFileURL(createRequire(new URL('package.json', project)).resolve('mithaq-admin/package.json'));
|
|
40
38
|
}
|
|
41
39
|
catch {
|
|
42
|
-
return
|
|
40
|
+
return undefined;
|
|
43
41
|
}
|
|
42
|
+
const root = new URL('./', manifest);
|
|
43
|
+
const { version } = JSON.parse(await readFile(manifest, 'utf8'));
|
|
44
|
+
const { items } = JSON.parse(await readFile(new URL('registry.json', root), 'utf8'));
|
|
45
|
+
return { root, version, items };
|
|
44
46
|
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
export const upgradeCheck = async (project) => {
|
|
47
|
+
// `new URL` resolves every `..`, encoded or not, so a prefix is the whole test.
|
|
48
|
+
const within = (url, folder) => url.href.startsWith(folder.href);
|
|
49
|
+
/** Where the project keeps each target: `@<alias>/…` through `components.json`, `~/…` from the project's root. */
|
|
50
|
+
const targetsOf = async (project) => {
|
|
50
51
|
const components = JSON.parse(await readFile(new URL('components.json', project), 'utf8'));
|
|
51
|
-
const built = registryOf(project);
|
|
52
|
-
if (!built)
|
|
53
|
-
return undefined;
|
|
54
|
-
const { items } = JSON.parse(await readFile(new URL('registry.json', built), 'utf8'));
|
|
55
52
|
const src = existsSync(new URL('src/', project)) ? 'src/' : '';
|
|
56
|
-
|
|
53
|
+
return (target) => {
|
|
57
54
|
if (target.startsWith('~/'))
|
|
58
55
|
return target.slice(2);
|
|
59
56
|
const [, alias = '', rest] = /^@([^/]+)\/(.+)$/.exec(target) ?? [];
|
|
@@ -62,44 +59,49 @@ export const upgradeCheck = async (project) => {
|
|
|
62
59
|
const at = components.aliases?.[alias];
|
|
63
60
|
return at?.startsWith('@/') ? `${src}${at.slice(2)}/${rest}` : undefined;
|
|
64
61
|
};
|
|
62
|
+
};
|
|
63
|
+
const folderOf = (path) => path.slice(0, path.lastIndexOf('/') + 1);
|
|
64
|
+
/** The folders a piece the package serves (`registry/<file>`) is ejected into: what `eject --prune` sweeps. */
|
|
65
|
+
const ejectedFolders = (items, pathOf) => new Set(items.flatMap((item) => item.files.filter((file) => !file.path.slice('registry/'.length).includes('/')).flatMap((file) => { const path = pathOf(file.target); return path ? folderOf(path) : []; })));
|
|
66
|
+
/**
|
|
67
|
+
* Every package file the project holds at its target, ejected or always the project's, with its state. Reads only;
|
|
68
|
+
* a path that climbs out of the project or the package is listed as outside, never followed.
|
|
69
|
+
*/
|
|
70
|
+
export const upgradeCheck = async (project) => {
|
|
71
|
+
const pathOf = await targetsOf(project);
|
|
72
|
+
const installed = await installedOf(project);
|
|
73
|
+
if (!installed)
|
|
74
|
+
return undefined;
|
|
65
75
|
const copied = [];
|
|
66
|
-
/** Every path an item names,
|
|
76
|
+
/** Every path an item names, held or not, and the folders they sit in, which a file no item names any more is looked for in. */
|
|
67
77
|
const named = new Set();
|
|
68
78
|
const folders = new Set();
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!within(source, built)) {
|
|
74
|
-
copied.push({ path: name, state: 'outside' });
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
const item = JSON.parse(await readFile(source, 'utf8'));
|
|
78
|
-
for (const file of item.files ?? []) {
|
|
79
|
+
const ejected = ejectedFolders(installed.items, pathOf);
|
|
80
|
+
for (const { name, files } of installed.items) {
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
const source = new URL(file.path, installed.root);
|
|
79
83
|
const path = pathOf(file.target);
|
|
84
|
+
if (!within(source, installed.root)) {
|
|
85
|
+
copied.push({ path: file.path, state: 'outside' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
80
88
|
if (path && !within(new URL(path, project), project)) {
|
|
81
89
|
copied.push({ path: file.target, state: 'outside' });
|
|
82
90
|
continue;
|
|
83
91
|
}
|
|
84
92
|
if (path) {
|
|
85
93
|
named.add(path);
|
|
86
|
-
folders.add(
|
|
94
|
+
folders.add(folderOf(path));
|
|
87
95
|
}
|
|
88
96
|
const text = path && (await readFile(new URL(path, project), 'utf8').catch(() => undefined));
|
|
89
97
|
if (!path || text === undefined)
|
|
90
98
|
continue;
|
|
91
|
-
const
|
|
92
|
-
const to =
|
|
93
|
-
|
|
94
|
-
copied.push({ path, state: 'unknown', to });
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
const differs = hash !== hashOf(file.content);
|
|
98
|
-
const edited = hash !== hashOf(text);
|
|
99
|
-
copied.push({ path, state: `${differs ? 'differs' : 'up to date'}${edited ? ', edited' : ''}`, from, to });
|
|
99
|
+
const state = stateOf(text, await readFile(source, 'utf8'));
|
|
100
|
+
const to = installed.version;
|
|
101
|
+
copied.push(state === 'unknown' ? { path, state, to } : { path, state, from: marker.exec(text)[1], to, ...(!ejected.has(folderOf(path)) && { item: name }) });
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
|
-
// A marked file no item names any more: an item the
|
|
104
|
+
// A marked file no item names any more: an item the package dropped, which the project deletes.
|
|
103
105
|
for (const folder of folders) {
|
|
104
106
|
const entries = await readdir(new URL(folder || './', project), { withFileTypes: true }).catch(() => []);
|
|
105
107
|
for (const entry of entries) {
|
|
@@ -113,35 +115,142 @@ export const upgradeCheck = async (project) => {
|
|
|
113
115
|
}
|
|
114
116
|
return copied;
|
|
115
117
|
};
|
|
116
|
-
const sentence = ({ path, state, from = '', to = 'unknown' }
|
|
118
|
+
const sentence = ({ path, state, from = '', to = 'unknown', item }) => ({
|
|
117
119
|
'up to date': `${path} is up to date.`,
|
|
118
|
-
'up to date, edited': `${path} is up to date, and edited since it was
|
|
119
|
-
differs: `${path} differs from
|
|
120
|
-
'differs, edited': `${path} differs from
|
|
121
|
-
unknown: `${path} carries no mithaq-admin marker on its last line, so nothing says what it was
|
|
122
|
-
outside: `${path} resolves outside this project or the
|
|
123
|
-
removed: `${path} was
|
|
120
|
+
'up to date, edited': `${path} is up to date, and edited since it was ejected.`,
|
|
121
|
+
differs: `${path} differs from the installed mithaq-admin's file (${to}), and is not edited since it was ejected from ${from.slice(0, 12)}. A hash shows a difference, not which side is newer: diff the two before replacing it, or ${item ? `delete it and run \`mithaq eject ${item}\` to take the package's` : 'delete it to draw the package\'s'}.`,
|
|
122
|
+
'differs, edited': `${path} differs from the installed mithaq-admin's file (${to}), and is edited since it was ejected from ${from.slice(0, 12)}: diff the two and merge by hand.`,
|
|
123
|
+
unknown: `${path} carries no mithaq-admin marker on its last line, so nothing says what it was ejected from.`,
|
|
124
|
+
outside: `${path} resolves outside this project or the package, so it was not read.`,
|
|
125
|
+
removed: `${path} was ejected from mithaq-admin ${from.slice(0, 12)}, and no item of the installed package names it any more: it was removed from the package, so delete it.`,
|
|
124
126
|
})[state];
|
|
125
|
-
|
|
127
|
+
const noComponents = 'No components.json here, so nothing says where mithaq-admin files go.';
|
|
128
|
+
const noPackage = 'No mithaq-admin is installed here.';
|
|
129
|
+
/** `mithaq upgrade --check`: one line per file the project holds, and 1 where any differs. It never writes. */
|
|
126
130
|
export const run = async (project) => {
|
|
127
131
|
if (!existsSync(new URL('components.json', project))) {
|
|
128
|
-
console.log(
|
|
132
|
+
console.log(noComponents);
|
|
129
133
|
return 0;
|
|
130
134
|
}
|
|
131
135
|
const copied = await upgradeCheck(project);
|
|
132
136
|
if (!copied) {
|
|
133
|
-
console.log(
|
|
137
|
+
console.log(`${noPackage} Nothing to check the files against.`);
|
|
134
138
|
return 0;
|
|
135
139
|
}
|
|
136
|
-
const own = registryOf(project)?.href === registry.href;
|
|
137
140
|
if (!copied.length)
|
|
138
141
|
console.log('Nothing from mithaq-admin here.');
|
|
139
142
|
for (const one of copied)
|
|
140
|
-
console.log(sentence(one
|
|
143
|
+
console.log(sentence(one));
|
|
141
144
|
const differing = copied.filter((one) => one.state.startsWith('differs')).length;
|
|
142
145
|
if (differing) {
|
|
143
|
-
console.error(`${differing} ${differing === 1 ? 'file differs' : 'files differ'} from
|
|
146
|
+
console.error(`${differing} ${differing === 1 ? 'file differs' : 'files differ'} from the installed mithaq-admin.`);
|
|
144
147
|
return 1;
|
|
145
148
|
}
|
|
146
149
|
return 0;
|
|
147
150
|
};
|
|
151
|
+
/**
|
|
152
|
+
* `mithaq eject <item>`: writes the item's own files where the project keeps them, each byte for byte with a marker,
|
|
153
|
+
* and never over a file already there. It copies no other item and installs nothing: the package serves every
|
|
154
|
+
* sibling, so it only names a primitive or an npm package the project lacks.
|
|
155
|
+
*/
|
|
156
|
+
export const eject = async (project, name) => {
|
|
157
|
+
if (!existsSync(new URL('components.json', project))) {
|
|
158
|
+
console.error(noComponents);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const pathOf = await targetsOf(project);
|
|
162
|
+
const installed = await installedOf(project);
|
|
163
|
+
if (!installed) {
|
|
164
|
+
console.error(`${noPackage} Install it at the version of mithaq, then eject.`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
const wanted = `mithaq-${name.replace(/\.tsx?$/, '').replace(/^mithaq-/, '')}`;
|
|
168
|
+
const item = installed.items.find((one) => one.name === wanted);
|
|
169
|
+
if (!item) {
|
|
170
|
+
console.error(`mithaq-admin has no item "${name}". Its items are: ${installed.items.map((one) => one.name).join(', ')}.`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const files = item.files.map((file) => ({ source: new URL(file.path, installed.root), path: pathOf(file.target), target: file.target }));
|
|
174
|
+
const refused = files.filter(({ source, path }) => !within(source, installed.root) || !path || !within(new URL(path, project), project));
|
|
175
|
+
if (refused.length) {
|
|
176
|
+
console.error(`${refused.map((one) => one.target).join(', ')} resolves outside this project or the package, so nothing was written.`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const ejected = ejectedFolders(installed.items, pathOf);
|
|
180
|
+
for (const { source, path } of files) {
|
|
181
|
+
const at = new URL(path, project);
|
|
182
|
+
const text = await readFile(source, 'utf8');
|
|
183
|
+
await mkdir(new URL('./', at), { recursive: true });
|
|
184
|
+
// `wx` fails on a file already there, so an eject never overwrites, whatever raced it.
|
|
185
|
+
const body = bodyOf(text);
|
|
186
|
+
const written = await writeFile(at, `${body}// mithaq-admin ${installed.version} ${sha(body)}\n`, { flag: 'wx' }).then(() => true, (error) => {
|
|
187
|
+
if (error.code === 'EEXIST')
|
|
188
|
+
return false;
|
|
189
|
+
throw error;
|
|
190
|
+
});
|
|
191
|
+
// A file the project always holds (the host, storage, a step) is never deleted: the app needs it.
|
|
192
|
+
const drawn = ejected.has(folderOf(path)) ? '; delete it to draw mithaq-admin\'s again' : '';
|
|
193
|
+
console.log(written ? `${path} is ejected. Edit it in place${drawn}.` : `${path} is already ejected, so it was left as it is.`);
|
|
194
|
+
}
|
|
195
|
+
for (const primitive of item.registryDependencies ?? []) {
|
|
196
|
+
const path = pathOf(`@ui/${primitive}.tsx`);
|
|
197
|
+
if (path && !existsSync(new URL(path, project)))
|
|
198
|
+
console.log(`${path} is missing: run shadcn add ${primitive}`);
|
|
199
|
+
}
|
|
200
|
+
const require = createRequire(new URL('package.json', project));
|
|
201
|
+
for (const dependency of item.dependencies ?? []) {
|
|
202
|
+
const npm = /^(@?[^@]+)/.exec(dependency)[1];
|
|
203
|
+
try {
|
|
204
|
+
require.resolve(npm);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code === 'MODULE_NOT_FOUND')
|
|
208
|
+
console.log(`${npm} is not installed: install ${dependency}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return 0;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* `mithaq eject --prune`: deletes every marked file in the folders pieces are ejected into whose text still hashes
|
|
215
|
+
* to its marker, so the package draws it again. Never an edited, unmarked or `~/` file.
|
|
216
|
+
*/
|
|
217
|
+
export const prune = async (project) => {
|
|
218
|
+
if (!existsSync(new URL('components.json', project))) {
|
|
219
|
+
console.error(noComponents);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
const pathOf = await targetsOf(project);
|
|
223
|
+
const installed = await installedOf(project);
|
|
224
|
+
if (!installed) {
|
|
225
|
+
console.error(noPackage);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
// The package's file at each path, whose leading comments a copy `shadcn add` made may hold and no more.
|
|
229
|
+
const sources = new Map(installed.items.flatMap((item) => item.files.flatMap((file) => {
|
|
230
|
+
const path = pathOf(file.target);
|
|
231
|
+
return path ? [[path, new URL(file.path, installed.root)]] : [];
|
|
232
|
+
})));
|
|
233
|
+
const leadOf = (text) => leading.exec(bodyOf(text))[0];
|
|
234
|
+
for (const folder of ejectedFolders(installed.items, pathOf)) {
|
|
235
|
+
if (!within(new URL(folder, project), project))
|
|
236
|
+
continue;
|
|
237
|
+
const entries = await readdir(new URL(folder, project), { withFileTypes: true }).catch(() => []);
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (!entry.isFile())
|
|
240
|
+
continue;
|
|
241
|
+
const path = `${folder}${entry.name}`;
|
|
242
|
+
const text = await readFile(new URL(path, project), 'utf8');
|
|
243
|
+
const [, from, hash] = marker.exec(text) ?? [];
|
|
244
|
+
if (!hash || hash !== hashFrom(from)(text))
|
|
245
|
+
continue;
|
|
246
|
+
// A commit's hash leaves leading comments out, so a pragma written above one is an edit the hash cannot see.
|
|
247
|
+
const source = sources.get(path);
|
|
248
|
+
const lead = leadOf(text);
|
|
249
|
+
if (madeByShadcn(from) && lead && lead !== (source && within(source, installed.root) ? leadOf(await readFile(source, 'utf8').catch(() => '')) : ''))
|
|
250
|
+
continue;
|
|
251
|
+
await rm(new URL(path, project));
|
|
252
|
+
console.log(`${path} was not edited since it was ejected, so it is deleted and mithaq-admin draws its own.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return 0;
|
|
256
|
+
};
|
|
@@ -7,17 +7,35 @@ export declare const draftOptions: (request: Request) => {
|
|
|
7
7
|
autosave?: boolean | undefined;
|
|
8
8
|
tenant?: string | undefined;
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* The largest request body a plug reads, which a project names as `maxBodyBytes`: room for a
|
|
12
|
+
* json field's `defaultJsonMaxBytes` written in any script. A file never comes this way.
|
|
13
|
+
*/
|
|
14
|
+
export declare const defaultMaxBodyBytes: number;
|
|
15
|
+
/**
|
|
16
|
+
* The body, or the answer that refuses it: 413 once it passes `limit` bytes, and 499 to a client
|
|
17
|
+
* that went away mid-body, which is no fault of the server's. Counted as it arrives, since a
|
|
18
|
+
* `content-length` may be absent or wrong, and the rest is never read.
|
|
19
|
+
*/
|
|
20
|
+
export declare const boundedBody: (request: Request, limit: number) => Promise<Blob | Response>;
|
|
21
|
+
/** Refused by name, since core holds no limit of its own. */
|
|
22
|
+
export declare const assertMaxBodyBytes: (owner: string, value: unknown) => void;
|
|
23
|
+
/** Refused by name, as `auth.onError` and `storage.onError` are: core reports no fault of its own. */
|
|
24
|
+
export declare const assertOnError: (owner: string, value: unknown) => void;
|
|
25
|
+
export declare const tooLarge: (limit: number) => Response;
|
|
10
26
|
export type FetchHttpOptions = {
|
|
27
|
+
/** The largest body a call takes, answered 413 past it: `defaultMaxBodyBytes` or the project's own. Required. */
|
|
28
|
+
maxBodyBytes: number;
|
|
11
29
|
/**
|
|
12
30
|
* Builds the context every step of a served call reads, from the request and the actor.
|
|
13
31
|
* The plug reads no header or body key into it; a throw is answered 500.
|
|
14
32
|
*/
|
|
15
33
|
context?: (request: Request, actor: unknown) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
16
|
-
/** Handed every error that is not an `AsasError`, before the 500 goes out with its message replaced.
|
|
17
|
-
onError
|
|
34
|
+
/** Handed every error that is not an `AsasError`, before the 500 goes out with its message replaced. Required. */
|
|
35
|
+
onError: (error: unknown) => void;
|
|
18
36
|
};
|
|
19
37
|
/**
|
|
20
38
|
* The default HTTP plug: `POST /api/<kayan>/<operation>` with a JSON body, over
|
|
21
39
|
* `Request` and `Response` alone. The runner parses each operation's input.
|
|
22
40
|
*/
|
|
23
|
-
export declare const fetchHttp: (
|
|
41
|
+
export declare const fetchHttp: (options: FetchHttpOptions) => <View>(operations: OperationTable<View>) => HttpPlug;
|
package/dist/asas/http/fetch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AfterCommitError, AsasError, afterCommitHeader, codes } from '../runner/errors.js';
|
|
1
|
+
import { AfterCommitError, AsasError, afterCommitHeader, codes, retryConflict } from '../runner/errors.js';
|
|
2
2
|
// The walk enters objects, arrays and the wrappers `unwrap()` opens, and nothing else.
|
|
3
3
|
const datesFromJson = (value, schema) => {
|
|
4
4
|
if (schema.type === 'date' && typeof value === 'string') {
|
|
@@ -40,76 +40,139 @@ export const draftOptions = (request) => {
|
|
|
40
40
|
...(query.has('tenant') ? { tenant: query.get('tenant') } : {}),
|
|
41
41
|
};
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* The largest request body a plug reads, which a project names as `maxBodyBytes`: room for a
|
|
45
|
+
* json field's `defaultJsonMaxBytes` written in any script. A file never comes this way.
|
|
46
|
+
*/
|
|
47
|
+
export const defaultMaxBodyBytes = 4 * 1024 * 1024;
|
|
48
|
+
/**
|
|
49
|
+
* The body, or the answer that refuses it: 413 once it passes `limit` bytes, and 499 to a client
|
|
50
|
+
* that went away mid-body, which is no fault of the server's. Counted as it arrives, since a
|
|
51
|
+
* `content-length` may be absent or wrong, and the rest is never read.
|
|
52
|
+
*/
|
|
53
|
+
export const boundedBody = async (request, limit) => {
|
|
54
|
+
const reader = request.body?.getReader();
|
|
55
|
+
const refused = () => {
|
|
56
|
+
// Not awaited: a clone's cancel settles only once its twin is cancelled too, which the
|
|
57
|
+
// caller does after this returns.
|
|
58
|
+
reader?.cancel().catch(() => { });
|
|
59
|
+
return tooLarge(limit);
|
|
60
|
+
};
|
|
61
|
+
if (Number(request.headers.get('content-length')) > limit)
|
|
62
|
+
return refused();
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
for (;;) {
|
|
66
|
+
let read;
|
|
67
|
+
try {
|
|
68
|
+
read = (await reader?.read()) ?? { done: true, value: undefined };
|
|
69
|
+
}
|
|
70
|
+
catch (thrown) {
|
|
71
|
+
if (request.signal.aborted)
|
|
72
|
+
return new Response(null, { status: 499 });
|
|
73
|
+
throw thrown;
|
|
74
|
+
}
|
|
75
|
+
if (read.done)
|
|
76
|
+
return new Blob(chunks);
|
|
77
|
+
size += read.value.byteLength;
|
|
78
|
+
if (size > limit)
|
|
79
|
+
return refused();
|
|
80
|
+
chunks.push(read.value);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** Refused by name, since core holds no limit of its own. */
|
|
84
|
+
export const assertMaxBodyBytes = (owner, value) => {
|
|
85
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
86
|
+
throw new Error(`${owner} names no "maxBodyBytes", the largest request body in bytes it reads, as a whole number above 0. Name the project's own, such as defaultMaxBodyBytes from mithaq.`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/** Refused by name, as `auth.onError` and `storage.onError` are: core reports no fault of its own. */
|
|
90
|
+
export const assertOnError = (owner, value) => {
|
|
91
|
+
if (typeof value !== 'function') {
|
|
92
|
+
throw new Error(`${owner} names no "onError" function, which it hands every error that is no refusal before it answers 500. Name the project's own, such as console.error.`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
export const tooLarge = (limit) => Response.json({ code: codes[413], message: `The request body holds at most ${limit} bytes.` }, { status: 413 });
|
|
43
96
|
/**
|
|
44
97
|
* The default HTTP plug: `POST /api/<kayan>/<operation>` with a JSON body, over
|
|
45
98
|
* `Request` and `Response` alone. The runner parses each operation's input.
|
|
46
99
|
*/
|
|
47
|
-
export const fetchHttp = (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
78
|
-
return undecodable('The request body is not a JSON object.');
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
try {
|
|
82
|
-
// Resolved after the match, so a host's own routes cost no session lookup.
|
|
83
|
-
const decoded = decodeJson(input, operation.input);
|
|
84
|
-
const who = await actor();
|
|
85
|
-
const result = await operation.call(decoded, { actor: who, context: await context?.(request, who), ...draftOptions(request) });
|
|
86
|
-
// Only a write says so, because the browser clears its banner on this value alone.
|
|
87
|
-
return Response.json(result, operation.effect === 'read' ? undefined : { headers: { [afterCommitHeader]: 'ok' } });
|
|
88
|
-
}
|
|
89
|
-
catch (thrown) {
|
|
90
|
-
if (thrown instanceof AfterCommitError) {
|
|
100
|
+
export const fetchHttp = (options) => {
|
|
101
|
+
// Read off `options?`, since a JavaScript caller may hand nothing, and is refused by name.
|
|
102
|
+
assertMaxBodyBytes('fetchHttp', options?.maxBodyBytes);
|
|
103
|
+
assertOnError('fetchHttp', options.onError);
|
|
104
|
+
const { context, onError, maxBodyBytes } = options;
|
|
105
|
+
return (operations) => {
|
|
106
|
+
const table = new Map(operations.map((one) => [`${one.kayan}/${one.name}`, one]));
|
|
107
|
+
return {
|
|
108
|
+
name: 'fetch',
|
|
109
|
+
// The operation table is the routes, already published as `asas.operations`.
|
|
110
|
+
router: undefined,
|
|
111
|
+
handle: async (request, actor) => {
|
|
112
|
+
if (request.method !== 'POST')
|
|
113
|
+
return null;
|
|
114
|
+
// Two segments under `/api` and no more, so another plug's three-segment prefix
|
|
115
|
+
// falls through. `null`, not a 404, so a host keeps its own routes.
|
|
116
|
+
const [api, kayan, name, ...rest] = new URL(request.url).pathname.split('/').slice(1);
|
|
117
|
+
if (api !== 'api' || !kayan || !name || rest.length > 0)
|
|
118
|
+
return null;
|
|
119
|
+
const operation = table.get(`${kayan}/${name}`);
|
|
120
|
+
if (!operation)
|
|
121
|
+
return null;
|
|
122
|
+
const read = await boundedBody(request, maxBodyBytes);
|
|
123
|
+
if (read instanceof Response)
|
|
124
|
+
return read;
|
|
125
|
+
const body = await read.text();
|
|
126
|
+
// Keyless: refused before any operation, and the key names an operation's refusal.
|
|
127
|
+
const undecodable = (message) => Response.json({ code: codes[400], message }, { status: 400 });
|
|
128
|
+
let input = {};
|
|
129
|
+
if (body) {
|
|
91
130
|
try {
|
|
92
|
-
|
|
131
|
+
input = JSON.parse(body);
|
|
93
132
|
}
|
|
94
133
|
catch {
|
|
95
|
-
|
|
134
|
+
return undecodable('The request body is not JSON.');
|
|
135
|
+
}
|
|
136
|
+
// The runner reads fields off the input, so a body of `null` or `1` would be a
|
|
137
|
+
// 500 a caller provoked; every caller-provoked refusal is a 4xx.
|
|
138
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
139
|
+
return undecodable('The request body is not a JSON object.');
|
|
96
140
|
}
|
|
97
|
-
return Response.json(thrown.result, { headers: { [afterCommitHeader]: 'failed' } });
|
|
98
|
-
}
|
|
99
|
-
if (thrown instanceof AsasError) {
|
|
100
|
-
// The cause is never serialized: a conflict's driver error names the column and
|
|
101
|
-
// value the refusal's message withholds.
|
|
102
|
-
return Response.json({ code: codes[thrown.status], data: { key: thrown.key, params: thrown.params }, message: thrown.message }, { status: thrown.status });
|
|
103
141
|
}
|
|
104
142
|
try {
|
|
105
|
-
|
|
143
|
+
// Resolved after the match, so a host's own routes cost no session lookup.
|
|
144
|
+
const decoded = decodeJson(input, operation.input);
|
|
145
|
+
const who = await actor();
|
|
146
|
+
const result = await operation.call(decoded, { actor: who, context: await context?.(request, who), ...draftOptions(request) });
|
|
147
|
+
// Only a write says so, because the browser clears its banner on this value alone.
|
|
148
|
+
return Response.json(result, operation.effect === 'read' ? undefined : { headers: { [afterCommitHeader]: 'ok' } });
|
|
106
149
|
}
|
|
107
|
-
catch {
|
|
108
|
-
|
|
150
|
+
catch (thrown) {
|
|
151
|
+
if (thrown instanceof AfterCommitError) {
|
|
152
|
+
try {
|
|
153
|
+
onError(thrown.cause);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Reporting cannot change a committed result.
|
|
157
|
+
}
|
|
158
|
+
return Response.json(thrown.result, { headers: { [afterCommitHeader]: 'failed' } });
|
|
159
|
+
}
|
|
160
|
+
thrown = retryConflict(thrown);
|
|
161
|
+
if (thrown instanceof AsasError) {
|
|
162
|
+
// The cause is never serialized: a conflict's driver error names the column and
|
|
163
|
+
// value the refusal's message withholds.
|
|
164
|
+
return Response.json({ code: codes[thrown.status], data: { key: thrown.key, params: thrown.params }, message: thrown.message }, { status: thrown.status });
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
onError(thrown);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Deliberately empty: reporting may not change what is reported.
|
|
171
|
+
}
|
|
172
|
+
// Not an `AsasError` is the project's own fault, so its message is replaced.
|
|
173
|
+
return Response.json({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error' }, { status: 500 });
|
|
109
174
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
114
177
|
};
|
|
115
178
|
};
|
package/dist/asas/http/orpc.d.ts
CHANGED
|
@@ -16,9 +16,11 @@ export type OrpcHttpOptions = {
|
|
|
16
16
|
context?: (request: Request, actor: unknown) => Promise<OrpcContext> | OrpcContext;
|
|
17
17
|
/**
|
|
18
18
|
* Handed every error that is not an `AsasError`, before it is rethrown with its message replaced.
|
|
19
|
-
* A host's own deliberate `ORPCError` reaches it too, so a host logging only faults filters on the code.
|
|
19
|
+
* A host's own deliberate `ORPCError` reaches it too, so a host logging only faults filters on the code. Required.
|
|
20
20
|
*/
|
|
21
|
-
onError
|
|
21
|
+
onError: (error: unknown) => void;
|
|
22
|
+
/** The largest body a call takes, answered 413 past it: `defaultMaxBodyBytes` or the project's own. Required. */
|
|
23
|
+
maxBodyBytes: number;
|
|
22
24
|
};
|
|
23
25
|
type OrpcProcedure<Method> = Method extends (...args: infer Args) => Promise<infer Answer> ? Procedure<OrpcContext, OrpcContext, Schema<Args[0], Args[0]>, Schema<Answer, Answer>, Record<never, never>, Record<never, never>> : never;
|
|
24
26
|
/** The router, typed from the operation table: kayan, then operation. `RouterClient` over it needs no generated code. */
|
|
@@ -31,7 +33,7 @@ export type OrpcRouter<View> = {
|
|
|
31
33
|
* The oRPC plug: one procedure per operation, served over both the typed RPC protocol
|
|
32
34
|
* and plain HTTP. Needs `@orpc/server` and `@orpc/openapi`.
|
|
33
35
|
*/
|
|
34
|
-
export declare const orpcHttp: (
|
|
36
|
+
export declare const orpcHttp: (options: OrpcHttpOptions) => <View>(operations: OperationTable<View>) => {
|
|
35
37
|
name: string;
|
|
36
38
|
router: OrpcRouter<View>;
|
|
37
39
|
/**
|