@notis_ai/cli 0.2.0 → 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/README.md +39 -10
- package/package.json +7 -1
- package/src/command-specs/apps.js +852 -47
- package/src/command-specs/helpers.js +28 -3
- package/src/command-specs/index.js +1 -1
- package/src/command-specs/tools.js +33 -6
- package/src/runtime/agent-browser.js +192 -0
- package/src/runtime/app-boundary-validator.js +132 -0
- package/src/runtime/app-dev-server.js +577 -0
- package/src/runtime/app-dev-sessions.js +87 -0
- package/src/runtime/app-platform.js +646 -71
- package/src/runtime/cli-mode.generated.js +4 -0
- package/src/runtime/cli-mode.js +29 -0
- package/src/runtime/output.js +2 -2
- package/src/runtime/ports.js +15 -0
- package/src/runtime/profiles.js +34 -3
- package/src/runtime/transport.js +129 -4
- package/template/.harness/index.html.tmpl +260 -0
- package/template/app/globals.css +3 -0
- package/template/app/layout.tsx +6 -0
- package/template/app/page.tsx +55 -0
- package/template/components/ui/badge.tsx +28 -0
- package/template/components/ui/button.tsx +53 -0
- package/template/components/ui/card.tsx +56 -0
- package/template/components.json +20 -0
- package/template/lib/utils.ts +6 -0
- package/template/notis.config.ts +33 -0
- package/template/package.json +31 -0
- package/template/packages/notis-sdk/package.json +26 -0
- package/template/packages/notis-sdk/src/components/MultiSelectActionBar.tsx +272 -0
- package/template/packages/notis-sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/template/packages/notis-sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/notis-sdk/src/config.ts +71 -0
- package/template/packages/notis-sdk/src/helpers.ts +131 -0
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +50 -0
- package/template/packages/notis-sdk/src/hooks/useBackend.ts +41 -0
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +58 -0
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +88 -0
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +61 -0
- package/template/packages/notis-sdk/src/hooks/useMultiSelect.ts +502 -0
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +33 -0
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTool.ts +49 -0
- package/template/packages/notis-sdk/src/hooks/useTools.ts +56 -0
- package/template/packages/notis-sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +58 -0
- package/template/packages/notis-sdk/src/index.ts +67 -0
- package/template/packages/notis-sdk/src/provider.tsx +43 -0
- package/template/packages/notis-sdk/src/runtime.ts +182 -0
- package/template/packages/notis-sdk/src/styles.css +38 -0
- package/template/packages/notis-sdk/src/ui.ts +15 -0
- package/template/packages/notis-sdk/src/vite.ts +56 -0
- package/template/packages/notis-sdk/tsconfig.json +15 -0
- package/template/postcss.config.mjs +8 -0
- package/template/tailwind.config.ts +58 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +10 -0
- package/src/runtime/app-preview-server.js +0 -272
|
@@ -105,13 +105,30 @@ export async function healthCheck(runtime) {
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
export function updateStoredProfile({
|
|
108
|
+
export function updateStoredProfile({
|
|
109
|
+
profileName,
|
|
110
|
+
jwt,
|
|
111
|
+
apiBase,
|
|
112
|
+
authMode,
|
|
113
|
+
refreshToken,
|
|
114
|
+
accessExpiresAt,
|
|
115
|
+
refreshExpiresAt,
|
|
116
|
+
setCurrent = true,
|
|
117
|
+
}) {
|
|
109
118
|
let config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
|
|
110
119
|
const resolvedName = profileName || DEFAULT_PROFILE;
|
|
111
120
|
config.profiles[resolvedName] = {
|
|
112
121
|
...config.profiles[resolvedName],
|
|
113
122
|
...(jwt ? { jwt } : {}),
|
|
114
123
|
...(apiBase ? { api_base: apiBase } : {}),
|
|
124
|
+
...(authMode ? { auth_mode: authMode } : {}),
|
|
125
|
+
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
126
|
+
...(typeof accessExpiresAt === 'number'
|
|
127
|
+
? { access_expires_at: accessExpiresAt }
|
|
128
|
+
: {}),
|
|
129
|
+
...(typeof refreshExpiresAt === 'number'
|
|
130
|
+
? { refresh_expires_at: refreshExpiresAt }
|
|
131
|
+
: {}),
|
|
115
132
|
};
|
|
116
133
|
if (setCurrent) {
|
|
117
134
|
config.current_profile = resolvedName;
|
|
@@ -123,15 +140,23 @@ export function updateStoredProfile({ profileName, jwt, apiBase, setCurrent = tr
|
|
|
123
140
|
export function clearStoredJwt(profileName) {
|
|
124
141
|
const config = ensureProfile(loadConfig(), profileName || DEFAULT_PROFILE);
|
|
125
142
|
delete config.profiles[profileName || DEFAULT_PROFILE].jwt;
|
|
143
|
+
delete config.profiles[profileName || DEFAULT_PROFILE].auth_mode;
|
|
144
|
+
delete config.profiles[profileName || DEFAULT_PROFILE].refresh_token;
|
|
145
|
+
delete config.profiles[profileName || DEFAULT_PROFILE].access_expires_at;
|
|
146
|
+
delete config.profiles[profileName || DEFAULT_PROFILE].refresh_expires_at;
|
|
126
147
|
saveConfig(config);
|
|
127
148
|
return config;
|
|
128
149
|
}
|
|
129
150
|
|
|
130
|
-
export async function fetchToolSchema(runtime, toolName) {
|
|
151
|
+
export async function fetchToolSchema(runtime, toolName, rawToolkits) {
|
|
152
|
+
const toolkits = normalizeToolkits(rawToolkits);
|
|
131
153
|
const result = await runToolCommand({
|
|
132
154
|
runtime,
|
|
133
155
|
toolName: 'notis_find_tools',
|
|
134
|
-
arguments_: {
|
|
156
|
+
arguments_: {
|
|
157
|
+
query: toolName,
|
|
158
|
+
...(toolkits.length ? { toolkits } : {}),
|
|
159
|
+
},
|
|
135
160
|
});
|
|
136
161
|
const tools = result.payload.tools || [];
|
|
137
162
|
const match = tools.find((t) => t.name === toolName);
|
|
@@ -6,7 +6,7 @@ import { metaCommandSpecs } from './meta.js';
|
|
|
6
6
|
|
|
7
7
|
export const GROUP_SUMMARIES = {
|
|
8
8
|
auth: 'Authentication and profile management.',
|
|
9
|
-
apps: '
|
|
9
|
+
apps: 'Develop, build, and deploy Notis Apps.',
|
|
10
10
|
db: 'List, query, and update native Notis Databases.',
|
|
11
11
|
tools: 'Discover and execute generic tools exposed through Notis.',
|
|
12
12
|
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
nextIdempotencyKey,
|
|
7
7
|
parseJson,
|
|
8
8
|
parseMaybeJson,
|
|
9
|
+
normalizeToolkits,
|
|
9
10
|
resolveSearchToolkits,
|
|
10
11
|
runToolCommand,
|
|
11
12
|
validateArguments,
|
|
@@ -33,6 +34,22 @@ async function resolveJsonInput(value, label) {
|
|
|
33
34
|
return parseJson(value, label);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
const LONG_RUNNING_TOOL_TIMEOUT_MS = 600000;
|
|
38
|
+
const LONG_RUNNING_TOOL_NAMES = new Set([
|
|
39
|
+
'notis-default-generate_image_openai',
|
|
40
|
+
'notis-default-edit_image_openai',
|
|
41
|
+
'notis-default-generate_image_gemini',
|
|
42
|
+
'notis-default-edit_image_gemini',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function ensureLongRunningToolTimeout(ctx, toolNames) {
|
|
46
|
+
const names = Array.isArray(toolNames) ? toolNames : [toolNames];
|
|
47
|
+
if (!names.some((name) => LONG_RUNNING_TOOL_NAMES.has(name))) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
ctx.runtime.timeoutMs = Math.max(ctx.runtime.timeoutMs || 0, LONG_RUNNING_TOOL_TIMEOUT_MS);
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
async function toolsToolkitsHandler(ctx) {
|
|
37
54
|
const toolkits = await fetchToolkits(ctx.runtime);
|
|
38
55
|
return ctx.output.emitSuccess({
|
|
@@ -105,11 +122,14 @@ async function toolsDescribeHandler(ctx) {
|
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
async function toolsExecHandler(ctx) {
|
|
125
|
+
ensureLongRunningToolTimeout(ctx, ctx.args.toolName);
|
|
126
|
+
const toolkits = normalizeToolkits(ctx.options.toolkits);
|
|
127
|
+
|
|
108
128
|
if (ctx.options.getSchema) {
|
|
109
|
-
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName);
|
|
129
|
+
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName, ctx.options.toolkits);
|
|
110
130
|
return ctx.output.emitSuccess({
|
|
111
131
|
command: ctx.spec.command_path.join(' '),
|
|
112
|
-
data: { tool },
|
|
132
|
+
data: { tool, toolkits },
|
|
113
133
|
humanSummary: `Schema for ${ctx.args.toolName}`,
|
|
114
134
|
hints: [
|
|
115
135
|
{ command: `notis tools exec ${ctx.args.toolName} --dry-run --arguments '{}'`, reason: 'Validate arguments before executing' },
|
|
@@ -124,13 +144,13 @@ async function toolsExecHandler(ctx) {
|
|
|
124
144
|
: parseMaybeJson(rawArguments, 'arguments') || {};
|
|
125
145
|
|
|
126
146
|
if (ctx.options.dryRun) {
|
|
127
|
-
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName);
|
|
147
|
+
const tool = await fetchToolSchema(ctx.runtime, ctx.args.toolName, ctx.options.toolkits);
|
|
128
148
|
const errors = validateArguments(tool.parameters, args);
|
|
129
149
|
|
|
130
150
|
if (errors.length) {
|
|
131
151
|
return ctx.output.emitSuccess({
|
|
132
152
|
command: ctx.spec.command_path.join(' '),
|
|
133
|
-
data: { valid: false, errors, tool: ctx.args.toolName },
|
|
153
|
+
data: { valid: false, errors, tool: ctx.args.toolName, toolkits },
|
|
134
154
|
humanSummary: `Dry run failed: ${errors.length} validation error(s)`,
|
|
135
155
|
hints: [
|
|
136
156
|
{ command: `notis tools exec ${ctx.args.toolName} --get-schema`, reason: 'Review the full parameter schema' },
|
|
@@ -141,7 +161,7 @@ async function toolsExecHandler(ctx) {
|
|
|
141
161
|
|
|
142
162
|
return ctx.output.emitSuccess({
|
|
143
163
|
command: ctx.spec.command_path.join(' '),
|
|
144
|
-
data: { valid: true, tool: ctx.args.toolName, arguments: args },
|
|
164
|
+
data: { valid: true, tool: ctx.args.toolName, arguments: args, toolkits },
|
|
145
165
|
humanSummary: `Dry run passed for ${ctx.args.toolName}`,
|
|
146
166
|
hints: [
|
|
147
167
|
{ command: `notis tools exec ${ctx.args.toolName} --arguments '${JSON.stringify(args)}'`, reason: 'Execute with these arguments' },
|
|
@@ -161,7 +181,11 @@ async function toolsExecHandler(ctx) {
|
|
|
161
181
|
const result = await runToolCommand({
|
|
162
182
|
runtime: ctx.runtime,
|
|
163
183
|
toolName: 'notis_execute_tool',
|
|
164
|
-
arguments_: {
|
|
184
|
+
arguments_: {
|
|
185
|
+
tool_name: ctx.args.toolName,
|
|
186
|
+
arguments: args,
|
|
187
|
+
...(toolkits.length ? { toolkits } : {}),
|
|
188
|
+
},
|
|
165
189
|
mutating: true,
|
|
166
190
|
idempotencyKey: nextIdempotencyKey(ctx.globalOptions),
|
|
167
191
|
});
|
|
@@ -184,6 +208,7 @@ async function toolsExecHandler(ctx) {
|
|
|
184
208
|
arguments_: {
|
|
185
209
|
tool_name: ctx.args.toolName,
|
|
186
210
|
arguments: args,
|
|
211
|
+
...(toolkits.length ? { toolkits } : {}),
|
|
187
212
|
},
|
|
188
213
|
mutating: true,
|
|
189
214
|
idempotencyKey,
|
|
@@ -209,6 +234,7 @@ async function toolsExecParallelHandler(ctx) {
|
|
|
209
234
|
throw usageError(`calls[${i}] missing required "tool_name" string`);
|
|
210
235
|
}
|
|
211
236
|
}
|
|
237
|
+
ensureLongRunningToolTimeout(ctx, calls.map((call) => call.tool_name));
|
|
212
238
|
|
|
213
239
|
const data = await Promise.all(
|
|
214
240
|
calls.map((call) =>
|
|
@@ -334,6 +360,7 @@ export const toolsCommandSpecs = [
|
|
|
334
360
|
{ flags: '--get-schema', description: 'Display the tool parameter schema without executing.' },
|
|
335
361
|
{ flags: '--dry-run', description: 'Validate arguments against the tool schema without executing.' },
|
|
336
362
|
{ flags: '--watch <seconds>', description: 'Re-execute on an interval and stream results.' },
|
|
363
|
+
{ flags: '--toolkits <csv-or-json>', description: 'Optional toolkit namespace(s) to use when resolving the tool.' },
|
|
337
364
|
],
|
|
338
365
|
},
|
|
339
366
|
examples: [
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function delay(ms) {
|
|
6
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function commandError(phase, result) {
|
|
10
|
+
return {
|
|
11
|
+
phase,
|
|
12
|
+
exit_code: result.exitCode,
|
|
13
|
+
stdout: result.stdout.trim(),
|
|
14
|
+
stderr: result.stderr.trim(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
|
|
19
|
+
return new Promise((resolvePromise) => {
|
|
20
|
+
const child = spawn('agent-browser', args, {
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
env: process.env,
|
|
23
|
+
});
|
|
24
|
+
let stdout = '';
|
|
25
|
+
let stderr = '';
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
timedOut = true;
|
|
29
|
+
child.kill('SIGTERM');
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
|
|
32
|
+
child.stdout.on('data', (chunk) => {
|
|
33
|
+
stdout += chunk.toString();
|
|
34
|
+
});
|
|
35
|
+
child.stderr.on('data', (chunk) => {
|
|
36
|
+
stderr += chunk.toString();
|
|
37
|
+
});
|
|
38
|
+
child.on('error', (error) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolvePromise({
|
|
41
|
+
exitCode: 1,
|
|
42
|
+
stdout,
|
|
43
|
+
stderr: stderr || error.message,
|
|
44
|
+
timedOut,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
child.on('close', (code) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
resolvePromise({
|
|
50
|
+
exitCode: timedOut ? 124 : (code ?? 1),
|
|
51
|
+
stdout,
|
|
52
|
+
stderr,
|
|
53
|
+
timedOut,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseAgentBrowserJson(stdout) {
|
|
60
|
+
const trimmed = stdout.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return JSON.parse(trimmed);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseHarnessFromEval(stdout) {
|
|
68
|
+
const payload = parseAgentBrowserJson(stdout);
|
|
69
|
+
const rawResult = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
70
|
+
if (rawResult == null || rawResult === 'null') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return typeof rawResult === 'string' ? JSON.parse(rawResult) : rawResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readHarness(sessionName, timeoutMs) {
|
|
77
|
+
const result = await runAgentBrowser(
|
|
78
|
+
[
|
|
79
|
+
'--session',
|
|
80
|
+
sessionName,
|
|
81
|
+
'--json',
|
|
82
|
+
'eval',
|
|
83
|
+
'JSON.stringify(window.__harness || null)',
|
|
84
|
+
],
|
|
85
|
+
{ timeoutMs },
|
|
86
|
+
);
|
|
87
|
+
if (result.exitCode !== 0) {
|
|
88
|
+
return { ok: false, toolError: commandError('eval', result) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return { ok: true, harness: parseHarnessFromEval(result.stdout) };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
toolError: {
|
|
97
|
+
phase: 'eval_parse',
|
|
98
|
+
message: error instanceof Error ? error.message : String(error),
|
|
99
|
+
stdout: result.stdout.trim(),
|
|
100
|
+
stderr: result.stderr.trim(),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isAgentBrowserAvailable() {
|
|
107
|
+
const result = spawnSync('agent-browser', ['--version'], {
|
|
108
|
+
stdio: 'ignore',
|
|
109
|
+
env: process.env,
|
|
110
|
+
});
|
|
111
|
+
return result.status === 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function runHarnessRoute({
|
|
115
|
+
url,
|
|
116
|
+
sessionName,
|
|
117
|
+
timeoutMs = 10_000,
|
|
118
|
+
snapshotPath = null,
|
|
119
|
+
}) {
|
|
120
|
+
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
121
|
+
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
122
|
+
});
|
|
123
|
+
if (opened.exitCode !== 0) {
|
|
124
|
+
return {
|
|
125
|
+
mounted: false,
|
|
126
|
+
renderStarted: false,
|
|
127
|
+
errors: [],
|
|
128
|
+
runtimeCalls: [],
|
|
129
|
+
snapshotPath: null,
|
|
130
|
+
tool_error: commandError('open', opened),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
const lastRead = await readHarness(sessionName, 5000);
|
|
137
|
+
if (!lastRead.ok) {
|
|
138
|
+
await delay(250);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const harness = lastRead.harness;
|
|
142
|
+
if (harness?.mounted || (Array.isArray(harness?.errors) && harness.errors.length > 0)) {
|
|
143
|
+
await delay(250);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
await delay(250);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const finalRead = await readHarness(sessionName, 5000);
|
|
150
|
+
if (!finalRead.ok) {
|
|
151
|
+
return {
|
|
152
|
+
mounted: false,
|
|
153
|
+
renderStarted: false,
|
|
154
|
+
errors: [],
|
|
155
|
+
runtimeCalls: [],
|
|
156
|
+
snapshotPath: null,
|
|
157
|
+
tool_error: finalRead.toolError,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const harness = finalRead.harness || {};
|
|
162
|
+
let savedSnapshotPath = null;
|
|
163
|
+
if (snapshotPath) {
|
|
164
|
+
const snapshot = await runAgentBrowser(['--session', sessionName, 'snapshot', '-i'], {
|
|
165
|
+
timeoutMs: 10_000,
|
|
166
|
+
});
|
|
167
|
+
if (snapshot.exitCode === 0) {
|
|
168
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
169
|
+
writeFileSync(snapshotPath, snapshot.stdout);
|
|
170
|
+
savedSnapshotPath = snapshotPath;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const timedOut = !harness.mounted && Date.now() >= deadline;
|
|
175
|
+
return {
|
|
176
|
+
mounted: Boolean(harness.mounted),
|
|
177
|
+
renderStarted: Boolean(harness.renderStarted),
|
|
178
|
+
errors: Array.isArray(harness.errors) ? harness.errors : [],
|
|
179
|
+
runtimeCalls: Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [],
|
|
180
|
+
snapshotPath: savedSnapshotPath,
|
|
181
|
+
timed_out: timedOut,
|
|
182
|
+
tool_error: null,
|
|
183
|
+
raw: harness,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function closeAgentBrowserSession(sessionName) {
|
|
188
|
+
const result = await runAgentBrowser(['--session', sessionName, 'close'], {
|
|
189
|
+
timeoutMs: 5000,
|
|
190
|
+
});
|
|
191
|
+
return result.exitCode === 0;
|
|
192
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { usageError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const RULES_PATH = resolve(
|
|
8
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
9
|
+
'../../../../server/config/notis_app_boundary_rules.json',
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const SOURCE_FILE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.sass', '.pcss']);
|
|
13
|
+
const IGNORED_SOURCE_DIRS = new Set([
|
|
14
|
+
'node_modules',
|
|
15
|
+
'.git',
|
|
16
|
+
'.next',
|
|
17
|
+
'.notis',
|
|
18
|
+
'dist',
|
|
19
|
+
'build',
|
|
20
|
+
'coverage',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function loadBoundaryRules() {
|
|
24
|
+
return JSON.parse(readFileSync(RULES_PATH, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compileRules(entries) {
|
|
28
|
+
return (entries || []).map((entry) => ({
|
|
29
|
+
regex: new RegExp(entry.pattern, 'm'),
|
|
30
|
+
message: entry.message,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const boundaryRules = loadBoundaryRules();
|
|
35
|
+
const javascriptRules = compileRules(boundaryRules.javascript);
|
|
36
|
+
const cssRules = compileRules(boundaryRules.css);
|
|
37
|
+
|
|
38
|
+
function collectProjectFiles(projectDir, dir, results) {
|
|
39
|
+
if (!existsSync(dir)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry.name.startsWith('.')) {
|
|
46
|
+
if (entry.name !== '.storybook') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fullPath = join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (IGNORED_SOURCE_DIRS.has(entry.name)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
collectProjectFiles(projectDir, fullPath, results);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!SOURCE_FILE_EXTENSIONS.has(extname(entry.name))) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
results.push({
|
|
65
|
+
path: fullPath,
|
|
66
|
+
relPath: fullPath.slice(projectDir.length + 1),
|
|
67
|
+
content: readFileSync(fullPath, 'utf-8'),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function applyRules(content, rules, relPath) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
if (rule.regex.test(content)) {
|
|
76
|
+
errors.push(`${relPath}: ${rule.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateTextFile(relPath, content) {
|
|
83
|
+
const extension = extname(relPath);
|
|
84
|
+
if (extension === '.css' || extension === '.scss' || extension === '.sass' || extension === '.pcss') {
|
|
85
|
+
return applyRules(content, cssRules, relPath);
|
|
86
|
+
}
|
|
87
|
+
if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
|
|
88
|
+
return applyRules(content, javascriptRules, relPath);
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function collectProjectBoundaryViolations(projectDir) {
|
|
94
|
+
const files = [];
|
|
95
|
+
collectProjectFiles(projectDir, projectDir, files);
|
|
96
|
+
return files.flatMap((file) => validateTextFile(file.relPath, file.content));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function collectArtifactBoundaryViolations(files) {
|
|
100
|
+
return Object.entries(files).flatMap(([relPath, rawContent]) => {
|
|
101
|
+
const extension = extname(relPath);
|
|
102
|
+
if (!['.js', '.css', '.scss', '.sass', '.pcss'].includes(extension)) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const content = Buffer.isBuffer(rawContent)
|
|
107
|
+
? rawContent.toString('utf-8')
|
|
108
|
+
: typeof rawContent === 'string'
|
|
109
|
+
? rawContent
|
|
110
|
+
: String(rawContent ?? '');
|
|
111
|
+
|
|
112
|
+
return validateTextFile(relPath, content);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validateProjectBoundary(projectDir) {
|
|
117
|
+
const violations = collectProjectBoundaryViolations(projectDir);
|
|
118
|
+
if (violations.length > 0) {
|
|
119
|
+
throw usageError(
|
|
120
|
+
`Project violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function validateArtifactBoundary(files) {
|
|
126
|
+
const violations = collectArtifactBoundaryViolations(files);
|
|
127
|
+
if (violations.length > 0) {
|
|
128
|
+
throw usageError(
|
|
129
|
+
`Built app violates the Notis app portal boundary:\n${violations.map((error) => ` - ${error}`).join('\n')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|