proteum 2.1.6 → 2.1.8
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/AGENTS.md +6 -1
- package/README.md +5 -1
- package/agents/project/AGENTS.md +7 -1
- package/agents/project/diagnostics.md +4 -0
- package/agents/project/optimizations.md +1 -0
- package/cli/bin.js +0 -8
- package/cli/commands/build.ts +60 -9
- package/cli/commands/dev.ts +236 -6
- package/cli/compiler/artifacts/manifest.ts +8 -3
- package/cli/compiler/common/bundleAnalysis.ts +56 -1
- package/cli/index.ts +37 -2
- package/cli/presentation/commands.ts +30 -4
- package/cli/presentation/devSession.ts +6 -26
- package/cli/presentation/help.ts +4 -0
- package/cli/presentation/welcome.ts +63 -0
- package/cli/runtime/commands.ts +40 -3
- package/cli/runtime/devSessions.ts +337 -0
- package/cli/scaffold/index.ts +4 -2
- package/cli/scaffold/templates.ts +7 -1
- package/cli/utils/agents.ts +102 -11
- package/package.json +1 -1
- package/server/app/container/index.ts +7 -1
- package/server/services/router/http/index.ts +52 -24
- package/server/services/router/index.ts +66 -10
- package/server/services/router/response/page/document.tsx +26 -14
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
|
|
5
|
+
export const devSessionRegistryVersion = 1 as const;
|
|
6
|
+
|
|
7
|
+
export type TDevSessionState = 'starting' | 'ready';
|
|
8
|
+
|
|
9
|
+
export type TDevSessionRecord = {
|
|
10
|
+
version: typeof devSessionRegistryVersion;
|
|
11
|
+
pid: number;
|
|
12
|
+
appRoot: string;
|
|
13
|
+
routerPort: number;
|
|
14
|
+
publicUrl: string;
|
|
15
|
+
startedAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
sessionFilePath: string;
|
|
18
|
+
state: TDevSessionState;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TDevSessionInspection = {
|
|
22
|
+
sessionFilePath: string;
|
|
23
|
+
record: TDevSessionRecord | null;
|
|
24
|
+
live: boolean;
|
|
25
|
+
stale: boolean;
|
|
26
|
+
invalid: boolean;
|
|
27
|
+
parseError: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TStopDevSessionResult = {
|
|
31
|
+
sessionFilePath: string;
|
|
32
|
+
pid: number | null;
|
|
33
|
+
routerPort: number | null;
|
|
34
|
+
publicUrl: string;
|
|
35
|
+
state: TDevSessionState | '';
|
|
36
|
+
matched: boolean;
|
|
37
|
+
stopped: boolean;
|
|
38
|
+
removed: boolean;
|
|
39
|
+
stale: boolean;
|
|
40
|
+
live: boolean;
|
|
41
|
+
invalid: boolean;
|
|
42
|
+
parseError: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const defaultRegistryDirectoryParts = ['var', 'run', 'proteum', 'dev'];
|
|
46
|
+
|
|
47
|
+
const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
48
|
+
|
|
49
|
+
const isRecordShape = (value: unknown): value is TDevSessionRecord => {
|
|
50
|
+
if (!value || typeof value !== 'object') return false;
|
|
51
|
+
|
|
52
|
+
const candidate = value as Partial<TDevSessionRecord>;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
candidate.version === devSessionRegistryVersion &&
|
|
56
|
+
typeof candidate.pid === 'number' &&
|
|
57
|
+
Number.isInteger(candidate.pid) &&
|
|
58
|
+
candidate.pid > 0 &&
|
|
59
|
+
typeof candidate.appRoot === 'string' &&
|
|
60
|
+
candidate.appRoot.length > 0 &&
|
|
61
|
+
typeof candidate.routerPort === 'number' &&
|
|
62
|
+
Number.isInteger(candidate.routerPort) &&
|
|
63
|
+
candidate.routerPort > 0 &&
|
|
64
|
+
typeof candidate.publicUrl === 'string' &&
|
|
65
|
+
typeof candidate.startedAt === 'string' &&
|
|
66
|
+
typeof candidate.updatedAt === 'string' &&
|
|
67
|
+
typeof candidate.sessionFilePath === 'string' &&
|
|
68
|
+
candidate.sessionFilePath.length > 0 &&
|
|
69
|
+
(candidate.state === 'starting' || candidate.state === 'ready')
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, signal);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const errno = error as NodeJS.ErrnoException;
|
|
79
|
+
|
|
80
|
+
if (errno.code === 'ESRCH') return false;
|
|
81
|
+
if (errno.code === 'EPERM') return true;
|
|
82
|
+
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const isProcessAlive = (pid: number) => canSignalProcess(pid, 0);
|
|
88
|
+
|
|
89
|
+
const waitForProcessExit = async (pid: number, timeoutMs: number) => {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
if (!isProcessAlive(pid)) return true;
|
|
94
|
+
await sleep(100);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return !isProcessAlive(pid);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const getDevSessionRegistryDirectory = (appRoot: string) => path.join(appRoot, ...defaultRegistryDirectoryParts);
|
|
101
|
+
|
|
102
|
+
export const resolveDevSessionFilePath = ({
|
|
103
|
+
appRoot,
|
|
104
|
+
port,
|
|
105
|
+
sessionFilePath,
|
|
106
|
+
}: {
|
|
107
|
+
appRoot: string;
|
|
108
|
+
port: number;
|
|
109
|
+
sessionFilePath?: string;
|
|
110
|
+
}) => {
|
|
111
|
+
if (sessionFilePath && sessionFilePath.trim()) {
|
|
112
|
+
return path.isAbsolute(sessionFilePath)
|
|
113
|
+
? path.normalize(sessionFilePath)
|
|
114
|
+
: path.resolve(appRoot, sessionFilePath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return path.join(getDevSessionRegistryDirectory(appRoot), `${port}.json`);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const createDevSessionRecord = ({
|
|
121
|
+
appRoot,
|
|
122
|
+
port,
|
|
123
|
+
sessionFilePath,
|
|
124
|
+
}: {
|
|
125
|
+
appRoot: string;
|
|
126
|
+
port: number;
|
|
127
|
+
sessionFilePath: string;
|
|
128
|
+
}): TDevSessionRecord => {
|
|
129
|
+
const timestamp = new Date().toISOString();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
version: devSessionRegistryVersion,
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
appRoot,
|
|
135
|
+
routerPort: port,
|
|
136
|
+
publicUrl: '',
|
|
137
|
+
startedAt: timestamp,
|
|
138
|
+
updatedAt: timestamp,
|
|
139
|
+
sessionFilePath,
|
|
140
|
+
state: 'starting',
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const writeDevSessionRecord = async (record: TDevSessionRecord) => {
|
|
145
|
+
await fs.ensureDir(path.dirname(record.sessionFilePath));
|
|
146
|
+
await fs.writeJson(record.sessionFilePath, record, { spaces: 2 });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const updateDevSessionRecord = async ({
|
|
150
|
+
sessionFilePath,
|
|
151
|
+
patch,
|
|
152
|
+
}: {
|
|
153
|
+
sessionFilePath: string;
|
|
154
|
+
patch: Partial<Omit<TDevSessionRecord, 'version' | 'pid' | 'appRoot' | 'routerPort' | 'startedAt' | 'sessionFilePath'>>;
|
|
155
|
+
}) => {
|
|
156
|
+
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
157
|
+
if (!inspection || !inspection.record) return;
|
|
158
|
+
|
|
159
|
+
await writeDevSessionRecord({
|
|
160
|
+
...inspection.record,
|
|
161
|
+
...patch,
|
|
162
|
+
updatedAt: new Date().toISOString(),
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const removeDevSessionRecord = async (sessionFilePath: string) => {
|
|
167
|
+
await fs.remove(sessionFilePath);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const removeDevSessionRecordSync = (sessionFilePath: string) => {
|
|
171
|
+
try {
|
|
172
|
+
fs.removeSync(sessionFilePath);
|
|
173
|
+
} catch {
|
|
174
|
+
// Best-effort cleanup during process exit.
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const inspectDevSessionFile = async (sessionFilePath: string): Promise<TDevSessionInspection | null> => {
|
|
179
|
+
if (!(await fs.pathExists(sessionFilePath))) return null;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const rawValue = await fs.readJson(sessionFilePath);
|
|
183
|
+
if (!isRecordShape(rawValue)) {
|
|
184
|
+
return {
|
|
185
|
+
sessionFilePath,
|
|
186
|
+
record: null,
|
|
187
|
+
live: false,
|
|
188
|
+
stale: true,
|
|
189
|
+
invalid: true,
|
|
190
|
+
parseError: 'Session file contents do not match the Proteum dev session schema.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const record = rawValue;
|
|
195
|
+
const live = isProcessAlive(record.pid);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
sessionFilePath,
|
|
199
|
+
record,
|
|
200
|
+
live,
|
|
201
|
+
stale: !live,
|
|
202
|
+
invalid: false,
|
|
203
|
+
parseError: '',
|
|
204
|
+
};
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
sessionFilePath,
|
|
208
|
+
record: null,
|
|
209
|
+
live: false,
|
|
210
|
+
stale: true,
|
|
211
|
+
invalid: true,
|
|
212
|
+
parseError: error instanceof Error ? error.message : String(error),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const listDevSessionFiles = async ({
|
|
218
|
+
appRoot,
|
|
219
|
+
sessionFilePath,
|
|
220
|
+
}: {
|
|
221
|
+
appRoot: string;
|
|
222
|
+
sessionFilePath?: string;
|
|
223
|
+
}) => {
|
|
224
|
+
if (sessionFilePath && sessionFilePath.trim())
|
|
225
|
+
return [resolveDevSessionFilePath({ appRoot, port: 1, sessionFilePath })];
|
|
226
|
+
|
|
227
|
+
const registryDirectory = getDevSessionRegistryDirectory(appRoot);
|
|
228
|
+
if (!(await fs.pathExists(registryDirectory))) return [];
|
|
229
|
+
|
|
230
|
+
const entries = await fs.readdir(registryDirectory);
|
|
231
|
+
|
|
232
|
+
return entries
|
|
233
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
234
|
+
.sort((left, right) => left.localeCompare(right))
|
|
235
|
+
.map((entry) => path.join(registryDirectory, entry));
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const listDevSessionInspections = async ({
|
|
239
|
+
appRoot,
|
|
240
|
+
sessionFilePath,
|
|
241
|
+
}: {
|
|
242
|
+
appRoot: string;
|
|
243
|
+
sessionFilePath?: string;
|
|
244
|
+
}) => {
|
|
245
|
+
const sessionFilePaths = await listDevSessionFiles({ appRoot, sessionFilePath });
|
|
246
|
+
const inspections = await Promise.all(sessionFilePaths.map((entryPath) => inspectDevSessionFile(entryPath)));
|
|
247
|
+
|
|
248
|
+
return inspections.filter((inspection): inspection is TDevSessionInspection => inspection !== null);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const stopDevSessionFile = async (sessionFilePath: string): Promise<TStopDevSessionResult> => {
|
|
252
|
+
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
253
|
+
|
|
254
|
+
if (!inspection) {
|
|
255
|
+
return {
|
|
256
|
+
sessionFilePath,
|
|
257
|
+
pid: null,
|
|
258
|
+
routerPort: null,
|
|
259
|
+
publicUrl: '',
|
|
260
|
+
state: '',
|
|
261
|
+
matched: false,
|
|
262
|
+
stopped: false,
|
|
263
|
+
removed: false,
|
|
264
|
+
stale: false,
|
|
265
|
+
live: false,
|
|
266
|
+
invalid: false,
|
|
267
|
+
parseError: '',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!inspection.record) {
|
|
272
|
+
await removeDevSessionRecord(sessionFilePath);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
sessionFilePath,
|
|
276
|
+
pid: null,
|
|
277
|
+
routerPort: null,
|
|
278
|
+
publicUrl: '',
|
|
279
|
+
state: '',
|
|
280
|
+
matched: true,
|
|
281
|
+
stopped: true,
|
|
282
|
+
removed: true,
|
|
283
|
+
stale: true,
|
|
284
|
+
live: false,
|
|
285
|
+
invalid: true,
|
|
286
|
+
parseError: inspection.parseError,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { record } = inspection;
|
|
291
|
+
|
|
292
|
+
if (!inspection.live) {
|
|
293
|
+
await removeDevSessionRecord(sessionFilePath);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
sessionFilePath,
|
|
297
|
+
pid: record.pid,
|
|
298
|
+
routerPort: record.routerPort,
|
|
299
|
+
publicUrl: record.publicUrl,
|
|
300
|
+
state: record.state,
|
|
301
|
+
matched: true,
|
|
302
|
+
stopped: true,
|
|
303
|
+
removed: true,
|
|
304
|
+
stale: true,
|
|
305
|
+
live: false,
|
|
306
|
+
invalid: false,
|
|
307
|
+
parseError: '',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (canSignalProcess(record.pid, 'SIGTERM')) {
|
|
312
|
+
const exitedAfterTerm = await waitForProcessExit(record.pid, 5000);
|
|
313
|
+
if (!exitedAfterTerm && canSignalProcess(record.pid, 'SIGKILL')) {
|
|
314
|
+
await waitForProcessExit(record.pid, 2000);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const live = isProcessAlive(record.pid);
|
|
319
|
+
if (!live) {
|
|
320
|
+
await removeDevSessionRecord(sessionFilePath);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
sessionFilePath,
|
|
325
|
+
pid: record.pid,
|
|
326
|
+
routerPort: record.routerPort,
|
|
327
|
+
publicUrl: record.publicUrl,
|
|
328
|
+
state: record.state,
|
|
329
|
+
matched: true,
|
|
330
|
+
stopped: !live,
|
|
331
|
+
removed: !live,
|
|
332
|
+
stale: !live,
|
|
333
|
+
live,
|
|
334
|
+
invalid: false,
|
|
335
|
+
parseError: '',
|
|
336
|
+
};
|
|
337
|
+
};
|
package/cli/scaffold/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { UsageError } from 'clipanion';
|
|
|
5
5
|
|
|
6
6
|
import cli from '..';
|
|
7
7
|
import { loadApplicationIdentityConfig } from '../../common/applicationConfigLoader';
|
|
8
|
-
import { ensureProjectAgentSymlinks } from '../utils/agents';
|
|
8
|
+
import { ensureProjectAgentSymlinks, renderProjectInstructionGitignoreBlock } from '../utils/agents';
|
|
9
9
|
import { runProcess } from '../utils/runProcess';
|
|
10
10
|
import {
|
|
11
11
|
createClientTsconfigTemplate,
|
|
@@ -633,7 +633,9 @@ const createInitFilePlans = (config: TScaffoldInitConfig): TScaffoldFilePlan[] =
|
|
|
633
633
|
},
|
|
634
634
|
{
|
|
635
635
|
relativePath: '.gitignore',
|
|
636
|
-
content: createGitignoreTemplate(
|
|
636
|
+
content: createGitignoreTemplate({
|
|
637
|
+
projectInstructionGitignoreBlock: renderProjectInstructionGitignoreBlock({ coreRoot: cli.paths.core.root }),
|
|
638
|
+
}),
|
|
637
639
|
},
|
|
638
640
|
{
|
|
639
641
|
relativePath: 'eslint.config.mjs',
|
|
@@ -237,7 +237,11 @@ export const createServerTsconfigTemplate = () => `{
|
|
|
237
237
|
}
|
|
238
238
|
`;
|
|
239
239
|
|
|
240
|
-
export const createGitignoreTemplate = (
|
|
240
|
+
export const createGitignoreTemplate = ({
|
|
241
|
+
projectInstructionGitignoreBlock,
|
|
242
|
+
}: {
|
|
243
|
+
projectInstructionGitignoreBlock: string;
|
|
244
|
+
}) => `node_modules
|
|
241
245
|
/.proteum
|
|
242
246
|
/.cache
|
|
243
247
|
/bin
|
|
@@ -245,6 +249,8 @@ export const createGitignoreTemplate = () => `node_modules
|
|
|
245
249
|
/var
|
|
246
250
|
/proteum.connected.json
|
|
247
251
|
.env
|
|
252
|
+
|
|
253
|
+
${projectInstructionGitignoreBlock}
|
|
248
254
|
`;
|
|
249
255
|
|
|
250
256
|
export const createEnvTemplate = ({ port, url }: { port: number; url: string }) => `ENV_NAME=local
|
package/cli/utils/agents.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { logVerbose } from '../runtime/verbose';
|
|
|
11
11
|
- TYPES
|
|
12
12
|
----------------------------------*/
|
|
13
13
|
|
|
14
|
+
type TProjectInstructionArgs = { coreRoot: string };
|
|
14
15
|
type TEnsureProjectAgentSymlinksArgs = { appRoot: string; coreRoot: string };
|
|
15
16
|
|
|
16
17
|
type TAgentLinkDefinition = { projectPath: string; sourcePath: string; ensureParentDir?: boolean };
|
|
@@ -34,41 +35,107 @@ const projectAgentLinkDefinitions: TAgentLinkDefinition[] = [
|
|
|
34
35
|
{ projectPath: path.join('server', 'routes', 'AGENTS.md'), sourcePath: path.join('server', 'routes', 'AGENTS.md') },
|
|
35
36
|
{ projectPath: path.join('tests', 'e2e', 'AGENTS.md'), sourcePath: path.join('tests', 'AGENTS.md') },
|
|
36
37
|
];
|
|
38
|
+
const projectInstructionGitignoreBlockStart = '# Proteum-managed instruction symlinks';
|
|
39
|
+
const projectInstructionGitignoreBlockEnd = '# End Proteum-managed instruction symlinks';
|
|
37
40
|
|
|
38
41
|
/*----------------------------------
|
|
39
42
|
- PUBLIC API
|
|
40
43
|
----------------------------------*/
|
|
41
44
|
|
|
42
45
|
export function ensureProjectAgentSymlinks({ appRoot, coreRoot }: TEnsureProjectAgentSymlinksArgs) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
ensureSymlinks(appRoot, getProjectAgentLinkDefinitions({ coreRoot }), '[agents]');
|
|
47
|
+
ensureSymlinks(appRoot, getProjectSkillLinkDefinitions({ coreRoot }), '[skills]');
|
|
48
|
+
ensureProjectInstructionGitignoreEntries({ appRoot, coreRoot });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getProjectInstructionGitignoreEntries({ coreRoot }: TProjectInstructionArgs) {
|
|
52
|
+
const entries = new Set<string>();
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
for (const linkDefinition of [
|
|
55
|
+
...getProjectAgentLinkDefinitions({ coreRoot }),
|
|
56
|
+
...getProjectSkillLinkDefinitions({ coreRoot }),
|
|
57
|
+
]) {
|
|
58
|
+
entries.add(`/${normalizeProjectPathForGitignore(linkDefinition.projectPath)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Array.from(entries);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderProjectInstructionGitignoreBlock({ coreRoot }: TProjectInstructionArgs) {
|
|
65
|
+
return [
|
|
66
|
+
projectInstructionGitignoreBlockStart,
|
|
67
|
+
...getProjectInstructionGitignoreEntries({ coreRoot }),
|
|
68
|
+
projectInstructionGitignoreBlockEnd,
|
|
69
|
+
].join('\n');
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/*----------------------------------
|
|
54
73
|
- HELPERS
|
|
55
74
|
----------------------------------*/
|
|
56
75
|
|
|
57
|
-
function
|
|
76
|
+
function getProjectAgentLinkDefinitions({ coreRoot }: TProjectInstructionArgs) {
|
|
77
|
+
const agentSourceRoot = path.join(coreRoot, 'agents', 'project');
|
|
78
|
+
|
|
79
|
+
return projectAgentLinkDefinitions.map((linkDefinition) => ({
|
|
80
|
+
...linkDefinition,
|
|
81
|
+
sourcePath: path.join(agentSourceRoot, linkDefinition.sourcePath),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getProjectSkillLinkDefinitions({ coreRoot }: TProjectInstructionArgs) {
|
|
58
86
|
const frameworkSkillsRoot = path.join(coreRoot, 'skills');
|
|
59
|
-
if (!fs.existsSync(frameworkSkillsRoot)) return;
|
|
87
|
+
if (!fs.existsSync(frameworkSkillsRoot)) return [];
|
|
60
88
|
|
|
61
|
-
|
|
89
|
+
return fs
|
|
62
90
|
.readdirSync(frameworkSkillsRoot, { withFileTypes: true })
|
|
63
91
|
.filter((dirent) => dirent.isDirectory())
|
|
92
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
64
93
|
.map((dirent) => ({
|
|
65
94
|
projectPath: path.join('skills', dirent.name),
|
|
66
95
|
sourcePath: path.join(frameworkSkillsRoot, dirent.name),
|
|
67
96
|
ensureParentDir: true,
|
|
68
97
|
}))
|
|
69
98
|
.filter((linkDefinition) => pathEntryExists(path.join(linkDefinition.sourcePath, 'SKILL.md')));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ensureProjectInstructionGitignoreEntries({ appRoot, coreRoot }: TEnsureProjectAgentSymlinksArgs) {
|
|
102
|
+
const gitignoreFilepath = path.join(appRoot, '.gitignore');
|
|
103
|
+
if (!pathEntryExists(gitignoreFilepath)) return;
|
|
104
|
+
|
|
105
|
+
const managedEntries = getProjectInstructionGitignoreEntries({ coreRoot });
|
|
106
|
+
const managedNormalizedEntries = new Set(managedEntries.map(normalizeGitignoreEntry));
|
|
107
|
+
const lines = fs.readFileSync(gitignoreFilepath, 'utf8').split(/\r?\n/);
|
|
108
|
+
const filteredLines: string[] = [];
|
|
109
|
+
|
|
110
|
+
let insideManagedBlock = false;
|
|
70
111
|
|
|
71
|
-
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
const trimmedLine = line.trim();
|
|
114
|
+
|
|
115
|
+
if (trimmedLine === projectInstructionGitignoreBlockStart) {
|
|
116
|
+
insideManagedBlock = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (trimmedLine === projectInstructionGitignoreBlockEnd) {
|
|
121
|
+
insideManagedBlock = false;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (insideManagedBlock) continue;
|
|
126
|
+
if (shouldSkipLegacyManagedGitignoreLine(line, managedNormalizedEntries)) continue;
|
|
127
|
+
|
|
128
|
+
filteredLines.push(line);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const baseContent = trimTrailingBlankLines(filteredLines).join('\n');
|
|
132
|
+
const managedBlock = renderProjectInstructionGitignoreBlock({ coreRoot });
|
|
133
|
+
const nextContent = baseContent ? `${baseContent}\n\n${managedBlock}\n` : `${managedBlock}\n`;
|
|
134
|
+
|
|
135
|
+
if (nextContent === fs.readFileSync(gitignoreFilepath, 'utf8')) return;
|
|
136
|
+
|
|
137
|
+
fs.writeFileSync(gitignoreFilepath, nextContent);
|
|
138
|
+
logVerbose(`[agents] Updated ${path.relative(appRoot, gitignoreFilepath) || '.gitignore'} with Proteum-managed instruction ignore entries.`);
|
|
72
139
|
}
|
|
73
140
|
|
|
74
141
|
function ensureSymlinks(appRoot: string, linkDefinitions: TAgentLinkDefinition[], logPrefix: string) {
|
|
@@ -93,6 +160,30 @@ function ensureSymlinks(appRoot: string, linkDefinitions: TAgentLinkDefinition[]
|
|
|
93
160
|
}
|
|
94
161
|
}
|
|
95
162
|
|
|
163
|
+
function normalizeProjectPathForGitignore(projectPath: string) {
|
|
164
|
+
return projectPath.replace(/\\/g, '/');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeGitignoreEntry(value: string) {
|
|
168
|
+
return value.trim().replace(/#.*/, '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function shouldSkipLegacyManagedGitignoreLine(line: string, managedNormalizedEntries: Set<string>) {
|
|
172
|
+
const normalizedLine = normalizeGitignoreEntry(line);
|
|
173
|
+
if (!normalizedLine) return false;
|
|
174
|
+
if (line.trim().startsWith('#')) return false;
|
|
175
|
+
|
|
176
|
+
return managedNormalizedEntries.has(normalizedLine);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function trimTrailingBlankLines(lines: string[]) {
|
|
180
|
+
const trimmedLines = [...lines];
|
|
181
|
+
|
|
182
|
+
while (trimmedLines.length > 0 && trimmedLines[trimmedLines.length - 1].trim() === '') trimmedLines.pop();
|
|
183
|
+
|
|
184
|
+
return trimmedLines;
|
|
185
|
+
}
|
|
186
|
+
|
|
96
187
|
function pathEntryExists(filepath: string) {
|
|
97
188
|
try {
|
|
98
189
|
fs.lstatSync(filepath);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proteum",
|
|
3
3
|
"description": "LLM-first Opinionated Typescript Framework for web applications.",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.8",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -74,7 +74,13 @@ export class ApplicationContainer<TServicesIndex extends StartedServicesIndex =
|
|
|
74
74
|
|
|
75
75
|
// Start application
|
|
76
76
|
try {
|
|
77
|
-
this.application.start()
|
|
77
|
+
void this.application.start().catch(async (error) => {
|
|
78
|
+
try {
|
|
79
|
+
await this.handleBug(error, 'Failed to start the Application');
|
|
80
|
+
} finally {
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
78
84
|
} catch (error) {
|
|
79
85
|
this.handleBug(error, 'Failed to start the Application');
|
|
80
86
|
process.exit(1);
|
|
@@ -92,6 +92,27 @@ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPol
|
|
|
92
92
|
};
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
const immutablePublicAssetCacheControl = 'public, max-age=31536000, immutable';
|
|
96
|
+
const revalidatedPublicAssetCacheControl = 'public, max-age=0, must-revalidate';
|
|
97
|
+
const hashedPublicAssetPattern = /(^|[-_.])[a-f0-9]{6,}(?=(\.[^.]+)+$)/i;
|
|
98
|
+
const connectedProjectBootRetryCount = 10;
|
|
99
|
+
const connectedProjectBootRetryDelayMs = 5_000;
|
|
100
|
+
|
|
101
|
+
const isVersionedPublicAssetRequest = (res: express.Response, filePath: string) => {
|
|
102
|
+
const requestUrl = res.req?.originalUrl || res.req?.url || '';
|
|
103
|
+
const searchParams = new URL(requestUrl, 'http://proteum.local').searchParams;
|
|
104
|
+
if (searchParams.has('v')) return true;
|
|
105
|
+
|
|
106
|
+
return hashedPublicAssetPattern.test(path.basename(filePath));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const resolvePublicAssetCacheControl = (res: express.Response, filePath: string) =>
|
|
110
|
+
isVersionedPublicAssetRequest(res, filePath) ? immutablePublicAssetCacheControl : revalidatedPublicAssetCacheControl;
|
|
111
|
+
const wait = async (durationMs: number) =>
|
|
112
|
+
await new Promise<void>((resolve) => {
|
|
113
|
+
setTimeout(resolve, durationMs);
|
|
114
|
+
});
|
|
115
|
+
|
|
95
116
|
/*----------------------------------
|
|
96
117
|
- FUNCTION
|
|
97
118
|
----------------------------------*/
|
|
@@ -149,22 +170,38 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
149
170
|
private async verifyConnectedProjectsBeforeStart() {
|
|
150
171
|
for (const connectedProject of Object.values(this.app.connectedProjects || {})) {
|
|
151
172
|
const healthUrl = new URL(connectedProjectHealthPath, connectedProject.urlInternal).toString();
|
|
173
|
+
let lastError: Error | undefined;
|
|
152
174
|
|
|
153
|
-
let
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
for (let retryIndex = 0; retryIndex <= connectedProjectBootRetryCount; retryIndex++) {
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(healthUrl, {
|
|
178
|
+
headers: { Accept: 'application/json' },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Connected project "${connectedProject.namespace}" health check failed at ${healthUrl} with status ${response.status}.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
lastError = undefined;
|
|
188
|
+
break;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
lastError =
|
|
191
|
+
error instanceof Error && error.message.startsWith(`Connected project "${connectedProject.namespace}"`)
|
|
192
|
+
? error
|
|
193
|
+
: new Error(
|
|
194
|
+
`Connected project "${connectedProject.namespace}" is unreachable at ${connectedProject.urlInternal}. ${error instanceof Error ? error.message : String(error)}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
163
197
|
|
|
164
|
-
|
|
165
|
-
throw
|
|
166
|
-
|
|
198
|
+
if (!lastError) continue;
|
|
199
|
+
if (retryIndex === connectedProjectBootRetryCount) throw lastError;
|
|
200
|
+
|
|
201
|
+
console.warn(
|
|
202
|
+
`[connect] ${lastError.message} Retrying ${retryIndex + 1}/${connectedProjectBootRetryCount} in ${connectedProjectBootRetryDelayMs / 1000}s.`,
|
|
167
203
|
);
|
|
204
|
+
await wait(connectedProjectBootRetryDelayMs);
|
|
168
205
|
}
|
|
169
206
|
}
|
|
170
207
|
}
|
|
@@ -329,17 +366,8 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
329
366
|
'/public',
|
|
330
367
|
express.static(path.join(Container.path.root, APP_OUTPUT_DIR, 'public'), {
|
|
331
368
|
dotfiles: 'deny',
|
|
332
|
-
setHeaders: function setCustomCacheControl(res,
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
res.setHeader('Cache-Control', 'public, max-age=0');
|
|
336
|
-
|
|
337
|
-
// Set long term cache, except for non-hashed filenames
|
|
338
|
-
/*if (dontCache.some( p => path.startsWith( p ))) {
|
|
339
|
-
res.setHeader('Cache-Control', 'public, max-age=0');
|
|
340
|
-
} else {
|
|
341
|
-
res.setHeader('Cache-Control', 'public, max-age=604800000'); // 7 Days
|
|
342
|
-
}*/
|
|
369
|
+
setHeaders: function setCustomCacheControl(res, filePath) {
|
|
370
|
+
res.setHeader('Cache-Control', resolvePublicAssetCacheControl(res, filePath));
|
|
343
371
|
},
|
|
344
372
|
}),
|
|
345
373
|
(req, res) => {
|