agentsw 0.7.0 → 0.7.2

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/commands.js CHANGED
@@ -1,3 +1,8 @@
1
+ import path from "node:path";
2
+ import { parse as parseJsonc } from "jsonc-parser";
3
+ import { parse as parseToml } from "smol-toml";
4
+ import { parseDocument } from "yaml";
5
+ import { envAssignments } from "./envfile.js";
1
6
  import pc from "picocolors";
2
7
  import prompts from "prompts";
3
8
  import { loadStore, saveStore, getProvider, configFile } from "./store.js";
@@ -5,7 +10,7 @@ import { scanCandidates, normalizeUrl, findMatchingProvider } from "./import.js"
5
10
  import { enrichModels, loadCatalog, searchCatalog } from "./modelsdev.js";
6
11
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
7
12
  import { discoverProviderModels, probeProtocols } from "./discover.js";
8
- import { appCommand, appPackages, installedVersion, isNewer, latestVersion, runShell } from "./apps.js";
13
+ import { appCommand, appPackages, installedVersion, isNewer, latestVersion, normalizeAppVersion, runShell } from "./apps.js";
9
14
  import { drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
10
15
  import { applyModelFilter } from "./filter.js";
11
16
  import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
@@ -89,14 +94,15 @@ function guessProviderHint(catalog, baseUrl) {
89
94
  }
90
95
  return undefined;
91
96
  }
