@drhomet/mithaq 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +231 -0
- package/dist/asas/build.js +787 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +145 -0
- package/dist/asas/cli/upgrade.d.ts +20 -0
- package/dist/asas/cli/upgrade.js +147 -0
- package/dist/asas/http/fetch.d.ts +23 -0
- package/dist/asas/http/fetch.js +115 -0
- package/dist/asas/http/orpc.d.ts +43 -0
- package/dist/asas/http/orpc.js +135 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +191 -0
- package/dist/asas/runner/errors.d.ts +60 -0
- package/dist/asas/runner/errors.js +59 -0
- package/dist/asas/runner/read.d.ts +68 -0
- package/dist/asas/runner/read.js +579 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +400 -0
- package/dist/asas/runner/steps.js +271 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +213 -0
- package/dist/asas/runner/write.d.ts +444 -0
- package/dist/asas/runner/write.js +791 -0
- package/dist/auth/better-auth/access.d.ts +15 -0
- package/dist/auth/better-auth/access.js +71 -0
- package/dist/auth/better-auth/generate.d.ts +29 -0
- package/dist/auth/better-auth/generate.js +310 -0
- package/dist/auth/better-auth/index.d.ts +92 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +18 -0
- package/dist/auth/better-auth/tenant.js +26 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +736 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +123 -0
- package/dist/base/fields.js +51 -0
- package/dist/base/index.d.ts +46 -0
- package/dist/base/index.js +5 -0
- package/dist/base/kinds.d.ts +54 -0
- package/dist/base/kinds.js +78 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +80 -0
- package/dist/drizzle/index.d.ts +438 -0
- package/dist/drizzle/index.js +832 -0
- package/dist/drizzle/kinds.d.ts +27 -0
- package/dist/drizzle/kinds.js +41 -0
- package/dist/drizzle/migrate.d.ts +133 -0
- package/dist/drizzle/migrate.js +1285 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +36 -0
- package/dist/mithaq/define.d.ts +288 -0
- package/dist/mithaq/define.js +43 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +234 -0
- package/dist/mithaq/language.d.ts +335 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +106 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +954 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +358 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +27 -0
- package/dist/rich-text/index.js +58 -0
- package/dist/rich-text/rich-text.d.ts +159 -0
- package/dist/rich-text/rich-text.js +372 -0
- package/dist/screen/client.d.ts +153 -0
- package/dist/screen/client.js +68 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +226 -0
- package/dist/screen/index.d.ts +479 -0
- package/dist/screen/index.js +645 -0
- package/dist/screen/react.d.ts +95 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +43 -0
- package/dist/storage/index.js +44 -0
- package/dist/storage/storage.d.ts +384 -0
- package/dist/storage/storage.js +405 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/zod/index.d.ts +184 -0
- package/dist/zod/index.js +121 -0
- package/dist/zod/kinds.d.ts +34 -0
- package/dist/zod/kinds.js +49 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/package.json +63 -4
- package/r/mithaq-cells.json +20 -0
- package/r/mithaq-controls.json +37 -0
- package/r/mithaq-directional-icon.json +15 -0
- package/r/mithaq-expected-updated-at.json +15 -0
- package/r/mithaq-field-shell.json +18 -0
- package/r/mithaq-filter-operators.json +15 -0
- package/r/mithaq-form.json +33 -0
- package/r/mithaq-list-columns.json +23 -0
- package/r/mithaq-list-filters.json +24 -0
- package/r/mithaq-list-pagination.json +24 -0
- package/r/mithaq-list-query.json +18 -0
- package/r/mithaq-list-selection.json +19 -0
- package/r/mithaq-list.json +33 -0
- package/r/mithaq-nav.json +24 -0
- package/r/mithaq-nodes.json +34 -0
- package/r/mithaq-relationship-cell.json +20 -0
- package/r/mithaq-relationship-control.json +20 -0
- package/r/mithaq-rich-text.json +40 -0
- package/r/mithaq-screens-host.json +96 -0
- package/r/mithaq-storage.json +44 -0
- package/r/mithaq-upload.json +29 -0
- package/r/registry.json +550 -0
- package/README.md +0 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// The lock signs only when a person types back a code shown in a popup, never in
|
|
10
|
+
// this command's own output, so an agent running `mithaq lock` cannot approve it.
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, randomInt, sign, verify } from 'node:crypto';
|
|
13
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
14
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { homedir, hostname } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createInterface } from 'node:readline/promises';
|
|
18
|
+
import { receivedLanguage } from './language.js';
|
|
19
|
+
import { Refusal } from './refusal.js';
|
|
20
|
+
import { composeArkan } from './rukn.js';
|
|
21
|
+
/** The first thing anyone reads in `mithaq.lock`. It signs nothing. */
|
|
22
|
+
export const notice = "ميثاق: do not change the vocabulary of defineMithaq (src/mithaq.ts), which is its locales, kinds, rules, targets and root keys, or this file unless the project's developer asked for that change. When the vocabulary must change, tell the developer what and why, then run `mithaq lock` and let them approve it in the popup it shows.";
|
|
23
|
+
export const covenant = "ميثاق: the language changed and is not signed. Only the project's owner may approve a language change. Stop here, tell the owner what you changed and why, and ask them to run `mithaq lock`.";
|
|
24
|
+
const pathOf = (path, key) => typeof key === 'number' ? `${path}[${key}]` : /^[A-Za-z_$][\w$]*$/.test(key) ? (path ? `${path}.${key}` : key) : `${path}[${JSON.stringify(key)}]`;
|
|
25
|
+
// Keys sorted and `undefined` dropped, so one value always writes the same bytes.
|
|
26
|
+
const canonical = (value) => {
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return value.map(canonical);
|
|
29
|
+
if (value === null || typeof value !== 'object')
|
|
30
|
+
return value;
|
|
31
|
+
const record = value;
|
|
32
|
+
return Object.fromEntries(Object.keys(record)
|
|
33
|
+
.sort()
|
|
34
|
+
.filter((key) => record[key] !== undefined)
|
|
35
|
+
.map((key) => [key, canonical(record[key])]));
|
|
36
|
+
};
|
|
37
|
+
/** Each entry of a record through `pick`; an `undefined` entry stays one, and `canonical` drops it. */
|
|
38
|
+
const each = (record, pick) => Object.fromEntries(Object.entries(record ?? {}).map(([name, value]) => [name, value === undefined ? undefined : pick(value)]));
|
|
39
|
+
/**
|
|
40
|
+
* What the lock records of the object `defineMithaq` received: the locales, each kind's label, message
|
|
41
|
+
* and accepted rules, each rule's message and excludes, each target's name and capability, and the root keys.
|
|
42
|
+
*/
|
|
43
|
+
export const vocabularyOf = (received) => {
|
|
44
|
+
const language = received;
|
|
45
|
+
// A language with no `arkan` holds its kinds and rules at the root; a lock signed in that shape still reads.
|
|
46
|
+
const { kinds, rules, targets } = (language.arkan ? composeArkan(language) : language);
|
|
47
|
+
return canonical({
|
|
48
|
+
keys: Object.keys(language)
|
|
49
|
+
.filter((key) => language[key] !== undefined)
|
|
50
|
+
.sort(),
|
|
51
|
+
locales: language.locales,
|
|
52
|
+
kinds: each(kinds, ({ label, message, rules }) => ({ label, message, rules })),
|
|
53
|
+
rules: each(rules, ({ message, excludes }) => ({ message, excludes })),
|
|
54
|
+
targets: each(targets, ({ capability }) => capability ?? null),
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
/** The lock as its file holds it: canonical, two-space indented, one trailing newline. */
|
|
58
|
+
export const formatLock = (lock) => `${JSON.stringify(canonical({ ...lock, _notice: notice }), null, 2)}\n`;
|
|
59
|
+
/** Each path at which two canonical languages differ, as a line a person reads. */
|
|
60
|
+
export const changedPaths = ({ was, now, path = '' }) => {
|
|
61
|
+
if (JSON.stringify(was) === JSON.stringify(now))
|
|
62
|
+
return [];
|
|
63
|
+
const name = path || 'the whole language';
|
|
64
|
+
if (was === undefined)
|
|
65
|
+
return [`${name} was added`];
|
|
66
|
+
if (now === undefined)
|
|
67
|
+
return [`${name} was removed`];
|
|
68
|
+
const branch = (one) => one !== null && typeof one === 'object';
|
|
69
|
+
if (!branch(was) || !branch(now) || Array.isArray(was) !== Array.isArray(now)) {
|
|
70
|
+
return [branch(was) || branch(now) ? `${name} changed` : `${name} was ${JSON.stringify(was)} and is now ${JSON.stringify(now)}`];
|
|
71
|
+
}
|
|
72
|
+
const keyed = (one) => one;
|
|
73
|
+
if (Array.isArray(was)) {
|
|
74
|
+
const nowList = now;
|
|
75
|
+
return Array.from({ length: Math.max(was.length, nowList.length) }, (_, index) => changedPaths({ was: was[index], now: nowList[index], path: pathOf(path, index) })).flat();
|
|
76
|
+
}
|
|
77
|
+
return [...new Set([...Object.keys(keyed(was)), ...Object.keys(keyed(now))])]
|
|
78
|
+
.sort()
|
|
79
|
+
.flatMap((key) => changedPaths({ was: keyed(was)[key], now: keyed(now)[key], path: pathOf(path, key) }));
|
|
80
|
+
};
|
|
81
|
+
const spki = Buffer.from('302a300506032b6570032100', 'hex');
|
|
82
|
+
const publicKeyOf = (key) => createPublicKey(key).export({ type: 'spki', format: 'der' }).subarray(spki.length).toString('base64');
|
|
83
|
+
const keyFromPublic = (publicKey) => createPublicKey({ key: Buffer.concat([spki, Buffer.from(publicKey, 'base64')]), format: 'der', type: 'spki' });
|
|
84
|
+
// `canonical` drops a missing `previous`, so a seal without one signs the same bytes.
|
|
85
|
+
const signed = (language, project, signers, previous) => Buffer.from(JSON.stringify(canonical({ language, project, signers, previous })));
|
|
86
|
+
// Checked before it names a file.
|
|
87
|
+
const projectId = /^[0-9a-f]{32}$/;
|
|
88
|
+
// Printed into the popup, so short and free of control characters.
|
|
89
|
+
const labelled = (label) => typeof label === 'string' && label.length <= 64 && !/[\p{Cc}\p{Cf}]/u.test(label);
|
|
90
|
+
const labelRule = 'is longer than 64 characters or holds a control character';
|
|
91
|
+
/**
|
|
92
|
+
* Signs a canonical language with this machine's key, over `base`'s project and
|
|
93
|
+
* signers, adding this machine to the signers when it is not one of them yet.
|
|
94
|
+
*/
|
|
95
|
+
export const sealLanguage = ({ language, key, base, label = hostname(), }) => {
|
|
96
|
+
const project = base?.project ?? randomBytes(16).toString('hex');
|
|
97
|
+
const publicKey = publicKeyOf(key);
|
|
98
|
+
const signers = base?.signers.some((one) => one.publicKey === publicKey)
|
|
99
|
+
? base.signers
|
|
100
|
+
: [...(base?.signers ?? []), { publicKey, label, addedAt: new Date().toISOString() }];
|
|
101
|
+
if (!signers.every((one) => labelled(one.label)))
|
|
102
|
+
throw new Refusal(`A signer's label ${labelRule}, so nothing is signed.`);
|
|
103
|
+
const signature = sign(null, signed(language, project, signers), key).toString('base64');
|
|
104
|
+
return { language, seal: { project, signers, signature, sealedAt: new Date().toISOString() } };
|
|
105
|
+
};
|
|
106
|
+
// A key a lock never holds is one a forgery could pad itself with.
|
|
107
|
+
const unreadable = (lock) => {
|
|
108
|
+
if (lock === null || typeof lock !== 'object' || Array.isArray(lock))
|
|
109
|
+
return 'is not a JSON object';
|
|
110
|
+
if (Object.keys(lock).some((key) => !['_notice', 'language', 'seal'].includes(key)))
|
|
111
|
+
return 'holds keys other than _notice, language and seal';
|
|
112
|
+
const seal = lock.seal;
|
|
113
|
+
const signers = seal && typeof seal === 'object' && Array.isArray(seal.signers) ? seal.signers : [];
|
|
114
|
+
if (signers.some((one) => !labelled(one?.label)))
|
|
115
|
+
return `names a signer whose label ${labelRule}`;
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
/** Where a lock stands against the current language: the paths that changed, and its seal's state. */
|
|
119
|
+
export const verifyLock = (language, lock) => {
|
|
120
|
+
const changed = changedPaths({ was: lock.language, now: language });
|
|
121
|
+
const seal = lock.seal;
|
|
122
|
+
if (unreadable(lock))
|
|
123
|
+
return { changed, seal: 'invalid' };
|
|
124
|
+
if (seal === 'pending')
|
|
125
|
+
return { changed, seal: 'pending' };
|
|
126
|
+
if (seal === undefined || seal === null)
|
|
127
|
+
return { changed, seal: 'missing' };
|
|
128
|
+
const { project, signers, signature, previous } = seal;
|
|
129
|
+
if (typeof project !== 'string' || !Array.isArray(signers) || typeof signature !== 'string' || !['string', 'undefined'].includes(typeof previous)) {
|
|
130
|
+
return { changed, seal: 'invalid' };
|
|
131
|
+
}
|
|
132
|
+
const payload = signed(lock.language, project, signers, previous);
|
|
133
|
+
const valid = signers.some((one) => {
|
|
134
|
+
try {
|
|
135
|
+
return verify(null, payload, keyFromPublic(String(one.publicKey)), Buffer.from(signature, 'base64'));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return { changed, seal: valid ? 'signed' : 'invalid' };
|
|
142
|
+
};
|
|
143
|
+
/** `mithaq lock --check`. A project with no `mithaq.lock` passes with one notice; one with a lock is held to it. */
|
|
144
|
+
export const checkLock = (language, text) => {
|
|
145
|
+
if (text === undefined) {
|
|
146
|
+
return { code: 0, message: "No mithaq.lock: this project's language is not locked. Run `mithaq lock` to lock it." };
|
|
147
|
+
}
|
|
148
|
+
let lock;
|
|
149
|
+
try {
|
|
150
|
+
lock = JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { code: 1, message: `mithaq.lock is not JSON, so nobody's signature over it can be read.\n\n${covenant}` };
|
|
154
|
+
}
|
|
155
|
+
const why = unreadable(lock);
|
|
156
|
+
if (why)
|
|
157
|
+
return { code: 1, message: `mithaq.lock ${why}, so nobody's signature over it is read.\n\n${covenant}` };
|
|
158
|
+
const { changed, seal } = verifyLock(language, lock);
|
|
159
|
+
if (changed.length) {
|
|
160
|
+
return { code: 1, message: `The language is not the one mithaq.lock records:\n${changed.map((line) => ` - ${line}`).join('\n')}\n\n${covenant}` };
|
|
161
|
+
}
|
|
162
|
+
// Nobody has signed a pending lock, so it fails like a missing seal.
|
|
163
|
+
if (seal === 'pending') {
|
|
164
|
+
return {
|
|
165
|
+
code: 1,
|
|
166
|
+
message: 'Pending: mithaq.lock records this language and nobody has signed it yet. Run `mithaq lock`, and the project\'s owner approves it in the popup it shows.',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (seal !== 'signed') {
|
|
170
|
+
return { code: 1, message: `mithaq.lock's seal is ${seal === 'missing' ? 'missing' : 'not a signature of the language it records'}.\n\n${covenant}` };
|
|
171
|
+
}
|
|
172
|
+
return { code: 0, message: 'mithaq.lock is signed, and the language is the one it records.' };
|
|
173
|
+
};
|
|
174
|
+
/** Runs a program, feeding `input` on stdin, and answers its stdout, or nothing where it failed. */
|
|
175
|
+
const ask = (program, argv, input) => new Promise((resolve) => {
|
|
176
|
+
const child = execFile(program, argv, { timeout: 5 * 60_000 }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
|
|
177
|
+
child.stdin?.end(input ?? '');
|
|
178
|
+
});
|
|
179
|
+
/** Whether a program answers on this machine's PATH. */
|
|
180
|
+
const has = (program) => ask(process.platform === 'win32' ? 'where' : 'which', [program]).then((found) => Boolean(found));
|
|
181
|
+
// Opened directly, so a piped or scripted stdin never answers it.
|
|
182
|
+
const terminal = async (summary, code) => {
|
|
183
|
+
const [inPath, outPath] = process.platform === 'win32' ? ['CONIN$', 'CONOUT$'] : ['/dev/tty', '/dev/tty'];
|
|
184
|
+
const opened = await new Promise((resolve) => {
|
|
185
|
+
const input = createReadStream(inPath);
|
|
186
|
+
input.once('error', () => resolve(undefined));
|
|
187
|
+
input.once('open', () => {
|
|
188
|
+
const output = createWriteStream(outPath);
|
|
189
|
+
output.once('error', () => resolve(undefined));
|
|
190
|
+
output.once('open', () => resolve([input, output]));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
if (!opened)
|
|
194
|
+
return undefined;
|
|
195
|
+
const [input, output] = opened;
|
|
196
|
+
const line = createInterface({ input, output, terminal: true });
|
|
197
|
+
try {
|
|
198
|
+
return await line.question(`${summary}\n\nType ${code} to approve, or anything else to cancel: `);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
line.close();
|
|
202
|
+
input.destroy();
|
|
203
|
+
output.end();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const appleText = (text) => `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
207
|
+
const powershellText = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
208
|
+
// The script reaches `osascript` and PowerShell on stdin, so the code is in no command
|
|
209
|
+
// line another process can list. `zenity` and `kdialog` take it as an argument.
|
|
210
|
+
export const popup = async (summary, code) => {
|
|
211
|
+
const text = `${summary}\n\nType ${code} to approve.`;
|
|
212
|
+
if (process.platform === 'darwin') {
|
|
213
|
+
const script = `text returned of (display dialog ${appleText(text)} with title "mithaq.lock" default answer "" buttons {"Cancel", "Approve"} default button "Approve")`;
|
|
214
|
+
const answer = await ask('osascript', ['-'], script);
|
|
215
|
+
if (answer !== undefined)
|
|
216
|
+
return answer;
|
|
217
|
+
}
|
|
218
|
+
else if (process.platform === 'win32') {
|
|
219
|
+
const script = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.Interaction]::InputBox(${powershellText(text)}, 'mithaq.lock', '')`;
|
|
220
|
+
const answer = await ask('powershell', ['-NoProfile', '-NonInteractive', '-Command', '-'], script);
|
|
221
|
+
if (answer !== undefined)
|
|
222
|
+
return answer;
|
|
223
|
+
}
|
|
224
|
+
else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
|
|
225
|
+
if (await has('zenity'))
|
|
226
|
+
return ask('zenity', ['--entry', '--title=mithaq.lock', `--text=${text}`]);
|
|
227
|
+
if (await has('kdialog'))
|
|
228
|
+
return ask('kdialog', ['--title', 'mithaq.lock', '--inputbox', text]);
|
|
229
|
+
}
|
|
230
|
+
return terminal(summary, code);
|
|
231
|
+
};
|
|
232
|
+
/** Where this machine keeps its signing keys, one file per project, outside every repository. */
|
|
233
|
+
const keysHome = () => process.platform === 'win32' && process.env.APPDATA ? join(process.env.APPDATA, 'mithaq', 'keys') : join(homedir(), '.mithaq', 'keys');
|
|
234
|
+
/** This machine's key for a project: read, or made and kept (mode 0600) on first use. */
|
|
235
|
+
const machineKey = async (keys, project) => {
|
|
236
|
+
if (!projectId.test(project))
|
|
237
|
+
throw new Refusal(`mithaq.lock names the project "${project}", which is not a project id, so no key file is read for it.`);
|
|
238
|
+
const file = join(keys, `${project}.key`);
|
|
239
|
+
const pem = await readFile(file, 'utf8').catch((error) => {
|
|
240
|
+
if (error.code === 'ENOENT')
|
|
241
|
+
return undefined;
|
|
242
|
+
throw error;
|
|
243
|
+
});
|
|
244
|
+
if (pem)
|
|
245
|
+
return createPrivateKey(pem);
|
|
246
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
247
|
+
await mkdir(keys, { recursive: true, mode: 0o700 });
|
|
248
|
+
await writeFile(file, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600, flag: 'wx' });
|
|
249
|
+
return privateKey;
|
|
250
|
+
};
|
|
251
|
+
// Never stops the command it runs before: `mithaq generate --check` is where an unsigned change fails.
|
|
252
|
+
export const warnLock = async (project) => {
|
|
253
|
+
try {
|
|
254
|
+
const { defineKayan } = (await import(__rewriteRelativeImportExtension(new URL('src/mithaq.ts', project).href)));
|
|
255
|
+
const received = receivedLanguage(defineKayan);
|
|
256
|
+
if (received === undefined)
|
|
257
|
+
return;
|
|
258
|
+
const text = await readFile(new URL('mithaq.lock', project), 'utf8').catch(() => undefined);
|
|
259
|
+
const { code, message } = checkLock(vocabularyOf(received), text);
|
|
260
|
+
if (code || message.startsWith('Pending:'))
|
|
261
|
+
console.error(`${message}\n`);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// The command itself reports a project that does not load.
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const usage = 'Usage: mithaq lock | mithaq lock --check';
|
|
268
|
+
/** `mithaq lock` and `mithaq lock --check`, run from the project root. `approve` and `keys` are for tests. */
|
|
269
|
+
export const run = async ({ project, noun, flags, approve = popup, keys = keysHome(), }) => {
|
|
270
|
+
const check = flags.includes('--check');
|
|
271
|
+
if (noun !== undefined || flags.some((flag) => flag !== '--check')) {
|
|
272
|
+
console.error(usage);
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
const { defineKayan } = (await import(__rewriteRelativeImportExtension(new URL('src/mithaq.ts', project).href)));
|
|
276
|
+
const received = receivedLanguage(defineKayan);
|
|
277
|
+
if (received === undefined) {
|
|
278
|
+
throw new Refusal('src/mithaq.ts exports no `defineKayan` returned by `defineMithaq`, so there is no language to lock. Export it as `export const { defineKayan, ... } = defineMithaq({ ... })`.');
|
|
279
|
+
}
|
|
280
|
+
const language = vocabularyOf(received);
|
|
281
|
+
const file = new URL('mithaq.lock', project);
|
|
282
|
+
const text = await readFile(file, 'utf8').catch((error) => {
|
|
283
|
+
if (error.code === 'ENOENT')
|
|
284
|
+
return undefined;
|
|
285
|
+
throw error;
|
|
286
|
+
});
|
|
287
|
+
if (check) {
|
|
288
|
+
const { code, message } = checkLock(language, text);
|
|
289
|
+
if (code)
|
|
290
|
+
console.error(message);
|
|
291
|
+
else
|
|
292
|
+
console.log(message);
|
|
293
|
+
return code;
|
|
294
|
+
}
|
|
295
|
+
const lock = text === undefined ? undefined : JSON.parse(text);
|
|
296
|
+
const why = text === undefined ? undefined : unreadable(lock);
|
|
297
|
+
if (why)
|
|
298
|
+
throw new Refusal(`mithaq.lock ${why}, so it is not read. Restore it from its last commit, then run \`mithaq lock\` again.`);
|
|
299
|
+
const seal = lock !== undefined && lock.seal !== 'pending' && lock.seal ? lock.seal : undefined;
|
|
300
|
+
// A seal that does not verify is dropped whole, so no name it lists is signed by the next approval.
|
|
301
|
+
const valid = seal !== undefined && verifyLock(lock.language, lock).seal === 'signed';
|
|
302
|
+
const id = valid ? seal.project : randomBytes(16).toString('hex');
|
|
303
|
+
const signers = valid ? seal.signers : [];
|
|
304
|
+
const key = await machineKey(keys, id);
|
|
305
|
+
const label = hostname();
|
|
306
|
+
if (!labelled(label))
|
|
307
|
+
throw new Refusal(`This machine's name, which labels it in mithaq.lock, ${labelRule}, so nothing is signed.`);
|
|
308
|
+
const newSigner = !signers.some((one) => one.publicKey === publicKeyOf(key));
|
|
309
|
+
const changed = changedPaths({ was: lock?.language, now: language });
|
|
310
|
+
if (valid && !changed.length && !newSigner) {
|
|
311
|
+
console.log('mithaq.lock is signed, and the language is the one it records. Nothing to sign.');
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
const summary = [
|
|
315
|
+
changed.length ? `These changes to the language:\n${changed.map((line) => ` - ${line}`).join('\n')}` : 'The language is the one mithaq.lock records.',
|
|
316
|
+
seal && !valid ? "mithaq.lock's seal does not verify, so its signers are dropped and the lock starts over as a new project." : '',
|
|
317
|
+
newSigner ? `This machine, ${label} (key ${publicKeyOf(key)}), signs this project's lock for the first time.` : '',
|
|
318
|
+
]
|
|
319
|
+
.filter(Boolean)
|
|
320
|
+
.join('\n\n');
|
|
321
|
+
console.log(`${summary}\n\nWaiting for a person to approve in the popup on this machine's screen.`);
|
|
322
|
+
const code = randomInt(10, 100);
|
|
323
|
+
const answer = await approve(summary, code);
|
|
324
|
+
if (answer?.trim() !== String(code))
|
|
325
|
+
throw new Refusal('Not approved, so nothing was signed.');
|
|
326
|
+
await writeFile(file, formatLock(sealLanguage({ language, key, base: { project: id, signers }, label })));
|
|
327
|
+
console.log(`Approved. Wrote mithaq.lock, signed by ${label}.`);
|
|
328
|
+
return 0;
|
|
329
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defaultFields } from '../base/fields.ts';
|
|
2
|
+
import { type LabelFunction, type Localized, type ResolvedKayan } from './types.ts';
|
|
3
|
+
import type { Language } from './language.ts';
|
|
4
|
+
/** What one key resolves to: a record per locale, which is checked, or a function or string, which nothing can verify. */
|
|
5
|
+
export type Copy = Localized | LabelFunction | string;
|
|
6
|
+
/** One generated file: the keys one scope claims, and where its file goes. */
|
|
7
|
+
export type MessageScope = {
|
|
8
|
+
name: string;
|
|
9
|
+
/** The path the scope named, if it named one, in place of the default. */
|
|
10
|
+
out?: string;
|
|
11
|
+
keys: Record<string, Copy>;
|
|
12
|
+
/** The `mithaq` scope key each seeded key is written from, so a run tells an entry nobody edited from one a person wrote. */
|
|
13
|
+
seeds?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
/** The shape a project's own resolver returns, keyed by the shipped copy's own keys, so a forgotten kind is a type error. */
|
|
16
|
+
export type ProjectMessages<L extends Language = Language> = {
|
|
17
|
+
kinds: {
|
|
18
|
+
[Name in keyof L['kinds']]: string;
|
|
19
|
+
};
|
|
20
|
+
fields: {
|
|
21
|
+
[Name in keyof typeof defaultFields]: string;
|
|
22
|
+
};
|
|
23
|
+
globalType: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The project's resolver. Its parameter is typed `never`, so a project annotates it with any
|
|
27
|
+
* argument; mithaq calls it with nothing and uses what comes back.
|
|
28
|
+
*/
|
|
29
|
+
export type MessagesResolver<L extends Language = Language> = (context: never) => ProjectMessages<L>;
|
|
30
|
+
/** Every scope of one build, with the keys two scopes both claim named. */
|
|
31
|
+
export type MessageRegistry = {
|
|
32
|
+
/** The locales the language declared, in the order it declared them. */
|
|
33
|
+
locales: readonly string[];
|
|
34
|
+
scopes: readonly MessageScope[];
|
|
35
|
+
/** One entry per key more than one scope claims, naming each claim. */
|
|
36
|
+
collisions: Record<string, readonly string[]>;
|
|
37
|
+
/** Whether the project handed `buildAsas` a resolver at all. */
|
|
38
|
+
resolved: boolean;
|
|
39
|
+
/** Every localized field of the build, so `--check` can find rows written before a locale was added. */
|
|
40
|
+
localized: readonly {
|
|
41
|
+
kayan: string;
|
|
42
|
+
store: string;
|
|
43
|
+
field: string;
|
|
44
|
+
sort: string | undefined;
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The scopes one build writes, and the keys two scopes both claim. Narrowest first: a field's
|
|
49
|
+
* `label`, the kayan's `messages`, the project's resolver, then the shipped copy.
|
|
50
|
+
*/
|
|
51
|
+
export declare const messagesOf: ({ language, kayanList, resolver, }: {
|
|
52
|
+
language: Language;
|
|
53
|
+
kayanList: readonly ResolvedKayan[];
|
|
54
|
+
resolver?: MessagesResolver;
|
|
55
|
+
}) => MessageRegistry;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { defaultFields, globalTypeLabel } from '../base/fields.js';
|
|
2
|
+
import { sortableFields } from './types.js';
|
|
3
|
+
import { providerOf } from '../base/rules.js';
|
|
4
|
+
// One refusal sentence per rule and per kind, which every per-field key is seeded
|
|
5
|
+
// from, so a new locale costs one sentence per rule and kind, not one per field.
|
|
6
|
+
const floorKeys = (language) => ({
|
|
7
|
+
...Object.fromEntries(Object.entries(language.kinds).map(([name, kind]) => [`kinds.${name}`, kind.label])),
|
|
8
|
+
...Object.fromEntries(Object.entries(defaultFields)
|
|
9
|
+
.filter(([, field]) => field.label)
|
|
10
|
+
.map(([name, field]) => [`fields.${name}`, field.label])),
|
|
11
|
+
globalType: globalTypeLabel,
|
|
12
|
+
...Object.fromEntries(Object.entries(language.rules)
|
|
13
|
+
.filter(([, rule]) => rule.message)
|
|
14
|
+
.map(([name, rule]) => [`rules.${name}`, rule.message])),
|
|
15
|
+
...Object.fromEntries(Object.entries(language.kinds).map(([name, kind]) => [`kinds.${name}.invalid`, kind.message])),
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* The scopes one build writes, and the keys two scopes both claim. Narrowest first: a field's
|
|
19
|
+
* `label`, the kayan's `messages`, the project's resolver, then the shipped copy.
|
|
20
|
+
*/
|
|
21
|
+
export const messagesOf = ({ language, kayanList, resolver, }) => {
|
|
22
|
+
const scopes = [{ name: 'mithaq', keys: floorKeys(language) }];
|
|
23
|
+
const collisions = {};
|
|
24
|
+
for (const kayan of kayanList) {
|
|
25
|
+
// No prototype, so no field or operation name finds an inherited answer.
|
|
26
|
+
const keys = Object.create(null);
|
|
27
|
+
const seeds = Object.create(null);
|
|
28
|
+
const claims = Object.create(null);
|
|
29
|
+
const claim = (key, from, copy) => {
|
|
30
|
+
;
|
|
31
|
+
(claims[key] ??= []).push(from);
|
|
32
|
+
// The field wins where both claim one, which is the narrowest-first rule.
|
|
33
|
+
keys[key] ??= copy;
|
|
34
|
+
};
|
|
35
|
+
for (const [name, field] of Object.entries(kayan.fields)) {
|
|
36
|
+
if (field.description)
|
|
37
|
+
claim(`${name}.description`, `field "${name}" description`, field.description);
|
|
38
|
+
if (field.placeholder)
|
|
39
|
+
claim(`${name}.placeholder`, `field "${name}" placeholder`, field.placeholder);
|
|
40
|
+
// A label mithaq ships is the same object as the floor's, so only a label this kayan declared is its own claim.
|
|
41
|
+
if (!field.label || field.label === defaultFields[name]?.label || field.label === globalTypeLabel)
|
|
42
|
+
continue;
|
|
43
|
+
claim(name, `field "${name}" label`, field.label);
|
|
44
|
+
}
|
|
45
|
+
// Claimed once per field however many screens ask for it.
|
|
46
|
+
for (const [name, operation] of Object.entries(kayan.operations)) {
|
|
47
|
+
const fields = Object.entries(operation.targets ?? {}).flatMap(([screen, block]) => language.targets[screen]?.capability === 'screen' ? Object.entries(block?.fields ?? {}) : []);
|
|
48
|
+
for (const [field, declared] of fields) {
|
|
49
|
+
const key = `operations.${name}.${field}`;
|
|
50
|
+
if (declared.label && !(key in keys))
|
|
51
|
+
claim(key, `operation "${name}" field "${field}" label`, declared.label);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const [key, copy] of Object.entries((kayan.messages ?? {}))) {
|
|
55
|
+
claim(key, 'messages', copy);
|
|
56
|
+
}
|
|
57
|
+
// A floor rather than a claim, so a kayan's `messages` may override a seeded key
|
|
58
|
+
// without a `--check` collision.
|
|
59
|
+
for (const [name, field] of Object.entries(kayan.fields)) {
|
|
60
|
+
// A field whose shape a target replaced gets none: mithaq built no shape for it. Neither
|
|
61
|
+
// does one the client supplies on neither write, since no refusal can name it.
|
|
62
|
+
if (field.targets?.[kayan.schema] || (field.name !== 'id' && providerOf(field, 'create') !== 'client' && providerOf(field, 'update') !== 'client'))
|
|
63
|
+
continue;
|
|
64
|
+
const seed = (key, from, message) => {
|
|
65
|
+
if (key in keys)
|
|
66
|
+
return;
|
|
67
|
+
keys[key] = message;
|
|
68
|
+
seeds[key] = from;
|
|
69
|
+
};
|
|
70
|
+
seed(`${name}.invalid`, `kinds.${field.kind}.invalid`, language.kinds[field.kind].message);
|
|
71
|
+
for (const rule of Object.keys(field.rules ?? {})) {
|
|
72
|
+
// Only a rule that can refuse a value: any other key is one no refusal asks for and
|
|
73
|
+
// every locale pays a translator for.
|
|
74
|
+
const message = language.rules[rule].message;
|
|
75
|
+
if (message)
|
|
76
|
+
seed(`${name}.${rule}`, `rules.${rule}`, message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [key, from] of Object.entries(claims)) {
|
|
80
|
+
if (from.length > 1)
|
|
81
|
+
collisions[`${kayan.name}.${key}`] = from;
|
|
82
|
+
}
|
|
83
|
+
// A kayan named `mithaq` or `project` is refused at `resolve`, since the generator finds these scopes by name.
|
|
84
|
+
if (Object.keys(keys).length)
|
|
85
|
+
scopes.push({ name: kayan.name, out: kayan.out, keys, seeds });
|
|
86
|
+
}
|
|
87
|
+
if (resolver) {
|
|
88
|
+
const returned = resolver();
|
|
89
|
+
const keys = { globalType: returned.globalType };
|
|
90
|
+
for (const [name, copy] of Object.entries(returned.kinds ?? {}))
|
|
91
|
+
keys[`kinds.${name}`] = copy;
|
|
92
|
+
for (const [name, copy] of Object.entries(returned.fields ?? {}))
|
|
93
|
+
keys[`fields.${name}`] = copy;
|
|
94
|
+
scopes.push({ name: 'project', keys });
|
|
95
|
+
}
|
|
96
|
+
const localized = kayanList.flatMap((kayan) => Object.values(kayan.fields)
|
|
97
|
+
.filter((field) => field.rules?.localized)
|
|
98
|
+
.map((field) => ({
|
|
99
|
+
kayan: kayan.name,
|
|
100
|
+
store: kayan.store,
|
|
101
|
+
field: field.name,
|
|
102
|
+
// `undefined` where no field can sort a page, and the scan refuses that by name.
|
|
103
|
+
sort: sortableFields(kayan)[0],
|
|
104
|
+
})));
|
|
105
|
+
return { locales: language.locales ?? [], scopes, collisions, resolved: !!resolver, localized };
|
|
106
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Kayan, type ResolvedKayan } from './types.ts';
|
|
2
|
+
import { type Language } from './language.ts';
|
|
3
|
+
export declare const snakeCase: (name: string) => string;
|
|
4
|
+
export declare const versionsTableOf: (kayan: {
|
|
5
|
+
table: string;
|
|
6
|
+
}) => string;
|
|
7
|
+
/** The default indexes a `unique` rule makes, which a store may build as the column's own constraint. */
|
|
8
|
+
export declare const uniqueRuleIndexes: WeakSet<object>;
|
|
9
|
+
export declare const isPlainIdentifier: (value: string) => boolean;
|
|
10
|
+
export declare const resolve: (kayanList: readonly Kayan[], language: Language) => ResolvedKayan[];
|