create-loopengine 0.1.13 → 0.1.14

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 CHANGED
@@ -51,6 +51,7 @@ agents/example-agent.ts Starter agent: persona, one tool, one permission rule
51
51
  adapters/http.ts HTTP API (streaming + non-streaming), routes by agent name
52
52
  adapters/cli.ts Command-line adapter
53
53
  .env.example ANTHROPIC_API_KEY / OPENAI_API_KEY / REDIS_URL
54
+ .create-loopengine.json Which version this project was scaffolded from — see "Upgrading" below
54
55
  ```
55
56
 
56
57
  The registry is multi-agent-capable from the start — seeded with one
@@ -84,6 +85,41 @@ working example. Not wired in here by default: it needs the `composio` CLI
84
85
  installed and an authenticated account (`composio link <toolkit>`) first,
85
86
  so it's a deliberate next step, not part of this starter agent.
86
87
 
88
+ ## Upgrading
89
+
90
+ `npm install loopengine@latest` alone only picks up library-side changes
91
+ — `adapters/http.ts`, `adapters/cli.ts`, and `agent-registry.ts` are
92
+ copied into your project once at scaffold time and never touched by npm
93
+ again, so template improvements (new routes, bug fixes) don't reach an
94
+ existing project on their own. `upgrade` closes that gap with a real
95
+ three-way merge — the same technique `git merge` uses — so your own
96
+ edits to those files survive:
97
+
98
+ ```bash
99
+ npx create-loopengine@latest upgrade
100
+ ```
101
+
102
+ Run from inside your project. It merges the current template against
103
+ whichever `create-loopengine` version your project was originally
104
+ scaffolded from (recorded in `.create-loopengine.json`, written
105
+ automatically at scaffold time — pass `--from <version>` if that file is
106
+ missing, e.g. for a project scaffolded before this command existed).
107
+ Each of the five template-owned files gets one of:
108
+
109
+ - **unchanged** — the template never touched this file between your
110
+ version and the latest; nothing to merge, nothing overwritten.
111
+ - **updated** — merged cleanly; your own edits and the template's own
112
+ changes didn't overlap.
113
+ - **conflict** — you edited the same lines the template changed. The
114
+ file is written with real `<<<<<<<`/`=======`/`>>>>>>>` markers, same
115
+ as any git merge conflict — resolve them by hand, then `npx tsc
116
+ --noEmit` to confirm it builds.
117
+
118
+ `upgrade` never touches `agents/`, `README.md`, `.env.example`, or
119
+ `package.json`'s own dependency versions — run `npm install
120
+ loopengine@latest` (and `actauth`/`skillgarden` if you use them directly)
121
+ separately to pick those up.
122
+
87
123
  ## Status
88
124
 
89
125
  Verified end to end against the real published packages: `npm create
package/dist/cli.d.ts CHANGED
@@ -6,4 +6,31 @@ export interface ScaffoldOptions {
6
6
  templateDir?: string;
7
7
  }
8
8
  export declare function scaffold(options: ScaffoldOptions): string;
9
+ export type UpgradeFileStatus = 'updated' | 'unchanged' | 'conflict' | 'skipped';
10
+ export interface UpgradeFileResult {
11
+ file: string;
12
+ status: UpgradeFileStatus;
13
+ }
14
+ export interface UpgradeResult {
15
+ fromVersion: string;
16
+ toVersion: string;
17
+ files: UpgradeFileResult[];
18
+ }
19
+ export interface UpgradeOptions {
20
+ /** Defaults to process.cwd(). */
21
+ projectDir?: string;
22
+ /** Overrides the version recorded in .create-loopengine.json — required
23
+ * if that file is missing (a project scaffolded before this command
24
+ * existed) or was removed. */
25
+ from?: string;
26
+ /** Test-only override for the "current" template — defaults to this
27
+ * CLI's own bundled template/, same as ScaffoldOptions.templateDir. */
28
+ currentTemplateDir?: string;
29
+ /** Resolves the historical template directory for a given
30
+ * create-loopengine version — defaults to fetchPublishedTemplateDir
31
+ * (a real `npm pack`). Test-only seam to avoid hitting the real
32
+ * registry for a fixture "old" template. */
33
+ resolveBaseTemplateDir?: (version: string) => string;
34
+ }
35
+ export declare function upgradeProject(options?: UpgradeOptions): UpgradeResult;
9
36
  export declare function main(argv?: string[]): void;
