@myelinbridge/cli 0.4.0 → 0.6.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/README.md +106 -59
- package/bin/myelin.js +616 -496
- package/package.json +23 -23
package/bin/myelin.js
CHANGED
|
@@ -1,496 +1,616 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// @myelinbridge/cli — Partner Ingestion CLI (Partner API Phase 5, design §3.3).
|
|
3
|
-
//
|
|
4
|
-
// export MYELIN_API_KEY=myl_live_…
|
|
5
|
-
// npx @myelinbridge/cli ping
|
|
6
|
-
// npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
7
|
-
//
|
|
8
|
-
// Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
|
|
9
|
-
// Every command accepts --json for machine-readable output.
|
|
10
|
-
|
|
11
|
-
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
|
-
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
13
|
-
import process from 'node:process'
|
|
14
|
-
|
|
15
|
-
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
16
|
-
const KEY = process.env.MYELIN_API_KEY
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
async function
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
)
|
|
220
|
-
emit(j)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
for (const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (flag('
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (!
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @myelinbridge/cli — Partner Ingestion CLI (Partner API Phase 5, design §3.3).
|
|
3
|
+
//
|
|
4
|
+
// export MYELIN_API_KEY=myl_live_…
|
|
5
|
+
// npx @myelinbridge/cli ping
|
|
6
|
+
// npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
7
|
+
//
|
|
8
|
+
// Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
|
|
9
|
+
// Every command accepts --json for machine-readable output.
|
|
10
|
+
|
|
11
|
+
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
|
+
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
13
|
+
import process from 'node:process'
|
|
14
|
+
|
|
15
|
+
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
16
|
+
const KEY = process.env.MYELIN_API_KEY
|
|
17
|
+
|
|
18
|
+
// Extra headers sent with every API call — newline-separated "Name: value"
|
|
19
|
+
// pairs in MYELIN_API_HEADER. For deployments fronted by something that
|
|
20
|
+
// authenticates before Myelin does (a corporate gateway, an SSO-protected
|
|
21
|
+
// preview environment). Never overrides Authorization.
|
|
22
|
+
const EXTRA_HEADERS = Object.fromEntries(
|
|
23
|
+
(process.env.MYELIN_API_HEADER ?? '')
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => line.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((line) => {
|
|
28
|
+
const i = line.indexOf(':')
|
|
29
|
+
if (i < 1) return null
|
|
30
|
+
return [line.slice(0, i).trim(), line.slice(i + 1).trim()]
|
|
31
|
+
})
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2)
|
|
36
|
+
const JSON_MODE = argv.includes('--json')
|
|
37
|
+
const args = argv.filter((a) => a !== '--json')
|
|
38
|
+
const command = args[0]
|
|
39
|
+
|
|
40
|
+
const out = (line) => { if (!JSON_MODE) console.log(line) }
|
|
41
|
+
const emit = (obj) => { if (JSON_MODE) console.log(JSON.stringify(obj, null, 2)) }
|
|
42
|
+
|
|
43
|
+
/** Thrown by die() to unwind to main; never reaches the user. */
|
|
44
|
+
class CliExit extends Error {}
|
|
45
|
+
|
|
46
|
+
// die() sets the exit code and unwinds rather than calling process.exit():
|
|
47
|
+
// killing the process while an HTTP socket is still closing trips a libuv
|
|
48
|
+
// assertion on Windows, which printed a C stack trace after a perfectly good
|
|
49
|
+
// error message and replaced the documented exit code with 127. Node exits on
|
|
50
|
+
// its own once the request settles — measured at ~130 ms, no keep-alive stall.
|
|
51
|
+
const die = (message, code = 1) => {
|
|
52
|
+
if (JSON_MODE) console.log(JSON.stringify({ error: message }))
|
|
53
|
+
else console.error(`✗ ${message}`)
|
|
54
|
+
process.exitCode = code
|
|
55
|
+
throw new CliExit(message)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Column widths come from the content. padEnd() alone silently ran a long
|
|
59
|
+
// value into the next column — a 28-character dataset slug swallowed the
|
|
60
|
+
// STATUS header's gutter. Pass headers = null for an unheadered list.
|
|
61
|
+
function table(headers, rows) {
|
|
62
|
+
const cols = headers?.length ?? rows[0]?.length ?? 0
|
|
63
|
+
const widths = Array.from({ length: cols }, (_, i) =>
|
|
64
|
+
Math.max(headers?.[i]?.length ?? 0, 0, ...rows.map((r) => String(r[i] ?? '').length)),
|
|
65
|
+
)
|
|
66
|
+
const line = (cells) =>
|
|
67
|
+
cells
|
|
68
|
+
.map((c, i) => (i === cols - 1 ? String(c ?? '') : String(c ?? '').padEnd(widths[i])))
|
|
69
|
+
.join(' ')
|
|
70
|
+
.trimEnd()
|
|
71
|
+
return headers ? [line(headers), ...rows.map(line)] : rows.map(line)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function flag(name) {
|
|
75
|
+
return args.includes(`--${name}`)
|
|
76
|
+
}
|
|
77
|
+
function opt(name) {
|
|
78
|
+
const i = args.indexOf(`--${name}`)
|
|
79
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function api(method, path, body, raw) {
|
|
83
|
+
const res = await fetch(API_URL + path, {
|
|
84
|
+
method,
|
|
85
|
+
headers: {
|
|
86
|
+
...EXTRA_HEADERS,
|
|
87
|
+
Authorization: `Bearer ${KEY}`,
|
|
88
|
+
...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
|
|
89
|
+
},
|
|
90
|
+
body: raw ? body : body ? JSON.stringify(body) : undefined,
|
|
91
|
+
}).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
|
|
92
|
+
let json = null
|
|
93
|
+
try { json = await res.json() } catch { /* non-JSON body */ }
|
|
94
|
+
return { status: res.status, json }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function expectOk(r, context) {
|
|
98
|
+
if (r.status >= 200 && r.status < 300) return r.json
|
|
99
|
+
const detail = r.json?.detail ?? `HTTP ${r.status}`
|
|
100
|
+
const code = r.json?.code ?? 'error'
|
|
101
|
+
const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused', 'sample_depth_locked'].includes(code)
|
|
102
|
+
die(`${context}: [${code}] ${detail}`, blocked ? 2 : 1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ——— dataset resolution (id or slug) ———
|
|
106
|
+
async function resolveDataset(ref) {
|
|
107
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
|
|
108
|
+
const r = await api('GET', `/datasets/${ref}`)
|
|
109
|
+
if (r.status === 200) return r.json.dataset
|
|
110
|
+
die(`Dataset ${ref} not found in this key's scope`)
|
|
111
|
+
}
|
|
112
|
+
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
113
|
+
for (const p of me.projects) {
|
|
114
|
+
const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
|
|
115
|
+
const hit = r.datasets.find((d) => d.slug === ref || d.name === ref)
|
|
116
|
+
if (hit) return hit
|
|
117
|
+
}
|
|
118
|
+
die(`No dataset with slug or name "${ref}" in this key's scope`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ——— local manifest ———
|
|
122
|
+
function walkDir(root) {
|
|
123
|
+
const files = []
|
|
124
|
+
const walk = (dir) => {
|
|
125
|
+
for (const entry of readdirSync(dir)) {
|
|
126
|
+
const full = join(dir, entry)
|
|
127
|
+
const st = statSync(full)
|
|
128
|
+
if (st.isDirectory()) walk(full)
|
|
129
|
+
else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
walk(root)
|
|
133
|
+
return files
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fmtBytes(n) {
|
|
137
|
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
|
|
138
|
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
|
|
139
|
+
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
|
|
140
|
+
return n + ' B'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
|
|
144
|
+
// reaches the client — declare tells us the part size, and each part gets a
|
|
145
|
+
// short-lived presigned PUT URL scoped to exactly that one part of one object).
|
|
146
|
+
// Resumable: the sign step reports parts that already landed, so a re-run skips
|
|
147
|
+
// them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
|
|
148
|
+
const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
|
|
149
|
+
|
|
150
|
+
async function readPart(absPath, start, end) {
|
|
151
|
+
const chunks = []
|
|
152
|
+
await new Promise((res, rej) => {
|
|
153
|
+
const s = createReadStream(absPath, { start, end }) // end inclusive
|
|
154
|
+
s.on('data', (c) => chunks.push(c))
|
|
155
|
+
s.on('end', res)
|
|
156
|
+
s.on('error', rej)
|
|
157
|
+
})
|
|
158
|
+
return Buffer.concat(chunks)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
|
|
162
|
+
const numParts = Math.max(1, Math.ceil(size / partSize))
|
|
163
|
+
const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
|
|
164
|
+
const etags = new Map() // part_number -> etag
|
|
165
|
+
let uploadId = null
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
|
|
168
|
+
const window = allParts.slice(i, i + SIGN_WINDOW)
|
|
169
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
|
|
170
|
+
uploadId = j.upload_id
|
|
171
|
+
for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
|
|
172
|
+
const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
|
|
173
|
+
|
|
174
|
+
// upload the still-missing parts in this window, up to 4 concurrent.
|
|
175
|
+
// Presigned URLs expire (~30 min); on a rejection we re-sign that one part
|
|
176
|
+
// and retry once — cheap, since upload-parts is idempotent.
|
|
177
|
+
const missing = window.filter((n) => !etags.has(n))
|
|
178
|
+
const queue = [...missing]
|
|
179
|
+
const resign = async (partNo) => {
|
|
180
|
+
const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
|
|
181
|
+
const fresh = j.urls.find((u) => u.part_number === partNo)?.url
|
|
182
|
+
if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
|
|
183
|
+
urlByPart.set(partNo, fresh)
|
|
184
|
+
return fresh
|
|
185
|
+
}
|
|
186
|
+
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
187
|
+
for (;;) {
|
|
188
|
+
const partNo = queue.shift()
|
|
189
|
+
if (partNo === undefined) return
|
|
190
|
+
const start = (partNo - 1) * partSize
|
|
191
|
+
const end = Math.min(start + partSize, size) - 1
|
|
192
|
+
const bytes = await readPart(absPath, start, end)
|
|
193
|
+
let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
|
|
194
|
+
if (!put || put.status === 403 || put.status === 401) {
|
|
195
|
+
put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
|
|
196
|
+
}
|
|
197
|
+
if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
|
|
198
|
+
const etag = put.headers.get('etag')
|
|
199
|
+
if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
|
|
200
|
+
etags.set(partNo, etag)
|
|
201
|
+
if (onPartDone) onPartDone()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
await Promise.all(workers)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ——— commands ———
|
|
211
|
+
|
|
212
|
+
async function cmdPing() {
|
|
213
|
+
const j = expectOk(await api('GET', '/ping'), 'ping')
|
|
214
|
+
emit(j)
|
|
215
|
+
out(`✓ key "${j.key.name}" (${j.key.prefix}…) · bridge ${j.bridge.name} · ${j.projects.length} project(s)`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function cmdProjects() {
|
|
219
|
+
const j = expectOk(await api('GET', '/projects'), 'projects')
|
|
220
|
+
emit(j)
|
|
221
|
+
for (const line of table(null, j.projects.map((p) => [p.code, p.status, p.title]))) out(line)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function cmdDatasets() {
|
|
225
|
+
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
226
|
+
const rows = []
|
|
227
|
+
for (const p of me.projects) {
|
|
228
|
+
const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
|
|
229
|
+
for (const d of r.datasets) {
|
|
230
|
+
rows.push({
|
|
231
|
+
project: p.code,
|
|
232
|
+
dataset: d.slug,
|
|
233
|
+
status: d.lifecycle_status,
|
|
234
|
+
quality_version: d.quality_check_version,
|
|
235
|
+
open_draft: d.open_draft_batch_id,
|
|
236
|
+
id: d.id,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
emit({ datasets: rows })
|
|
241
|
+
if (!JSON_MODE) {
|
|
242
|
+
const lines = table(
|
|
243
|
+
['PROJECT', 'DATASET', 'STATUS', 'YOUR MOVE?'],
|
|
244
|
+
rows.map((r) => [r.project, r.dataset, r.status, r.open_draft ? 'yes — open draft to finish' : '—']),
|
|
245
|
+
)
|
|
246
|
+
for (const line of lines) out(line)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The delivery contract, before you deliver anything. Answers "what is expected
|
|
251
|
+
// of me?" — which used to be discoverable only by submitting and being rejected.
|
|
252
|
+
async function cmdContract() {
|
|
253
|
+
const dsRef = opt('dataset')
|
|
254
|
+
if (!dsRef) die('Usage: myelin contract --dataset <slug|id>')
|
|
255
|
+
const dataset = await resolveDataset(dsRef)
|
|
256
|
+
const j = expectOk(
|
|
257
|
+
await api('GET', `/datasets/${dataset.id}/quality-checks`),
|
|
258
|
+
'quality-checks',
|
|
259
|
+
)
|
|
260
|
+
emit(j)
|
|
261
|
+
if (JSON_MODE) return
|
|
262
|
+
|
|
263
|
+
if (!j.version) {
|
|
264
|
+
out(`${dataset.name}: no quality contract published yet — nothing is enforced.`)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const s = j.summary
|
|
269
|
+
out(`${dataset.name} — delivery contract v${j.version}`)
|
|
270
|
+
out(
|
|
271
|
+
`${s.total} checks · ${s.blocking} block validation · ` +
|
|
272
|
+
`${s.checkable_before_upload} checkable before upload` +
|
|
273
|
+
(s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
|
|
274
|
+
)
|
|
275
|
+
out('')
|
|
276
|
+
|
|
277
|
+
// Grouped by dimension so the contract reads as questions, not a flat list.
|
|
278
|
+
const byDim = new Map()
|
|
279
|
+
for (const c of j.checks) {
|
|
280
|
+
const key = c.dimension ?? 'review'
|
|
281
|
+
if (!byDim.has(key)) byDim.set(key, [])
|
|
282
|
+
byDim.get(key).push(c)
|
|
283
|
+
}
|
|
284
|
+
for (const d of s.dimensions) {
|
|
285
|
+
const items = byDim.get(d.key) ?? []
|
|
286
|
+
if (items.length === 0) continue
|
|
287
|
+
out(`${d.label.toUpperCase()} — ${d.question}`)
|
|
288
|
+
for (const c of items) {
|
|
289
|
+
const gate = c.severity === 'blocking' ? 'must' : 'should'
|
|
290
|
+
const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
|
|
291
|
+
out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
|
|
292
|
+
}
|
|
293
|
+
out('')
|
|
294
|
+
}
|
|
295
|
+
const manual = byDim.get('review') ?? []
|
|
296
|
+
if (manual.length > 0) {
|
|
297
|
+
out('REVIEWER JUDGEMENT — decided by a person, not the engine')
|
|
298
|
+
for (const c of manual) out(` · ${c.name}`)
|
|
299
|
+
out('')
|
|
300
|
+
}
|
|
301
|
+
out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function cmdCheck() {
|
|
305
|
+
const dir = args[1]
|
|
306
|
+
const dsRef = opt('dataset')
|
|
307
|
+
if (!dir || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
|
|
308
|
+
const root = resolve(dir)
|
|
309
|
+
const files = walkDir(root)
|
|
310
|
+
if (files.length === 0) die(`No files under ${root}`)
|
|
311
|
+
const dataset = await resolveDataset(dsRef)
|
|
312
|
+
|
|
313
|
+
out(`Evaluating ${files.length} files (${fmtBytes(files.reduce((s, f) => s + f.size, 0))}) against quality checks v${dataset.quality_check_version ?? '—'}…`)
|
|
314
|
+
const j = expectOk(
|
|
315
|
+
await api('POST', `/datasets/${dataset.id}/preflight`, {
|
|
316
|
+
files: files.map((f) => ({ path: f.path, size_bytes: f.size })),
|
|
317
|
+
}),
|
|
318
|
+
'preflight',
|
|
319
|
+
)
|
|
320
|
+
emit(j)
|
|
321
|
+
if (!JSON_MODE) {
|
|
322
|
+
// One width across all three lists so the verdicts line up, and the hint
|
|
323
|
+
// lines can still be interleaved under the check they belong to.
|
|
324
|
+
const w = Math.max(
|
|
325
|
+
0,
|
|
326
|
+
...j.evaluated.map((c) => c.check_type.length),
|
|
327
|
+
...j.deferred.map((d) => d.check_type.length),
|
|
328
|
+
...j.manual.map((m) => m.name.length),
|
|
329
|
+
)
|
|
330
|
+
for (const c of j.evaluated) {
|
|
331
|
+
const mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
|
|
332
|
+
out(`${mark} ${c.check_type.padEnd(w)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING' : ''}`)
|
|
333
|
+
if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
|
|
334
|
+
}
|
|
335
|
+
for (const d of j.deferred) out(`… ${d.check_type.padEnd(w)} ${d.reason}`)
|
|
336
|
+
for (const m of j.manual) out(`○ ${m.name.padEnd(w)} ${m.reason}`)
|
|
337
|
+
}
|
|
338
|
+
if (j.blocking_failures > 0) {
|
|
339
|
+
out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
|
|
340
|
+
process.exitCode = 2
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
out('All checks that run before upload pass.')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function cmdPush() {
|
|
347
|
+
const dir = args[1]
|
|
348
|
+
const dsRef = opt('dataset')
|
|
349
|
+
if (!dir || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
|
|
350
|
+
const root = resolve(dir)
|
|
351
|
+
const files = walkDir(root)
|
|
352
|
+
if (files.length === 0) die(`No files under ${root}`)
|
|
353
|
+
const dataset = await resolveDataset(dsRef)
|
|
354
|
+
|
|
355
|
+
// 1. create or resume the draft
|
|
356
|
+
const created = expectOk(await api('POST', `/datasets/${dataset.id}/batches`), 'create batch')
|
|
357
|
+
const batchId = created.batch_id
|
|
358
|
+
out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
|
|
359
|
+
|
|
360
|
+
// 2. declare in chunks of 500 — idempotent, so re-running skips what's done
|
|
361
|
+
const declared = []
|
|
362
|
+
let upload = null
|
|
363
|
+
for (let i = 0; i < files.length; i += 500) {
|
|
364
|
+
const slice = files.slice(i, i + 500)
|
|
365
|
+
const r = await api('POST', `/batches/${batchId}/files`, {
|
|
366
|
+
files: slice.map((f) => ({ path: f.path, size_bytes: f.size })),
|
|
367
|
+
replace: flag('replace'),
|
|
368
|
+
})
|
|
369
|
+
if (r.status !== 200 && r.status !== 409) expectOk(r, 'declare')
|
|
370
|
+
const conflicts = r.json.files.filter((f) => f.error)
|
|
371
|
+
if (conflicts.length > 0) {
|
|
372
|
+
for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
|
|
373
|
+
die(`${conflicts.length} path conflict(s) — pass --replace to overwrite`, 2)
|
|
374
|
+
}
|
|
375
|
+
declared.push(...r.json.files)
|
|
376
|
+
upload = r.json.upload
|
|
377
|
+
}
|
|
378
|
+
const partSize = upload?.part_size ?? 64 * 1024 * 1024
|
|
379
|
+
|
|
380
|
+
// 3. upload the delta (anything not already 'uploaded') via S3 multipart,
|
|
381
|
+
// 2 files in parallel (each file uploads its parts 4-wide internally)
|
|
382
|
+
const byPath = new Map(files.map((f) => [f.path, f]))
|
|
383
|
+
const pending = declared.filter((d) => d.upload_state !== 'uploaded')
|
|
384
|
+
out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
|
|
385
|
+
let done = 0
|
|
386
|
+
const queue = [...pending]
|
|
387
|
+
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
|
388
|
+
for (;;) {
|
|
389
|
+
const d = queue.shift()
|
|
390
|
+
if (!d) return
|
|
391
|
+
const local = byPath.get(d.path)
|
|
392
|
+
const { upload_id, parts } = await uploadFileMultipart(local.abs, local.size, d.file_id, partSize)
|
|
393
|
+
const c = await api('POST', `/files/${d.file_id}/confirm`, { upload_id, parts })
|
|
394
|
+
if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
|
|
395
|
+
done++
|
|
396
|
+
out(` ✓ ${d.path} (${done}/${pending.length})`)
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
await Promise.all(workers)
|
|
400
|
+
out('All files confirmed.')
|
|
401
|
+
|
|
402
|
+
// 4. optional submit
|
|
403
|
+
if (flag('submit')) {
|
|
404
|
+
const r = await api('POST', `/batches/${batchId}/submit`)
|
|
405
|
+
if (r.status !== 200) {
|
|
406
|
+
const code = r.json?.code ?? 'error'
|
|
407
|
+
die(`submit: [${code}] ${r.json?.detail ?? r.status}`, code === 'submit_blocked' ? 2 : 1)
|
|
408
|
+
}
|
|
409
|
+
const a = r.json.auto_checks
|
|
410
|
+
emit(r.json)
|
|
411
|
+
out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
|
|
412
|
+
out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
|
|
413
|
+
} else {
|
|
414
|
+
emit({ batch_id: batchId, files: declared.length, submitted: false })
|
|
415
|
+
out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function cmdStatus() {
|
|
420
|
+
const ref = args[1]
|
|
421
|
+
if (!ref) die('Usage: myelin status <batch-id|display-name> [--watch]')
|
|
422
|
+
|
|
423
|
+
const findBatch = async () => {
|
|
424
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
|
|
425
|
+
const r = await api('GET', `/batches/${ref}`)
|
|
426
|
+
if (r.status === 200) return r.json.batch
|
|
427
|
+
die(`Batch ${ref} not found in this key's scope`)
|
|
428
|
+
}
|
|
429
|
+
const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
|
|
430
|
+
const hit = list.batches.find((b) => b.display_name === ref)
|
|
431
|
+
if (!hit) die(`No batch named "${ref}" in this key's scope`)
|
|
432
|
+
return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const print = async (batch) => {
|
|
436
|
+
emit({ batch })
|
|
437
|
+
out(`${batch.display_name}: ${batch.status} — ${batch.court === 'partner' ? 'your move.' : batch.court === 'reviewer' ? "reviewer's move." : batch.court === 'system' ? 'transferring…' : 'done.'}`)
|
|
438
|
+
if (batch.status === 'changes_requested') {
|
|
439
|
+
const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
|
|
440
|
+
if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
|
|
441
|
+
for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
|
|
442
|
+
out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
|
|
443
|
+
}
|
|
444
|
+
for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
|
|
445
|
+
out(` ✗ ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return batch
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let batch = await print(await findBatch())
|
|
452
|
+
if (flag('watch')) {
|
|
453
|
+
const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
|
|
454
|
+
while (!terminal.includes(batch.status)) {
|
|
455
|
+
await new Promise((r) => setTimeout(r, 20_000))
|
|
456
|
+
const next = await findBatch()
|
|
457
|
+
if (next.status !== batch.status) batch = await print(next)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function cmdSandbox() {
|
|
463
|
+
const file = args[1]
|
|
464
|
+
if (!file) die('Usage: myelin sandbox <file>')
|
|
465
|
+
const abs = resolve(file)
|
|
466
|
+
const size = statSync(abs).size
|
|
467
|
+
if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
|
|
468
|
+
|
|
469
|
+
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
470
|
+
const form = new FormData()
|
|
471
|
+
form.set('file', new File([readFileSync(abs)], basename(abs)))
|
|
472
|
+
const r = await api('POST', `/bridges/${me.bridge.id}/sandbox`, form, true)
|
|
473
|
+
const j = expectOk(r, 'sandbox')
|
|
474
|
+
emit(j)
|
|
475
|
+
out(`✓ Sandbox upload succeeded — the partner-side test transfer checklist item is satisfied for bridge "${me.bridge.name}".`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function cmdSampleDepth() {
|
|
479
|
+
const dsRef = opt('dataset')
|
|
480
|
+
const raw = args[1]
|
|
481
|
+
if (!dsRef || raw === undefined) {
|
|
482
|
+
die('Usage: myelin sample-depth <0-5> --dataset <slug|id>')
|
|
483
|
+
}
|
|
484
|
+
const depth = Number(raw)
|
|
485
|
+
if (!Number.isInteger(depth) || depth < 0 || depth > 5) {
|
|
486
|
+
die('sample-depth must be an integer between 0 and 5')
|
|
487
|
+
}
|
|
488
|
+
const dataset = await resolveDataset(dsRef)
|
|
489
|
+
const j = expectOk(
|
|
490
|
+
await api('POST', `/datasets/${dataset.id}/sample-depth`, { sample_depth: depth }),
|
|
491
|
+
'sample-depth',
|
|
492
|
+
)
|
|
493
|
+
emit(j)
|
|
494
|
+
if (!JSON_MODE) {
|
|
495
|
+
out(
|
|
496
|
+
j.changed
|
|
497
|
+
? `Sample depth for ${dataset.name} set to ${j.sample_depth}.`
|
|
498
|
+
: `Sample depth for ${dataset.name} already ${j.sample_depth} — nothing to change.`,
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function cmdHelp() {
|
|
504
|
+
console.log(`myelin — Partner Ingestion CLI
|
|
505
|
+
|
|
506
|
+
Setup:
|
|
507
|
+
export MYELIN_API_KEY=myl_live_… (create in Bridge → API)
|
|
508
|
+
export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
|
|
509
|
+
export MYELIN_API_HEADER="Name: value" (optional; extra headers, one per line —
|
|
510
|
+
for a gateway that authenticates ahead of Myelin)
|
|
511
|
+
|
|
512
|
+
Commands:
|
|
513
|
+
ping verify the key; show bridge + projects
|
|
514
|
+
projects list scoped projects
|
|
515
|
+
datasets list datasets with "your move" hints
|
|
516
|
+
sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
|
|
517
|
+
contract --dataset <slug|id> what this dataset expects of your delivery
|
|
518
|
+
check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
|
|
519
|
+
push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
|
|
520
|
+
[--submit] [--replace]
|
|
521
|
+
status <batch|name> [--watch] review status + fix-loop findings
|
|
522
|
+
sandbox <file> partner-side test transfer (bridge activation)
|
|
523
|
+
|
|
524
|
+
Client-side commands (need a CLIENT key — Organisation → API keys):
|
|
525
|
+
deliveries [--project <id>] what landed in your destination bucket
|
|
526
|
+
[--dataset <id>] [--since <iso>] [--limit <n>]
|
|
527
|
+
resolve <path|prefix|id> what a path in your bucket actually is —
|
|
528
|
+
project, dataset, batch, file
|
|
529
|
+
|
|
530
|
+
Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.`)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ——— client-side commands (client keys only) ———
|
|
534
|
+
//
|
|
535
|
+
// These need a CLIENT key, minted by the client bridge owner in Bridge → API →
|
|
536
|
+
// "Your read keys". A partner key gets 403 wrong_key_side, and the message says
|
|
537
|
+
// so — the two sides deliberately do not silently degrade into each other.
|
|
538
|
+
|
|
539
|
+
async function cmdDeliveries() {
|
|
540
|
+
const qs = new URLSearchParams()
|
|
541
|
+
const map = { project: 'project_id', dataset: 'dataset_id', since: 'since', limit: 'limit' }
|
|
542
|
+
for (const [flagName, param] of Object.entries(map)) {
|
|
543
|
+
const v = opt(flagName)
|
|
544
|
+
if (v) qs.set(param, v)
|
|
545
|
+
}
|
|
546
|
+
const query = qs.toString()
|
|
547
|
+
const j = expectOk(await api('GET', `/deliveries${query ? `?${query}` : ''}`), 'deliveries')
|
|
548
|
+
emit(j)
|
|
549
|
+
if (j.deliveries.length === 0) {
|
|
550
|
+
out('No deliveries yet in this key’s scope.')
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
const rows = j.deliveries.map((d) => [
|
|
554
|
+
d.project.code ?? d.project.id.slice(0, 8),
|
|
555
|
+
d.dataset.slug ?? d.dataset.id.slice(0, 8),
|
|
556
|
+
d.batch_name ?? '—',
|
|
557
|
+
String(d.file_count),
|
|
558
|
+
(d.transferred_at ?? '').slice(0, 10),
|
|
559
|
+
d.destination_root ?? '—',
|
|
560
|
+
])
|
|
561
|
+
for (const line of table(['PROJECT', 'DATASET', 'BATCH', 'FILES', 'DELIVERED', 'LOCATION'], rows)) out(line)
|
|
562
|
+
if (j.next_cursor) out(`\n… more — re-run with --since ${j.next_cursor}`)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function cmdResolve() {
|
|
566
|
+
const path = args[1]
|
|
567
|
+
if (!path || path.startsWith('--')) die('Usage: myelin resolve <path|prefix|id>')
|
|
568
|
+
const j = expectOk(await api('GET', `/deliveries/resolve?path=${encodeURIComponent(path)}`), 'resolve')
|
|
569
|
+
emit(j)
|
|
570
|
+
out(`${j.resolved} · ${j.query}`)
|
|
571
|
+
if (j.project) out(` project ${j.project.code} — ${j.project.title}`)
|
|
572
|
+
if (j.dataset) out(` dataset ${j.dataset.name}${j.dataset.label ? ` (${j.dataset.label})` : ''}`)
|
|
573
|
+
if (j.delivery) {
|
|
574
|
+
out(` batch ${j.delivery.batch_name ?? j.delivery.batch_id} · #${j.delivery.sequence_number ?? '?'}`)
|
|
575
|
+
out(` validated ${j.delivery.validated_at ?? '—'} · delivered ${j.delivery.transferred_at ?? '—'}`)
|
|
576
|
+
if (j.delivery.manifest_path) out(` manifest ${j.delivery.manifest_path}`)
|
|
577
|
+
}
|
|
578
|
+
if (j.file) {
|
|
579
|
+
out(
|
|
580
|
+
` file ${j.file.filename} · ${j.file.size_bytes} bytes` +
|
|
581
|
+
(j.file.declared_checksum
|
|
582
|
+
? ` · ${j.file.declared_checksum_algorithm}:${j.file.declared_checksum}`
|
|
583
|
+
: ''),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ——— main ———
|
|
589
|
+
if (!command || command === 'help' || command === '--help') {
|
|
590
|
+
cmdHelp()
|
|
591
|
+
process.exit(0)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const commands = {
|
|
595
|
+
ping: cmdPing,
|
|
596
|
+
projects: cmdProjects,
|
|
597
|
+
datasets: cmdDatasets,
|
|
598
|
+
'sample-depth': cmdSampleDepth,
|
|
599
|
+
contract: cmdContract,
|
|
600
|
+
check: cmdCheck,
|
|
601
|
+
push: cmdPush,
|
|
602
|
+
status: cmdStatus,
|
|
603
|
+
sandbox: cmdSandbox,
|
|
604
|
+
deliveries: cmdDeliveries,
|
|
605
|
+
resolve: cmdResolve,
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge → API).')
|
|
609
|
+
const fn = commands[command]
|
|
610
|
+
if (!fn) die(`Unknown command "${command}" — run: myelin help`)
|
|
611
|
+
await fn()
|
|
612
|
+
} catch (err) {
|
|
613
|
+
// die() already printed and set the code; anything else is a real bug and
|
|
614
|
+
// deserves its stack.
|
|
615
|
+
if (!(err instanceof CliExit)) throw err
|
|
616
|
+
}
|