@metrio-ai/cli 1.1.0 → 1.3.0

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
@@ -48,3 +48,49 @@ For CI set `METRIO_TOKEN` (and `METRIO_CONSOLE_URL` if not `https://app.metrio.a
48
48
  Exit codes: `0` ok, `1` failure, `2` usage or validation error, `3` not logged in / not allowed. Codes 2 and 3 apply to the account and resource-management commands (`login`, `logout`, `whoami`, `tools`, `call`, and every `metrio <group> ...` command); the run commands (`chat`, `workflow run` / `run-batch`, `prompt run-batch` / `create-version`) keep their original behavior (errors exit 1). A workflow DAG the console rejects exits 2; a partially failed `version create-batch` exits 1.
49
49
 
50
50
  Migrating from `@metrio-ai/client`'s bundled CLI: if the old CLI was installed globally, run `npm rm -g @metrio-ai/client` before `npm i -g @metrio-ai/cli` (both install a `metrio` bin). Every command and flag is unchanged. Bare `metrio --messages …` still works but is deprecated in favor of `metrio chat --messages …`.
51
+
52
+ ## Sync a project as files
53
+
54
+ ```bash
55
+ mkdir my-project && cd my-project
56
+ metrio pull --project my-project # metrio.yaml, prompts/, skills/, workflows/, .metrio/state.json
57
+ $EDITOR prompts/pick-products/system.md
58
+ metrio diff # modified / remote-changed / conflict / new … with unified diffs
59
+ metrio push -m "tighter wording" # new prompt version (not live)
60
+ metrio push --activate # …or make it live right away
61
+ metrio version list pick-products # prompt directories work wherever a prompt id is expected
62
+ ```
63
+
64
+ `push` creates new versions and never overwrites changes made in the console since your last `pull` (use `--force` deliberately: local wins, bindings and MCP lists are replaced, and resources changed in the console may be pruned). Workflow pushes create draft versions (`--publish`, `--activate`). Deleting files deletes nothing remotely unless you pass `--prune --yes`. Commit `.metrio/state.json` with the files. Run `metrio pull --help` for every option.
65
+
66
+ - A second plain `metrio push` does not create duplicates ("already pushed as version N"); `metrio push --activate` (prompts) or `--publish` / `--activate` (workflows) makes the pending pushed version live.
67
+ - `pull` adopts untracked local files that are identical to the console. `pull` and `push` report per-resource failures, exit 1, and still save state for the resources that succeeded.
68
+ - Renaming a prompt or skill directory is detected by content (status `renamed`); pull/push move the state entry. Workflow directories are named by workflow id and cannot be renamed locally; push refuses it.
69
+ - Known limitation: renaming AND editing a prompt/skill directory in one go looks like a delete plus a new resource (push would create a second one; `--prune --yes` would delete the old one). Rename first, sync, then edit.
70
+
71
+ ## Verify prompts and workflows
72
+
73
+ Put a `cases.yaml` next to a synced resource (`prompts/<name>/cases.yaml`, `workflows/<id>/cases.yaml`) and run it with the project API key (`METRIOAI_API_KEY` in `.env`, or `--api-key`; it must belong to the workspace's project):
74
+
75
+ ```yaml
76
+ defaults:
77
+ variables: { region: TW }
78
+ cases:
79
+ - name: regular order
80
+ message: "我要訂 3 箱蘋果" # or messages: [...] or messages_file: path
81
+ expect:
82
+ contains: ["蘋果"] # not_contains, regex, equals read the reply text
83
+ json:
84
+ "$.state.order.qty": { equals: 3 } # $.content $.structContent $.state $.tags $.json
85
+ path_includes: [classify, create_order] # workflows only (also terminal_node,
86
+ max_latency_ms: 8000 # response_type, max_llm_calls)
87
+ ```
88
+
89
+ ```bash
90
+ metrio push -m "new wording" # creates a version that is not live yet
91
+ metrio verify --version pushed # run every cases.yaml against what you just pushed
92
+ metrio push --activate # make it live once the cases pass
93
+ metrio verify -p 12 --cases cases.yaml --junit report.xml # outside a workspace
94
+ ```
95
+
96
+ Every `cases.yaml` is validated before the first request (a typo in an assertion is exit 2). A failed case prints each failing assertion with expected vs actual and exits 1. `--json` prints machine-readable results, `--junit <file>` writes JUnit XML, `--fail-fast` stops starting new cases after the first failure. Run `metrio verify --help` for the full format.
@@ -0,0 +1,489 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SYNC_HELP = void 0;
37
+ exports.parseSyncArgs = parseSyncArgs;
38
+ exports.openWorkspace = openWorkspace;
39
+ exports.filterEntries = filterEntries;
40
+ exports.loadEntries = loadEntries;
41
+ exports.safe = safe;
42
+ exports.runDiffCommand = runDiffCommand;
43
+ exports.runPullCommand = runPullCommand;
44
+ exports.runPushCommand = runPushCommand;
45
+ const path = __importStar(require("path"));
46
+ const diff_1 = require("diff");
47
+ const client_1 = require("../console/client");
48
+ const errors_1 = require("../errors");
49
+ const push_1 = require("../sync/push");
50
+ const remote_1 = require("../sync/remote");
51
+ const status_1 = require("../sync/status");
52
+ const workspace_1 = require("../sync/workspace");
53
+ const ONLY = { prompts: 'prompt', skills: 'skill', workflows: 'workflow' };
54
+ function parseSyncArgs(argv) {
55
+ const o = {
56
+ paths: [], kinds: ['prompt', 'skill', 'workflow'], force: false, activate: false, publish: false,
57
+ prune: false, yes: false, dryRun: false, json: false,
58
+ };
59
+ for (let i = 0; i < argv.length; i += 1) {
60
+ const arg = argv[i];
61
+ const value = () => {
62
+ const v = argv[i + 1];
63
+ if (v === undefined)
64
+ throw new errors_1.CliError(`${arg} needs a value`);
65
+ i += 1;
66
+ return v;
67
+ };
68
+ if (arg === '--only') {
69
+ const kind = ONLY[value()];
70
+ if (!kind)
71
+ throw new errors_1.CliError('--only must be prompts, skills or workflows');
72
+ o.kinds = [kind];
73
+ }
74
+ else if (arg === '-m' || arg === '--message')
75
+ o.message = value();
76
+ else if (arg === '--project')
77
+ o.project = value();
78
+ else if (arg === '--force')
79
+ o.force = true;
80
+ else if (arg === '--activate')
81
+ o.activate = true;
82
+ else if (arg === '--publish')
83
+ o.publish = true;
84
+ else if (arg === '--prune')
85
+ o.prune = true;
86
+ else if (arg === '--yes')
87
+ o.yes = true;
88
+ else if (arg === '--dry-run')
89
+ o.dryRun = true;
90
+ else if (arg === '--json')
91
+ o.json = true;
92
+ else if (arg.startsWith('-'))
93
+ throw new errors_1.CliError(`Unknown option ${arg}`);
94
+ else
95
+ o.paths.push(arg);
96
+ }
97
+ return o;
98
+ }
99
+ async function openWorkspace() {
100
+ const root = (0, workspace_1.findRoot)();
101
+ if (!root)
102
+ throw new errors_1.CliError('Not in a metrio workspace (no metrio.yaml). Run "metrio pull --project <slug>" first.');
103
+ return { root, project: (0, workspace_1.readProject)(root), client: new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)()) };
104
+ }
105
+ /** Entries whose key equals or sits under one of the given paths (relative to the workspace root). */
106
+ function filterEntries(entries, root, paths) {
107
+ if (!paths.length)
108
+ return entries;
109
+ const wanted = paths.map((p) => {
110
+ const rel = path.relative(root, path.resolve(p));
111
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
112
+ throw new errors_1.CliError(`${p} is outside the metrio workspace`);
113
+ }
114
+ return rel.split(path.sep).join('/').replace(/\/+$/, '');
115
+ });
116
+ const matches = (e, w) => w === '' || e.key === w || e.key.startsWith(`${w}/`) || w.startsWith(`${e.key}/`);
117
+ wanted.forEach((w, i) => {
118
+ if (!entries.some((e) => matches(e, w)))
119
+ throw new errors_1.CliError(`${paths[i]} matches nothing in this workspace`);
120
+ });
121
+ return entries.filter((e) => wanted.some((w) => matches(e, w)));
122
+ }
123
+ async function loadEntries(ctx, opts) {
124
+ const state = (0, workspace_1.readState)(ctx.root, ctx.project);
125
+ const remote = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, opts.kinds);
126
+ return filterEntries((0, status_1.computeEntries)(ctx.root, state, remote, opts.kinds), ctx.root, opts.paths);
127
+ }
128
+ /** Console-sourced text must not carry terminal control sequences. */
129
+ function safe(text) {
130
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
131
+ }
132
+ exports.SYNC_HELP = `metrio pull | diff | push - sync prompts, skills and workflows with local files
133
+
134
+ metrio pull --project <slug> First pull: creates metrio.yaml here and writes:
135
+ prompts/<name>/prompt.yaml + system.md
136
+ skills/<name>.md
137
+ workflows/<id>/workflow.yaml + definition.json
138
+ metrio pull [paths…] Update files from the console (keeps local edits unless --force)
139
+ metrio diff [paths…] Show what differs: modified, remote-changed, conflict, new, …
140
+ metrio push [paths…] Send local edits: new prompt version (not live unless --activate),
141
+ skill update, workflow version (draft; --publish / --activate)
142
+
143
+ Options:
144
+ --only prompts|skills|workflows Limit to one kind
145
+ --force pull: overwrite local edits; push: push over console changes
146
+ (local wins — bindings and MCP list are replaced), allow
147
+ pruning resources changed in the console, and allow a prompt
148
+ version that drops the live messages/agent_description
149
+ -m, --message <text> push: description of the new versions
150
+ --activate push: make new prompt versions head / workflow versions live
151
+ --publish push: publish new workflow versions without activating
152
+ --prune --yes push: delete console resources whose local files were removed
153
+ --dry-run Print the plan without changing anything
154
+ --json diff: machine-readable statuses
155
+
156
+ State lives in .metrio/state.json (commit it with the files).
157
+ `;
158
+ const wantsHelp = (argv) => argv.includes('-h') || argv.includes('--help');
159
+ function patch(e) {
160
+ const local = e.local ?? {};
161
+ const remote = e.remote?.files ?? {};
162
+ return [...new Set([...Object.keys(local), ...Object.keys(remote)])]
163
+ .sort()
164
+ .filter((f) => local[f] !== remote[f])
165
+ .map((f) => (0, diff_1.createTwoFilesPatch)(`remote/${e.key}/${f}`, `local/${e.key}/${f}`, remote[f] ?? '', local[f] ?? ''))
166
+ .join('');
167
+ }
168
+ async function runDiffCommand(argv) {
169
+ if (wantsHelp(argv)) {
170
+ process.stdout.write(exports.SYNC_HELP);
171
+ return;
172
+ }
173
+ const opts = parseSyncArgs(argv);
174
+ const ctx = await openWorkspace();
175
+ const entries = await loadEntries(ctx, opts);
176
+ if (opts.json) {
177
+ const rows = entries.map((e) => ({ key: e.key, kind: e.kind, status: e.status, ...(e.from ? { from: e.from } : {}) }));
178
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
179
+ return;
180
+ }
181
+ let unchanged = 0;
182
+ for (const e of entries) {
183
+ if (e.status === 'unchanged') {
184
+ unchanged += 1;
185
+ continue;
186
+ }
187
+ process.stdout.write(safe(`${e.status.padEnd(15)}${e.key}${e.from ? ` (from ${e.from})` : ''}\n`));
188
+ if (e.status === 'modified' || e.status === 'conflict' || e.status === 'remote-changed')
189
+ process.stdout.write(safe(patch(e)));
190
+ }
191
+ process.stdout.write(`${unchanged} unchanged\n`);
192
+ }
193
+ async function runPullCommand(argv) {
194
+ if (wantsHelp(argv)) {
195
+ process.stdout.write(exports.SYNC_HELP);
196
+ return;
197
+ }
198
+ const opts = parseSyncArgs(argv);
199
+ const found = (0, workspace_1.findRoot)();
200
+ let root = found;
201
+ if (!root) {
202
+ if (!opts.project)
203
+ throw new errors_1.CliError('Not in a metrio workspace. Run "metrio pull --project <slug>" to start one here.');
204
+ root = process.cwd(); // metrio.yaml is only created once the console fetch succeeded
205
+ }
206
+ else if (opts.project && opts.project !== (0, workspace_1.readProject)(root)) {
207
+ (0, workspace_1.initRoot)(root, opts.project); // throws: metrio.yaml names another project
208
+ }
209
+ const ctx = { root, project: found ? (0, workspace_1.readProject)(root) : opts.project, client: new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)()) };
210
+ // A fresh workspace is locked once metrio.yaml exists (below).
211
+ let release = found && !opts.dryRun ? (0, workspace_1.acquireLock)(root) : undefined;
212
+ try {
213
+ const state = (0, workspace_1.readState)(ctx.root, ctx.project);
214
+ const entries = await loadEntries(ctx, opts);
215
+ if (!found && !opts.dryRun) {
216
+ (0, workspace_1.initRoot)(root, ctx.project);
217
+ release = (0, workspace_1.acquireLock)(root);
218
+ }
219
+ let unchanged = 0;
220
+ let failed = 0;
221
+ const record = (e, files) => {
222
+ const h = (0, workspace_1.hashFiles)(files);
223
+ state.resources[e.key] = { ...(state.resources[e.key] ?? {}), kind: e.kind, id: e.remote.id, base: h, remote: h };
224
+ };
225
+ const take = (e, verb) => {
226
+ if (!opts.dryRun) {
227
+ (0, workspace_1.writeLocal)(ctx.root, e.key, e.kind, e.remote.files);
228
+ record(e, e.remote.files);
229
+ }
230
+ process.stdout.write(safe(`${opts.dryRun ? 'would pull' : verb} ${e.key}\n`));
231
+ };
232
+ for (const e of entries) {
233
+ try {
234
+ switch (e.status) {
235
+ case 'unchanged':
236
+ if (e.refresh && !opts.dryRun)
237
+ record(e, e.remote.files);
238
+ if (e.refresh && !e.state)
239
+ process.stdout.write(safe(`${opts.dryRun ? 'would track' : 'tracked'} ${e.key}\n`));
240
+ else
241
+ unchanged += 1;
242
+ break;
243
+ case 'remote-new':
244
+ case 'remote-changed':
245
+ take(e, 'pulled');
246
+ break;
247
+ case 'deleted':
248
+ take(e, 'restored');
249
+ break;
250
+ case 'modified':
251
+ if (opts.force)
252
+ take(e, 'pulled');
253
+ else
254
+ process.stderr.write(safe(`skipped ${e.key}: local changes (use --force to overwrite)\n`));
255
+ break;
256
+ case 'conflict':
257
+ if (!e.state && e.kind === 'prompt' && e.remote.data.version === null) {
258
+ // A console prompt without a version has nothing to pull: track it as if its empty render
259
+ // had been pulled, so the local files show as edits and push creates the first version.
260
+ if (!opts.dryRun) {
261
+ const h = (0, workspace_1.hashFiles)(e.remote.files);
262
+ state.resources[e.key] = { kind: e.kind, id: e.remote.id, base: h, remote: h };
263
+ }
264
+ process.stdout.write(safe(`${opts.dryRun ? 'would track' : 'tracked'} ${e.key} (the console prompt has no version yet; push to create one)\n`));
265
+ }
266
+ else if (opts.force)
267
+ take(e, 'pulled');
268
+ else
269
+ process.stderr.write(safe(`skipped ${e.key}: local changes (use --force to overwrite)\n`));
270
+ break;
271
+ case 'renamed': {
272
+ const old = state.resources[e.from];
273
+ if (!opts.dryRun) {
274
+ state.resources[e.key] = old;
275
+ delete state.resources[e.from];
276
+ }
277
+ process.stdout.write(safe(`${opts.dryRun ? 'would rename' : 'renamed'} ${e.from} -> ${e.key}\n`));
278
+ if ((0, workspace_1.hashFiles)(e.remote.files) !== old.remote)
279
+ take(e, 'pulled');
280
+ break;
281
+ }
282
+ case 'remote-deleted':
283
+ if (!opts.dryRun)
284
+ delete state.resources[e.key];
285
+ process.stderr.write(safe(`${e.key}: deleted in the console; local files kept (push to recreate, or delete them)\n`));
286
+ break;
287
+ case 'new':
288
+ break;
289
+ }
290
+ }
291
+ catch (error) {
292
+ failed += 1;
293
+ process.stderr.write(safe(`failed ${e.key}: ${error instanceof Error ? error.message : String(error)}\n`));
294
+ }
295
+ }
296
+ if (!opts.dryRun)
297
+ (0, workspace_1.writeState)(ctx.root, state);
298
+ process.stdout.write(`${unchanged} unchanged\n`);
299
+ if (failed > 0)
300
+ throw new errors_1.CliError(`${failed} resources failed`, 1);
301
+ }
302
+ finally {
303
+ release?.();
304
+ }
305
+ }
306
+ function remoteContent(r) {
307
+ try {
308
+ return (0, push_1.versionContent)(r.kind, r.files, r.kind);
309
+ }
310
+ catch {
311
+ return undefined;
312
+ }
313
+ }
314
+ const remoteMoved = (e) => (0, workspace_1.hashFiles)(e.remote.files) !== e.state.remote;
315
+ function planPush(entries, opts, options) {
316
+ const actions = [];
317
+ for (const entry of entries) {
318
+ switch (entry.status) {
319
+ case 'unchanged':
320
+ if ((0, push_1.hasPendingVersion)(entry, options))
321
+ actions.push({ entry, op: 'update' });
322
+ break;
323
+ case 'modified':
324
+ actions.push({ entry, op: 'update' });
325
+ break;
326
+ case 'new':
327
+ actions.push({ entry, op: 'create' });
328
+ break;
329
+ case 'renamed':
330
+ if (entry.kind === 'workflow') {
331
+ actions.push({ entry, skip: 'workflow directories are named by workflow_id; rename it in the console instead', failure: true });
332
+ }
333
+ else if (remoteMoved(entry))
334
+ actions.push({ entry, skip: 'remote changed; run "metrio pull" first', failure: true });
335
+ else
336
+ actions.push({ entry, op: 'rename' });
337
+ break;
338
+ case 'conflict':
339
+ if (!entry.state) {
340
+ const reason = `exists in the console but is not tracked here. "metrio pull --force ${entry.key}" would OVERWRITE your local files; ` +
341
+ 'to keep them, rename the local directory (it will be pushed as new) or delete the console copy first';
342
+ actions.push({ entry, skip: reason, failure: true });
343
+ }
344
+ else if (opts.force)
345
+ actions.push({ entry, op: 'update' });
346
+ else
347
+ actions.push({ entry, skip: 'conflict: changed locally and in the console (pull, merge, then push; or --force)', failure: true });
348
+ break;
349
+ case 'remote-changed':
350
+ actions.push({ entry, skip: 'remote changed; run "metrio pull" first', failure: true });
351
+ break;
352
+ case 'remote-deleted':
353
+ actions.push({ entry, skip: 'deleted in the console; run "metrio pull" to drop it, then push to recreate', failure: true });
354
+ break;
355
+ case 'deleted':
356
+ if (opts.prune && opts.yes) {
357
+ if (remoteMoved(entry) && !opts.force) {
358
+ actions.push({ entry, skip: 'changed in the console since your last pull; use --force to delete anyway', failure: true });
359
+ }
360
+ else
361
+ actions.push({ entry, op: 'delete' });
362
+ }
363
+ else if (opts.prune)
364
+ actions.push({ entry, skip: 'use --prune --yes to delete it in the console', failure: true });
365
+ else
366
+ actions.push({ entry, skip: 'deleted locally (use --prune --yes to delete it in the console)', failure: false });
367
+ break;
368
+ default:
369
+ break;
370
+ }
371
+ }
372
+ return sortForExecution(actions);
373
+ }
374
+ const KIND_RANK = { skill: 0, prompt: 1, workflow: 2 };
375
+ const DELETE_RANK = { prompt: 3, workflow: 3, skill: 4 };
376
+ /** Skills first (prompts may name new ones), then prompts, workflows; deletes last, skills after what uses them. */
377
+ function sortForExecution(actions) {
378
+ const rank = (a) => ('op' in a && a.op === 'delete' ? DELETE_RANK : KIND_RANK)[a.entry.kind];
379
+ return [...actions].sort((a, b) => rank(a) - rank(b));
380
+ }
381
+ const PAST = { create: 'created', update: 'updated', delete: 'deleted', rename: 'renamed' };
382
+ const describeOp = (op, e) => (op === 'rename' ? `${e.from} -> ${e.key}` : e.key);
383
+ async function runPushCommand(argv) {
384
+ if (wantsHelp(argv)) {
385
+ process.stdout.write(exports.SYNC_HELP);
386
+ return;
387
+ }
388
+ const opts = parseSyncArgs(argv);
389
+ const ctx = await openWorkspace();
390
+ const release = opts.dryRun ? undefined : (0, workspace_1.acquireLock)(ctx.root);
391
+ try {
392
+ const state = (0, workspace_1.readState)(ctx.root, ctx.project);
393
+ const options = { activate: opts.activate, publish: opts.publish, message: opts.message, force: opts.force };
394
+ const entries = await loadEntries(ctx, opts);
395
+ const actions = planPush(entries, opts, options);
396
+ if (opts.dryRun) {
397
+ for (const a of actions) {
398
+ process.stdout.write(safe('op' in a ? `would ${a.op} ${describeOp(a.op, a.entry)}\n` : `skip ${a.entry.key}: ${a.skip}\n`));
399
+ }
400
+ return;
401
+ }
402
+ const pushCtx = { client: ctx.client, project: ctx.project, options };
403
+ const done = [];
404
+ let failed = 0;
405
+ for (const a of actions) {
406
+ if (!('op' in a)) {
407
+ if (a.failure) {
408
+ failed += 1;
409
+ process.stderr.write(safe(`skip ${a.entry.key}: ${a.skip}\n`));
410
+ }
411
+ else
412
+ process.stderr.write(safe(`${a.entry.key}: ${a.skip}\n`));
413
+ continue;
414
+ }
415
+ try {
416
+ let result;
417
+ if (a.op === 'delete')
418
+ await (0, push_1.deleteEntry)(pushCtx, a.entry);
419
+ else if (a.op !== 'rename')
420
+ result = await (0, push_1.pushEntry)(pushCtx, a.entry);
421
+ done.push({ entry: a.entry, op: a.op, result });
422
+ process.stdout.write(safe(`${PAST[a.op]} ${describeOp(a.op, a.entry)}${result ? ` (${result.summary})` : ''}\n`));
423
+ }
424
+ catch (error) {
425
+ failed += 1;
426
+ process.stderr.write(safe(`failed ${a.entry.key}: ${error instanceof Error ? error.message : String(error)}\n`));
427
+ }
428
+ }
429
+ // Converged resources (same content on both sides, stale state): record them as synced.
430
+ const converged = entries.filter((e) => e.refresh && !done.some((d) => d.entry === e));
431
+ for (const e of converged) {
432
+ const h = (0, workspace_1.hashFiles)(e.local);
433
+ state.resources[e.key] = { ...(state.resources[e.key] ?? {}), kind: e.kind, id: e.remote.id, base: h, remote: h };
434
+ }
435
+ if (converged.length && !done.length)
436
+ (0, workspace_1.writeState)(ctx.root, state);
437
+ let refetchFailed = false;
438
+ if (done.length) {
439
+ const pushed = done.filter((d) => d.result);
440
+ let refreshed;
441
+ if (pushed.length) {
442
+ try {
443
+ refreshed = await (0, remote_1.fetchRemote)(ctx.client, ctx.project, [...new Set(pushed.map((d) => d.entry.kind))]);
444
+ }
445
+ catch (error) {
446
+ refetchFailed = true;
447
+ process.stderr.write(safe(`could not re-read the console after pushing: ${error instanceof Error ? error.message : String(error)}; run "metrio diff" to check\n`));
448
+ }
449
+ }
450
+ for (const d of done) {
451
+ const key = d.entry.key;
452
+ if (d.op === 'delete') {
453
+ delete state.resources[key];
454
+ }
455
+ else if (d.op === 'rename') {
456
+ state.resources[key] = state.resources[d.entry.from];
457
+ delete state.resources[d.entry.from];
458
+ }
459
+ else {
460
+ const r = d.result;
461
+ const previous = state.resources[key];
462
+ const remote = refreshed?.find((x) => x.kind === d.entry.kind && x.id === r.id);
463
+ state.resources[key] = {
464
+ ...(previous ?? {}),
465
+ kind: d.entry.kind,
466
+ id: r.id,
467
+ base: (0, workspace_1.hashFiles)(d.entry.local),
468
+ // Without a re-read, keep the last known remote hash: the next diff then shows what really changed.
469
+ remote: refreshed ? (remote ? (0, workspace_1.hashFiles)(remote.files) : '') : (previous?.remote ?? ''),
470
+ ...(r.pushed_version_id !== undefined ? { pushed_version_id: r.pushed_version_id, pushed_serial: r.pushed_serial } : {}),
471
+ ...(r.pushed_workflow_version !== undefined ? { pushed_workflow_version: r.pushed_workflow_version } : {}),
472
+ ...(r.pushed_content !== undefined ? { pushed_content: r.pushed_content } : {}),
473
+ // What the console made of a new/activated version (defaults filled, prompt_refs pinned): see push.ts.
474
+ ...(refreshed && r.versioned ? { remote_content: remote ? remoteContent(remote) : undefined } : {}),
475
+ };
476
+ }
477
+ }
478
+ (0, workspace_1.writeState)(ctx.root, state);
479
+ }
480
+ process.stdout.write(`${done.length} pushed, ${failed} failed\n`);
481
+ if (failed)
482
+ throw new errors_1.CliError(`${failed} of ${done.length + failed} resources failed`, 1);
483
+ if (refetchFailed)
484
+ throw new errors_1.CliError('pushed, but could not re-read the console; run "metrio diff" to check', 1);
485
+ }
486
+ finally {
487
+ release?.();
488
+ }
489
+ }