create-loopengine 0.1.60 → 0.1.61

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/dist/cli.d.ts CHANGED
@@ -6,7 +6,10 @@ 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';
9
+ /** `kept`: the project edited a file the template also changed, and
10
+ * `keepEdited` was set, so it was left exactly as it was (no merge, no
11
+ * conflict markers). */
12
+ export type UpgradeFileStatus = 'updated' | 'unchanged' | 'conflict' | 'skipped' | 'kept';
10
13
  export interface UpgradeFileResult {
11
14
  file: string;
12
15
  status: UpgradeFileStatus;
@@ -57,6 +60,11 @@ export interface UpgradeOptions {
57
60
  /** Runs `npm install` in projectDir — defaults to a real one. Test-only
58
61
  * seam to avoid actually installing anything during tests. */
59
62
  runNpmInstall?: (projectDir: string) => void;
63
+ /** Leave a file the project edited untouched (status `kept`) instead of
64
+ * three-way merging it, so nothing ever gets conflict markers. For
65
+ * unattended upgrades (the console's Upgrade button): a half-merged
66
+ * adapters/http.ts would stop the server from starting at all. */
67
+ keepEdited?: boolean;
60
68
  }
61
69
  export declare function upgradeProject(options?: UpgradeOptions): UpgradeResult;
62
70
  export declare function main(argv?: string[]): void;
package/dist/cli.js CHANGED
@@ -116,7 +116,7 @@ function threeWayMerge(minePath, basePath, theirsPath) {
116
116
  }
117
117
  return { merged: readFileSync(minePath, 'utf8'), conflicted };
118
118
  }
119
- function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir) {
119
+ function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir, keepEdited = false) {
120
120
  const projectFilePath = join(projectDir, candidate.projectPath);
121
121
  const baseFilePath = join(baseTemplateDir, candidate.templatePath);
122
122
  const theirsFilePath = join(currentTemplateDir, candidate.templatePath);
@@ -132,6 +132,17 @@ function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir)
132
132
  return { file: candidate.projectPath, status: 'unchanged' };
133
133
  }
134
134
  const mine = readFileSync(projectFilePath, 'utf8');
135
+ // The common cases need no merge (and so no git): a file the project
136
+ // never edited takes the new template as is, and one that already
137
+ // matches it has nothing to do.
138
+ if (mine === theirs)
139
+ return { file: candidate.projectPath, status: 'unchanged' };
140
+ if (mine === base) {
141
+ writeFileSync(projectFilePath, theirs);
142
+ return { file: candidate.projectPath, status: 'updated' };
143
+ }
144
+ if (keepEdited)
145
+ return { file: candidate.projectPath, status: 'kept' };
135
146
  const scratchDir = mkdtempSync(join(tmpdir(), 'create-loopengine-merge-'));
136
147
  const mineTmp = join(scratchDir, 'mine');
137
148
  const baseTmp = join(scratchDir, 'base');
@@ -162,7 +173,7 @@ export function upgradeProject(options = {}) {
162
173
  }
163
174
  const resolveBase = options.resolveBaseTemplateDir ?? fetchPublishedTemplateDir;
164
175
  const baseTemplateDir = resolveBase(fromVersion);
165
- const files = UPGRADE_CANDIDATE_FILES.map((candidate) => upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir));
176
+ const files = UPGRADE_CANDIDATE_FILES.map((candidate) => upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir, options.keepEdited));
166
177
  const resolveLatestVersion = options.resolveLatestVersion ?? resolveLatestVersionFromNpm;
167
178
  const dependencies = bumpDependencies(projectDir, resolveLatestVersion);
168
179
  let npmInstall;
@@ -236,17 +247,23 @@ function bumpDependencies(projectDir, resolveLatestVersion) {
236
247
  }
237
248
  function parseUpgradeArgs(argv) {
238
249
  let from;
250
+ let json = false;
251
+ let keepEdited = false;
239
252
  for (let i = 0; i < argv.length; i++) {
240
253
  if (argv[i] === '--from')
241
254
  from = argv[++i];
255
+ else if (argv[i] === '--json')
256
+ json = true;
257
+ else if (argv[i] === '--keep-edited')
258
+ keepEdited = true;
242
259
  }
243
- return { from };
260
+ return { from, json, keepEdited };
244
261
  }