92
- function parseFilterOpts(opts) {
93
- const split = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
94
- const include = split(opts.include);
95
- const exclude = split(opts.exclude);
96
- // dedup (dropping snapshot duplicates) is on by default; persist only the opt-out
97
- if (!include?.length && !exclude?.length && opts.dedup !== false)
98
- return undefined;
99
- return { include, exclude, ...(opts.dedup === false ? { dedup: false } : {}) };
97
+ function parseFilterOpts(opts, previous) {
98
+ if (opts.include === undefined && opts.exclude === undefined && opts.dedup === undefined)
99
+ return previous;
100
+ return {
101
+ ...previous,
102
+ ...(opts.include !== undefined ? { include: opts.include.split(",").map((x) => x.trim()).filter(Boolean) } : {}),
103
+ ...(opts.exclude !== undefined ? { exclude: opts.exclude.split(",").map((x) => x.trim()).filter(Boolean) } : {}),
104
+ ...(opts.dedup !== undefined ? { dedup: opts.dedup } : {}),
105
+ };
100
106
  }
101
107
  function reportDropped(dropped) {
102
108
  if (!dropped.length)
@@ -179,13 +185,16 @@ export async function cmdAdd(opts) {
179
185
  if (protocol !== "openai" && protocol !== "anthropic")
180
186
  fail(t("add.protocolInvalid"));
181
187
  const wire = answers.openaiApi ?? opts.openaiApi;
182
- if (wire && wire !== "completions" && wire !== "responses")
188
+ if (wire !== undefined && wire !== "completions" && wire !== "responses")
183
189
  fail(t("add.openaiApiInvalid"));
184
- const openaiApi = protocol === "openai" ? wire : undefined;
185
190
  const baseUrl = normalizeUrl(answers.baseUrl);
191
+ const explicitId = answers.id || undefined;
192
+ const existing = explicitId ? store.providers[explicitId] : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
193
+ const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
194
+ const openaiApi = protocol === "openai" ? wire ?? existing?.openaiApi : undefined;
186
195
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
187
196
  let modelIds = manualIds;
188
- const modelFilter = parseFilterOpts(opts);
197
+ const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
189
198
  const shouldDiscover = opts.discover || manualIds.length === 0;
190
199
  if (shouldDiscover) {
191
200
  if (!opts.discover) {
@@ -196,35 +205,35 @@ export async function cmdAdd(opts) {
196
205
  console.log(t("add.providerLists", { count: discovered.length }));
197
206
  modelIds = [...new Set([...manualIds, ...discovered])];
198
207
  }
199
- const pinned = [...manualIds, ...(opts.defaultModel ? [opts.defaultModel] : [])];
208
+ const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
209
+ modelIds = [...new Set([...modelIds, ...retained])];
210
+ const pinned = [...manualIds, ...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
200
211
  const outcome = applyModelFilter(modelIds, modelFilter, pinned);
201
212
  reportDropped(outcome.dropped);
202
213
  modelIds = outcome.kept;
203
214
  if (modelIds.length === 0)
204
215
  fail(t("add.atLeastOne"));
205
216
  const catalog = await loadCatalog();
206
- const hint = opts.modelsDev ?? guessProviderHint(catalog, answers.baseUrl);
217
+ const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
207
218
  const models = enrichModels(catalog, modelIds, hint);
208
219
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
209
- const defaultModel = opts.defaultModel ?? modelIds[0];
220
+ const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
210
221
  if (!modelIds.includes(defaultModel))
211
222
  fail(t("add.defaultMissing", { model: defaultModel }));
212
223
  if (opts.smallModel && !modelIds.includes(opts.smallModel))
213
224
  fail(t("add.smallMissing", { model: opts.smallModel }));
214
- const explicitId = answers.id || undefined;
215
- const existing = explicitId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
216
- const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
217
225
  const provider = {
226
+ ...existing,
218
227
  id,
219
228
  name: answers.name || existing?.name || (explicitId ? explicitId : providerNameFromBaseUrl(baseUrl, protocol)),
220
229
  protocol,
221
- ...(openaiApi ? { openaiApi } : {}),
230
+ openaiApi,
222
231
  baseUrl,
223
232
  apiKey: answers.apiKey,
224
233
  models,
225
234
  defaultModel,
226
- smallModel: opts.smallModel,
227
- reasoningEffort: opts.reasoningEffort,
235
+ smallModel: opts.smallModel ?? existing?.smallModel,
236
+ reasoningEffort: opts.reasoningEffort ?? existing?.reasoningEffort,
228
237
  modelsDevId: hint,
229
238
  modelFilter,
230
239
  };
@@ -244,23 +253,27 @@ export async function cmdAdd(opts) {
244
253
  async function createProvider(opts) {
245
254
  const { store, id, name, protocol, openaiApi, baseUrl, apiKey, modelIds, modelFilter } = opts;
246
255
  const catalog = await loadCatalog();
247
- const hint = guessProviderHint(catalog, baseUrl);
256
+ const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
248
257
  const models = enrichModels(catalog, modelIds, hint);
249
258
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
250
- const defaultModel = opts.defaultModel ?? modelIds[0];
259
+ const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
251
260
  if (!modelIds.includes(defaultModel))
252
261
  fail(t("add.defaultMissing", { model: defaultModel }));
262
+ const smallModel = opts.smallModel ?? opts.existing?.smallModel;
263
+ if (smallModel && !modelIds.includes(smallModel))
264
+ fail(t("add.smallMissing", { model: smallModel }));
253
265
  const provider = {
266
+ ...opts.existing,
254
267
  id,
255
268
  name,
256
269
  protocol,
257
- ...(openaiApi ? { openaiApi } : {}),
270
+ openaiApi: protocol === "openai" ? openaiApi ?? opts.existing?.openaiApi : undefined,
258
271
  baseUrl,
259
272
  apiKey,
260
273
  models,
261
274
  defaultModel,
262
- smallModel: opts.smallModel,
263
- reasoningEffort: opts.reasoningEffort,
275
+ smallModel,
276
+ reasoningEffort: opts.reasoningEffort ?? opts.existing?.reasoningEffort,
264
277
  modelsDevId: hint,
265
278
  modelFilter,
266
279
  };
@@ -306,19 +319,22 @@ export async function cmdQuickAdd(opts) {
306
319
  if (protocols.length === 0) {
307
320
  fail(t("quick.noProtocol"));
308
321
  }
309
- const modelFilter = parseFilterOpts(opts);
322
+ if (opts.openaiApi !== undefined && opts.openaiApi !== "completions" && opts.openaiApi !== "responses")
323
+ fail(t("add.openaiApiInvalid"));
310
324
  const multi = protocols.length > 1;
311
325
  const createdIds = [];
312
326
  for (const protocol of protocols) {
313
327
  const explicitId = opts.id === undefined ? undefined : `${opts.id}${multi ? `-${protocol}` : ""}`;
314
- const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : undefined;
328
+ const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : store.providers[explicitId];
315
329
  const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
316
- const name = existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
330
+ const name = opts.name ?? existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
331
+ const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
317
332
  process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })} [${protocol}]\n`));
318
333
  const discovered = await discoverProviderModels({ baseUrl, apiKey, protocol });
319
334
  console.log(t("add.providerLists", { count: discovered.length }));
320
- let modelIds = discovered;
321
- const pinned = [...(opts.defaultModel ? [opts.defaultModel] : [])];
335
+ const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
336
+ let modelIds = [...new Set([...discovered, ...retained])];
337
+ const pinned = [...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
322
338
  const outcome = applyModelFilter(modelIds, modelFilter, pinned);
323
339
  reportDropped(outcome.dropped);
324
340
  modelIds = outcome.kept;
@@ -338,6 +354,9 @@ export async function cmdQuickAdd(opts) {
338
354
  smallModel: opts.smallModel,
339
355
  reasoningEffort: opts.reasoningEffort,
340
356
  modelFilter,
357
+ existing,
358
+ openaiApi: opts.openaiApi,
359
+ modelsDev: opts.modelsDev,
341
360
  });
342
361
  createdIds.push(provider.id);
343
362
  console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
@@ -384,7 +403,7 @@ function reportResults(results) {
384
403
  console.log(` ${" ".repeat(9)} ${pc.dim(note)}`);
385
404
  }
386
405
  }
387
- async function runTargets(op, provider, appsFilter) {
406
+ async function runTargets(op, provider, appsFilter, redactErrors = false) {
388
407
  const selected = resolveTargets(appsFilter);
389
408
  const explicit = appsFilter !== undefined && appsFilter !== "all";
390
409
  const results = [];
@@ -398,48 +417,131 @@ async function runTargets(op, provider, appsFilter) {
398
417
  });
399
418
  continue;
400
419
  }
401
- if (!target.detect() && !explicit) {
402
- results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
403
- continue;
404
- }
405
420
  try {
421
+ if (!explicit && !target.detect()) {
422
+ results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
423
+ continue;
424
+ }
406
425
  results.push(await target[op](provider));
407
426
  }
408
427
  catch (err) {
409
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${err.message}`) });
428
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(redactErrors ? "failed: configuration could not be previewed safely" : `failed: ${err.message}`) });
410
429
  process.exitCode = 1;
411
430
  }
412
431
  }
