proteum 2.4.4 → 2.5.0
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 +60 -55
- package/agents/project/AGENTS.md +112 -31
- package/agents/project/CODING_STYLE.md +2 -2
- package/agents/project/app-root/AGENTS.md +1 -3
- package/agents/project/client/AGENTS.md +1 -1
- package/agents/project/client/pages/AGENTS.md +21 -9
- package/agents/project/diagnostics.md +2 -2
- package/agents/project/optimizations.md +1 -1
- package/agents/project/root/AGENTS.md +105 -22
- package/agents/project/server/routes/AGENTS.md +30 -1
- package/agents/project/tests/AGENTS.md +1 -1
- package/cli/commands/doctor.ts +54 -3
- package/cli/commands/runtime.ts +6 -0
- package/cli/commands/worktree.ts +116 -0
- package/cli/compiler/artifacts/controllers.ts +16 -15
- package/cli/compiler/artifacts/discovery.ts +129 -17
- package/cli/compiler/artifacts/routing.ts +0 -5
- package/cli/compiler/artifacts/services.ts +253 -76
- package/cli/compiler/common/controllers.ts +159 -57
- package/cli/compiler/common/generatedRouteModules.ts +457 -363
- package/cli/mcp/router.ts +47 -3
- package/cli/presentation/commands.ts +25 -15
- package/cli/runtime/commands.ts +39 -12
- package/cli/runtime/worktreeBootstrap.ts +608 -0
- package/cli/scaffold/index.ts +28 -18
- package/cli/scaffold/templates.ts +44 -33
- package/cli/utils/agents.ts +14 -1
- package/client/services/router/index.tsx +23 -3
- package/client/services/router/request/api.ts +14 -4
- package/common/dev/contractsDoctor.ts +1 -1
- package/common/dev/mcpPayloads.ts +8 -1
- package/common/env/proteumEnv.ts +14 -2
- package/common/router/contracts.ts +1 -1
- package/common/router/definitions.ts +177 -0
- package/common/router/index.ts +23 -12
- package/common/router/pageData.ts +5 -5
- package/common/router/register.ts +2 -2
- package/common/router/request/api.ts +12 -2
- package/docs/agent-routing.md +5 -2
- package/docs/diagnostics.md +2 -0
- package/docs/mcp.md +6 -3
- package/eslint.js +36 -1
- package/package.json +1 -1
- package/server/app/commands.ts +5 -1
- package/server/app/container/console/index.ts +1 -1
- package/server/app/controller/index.ts +98 -40
- package/server/app/index.ts +92 -1
- package/server/app/service/index.ts +5 -1
- package/server/index.ts +6 -2
- package/server/services/router/index.ts +47 -38
- package/server/services/router/response/index.ts +2 -2
- package/tests/agents-utils.test.cjs +14 -1
- package/tests/cli-mcp-command.test.cjs +84 -0
- package/tests/definition-contracts.test.cjs +453 -0
- package/tests/dev-transpile-watch.test.cjs +37 -28
- package/tests/eslint-rules.test.cjs +39 -1
- package/tests/mcp.test.cjs +90 -0
- package/tests/worktree-bootstrap.test.cjs +206 -0
- package/types/aliases.d.ts +0 -5
- package/types/controller-input.test.ts +23 -17
- package/types/controller-request-context.test.ts +10 -11
- package/cli/commands/migrate.ts +0 -51
- package/cli/migrate/pageContract.ts +0 -516
- package/docs/migrate-from-2.1.3.md +0 -396
- package/scripts/cleanup-generated-controllers.ts +0 -62
- package/scripts/fix-reference-app-typing.ts +0 -490
- package/scripts/format-router-registrations.ts +0 -119
- package/scripts/migrate-explicit-controllers-and-request.ts +0 -423
- package/scripts/refactor-client-app-imports.ts +0 -244
- package/scripts/refactor-client-pages.ts +0 -587
- package/scripts/refactor-server-controllers.ts +0 -471
- package/scripts/refactor-server-runtime-aliases.ts +0 -360
- package/scripts/restore-client-app-import-files.ts +0 -41
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import cp from 'child_process';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
/*----------------------------------
|
|
7
|
+
- TYPES
|
|
8
|
+
----------------------------------*/
|
|
9
|
+
|
|
10
|
+
export type TWorktreeBootstrapStepStatus = 'failed' | 'ok' | 'skipped' | 'up-to-date' | 'installed';
|
|
11
|
+
|
|
12
|
+
export type TWorktreeBootstrapMarker = {
|
|
13
|
+
agentsHash?: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
dependencies: {
|
|
16
|
+
nodeModulesPresent: boolean;
|
|
17
|
+
packageLockHash?: string;
|
|
18
|
+
ranAt?: string;
|
|
19
|
+
reason?: string;
|
|
20
|
+
status: TWorktreeBootstrapStepStatus;
|
|
21
|
+
};
|
|
22
|
+
env: {
|
|
23
|
+
copied: boolean;
|
|
24
|
+
copiedAt?: string;
|
|
25
|
+
present: boolean;
|
|
26
|
+
source?: string;
|
|
27
|
+
};
|
|
28
|
+
packageLockHash?: string;
|
|
29
|
+
proteumConfigHash?: string;
|
|
30
|
+
proteumVersion: string;
|
|
31
|
+
refresh: {
|
|
32
|
+
ranAt: string;
|
|
33
|
+
status: TWorktreeBootstrapStepStatus;
|
|
34
|
+
};
|
|
35
|
+
runtimeStatus: {
|
|
36
|
+
checkedAt: string;
|
|
37
|
+
status: TWorktreeBootstrapStepStatus;
|
|
38
|
+
summary?: string;
|
|
39
|
+
};
|
|
40
|
+
skips?: {
|
|
41
|
+
dependencies?: {
|
|
42
|
+
at: string;
|
|
43
|
+
reason: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
version: 1;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type TWorktreeBootstrapStaleReason = {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type TWorktreeBootstrapStatus = {
|
|
56
|
+
blocking: boolean;
|
|
57
|
+
bypassed: boolean;
|
|
58
|
+
guarded: boolean;
|
|
59
|
+
marker?: TWorktreeBootstrapMarker;
|
|
60
|
+
markerFilepath: string;
|
|
61
|
+
nextAction: {
|
|
62
|
+
command: string;
|
|
63
|
+
label: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
};
|
|
66
|
+
ok: boolean;
|
|
67
|
+
staleReasons: TWorktreeBootstrapStaleReason[];
|
|
68
|
+
state: 'bypassed' | 'fresh' | 'missing' | 'not-codex-worktree' | 'stale';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type TWorktreeBootstrapDiagnostic = {
|
|
72
|
+
code: string;
|
|
73
|
+
filepath: string;
|
|
74
|
+
fixHint?: string;
|
|
75
|
+
level: 'error' | 'warning';
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type TWorktreeBootstrapInputs = {
|
|
80
|
+
agentsHash?: string;
|
|
81
|
+
envPresent: boolean;
|
|
82
|
+
manifestPresent: boolean;
|
|
83
|
+
nodeModulesPresent: boolean;
|
|
84
|
+
packageLockHash?: string;
|
|
85
|
+
proteumConfigHash?: string;
|
|
86
|
+
proteumVersion: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type TRunCaptureResult = {
|
|
90
|
+
stderr: string;
|
|
91
|
+
stdout: string;
|
|
92
|
+
summary?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type TRunWorktreeBootstrapInitOptions = {
|
|
96
|
+
appRoot: string;
|
|
97
|
+
coreRoot: string;
|
|
98
|
+
json?: boolean;
|
|
99
|
+
proteumVersion: string;
|
|
100
|
+
reason?: string;
|
|
101
|
+
refresh?: boolean;
|
|
102
|
+
runDependencies?: (appRoot: string) => Promise<void>;
|
|
103
|
+
runRefresh?: (appRoot: string, coreRoot: string) => Promise<TRunCaptureResult>;
|
|
104
|
+
runRuntimeStatus?: (appRoot: string, coreRoot: string) => Promise<TRunCaptureResult>;
|
|
105
|
+
skipDeps?: boolean;
|
|
106
|
+
source?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type TRunWorktreeBootstrapCreateOptions = TRunWorktreeBootstrapInitOptions & {
|
|
110
|
+
base?: string;
|
|
111
|
+
branch: string;
|
|
112
|
+
targetRepoRoot: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/*----------------------------------
|
|
116
|
+
- CONSTANTS
|
|
117
|
+
----------------------------------*/
|
|
118
|
+
|
|
119
|
+
export const worktreeBootstrapMarkerRelativePath = path.join('.proteum', 'worktree-bootstrap.json');
|
|
120
|
+
|
|
121
|
+
const allowUnbootstrappedEnv = 'PROTEUM_ALLOW_UNBOOTSTRAPPED_WORKTREE';
|
|
122
|
+
const codexWorktreeSegment = `${path.sep}.codex${path.sep}worktrees${path.sep}`;
|
|
123
|
+
|
|
124
|
+
/*----------------------------------
|
|
125
|
+
- HELPERS
|
|
126
|
+
----------------------------------*/
|
|
127
|
+
|
|
128
|
+
const normalizePath = (value: string) => path.normalize(path.resolve(value));
|
|
129
|
+
|
|
130
|
+
const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true' || value === 'yes';
|
|
131
|
+
|
|
132
|
+
const nowIso = () => new Date().toISOString();
|
|
133
|
+
|
|
134
|
+
export const isCodexWorktreePath = (value: string) => normalizePath(value).includes(codexWorktreeSegment);
|
|
135
|
+
|
|
136
|
+
const findNearestExistingPath = (startPath: string, filename: string) => {
|
|
137
|
+
let currentPath = normalizePath(startPath);
|
|
138
|
+
|
|
139
|
+
while (true) {
|
|
140
|
+
const candidate = path.join(currentPath, filename);
|
|
141
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
142
|
+
|
|
143
|
+
const parentPath = path.dirname(currentPath);
|
|
144
|
+
if (parentPath === currentPath) return undefined;
|
|
145
|
+
currentPath = parentPath;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const findVisibleDirectory = (startPath: string, directoryName: string) => {
|
|
150
|
+
let currentPath = normalizePath(startPath);
|
|
151
|
+
|
|
152
|
+
while (true) {
|
|
153
|
+
const candidate = path.join(currentPath, directoryName);
|
|
154
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
|
|
155
|
+
|
|
156
|
+
const parentPath = path.dirname(currentPath);
|
|
157
|
+
if (parentPath === currentPath) return undefined;
|
|
158
|
+
currentPath = parentPath;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const hashFile = (filepath: string | undefined) => {
|
|
163
|
+
if (!filepath || !fs.existsSync(filepath)) return undefined;
|
|
164
|
+
|
|
165
|
+
return crypto.createHash('sha256').update(fs.readFileSync(filepath)).digest('hex');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const readMarker = (markerFilepath: string) => {
|
|
169
|
+
if (!fs.existsSync(markerFilepath)) return { marker: undefined, invalid: false };
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
return { marker: fs.readJSONSync(markerFilepath) as TWorktreeBootstrapMarker, invalid: false };
|
|
173
|
+
} catch {
|
|
174
|
+
return { marker: undefined, invalid: true };
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const readInputs = (appRoot: string, proteumVersion: string): TWorktreeBootstrapInputs => {
|
|
179
|
+
const packageLockFilepath = findNearestExistingPath(appRoot, 'package-lock.json');
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
agentsHash: hashFile(path.join(appRoot, 'AGENTS.md')),
|
|
183
|
+
envPresent: fs.existsSync(path.join(appRoot, '.env')),
|
|
184
|
+
manifestPresent: fs.existsSync(path.join(appRoot, '.proteum', 'manifest.json')),
|
|
185
|
+
nodeModulesPresent: findVisibleDirectory(appRoot, 'node_modules') !== undefined,
|
|
186
|
+
packageLockHash: hashFile(packageLockFilepath),
|
|
187
|
+
proteumConfigHash: hashFile(path.join(appRoot, 'proteum.config.ts')),
|
|
188
|
+
proteumVersion,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const dependenciesWereIntentionallySkipped = (marker: TWorktreeBootstrapMarker | undefined, inputs: TWorktreeBootstrapInputs) =>
|
|
193
|
+
marker?.dependencies.status === 'skipped' &&
|
|
194
|
+
marker.dependencies.packageLockHash === inputs.packageLockHash &&
|
|
195
|
+
Boolean(marker.skips?.dependencies?.reason);
|
|
196
|
+
|
|
197
|
+
const collectStaleReasons = ({
|
|
198
|
+
inputs,
|
|
199
|
+
invalid,
|
|
200
|
+
marker,
|
|
201
|
+
}: {
|
|
202
|
+
inputs: TWorktreeBootstrapInputs;
|
|
203
|
+
invalid: boolean;
|
|
204
|
+
marker?: TWorktreeBootstrapMarker;
|
|
205
|
+
}) => {
|
|
206
|
+
const reasons: TWorktreeBootstrapStaleReason[] = [];
|
|
207
|
+
|
|
208
|
+
if (invalid) reasons.push({ code: 'worktree-bootstrap/invalid-marker', message: 'The bootstrap marker is unreadable.' });
|
|
209
|
+
if (!marker) return reasons;
|
|
210
|
+
|
|
211
|
+
if (marker.proteumVersion !== inputs.proteumVersion)
|
|
212
|
+
reasons.push({ code: 'worktree-bootstrap/proteum-version-changed', message: 'Proteum version changed since bootstrap.' });
|
|
213
|
+
if (marker.packageLockHash !== inputs.packageLockHash)
|
|
214
|
+
reasons.push({ code: 'worktree-bootstrap/package-lock-changed', message: 'package-lock.json changed since bootstrap.' });
|
|
215
|
+
if (marker.proteumConfigHash !== inputs.proteumConfigHash)
|
|
216
|
+
reasons.push({ code: 'worktree-bootstrap/proteum-config-changed', message: 'proteum.config.ts changed since bootstrap.' });
|
|
217
|
+
if (marker.agentsHash !== inputs.agentsHash)
|
|
218
|
+
reasons.push({ code: 'worktree-bootstrap/agents-changed', message: 'AGENTS.md changed since bootstrap.' });
|
|
219
|
+
if (!inputs.envPresent) reasons.push({ code: 'worktree-bootstrap/env-missing', message: '.env is missing.' });
|
|
220
|
+
if (!inputs.manifestPresent)
|
|
221
|
+
reasons.push({ code: 'worktree-bootstrap/manifest-missing', message: '.proteum/manifest.json is missing.' });
|
|
222
|
+
if (!inputs.nodeModulesPresent && !dependenciesWereIntentionallySkipped(marker, inputs))
|
|
223
|
+
reasons.push({ code: 'worktree-bootstrap/node-modules-missing', message: 'node_modules is missing.' });
|
|
224
|
+
if (marker.refresh.status !== 'ok')
|
|
225
|
+
reasons.push({ code: 'worktree-bootstrap/refresh-not-ok', message: 'The last bootstrap refresh did not complete.' });
|
|
226
|
+
if (marker.runtimeStatus.status !== 'ok')
|
|
227
|
+
reasons.push({ code: 'worktree-bootstrap/runtime-status-not-ok', message: 'The last bootstrap runtime status check did not complete.' });
|
|
228
|
+
|
|
229
|
+
return reasons;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const createNextAction = (state: TWorktreeBootstrapStatus['state']) => ({
|
|
233
|
+
label: state === 'stale' ? 'Refresh Worktree Bootstrap' : 'Initialize Worktree Bootstrap',
|
|
234
|
+
command: `npx proteum worktree init --source <source-app-root>${state === 'stale' ? ' --refresh' : ''}`,
|
|
235
|
+
reason:
|
|
236
|
+
state === 'stale'
|
|
237
|
+
? 'Refresh the Proteum worktree bootstrap marker before running runtime or verification commands.'
|
|
238
|
+
: 'Complete Proteum worktree bootstrap before running runtime or verification commands.',
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const runCapture = (command: string, args: string[], { cwd, env }: { cwd: string; env?: NodeJS.ProcessEnv }) =>
|
|
242
|
+
new Promise<TRunCaptureResult>((resolve, reject) => {
|
|
243
|
+
const child = cp.spawn(command, args, {
|
|
244
|
+
cwd,
|
|
245
|
+
env: { ...process.env, ...env },
|
|
246
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
247
|
+
});
|
|
248
|
+
const stdoutChunks: Buffer[] = [];
|
|
249
|
+
const stderrChunks: Buffer[] = [];
|
|
250
|
+
|
|
251
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
|
252
|
+
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
|
253
|
+
child.on('error', reject);
|
|
254
|
+
child.on('exit', (code, signal) => {
|
|
255
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
256
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
257
|
+
|
|
258
|
+
if (signal) {
|
|
259
|
+
reject(new Error(`Command "${command}" was interrupted by signal ${signal}.`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (code === 0) {
|
|
264
|
+
const outputLines = stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
265
|
+
resolve({ stdout, stderr, summary: outputLines[outputLines.length - 1] });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
reject(new Error([`Command "${command}" exited with code ${code ?? 'unknown'}.`, stdout, stderr].filter(Boolean).join('\n')));
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const runProteumCli = async (appRoot: string, coreRoot: string, args: string[]) =>
|
|
274
|
+
await runCapture(process.execPath, [path.join(coreRoot, 'cli', 'bin.js'), ...args], {
|
|
275
|
+
cwd: appRoot,
|
|
276
|
+
env: { [allowUnbootstrappedEnv]: '1' },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const runNpmInstall = (appRoot: string) =>
|
|
280
|
+
new Promise<void>((resolve, reject) => {
|
|
281
|
+
const child = cp.spawn('npm', ['install'], { cwd: appRoot, stdio: 'inherit' });
|
|
282
|
+
|
|
283
|
+
child.on('error', reject);
|
|
284
|
+
child.on('exit', (code, signal) => {
|
|
285
|
+
if (signal) {
|
|
286
|
+
reject(new Error(`Command "npm install" was interrupted by signal ${signal}.`));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (code === 0) resolve();
|
|
291
|
+
else reject(new Error(`Command "npm install" exited with code ${code ?? 'unknown'}.`));
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const defaultRunRefresh = async (appRoot: string, coreRoot: string) => await runProteumCli(appRoot, coreRoot, ['refresh']);
|
|
296
|
+
|
|
297
|
+
const defaultRunRuntimeStatus = async (appRoot: string, coreRoot: string) =>
|
|
298
|
+
await runProteumCli(appRoot, coreRoot, ['runtime', 'status', '--full']);
|
|
299
|
+
|
|
300
|
+
const resolveDependencyAction = ({
|
|
301
|
+
inputs,
|
|
302
|
+
marker,
|
|
303
|
+
}: {
|
|
304
|
+
inputs: TWorktreeBootstrapInputs;
|
|
305
|
+
marker?: TWorktreeBootstrapMarker;
|
|
306
|
+
}) => {
|
|
307
|
+
if (!inputs.nodeModulesPresent) return 'install';
|
|
308
|
+
if (marker && marker.packageLockHash !== inputs.packageLockHash) return 'install';
|
|
309
|
+
return 'up-to-date';
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const requireSourceEnvWhenNeeded = ({ appRoot, source }: { appRoot: string; source?: string }) => {
|
|
313
|
+
const envFilepath = path.join(appRoot, '.env');
|
|
314
|
+
if (fs.existsSync(envFilepath)) return { copied: false, present: true, source: undefined };
|
|
315
|
+
|
|
316
|
+
if (!source) throw new Error('This worktree is missing .env. Pass --source <app-root> with a readable source .env.');
|
|
317
|
+
|
|
318
|
+
const sourceEnvFilepath = path.join(path.resolve(source), '.env');
|
|
319
|
+
if (!fs.existsSync(sourceEnvFilepath)) throw new Error(`Source .env does not exist: ${sourceEnvFilepath}`);
|
|
320
|
+
|
|
321
|
+
fs.copyFileSync(sourceEnvFilepath, envFilepath);
|
|
322
|
+
|
|
323
|
+
return { copied: true, copiedAt: nowIso(), present: true, source: path.resolve(source) };
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const writeMarker = (appRoot: string, marker: TWorktreeBootstrapMarker) => {
|
|
327
|
+
const markerFilepath = path.join(appRoot, worktreeBootstrapMarkerRelativePath);
|
|
328
|
+
|
|
329
|
+
fs.ensureDirSync(path.dirname(markerFilepath));
|
|
330
|
+
fs.writeJSONSync(markerFilepath, marker, { spaces: 2 });
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const findGitRepoRoot = async (cwd: string) => {
|
|
334
|
+
const result = await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd });
|
|
335
|
+
return result.stdout.trim();
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/*----------------------------------
|
|
339
|
+
- PUBLIC API
|
|
340
|
+
----------------------------------*/
|
|
341
|
+
|
|
342
|
+
export const getWorktreeBootstrapStatus = ({
|
|
343
|
+
appRoot,
|
|
344
|
+
proteumVersion,
|
|
345
|
+
}: {
|
|
346
|
+
appRoot: string;
|
|
347
|
+
proteumVersion: string;
|
|
348
|
+
}): TWorktreeBootstrapStatus => {
|
|
349
|
+
const normalizedAppRoot = normalizePath(appRoot);
|
|
350
|
+
const markerFilepath = path.join(normalizedAppRoot, worktreeBootstrapMarkerRelativePath);
|
|
351
|
+
const guarded = isCodexWorktreePath(normalizedAppRoot);
|
|
352
|
+
const bypassed = guarded && isTruthyEnv(process.env[allowUnbootstrappedEnv]);
|
|
353
|
+
const { invalid, marker } = readMarker(markerFilepath);
|
|
354
|
+
const inputs = readInputs(normalizedAppRoot, proteumVersion);
|
|
355
|
+
|
|
356
|
+
if (!guarded) {
|
|
357
|
+
return {
|
|
358
|
+
blocking: false,
|
|
359
|
+
bypassed: false,
|
|
360
|
+
guarded,
|
|
361
|
+
marker,
|
|
362
|
+
markerFilepath,
|
|
363
|
+
nextAction: createNextAction('not-codex-worktree'),
|
|
364
|
+
ok: true,
|
|
365
|
+
staleReasons: [],
|
|
366
|
+
state: 'not-codex-worktree',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const missing = !marker && !invalid;
|
|
371
|
+
const staleReasons = missing
|
|
372
|
+
? [{ code: 'worktree-bootstrap/missing-marker', message: 'The bootstrap marker is missing.' }]
|
|
373
|
+
: collectStaleReasons({ inputs, invalid, marker });
|
|
374
|
+
const actualState: TWorktreeBootstrapStatus['state'] = staleReasons.length === 0 ? 'fresh' : missing ? 'missing' : 'stale';
|
|
375
|
+
const blocking = !bypassed && actualState !== 'fresh';
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
blocking,
|
|
379
|
+
bypassed,
|
|
380
|
+
guarded,
|
|
381
|
+
marker,
|
|
382
|
+
markerFilepath,
|
|
383
|
+
nextAction: createNextAction(actualState),
|
|
384
|
+
ok: !blocking,
|
|
385
|
+
staleReasons,
|
|
386
|
+
state: bypassed && actualState !== 'fresh' ? 'bypassed' : actualState,
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export const compactWorktreeBootstrapStatus = (status: TWorktreeBootstrapStatus) => ({
|
|
391
|
+
blocking: status.blocking,
|
|
392
|
+
bypassed: status.bypassed,
|
|
393
|
+
guarded: status.guarded,
|
|
394
|
+
markerFilepath: status.markerFilepath,
|
|
395
|
+
staleReasons: status.staleReasons,
|
|
396
|
+
state: status.state,
|
|
397
|
+
updatedAt: status.marker?.updatedAt,
|
|
398
|
+
dependencies: status.marker?.dependencies
|
|
399
|
+
? {
|
|
400
|
+
status: status.marker.dependencies.status,
|
|
401
|
+
reason: status.marker.dependencies.reason,
|
|
402
|
+
}
|
|
403
|
+
: undefined,
|
|
404
|
+
skips: status.marker?.skips,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
export const createWorktreeBootstrapBlockResponse = (status: TWorktreeBootstrapStatus) => ({
|
|
408
|
+
ok: false,
|
|
409
|
+
format: 'proteum-agent-v1',
|
|
410
|
+
summary:
|
|
411
|
+
status.state === 'stale'
|
|
412
|
+
? 'This worktree bootstrap is stale. Run: npx proteum worktree init --source <source-app-root> --refresh'
|
|
413
|
+
: 'This worktree has not completed Proteum worktree bootstrap. Run: npx proteum worktree init --source <source-app-root>',
|
|
414
|
+
data: {
|
|
415
|
+
worktreeBootstrap: compactWorktreeBootstrapStatus(status),
|
|
416
|
+
},
|
|
417
|
+
nextActions: [status.nextAction],
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
export const createWorktreeBootstrapMcpBlockResponse = (status: TWorktreeBootstrapStatus, project?: object) => ({
|
|
421
|
+
ok: false,
|
|
422
|
+
format: 'proteum-mcp-v1',
|
|
423
|
+
summary:
|
|
424
|
+
status.state === 'stale'
|
|
425
|
+
? 'This worktree bootstrap is stale. Run: npx proteum worktree init --source <source-app-root> --refresh'
|
|
426
|
+
: 'This worktree has not completed Proteum worktree bootstrap. Run: npx proteum worktree init --source <source-app-root>',
|
|
427
|
+
data: {
|
|
428
|
+
project,
|
|
429
|
+
worktreeBootstrap: compactWorktreeBootstrapStatus(status),
|
|
430
|
+
},
|
|
431
|
+
nextActions: [status.nextAction],
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
export const createWorktreeBootstrapDiagnostics = ({
|
|
435
|
+
appRoot,
|
|
436
|
+
status,
|
|
437
|
+
}: {
|
|
438
|
+
appRoot: string;
|
|
439
|
+
status: TWorktreeBootstrapStatus;
|
|
440
|
+
}): TWorktreeBootstrapDiagnostic[] => {
|
|
441
|
+
if (!status.guarded) return [];
|
|
442
|
+
|
|
443
|
+
const diagnostics: TWorktreeBootstrapDiagnostic[] = [];
|
|
444
|
+
const level = status.bypassed ? 'warning' : 'error';
|
|
445
|
+
|
|
446
|
+
if (status.bypassed) {
|
|
447
|
+
diagnostics.push({
|
|
448
|
+
code: 'worktree-bootstrap/bypassed',
|
|
449
|
+
filepath: appRoot,
|
|
450
|
+
fixHint: status.nextAction.command,
|
|
451
|
+
level: 'warning',
|
|
452
|
+
message: 'Worktree bootstrap enforcement is bypassed by PROTEUM_ALLOW_UNBOOTSTRAPPED_WORKTREE.',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const reason of status.staleReasons) {
|
|
457
|
+
diagnostics.push({
|
|
458
|
+
code: reason.code,
|
|
459
|
+
filepath: appRoot,
|
|
460
|
+
fixHint: status.nextAction.command,
|
|
461
|
+
level,
|
|
462
|
+
message: reason.message,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (status.marker?.dependencies.status === 'skipped' && status.marker.skips?.dependencies?.reason) {
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
code: 'worktree-bootstrap/dependencies-skipped',
|
|
469
|
+
filepath: appRoot,
|
|
470
|
+
fixHint: 'npx proteum worktree init --source <source-app-root> --refresh',
|
|
471
|
+
level: 'warning',
|
|
472
|
+
message: `Dependency install was skipped during worktree bootstrap: ${status.marker.skips.dependencies.reason}`,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return diagnostics;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
export const runWorktreeBootstrapInit = async ({
|
|
480
|
+
appRoot,
|
|
481
|
+
coreRoot,
|
|
482
|
+
proteumVersion,
|
|
483
|
+
reason,
|
|
484
|
+
refresh = false,
|
|
485
|
+
runDependencies = runNpmInstall,
|
|
486
|
+
runRefresh = defaultRunRefresh,
|
|
487
|
+
runRuntimeStatus = defaultRunRuntimeStatus,
|
|
488
|
+
skipDeps = false,
|
|
489
|
+
source,
|
|
490
|
+
}: TRunWorktreeBootstrapInitOptions) => {
|
|
491
|
+
const normalizedAppRoot = normalizePath(appRoot);
|
|
492
|
+
const beforeStatus = getWorktreeBootstrapStatus({ appRoot: normalizedAppRoot, proteumVersion });
|
|
493
|
+
|
|
494
|
+
if (skipDeps && !reason?.trim()) throw new Error('--skip-deps requires a non-empty --reason.');
|
|
495
|
+
if (beforeStatus.state === 'stale' && !refresh) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
[
|
|
498
|
+
'This worktree bootstrap is stale. Run: npx proteum worktree init --source <source-app-root> --refresh',
|
|
499
|
+
...beforeStatus.staleReasons.map((entry) => `- ${entry.message}`),
|
|
500
|
+
].join('\n'),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const existingMarker = beforeStatus.marker;
|
|
505
|
+
const timestamp = nowIso();
|
|
506
|
+
const env = requireSourceEnvWhenNeeded({ appRoot: normalizedAppRoot, source });
|
|
507
|
+
const refreshResult = await runRefresh(normalizedAppRoot, coreRoot);
|
|
508
|
+
const dependencyInputs = readInputs(normalizedAppRoot, proteumVersion);
|
|
509
|
+
const dependencyAction = resolveDependencyAction({ inputs: dependencyInputs, marker: existingMarker });
|
|
510
|
+
let dependencyStatus: TWorktreeBootstrapMarker['dependencies'];
|
|
511
|
+
let skips: TWorktreeBootstrapMarker['skips'] | undefined;
|
|
512
|
+
|
|
513
|
+
if (dependencyAction === 'install' && skipDeps) {
|
|
514
|
+
skips = { dependencies: { at: nowIso(), reason: reason?.trim() || '' } };
|
|
515
|
+
dependencyStatus = {
|
|
516
|
+
nodeModulesPresent: dependencyInputs.nodeModulesPresent,
|
|
517
|
+
packageLockHash: dependencyInputs.packageLockHash,
|
|
518
|
+
ranAt: nowIso(),
|
|
519
|
+
reason: reason?.trim(),
|
|
520
|
+
status: 'skipped',
|
|
521
|
+
};
|
|
522
|
+
} else if (dependencyAction === 'install') {
|
|
523
|
+
await runDependencies(normalizedAppRoot);
|
|
524
|
+
const afterInstallInputs = readInputs(normalizedAppRoot, proteumVersion);
|
|
525
|
+
dependencyStatus = {
|
|
526
|
+
nodeModulesPresent: afterInstallInputs.nodeModulesPresent,
|
|
527
|
+
packageLockHash: afterInstallInputs.packageLockHash,
|
|
528
|
+
ranAt: nowIso(),
|
|
529
|
+
status: 'installed',
|
|
530
|
+
};
|
|
531
|
+
} else {
|
|
532
|
+
dependencyStatus = {
|
|
533
|
+
nodeModulesPresent: dependencyInputs.nodeModulesPresent,
|
|
534
|
+
packageLockHash: dependencyInputs.packageLockHash,
|
|
535
|
+
ranAt: nowIso(),
|
|
536
|
+
status: 'up-to-date',
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const runtimeStatus = await runRuntimeStatus(normalizedAppRoot, coreRoot);
|
|
541
|
+
const finalInputs = readInputs(normalizedAppRoot, proteumVersion);
|
|
542
|
+
const marker: TWorktreeBootstrapMarker = {
|
|
543
|
+
agentsHash: finalInputs.agentsHash,
|
|
544
|
+
createdAt: existingMarker?.createdAt || timestamp,
|
|
545
|
+
dependencies: dependencyStatus,
|
|
546
|
+
env,
|
|
547
|
+
packageLockHash: finalInputs.packageLockHash,
|
|
548
|
+
proteumConfigHash: finalInputs.proteumConfigHash,
|
|
549
|
+
proteumVersion,
|
|
550
|
+
refresh: {
|
|
551
|
+
ranAt: timestamp,
|
|
552
|
+
status: 'ok',
|
|
553
|
+
},
|
|
554
|
+
runtimeStatus: {
|
|
555
|
+
checkedAt: nowIso(),
|
|
556
|
+
status: 'ok',
|
|
557
|
+
summary: runtimeStatus.summary,
|
|
558
|
+
},
|
|
559
|
+
skips,
|
|
560
|
+
updatedAt: nowIso(),
|
|
561
|
+
version: 1,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
writeMarker(normalizedAppRoot, marker);
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
appRoot: normalizedAppRoot,
|
|
568
|
+
marker,
|
|
569
|
+
markerFilepath: path.join(normalizedAppRoot, worktreeBootstrapMarkerRelativePath),
|
|
570
|
+
refresh: refreshResult.summary,
|
|
571
|
+
runtimeStatus: runtimeStatus.summary,
|
|
572
|
+
status: getWorktreeBootstrapStatus({ appRoot: normalizedAppRoot, proteumVersion }),
|
|
573
|
+
};
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
export const runWorktreeBootstrapCreate = async ({
|
|
577
|
+
appRoot,
|
|
578
|
+
base = 'HEAD',
|
|
579
|
+
branch,
|
|
580
|
+
targetRepoRoot,
|
|
581
|
+
...initOptions
|
|
582
|
+
}: TRunWorktreeBootstrapCreateOptions) => {
|
|
583
|
+
if (!branch.trim()) throw new Error('worktree create requires --branch <branch>.');
|
|
584
|
+
if (!targetRepoRoot.trim()) throw new Error('worktree create requires <target-repo-root>.');
|
|
585
|
+
|
|
586
|
+
const normalizedSourceAppRoot = normalizePath(appRoot);
|
|
587
|
+
const sourceRepoRoot = await findGitRepoRoot(normalizedSourceAppRoot);
|
|
588
|
+
const sourceAppRelativePath = path.relative(sourceRepoRoot, normalizedSourceAppRoot);
|
|
589
|
+
const normalizedTargetRepoRoot = path.resolve(targetRepoRoot);
|
|
590
|
+
|
|
591
|
+
await runCapture('git', ['worktree', 'add', '-b', branch, normalizedTargetRepoRoot, base], { cwd: sourceRepoRoot });
|
|
592
|
+
|
|
593
|
+
const targetAppRoot = path.join(normalizedTargetRepoRoot, sourceAppRelativePath);
|
|
594
|
+
const initResult = await runWorktreeBootstrapInit({
|
|
595
|
+
...initOptions,
|
|
596
|
+
appRoot: targetAppRoot,
|
|
597
|
+
source: normalizedSourceAppRoot,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
branch,
|
|
602
|
+
sourceAppRoot: normalizedSourceAppRoot,
|
|
603
|
+
sourceRepoRoot,
|
|
604
|
+
targetAppRoot,
|
|
605
|
+
targetRepoRoot: normalizedTargetRepoRoot,
|
|
606
|
+
worktreeBootstrap: initResult,
|
|
607
|
+
};
|
|
608
|
+
};
|
package/cli/scaffold/index.ts
CHANGED
|
@@ -344,14 +344,15 @@ const insertImportLine = ({
|
|
|
344
344
|
const lines = content.split('\n');
|
|
345
345
|
const preferredIndex = findLastMatchingIndex(lines, matcher);
|
|
346
346
|
const fallbackIndex = findLastMatchingIndex(lines, fallbackMatcher);
|
|
347
|
-
const
|
|
348
|
-
const insertIndex =
|
|
347
|
+
const defineApplicationIndex = lines.findIndex((line) => line.includes('defineApplication('));
|
|
348
|
+
const insertIndex =
|
|
349
|
+
preferredIndex >= 0 ? preferredIndex + 1 : fallbackIndex >= 0 ? fallbackIndex + 1 : Math.max(defineApplicationIndex, 0);
|
|
349
350
|
|
|
350
351
|
lines.splice(insertIndex, 0, importLine);
|
|
351
352
|
return lines.join('\n');
|
|
352
353
|
};
|
|
353
354
|
|
|
354
|
-
const
|
|
355
|
+
const insertDefineApplicationService = ({
|
|
355
356
|
content,
|
|
356
357
|
propertyLine,
|
|
357
358
|
}: {
|
|
@@ -361,24 +362,33 @@ const insertClassProperty = ({
|
|
|
361
362
|
if (content.includes(propertyLine.trim())) return content;
|
|
362
363
|
|
|
363
364
|
const lines = content.split('\n');
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
365
|
+
const servicesIndex = lines.findIndex((line) => line.includes('services:') && line.includes('=>'));
|
|
366
|
+
if (servicesIndex < 0) return undefined;
|
|
366
367
|
|
|
367
|
-
const closingIndex = lines.
|
|
368
|
-
|
|
369
|
-
for (let index = closingIndex - 1; index > classIndex; index -= 1) {
|
|
370
|
-
if (/^\s+public .*= new .*;\s*$/.test(lines[index])) return index + 1;
|
|
371
|
-
}
|
|
372
|
-
for (let index = classIndex + 1; index < closingIndex; index += 1) {
|
|
373
|
-
if (/^\s+public .*[;!]\s*$/.test(lines[index])) return index + 1;
|
|
374
|
-
}
|
|
375
|
-
return classIndex + 1;
|
|
376
|
-
})();
|
|
368
|
+
const closingIndex = lines.findIndex((line, index) => index > servicesIndex && /^\s{4}\}\),?\s*$/.test(line));
|
|
369
|
+
if (closingIndex < 0) return undefined;
|
|
377
370
|
|
|
378
|
-
lines.splice(
|
|
371
|
+
lines.splice(closingIndex, 0, propertyLine);
|
|
379
372
|
return lines.join('\n');
|
|
380
373
|
};
|
|
381
374
|
|
|
375
|
+
const insertRootServiceProperty = ({
|
|
376
|
+
content,
|
|
377
|
+
definitionPropertyLine,
|
|
378
|
+
}: {
|
|
379
|
+
content: string;
|
|
380
|
+
definitionPropertyLine: string;
|
|
381
|
+
}) => {
|
|
382
|
+
const defineApplicationContent = insertDefineApplicationService({
|
|
383
|
+
content,
|
|
384
|
+
propertyLine: definitionPropertyLine,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (defineApplicationContent) return defineApplicationContent;
|
|
388
|
+
|
|
389
|
+
throw new UsageError('Could not locate defineApplication services graph in server/index.ts.');
|
|
390
|
+
};
|
|
391
|
+
|
|
382
392
|
const registerRootService = ({
|
|
383
393
|
appRoot,
|
|
384
394
|
servicePath,
|
|
@@ -406,7 +416,7 @@ const registerRootService = ({
|
|
|
406
416
|
|
|
407
417
|
const serviceImportLine = `import ${serviceImportName} from ${JSON.stringify(`@/server/services/${toPosix(servicePath)}`)};`;
|
|
408
418
|
const configImportLine = `import * as ${configNamespace} from ${JSON.stringify(`@/server/config/${configFileBase}`)};`;
|
|
409
|
-
const
|
|
419
|
+
const definitionPropertyLine = ` ${propertyName}: new ${serviceImportName}(app, ${configNamespace}.${configExportName}, app),`;
|
|
410
420
|
|
|
411
421
|
let content = fs.readFileSync(serverIndexFilepath, 'utf8');
|
|
412
422
|
const initialContent = content;
|
|
@@ -423,7 +433,7 @@ const registerRootService = ({
|
|
|
423
433
|
matcher: /^import \* as .* from ['"]@\/server\/config\//,
|
|
424
434
|
fallbackMatcher: /^import .* from ['"]@\/server\/services\//,
|
|
425
435
|
});
|
|
426
|
-
content =
|
|
436
|
+
content = insertRootServiceProperty({ content, definitionPropertyLine });
|
|
427
437
|
|
|
428
438
|
if (content === initialContent) {
|
|
429
439
|
return {
|