@adula/kit 0.2.0-alpha.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.md +10 -0
- package/README.md +31 -0
- package/build/agent/AGENTS.template.md +28 -0
- package/build/agent/capabilities.md +13 -0
- package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
- package/build/agent/skills/idea-review/SKILL.md +6 -0
- package/build/commands/capabilities.d.ts +6 -0
- package/build/commands/capabilities.js +9 -0
- package/build/commands/doctor.d.ts +9 -0
- package/build/commands/doctor.js +32 -0
- package/build/commands/gaps.d.ts +12 -0
- package/build/commands/gaps.js +46 -0
- package/build/commands/install.d.ts +9 -0
- package/build/commands/install.js +91 -0
- package/build/commands/main.d.ts +6 -0
- package/build/commands/main.js +27 -0
- package/build/commands/module_add.d.ts +8 -0
- package/build/commands/module_add.js +57 -0
- package/build/commands/module_remove.d.ts +10 -0
- package/build/commands/module_remove.js +94 -0
- package/build/commands/resource.d.ts +8 -0
- package/build/commands/resource.js +29 -0
- package/build/commands/storage_migrate.d.ts +12 -0
- package/build/commands/storage_migrate.js +39 -0
- package/build/commands/ui.d.ts +11 -0
- package/build/commands/ui.js +210 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +79 -0
- package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
- package/build/database/migrations/1770000000000_kit_core.js +10 -0
- package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
- package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
- package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
- package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +34 -0
- package/build/providers/kit_provider.d.ts +6 -0
- package/build/providers/kit_provider.js +10 -0
- package/build/src/admin/contracts.d.ts +12 -0
- package/build/src/admin/contracts.js +93 -0
- package/build/src/admin/controller.d.ts +30 -0
- package/build/src/admin/controller.js +116 -0
- package/build/src/admin/errors.d.ts +5 -0
- package/build/src/admin/errors.js +9 -0
- package/build/src/admin/presentation.d.ts +22 -0
- package/build/src/admin/presentation.js +1 -0
- package/build/src/admin/resource_service.d.ts +170 -0
- package/build/src/admin/resource_service.js +763 -0
- package/build/src/attachments/attachment_service.d.ts +71 -0
- package/build/src/attachments/attachment_service.js +182 -0
- package/build/src/attachments/doctor.d.ts +4 -0
- package/build/src/attachments/doctor.js +28 -0
- package/build/src/attachments/storage_migrate.d.ts +39 -0
- package/build/src/attachments/storage_migrate.js +64 -0
- package/build/src/auth/ability.d.ts +22 -0
- package/build/src/auth/ability.js +31 -0
- package/build/src/auth/actor_store.d.ts +14 -0
- package/build/src/auth/actor_store.js +65 -0
- package/build/src/auth/conditions.d.ts +11 -0
- package/build/src/auth/conditions.js +81 -0
- package/build/src/auth/packed_rules.d.ts +4 -0
- package/build/src/auth/packed_rules.js +13 -0
- package/build/src/auth/sql.d.ts +11 -0
- package/build/src/auth/sql.js +114 -0
- package/build/src/commands/agent_assets.d.ts +7 -0
- package/build/src/commands/agent_assets.js +54 -0
- package/build/src/commands/doctor.d.ts +11 -0
- package/build/src/commands/doctor.js +261 -0
- package/build/src/commands/generator.d.ts +1 -0
- package/build/src/commands/generator.js +70 -0
- package/build/src/commands/source_markers.d.ts +2 -0
- package/build/src/commands/source_markers.js +9 -0
- package/build/src/core/activity.d.ts +43 -0
- package/build/src/core/activity.js +77 -0
- package/build/src/core/administration_guard.d.ts +3 -0
- package/build/src/core/administration_guard.js +37 -0
- package/build/src/core/health.d.ts +42 -0
- package/build/src/core/health.js +44 -0
- package/build/src/core/mail_test.d.ts +18 -0
- package/build/src/core/mail_test.js +88 -0
- package/build/src/core/notifications.d.ts +25 -0
- package/build/src/core/notifications.js +65 -0
- package/build/src/core/org_units.d.ts +30 -0
- package/build/src/core/org_units.js +123 -0
- package/build/src/core/roles.d.ts +72 -0
- package/build/src/core/roles.js +319 -0
- package/build/src/core/saved_views.d.ts +37 -0
- package/build/src/core/saved_views.js +139 -0
- package/build/src/core/settings.d.ts +29 -0
- package/build/src/core/settings.js +113 -0
- package/build/src/core/setup.d.ts +14 -0
- package/build/src/core/setup.js +89 -0
- package/build/src/core/ui_preferences.d.ts +12 -0
- package/build/src/core/ui_preferences.js +30 -0
- package/build/src/core/user_invitations.d.ts +23 -0
- package/build/src/core/user_invitations.js +144 -0
- package/build/src/core/users.d.ts +44 -0
- package/build/src/core/users.js +182 -0
- package/build/src/database/configure.d.ts +3 -0
- package/build/src/database/configure.js +41 -0
- package/build/src/database/schema.d.ts +7 -0
- package/build/src/database/schema.js +248 -0
- package/build/src/eslint/index.d.ts +3 -0
- package/build/src/eslint/index.js +64 -0
- package/build/src/events/adonis_jobs.d.ts +9 -0
- package/build/src/events/adonis_jobs.js +13 -0
- package/build/src/events/outbox.d.ts +18 -0
- package/build/src/events/outbox.js +33 -0
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +45 -0
- package/build/src/mcp/tools.d.ts +8 -0
- package/build/src/mcp/tools.js +156 -0
- package/build/src/org/org_service.d.ts +2 -0
- package/build/src/org/org_service.js +16 -0
- package/build/src/resource/define_resource.d.ts +4 -0
- package/build/src/resource/define_resource.js +48 -0
- package/build/src/resource/registry.d.ts +9 -0
- package/build/src/resource/registry.js +59 -0
- package/build/src/resource/types.d.ts +80 -0
- package/build/src/resource/types.js +1 -0
- package/build/src/resource/values.d.ts +5 -0
- package/build/src/resource/values.js +87 -0
- package/build/src/services/backup.d.ts +14 -0
- package/build/src/services/backup.js +59 -0
- package/build/src/services/settings.d.ts +9 -0
- package/build/src/services/settings.js +23 -0
- package/build/src/version.d.ts +1 -0
- package/build/src/version.js +2 -0
- package/build/stubs/README.md +6 -0
- package/build/stubs/controller.stub +8 -0
- package/build/stubs/main.d.ts +5 -0
- package/build/stubs/main.js +5 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/modules.stub +6 -0
- package/build/stubs/resource_contract.stub +1 -0
- package/build/stubs/resource_contract.txt +390 -0
- package/build/stubs/routes.stub +16 -0
- package/build/stubs/service.stub +9 -0
- package/package.json +104 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { abilitySchemas } from './ability.js';
|
|
2
|
+
import { predicates } from './conditions.js';
|
|
3
|
+
import { columnName } from '../resource/define_resource.js';
|
|
4
|
+
import { canonicalDate, canonicalMoney } from '../resource/values.js';
|
|
5
|
+
export function conditionSql(conditions, resource) {
|
|
6
|
+
const allowed = new Set([
|
|
7
|
+
...Object.keys(resource.fields),
|
|
8
|
+
'id',
|
|
9
|
+
'orgUnitId',
|
|
10
|
+
'createdBy',
|
|
11
|
+
'updatedBy',
|
|
12
|
+
'docStatus',
|
|
13
|
+
'version',
|
|
14
|
+
'orgPath',
|
|
15
|
+
]);
|
|
16
|
+
const parts = predicates(conditions, allowed);
|
|
17
|
+
const bindings = [];
|
|
18
|
+
const text = parts
|
|
19
|
+
.map(({ field, operator, value }) => {
|
|
20
|
+
const column = field === 'orgPath' ? 'ou.path' : `r.${resource.fields[field]?.column ?? columnName(field)}`;
|
|
21
|
+
if (resource.fields[field]?.type === 'hasMany')
|
|
22
|
+
throw new Error('Conditions on collections are unsupported');
|
|
23
|
+
const type = resource.fields[field]?.type;
|
|
24
|
+
if (type === 'json' || type === 'attachment')
|
|
25
|
+
throw new Error(`Conditions on ${type} fields require a typed adapter`);
|
|
26
|
+
const expected = ['id', 'orgUnitId', 'createdBy', 'updatedBy', 'version', 'docStatus'].includes(field) ||
|
|
27
|
+
type === 'integer' ||
|
|
28
|
+
type === 'belongsTo'
|
|
29
|
+
? 'number'
|
|
30
|
+
: type === 'boolean'
|
|
31
|
+
? 'boolean'
|
|
32
|
+
: 'string';
|
|
33
|
+
for (const operand of Array.isArray(value) ? value : [value]) {
|
|
34
|
+
const operandType = typeof operand;
|
|
35
|
+
if (operand !== null && operandType !== expected)
|
|
36
|
+
throw new Error(`Condition type mismatch for ${field}: expected ${expected}`);
|
|
37
|
+
if (typeof operand === 'number' && !Number.isSafeInteger(operand))
|
|
38
|
+
throw new Error('Integer conditions require safe integers');
|
|
39
|
+
if (type === 'money' && operand !== null && !canonicalMoney(operand))
|
|
40
|
+
throw new Error('Money conditions require canonical integer strings');
|
|
41
|
+
if ((type === 'date' || type === 'datetime') && operand !== null) {
|
|
42
|
+
if (!canonicalDate(operand, type))
|
|
43
|
+
throw new Error('Date conditions require a valid ISO date or canonical UTC timestamp');
|
|
44
|
+
if (operator === '$like')
|
|
45
|
+
throw new Error('LIKE is supported on text fields only');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (operator === '$in') {
|
|
49
|
+
const values = value;
|
|
50
|
+
if (!values.length)
|
|
51
|
+
return 'FALSE';
|
|
52
|
+
bindings.push(column, ...values.filter((v) => v !== null));
|
|
53
|
+
const nonNull = values.filter((v) => v !== null);
|
|
54
|
+
let clause = nonNull.length
|
|
55
|
+
? `COALESCE(?? IN (${nonNull.map(() => '?').join(',')}), FALSE)`
|
|
56
|
+
: '?? IS NULL';
|
|
57
|
+
if (nonNull.length && values.includes(null)) {
|
|
58
|
+
bindings.push(column);
|
|
59
|
+
clause = `(${clause} OR ?? IS NULL)`;
|
|
60
|
+
}
|
|
61
|
+
return clause;
|
|
62
|
+
}
|
|
63
|
+
bindings.push(column);
|
|
64
|
+
if (operator === '$eq' || operator === '$ne') {
|
|
65
|
+
bindings.push(value);
|
|
66
|
+
return operator === '$eq' ? '?? IS NOT DISTINCT FROM ?' : '?? IS DISTINCT FROM ?';
|
|
67
|
+
}
|
|
68
|
+
bindings.push(value);
|
|
69
|
+
if (operator === '$like')
|
|
70
|
+
return "COALESCE(CAST(?? AS text) LIKE ? ESCAPE '', FALSE)";
|
|
71
|
+
const collation = ['string', 'text', 'lookup'].includes(type ?? '') ? ' COLLATE "C"' : '';
|
|
72
|
+
return `COALESCE(??${collation} ${operator === '$lt' ? '<' : '>'} ?, FALSE)`;
|
|
73
|
+
})
|
|
74
|
+
.join(' AND ');
|
|
75
|
+
return { text: text || 'TRUE', bindings };
|
|
76
|
+
}
|
|
77
|
+
export function authorizationSql(ability, action, resource, field) {
|
|
78
|
+
// CASL rulesFor returns highest-priority rules first. CASE preserves last-rule-wins.
|
|
79
|
+
const rules = ability
|
|
80
|
+
.rulesFor(action, resource.name, field)
|
|
81
|
+
.filter((rule) => !(field === undefined && rule.inverted && rule.fields));
|
|
82
|
+
const bindings = [];
|
|
83
|
+
const branches = rules.map((rule) => {
|
|
84
|
+
for (const predicate of predicates(rule.conditions)) {
|
|
85
|
+
if (resource.fields[predicate.field]?.type === 'money' &&
|
|
86
|
+
['$lt', '$gt'].includes(predicate.operator) &&
|
|
87
|
+
!abilitySchemas
|
|
88
|
+
.get(ability)
|
|
89
|
+
?.some((schema) => schema.name === resource.name && schema.fields[predicate.field]?.type === 'money'))
|
|
90
|
+
throw new Error('Ordered money rules require buildAbility(rules, resourceSchemas)');
|
|
91
|
+
}
|
|
92
|
+
const where = conditionSql(rule.conditions, resource);
|
|
93
|
+
bindings.push(...where.bindings);
|
|
94
|
+
return `WHEN (${where.text}) THEN ${rule.inverted ? 'FALSE' : 'TRUE'}`;
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
text: branches.length ? `(CASE ${branches.join(' ')} ELSE FALSE END)` : 'FALSE',
|
|
98
|
+
bindings,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function accessibleBy(query, ability, actor, action, resource) {
|
|
102
|
+
const sql = authorizationSql(ability, action, resource);
|
|
103
|
+
query.whereRaw(sql.text, sql.bindings);
|
|
104
|
+
if (resource.scoped) {
|
|
105
|
+
query.join('org_units as ou', 'ou.id', 'r.org_unit_id');
|
|
106
|
+
query.where((scope) => {
|
|
107
|
+
if (!actor.orgPaths.length)
|
|
108
|
+
scope.whereRaw('FALSE');
|
|
109
|
+
for (const path of actor.orgPaths)
|
|
110
|
+
scope.orWhereRaw('ou.path <@ ?::ltree', [path]);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return query;
|
|
114
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const digest: (text: string) => string;
|
|
2
|
+
export declare function managedRules(text: string): string;
|
|
3
|
+
export declare function agentAssets(): Promise<{
|
|
4
|
+
rules: string;
|
|
5
|
+
skills: Record<string, string>;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function syncAgentAssets(root: string): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { KIT_VERSION } from '../version.js';
|
|
5
|
+
const endMarker = '<!-- adula-kit:end -->';
|
|
6
|
+
export const digest = (text) => createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex');
|
|
7
|
+
export function managedRules(text) {
|
|
8
|
+
const start = text.indexOf('<!-- adula-kit:start');
|
|
9
|
+
const end = text.indexOf(endMarker);
|
|
10
|
+
return start >= 0 && end > start
|
|
11
|
+
? text.slice(start, end + endMarker.length).replace(/\r\n/g, '\n')
|
|
12
|
+
: '';
|
|
13
|
+
}
|
|
14
|
+
export async function agentAssets() {
|
|
15
|
+
const skills = {};
|
|
16
|
+
for (const [source, target] of [
|
|
17
|
+
['idea-review', 'adula-idea-review'],
|
|
18
|
+
['adula-frontend-design', 'adula-frontend-design'],
|
|
19
|
+
]) {
|
|
20
|
+
skills[`.agents/skills/${target}/SKILL.md`] = await readFile(new URL(`../../agent/skills/${source}/SKILL.md`, import.meta.url), 'utf8');
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
rules: managedRules(await readFile(new URL('../../agent/AGENTS.template.md', import.meta.url), 'utf8')),
|
|
24
|
+
skills,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export async function syncAgentAssets(root) {
|
|
28
|
+
const assets = await agentAssets();
|
|
29
|
+
const path = join(root, 'AGENTS.md');
|
|
30
|
+
let existing = '';
|
|
31
|
+
try {
|
|
32
|
+
existing = await readFile(path, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== 'ENOENT')
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
const old = managedRules(existing);
|
|
39
|
+
if (!old && (existing.includes('<!-- adula-kit:start') || existing.includes(endMarker)))
|
|
40
|
+
throw new Error('Malformed managed AGENTS.md boundaries; repair markers before updating');
|
|
41
|
+
const normalized = existing.replace(/\r\n/g, '\n');
|
|
42
|
+
await writeFile(path, old
|
|
43
|
+
? normalized.replace(old, assets.rules)
|
|
44
|
+
: `${assets.rules}\n\n${existing || '## Project rules\n'}`);
|
|
45
|
+
for (const [skill, content] of Object.entries(assets.skills)) {
|
|
46
|
+
await mkdir(dirname(join(root, skill)), { recursive: true });
|
|
47
|
+
await writeFile(join(root, skill), content);
|
|
48
|
+
}
|
|
49
|
+
await writeFile(join(root, 'adula.lock.json'), `${JSON.stringify({
|
|
50
|
+
version: KIT_VERSION,
|
|
51
|
+
managedRules: digest(assets.rules),
|
|
52
|
+
skills: Object.fromEntries(Object.entries(assets.skills).map(([skillPath, content]) => [skillPath, digest(content)])),
|
|
53
|
+
}, null, 2)}\n`);
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Settings } from '../services/settings.js';
|
|
2
|
+
export type Finding = {
|
|
3
|
+
check: string;
|
|
4
|
+
status: 'pass' | 'warn' | 'fail' | 'info';
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function assessUploadSize(bytes: number): Finding;
|
|
8
|
+
export declare function diagnoseUploads(root: string): Promise<Finding>;
|
|
9
|
+
export declare function diagnoseUi(root: string): Promise<Finding[]>;
|
|
10
|
+
export declare function diagnoseAgentSkills(root: string): Promise<Finding>;
|
|
11
|
+
export declare function diagnose(root: string, settings: Settings, production: boolean, env: Record<string, string | undefined>, now?: number): Promise<Finding[]>;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
3
|
+
import { KIT_VERSION } from '../version.js';
|
|
4
|
+
import { agentAssets, managedRules, digest } from './agent_assets.js';
|
|
5
|
+
// The deployment volume and backup scripts share this application-relative path.
|
|
6
|
+
const UPLOADS_PATH = 'storage/uploads';
|
|
7
|
+
const UPLOADS_WARNING_BYTES = 5_000_000_000;
|
|
8
|
+
async function assertContained(root, path) {
|
|
9
|
+
const resolved = relative(await realpath(root), await realpath(path));
|
|
10
|
+
if (resolved === '..' ||
|
|
11
|
+
resolved.startsWith('../') ||
|
|
12
|
+
resolved.startsWith('..\\') ||
|
|
13
|
+
isAbsolute(resolved))
|
|
14
|
+
throw new Error('Path resolves outside the application');
|
|
15
|
+
}
|
|
16
|
+
async function visitFiles(root, directory, visit) {
|
|
17
|
+
const path = join(root, directory);
|
|
18
|
+
try {
|
|
19
|
+
await lstat(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT')
|
|
23
|
+
return false;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
await assertContained(root, path);
|
|
27
|
+
const walk = async (current) => {
|
|
28
|
+
const entry = await lstat(current);
|
|
29
|
+
if (entry.isSymbolicLink())
|
|
30
|
+
throw new Error('Symbolic links require manual inspection');
|
|
31
|
+
if (entry.isFile()) {
|
|
32
|
+
await visit(relative(root, current).replaceAll('\\', '/'), entry.size);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!entry.isDirectory())
|
|
36
|
+
throw new Error('Unsupported filesystem entry');
|
|
37
|
+
const children = await readdir(current);
|
|
38
|
+
for (const child of children.sort())
|
|
39
|
+
await walk(join(current, child));
|
|
40
|
+
};
|
|
41
|
+
const entry = await lstat(path);
|
|
42
|
+
if (!entry.isDirectory())
|
|
43
|
+
throw new Error('Expected a directory');
|
|
44
|
+
await walk(path);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
export function assessUploadSize(bytes) {
|
|
48
|
+
const large = bytes > UPLOADS_WARNING_BYTES;
|
|
49
|
+
return {
|
|
50
|
+
check: 'storage.uploads',
|
|
51
|
+
status: large ? 'warn' : 'pass',
|
|
52
|
+
message: `${UPLOADS_PATH} contains ${bytes} bytes (${(bytes / 1_000_000_000).toFixed(2)} GB); ${large
|
|
53
|
+
? 'above 5 GB, plan an S3 migration and verify the uploaded files before removing local copies'
|
|
54
|
+
: 'within the 5 GB local-storage warning threshold'}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function diagnoseUploads(root) {
|
|
58
|
+
try {
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
const exists = await visitFiles(root, UPLOADS_PATH, (_path, size) => {
|
|
61
|
+
bytes += size;
|
|
62
|
+
});
|
|
63
|
+
return exists
|
|
64
|
+
? assessUploadSize(bytes)
|
|
65
|
+
: {
|
|
66
|
+
check: 'storage.uploads',
|
|
67
|
+
status: 'pass',
|
|
68
|
+
message: `${UPLOADS_PATH} does not exist; no local uploads volume to measure`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return {
|
|
73
|
+
check: 'storage.uploads',
|
|
74
|
+
status: 'warn',
|
|
75
|
+
message: `Cannot completely measure ${UPLOADS_PATH}; check permissions, directory structure and symbolic links. Size is unknown`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function diagnosePages(root, changed) {
|
|
80
|
+
if (!changed.length)
|
|
81
|
+
return {
|
|
82
|
+
check: 'ui.pages',
|
|
83
|
+
status: 'pass',
|
|
84
|
+
message: 'No copied UI drift detected; page compatibility still requires typecheck and page tests',
|
|
85
|
+
};
|
|
86
|
+
const pages = [];
|
|
87
|
+
const collect = (file) => {
|
|
88
|
+
if (/\.[jt]sx$/.test(file))
|
|
89
|
+
pages.push(file);
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
await visitFiles(root, 'inertia/pages', collect);
|
|
93
|
+
// Module-local page overrides are also project-owned extension points.
|
|
94
|
+
await visitFiles(root, 'app/modules', (file) => {
|
|
95
|
+
if (/^app\/modules\/[^/]+\/pages\//.test(file))
|
|
96
|
+
collect(file);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return {
|
|
101
|
+
check: 'ui.pages',
|
|
102
|
+
status: 'warn',
|
|
103
|
+
message: `UI review required (${changed.join('; ')}), but page sources could not be completely inventoried. Check source permissions and symbolic links, then run typecheck and page tests`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
check: 'ui.pages',
|
|
108
|
+
status: 'warn',
|
|
109
|
+
message: `UI review required (${changed.join('; ')}). ${pages.length
|
|
110
|
+
? `Review all ${pages.length} project-owned page sources: ${pages.sort().join(', ')}`
|
|
111
|
+
: 'No page sources found in inertia/pages or app/modules/*/pages; review overrides in the source checkout'}. This is a conservative inventory, not import dependency analysis; run typecheck and page tests`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export async function diagnoseUi(root) {
|
|
115
|
+
try {
|
|
116
|
+
const lock = JSON.parse(await readFile(join(root, 'ui.lock.json'), 'utf8'));
|
|
117
|
+
if (typeof lock.version !== 'string' ||
|
|
118
|
+
typeof lock.kitCompatibility !== 'string' ||
|
|
119
|
+
!lock.files ||
|
|
120
|
+
typeof lock.files !== 'object' ||
|
|
121
|
+
Array.isArray(lock.files))
|
|
122
|
+
throw new Error('Invalid UI lock');
|
|
123
|
+
const compatible = lock.kitCompatibility === KIT_VERSION.split('.').slice(0, 2).join('.');
|
|
124
|
+
const customized = [];
|
|
125
|
+
for (const [file, entry] of Object.entries(lock.files)) {
|
|
126
|
+
if (!/^inertia\/components\/ui\/[a-z][a-z0-9_-]*\.tsx?$/.test(file) ||
|
|
127
|
+
!entry ||
|
|
128
|
+
typeof entry.hash !== 'string' ||
|
|
129
|
+
!/^[a-f0-9]{64}$/.test(entry.hash))
|
|
130
|
+
throw new Error('Invalid UI lock entry');
|
|
131
|
+
const path = join(root, file);
|
|
132
|
+
await assertContained(root, path);
|
|
133
|
+
const info = await lstat(path);
|
|
134
|
+
if (!info.isFile())
|
|
135
|
+
throw new Error('UI component must be a regular file');
|
|
136
|
+
if (digest(await readFile(path, 'utf8')) !== entry.hash)
|
|
137
|
+
customized.push(file);
|
|
138
|
+
}
|
|
139
|
+
const changes = [
|
|
140
|
+
...(!compatible ? ['UI registry compatibility differs from this kit version'] : []),
|
|
141
|
+
...(customized.length ? [`copied UI changed: ${customized.sort().join(', ')}`] : []),
|
|
142
|
+
];
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
check: 'ui.compatibility',
|
|
146
|
+
status: compatible ? 'pass' : 'fail',
|
|
147
|
+
message: compatible
|
|
148
|
+
? 'UI registry is compatible with this kit version'
|
|
149
|
+
: 'UI registry version requires a compatibility review',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
check: 'ui.customizations',
|
|
153
|
+
status: customized.length ? 'warn' : 'pass',
|
|
154
|
+
message: customized.length
|
|
155
|
+
? `${customized.length} project-owned UI files are customized; review diffs before updates`
|
|
156
|
+
: 'UI files match the installed registry snapshot',
|
|
157
|
+
},
|
|
158
|
+
await diagnosePages(root, changes),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
check: 'ui.registry',
|
|
165
|
+
status: 'fail',
|
|
166
|
+
message: 'UI lock or component files are missing or invalid; run adula:ui add all',
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export async function diagnoseAgentSkills(root) {
|
|
172
|
+
let valid = false;
|
|
173
|
+
try {
|
|
174
|
+
const assets = await agentAssets();
|
|
175
|
+
const lock = JSON.parse(await readFile(join(root, 'adula.lock.json'), 'utf8'));
|
|
176
|
+
valid = lock.version === KIT_VERSION && lock.managedRules === digest(assets.rules);
|
|
177
|
+
for (const [skillPath, content] of Object.entries(assets.skills)) {
|
|
178
|
+
const actual = await readFile(join(root, skillPath), 'utf8');
|
|
179
|
+
valid =
|
|
180
|
+
valid && lock.skills?.[skillPath] === digest(content) && digest(actual) === digest(content);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
valid = false;
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
check: 'agent.skills',
|
|
188
|
+
status: valid ? 'pass' : 'fail',
|
|
189
|
+
message: valid
|
|
190
|
+
? 'Managed skill hashes match this kit version'
|
|
191
|
+
: 'Run adula:install to synchronize managed rules and skills',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export async function diagnose(root, settings, production, env, now = Date.now()) {
|
|
195
|
+
const findings = [];
|
|
196
|
+
findings.push(await diagnoseUploads(root));
|
|
197
|
+
const version = await settings.get('kit.version');
|
|
198
|
+
findings.push({
|
|
199
|
+
check: 'kit.version',
|
|
200
|
+
status: version === KIT_VERSION ? 'pass' : 'fail',
|
|
201
|
+
message: version === KIT_VERSION
|
|
202
|
+
? `Kit ${KIT_VERSION} is installed`
|
|
203
|
+
: 'Run adula:install after migrations; installed kit version differs',
|
|
204
|
+
});
|
|
205
|
+
const template = await readFile(new URL('../../agent/AGENTS.template.md', import.meta.url), 'utf8');
|
|
206
|
+
let agents = '';
|
|
207
|
+
try {
|
|
208
|
+
agents = await readFile(join(root, 'AGENTS.md'), 'utf8');
|
|
209
|
+
}
|
|
210
|
+
catch { }
|
|
211
|
+
const managed = managedRules;
|
|
212
|
+
findings.push({
|
|
213
|
+
check: 'agent.rules',
|
|
214
|
+
status: managed(agents) === managed(template) ? 'pass' : 'fail',
|
|
215
|
+
message: managed(agents) === managed(template)
|
|
216
|
+
? 'Managed rules match'
|
|
217
|
+
: 'Managed AGENTS.md was changed or is missing',
|
|
218
|
+
});
|
|
219
|
+
const keys = [
|
|
220
|
+
'BACKUP_S3_ENDPOINT',
|
|
221
|
+
'BACKUP_S3_BUCKET',
|
|
222
|
+
'BACKUP_S3_REGION',
|
|
223
|
+
'BACKUP_S3_ACCESS_KEY_ID',
|
|
224
|
+
'BACKUP_S3_SECRET_ACCESS_KEY',
|
|
225
|
+
];
|
|
226
|
+
const configured = keys.every((key) => Boolean(env[key]));
|
|
227
|
+
findings.push({
|
|
228
|
+
check: 'backup.config',
|
|
229
|
+
status: configured ? 'pass' : production ? 'fail' : 'warn',
|
|
230
|
+
message: configured ? 'Offsite settings are present' : 'Offsite backup is not configured',
|
|
231
|
+
});
|
|
232
|
+
for (const [key, maxDays] of [
|
|
233
|
+
['backup.lastOffsite', 2],
|
|
234
|
+
['backup.lastRestoreTest', 90],
|
|
235
|
+
]) {
|
|
236
|
+
const date = await settings.get(key);
|
|
237
|
+
const fresh = date &&
|
|
238
|
+
Number.isFinite(Date.parse(date)) &&
|
|
239
|
+
now - Date.parse(date) >= 0 &&
|
|
240
|
+
now - Date.parse(date) < maxDays * 86400000;
|
|
241
|
+
findings.push({
|
|
242
|
+
check: key,
|
|
243
|
+
status: fresh ? 'pass' : production ? 'fail' : 'warn',
|
|
244
|
+
message: fresh ? `${key} is current` : `${key} is missing or older than ${maxDays} days`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
248
|
+
findings.push({
|
|
249
|
+
check: 'boundaries',
|
|
250
|
+
status: pkg.dependencies?.['patch-package'] ||
|
|
251
|
+
pkg.devDependencies?.['patch-package'] ||
|
|
252
|
+
pkg.pnpm?.patchedDependencies
|
|
253
|
+
? 'fail'
|
|
254
|
+
: 'pass',
|
|
255
|
+
message: 'Application may not patch kit internals',
|
|
256
|
+
});
|
|
257
|
+
findings.push(await diagnoseAgentSkills(root));
|
|
258
|
+
if (pkg.dependencies?.['@adula/ui'] || pkg.devDependencies?.['@adula/ui'])
|
|
259
|
+
findings.push(...(await diagnoseUi(root)));
|
|
260
|
+
return findings;
|
|
261
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateResource(root: string, name: string, module: string): Promise<string[]>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, access, glob } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { identifier } from '../resource/define_resource.js';
|
|
4
|
+
import { appendMarkedItem } from './source_markers.js';
|
|
5
|
+
export async function generateResource(root, name, module) {
|
|
6
|
+
identifier(name);
|
|
7
|
+
identifier(module);
|
|
8
|
+
for await (const existing of glob(`app/modules/*/resources/${name}.ts`, { cwd: root }))
|
|
9
|
+
throw new Error(`Refusing to overwrite resource registered at ${existing}`);
|
|
10
|
+
const base = resolve(root, 'app/modules', module);
|
|
11
|
+
const title = name.replace(/_/g, ' ');
|
|
12
|
+
const relative = `app/modules/${module}`;
|
|
13
|
+
const resourceSource = `import { defineResource } from '@adula/kit'\nimport Model from '#modules/${module}/models/${name}'\nimport { validator } from '#modules/${module}/validators/${name}'\n\nexport default defineResource({\n name: '${name}', label: { ar: '${title}', en: '${title}' }, model: Model, scoped: true,\n fields: { title: { type: 'string', label: { ar: 'العنوان', en: 'Title' }, required: true, searchable: true } },\n list: ['title'], form: ['title'], show: ['title'],\n actions: ['view', 'create', 'update', 'delete'], validator,\n})\n`;
|
|
14
|
+
const migration = {
|
|
15
|
+
name,
|
|
16
|
+
scoped: true,
|
|
17
|
+
fields: {
|
|
18
|
+
title: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
label: { ar: 'العنوان', en: 'Title' },
|
|
21
|
+
required: true,
|
|
22
|
+
searchable: true,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const files = {
|
|
27
|
+
[`${relative}/resources/${name}.ts`]: resourceSource,
|
|
28
|
+
[`${relative}/models/${name}.ts`]: `import { BaseModel, column } from '@adonisjs/lucid/orm'\nimport { DateTime } from 'luxon'\n\nexport default class extends BaseModel {\n static table = '${name}'\n @column({ isPrimary: true }) declare id: number\n @column() declare title: string\n @column() declare orgUnitId: number\n @column() declare createdBy: number\n @column() declare updatedBy: number\n @column.dateTime({ autoCreate: true }) declare createdAt: DateTime\n @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime\n @column.dateTime() declare deletedAt: DateTime | null\n}\n`,
|
|
29
|
+
[`${relative}/validators/${name}.ts`]: `import vine from '@vinejs/vine'\nexport const validator = vine.create({ title: vine.string().trim().minLength(1).maxLength(255) })\n`,
|
|
30
|
+
[`${relative}/migrations/${Date.now()}_create_${name}.ts`]: `import { BaseSchema } from '@adonisjs/lucid/schema'\nimport { createResourceTable } from '@adula/kit'\nexport default class extends BaseSchema {\n async up() { await createResourceTable(this.db.getWriteClient(), ${JSON.stringify(migration, null, 2)}) }\n async down() { throw new Error('Use an expand/contract migration or restore a backup') }\n}\n`,
|
|
31
|
+
[`${relative}/factories/${name}.ts`]: `import factory from '@adonisjs/lucid/factories'\nimport Model from '#modules/${module}/models/${name}'\nexport default factory.define(Model, ({ faker }) => ({ title: faker.lorem.words(3) })).build()\n`,
|
|
32
|
+
[`tests/functional/${name}.spec.ts`]: `import { resourceContract } from '#tests/helpers/resource_contract'\n\n// Extend this fixture whenever the resource form or its validator changes.\nresourceContract('${name}', ({ unique }) => ({\n input: { title: 'سجل ' + unique },\n expected: { title: 'سجل ' + unique },\n update: { title: 'سجل محدث ' + unique },\n updated: { title: 'سجل محدث ' + unique },\n}))\n`,
|
|
33
|
+
};
|
|
34
|
+
const modulePath = join(base, 'module.ts');
|
|
35
|
+
let current;
|
|
36
|
+
try {
|
|
37
|
+
current = await readFile(modulePath, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'ENOENT')
|
|
41
|
+
throw error;
|
|
42
|
+
current = `// adula:imports\nexport default { name: '${module}', label: { ar: '${module}', en: '${module}' }, dependsOn: [], resources: [/* adula:resources */] }\n`;
|
|
43
|
+
}
|
|
44
|
+
if (!current.includes('// adula:imports') || !current.includes('/* adula:resources */'))
|
|
45
|
+
throw new Error('Module registration markers are missing; no files were changed');
|
|
46
|
+
const modulesPath = join(root, 'start/modules.ts');
|
|
47
|
+
const modules = await readFile(modulesPath, 'utf8');
|
|
48
|
+
if (!modules.includes('// adula:imports') || !modules.includes('/* adula:modules */'))
|
|
49
|
+
throw new Error('Module index markers are missing; no files were changed');
|
|
50
|
+
for (const file of Object.keys(files)) {
|
|
51
|
+
try {
|
|
52
|
+
await access(join(root, file));
|
|
53
|
+
throw new Error(`Refusing to overwrite ${file}`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT')
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const [file, content] of Object.entries(files)) {
|
|
61
|
+
const path = join(root, file);
|
|
62
|
+
await mkdir(dirname(path), { recursive: true });
|
|
63
|
+
await writeFile(path, content, { flag: 'wx' });
|
|
64
|
+
}
|
|
65
|
+
await mkdir(base, { recursive: true });
|
|
66
|
+
await writeFile(modulePath, appendMarkedItem(current.replace('// adula:imports', `import resource_${name} from './resources/${name}.js'\n// adula:imports`), '/* adula:resources */', `resource_${name}`));
|
|
67
|
+
if (!modules.includes(`'#modules/${module}/module'`))
|
|
68
|
+
await writeFile(modulesPath, appendMarkedItem(modules.replace('// adula:imports', `import module_${module} from '#modules/${module}/module'\n// adula:imports`), '/* adula:modules */', `module_${module}`));
|
|
69
|
+
return Object.keys(files);
|
|
70
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Append to a marked literal array, including when the last item has no trailing comma. */
|
|
2
|
+
export function appendMarkedItem(source, marker, expression) {
|
|
3
|
+
const offset = source.indexOf(marker);
|
|
4
|
+
if (offset < 0 || source.indexOf(marker, offset + marker.length) >= 0)
|
|
5
|
+
throw new Error(`Expected one registration marker: ${marker}`);
|
|
6
|
+
const before = source.slice(0, offset).trimEnd();
|
|
7
|
+
const separator = /[\[,]$/.test(before) ? '' : ', ';
|
|
8
|
+
return source.replace(marker, `${separator}${expression}, ${marker}`);
|
|
9
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { JsonValue } from '../resource/types.js';
|
|
3
|
+
export type ActivityEntry = {
|
|
4
|
+
resource: string;
|
|
5
|
+
recordId: number;
|
|
6
|
+
actorId: number;
|
|
7
|
+
action: string;
|
|
8
|
+
changes?: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
export type ActivityFilters = {
|
|
11
|
+
resource?: string;
|
|
12
|
+
actorId?: number;
|
|
13
|
+
action?: string;
|
|
14
|
+
from?: string;
|
|
15
|
+
to?: string;
|
|
16
|
+
cursor?: string;
|
|
17
|
+
limit?: number;
|
|
18
|
+
};
|
|
19
|
+
export type ActivityRow = {
|
|
20
|
+
id: string;
|
|
21
|
+
resource: string;
|
|
22
|
+
recordId: number;
|
|
23
|
+
actorId: number;
|
|
24
|
+
actor: string | null;
|
|
25
|
+
action: string;
|
|
26
|
+
changes: JsonValue;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
};
|
|
29
|
+
export type ActivityPage = {
|
|
30
|
+
data: ActivityRow[];
|
|
31
|
+
nextCursor: string | null;
|
|
32
|
+
};
|
|
33
|
+
export declare function pageLimit(limit: unknown, fallback?: number): number;
|
|
34
|
+
export declare function logActivity(db: Knex, entry: ActivityEntry): Promise<void>;
|
|
35
|
+
export declare class ActivityAdmin {
|
|
36
|
+
private db;
|
|
37
|
+
constructor(db: Knex);
|
|
38
|
+
list(filters?: ActivityFilters): Promise<ActivityPage>;
|
|
39
|
+
facets(): Promise<{
|
|
40
|
+
resources: string[];
|
|
41
|
+
actions: string[];
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { KitError } from '../admin/errors.js';
|
|
2
|
+
export function pageLimit(limit, fallback = 50) {
|
|
3
|
+
const value = Number(limit ?? fallback);
|
|
4
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
5
|
+
return fallback;
|
|
6
|
+
return Math.min(value, 100);
|
|
7
|
+
}
|
|
8
|
+
export async function logActivity(db, entry) {
|
|
9
|
+
await db('activities').insert({
|
|
10
|
+
resource: entry.resource,
|
|
11
|
+
record_id: entry.recordId,
|
|
12
|
+
actor_id: entry.actorId,
|
|
13
|
+
action: entry.action,
|
|
14
|
+
changes: JSON.stringify(entry.changes ?? {}),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function boundary(value, end) {
|
|
18
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
19
|
+
const parsed = Date.parse(dateOnly ? `${value}T00:00:00Z` : value);
|
|
20
|
+
if (!Number.isFinite(parsed))
|
|
21
|
+
throw new KitError(422, 'E_ACTIVITY_DATE', 'تاريخ غير صالح');
|
|
22
|
+
return new Date(dateOnly && end ? parsed + 86400000 : parsed);
|
|
23
|
+
}
|
|
24
|
+
export class ActivityAdmin {
|
|
25
|
+
db;
|
|
26
|
+
constructor(db) {
|
|
27
|
+
this.db = db;
|
|
28
|
+
}
|
|
29
|
+
async list(filters = {}) {
|
|
30
|
+
const limit = pageLimit(filters.limit);
|
|
31
|
+
const query = this.db('activities as a')
|
|
32
|
+
.leftJoin('users as u', 'u.id', 'a.actor_id')
|
|
33
|
+
.select('a.*', 'u.email as actor_email')
|
|
34
|
+
.orderBy('a.id', 'desc')
|
|
35
|
+
.limit(limit + 1);
|
|
36
|
+
if (filters.resource)
|
|
37
|
+
query.where('a.resource', filters.resource);
|
|
38
|
+
if (filters.actorId)
|
|
39
|
+
query.where('a.actor_id', filters.actorId);
|
|
40
|
+
if (filters.action)
|
|
41
|
+
query.where('a.action', filters.action);
|
|
42
|
+
if (filters.from)
|
|
43
|
+
query.where('a.created_at', '>=', boundary(filters.from, false));
|
|
44
|
+
if (filters.to) {
|
|
45
|
+
const end = /^\d{4}-\d{2}-\d{2}$/.test(filters.to);
|
|
46
|
+
query.where('a.created_at', end ? '<' : '<=', boundary(filters.to, end));
|
|
47
|
+
}
|
|
48
|
+
if (filters.cursor) {
|
|
49
|
+
if (!/^\d{1,18}$/.test(filters.cursor))
|
|
50
|
+
throw new KitError(422, 'E_ACTIVITY_CURSOR', 'مؤشر الصفحة غير صالح');
|
|
51
|
+
query.where('a.id', '<', filters.cursor);
|
|
52
|
+
}
|
|
53
|
+
const rows = await query;
|
|
54
|
+
const page = rows.slice(0, limit);
|
|
55
|
+
return {
|
|
56
|
+
data: page.map((row) => ({
|
|
57
|
+
id: String(row.id),
|
|
58
|
+
resource: row.resource,
|
|
59
|
+
recordId: row.record_id,
|
|
60
|
+
actorId: row.actor_id,
|
|
61
|
+
actor: row.actor_email ?? null,
|
|
62
|
+
action: row.action,
|
|
63
|
+
changes: row.changes,
|
|
64
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
65
|
+
})),
|
|
66
|
+
nextCursor: rows.length > limit ? String(page[page.length - 1].id) : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async facets() {
|
|
70
|
+
const resources = await this.db('activities').distinct('resource').orderBy('resource');
|
|
71
|
+
const actions = await this.db('activities').distinct('action').orderBy('action');
|
|
72
|
+
return {
|
|
73
|
+
resources: resources.map((row) => row.resource),
|
|
74
|
+
actions: actions.map((row) => row.action),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|