agentsw 0.9.2 → 0.10.1

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/menu.js CHANGED
@@ -9,8 +9,9 @@ import { cmdRemoveProvider, cmdRename } from "./provider-actions.js";
9
9
  import { listRemovableProviders } from "./remove.js";
10
10
  import { providerIdFromBaseUrl } from "./slug.js";
11
11
  import { targets } from "./targets/index.js";
12
+ import { ActionCancelled, out, setInMenu } from "./ui.js";
12
13
  function bye() {
13
- console.log(pc.dim(`\n${t("menu.bye")}`));
14
+ out(pc.dim(`\n${t("menu.bye")}`));
14
15
  process.exit(0);
15
16
  }
16
17
  const cancel = { onCancel: () => bye() };
@@ -34,14 +35,14 @@ async function chooseLanguage() {
34
35
  const store = loadStore();
35
36
  store.language = language;
36
37
  saveStore(store);
37
- console.log(pc.green(t("language.saved")));
38
+ out(pc.green(t("language.saved")));
38
39
  }
39
40
  /** select an existing provider; prints a hint and returns undefined when none configured */
40
41
  async function pickProvider(message) {
41
42
  const store = loadStore();
42
43
  const ids = Object.keys(store.providers);
43
44
  if (ids.length === 0) {
44
- console.log(pc.yellow(t("menu.noProvidersHint")));
45
+ out(pc.yellow(t("menu.noProvidersHint")));
45
46
  return undefined;
46
47
  }
47
48
  const { id } = await prompts({
@@ -49,15 +50,18 @@ async function pickProvider(message) {
49
50
  name: "id",
50
51
  message,
51
52
  hint: t("menu.selectInstructions"),
52
- choices: ids.map((pid) => {
53
- const p = store.providers[pid];
54
- return {
55
- title: `${pid} · ${p.protocol} · ${t("menu.defaultModel")} ${p.defaultModel}${store.active === pid ? ` (${t("menu.active")})` : ""}`,
56
- value: pid,
57
- };
58
- }),
53
+ choices: [
54
+ { title: t("menu.back"), value: "__back__" },
55
+ ...ids.map((pid) => {
56
+ const p = store.providers[pid];
57
+ return {
58
+ title: `${pid} · ${p.protocol} · ${t("menu.defaultModel")} ${p.defaultModel}${store.active === pid ? ` (${t("menu.active")})` : ""}`,
59
+ value: pid,
60
+ };
61
+ }),
62
+ ],
59
63
  }, cancel);
60
- if (!id)
64
+ if (!id || id === "__back__")
61
65
  return undefined;
62
66
  const p = store.providers[id];
63
67
  return { id, defaultModel: p.defaultModel, models: p.models.map((m) => m.id) };
@@ -151,7 +155,7 @@ async function removeFromMenu() {
151
155
  ? listRemovableProviders(app)
152
156
  : Object.values(loadStore().providers).map((provider) => ({ id: provider.id, name: provider.name }));
153
157
  if (!entries.length) {
154
- console.log(pc.dim(t("menu.noRemovable")));
158
+ out(pc.dim(t("menu.noRemovable")));
155
159
  return;
156
160
  }
157
161
  const { selected } = await prompts({
@@ -159,10 +163,13 @@ async function removeFromMenu() {
159
163
  name: "selected",
160
164
  message: t("menu.removeProvider"),
161
165
  hint: t("menu.selectInstructions"),
162
- choices: entries.map((entry, index) => ({
163
- title: `${entry.id} · ${entry.app ?? "agentsw"}${entry.name && entry.name !== entry.id ? ` · ${entry.name}` : ""}`,
164
- value: index,
165
- })),
166
+ choices: [
167
+ { title: t("menu.back"), value: "__back__" },
168
+ ...entries.map((entry, index) => ({
169
+ title: `${entry.id} · ${entry.app ?? "agentsw"}${entry.name && entry.name !== entry.id ? ` · ${entry.name}` : ""}`,
170
+ value: index,
171
+ })),
172
+ ],
166
173
  }, cancel);
167
174
  const picked = entries[selected];
168
175
  if (!picked)
@@ -179,16 +186,19 @@ async function removeFromMenu() {
179
186
  }
180
187
  }
181
188
  export async function cmdMenu() {
182
- if (!loadStore().language)
189
+ let store = loadStore();
190
+ if (!store.language) {
183
191
  await chooseLanguage();
184
- console.log(pc.bold("agentsw") + pc.dim(t("menu.title")));
185
- if (Object.keys(loadStore().providers).length === 0) {
186
- console.log(pc.yellow(t("menu.noProviders")));
192
+ store = loadStore(); // chooseLanguage persists the preference, so re-read before trusting the rest
193
+ }
194
+ out(pc.bold("agentsw") + pc.dim(t("menu.title")));
195
+ if (Object.keys(store.providers).length === 0) {
196
+ out(pc.yellow(t("menu.noProviders")));
187
197
  if (await askToggle(t("menu.firstScan"), true))
188
198
  await cmdImport({});
189
199
  }
190
200
  for (;;) {
191
- console.log("");
201
+ out("");
192
202
  const { action } = await prompts({
193
203
  type: "select",
194
204
  name: "action",
@@ -214,114 +224,124 @@ export async function cmdMenu() {
214
224
  }, cancel);
215
225
  if (action === undefined || action === "quit")
216
226
  return;
217
- if (action === "quickAdd") {
218
- await cmdQuickAdd({});
219
- }
220
- else if (action === "add") {
221
- const { src } = await prompts({
222
- type: "select",
223
- name: "src",
224
- message: t("menu.modelSource"),
225
- hint: t("menu.selectInstructions"),
226
- choices: [
227
- { title: t("menu.modelDiscover"), value: "discover" },
228
- { title: t("menu.modelManual"), value: "manual" },
229
- ],
230
- }, cancel);
231
- await cmdAdd({ discover: src === "discover" });
232
- }
233
- else if (action === "import") {
234
- await cmdImport({});
235
- }
236
- else if (action === "use") {
237
- const picked = await pickProvider(t("menu.pickProvider"));
238
- if (!picked)
239
- continue;
240
- const apps = await pickApps(t("menu.pickApps"));
241
- const { model } = await prompts({
242
- type: picked.models.length > 1 ? "select" : null,
243
- name: "model",
244
- message: t("menu.defaultModel"),
245
- hint: t("menu.selectInstructions"),
246
- choices: [
247
- { title: t("menu.keepDefault", { model: picked.defaultModel }), value: "" },
248
- ...picked.models.filter((m) => m !== picked.defaultModel).map((m) => ({ title: m, value: m })),
249
- ],
250
- }, cancel);
251
- await cmdUse(picked.id, { model: model || undefined, apps });
252
- }
253
- else if (action === "status") {
254
- cmdStatus();
255
- }
256
- else if (action === "list") {
257
- cmdList();
258
- }
259
- else if (action === "sync") {
260
- await cmdSync({ apps: await pickApps(t("menu.pickApps")) });
261
- }
262
- else if (action === "discover") {
263
- const picked = await pickProvider(t("menu.discoverFor"));
264
- if (!picked)
265
- continue;
266
- const sync = await askToggle(t("menu.pushRefresh"));
267
- await cmdDiscover(picked.id, { sync, apps: sync ? await pickApps(t("menu.pickApps")) : undefined });
268
- }
269
- else if (action === "metadata") {
270
- const picked = await pickProvider(t("menu.metadataProvider"));
271
- if (!picked)
272
- continue;
273
- const current = loadStore().providers[picked.id];
274
- const { metadataMode } = await prompts({
275
- type: "select",
276
- name: "metadataMode",
277
- message: t("menu.metadataMode"),
278
- hint: t("menu.selectInstructions"),
279
- initial: ["auto", "on", "off"].indexOf(getMetadataMode(current)),
280
- choices: [
281
- { title: t("menu.metadataAuto"), description: t("menu.metadataAutoHelp"), value: "auto" },
282
- { title: t("menu.metadataOn"), description: t("menu.metadataOnHelp"), value: "on" },
283
- { title: t("menu.metadataOff"), description: t("menu.metadataOffHelp"), value: "off" },
284
- ],
285
- }, cancel);
286
- await cmdRefreshMeta({ provider: picked.id, metadataMode });
287
- }
288
- else if (action === "rename" || action === "remove") {
289
- try {
227
+ // One guard for every action: a cancelled prompt unwinds to here, and any other failure reports itself
228
+ // and returns to the menu instead of ending the session.
229
+ setInMenu(true);
230
+ try {
231
+ if (action === "quickAdd") {
232
+ await cmdQuickAdd({});
233
+ }
234
+ else if (action === "add") {
235
+ const { src } = await prompts({
236
+ type: "select",
237
+ name: "src",
238
+ message: t("menu.modelSource"),
239
+ hint: t("menu.selectInstructions"),
240
+ choices: [
241
+ { title: t("menu.modelDiscover"), value: "discover" },
242
+ { title: t("menu.modelManual"), value: "manual" },
243
+ ],
244
+ }, cancel);
245
+ await cmdAdd({ discover: src === "discover" });
246
+ }
247
+ else if (action === "import") {
248
+ await cmdImport({});
249
+ }
250
+ else if (action === "use") {
251
+ const picked = await pickProvider(t("menu.pickProvider"));
252
+ if (!picked)
253
+ continue;
254
+ const apps = await pickApps(t("menu.pickApps"));
255
+ const { model } = await prompts({
256
+ type: picked.models.length > 1 ? "select" : null,
257
+ name: "model",
258
+ message: t("menu.defaultModel"),
259
+ hint: t("menu.selectInstructions"),
260
+ choices: [
261
+ { title: t("menu.keepDefault", { model: picked.defaultModel }), value: "" },
262
+ ...picked.models.filter((m) => m !== picked.defaultModel).map((m) => ({ title: m, value: m })),
263
+ ],
264
+ }, cancel);
265
+ await cmdUse(picked.id, { model: model || undefined, apps });
266
+ }
267
+ else if (action === "status") {
268
+ cmdStatus();
269
+ }
270
+ else if (action === "list") {
271
+ cmdList();
272
+ }
273
+ else if (action === "sync") {
274
+ await cmdSync({ apps: await pickApps(t("menu.pickApps")) });
275
+ }
276
+ else if (action === "discover") {
277
+ const picked = await pickProvider(t("menu.discoverFor"));
278
+ if (!picked)
279
+ continue;
280
+ const sync = await askToggle(t("menu.pushRefresh"));
281
+ await cmdDiscover(picked.id, { sync, apps: sync ? await pickApps(t("menu.pickApps")) : undefined });
282
+ }
283
+ else if (action === "metadata") {
284
+ const picked = await pickProvider(t("menu.metadataProvider"));
285
+ if (!picked)
286
+ continue;
287
+ const current = loadStore().providers[picked.id];
288
+ const { metadataMode } = await prompts({
289
+ type: "select",
290
+ name: "metadataMode",
291
+ message: t("menu.metadataMode"),
292
+ hint: t("menu.selectInstructions"),
293
+ initial: ["auto", "on", "off"].indexOf(getMetadataMode(current)),
294
+ choices: [
295
+ { title: t("menu.metadataAuto"), description: t("menu.metadataAutoHelp"), value: "auto" },
296
+ { title: t("menu.metadataOn"), description: t("menu.metadataOnHelp"), value: "on" },
297
+ { title: t("menu.metadataOff"), description: t("menu.metadataOffHelp"), value: "off" },
298
+ ],
299
+ }, cancel);
300
+ await cmdRefreshMeta({ provider: picked.id, metadataMode });
301
+ }
302
+ else if (action === "rename" || action === "remove") {
290
303
  if (action === "rename")
291
304
  await renameFromMenu();
292
305
  else
293
306
  await removeFromMenu();
294
307
  }
295
- catch (error) {
296
- console.error(pc.red(error instanceof Error ? error.message : String(error)));
308
+ else if (action === "apps") {
309
+ await cmdApps();
310
+ if (await askToggle(t("menu.upgrade")))
311
+ await cmdUpgrade([]);
297
312
  }
298
- }
299
- else if (action === "apps") {
300
- await cmdApps();
301
- if (await askToggle(t("menu.upgrade")))
302
- await cmdUpgrade([]);
303
- }
304
- else if (action === "install") {
305
- const installable = appPackages
306
- .filter((a) => !installedVersion(a) && appCommand(a, "install"))
307
- .map((a) => ({ title: `${a.id} · ${a.name}`, value: a.id }));
308
- if (installable.length === 0) {
309
- console.log(pc.dim(t("menu.allInstalled")));
310
- continue;
313
+ else if (action === "install") {
314
+ const installable = appPackages
315
+ .filter((a) => !installedVersion(a) && appCommand(a, "install"))
316
+ .map((a) => ({ title: `${a.id} · ${a.name}`, value: a.id }));
317
+ if (installable.length === 0) {
318
+ out(pc.dim(t("menu.allInstalled")));
319
+ continue;
320
+ }
321
+ const { appId } = await prompts({
322
+ type: "select",
323
+ name: "appId",
324
+ message: t("menu.pickApp"),
325
+ hint: t("menu.selectInstructions"),
326
+ choices: installable,
327
+ }, cancel);
328
+ if (!appId)
329
+ continue;
330
+ await cmdInstall(appId);
311
331
  }
312
- const { appId } = await prompts({
313
- type: "select",
314
- name: "appId",
315
- message: t("menu.pickApp"),
316
- hint: t("menu.selectInstructions"),
317
- choices: installable,
318
- }, cancel);
319
- if (!appId)
320
- continue;
321
- await cmdInstall(appId);
332
+ else if (action === "language") {
333
+ await chooseLanguage();
334
+ }
335
+ }
336
+ catch (error) {
337
+ // Ctrl-C inside a sub-prompt means "abandon this action", not "quit the app".
338
+ if (error instanceof ActionCancelled)
339
+ out(pc.dim(t("menu.cancelledAction")));
340
+ else
341
+ console.error(pc.red(error instanceof Error ? error.message : String(error)));
322
342
  }
323
- else if (action === "language") {
324
- await chooseLanguage();
343
+ finally {
344
+ setInMenu(false);
325
345
  }
326
346
  }
327
347
  }
package/dist/metadata.js CHANGED
@@ -116,7 +116,10 @@ function indexCatalog(catalog) {
116
116
  for (const provider of Object.values(catalog)) {
117
117
  for (const [key, model] of Object.entries(provider.models)) {
118
118
  const modelId = model.id.includes("/") ? model.id : `${provider.id}/${model.id}`;
119
- const entry = { provider: provider.id, modelId, identity: model.canonical_model_id ?? modelId, model };
119
+ const entry = {
120
+ provider: provider.id, modelId, identity: model.canonical_model_id ?? modelId,
121
+ claimed: model.canonical_model_id !== undefined, model,
122
+ };
120
123
  for (const id of new Set([key, model.id]))
121
124
  push(exact, id, entry);
122
125
  push(canonical, modelId, entry);
@@ -125,10 +128,29 @@ function indexCatalog(catalog) {
125
128
  }
126
129
  return { exact, canonical, identities };
127
130
  }
131
+ /**
132
+ * The identities a group of catalog rows stands for.
133
+ *
134
+ * Only `canonical_model_id` is evidence. Counting the fallback guess of a row
135
+ * that declares nothing would let one sloppy reseller row invent a second
136
+ * creator and block an otherwise unique match — the id then reports as
137
+ * ambiguous even though every row that does carry the field names one model.
138
+ * A group where *nothing* declares an identity is a different situation: there
139
+ * is no primary evidence to weigh, so the listings' own ids are all the group
140
+ * can be held to, which is exactly the conservative answer. An empty group
141
+ * stays empty — that is "no evidence at all", not "one guess".
142
+ */
143
+ function claimIdentities(entries) {
144
+ const list = entries ?? [];
145
+ const claimed = new Set(list.filter((entry) => entry.claimed).map((entry) => entry.identity));
146
+ if (claimed.size)
147
+ return claimed;
148
+ return new Set(list.map((entry) => entry.identity));
149
+ }
128
150
  /** Creator identity of a catalog model id, or undefined when its rows disagree. */
129
151
  function catalogIdentity(index, modelId) {
130
- const identities = new Set(index?.canonical.get(modelId)?.map((entry) => entry.identity));
131
- return identities?.size === 1 ? [...identities][0] : undefined;
152
+ const identities = claimIdentities(index?.canonical.get(modelId));
153
+ return identities.size === 1 ? [...identities][0] : undefined;
132
154
  }
133
155
  /**
134
156
  * The creator's own listing for an identity, when models.dev carries one.
@@ -164,7 +186,7 @@ function selectExact(index, id, hint, aliases, gateway, gatewayBareIds) {
164
186
  const candidate = gatewayBareIds?.get(id);
165
187
  const evidence = index?.exact.get(id);
166
188
  const hintedEvidence = hint ? evidence?.filter((entry) => entry.provider === hint) : undefined;
167
- const identities = new Set((hintedEvidence?.length ? hintedEvidence : evidence)?.map((entry) => entry.identity));
189
+ const identities = claimIdentities(hintedEvidence?.length ? hintedEvidence : evidence);
168
190
  // Gateway names the creator while models.dev names the listing provider, so
169
191
  // the two agree only once both are reduced to a creator identity. A gateway
170
192
  // id the catalog has no row for stands for itself.
@@ -181,7 +203,7 @@ function selectExact(index, id, hint, aliases, gateway, gatewayBareIds) {
181
203
  : (bare && hint ? index?.canonical.get(`${hint}/${id}`) : undefined)
182
204
  ?? index?.canonical.get(id) ?? index?.exact.get(id);
183
205
  const hinted = candidates?.find((entry) => entry.provider === hint);
184
- const identities = new Set(candidates?.map((entry) => entry.identity));
206
+ const identities = claimIdentities(candidates);
185
207
  // Reseller rows for one model share an identity but disagree on limits and
186
208
  // prices, so only the creator's own row is authoritative.
187
209
  const primary = hinted ?? (identities.size === 1 ? creatorEntry(index, [...identities][0]) : undefined);
@@ -440,10 +462,13 @@ export function classifyUnresolved(catalog, ids, hint) {
440
462
  const candidates = hinted ?? index.canonical.get(id) ?? index.exact.get(id);
441
463
  if (!candidates?.length)
442
464
  unknown.push(id);
443
- else if (new Set(candidates.map((entry) => entry.identity)).size > 1)
444
- ambiguous.push(id);
445
- else if (!creatorEntry(index, [...new Set(candidates.map((entry) => entry.identity))][0]))
446
- noCreatorRow.push(id);
465
+ else {
466
+ const identities = claimIdentities(candidates);
467
+ if (identities.size > 1)
468
+ ambiguous.push(id);
469
+ else if (!creatorEntry(index, [...identities][0]))
470
+ noCreatorRow.push(id);
471
+ }
447
472
  }
448
473
  return { ambiguous, unknown, noCreatorRow };
449
474
  }
package/dist/modelsdev.js CHANGED
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import { configDir } from "./store.js";
4
4
  import { readJsonIfExists, writeFileAtomic } from "./fsutil.js";
5
+ import { warn } from "./ui.js";
5
6
  const API_URL = "https://models.dev/api.json";
6
7
  const CACHE_FILE = path.join(configDir, "models-dev.json");
7
8
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -10,29 +11,56 @@ const catalogFetchedAt = new WeakMap();
10
11
  export function getCatalogFetchedAt(catalog) {
11
12
  return catalogFetchedAt.get(catalog);
12
13
  }
14
+ /**
15
+ * models.dev maps provider ids to providers, each carrying a model dictionary.
16
+ * A 200 response that is not shaped like a catalog (an error document from a proxy
17
+ * or mirror, say) must never be cached: later runs would then re-read the broken
18
+ * file on every load, long after the network recovered.
19
+ */
20
+ function isCatalog(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value))
22
+ return false;
23
+ return Object.values(value).every((provider) => {
24
+ if (!provider || typeof provider !== "object" || Array.isArray(provider))
25
+ return false;
26
+ const models = provider.models;
27
+ return !!models && typeof models === "object" && !Array.isArray(models);
28
+ });
29
+ }
30
+ /** A cache file that is missing, unreadable or malformed is simply not a catalog. */
13
31
  function readCachedCatalog() {
14
- const catalog = readJsonIfExists(CACHE_FILE);
15
- if (catalog) {
16
- try {
17
- catalogFetchedAt.set(catalog, fs.statSync(CACHE_FILE).mtime.toISOString());
18
- }
19
- catch {
20
- // A concurrently removed cache still supplies useful metadata without a fetch time.
21
- }
32
+ let parsed;
33
+ try {
34
+ parsed = readJsonIfExists(CACHE_FILE);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ if (!isCatalog(parsed))
40
+ return undefined;
41
+ const catalog = parsed;
42
+ try {
43
+ catalogFetchedAt.set(catalog, fs.statSync(CACHE_FILE).mtime.toISOString());
44
+ }
45
+ catch {
46
+ // A concurrently removed cache still supplies useful metadata without a fetch time.
22
47
  }
23
48
  return catalog;
24
49
  }
50
+ function cacheIsFresh() {
51
+ try {
52
+ return Date.now() - fs.statSync(CACHE_FILE).mtimeMs < CACHE_TTL_MS;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
25
58
  export async function loadCatalog(opts = {}) {
26
59
  if (!opts.refresh) {
27
- try {
28
- const stat = fs.statSync(CACHE_FILE);
29
- const fresh = Date.now() - stat.mtimeMs < CACHE_TTL_MS;
30
- if (fresh || opts.offline)
31
- return readCachedCatalog();
32
- }
33
- catch {
34
- /* no cache */
35
- }
60
+ const cached = readCachedCatalog();
61
+ // A malformed cache falls through to a fetch rather than failing the command.
62
+ if (cached && (opts.offline || cacheIsFresh()))
63
+ return cached;
36
64
  }
37
65
  if (opts.offline)
38
66
  return readCachedCatalog();
@@ -41,17 +69,19 @@ export async function loadCatalog(opts = {}) {
41
69
  if (!res.ok)
42
70
  throw new Error(`models.dev responded ${res.status}`);
43
71
  const text = await res.text();
44
- const catalog = JSON.parse(text); // validate before caching
72
+ const parsed = JSON.parse(text);
73
+ if (!isCatalog(parsed))
74
+ throw new Error("models.dev returned an unexpected catalog shape");
45
75
  writeFileAtomic(CACHE_FILE, text);
46
- catalogFetchedAt.set(catalog, new Date().toISOString());
47
- return catalog;
76
+ catalogFetchedAt.set(parsed, new Date().toISOString());
77
+ return parsed;
48
78
  }
49
79
  catch (err) {
50
80
  // network failure: fall back to stale cache if present
51
81
  const cached = readCachedCatalog();
52
82
  if (cached)
53
83
  return cached;
54
- process.stderr.write(`warning: could not fetch models.dev catalog: ${err.message}\n`);
84
+ warn(`could not fetch models.dev catalog: ${err.message}`);
55
85
  return undefined;
56
86
  }
57
87
  }
@@ -0,0 +1,116 @@
1
+ import pc from "picocolors";
2
+ import { isJson, isQuiet, note } from "./ui.js";
3
+ const FRAMES = ["|", "/", "-", "\\"];
4
+ const CLEAR = "\r\u001b[2K";
5
+ /** `npm test` frequently runs in a real terminal, where stderr is still a TTY — never animate under it. */
6
+ function isTestRunner() {
7
+ return process.env.NODE_TEST_CONTEXT !== undefined || process.argv.includes("--test");
8
+ }
9
+ export function progressEnabled(options = {}) {
10
+ if (options.enabled !== undefined)
11
+ return options.enabled;
12
+ const stream = options.stream ?? process.stderr;
13
+ return stream.isTTY === true
14
+ && !isJson()
15
+ && !isQuiet()
16
+ && !isTestRunner()
17
+ && process.env.AGENTSW_NO_PROGRESS !== "1";
18
+ }
19
+ let live;
20
+ /**
21
+ * Progress feedback for slow awaits. Renders one animated stderr line on a TTY; everywhere else it degrades
22
+ * to the single dim line the CLI printed before, so pipes and CI logs keep exactly the same content.
23
+ */
24
+ export function startProgress(message, options = {}) {
25
+ // A nested caller borrows the live line: it may retitle it, but stopping must not tear down the owner.
26
+ if (live) {
27
+ const owner = live;
28
+ const previous = owner.setText(message);
29
+ let finished = false;
30
+ return {
31
+ update(next) { owner.setText(next); },
32
+ stop() {
33
+ if (finished)
34
+ return;
35
+ finished = true;
36
+ owner.setText(previous);
37
+ },
38
+ succeed() { },
39
+ fail() { },
40
+ };
41
+ }
42
+ const enabled = progressEnabled(options);
43
+ const stream = options.stream ?? process.stderr;
44
+ const intervalMs = options.intervalMs ?? 80;
45
+ const delayMs = options.delayMs ?? 150;
46
+ const frames = options.frames ?? FRAMES;
47
+ let text = message;
48
+ let frame = 0;
49
+ let timer;
50
+ let painted = false;
51
+ let stopped = false;
52
+ // Disabled path: reproduce the old static line exactly once. Callers no longer print it themselves.
53
+ if (!enabled)
54
+ note(message);
55
+ const clear = () => {
56
+ if (painted)
57
+ stream.write(CLEAR);
58
+ painted = false;
59
+ };
60
+ const paint = () => {
61
+ stream.write(`${CLEAR}${pc.dim(frames[frame % frames.length])} ${pc.dim(text)}`);
62
+ painted = true;
63
+ frame++;
64
+ };
65
+ const onInterrupt = () => {
66
+ clear();
67
+ process.exit(130);
68
+ };
69
+ const finish = () => {
70
+ if (stopped)
71
+ return;
72
+ stopped = true;
73
+ if (timer) {
74
+ clearTimeout(timer);
75
+ clearInterval(timer);
76
+ timer = undefined;
77
+ }
78
+ if (enabled)
79
+ process.removeListener("SIGINT", onInterrupt);
80
+ clear();
81
+ if (live?.handle === handle)
82
+ live = undefined;
83
+ };
84
+ const handle = {
85
+ update(next) { text = next; },
86
+ stop: finish,
87
+ succeed(message) { finish(); if (message)
88
+ note(message); },
89
+ fail(message) { finish(); if (message)
90
+ stream.write(pc.red(message) + "\n"); },
91
+ };
92
+ live = { handle, setText(next) { const previous = text; text = next; return previous; } };
93
+ if (enabled) {
94
+ process.once("SIGINT", onInterrupt);
95
+ // Nothing is painted until the delay elapses, so cached and local work never flickers.
96
+ timer = setTimeout(() => {
97
+ if (stopped)
98
+ return;
99
+ paint();
100
+ timer = setInterval(() => { if (!stopped)
101
+ paint(); }, intervalMs);
102
+ timer.unref();
103
+ }, delayMs);
104
+ timer.unref();
105
+ }
106
+ return handle;
107
+ }
108
+ export async function withProgress(message, fn, options) {
109
+ const handle = startProgress(message, options);
110
+ try {
111
+ return await fn(handle);
112
+ }
113
+ finally {
114
+ handle.stop();
115
+ }
116
+ }