@metrio-ai/cli 1.1.0 → 1.2.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.
@@ -0,0 +1,419 @@
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.KIND_FILES = exports.KIND_DIR = void 0;
37
+ exports.findRoot = findRoot;
38
+ exports.readProject = readProject;
39
+ exports.initRoot = initRoot;
40
+ exports.readState = readState;
41
+ exports.writeState = writeState;
42
+ exports.acquireLock = acquireLock;
43
+ exports.hashFiles = hashFiles;
44
+ exports.slugify = slugify;
45
+ exports.readLocal = readLocal;
46
+ exports.writeLocal = writeLocal;
47
+ exports.removeLocal = removeLocal;
48
+ exports.listLocal = listLocal;
49
+ const crypto_1 = require("crypto");
50
+ const fs = __importStar(require("fs"));
51
+ const path = __importStar(require("path"));
52
+ const yaml_1 = require("yaml");
53
+ const errors_1 = require("../errors");
54
+ exports.KIND_DIR = { prompt: 'prompts', skill: 'skills', workflow: 'workflows' };
55
+ exports.KIND_FILES = {
56
+ prompt: ['prompt.yaml', 'system.md'],
57
+ skill: ['skill.md'],
58
+ workflow: ['workflow.yaml', 'definition.json'],
59
+ };
60
+ const CONFIG = 'metrio.yaml';
61
+ /** Check a file path for symlinks; ENOENT is fine (file doesn't exist yet). */
62
+ function assertNotSymlink(fullPath, relativePath) {
63
+ try {
64
+ const stat = fs.lstatSync(fullPath);
65
+ if (stat.isSymbolicLink()) {
66
+ throw new errors_1.CliError(`Refusing to follow symlink at ${relativePath}`);
67
+ }
68
+ }
69
+ catch (err) {
70
+ if (err.code === 'ENOENT')
71
+ return;
72
+ if (err instanceof errors_1.CliError)
73
+ throw err;
74
+ throw new errors_1.CliError(`Cannot inspect ${relativePath}: ${err.message}`);
75
+ }
76
+ }
77
+ /** Nearest directory at or above cwd that holds metrio.yaml. */
78
+ function findRoot(cwd = process.cwd()) {
79
+ let dir = path.resolve(cwd);
80
+ for (;;) {
81
+ if (fs.existsSync(path.join(dir, CONFIG)))
82
+ return dir;
83
+ const parent = path.dirname(dir);
84
+ if (parent === dir)
85
+ return undefined;
86
+ dir = parent;
87
+ }
88
+ }
89
+ function readProject(root) {
90
+ const file = path.join(root, CONFIG);
91
+ assertNotSymlink(file, CONFIG);
92
+ let doc;
93
+ try {
94
+ doc = (0, yaml_1.parse)(fs.readFileSync(file, 'utf8'));
95
+ }
96
+ catch (err) {
97
+ throw new errors_1.CliError(`Cannot read ${file}: ${err.message}`);
98
+ }
99
+ const project = doc?.project;
100
+ if (typeof project !== 'string' || !project)
101
+ throw new errors_1.CliError(`${file} has no "project:" entry`);
102
+ return project;
103
+ }
104
+ /**
105
+ * Creates metrio.yaml in dir if missing; an existing one must name the same project.
106
+ * A new workspace also gets a .gitattributes that stops git from converting line endings in
107
+ * the synced files (they must stay byte-identical to the console); an existing one is left alone.
108
+ */
109
+ function initRoot(dir, project) {
110
+ const root = path.resolve(dir);
111
+ const file = path.join(root, CONFIG);
112
+ if (fs.existsSync(file)) {
113
+ assertNotSymlink(file, CONFIG);
114
+ const existing = readProject(root);
115
+ if (existing !== project)
116
+ throw new errors_1.CliError(`${file} is already set up for project "${existing}"`);
117
+ return root;
118
+ }
119
+ fs.mkdirSync(root, { recursive: true });
120
+ assertNotSymlink(file, CONFIG);
121
+ fs.writeFileSync(file, (0, yaml_1.stringify)({ project }));
122
+ const attributes = path.join(root, '.gitattributes');
123
+ assertNotSymlink(attributes, '.gitattributes');
124
+ if (!fs.existsSync(attributes)) {
125
+ fs.writeFileSync(attributes, ['prompts/** -text', 'skills/** -text', 'workflows/** -text', '.metrio/** -text', ''].join('\n'));
126
+ }
127
+ return root;
128
+ }
129
+ const statePath = (root) => path.join(root, '.metrio', 'state.json');
130
+ /** Creates .metrio/ (refusing a symlink) with a .gitignore so the lock and temp files are never committed. */
131
+ function ensureMetrioDir(root) {
132
+ const dir = path.join(root, '.metrio');
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ assertNotSymlink(dir, '.metrio');
135
+ const ignore = path.join(dir, '.gitignore');
136
+ assertNotSymlink(ignore, '.metrio/.gitignore');
137
+ if (!fs.existsSync(ignore))
138
+ fs.writeFileSync(ignore, 'lock\n*.tmp\n');
139
+ return dir;
140
+ }
141
+ function readState(root, project) {
142
+ const file = statePath(root);
143
+ assertNotSymlink(path.dirname(file), '.metrio');
144
+ if (!fs.existsSync(file))
145
+ return { version: 1, project, resources: {} };
146
+ assertNotSymlink(file, `.metrio/state.json`);
147
+ let state;
148
+ try {
149
+ state = JSON.parse(fs.readFileSync(file, 'utf8'));
150
+ }
151
+ catch (err) {
152
+ throw new errors_1.CliError(`${file} is not valid JSON: ${err.message}`);
153
+ }
154
+ // Validate state shape
155
+ if (!state ||
156
+ typeof state !== 'object' ||
157
+ state.version !== 1 ||
158
+ typeof state.project !== 'string' ||
159
+ !state.resources ||
160
+ typeof state.resources !== 'object' ||
161
+ Array.isArray(state.resources)) {
162
+ throw new errors_1.CliError(`${file} is not a metrio state file`);
163
+ }
164
+ const typedState = state;
165
+ if (typedState.project !== project) {
166
+ throw new errors_1.CliError(`${file} belongs to project "${typedState.project}", not "${project}"`);
167
+ }
168
+ // Validate all resource keys
169
+ for (const key of Object.keys(typedState.resources)) {
170
+ validateKey(key);
171
+ // An older push stored a NaN version, which JSON wrote as null: treat it as never pushed.
172
+ const entry = typedState.resources[key];
173
+ if (entry && entry.pushed_workflow_version === null)
174
+ delete entry.pushed_workflow_version;
175
+ }
176
+ return typedState;
177
+ }
178
+ /** Atomic write with resources sorted by path, so the file diffs cleanly in git. */
179
+ function writeState(root, state) {
180
+ const file = statePath(root);
181
+ ensureMetrioDir(root);
182
+ assertNotSymlink(file, `.metrio/state.json`);
183
+ const sorted = {
184
+ version: 1,
185
+ project: state.project,
186
+ resources: Object.fromEntries(Object.keys(state.resources).sort().map((k) => [k, state.resources[k]])),
187
+ };
188
+ const data = `${JSON.stringify(sorted, null, 2)}\n`;
189
+ const tmp = `${file}.${process.pid}.tmp`;
190
+ try {
191
+ fs.writeFileSync(tmp, data, { flag: 'wx' });
192
+ }
193
+ catch (err) {
194
+ if (err.code === 'EEXIST') {
195
+ fs.rmSync(tmp, { force: true });
196
+ fs.writeFileSync(tmp, data, { flag: 'wx' });
197
+ }
198
+ else {
199
+ throw err;
200
+ }
201
+ }
202
+ try {
203
+ fs.renameSync(tmp, file);
204
+ }
205
+ catch (err) {
206
+ fs.rmSync(tmp, { force: true });
207
+ throw err;
208
+ }
209
+ }
210
+ /**
211
+ * Takes the workspace lock (.metrio/lock, created exclusively) so two pulls/pushes can't
212
+ * interleave their reads and state writes. Returns the release function; call it in finally.
213
+ */
214
+ function acquireLock(root) {
215
+ const file = path.join(ensureMetrioDir(root), 'lock');
216
+ assertNotSymlink(file, '.metrio/lock');
217
+ try {
218
+ fs.writeFileSync(file, `${process.pid} ${new Date().toISOString()}\n`, { flag: 'wx' });
219
+ }
220
+ catch (err) {
221
+ if (err.code !== 'EEXIST')
222
+ throw new errors_1.CliError(`Cannot create .metrio/lock: ${err.message}`);
223
+ let holder = '';
224
+ try {
225
+ holder = fs.readFileSync(file, 'utf8').trim();
226
+ }
227
+ catch {
228
+ // released meanwhile, or unreadable: report what we know
229
+ }
230
+ const [pid = '?', since = '?'] = holder.split(/\s+/);
231
+ throw new errors_1.CliError(`another metrio pull/push is running in this workspace (pid ${pid} since ${since}); delete .metrio/lock if it is stale`);
232
+ }
233
+ return () => fs.rmSync(file, { force: true });
234
+ }
235
+ /**
236
+ * sha256 over sorted (name, length, content) triples — unambiguous for any file contents.
237
+ * Only used for change detection, so CRLF counts as LF: a checkout that converted line
238
+ * endings is not a local edit. File contents themselves are never rewritten.
239
+ */
240
+ function hashFiles(files) {
241
+ const hash = (0, crypto_1.createHash)('sha256');
242
+ for (const name of Object.keys(files).sort()) {
243
+ const content = files[name].replace(/\r\n/g, '\n');
244
+ hash.update(`${name}\n${Buffer.byteLength(content)}\n`);
245
+ hash.update(content);
246
+ }
247
+ return hash.digest('hex');
248
+ }
249
+ function slugify(name, fallback) {
250
+ const slug = name
251
+ .normalize('NFKC')
252
+ .toLowerCase()
253
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
254
+ .replace(/^-+|-+$/g, '');
255
+ return slug || fallback;
256
+ }
257
+ /** Validates a resource key format (kind/name). */
258
+ function validateKey(key) {
259
+ if (!key)
260
+ throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
261
+ const segments = key.split('/');
262
+ // Keys must be exactly 2 segments: kind/name
263
+ if (segments.length !== 2)
264
+ throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
265
+ // First segment must be a resource kind directory
266
+ if (!['prompts', 'skills', 'workflows'].includes(segments[0])) {
267
+ throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
268
+ }
269
+ // Check each segment
270
+ for (const seg of segments) {
271
+ if (seg === '' || seg === '.' || seg === '..') {
272
+ throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
273
+ }
274
+ if (seg.includes('\\') || seg.includes(':') || seg.includes('\0')) {
275
+ throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
276
+ }
277
+ }
278
+ }
279
+ /** Resolves a resource key to an absolute path, validating and checking for symlinks. */
280
+ function resolveKey(root, key) {
281
+ validateKey(key);
282
+ const segments = key.split('/');
283
+ let currentPath = root;
284
+ // Walk from root down to the target, checking for symlinks
285
+ for (let i = 0; i < segments.length; i++) {
286
+ const segment = segments[i];
287
+ currentPath = path.join(currentPath, segment);
288
+ // Only check existing path components; ignore ENOENT for not-yet-created parts
289
+ try {
290
+ const stat = fs.lstatSync(currentPath);
291
+ if (stat.isSymbolicLink()) {
292
+ const relativePath = segments.slice(0, i + 1).join('/');
293
+ throw new errors_1.CliError(`Refusing to follow symlink at ${relativePath}`);
294
+ }
295
+ }
296
+ catch (err) {
297
+ if (err instanceof errors_1.CliError)
298
+ throw err;
299
+ if (err.code !== 'ENOENT') {
300
+ const relativePath = segments.slice(0, i + 1).join('/');
301
+ throw new errors_1.CliError(`Cannot inspect ${relativePath}: ${err.message}`);
302
+ }
303
+ // Path doesn't exist yet, which is fine
304
+ }
305
+ }
306
+ return currentPath;
307
+ }
308
+ function readLocal(root, key, kind) {
309
+ const targetPath = resolveKey(root, key);
310
+ if (kind === 'skill') {
311
+ if (!fs.existsSync(targetPath))
312
+ return undefined;
313
+ assertNotSymlink(targetPath, key);
314
+ return { 'skill.md': fs.readFileSync(targetPath, 'utf8') };
315
+ }
316
+ const files = {};
317
+ for (const name of exports.KIND_FILES[kind]) {
318
+ const file = path.join(targetPath, name);
319
+ if (fs.existsSync(file)) {
320
+ assertNotSymlink(file, `${key}/${name}`);
321
+ files[name] = fs.readFileSync(file, 'utf8');
322
+ }
323
+ }
324
+ return Object.keys(files).length ? files : undefined;
325
+ }
326
+ function writeLocal(root, key, kind, files) {
327
+ const targetPath = resolveKey(root, key);
328
+ if (kind === 'skill') {
329
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
330
+ assertNotSymlink(targetPath, key);
331
+ fs.writeFileSync(targetPath, files['skill.md']);
332
+ return;
333
+ }
334
+ fs.mkdirSync(targetPath, { recursive: true });
335
+ for (const name of exports.KIND_FILES[kind]) {
336
+ if (files[name] !== undefined) {
337
+ const file = path.join(targetPath, name);
338
+ assertNotSymlink(file, `${key}/${name}`);
339
+ fs.writeFileSync(file, files[name]);
340
+ }
341
+ }
342
+ }
343
+ function removeLocal(root, key, kind) {
344
+ const targetPath = resolveKey(root, key);
345
+ if (kind === 'skill') {
346
+ assertNotSymlink(targetPath, key);
347
+ fs.rmSync(targetPath, { force: true });
348
+ return;
349
+ }
350
+ for (const name of exports.KIND_FILES[kind]) {
351
+ const file = path.join(targetPath, name);
352
+ assertNotSymlink(file, `${key}/${name}`);
353
+ fs.rmSync(file, { force: true });
354
+ }
355
+ try {
356
+ fs.rmdirSync(targetPath);
357
+ }
358
+ catch {
359
+ // directory still holds other files the user put there; leave it
360
+ }
361
+ }
362
+ function listLocal(root, kinds) {
363
+ const out = [];
364
+ for (const kind of kinds) {
365
+ const dir = path.join(root, exports.KIND_DIR[kind]);
366
+ if (!fs.existsSync(dir))
367
+ continue;
368
+ // Check the kind directory itself for symlinks
369
+ try {
370
+ const stat = fs.lstatSync(dir);
371
+ if (stat.isSymbolicLink()) {
372
+ console.error(`metrio: skipping ${exports.KIND_DIR[kind]}: symlink`);
373
+ continue;
374
+ }
375
+ }
376
+ catch (err) {
377
+ console.error(`metrio: skipping ${exports.KIND_DIR[kind]}: ${err.message}`);
378
+ continue;
379
+ }
380
+ for (const name of fs.readdirSync(dir).sort()) {
381
+ const key = `${exports.KIND_DIR[kind]}/${name}`;
382
+ // Validate key format
383
+ try {
384
+ validateKey(key);
385
+ }
386
+ catch (err) {
387
+ console.error(`metrio: skipping ${key}: ${err.message}`);
388
+ continue;
389
+ }
390
+ const fullPath = path.join(dir, name);
391
+ let stat;
392
+ try {
393
+ stat = fs.lstatSync(fullPath);
394
+ }
395
+ catch (err) {
396
+ console.error(`metrio: skipping ${key}: ${err.message}`);
397
+ continue;
398
+ }
399
+ if (stat.isSymbolicLink()) {
400
+ console.error(`metrio: skipping ${key}: symlink`);
401
+ continue;
402
+ }
403
+ if (kind === 'skill') {
404
+ if (name.endsWith('.md') && stat.isFile())
405
+ out.push({ key, kind });
406
+ }
407
+ else if (stat.isDirectory()) {
408
+ try {
409
+ if (readLocal(root, key, kind))
410
+ out.push({ key, kind });
411
+ }
412
+ catch (err) {
413
+ console.error(`metrio: skipping ${key}: ${err.message}`);
414
+ }
415
+ }
416
+ }
417
+ }
418
+ return out;
419
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metrio-ai/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "packageManager": "yarn@4.14.1",
5
5
  "description": "Metrio AI command-line interface: run prompts and workflows, manage Metrio resources",
6
6
  "bin": {
@@ -31,9 +31,11 @@
31
31
  "dependencies": {
32
32
  "@metrio-ai/client": "^1.5.2",
33
33
  "ajv": "^8.17.1",
34
+ "diff": "^5.2.0",
34
35
  "yaml": "^2.8.3"
35
36
  },
36
37
  "devDependencies": {
38
+ "@types/diff": "^5.2.3",
37
39
  "@types/jest": "^29.5.14",
38
40
  "@types/node": "^20.10.0",
39
41
  "jest": "^29.7.0",