413
432
  return results;
414
433
  }
415
- /** naive line diff for config previews: order-preserving added/removed lines */
416
- function printFileDiff(file, next) {
434
+ /** Parse first: an unknown format or malformed document must never fall back to raw secrets. */
435
+ function previewDocument(file, text) {
436
+ if (!text.trim())
437
+ return undefined;
438
+ if (path.basename(file) === ".env" || file.endsWith(".env")) {
439
+ return Object.fromEntries(envAssignments(file, text).map(({ name }) => [name, "[REDACTED]"]));
440
+ }
441
+ switch (path.extname(file).toLowerCase()) {
442
+ case ".json":
443
+ case ".jsonc": {
444
+ const errors = [];
445
+ const value = parseJsonc(text, errors, { allowTrailingComma: true });
446
+ if (errors.length)
447
+ throw new Error("invalid JSON configuration");
448
+ return value;
449
+ }
450
+ case ".toml": return parseToml(text);
451
+ case ".yaml":
452
+ case ".yml": {
453
+ const document = parseDocument(text);
454
+ if (document.errors.length)
455
+ throw new Error("invalid YAML configuration");
456
+ return document.toJS();
457
+ }
458
+ default: throw new Error("unsupported configuration format");
459
+ }
460
+ }
461
+ /** Normalized semantic diff: comments and unparseable raw input never enter output. */
462
+ function printFileDiff(file, next, apiKey) {
417
463
  const before = readTextIfExists(file) ?? "";
418
464
  if (before === next) {
419
465
  console.log(`${pc.dim("unchanged")} ${file}`);
420
466
  return;
421
467
  }
422
- console.log(pc.bold(`--- ${file}`));
423
- const oldLines = before.split("\n");
424
- const newLines = next.split("\n");
425
- const oldSeen = {};
426
- for (const l of oldLines)
427
- oldSeen[l] = (oldSeen[l] ?? 0) + 1;
428
- const newSeen = {};
429
- for (const l of newLines)
430
- newSeen[l] = (newSeen[l] ?? 0) + 1;
431
- for (const l of oldLines) {
432
- if ((newSeen[l] ?? 0) > 0)
433
- newSeen[l]--;
468
+ console.log(pc.bold(`--- ${file} (redacted configuration)`));
469
+ let oldText;
470
+ let newText;
471
+ try {
472
+ const oldDocument = previewDocument(file, before);
473
+ const newDocument = previewDocument(file, next);
474
+ const sensitive = /key|token|secret|password|auth|credential|cookie|headers/i;
475
+ const credentialFile = path.basename(file) === ".credentials.yaml";
476
+ const secrets = new Set(apiKey ? [apiKey] : []);
477
+ const visited = new WeakSet();
478
+ const collect = (value, hidden = false) => {
479
+ if (typeof value === "string" && hidden && value)
480
+ secrets.add(value);
481
+ if (!value || typeof value !== "object" || visited.has(value))
482
+ return;
483
+ visited.add(value);
484
+ for (const [key, child] of Object.entries(value))
485
+ collect(child, hidden || sensitive.test(key));
486
+ };
487
+ collect(oldDocument, credentialFile);
488
+ collect(newDocument, credentialFile);
489
+ const orderedSecrets = [...secrets].sort((a, b) => b.length - a.length);
490
+ const redact = (key, value) => {
491
+ if (sensitive.test(key))
492
+ return "[REDACTED]";
493
+ if ((key === "" || credentialFile) && typeof value === "string")
494
+ return "[REDACTED]";
495
+ if (typeof value !== "string")
496
+ return value;
497
+ let safe = value;
498
+ if (/^https?:\/\//i.test(safe)) {
499
+ const url = new URL(safe);
500
+ if (url.username)
501
+ url.username = "[REDACTED]";
502
+ if (url.password)
503
+ url.password = "[REDACTED]";
504
+ for (const name of new Set(url.searchParams.keys()))
505
+ if (sensitive.test(name))
506
+ url.searchParams.set(name, "[REDACTED]");
507
+ url.hash = "";
508
+ safe = url.toString();
509
+ }
510
+ for (const secret of orderedSecrets)
511
+ safe = safe.split(secret).join("[REDACTED]");
512
+ return safe.replace(/\b(?:Bearer|Basic)\s+[^\s"',;]+/gi, "[REDACTED]");
513
+ };
514
+ oldText = JSON.stringify(oldDocument, redact, 2) ?? "";
515
+ newText = JSON.stringify(newDocument, redact, 2) ?? "";
516
+ }
517
+ catch {
518
+ console.log(pc.dim("[content withheld: unsupported or malformed configuration]"));
519
+ return;
520
+ }
521
+ const oldLines = oldText.split("\n");
522
+ const newLines = newText.split("\n");
523
+ const oldSeen = new Map();
524
+ for (const line of oldLines)
525
+ oldSeen.set(line, (oldSeen.get(line) ?? 0) + 1);
526
+ const newSeen = new Map();
527
+ for (const line of newLines)
528
+ newSeen.set(line, (newSeen.get(line) ?? 0) + 1);
529
+ for (const line of oldLines) {
530
+ const count = newSeen.get(line) ?? 0;
531
+ if (count > 0)
532
+ newSeen.set(line, count - 1);
434
533
  else
435
- console.log(pc.red(`- ${l}`));
534
+ console.log(pc.red(`- ${line}`));
436
535
  }
437
- for (const l of newLines) {
438
- if ((oldSeen[l] ?? 0) > 0)
439
- oldSeen[l]--;
536
+ for (const line of newLines) {
537
+ const count = oldSeen.get(line) ?? 0;
538
+ if (count > 0)
539
+ oldSeen.set(line, count - 1);
440
540
  else
441
- console.log(pc.green(`+ ${l}`));
541
+ console.log(pc.green(`+ ${line}`));
442
542
  }
543
+ if (oldText === newText)
544
+ console.log(pc.dim("[only redacted values or formatting changed]"));
443
545
  }
444
546
  async function runWithOptionalDryRun(op, provider, apps, dryRun) {
445
547
  if (!dryRun) {
@@ -448,7 +550,7 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
448
550
  }
449
551
  setDryRun(true);
450
552
  try {
451
- const results = await runTargets(op, provider, apps);
553
+ const results = await runTargets(op, provider, apps, true);
452
554
  const writes = drainPendingWrites();
453
555
  for (const r of results) {
454
556
  if (r.skipped)
@@ -456,13 +558,14 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
456
558
  }
457
559
  console.log(pc.bold(`\ndry run — ${writes.length} file(s) would be written:\n`));
458
560
  for (const w of writes)
459
- printFileDiff(w.file, w.content);
561
+ printFileDiff(w.file, w.content, provider.apiKey);
460
562
  }
461
563
  finally {
462
564
  setDryRun(false);
463
565
  }
464
566
  }
465
567
  export async function cmdUse(id, opts) {
568
+ resolveTargets(opts.apps);
466
569
  const store = loadStore();
467
570
  const provider = getProvider(store, id);
468
571
  if (opts.model) {
@@ -479,6 +582,7 @@ export async function cmdUse(id, opts) {
479
582
  await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
480
583
  }
481
584
  export async function cmdSync(opts) {
585
+ resolveTargets(opts.apps);
482
586
  const store = loadStore();
483
587
  const id = opts.provider ?? store.active;
484
588
  if (!id)
@@ -544,10 +648,11 @@ export async function cmdRefreshMeta() {
544
648
  console.log(pc.dim("run `agentsw sync` to push updated metadata into app configs"));
545
649
  }
546
650
  export async function cmdDiscover(id, opts) {
651
+ resolveTargets(opts.apps);
547
652
  const store = loadStore();
548
653
  const provider = getProvider(store, id);
549
654
  // flags override and re-persist the filter; --no-filter clears it
550
- const flagFilter = parseFilterOpts(opts);
655
+ const flagFilter = parseFilterOpts(opts, provider.modelFilter);
551
656
  if (opts.filter === false)
552
657
  provider.modelFilter = undefined;
553
658
  else if (flagFilter)
@@ -582,18 +687,29 @@ export async function cmdDiscover(id, opts) {
582
687
  }
583
688
  async function collectAppRows() {
584
689
  return Promise.all(appPackages.map(async (app) => {
585
- const [installed, latest] = await Promise.all([
586
- Promise.resolve(installedVersion(app)),
690
+ const [installedResult, latestResult] = await Promise.allSettled([
691
+ Promise.resolve().then(() => installedVersion(app)),
587
692
  latestVersion(app),
588
693
  ]);
589
- const knownInstalled = !!installed && installed !== "?";
694
+ const installed = installedResult.status === "fulfilled" ? installedResult.value : "?";
695
+ const latest = latestResult.status === "fulfilled" ? latestResult.value : undefined;
696
+ const knownInstalled = normalizeAppVersion(installed);
697
+ const knownLatest = normalizeAppVersion(latest);
698
+ const checkFailed = installedResult.status === "rejected"
699
+ ? "installed version check failed"
700
+ : installed && !knownInstalled
701
+ ? "installed version unknown"
702
+ : installed && !knownLatest
703
+ ? "latest version unavailable"
704
+ : undefined;
590
705
  return {
591
706
  id: app.id,
592
707
  name: app.name,
593
708
  installed,
594
709
  latest,
595
- upgradable: knownInstalled && !!latest && isNewer(installed, latest),
710
+ upgradable: !!knownInstalled && !!knownLatest && isNewer(knownInstalled, knownLatest),
596
711
  installable: !installed && !!appCommand(app, "install"),
712
+ checkFailed,
597
713
  };
598
714
  }));
599
715
  }
@@ -610,7 +726,7 @@ export async function cmdApps() {
610
726
  ? r.installable
611
727
  ? pc.dim("installable")
612
728
  : pc.dim("-")
613
- : r.installed === "?" || !r.latest
729
+ : r.checkFailed || !normalizeAppVersion(r.latest)
614
730
  ? pc.dim("unknown")
615
731
  : pc.green("up to date"),
616
732
  ]), ["APP", "INSTALLED", "LATEST", "STATUS"]));
@@ -632,38 +748,69 @@ export async function cmdInstall(id) {
632
748
  }
633
749
  console.log(`installing ${app.name}: ${pc.dim(installCmd)}`);
634
750
  runShell(installCmd);
635
- console.log(pc.green(`${app.name} installed: ${installedVersion(app) ?? "version unknown"}`));
751
+ const detected = installedVersion(app);
752
+ if (!detected)
753
+ throw new Error(`${app.name}: installer completed but the app is still not detected; check the installation and PATH`);
754
+ const version = normalizeAppVersion(detected);
755
+ if (!version) {
756
+ console.log(pc.yellow(`${app.name}: installer completed; app detected but version unknown`));
757
+ process.exitCode = 1;
758
+ return;
759
+ }
760
+ console.log(pc.green(`${app.name} installed: ${version}`));
636
761
  }
637
762
  export async function cmdUpgrade(ids) {
638
763
  let selected = appPackages.filter((a) => ids.length === 0 || ids.includes(a.id));
639
764
  const unknown = ids.filter((id) => !appPackages.some((a) => a.id === id));
640
765
  if (unknown.length)
641
766
  fail(`unknown app(s): ${unknown.join(", ")}`);
767
+ const expectedVersions = new Map();
642
768
  if (ids.length === 0) {
643
769
  // no args: upgrade everything that is installed and outdated
644
770
  process.stderr.write(pc.dim("checking versions ...\n"));
645
771
  const rows = await collectAppRows();
646
- const upgradable = rows.filter((r) => r.upgradable).map((r) => r.id);
772
+ const managed = rows.filter((row) => !!appCommand(appPackages.find((app) => app.id === row.id), "upgrade"));
773
+ const failed = managed.filter((row) => row.checkFailed);
774
+ for (const row of failed)
775
+ console.log(pc.yellow(`${row.id}: ${row.checkFailed}; update status unknown`));
776
+ if (failed.length)
777
+ process.exitCode = 1;
778
+ const upgradable = managed.filter((row) => row.upgradable);
647
779
  if (upgradable.length === 0) {
648
- console.log("everything is up to date");
780
+ if (failed.length)
781
+ console.log("could not determine update status for every installed app");
782
+ else if (managed.some((row) => row.installed))
783
+ console.log("all checked apps are up to date");
784
+ else
785
+ console.log("no installed CLI-managed apps to upgrade");
649
786
  return;
650
787
  }
651
- selected = appPackages.filter((a) => upgradable.includes(a.id));
788
+ for (const row of upgradable)
789
+ expectedVersions.set(row.id, row.latest);
790
+ selected = appPackages.filter((app) => expectedVersions.has(app.id));
652
791
  }
653
792
  for (const app of selected) {
654
793
  const cmd = appCommand(app, "upgrade");
655
794
  if (!cmd) {
656
795
  console.log(`${pc.yellow("skip")} ${app.id}: not CLI-upgradable`);
796
+ process.exitCode = 1;
657
797
  continue;
658
798
  }
659
- if (!installedVersion(app)) {
660
- console.log(`${pc.yellow("skip")} ${app.id}: not installed (use \`agentsw install ${app.id}\`)`);
661
- continue;
662
- }
663
- console.log(`upgrading ${app.name}: ${pc.dim(cmd)}`);
664
799
  try {
800
+ if (!installedVersion(app)) {
801
+ console.log(`${pc.yellow("skip")} ${app.id}: not installed (use \`agentsw install ${app.id}\`)`);
802
+ process.exitCode = 1;
803
+ continue;
804
+ }
805
+ console.log(`upgrading ${app.name}: ${pc.dim(cmd)}`);
665
806
  runShell(cmd);
666
- console.log(pc.green(`${app.id} -> ${installedVersion(app) ?? "?"}`));
807
+ const version = normalizeAppVersion(installedVersion(app));
808
+ if (!version)
809
+ throw new Error("upgrade command completed but installed version is unknown or app is not detected");
810
+ const expected = expectedVersions.get(app.id);
811
+ if (expected && isNewer(version, expected))
812
+ throw new Error(`upgrade command completed but ${version} is older than available ${expected}`);
813
+ console.log(pc.green(`${app.id} -> ${version}`));
667
814
  }
668
815
  catch (err) {
669
816
  console.log(pc.red(`${app.id} upgrade failed: ${err.message}`));