245
262
  function printUpgradeSummary(result) {
246
263
  console.log(`Upgrading create-loopengine@${result.fromVersion} -> @${result.toVersion}`);
247
264
  console.log();
248
265
  for (const f of result.files) {
249
- const label = f.status === 'updated' ? 'updated ' : f.status === 'conflict' ? 'conflict ' : f.status === 'unchanged' ? 'unchanged' : 'skipped ';
266
+ const label = f.status === 'updated' ? 'updated ' : f.status === 'conflict' ? 'conflict ' : f.status === 'unchanged' ? 'unchanged' : f.status === 'kept' ? 'kept ' : 'skipped ';
250
267
  console.log(` ${label} ${f.file}`);
251
268
  }
252
269
  console.log();
@@ -275,8 +292,20 @@ function parseArgs(argv) {
275
292
  export function main(argv = process.argv.slice(2)) {
276
293
  try {
277
294
  if (argv[0] === 'upgrade') {
278
- const { from } = parseUpgradeArgs(argv.slice(1));
279
- printUpgradeSummary(upgradeProject({ from }));
295
+ const { from, json, keepEdited } = parseUpgradeArgs(argv.slice(1));
296
+ if (json) {
297
+ // One JSON object on stdout, for a program driving the upgrade
298
+ // (loopengine's console); errors become { error } with exit 1.
299
+ try {
300
+ console.log(JSON.stringify(upgradeProject({ from, keepEdited })));
301
+ }
302
+ catch (err) {
303
+ console.log(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
304
+ process.exitCode = 1;
305
+ }
306
+ return;
307
+ }
308
+ printUpgradeSummary(upgradeProject({ from, keepEdited }));
280
309
  return;
281
310
  }
282
311
  const { name } = parseArgs(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
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",
@@ -6,3 +6,5 @@ dist
6
6
  .env
7
7
  *.log
8
8
  .DS_Store
9
+ # Backups the console Upgrade button keeps of the files it changed.
10
+ .upgrade-backups
@@ -154,6 +154,9 @@ import {
154
154
  fetchLatestAbilityVersion,
155
155
  describeModelProviders,
156
156
  loopengineVersion,
157
+ describeUpgrade,
158
+ runUpgrade,
159
+ UpgradeError,
157
160
  setModelProviderKey,
158
161
  clearModelProviderKey,
159
162
  ModelKeyError,
@@ -3008,6 +3011,29 @@ const server = createServer(async (req, res) => {
3008
3011
  )
3009
3012
  return
3010
3013
  }
3014
+ // The Settings section's Upgrade button (web/upgrade-admin.ts).
3015
+ if (req.method === 'GET' && pathname === '/config/upgrade') {
3016
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(
3017
+ JSON.stringify({ ...(await describeUpgrade()), canUpgrade: secretsProtected, restartsItself: exitOnCodeChange }),
3018
+ )
3019
+ return
3020
+ }
3021
+ if (req.method === 'POST' && pathname === '/config/upgrade') {
3022
+ if (!secretsProtected) {
3023
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Refusing to upgrade without LOOPENGINE_ADMIN_AUTH configured.' }))
3024
+ return
3025
+ }
3026
+ try {
3027
+ const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running > 0 })
3028
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, outcome, restarting: exitOnCodeChange }))
3029
+ } catch (err) {
3030
+ const status = err instanceof UpgradeError ? 409 : 500
3031
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
3032
+ return
3033
+ }
3034
+ exitIfCodeChanged(res)
3035
+ return
3036
+ }
3011
3037
  if (req.method === 'GET' && pathname === '/config/models') {
3012
3038
  res.writeHead(200, { 'content-type': 'application/json' }).end(
3013
3039
  JSON.stringify({ ...describeModelProviders(listAgents(), (name) => getEntry(name)?.config), canSave: secretsProtected }),
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.75",
12
+ "loopengine": "^0.1.76",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {