proteum 2.2.3 → 2.2.7
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 -6
- package/README.md +4 -4
- package/agents/project/AGENTS.md +11 -9
- package/agents/project/app-root/AGENTS.md +1 -1
- package/agents/project/diagnostics.md +11 -9
- package/agents/project/optimizations.md +2 -2
- package/agents/project/root/AGENTS.md +10 -8
- package/agents/project/tests/AGENTS.md +2 -1
- package/cli/commands/configure.ts +14 -35
- package/cli/commands/dev.ts +105 -52
- package/cli/compiler/artifacts/manifest.ts +1 -5
- package/cli/presentation/commands.ts +9 -9
- package/cli/presentation/help.ts +1 -1
- package/cli/scaffold/index.ts +2 -5
- package/cli/scaffold/templates.ts +1 -7
- package/cli/utils/agents.ts +281 -199
- package/package.json +1 -1
- package/server/services/router/request/index.ts +1 -18
- package/server/services/router/request/ip.test.cjs +60 -0
- package/server/services/router/request/ip.ts +71 -0
- package/tests/agents-utils.test.cjs +207 -0
- package/tests/dev-transpile-watch.test.cjs +513 -0
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.2.
|
|
4
|
+
"version": "2.2.7",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@ import type { HttpMethod, HttpHeaders } from '..';
|
|
|
17
17
|
import ApiClient from './api';
|
|
18
18
|
import ServerResponse from '../response';
|
|
19
19
|
import type { TAnyRouter } from '..';
|
|
20
|
+
import { resolveRequestIp } from './ip';
|
|
20
21
|
|
|
21
22
|
/*----------------------------------
|
|
22
23
|
- TYPES
|
|
@@ -42,24 +43,6 @@ type TRequestTraceCallContext = {
|
|
|
42
43
|
label: string;
|
|
43
44
|
origin: TTraceCallOrigin;
|
|
44
45
|
};
|
|
45
|
-
type THeaderValue = string | string[] | undefined;
|
|
46
|
-
|
|
47
|
-
const readHeader = (headers: Record<string, THeaderValue>, name: string): string | null => {
|
|
48
|
-
const exact = headers[name];
|
|
49
|
-
const alternate = exact === undefined ? headers[name.toLowerCase()] : exact;
|
|
50
|
-
const value = alternate === undefined ? headers[name.toUpperCase()] : alternate;
|
|
51
|
-
|
|
52
|
-
if (Array.isArray(value)) {
|
|
53
|
-
const firstValue = value.find((entry) => typeof entry === 'string' && entry.trim());
|
|
54
|
-
return typeof firstValue === 'string' ? firstValue.trim() : null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const resolveRequestIp = (req: express.Request): string | undefined => {
|
|
61
|
-
return readHeader(req.headers as Record<string, THeaderValue>, 'cf-connecting-ip') || req.ip;
|
|
62
|
-
};
|
|
63
46
|
|
|
64
47
|
/*----------------------------------
|
|
65
48
|
- CONTEXTE
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
|
|
4
|
+
const { resolveRequestIp } = require('./ip.ts');
|
|
5
|
+
|
|
6
|
+
const request = (headers, ip = '192.0.2.10') => ({
|
|
7
|
+
headers,
|
|
8
|
+
ip,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('Cloudflare client IP wins over Express fallback IP', () => {
|
|
12
|
+
assert.equal(
|
|
13
|
+
resolveRequestIp(request({ 'cf-connecting-ip': '203.0.113.10' }, '192.0.2.10')),
|
|
14
|
+
'203.0.113.10',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('True-Client-IP wins when Cloudflare IP is absent', () => {
|
|
19
|
+
assert.equal(
|
|
20
|
+
resolveRequestIp(request({ 'true-client-ip': '198.51.100.24' }, '192.0.2.10')),
|
|
21
|
+
'198.51.100.24',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('Fastly-Client-IP wins when Cloudflare and True-Client-IP are absent', () => {
|
|
26
|
+
assert.equal(
|
|
27
|
+
resolveRequestIp(request({ 'fastly-client-ip': '51.182.144.154' }, '192.0.2.10')),
|
|
28
|
+
'51.182.144.154',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('invalid and spoof-looking values are ignored before falling back to Express IP', () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
resolveRequestIp(
|
|
35
|
+
request(
|
|
36
|
+
{
|
|
37
|
+
'cf-connecting-ip': 'not-an-ip',
|
|
38
|
+
'true-client-ip': '203.0.113.10, 198.51.100.24',
|
|
39
|
+
'fastly-client-ip': 'unknown',
|
|
40
|
+
'x-forwarded-for': '203.0.113.200',
|
|
41
|
+
forwarded: 'for=203.0.113.201',
|
|
42
|
+
},
|
|
43
|
+
'192.0.2.10',
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
'192.0.2.10',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('IPv4-mapped IPv6 and bracketed IPv6 candidates normalize correctly', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
resolveRequestIp(request({ 'fastly-client-ip': ' ::ffff:51.182.144.154 ' }, '192.0.2.10')),
|
|
53
|
+
'51.182.144.154',
|
|
54
|
+
);
|
|
55
|
+
assert.equal(resolveRequestIp(request({ 'true-client-ip': '[2001:db8::12]' }, '192.0.2.10')), '2001:db8::12');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('IPv4 port suffix candidates normalize correctly', () => {
|
|
59
|
+
assert.equal(resolveRequestIp(request({ 'fastly-client-ip': '51.182.144.154:443' }, '192.0.2.10')), '51.182.144.154');
|
|
60
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*----------------------------------
|
|
2
|
+
- DEPENDANCES
|
|
3
|
+
----------------------------------*/
|
|
4
|
+
|
|
5
|
+
// Npm
|
|
6
|
+
import type express from 'express';
|
|
7
|
+
import { isIP } from 'net';
|
|
8
|
+
|
|
9
|
+
/*----------------------------------
|
|
10
|
+
- TYPES
|
|
11
|
+
----------------------------------*/
|
|
12
|
+
|
|
13
|
+
type THeaderValue = string | string[] | undefined;
|
|
14
|
+
|
|
15
|
+
/*----------------------------------
|
|
16
|
+
- CONSTANTS
|
|
17
|
+
----------------------------------*/
|
|
18
|
+
|
|
19
|
+
const trustedClientIpHeaders = ['cf-connecting-ip', 'true-client-ip', 'fastly-client-ip'] as const;
|
|
20
|
+
const bracketedIpPattern = /^\[([^\]]+)\](?::\d+)?$/;
|
|
21
|
+
const ipv4WithPortPattern = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/;
|
|
22
|
+
const ipv4MappedIpv6Pattern = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
23
|
+
|
|
24
|
+
/*----------------------------------
|
|
25
|
+
- HELPERS
|
|
26
|
+
----------------------------------*/
|
|
27
|
+
|
|
28
|
+
const readHeader = (headers: Record<string, THeaderValue>, name: string): string | null => {
|
|
29
|
+
const normalizedName = name.toLowerCase();
|
|
30
|
+
const directValue = headers[name] ?? headers[normalizedName] ?? headers[name.toUpperCase()];
|
|
31
|
+
const value =
|
|
32
|
+
directValue !== undefined
|
|
33
|
+
? directValue
|
|
34
|
+
: Object.entries(headers).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
const firstValue = value.find((entry) => typeof entry === 'string' && entry.trim());
|
|
38
|
+
return typeof firstValue === 'string' ? firstValue.trim() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const normalizeRequestIpCandidate = (value: string | null | undefined): string | null => {
|
|
45
|
+
let candidate = typeof value === 'string' ? value.trim() : '';
|
|
46
|
+
if (!candidate) return null;
|
|
47
|
+
|
|
48
|
+
const bracketedIp = bracketedIpPattern.exec(candidate);
|
|
49
|
+
if (bracketedIp) candidate = bracketedIp[1].trim();
|
|
50
|
+
|
|
51
|
+
const ipv4MappedIpv6 = ipv4MappedIpv6Pattern.exec(candidate);
|
|
52
|
+
if (ipv4MappedIpv6 && isIP(ipv4MappedIpv6[1]) === 4) {
|
|
53
|
+
candidate = ipv4MappedIpv6[1];
|
|
54
|
+
} else {
|
|
55
|
+
const ipv4WithPort = ipv4WithPortPattern.exec(candidate);
|
|
56
|
+
if (ipv4WithPort && isIP(ipv4WithPort[1]) === 4) candidate = ipv4WithPort[1];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return isIP(candidate) ? candidate : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const resolveRequestIp = (req: Pick<express.Request, 'headers' | 'ip'>): string | undefined => {
|
|
63
|
+
const headers = req.headers as Record<string, THeaderValue>;
|
|
64
|
+
|
|
65
|
+
for (const headerName of trustedClientIpHeaders) {
|
|
66
|
+
const ip = normalizeRequestIpCandidate(readHeader(headers, headerName));
|
|
67
|
+
if (ip) return ip;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return normalizeRequestIpCandidate(req.ip) || undefined;
|
|
71
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
|
|
7
|
+
const coreRoot = path.resolve(__dirname, '..');
|
|
8
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
9
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
10
|
+
require('ts-node/register/transpile-only');
|
|
11
|
+
|
|
12
|
+
const { configureProjectAgentInstructions, resolveProjectAgentMonorepoRoot } = require('../cli/utils/agents.ts');
|
|
13
|
+
|
|
14
|
+
const writeFile = (filepath, content) => {
|
|
15
|
+
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
16
|
+
fs.writeFileSync(filepath, content);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const makeTempRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-agents-'));
|
|
20
|
+
|
|
21
|
+
const createCoreFixture = () => {
|
|
22
|
+
const root = makeTempRoot();
|
|
23
|
+
const agentsRoot = path.join(root, 'agents', 'project');
|
|
24
|
+
|
|
25
|
+
writeFile(path.join(agentsRoot, 'AGENTS.md'), '# Root Contract\n\n- Root rule\n');
|
|
26
|
+
writeFile(path.join(agentsRoot, 'CODING_STYLE.md'), '# Coding Style\n\n- Style rule\n');
|
|
27
|
+
writeFile(path.join(agentsRoot, 'client', 'AGENTS.md'), '# Client Rules\n\n- Client rule\n');
|
|
28
|
+
|
|
29
|
+
return root;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const createAppFixture = () => {
|
|
33
|
+
const appRoot = makeTempRoot();
|
|
34
|
+
|
|
35
|
+
for (const dir of ['client/pages', 'server/routes', 'server/services', 'tests/e2e']) {
|
|
36
|
+
fs.mkdirSync(path.join(appRoot, dir), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
writeFile(
|
|
40
|
+
path.join(appRoot, '.gitignore'),
|
|
41
|
+
[
|
|
42
|
+
'node_modules',
|
|
43
|
+
'# Proteum-managed instruction files',
|
|
44
|
+
'/AGENTS.md',
|
|
45
|
+
'/CODING_STYLE.md',
|
|
46
|
+
'# End Proteum-managed instruction files',
|
|
47
|
+
'/.proteum',
|
|
48
|
+
'',
|
|
49
|
+
].join('\n'),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return appRoot;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test('standalone configure creates tracked instruction files with embedded corpus', () => {
|
|
56
|
+
const coreRoot = createCoreFixture();
|
|
57
|
+
const appRoot = createAppFixture();
|
|
58
|
+
const result = configureProjectAgentInstructions({ appRoot, coreRoot });
|
|
59
|
+
const agentsContent = fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8');
|
|
60
|
+
const codingStyleContent = fs.readFileSync(path.join(appRoot, 'CODING_STYLE.md'), 'utf8');
|
|
61
|
+
const gitignoreContent = fs.readFileSync(path.join(appRoot, '.gitignore'), 'utf8');
|
|
62
|
+
|
|
63
|
+
assert.equal(result.blocked.length, 0);
|
|
64
|
+
assert.match(agentsContent, /^# Proteum Instructions/m);
|
|
65
|
+
assert.match(agentsContent, /<!-- proteum-instructions:start -->/);
|
|
66
|
+
assert.match(agentsContent, /## Source: AGENTS\.md/);
|
|
67
|
+
assert.match(agentsContent, /## Root Contract/);
|
|
68
|
+
assert.match(agentsContent, /## Source: CODING_STYLE\.md/);
|
|
69
|
+
assert.match(codingStyleContent, /## Source: client\/AGENTS\.md/);
|
|
70
|
+
assert.doesNotMatch(agentsContent, /Before reading or applying instructions from this file/);
|
|
71
|
+
assert.doesNotMatch(gitignoreContent, /Proteum-managed instruction files/);
|
|
72
|
+
assert.doesNotMatch(gitignoreContent, /^\/AGENTS\.md$/m);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('configure preserves project content outside the managed section', () => {
|
|
76
|
+
const coreRoot = createCoreFixture();
|
|
77
|
+
const appRoot = createAppFixture();
|
|
78
|
+
|
|
79
|
+
writeFile(
|
|
80
|
+
path.join(appRoot, 'AGENTS.md'),
|
|
81
|
+
[
|
|
82
|
+
'# Product Notes',
|
|
83
|
+
'',
|
|
84
|
+
'Keep this product note.',
|
|
85
|
+
'',
|
|
86
|
+
'# Proteum Instructions',
|
|
87
|
+
'<!-- proteum-instructions:start -->',
|
|
88
|
+
'',
|
|
89
|
+
'Old managed content.',
|
|
90
|
+
'',
|
|
91
|
+
'<!-- proteum-instructions:end -->',
|
|
92
|
+
'',
|
|
93
|
+
'# Local Footer',
|
|
94
|
+
'',
|
|
95
|
+
'Keep this footer.',
|
|
96
|
+
'',
|
|
97
|
+
].join('\n'),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
configureProjectAgentInstructions({ appRoot, coreRoot });
|
|
101
|
+
|
|
102
|
+
const content = fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8');
|
|
103
|
+
assert.match(content, /# Product Notes/);
|
|
104
|
+
assert.match(content, /Keep this product note\./);
|
|
105
|
+
assert.match(content, /## Source: CODING_STYLE\.md/);
|
|
106
|
+
assert.doesNotMatch(content, /Old managed content/);
|
|
107
|
+
assert.match(content, /# Local Footer/);
|
|
108
|
+
assert.match(content, /Keep this footer\./);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('configure preserves project content around legacy managed stubs', () => {
|
|
112
|
+
const coreRoot = createCoreFixture();
|
|
113
|
+
const appRoot = createAppFixture();
|
|
114
|
+
|
|
115
|
+
writeFile(
|
|
116
|
+
path.join(appRoot, 'AGENTS.md'),
|
|
117
|
+
[
|
|
118
|
+
'## Product Bootstrap',
|
|
119
|
+
'',
|
|
120
|
+
'Keep these local bootstrap notes.',
|
|
121
|
+
'',
|
|
122
|
+
'# Proteum Managed Instructions',
|
|
123
|
+
'',
|
|
124
|
+
'This file is managed by `proteum configure agents`.',
|
|
125
|
+
'',
|
|
126
|
+
'Before reading or applying instructions from this file, read and follow the canonical Proteum instruction file at:',
|
|
127
|
+
'',
|
|
128
|
+
'`node_modules/proteum/agents/project/AGENTS.md`',
|
|
129
|
+
'',
|
|
130
|
+
'Resolve that path relative to this file. Treat the canonical file as if its full contents were written here.',
|
|
131
|
+
'',
|
|
132
|
+
'If the canonical file cannot be read, stop and run `npx proteum configure agents` before continuing.',
|
|
133
|
+
'',
|
|
134
|
+
'## Local Footer',
|
|
135
|
+
'',
|
|
136
|
+
'Keep this footer too.',
|
|
137
|
+
'',
|
|
138
|
+
].join('\n'),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
configureProjectAgentInstructions({ appRoot, coreRoot });
|
|
142
|
+
|
|
143
|
+
const content = fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8');
|
|
144
|
+
assert.match(content, /## Product Bootstrap/);
|
|
145
|
+
assert.match(content, /Keep these local bootstrap notes\./);
|
|
146
|
+
assert.match(content, /# Proteum Instructions/);
|
|
147
|
+
assert.match(content, /## Source: CODING_STYLE\.md/);
|
|
148
|
+
assert.doesNotMatch(content, /# Proteum Managed Instructions/);
|
|
149
|
+
assert.doesNotMatch(content, /Before reading or applying instructions from this file/);
|
|
150
|
+
assert.match(content, /## Local Footer/);
|
|
151
|
+
assert.match(content, /Keep this footer too\./);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('monorepo configure writes root and app instruction files', () => {
|
|
155
|
+
const coreRoot = createCoreFixture();
|
|
156
|
+
const monorepoRoot = makeTempRoot();
|
|
157
|
+
const appRoot = path.join(monorepoRoot, 'apps', 'product');
|
|
158
|
+
|
|
159
|
+
fs.mkdirSync(path.join(monorepoRoot, '.git'));
|
|
160
|
+
fs.mkdirSync(path.join(appRoot, 'client'), { recursive: true });
|
|
161
|
+
|
|
162
|
+
const result = configureProjectAgentInstructions({ appRoot, coreRoot, monorepoRoot });
|
|
163
|
+
|
|
164
|
+
assert.equal(result.mode, 'monorepo');
|
|
165
|
+
assert.equal(resolveProjectAgentMonorepoRoot(appRoot), fs.realpathSync(monorepoRoot));
|
|
166
|
+
assert.match(fs.readFileSync(path.join(monorepoRoot, 'AGENTS.md'), 'utf8'), /## Source: AGENTS\.md/);
|
|
167
|
+
assert.match(fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8'), /## Source: client\/AGENTS\.md/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('configure migrates legacy managed symlinks to embedded files', () => {
|
|
171
|
+
const coreRoot = createCoreFixture();
|
|
172
|
+
const appRoot = createAppFixture();
|
|
173
|
+
const installedCoreRoot = createCoreFixture();
|
|
174
|
+
const target = path.join(installedCoreRoot, 'agents', 'project', 'AGENTS.md');
|
|
175
|
+
const linkPath = path.join(appRoot, 'AGENTS.md');
|
|
176
|
+
|
|
177
|
+
fs.symlinkSync(target, linkPath);
|
|
178
|
+
|
|
179
|
+
const result = configureProjectAgentInstructions({ appRoot, coreRoot });
|
|
180
|
+
const stats = fs.lstatSync(linkPath);
|
|
181
|
+
const content = fs.readFileSync(linkPath, 'utf8');
|
|
182
|
+
|
|
183
|
+
assert.equal(result.updated.some((entry) => entry.endsWith('/AGENTS.md')), true);
|
|
184
|
+
assert.equal(stats.isSymbolicLink(), false);
|
|
185
|
+
assert.match(content, /# Proteum Instructions/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('configure reports blocked paths unless overwrite is allowed', () => {
|
|
189
|
+
const coreRoot = createCoreFixture();
|
|
190
|
+
const appRoot = createAppFixture();
|
|
191
|
+
const blockedPath = path.join(appRoot, 'CODING_STYLE.md');
|
|
192
|
+
|
|
193
|
+
fs.mkdirSync(blockedPath);
|
|
194
|
+
|
|
195
|
+
const preview = configureProjectAgentInstructions({ appRoot, coreRoot, dryRun: true });
|
|
196
|
+
assert.equal(preview.blocked.some((entry) => entry.endsWith('/CODING_STYLE.md')), true);
|
|
197
|
+
|
|
198
|
+
const result = configureProjectAgentInstructions({
|
|
199
|
+
appRoot,
|
|
200
|
+
coreRoot,
|
|
201
|
+
overwriteBlockedPaths: [blockedPath],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
assert.equal(result.overwritten.some((entry) => entry.endsWith('/CODING_STYLE.md')), true);
|
|
205
|
+
assert.equal(fs.lstatSync(blockedPath).isFile(), true);
|
|
206
|
+
assert.match(fs.readFileSync(blockedPath, 'utf8'), /## Source: AGENTS\.md/);
|
|
207
|
+
});
|