me2lon 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/api.d.ts +35 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +286 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +261 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +272 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +173 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +12 -0
- package/dist/presentation.d.ts.map +1 -0
- package/dist/presentation.js +50 -0
- package/dist/presentation.js.map +1 -0
- package/dist/safe-output.d.ts +20 -0
- package/dist/safe-output.d.ts.map +1 -0
- package/dist/safe-output.js +770 -0
- package/dist/safe-output.js.map +1 -0
- package/dist/setup.d.ts +14 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +92 -0
- package/dist/setup.js.map +1 -0
- package/dist/skill.d.ts +2 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +30 -0
- package/dist/skill.js.map +1 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace-source.d.ts +11 -0
- package/dist/workspace-source.d.ts.map +1 -0
- package/dist/workspace-source.js +387 -0
- package/dist/workspace-source.js.map +1 -0
- package/package.json +43 -4
- package/skill/LICENSE +21 -0
- package/skill/SKILL.md +18 -0
- package/src/api.ts +426 -0
- package/src/cli.ts +305 -0
- package/src/client.ts +413 -0
- package/src/config.ts +252 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +37 -0
- package/src/presentation.ts +39 -0
- package/src/safe-output.ts +826 -0
- package/src/setup.ts +95 -0
- package/src/skill.ts +22 -0
- package/src/types.ts +85 -0
- package/src/workspace-source.ts +526 -0
- package/tsconfig.build.json +18 -0
- package/tsconfig.json +14 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { Me2lonAgentApi, mutationAttemptId } from "./api.js";
|
|
2
|
+
import { Me2lonClientError } from "./errors.js";
|
|
3
|
+
import {
|
|
4
|
+
assertNoForbiddenOutput,
|
|
5
|
+
projectCapabilities,
|
|
6
|
+
projectJob,
|
|
7
|
+
projectPolicy,
|
|
8
|
+
projectPolicyProposal,
|
|
9
|
+
projectPolicyUnits,
|
|
10
|
+
repositoryFromList,
|
|
11
|
+
requireResourceId,
|
|
12
|
+
} from "./safe-output.js";
|
|
13
|
+
import {
|
|
14
|
+
LOCAL_SOURCE_LIMITS,
|
|
15
|
+
SOURCE_SCHEMA_VERSION,
|
|
16
|
+
type AgentRuntimeConfig,
|
|
17
|
+
type JsonValue,
|
|
18
|
+
type SafeToolResult,
|
|
19
|
+
type SourceLimits,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
import { buildWorkspaceSource, buildWorkspaceSnapshot } from "./workspace-source.js";
|
|
22
|
+
import { updateProjectState } from "./config.js";
|
|
23
|
+
|
|
24
|
+
const MAX_RETURNED_DIAGNOSTICS = 2_000;
|
|
25
|
+
|
|
26
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
27
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
28
|
+
? (value as Record<string, unknown>)
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function positiveInteger(value: unknown): number | undefined {
|
|
33
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
34
|
+
? value
|
|
35
|
+
: undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nonnegativeInteger(value: unknown): number | undefined {
|
|
39
|
+
return typeof value === "number" &&
|
|
40
|
+
Number.isSafeInteger(value) &&
|
|
41
|
+
value >= 0
|
|
42
|
+
? value
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function jobRecord(raw: Record<string, unknown>): Record<string, unknown> {
|
|
47
|
+
return object(raw.job) ??
|
|
48
|
+
object(raw.checkJob) ??
|
|
49
|
+
object(raw.translationJob) ??
|
|
50
|
+
object(raw.verificationJob) ??
|
|
51
|
+
object(raw.data) ??
|
|
52
|
+
raw;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sourceLimits(raw: Record<string, unknown>): SourceLimits {
|
|
56
|
+
const capabilities = object(raw.capabilities) ?? raw;
|
|
57
|
+
const limits = object(capabilities.limits);
|
|
58
|
+
const source = object(limits?.source) ?? limits;
|
|
59
|
+
return {
|
|
60
|
+
maxChanges: Math.min(
|
|
61
|
+
LOCAL_SOURCE_LIMITS.maxChanges,
|
|
62
|
+
positiveInteger(source?.maxChanges) ?? LOCAL_SOURCE_LIMITS.maxChanges,
|
|
63
|
+
),
|
|
64
|
+
maxFileBytes: Math.min(
|
|
65
|
+
LOCAL_SOURCE_LIMITS.maxFileBytes,
|
|
66
|
+
positiveInteger(source?.maxFileBytes) ?? LOCAL_SOURCE_LIMITS.maxFileBytes,
|
|
67
|
+
),
|
|
68
|
+
maxTotalBytes: Math.min(
|
|
69
|
+
LOCAL_SOURCE_LIMITS.maxTotalBytes,
|
|
70
|
+
positiveInteger(source?.maxTotalBytes) ??
|
|
71
|
+
LOCAL_SOURCE_LIMITS.maxTotalBytes,
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checked(result: SafeToolResult): SafeToolResult {
|
|
77
|
+
assertNoForbiddenOutput(result);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateBoundRepository(
|
|
82
|
+
response: Record<string, unknown>,
|
|
83
|
+
repositoryId: string,
|
|
84
|
+
): void {
|
|
85
|
+
const candidates: Array<string | number> = [];
|
|
86
|
+
const visit = (value: unknown, depth: number): void => {
|
|
87
|
+
if (depth > 4 || !value || typeof value !== "object") return;
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
for (const item of value.slice(0, 100)) visit(item, depth + 1);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
93
|
+
if (
|
|
94
|
+
key === "repositoryId" &&
|
|
95
|
+
(typeof nested === "string" || typeof nested === "number")
|
|
96
|
+
) {
|
|
97
|
+
candidates.push(nested);
|
|
98
|
+
} else if (/^(?:data|job|workspace|revision|result)$/u.test(key)) {
|
|
99
|
+
visit(nested, depth + 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
visit(response, 0);
|
|
104
|
+
if (candidates.some((value) => String(value) !== repositoryId)) {
|
|
105
|
+
throw new Me2lonClientError(
|
|
106
|
+
"ME2LON_REPOSITORY_MISMATCH",
|
|
107
|
+
"The API response belongs to a repository other than the MCP-bound repository.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type PolicyProposalInput = Readonly<{
|
|
113
|
+
policyUnitId: string;
|
|
114
|
+
expectedPolicySetDigest: string;
|
|
115
|
+
policy: JsonValue;
|
|
116
|
+
rationale?: string;
|
|
117
|
+
}>;
|
|
118
|
+
|
|
119
|
+
export class Me2lonAgentClient {
|
|
120
|
+
readonly #config: AgentRuntimeConfig;
|
|
121
|
+
readonly #api: Me2lonAgentApi;
|
|
122
|
+
#repositoryPromise?: Promise<Record<string, unknown>>;
|
|
123
|
+
#workspaceIdPromise?: Promise<string>;
|
|
124
|
+
readonly #newAttemptId: () => string;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
config: AgentRuntimeConfig,
|
|
128
|
+
api?: Me2lonAgentApi,
|
|
129
|
+
newAttemptId: () => string = mutationAttemptId,
|
|
130
|
+
) {
|
|
131
|
+
this.#config = config;
|
|
132
|
+
this.#newAttemptId = newAttemptId;
|
|
133
|
+
this.#api =
|
|
134
|
+
api ??
|
|
135
|
+
new Me2lonAgentApi(
|
|
136
|
+
config.baseUrl,
|
|
137
|
+
config.token,
|
|
138
|
+
config.workspace.repositoryId,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #repository(): Promise<Record<string, unknown>> {
|
|
143
|
+
this.#repositoryPromise ??= (async () => {
|
|
144
|
+
const raw = await this.#api.repositories();
|
|
145
|
+
const repository = repositoryFromList(
|
|
146
|
+
raw,
|
|
147
|
+
this.#config.workspace.repositoryId,
|
|
148
|
+
);
|
|
149
|
+
if (!repository) {
|
|
150
|
+
throw new Me2lonClientError(
|
|
151
|
+
"ME2LON_REPOSITORY_UNAUTHORIZED",
|
|
152
|
+
"The agent credential does not authorize the MCP-bound repository.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return repository;
|
|
156
|
+
})();
|
|
157
|
+
return await this.#repositoryPromise;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async #workspaceId(): Promise<string> {
|
|
161
|
+
await this.#repository();
|
|
162
|
+
if (this.#config.workspaceId) return this.#config.workspaceId;
|
|
163
|
+
this.#workspaceIdPromise ??= (async () => {
|
|
164
|
+
const raw = await this.#api.createWorkspace(
|
|
165
|
+
this.#config.workspace.repositoryId,
|
|
166
|
+
this.#newAttemptId(),
|
|
167
|
+
);
|
|
168
|
+
validateBoundRepository(raw, this.#config.workspace.repositoryId);
|
|
169
|
+
const id = requireResourceId(raw, ["workspace"], ["workspaceId", "id"]);
|
|
170
|
+
await updateProjectState(this.#config, { workspaceId: id });
|
|
171
|
+
return id;
|
|
172
|
+
})();
|
|
173
|
+
return await this.#workspaceIdPromise;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async capabilities(): Promise<SafeToolResult> {
|
|
177
|
+
const [rawCapabilities, rawRepositories] = await Promise.all([
|
|
178
|
+
this.#api.capabilities(),
|
|
179
|
+
this.#api.repositories(),
|
|
180
|
+
]);
|
|
181
|
+
const repository = repositoryFromList(
|
|
182
|
+
rawRepositories,
|
|
183
|
+
this.#config.workspace.repositoryId,
|
|
184
|
+
);
|
|
185
|
+
return checked(projectCapabilities(rawCapabilities, repository));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async analyzeWorkspace(projectRoot?: string, workspaceRevisionId?: string): Promise<SafeToolResult> {
|
|
189
|
+
if (workspaceRevisionId !== undefined) {
|
|
190
|
+
await this.#repository();
|
|
191
|
+
const raw = await this.#api.createAnalysisJob(workspaceRevisionId, projectRoot);
|
|
192
|
+
validateBoundRepository(raw, this.#config.workspace.repositoryId);
|
|
193
|
+
requireResourceId(raw, ['job'], ['id', 'jobId']);
|
|
194
|
+
return checked({ job: projectJob(raw) });
|
|
195
|
+
}
|
|
196
|
+
return this.captureWorkspace(true, projectRoot);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async checkWorkspace(): Promise<SafeToolResult> { return this.captureWorkspace(false); }
|
|
200
|
+
|
|
201
|
+
private async captureWorkspace(analyze: boolean, projectRoot?: string): Promise<SafeToolResult> {
|
|
202
|
+
const attemptId = this.#newAttemptId();
|
|
203
|
+
const [workspaceId, capabilities] = await Promise.all([
|
|
204
|
+
this.#workspaceId(),
|
|
205
|
+
this.#api.capabilities(),
|
|
206
|
+
]);
|
|
207
|
+
if (this.#config.sourceFormat === "snapshot") {
|
|
208
|
+
const source = await buildWorkspaceSnapshot(this.#config.workspace, sourceLimits(capabilities));
|
|
209
|
+
const revision = await this.#api.createSnapshot(workspaceId, source, this.#config.previousRevisionId, attemptId);
|
|
210
|
+
validateBoundRepository(revision, this.#config.workspace.repositoryId);
|
|
211
|
+
const revisionId = requireResourceId(revision, ["revision"], ["id", "revisionId"]);
|
|
212
|
+
await updateProjectState(this.#config, { previousRevisionId: revisionId });
|
|
213
|
+
const job = await (analyze ? this.#api.createAnalysisJob(revisionId, projectRoot, attemptId) : this.#api.createCheckJob(revisionId, attemptId));
|
|
214
|
+
validateBoundRepository(job, this.#config.workspace.repositoryId);
|
|
215
|
+
requireResourceId(job, ["job", "checkJob"], ["jobId", "id"]);
|
|
216
|
+
return checked({ job: projectJob(job), upload: {
|
|
217
|
+
sourceSchemaVersion: source.schemaVersion, baseCommitSha: source.baseCommitSha,
|
|
218
|
+
branchLabel: source.branchLabel, digest: source.digest, fileCount: source.files.length,
|
|
219
|
+
totalReplacementBytes: source.totalReplacementBytes, workspaceRevisionId: revisionId,
|
|
220
|
+
} });
|
|
221
|
+
}
|
|
222
|
+
const source = await buildWorkspaceSource(
|
|
223
|
+
this.#config.workspace,
|
|
224
|
+
sourceLimits(capabilities),
|
|
225
|
+
);
|
|
226
|
+
const revision = await this.#api.createRevision(
|
|
227
|
+
workspaceId,
|
|
228
|
+
source,
|
|
229
|
+
attemptId,
|
|
230
|
+
);
|
|
231
|
+
validateBoundRepository(revision, this.#config.workspace.repositoryId);
|
|
232
|
+
const workspaceRevisionId = requireResourceId(
|
|
233
|
+
revision,
|
|
234
|
+
["revision", "workspaceRevision"],
|
|
235
|
+
["workspaceRevisionId", "revisionId", "id"],
|
|
236
|
+
);
|
|
237
|
+
const job = await (analyze ? this.#api.createAnalysisJob(workspaceRevisionId, projectRoot, attemptId) : this.#api.createCheckJob(workspaceRevisionId, attemptId));
|
|
238
|
+
validateBoundRepository(job, this.#config.workspace.repositoryId);
|
|
239
|
+
requireResourceId(job, ["job", "checkJob"], ["jobId", "id"]);
|
|
240
|
+
return checked({
|
|
241
|
+
job: projectJob(job),
|
|
242
|
+
upload: {
|
|
243
|
+
sourceSchemaVersion: SOURCE_SCHEMA_VERSION,
|
|
244
|
+
baseCommitSha: source.baseCommitSha,
|
|
245
|
+
digest: source.digest,
|
|
246
|
+
replacementCount: source.replacementCount,
|
|
247
|
+
deletionCount: source.deletionCount,
|
|
248
|
+
totalReplacementBytes: source.totalReplacementBytes,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async getJob(jobId: string): Promise<SafeToolResult> {
|
|
254
|
+
await this.#repository();
|
|
255
|
+
const raw = await this.#api.job(jobId);
|
|
256
|
+
validateBoundRepository(raw, this.#config.workspace.repositoryId);
|
|
257
|
+
const job = jobRecord(raw);
|
|
258
|
+
const kind = typeof job.kind === "string" ? job.kind.toLowerCase() : "";
|
|
259
|
+
const status =
|
|
260
|
+
typeof job.status === "string" ? job.status.toLowerCase() : "";
|
|
261
|
+
const diagnostics =
|
|
262
|
+
(kind === "analysis" || (kind === "check" && ["succeeded", "completed"].includes(status)))
|
|
263
|
+
? await this.#checkDiagnostics(jobId)
|
|
264
|
+
: undefined;
|
|
265
|
+
return checked(projectJob(raw, diagnostics));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async #checkDiagnostics(jobId: string): Promise<Record<string, unknown>> {
|
|
269
|
+
const items: unknown[] = [];
|
|
270
|
+
let cursor = 0;
|
|
271
|
+
let total: number | undefined;
|
|
272
|
+
let nextCursor: number | null = null;
|
|
273
|
+
let pages = 0;
|
|
274
|
+
|
|
275
|
+
while (items.length < MAX_RETURNED_DIAGNOSTICS && pages < 4) {
|
|
276
|
+
pages += 1;
|
|
277
|
+
const page = await this.#api.jobDiagnostics(
|
|
278
|
+
jobId,
|
|
279
|
+
cursor,
|
|
280
|
+
Math.min(500, MAX_RETURNED_DIAGNOSTICS - items.length),
|
|
281
|
+
);
|
|
282
|
+
const body = object(page.data) ?? page;
|
|
283
|
+
const pageItems = Array.isArray(body.items) ? body.items : [];
|
|
284
|
+
items.push(...pageItems.slice(0, MAX_RETURNED_DIAGNOSTICS - items.length));
|
|
285
|
+
total = nonnegativeInteger(body.total) ?? total;
|
|
286
|
+
const candidate =
|
|
287
|
+
body.nextCursor === null ? null : nonnegativeInteger(body.nextCursor);
|
|
288
|
+
if (candidate === null || candidate === undefined || candidate <= cursor) {
|
|
289
|
+
nextCursor = null;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
nextCursor = candidate;
|
|
293
|
+
cursor = candidate;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (pages === 4 && nextCursor !== null) {
|
|
297
|
+
nextCursor = cursor;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
items,
|
|
302
|
+
total: total ?? items.length,
|
|
303
|
+
nextCursor,
|
|
304
|
+
truncated:
|
|
305
|
+
nextCursor !== null ||
|
|
306
|
+
(total !== undefined && total > items.length),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async translate(checkJobId: string, projectId: string): Promise<SafeToolResult> {
|
|
311
|
+
await this.#repository();
|
|
312
|
+
const raw = await this.#api.createTranslationJob(
|
|
313
|
+
checkJobId,
|
|
314
|
+
projectId,
|
|
315
|
+
this.#newAttemptId(),
|
|
316
|
+
);
|
|
317
|
+
validateBoundRepository(raw, this.#config.workspace.repositoryId);
|
|
318
|
+
requireResourceId(raw, ["job", "translationJob"], ["jobId", "id"]);
|
|
319
|
+
return checked(projectJob(raw));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async verify(
|
|
323
|
+
translationJobId: string,
|
|
324
|
+
expectedPolicySetDigest: string,
|
|
325
|
+
): Promise<SafeToolResult> {
|
|
326
|
+
await this.#repository();
|
|
327
|
+
const raw = await this.#api.createVerificationJob(
|
|
328
|
+
translationJobId,
|
|
329
|
+
expectedPolicySetDigest,
|
|
330
|
+
this.#newAttemptId(),
|
|
331
|
+
);
|
|
332
|
+
validateBoundRepository(raw, this.#config.workspace.repositoryId);
|
|
333
|
+
requireResourceId(raw, ["job", "verificationJob"], ["jobId", "id"]);
|
|
334
|
+
return checked(projectJob(raw));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async getPolicy(policyUnitId?: string): Promise<SafeToolResult> {
|
|
338
|
+
await this.#repository();
|
|
339
|
+
if (!policyUnitId) {
|
|
340
|
+
return checked(
|
|
341
|
+
projectPolicyUnits(
|
|
342
|
+
await this.#api.policyUnits(this.#config.workspace.repositoryId),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return checked(projectPolicy(await this.#api.policyUnit(policyUnitId)));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async proposePolicy(input: PolicyProposalInput): Promise<SafeToolResult> {
|
|
350
|
+
await this.#repository();
|
|
351
|
+
const attemptId = this.#newAttemptId();
|
|
352
|
+
const draft = await this.#api.createPolicyDraft(
|
|
353
|
+
input.policyUnitId,
|
|
354
|
+
input.expectedPolicySetDigest,
|
|
355
|
+
attemptId,
|
|
356
|
+
);
|
|
357
|
+
const draftId = requireResourceId(draft, ["draft"], ["draftId", "id"]);
|
|
358
|
+
const revision = await this.#api.createPolicyRevision(
|
|
359
|
+
draftId,
|
|
360
|
+
input.expectedPolicySetDigest,
|
|
361
|
+
input.policy,
|
|
362
|
+
input.rationale,
|
|
363
|
+
attemptId,
|
|
364
|
+
);
|
|
365
|
+
const revisionId = requireResourceId(
|
|
366
|
+
revision,
|
|
367
|
+
["revision"],
|
|
368
|
+
["revisionId", "id"],
|
|
369
|
+
);
|
|
370
|
+
const validation = await this.#api.validatePolicyRevision(
|
|
371
|
+
revisionId,
|
|
372
|
+
attemptId,
|
|
373
|
+
);
|
|
374
|
+
const validationInput = object(validation.validation) ?? validation;
|
|
375
|
+
const diff = await this.#api.policyRevisionDiff(revisionId);
|
|
376
|
+
const approvalEligible =
|
|
377
|
+
validationInput.status === "valid_complete" &&
|
|
378
|
+
validationInput.approvalEligible === true;
|
|
379
|
+
if (!approvalEligible) {
|
|
380
|
+
return checked(
|
|
381
|
+
projectPolicyProposal({
|
|
382
|
+
draft,
|
|
383
|
+
revision,
|
|
384
|
+
validation,
|
|
385
|
+
diff,
|
|
386
|
+
submitted: false,
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const submission = await this.#api.submitPolicyRevision(
|
|
391
|
+
revisionId,
|
|
392
|
+
input.expectedPolicySetDigest,
|
|
393
|
+
attemptId,
|
|
394
|
+
);
|
|
395
|
+
return checked(
|
|
396
|
+
projectPolicyProposal({
|
|
397
|
+
draft,
|
|
398
|
+
revision,
|
|
399
|
+
validation,
|
|
400
|
+
diff,
|
|
401
|
+
submission,
|
|
402
|
+
submitted: true,
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async cancelJob(jobId: string): Promise<SafeToolResult> {
|
|
408
|
+
await this.#repository();
|
|
409
|
+
const raw = await this.#api.cancelJob(jobId);
|
|
410
|
+
validateBoundRepository(raw, this.#config.workspace.repositoryId);
|
|
411
|
+
return checked(projectJob(raw));
|
|
412
|
+
}
|
|
413
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { Me2lonClientError } from "./errors.js";
|
|
7
|
+
import type {
|
|
8
|
+
AgentRuntimeConfig,
|
|
9
|
+
ConfigFileShape,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
import { bindWorkspace, discoverWorkspace } from "./workspace-source.js";
|
|
12
|
+
|
|
13
|
+
export function projectConfigPath(root: string, environment: NodeJS.ProcessEnv = process.env): string {
|
|
14
|
+
return join(environment.XDG_CONFIG_HOME || join(homedir(), ".config"), "me2lon", "projects",
|
|
15
|
+
`${createHash("sha256").update(root).digest("hex")}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeProjectConfig(path: string, value: ConfigFileShape): Promise<void> {
|
|
19
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
20
|
+
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
21
|
+
try {
|
|
22
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
23
|
+
await rename(temporary, path);
|
|
24
|
+
} finally { await rm(temporary, { force: true }); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function updateProjectState(config: AgentRuntimeConfig, state: { workspaceId?: string; previousRevisionId?: string }): Promise<void> {
|
|
28
|
+
if (!config.configPath || config.sourceFormat !== "snapshot") return;
|
|
29
|
+
const current = await readConfigFile(config.configPath);
|
|
30
|
+
if (current.repositoryId !== config.workspace.repositoryId || current.workspaceRoot !== config.workspace.root ||
|
|
31
|
+
current.baseUrl !== config.baseUrl.origin || current.credentialId !== config.credentialId) {
|
|
32
|
+
throw new Me2lonClientError("ME2LON_CONFIG_CHANGED", "The project connection changed; restart the client.");
|
|
33
|
+
}
|
|
34
|
+
await writeProjectConfig(config.configPath, { ...current, ...state });
|
|
35
|
+
Object.assign(config, state);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type RuntimeConfigOverrides = {
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
tokenFromStdin?: string;
|
|
41
|
+
workspaceRoot?: string;
|
|
42
|
+
repositoryId?: string;
|
|
43
|
+
baseCommitSha?: string;
|
|
44
|
+
configPath?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function optionalNonEmptyString(
|
|
48
|
+
value: unknown,
|
|
49
|
+
field: string,
|
|
50
|
+
): string | undefined {
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
55
|
+
throw new Me2lonClientError(
|
|
56
|
+
"ME2LON_CONFIG_INVALID",
|
|
57
|
+
`Configuration field ${field} must be a non-empty string.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return value.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readConfigFile(
|
|
64
|
+
configPath: string | undefined,
|
|
65
|
+
): Promise<ConfigFileShape> {
|
|
66
|
+
if (!configPath) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const absolutePath = resolve(configPath);
|
|
71
|
+
let metadata;
|
|
72
|
+
try {
|
|
73
|
+
metadata = await stat(absolutePath);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Me2lonClientError(
|
|
76
|
+
"ME2LON_CONFIG_UNREADABLE",
|
|
77
|
+
`Cannot read the Me2lon configuration file at ${absolutePath}.`,
|
|
78
|
+
{ cause: error },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!metadata.isFile()) {
|
|
82
|
+
throw new Me2lonClientError(
|
|
83
|
+
"ME2LON_CONFIG_INVALID",
|
|
84
|
+
`The Me2lon configuration path is not a regular file: ${absolutePath}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) {
|
|
88
|
+
throw new Me2lonClientError(
|
|
89
|
+
"ME2LON_CONFIG_PERMISSIONS",
|
|
90
|
+
"The Me2lon configuration file contains credentials and must not be accessible by group or other users (use mode 0600).",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let parsed: unknown;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(await readFile(absolutePath, "utf8"));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Me2lonClientError(
|
|
99
|
+
"ME2LON_CONFIG_INVALID",
|
|
100
|
+
"The Me2lon configuration file must contain valid JSON.",
|
|
101
|
+
{ cause: error },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
105
|
+
throw new Me2lonClientError(
|
|
106
|
+
"ME2LON_CONFIG_INVALID",
|
|
107
|
+
"The Me2lon configuration file must contain a JSON object.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const record = parsed as Record<string, unknown>;
|
|
112
|
+
const result: ConfigFileShape = {};
|
|
113
|
+
const baseUrl = optionalNonEmptyString(record.baseUrl, "baseUrl");
|
|
114
|
+
const token = optionalNonEmptyString(record.token, "token");
|
|
115
|
+
const workspaceRoot = optionalNonEmptyString(
|
|
116
|
+
record.workspaceRoot,
|
|
117
|
+
"workspaceRoot",
|
|
118
|
+
);
|
|
119
|
+
const repositoryId = optionalNonEmptyString(
|
|
120
|
+
record.repositoryId,
|
|
121
|
+
"repositoryId",
|
|
122
|
+
);
|
|
123
|
+
const baseCommitSha = optionalNonEmptyString(
|
|
124
|
+
record.baseCommitSha,
|
|
125
|
+
"baseCommitSha",
|
|
126
|
+
);
|
|
127
|
+
if (baseUrl) result.baseUrl = baseUrl;
|
|
128
|
+
if (token) result.token = token;
|
|
129
|
+
if (workspaceRoot) result.workspaceRoot = workspaceRoot;
|
|
130
|
+
if (repositoryId) result.repositoryId = repositoryId;
|
|
131
|
+
if (baseCommitSha) result.baseCommitSha = baseCommitSha;
|
|
132
|
+
if (record.sourceFormat !== undefined) {
|
|
133
|
+
if (record.sourceFormat !== "snapshot") throw new Me2lonClientError("ME2LON_CONFIG_INVALID", "Unknown source format.");
|
|
134
|
+
result.sourceFormat = "snapshot";
|
|
135
|
+
}
|
|
136
|
+
for (const field of ["credentialId", "workspaceId", "previousRevisionId"] as const) {
|
|
137
|
+
const value = optionalNonEmptyString(record[field], field);
|
|
138
|
+
if (value !== undefined) result[field] = value;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parseBaseUrl(raw: string): URL {
|
|
144
|
+
let url: URL;
|
|
145
|
+
try {
|
|
146
|
+
url = new URL(raw);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Me2lonClientError(
|
|
149
|
+
"ME2LON_CONFIG_INVALID",
|
|
150
|
+
"The Me2lon API URL is invalid.",
|
|
151
|
+
{ cause: error },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
155
|
+
throw new Me2lonClientError(
|
|
156
|
+
"ME2LON_CONFIG_INVALID",
|
|
157
|
+
"The Me2lon API URL cannot contain credentials, a query, or a fragment.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (url.pathname !== "/") {
|
|
161
|
+
throw new Me2lonClientError(
|
|
162
|
+
"ME2LON_CONFIG_INVALID",
|
|
163
|
+
"The Me2lon API URL must be an origin without a path.",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const loopback = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
167
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback.has(url.hostname))) {
|
|
168
|
+
throw new Me2lonClientError(
|
|
169
|
+
"ME2LON_INSECURE_URL",
|
|
170
|
+
"The Me2lon API URL must use HTTPS (HTTP is accepted only for loopback development).",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return url;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function required(value: string | undefined, label: string): string {
|
|
177
|
+
if (!value) {
|
|
178
|
+
throw new Me2lonClientError(
|
|
179
|
+
"ME2LON_CONFIG_MISSING",
|
|
180
|
+
`Missing ${label}.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function resolveRuntimeConfig(
|
|
187
|
+
overrides: RuntimeConfigOverrides,
|
|
188
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
189
|
+
): Promise<AgentRuntimeConfig> {
|
|
190
|
+
let configPath = overrides.configPath ?? environment.ME2LON_AGENT_CONFIG;
|
|
191
|
+
if (!configPath) {
|
|
192
|
+
const workspace = await discoverWorkspace(overrides.workspaceRoot ?? environment.ME2LON_WORKSPACE_ROOT ?? process.cwd());
|
|
193
|
+
const candidate = projectConfigPath(workspace.root, environment);
|
|
194
|
+
try { await stat(candidate); configPath = candidate; }
|
|
195
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
196
|
+
}
|
|
197
|
+
const file = await readConfigFile(configPath);
|
|
198
|
+
const token =
|
|
199
|
+
overrides.tokenFromStdin?.trim() ||
|
|
200
|
+
environment.ME2LON_AGENT_TOKEN?.trim() ||
|
|
201
|
+
file.token?.trim();
|
|
202
|
+
if (!token) {
|
|
203
|
+
throw new Me2lonClientError(
|
|
204
|
+
"ME2LON_TOKEN_MISSING",
|
|
205
|
+
"Run me2lon setup to connect this project. Existing tokens can also use ME2LON_AGENT_TOKEN, a protected config file, or --token-stdin.",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (token.length > 8_192 || /[\r\n]/u.test(token)) {
|
|
209
|
+
throw new Me2lonClientError(
|
|
210
|
+
"ME2LON_TOKEN_INVALID",
|
|
211
|
+
"The Me2lon agent token has an invalid shape.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const baseUrl = parseBaseUrl(
|
|
216
|
+
required(
|
|
217
|
+
overrides.baseUrl ?? environment.ME2LON_AGENT_URL ?? file.baseUrl,
|
|
218
|
+
"Me2lon API URL (--base-url or ME2LON_AGENT_URL)",
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
const root =
|
|
222
|
+
overrides.workspaceRoot ??
|
|
223
|
+
environment.ME2LON_WORKSPACE_ROOT ??
|
|
224
|
+
file.workspaceRoot ??
|
|
225
|
+
process.cwd();
|
|
226
|
+
const repositoryId = required(
|
|
227
|
+
overrides.repositoryId ??
|
|
228
|
+
environment.ME2LON_REPOSITORY_ID ??
|
|
229
|
+
file.repositoryId,
|
|
230
|
+
"repository id (--repository-id or ME2LON_REPOSITORY_ID)",
|
|
231
|
+
);
|
|
232
|
+
if (file.sourceFormat === "snapshot" && (overrides.baseCommitSha || environment.ME2LON_BASE_COMMIT)) {
|
|
233
|
+
throw new Me2lonClientError("ME2LON_CONFIG_INVALID", "Full snapshots capture the current HEAD; remove the base-commit override.");
|
|
234
|
+
}
|
|
235
|
+
const baseCommitSha = file.sourceFormat === "snapshot" ? (await discoverWorkspace(root)).head : required(
|
|
236
|
+
overrides.baseCommitSha ??
|
|
237
|
+
environment.ME2LON_BASE_COMMIT ??
|
|
238
|
+
file.baseCommitSha,
|
|
239
|
+
"exact base commit (--base-commit or ME2LON_BASE_COMMIT)",
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
baseUrl,
|
|
244
|
+
token,
|
|
245
|
+
workspace: await bindWorkspace(root, repositoryId, baseCommitSha),
|
|
246
|
+
...(configPath ? { configPath: resolve(configPath) } : {}),
|
|
247
|
+
...(file.sourceFormat ? { sourceFormat: file.sourceFormat } : {}),
|
|
248
|
+
...(file.credentialId ? { credentialId: file.credentialId } : {}),
|
|
249
|
+
...(file.workspaceId ? { workspaceId: file.workspaceId } : {}),
|
|
250
|
+
...(file.previousRevisionId ? { previousRevisionId: file.previousRevisionId } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export class Me2lonClientError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
|
|
4
|
+
constructor(code: string, message: string, options?: ErrorOptions) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = "Me2lonClientError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Me2lonApiError extends Me2lonClientError {
|
|
12
|
+
readonly status: number;
|
|
13
|
+
readonly requestId?: string;
|
|
14
|
+
|
|
15
|
+
constructor(status: number, requestId?: string) {
|
|
16
|
+
const requestSuffix = requestId ? `; request ${requestId}` : "";
|
|
17
|
+
super(
|
|
18
|
+
status === 401 ? "ME2LON_AUTHENTICATION_REQUIRED" : "ME2LON_API_ERROR",
|
|
19
|
+
status === 401 ? "Me2lon authentication expired or was revoked. Run me2lon setup, then retry." : `Me2lon API request failed with HTTP ${status}${requestSuffix}.`,
|
|
20
|
+
);
|
|
21
|
+
this.name = "Me2lonApiError";
|
|
22
|
+
this.status = status;
|
|
23
|
+
if (requestId) {
|
|
24
|
+
this.requestId = requestId;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function publicError(error: unknown): {
|
|
30
|
+
code: string;
|
|
31
|
+
message: string;
|
|
32
|
+
} {
|
|
33
|
+
if (error instanceof Me2lonClientError) {
|
|
34
|
+
return { code: error.code, message: error.message };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
code: "ME2LON_CLIENT_FAILURE",
|
|
38
|
+
message: "The Me2lon client could not complete the request.",
|
|
39
|
+
};
|
|
40
|
+
}
|