package/dist/cli.js CHANGED
@@ -1,11 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
3
5
  import { dirname, join } from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  // Template ships in `template/` next to `dist/`, not inside it — it's a
6
8
  // real, buildable project (its own package.json/tsconfig), not TypeScript
7
9
  // source belonging to this CLI.
8
10
  const DEFAULT_TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
11
+ // This CLI's own version — embedded in a scaffolded project's own
12
+ // .create-loopengine.json (see scaffold() below) as the future upgrade
13
+ // command's merge base, and used again here as the "to" version once
14
+ // upgradeProject() actually runs. Read at runtime rather than baked in
15
+ // at build time so a locally-linked/packed build always reports its own
16
+ // real package.json, not a stale constant.
17
+ function ownVersion() {
18
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
19
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
20
+ }
9
21
  export function scaffold(options) {
10
22
  const { name } = options;
11
23
  const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
@@ -25,13 +37,169 @@ export function scaffold(options) {
25
37
  const readmePath = join(destination, 'README.md');
26
38
  const readme = readFileSync(readmePath, 'utf8').replace('__PROJECT_NAME__', name);
27
39
  writeFileSync(readmePath, readme);
40
+ // Records which create-loopengine version this project's copied,
41
+ // hand-editable files (adapters/http.ts, agent-registry.ts, ...) last
42
+ // matched — the merge base `upgrade` needs to pull in template changes
43
+ // later without clobbering whatever's been hand-edited since. Tracked
44
+ // like package.json/lockfiles (committed, not gitignored): it's project
45
+ // metadata, not a build artifact.
46
+ writeFileSync(join(destination, '.create-loopengine.json'), JSON.stringify({ version: ownVersion() }, null, 2) + '\n');
28
47
  return destination;
29
48
  }
49
+ // ---- upgrade: pulls template improvements into an already-scaffolded
50
+ // project's own copied files (adapters/http.ts, adapters/cli.ts,
51
+ // agent-registry.ts, tsconfig.json, .gitignore) via a real three-way
52
+ // merge — the same technique `git merge`/`rebase` use, applied here
53
+ // because those files are meant to be hand-edited after scaffolding, not
54
+ // re-imported from a package the way core library code is. See
55
+ // scaffold()'s own .create-loopengine.json write above for the "base"
56
+ // version this merge starts from. ----
57
+ // templatePath is where the file lives inside a template/ directory
58
+ // (base or current); projectPath is where the *scaffolded* copy lives —
59
+ // only differs for _gitignore, which scaffold() renames to .gitignore
60
+ // (see its own comment for why the template can't ship it under that
61
+ // name directly). Deliberately narrow: agents/example-agent/** stops
62
+ // being "template" the instant someone builds a real agent on top of it,
63
+ // and package.json's own dependency versions are a separate, simpler
64
+ // `npm install loopengine@latest` left to the operator rather than
65
+ // merged here.
66
+ const UPGRADE_CANDIDATE_FILES = [
67
+ { templatePath: 'adapters/http.ts', projectPath: 'adapters/http.ts' },
68
+ { templatePath: 'adapters/cli.ts', projectPath: 'adapters/cli.ts' },
69
+ { templatePath: 'agent-registry.ts', projectPath: 'agent-registry.ts' },
70
+ { templatePath: 'tsconfig.json', projectPath: 'tsconfig.json' },
71
+ { templatePath: '_gitignore', projectPath: '.gitignore' },
72
+ ];
73
+ // npm keeps every published tarball forever, so the historical template
74
+ // for *any* previously-released version is always fetchable this way —
75
+ // no separate snapshot storage, no git-tag dependency. `npm pack`
76
+ // extracts to a `package/` directory inside whatever `--pack-destination`
77
+ // it's given, same as every other real-package verification already done
78
+ // this way elsewhere in this project's own test suite.
79
+ function fetchPublishedTemplateDir(version) {
80
+ const workDir = mkdtempSync(join(tmpdir(), 'create-loopengine-upgrade-base-'));
81
+ execFileSync('npm', ['pack', `create-loopengine@${version}`, '--pack-destination', workDir], { stdio: 'pipe' });
82
+ const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
83
+ if (!tarball) {
84
+ throw new Error(`Could not fetch create-loopengine@${version} from npm — check the version exists and you have network access.`);
85
+ }
86
+ // Absolute path, not the bare filename readdirSync returns — tar
87
+ // resolves a relative first argument against the calling process's own
88
+ // cwd (wherever `create-loopengine upgrade` was actually run from), not
89
+ // workDir, even with -C set (-C only affects where extraction lands).
90
+ // Confirmed live: this failed with a wrong-directory ENOENT.
91
+ execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
92
+ return join(workDir, 'package', 'template');
93
+ }
94
+ // git merge-file's own exit code *is* its conflict count (0 = clean),
95
+ // not a pass/fail signal — execFileSync throws on any non-zero exit, so
96
+ // a real conflict has to be told apart from git genuinely failing to
97
+ // run (not installed, path error: no `status`, an `ENOENT`-shaped error
98
+ // instead) by checking for a numeric `status` on the caught error.
99
+ // Operates on `minePath` in place (git merge-file's own contract) — always
100
+ // called with a disposable temp copy, never the real project file
101
+ // directly, so the caller decides whether/how to apply the result.
102
+ function threeWayMerge(minePath, basePath, theirsPath) {
103
+ let conflicted = false;
104
+ try {
105
+ execFileSync('git', ['merge-file', '--diff3', '-L', 'mine', '-L', 'base', '-L', 'latest', minePath, basePath, theirsPath], {
106
+ stdio: 'pipe',
107
+ });
108
+ }
109
+ catch (err) {
110
+ if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
111
+ conflicted = true;
112
+ }
113
+ else {
114
+ throw new Error(`git merge-file failed — is git installed and on PATH? (${err instanceof Error ? err.message : String(err)})`);
115
+ }
116
+ }
117
+ return { merged: readFileSync(minePath, 'utf8'), conflicted };
118
+ }
119
+ function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir) {
120
+ const projectFilePath = join(projectDir, candidate.projectPath);
121
+ const baseFilePath = join(baseTemplateDir, candidate.templatePath);
122
+ const theirsFilePath = join(currentTemplateDir, candidate.templatePath);
123
+ if (!existsSync(projectFilePath) || !existsSync(baseFilePath) || !existsSync(theirsFilePath)) {
124
+ return { file: candidate.projectPath, status: 'skipped' };
125
+ }
126
+ const base = readFileSync(baseFilePath, 'utf8');
127
+ const theirs = readFileSync(theirsFilePath, 'utf8');
128
+ if (base === theirs) {
129
+ // The template never touched this file between the recorded version
130
+ // and now — nothing to merge, and importantly nothing to overwrite,
131
+ // so a hand-edit here is never even diffed against, let alone at risk.
132
+ return { file: candidate.projectPath, status: 'unchanged' };
133
+ }
134
+ const mine = readFileSync(projectFilePath, 'utf8');
135
+ const scratchDir = mkdtempSync(join(tmpdir(), 'create-loopengine-merge-'));
136
+ const mineTmp = join(scratchDir, 'mine');
137
+ const baseTmp = join(scratchDir, 'base');
138
+ const theirsTmp = join(scratchDir, 'theirs');
139
+ writeFileSync(mineTmp, mine);
140
+ writeFileSync(baseTmp, base);
141
+ writeFileSync(theirsTmp, theirs);
142
+ const { merged, conflicted } = threeWayMerge(mineTmp, baseTmp, theirsTmp);
143
+ rmSync(scratchDir, { recursive: true, force: true });
144
+ writeFileSync(projectFilePath, merged);
145
+ return { file: candidate.projectPath, status: conflicted ? 'conflict' : 'updated' };
146
+ }
147
+ export function upgradeProject(options = {}) {
148
+ const projectDir = options.projectDir ?? process.cwd();
149
+ const currentTemplateDir = options.currentTemplateDir ?? DEFAULT_TEMPLATE_DIR;
150
+ const toVersion = ownVersion();
151
+ const provenancePath = join(projectDir, '.create-loopengine.json');
152
+ let fromVersion = options.from;
153
+ if (!fromVersion) {
154
+ if (!existsSync(provenancePath)) {
155
+ throw new Error(`No .create-loopengine.json in ${projectDir} — this project was scaffolded before \`upgrade\` existed, or the file was removed. Pass --from <version> to specify which create-loopengine version it was originally scaffolded from.`);
156
+ }
157
+ const provenance = JSON.parse(readFileSync(provenancePath, 'utf8'));
158
+ if (!provenance.version) {
159
+ throw new Error(`${provenancePath} is missing its "version" field — pass --from <version> instead.`);
160
+ }
161
+ fromVersion = provenance.version;
162
+ }
163
+ const resolveBase = options.resolveBaseTemplateDir ?? fetchPublishedTemplateDir;
164
+ const baseTemplateDir = resolveBase(fromVersion);
165
+ const files = UPGRADE_CANDIDATE_FILES.map((candidate) => upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir));
166
+ writeFileSync(provenancePath, JSON.stringify({ version: toVersion }, null, 2) + '\n');
167
+ return { fromVersion, toVersion, files };
168
+ }
169
+ function parseUpgradeArgs(argv) {
170
+ let from;
171
+ for (let i = 0; i < argv.length; i++) {
172
+ if (argv[i] === '--from')
173
+ from = argv[++i];
174
+ }
175
+ return { from };
176
+ }
177
+ function printUpgradeSummary(result) {
178
+ console.log(`Upgrading create-loopengine@${result.fromVersion} -> @${result.toVersion}`);
179
+ console.log();
180
+ for (const f of result.files) {
181
+ const label = f.status === 'updated' ? 'updated ' : f.status === 'conflict' ? 'conflict ' : f.status === 'unchanged' ? 'unchanged' : 'skipped ';
182
+ console.log(` ${label} ${f.file}`);
183
+ }
184
+ console.log();
185
+ const conflicted = result.files.filter((f) => f.status === 'conflict');
186
+ if (conflicted.length > 0) {
187
+ console.log(`${conflicted.length} file(s) have <<<<<<< conflict markers to resolve by hand before this will build.`);
188
+ }
189
+ else {
190
+ console.log('No conflicts. Run `npm install loopengine@latest` to pick up any library-side changes too.');
191
+ }
192
+ }
30
193
  function parseArgs(argv) {
31
194
  return { name: argv[0] ?? 'my-agents' };
32
195
  }
33
196
  export function main(argv = process.argv.slice(2)) {
34
197
  try {
198
+ if (argv[0] === 'upgrade') {
199
+ const { from } = parseUpgradeArgs(argv.slice(1));
200
+ printUpgradeSummary(upgradeProject({ from }));
201
+ return;
202
+ }
35
203
  const { name } = parseArgs(argv);
36
204
  const destination = scaffold({ name });
37
205
  console.log(`Created ${destination}/`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",