loopengine 0.1.13 → 0.1.15
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.
|
@@ -29,6 +29,14 @@ export interface PackageManifest {
|
|
|
29
29
|
* same role .create-loopengine.json already plays for template files. */
|
|
30
30
|
export interface InstalledPackageRecord {
|
|
31
31
|
version: string;
|
|
32
|
+
/** The exact spec (bare registry name, "name@version", a git+ssh/
|
|
33
|
+
* git+https URL with a #committish, a file: path, ...) last used to
|
|
34
|
+
* successfully install or upgrade this package — see
|
|
35
|
+
* oldContentSpec's own doc comment for why upgradePackage needs this
|
|
36
|
+
* stored verbatim rather than reconstructed from `packageName`.
|
|
37
|
+
* Optional only because a package installed before this field existed
|
|
38
|
+
* has no recorded value — see oldContentSpec's own fallback. */
|
|
39
|
+
spec?: string;
|
|
32
40
|
tools: string[];
|
|
33
41
|
skills: string[];
|
|
34
42
|
actauthRules: string[];
|
|
@@ -99,14 +107,14 @@ export interface UpgradeFileResult {
|
|
|
99
107
|
* to `packageName`, i.e. "latest") currently resolves to. Tool and skill
|
|
100
108
|
* files get a real three-way merge (mine = current file, possibly
|
|
101
109
|
* hand-edited since install; base = the version recorded at
|
|
102
|
-
* install/last-upgrade, refetched via
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
+
* install/last-upgrade, refetched via oldContentSpec; theirs = newly
|
|
111
|
+
* fetched) — identical technique to `create-loopengine upgrade`, just
|
|
112
|
+
* applied to package-managed files instead of template files. actauth
|
|
113
|
+
* rules upgrade per-rule instead: the target actauth.yml holds rules
|
|
114
|
+
* from other packages and hand-written ones too, so there's no coherent
|
|
115
|
+
* "whole file" base/theirs to merge — a rule unchanged since install
|
|
116
|
+
* updates cleanly; a hand-edited one is left alone and reported as a
|
|
117
|
+
* conflict rather than overwritten. */
|
|
110
118
|
export declare function upgradePackage(agentName: string, packageName: string, options?: FetchOptions & {
|
|
111
119
|
spec?: string;
|
|
112
120
|
}): Promise<{
|
|
@@ -24,7 +24,7 @@ import { tmpdir } from 'node:os';
|
|
|
24
24
|
import { parse as parseYaml } from 'yaml';
|
|
25
25
|
import semver from 'semver';
|
|
26
26
|
import { agentDir } from '../core/gateway-tools.js';
|
|
27
|
-
import { addToolToIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
27
|
+
import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
28
28
|
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
29
29
|
export class PackageManifestError extends Error {
|
|
30
30
|
}
|
|
@@ -283,6 +283,7 @@ export async function installPackage(agentName, spec, options = {}) {
|
|
|
283
283
|
}
|
|
284
284
|
provenance[manifest.name] = {
|
|
285
285
|
version: manifest.version,
|
|
286
|
+
spec,
|
|
286
287
|
tools: toolNames,
|
|
287
288
|
skills: skillIds,
|
|
288
289
|
actauthRules: rules.map((r) => r.name),
|
|
@@ -321,18 +322,53 @@ function mergeFile(mine, base, theirs) {
|
|
|
321
322
|
rmSync(scratchDir, { recursive: true, force: true });
|
|
322
323
|
return { merged, conflicted };
|
|
323
324
|
}
|
|
325
|
+
// A git+ssh/git+https/git: URL, a plain https: tarball URL, or a local
|
|
326
|
+
// file:/relative/absolute path all already pin to exact, immutable
|
|
327
|
+
// content on their own (a #committish, or the file/tarball's own
|
|
328
|
+
// content) — appending "@version" to one of these doesn't select an
|
|
329
|
+
// older version the way it does for a registry specifier, it corrupts
|
|
330
|
+
// the spec. Confirmed live: `npm pack 'git+file://...#v1.0.0@1.0.0'`
|
|
331
|
+
// fails outright ("The git reference could not be found... pathspec
|
|
332
|
+
// 'v1.0.0@1.0.0'"), it doesn't fall back to resolving just the tag.
|
|
333
|
+
const PINNED_SPEC = /^(git\+|git:|https?:|file:|\.\.?\/|\/)/;
|
|
334
|
+
/** What to fetch to reconstruct the exact content that was installed or
|
|
335
|
+
* last upgraded to, for use as the three-way merge's `base` — as
|
|
336
|
+
* distinct from `spec`, which is what the *new* content resolves to.
|
|
337
|
+
* A plain registry specifier (bare or scoped name, with or without its
|
|
338
|
+
* own "@version") is repinned to `version` — the manifest's own
|
|
339
|
+
* declared version at that install/upgrade, which is what actually got
|
|
340
|
+
* written to disk, regardless of whether the range originally given
|
|
341
|
+
* would still resolve there today. A git/file/URL spec is returned
|
|
342
|
+
* as-is — see PINNED_SPEC's own doc comment for why appending "@version"
|
|
343
|
+
* to one of those breaks instead of pinning. `recordedSpec` is only
|
|
344
|
+
* absent for a package installed before InstalledPackageRecord.spec
|
|
345
|
+
* existed; falling back to `packageName` there reproduces this
|
|
346
|
+
* function's own old (buggy for a git/file install) behavior exactly —
|
|
347
|
+
* no worse than before, and only for a package that hasn't upgraded
|
|
348
|
+
* since. */
|
|
349
|
+
function oldContentSpec(recordedSpec, packageName, version) {
|
|
350
|
+
const base = recordedSpec ?? packageName;
|
|
351
|
+
if (PINNED_SPEC.test(base))
|
|
352
|
+
return base;
|
|
353
|
+
// Strip any version/tag the spec already carries (a scoped name's own
|
|
354
|
+
// leading '@' isn't this — only a second '@' after the name is) before
|
|
355
|
+
// repinning, so re-upgrading an already-version-pinned install doesn't
|
|
356
|
+
// produce a doubled-up "name@1.0.0@1.0.0".
|
|
357
|
+
const bareName = base.startsWith('@') ? `@${base.slice(1).split('@')[0]}` : base.split('@')[0];
|
|
358
|
+
return `${bareName}@${version}`;
|
|
359
|
+
}
|
|
324
360
|
/** Upgrades an already-installed package to whatever `spec` (defaulting
|
|
325
361
|
* to `packageName`, i.e. "latest") currently resolves to. Tool and skill
|
|
326
362
|
* files get a real three-way merge (mine = current file, possibly
|
|
327
363
|
* hand-edited since install; base = the version recorded at
|
|
328
|
-
* install/last-upgrade, refetched via
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
364
|
+
* install/last-upgrade, refetched via oldContentSpec; theirs = newly
|
|
365
|
+
* fetched) — identical technique to `create-loopengine upgrade`, just
|
|
366
|
+
* applied to package-managed files instead of template files. actauth
|
|
367
|
+
* rules upgrade per-rule instead: the target actauth.yml holds rules
|
|
368
|
+
* from other packages and hand-written ones too, so there's no coherent
|
|
369
|
+
* "whole file" base/theirs to merge — a rule unchanged since install
|
|
370
|
+
* updates cleanly; a hand-edited one is left alone and reported as a
|
|
371
|
+
* conflict rather than overwritten. */
|
|
336
372
|
export async function upgradePackage(agentName, packageName, options = {}) {
|
|
337
373
|
const fetch = options.fetchPackageDir ?? fetchPackageDir;
|
|
338
374
|
const provenance = readProvenance(agentName);
|
|
@@ -341,7 +377,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
|
|
|
341
377
|
throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
|
|
342
378
|
}
|
|
343
379
|
const spec = options.spec ?? packageName;
|
|
344
|
-
const oldDir = fetch(
|
|
380
|
+
const oldDir = fetch(oldContentSpec(record.spec, packageName, record.version));
|
|
345
381
|
const newDir = fetch(spec);
|
|
346
382
|
const oldManifest = readManifest(oldDir);
|
|
347
383
|
const newManifest = readManifest(newDir);
|
|
@@ -416,7 +452,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
|
|
|
416
452
|
updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
|
|
417
453
|
results.push({ path, status: 'updated' });
|
|
418
454
|
}
|
|
419
|
-
provenance[packageName] = { ...record, version: newManifest.version, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
|
|
455
|
+
provenance[packageName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
|
|
420
456
|
writeProvenance(agentName, provenance);
|
|
421
457
|
return { files: results };
|
|
422
458
|
}
|
|
@@ -448,6 +484,7 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
448
484
|
const refused = [];
|
|
449
485
|
const remainingTools = [];
|
|
450
486
|
const remainingSkills = [];
|
|
487
|
+
const toolsIndexPath = join(agentDir(agentName), 'tools', 'index.ts');
|
|
451
488
|
for (const toolName of record.tools) {
|
|
452
489
|
const relPath = `tools/${toolName}.ts`;
|
|
453
490
|
const fullPath = join(agentDir(agentName), relPath);
|
|
@@ -457,6 +494,14 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
457
494
|
continue;
|
|
458
495
|
}
|
|
459
496
|
rmSync(fullPath, { force: true });
|
|
497
|
+
// Undoes installPackage's own addToolToIndex call — without this, a
|
|
498
|
+
// deleted tool's import survives in tools/index.ts, and either fails
|
|
499
|
+
// the next build (a dangling import to a file that no longer exists)
|
|
500
|
+
// or, worse, silently duplicates on a later add-package of the same
|
|
501
|
+
// tool (addToolToIndex used to have no idempotence check at all —
|
|
502
|
+
// now it does, but this is the actual fix: the stale entry shouldn't
|
|
503
|
+
// be there to begin with).
|
|
504
|
+
removeToolFromIndex(toolsIndexPath, toolName, toCamelCase(toolName));
|
|
460
505
|
removed.push(relPath);
|
|
461
506
|
}
|
|
462
507
|
for (const skillId of record.skills) {
|
|
@@ -38,6 +38,17 @@ export interface HttpToolSpec {
|
|
|
38
38
|
}
|
|
39
39
|
export declare function toCamelCase(snakeCase: string): string;
|
|
40
40
|
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
|
|
41
|
+
/** addToolToIndex's own undo — removes the named tool's import line and
|
|
42
|
+
* array entry. Best-effort, not "refuse rather than guess" like
|
|
43
|
+
* addToolToIndex: this only ever runs against a tool package-manager.ts
|
|
44
|
+
* itself installed (so the exact shape addToolToIndex wrote is known),
|
|
45
|
+
* and the caller (removePackage) has already deleted the tool's .ts
|
|
46
|
+
* file — leaving a stale entry behind, the previous behavior, means a
|
|
47
|
+
* dangling import that fails the next build, which is strictly worse
|
|
48
|
+
* than a no-op here if the file turns out not to match (hand-edited
|
|
49
|
+
* since). No-ops (rather than throwing) when the file is missing
|
|
50
|
+
* entirely, or the expected import/array-entry shape isn't found. */
|
|
51
|
+
export declare function removeToolFromIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
|
|
41
52
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
42
53
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
43
54
|
* returns the real, callable ToolDefinition object — so the route
|
|
@@ -191,15 +191,52 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
|
191
191
|
if (!arrayMatch) {
|
|
192
192
|
throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
|
|
193
193
|
}
|
|
194
|
+
// Idempotent — a caller that adds the same tool twice (e.g. package-
|
|
195
|
+
// manager.ts's removePackage not having cleaned up this same file
|
|
196
|
+
// before a re-add, or simply calling this twice by mistake) gets a
|
|
197
|
+
// no-op the second time instead of a duplicate `import { X }`
|
|
198
|
+
// declaration, which is a TypeScript syntax error ("Duplicate
|
|
199
|
+
// identifier"), not just a harmless extra line.
|
|
200
|
+
const arrayEntries = arrayMatch[1]
|
|
201
|
+
.split(',')
|
|
202
|
+
.map((e) => e.trim())
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
if (arrayEntries.includes(exportName))
|
|
205
|
+
return;
|
|
194
206
|
const importLine = `import { ${exportName} } from './${toolFileName}.js'\n`;
|
|
195
207
|
const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
|
|
196
208
|
const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
|
|
197
209
|
const withImport = source.slice(0, insertAt) + importLine + source.slice(insertAt);
|
|
198
|
-
const
|
|
199
|
-
const newEntries = existingEntries ? `${existingEntries}, ${exportName}` : exportName;
|
|
210
|
+
const newEntries = [...arrayEntries, exportName].join(', ');
|
|
200
211
|
const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
|
|
201
212
|
writeFileSync(toolsIndexPath, withEntry);
|
|
202
213
|
}
|
|
214
|
+
/** addToolToIndex's own undo — removes the named tool's import line and
|
|
215
|
+
* array entry. Best-effort, not "refuse rather than guess" like
|
|
216
|
+
* addToolToIndex: this only ever runs against a tool package-manager.ts
|
|
217
|
+
* itself installed (so the exact shape addToolToIndex wrote is known),
|
|
218
|
+
* and the caller (removePackage) has already deleted the tool's .ts
|
|
219
|
+
* file — leaving a stale entry behind, the previous behavior, means a
|
|
220
|
+
* dangling import that fails the next build, which is strictly worse
|
|
221
|
+
* than a no-op here if the file turns out not to match (hand-edited
|
|
222
|
+
* since). No-ops (rather than throwing) when the file is missing
|
|
223
|
+
* entirely, or the expected import/array-entry shape isn't found. */
|
|
224
|
+
export function removeToolFromIndex(toolsIndexPath, toolFileName, exportName) {
|
|
225
|
+
if (!existsSync(toolsIndexPath))
|
|
226
|
+
return;
|
|
227
|
+
const source = readFileSync(toolsIndexPath, 'utf8');
|
|
228
|
+
const importLineRe = new RegExp(`^import \\{ ${exportName} \\} from '\\./${toolFileName}\\.js'\\n`, 'm');
|
|
229
|
+
const withoutImport = source.replace(importLineRe, '');
|
|
230
|
+
const arrayMatch = withoutImport.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
231
|
+
if (!arrayMatch)
|
|
232
|
+
return;
|
|
233
|
+
const remainingEntries = arrayMatch[1]
|
|
234
|
+
.split(',')
|
|
235
|
+
.map((e) => e.trim())
|
|
236
|
+
.filter((e) => e && e !== exportName);
|
|
237
|
+
const withEntry = withoutImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${remainingEntries.join(', ')}]`);
|
|
238
|
+
writeFileSync(toolsIndexPath, withEntry);
|
|
239
|
+
}
|
|
203
240
|
// The spec is persisted alongside the generated .ts file, under the same
|
|
204
241
|
// name — this is what lets the Tools tab's Edit button repopulate the
|
|
205
242
|
// form later without reverse-parsing generated code back into a spec,
|