@elmundi/ship-cli 0.11.2 → 0.12.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 +255 -22
- package/bin/shipctl.mjs +11 -7
- package/lib/bootstrap/render.mjs +49 -0
- package/lib/commands/callback.mjs +457 -17
- package/lib/commands/config.mjs +1 -1
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/help.mjs +137 -77
- package/lib/commands/kickoff.mjs +3 -3
- package/lib/commands/knowledge.mjs +216 -18
- package/lib/commands/lanes.mjs +36 -11
- package/lib/commands/manifest-catalog.mjs +5 -5
- package/lib/commands/patterns.mjs +5 -5
- package/lib/commands/run.mjs +329 -89
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +83 -8
- package/lib/commands/trigger.mjs +200 -0
- package/lib/config/migrate.mjs +13 -5
- package/lib/config/schema.mjs +253 -2
- package/lib/config.mjs +3 -0
- package/lib/state/idempotency.mjs +1 -1
- package/lib/templates.mjs +3 -3
- package/package.json +2 -2
package/lib/config/schema.mjs
CHANGED
|
@@ -33,6 +33,22 @@ export const LANE_EVENT_TYPES = Object.freeze([
|
|
|
33
33
|
export const LANE_IDEMPOTENCY_STORES = Object.freeze(["file", "backend"]);
|
|
34
34
|
export const LANE_IDEMPOTENCY_RESET_ON = Object.freeze(["version-change", "manual"]);
|
|
35
35
|
|
|
36
|
+
/* RFC-0008 C3.2 — fan-out strategy for multi-pattern lanes.
|
|
37
|
+
*
|
|
38
|
+
* matrix — GitHub Actions matrix: one runner per pattern, parallel.
|
|
39
|
+
* sequential — Single runner, `shipctl run` iterates patterns in order.
|
|
40
|
+
* concurrent — Single runner, `shipctl run` spawns subprocesses in parallel.
|
|
41
|
+
*
|
|
42
|
+
* Meaningful only when ``patterns.length > 1``; single-pattern lanes ignore
|
|
43
|
+
* it (the linter warns if it's set on a single-pattern lane). Default:
|
|
44
|
+
* ``matrix``. */
|
|
45
|
+
export const LANE_FANOUT_MODES = Object.freeze([
|
|
46
|
+
"matrix",
|
|
47
|
+
"sequential",
|
|
48
|
+
"concurrent",
|
|
49
|
+
]);
|
|
50
|
+
export const LANE_FANOUT_DEFAULT = "matrix";
|
|
51
|
+
|
|
36
52
|
/* Lane ids travel into file paths (`.ship/state/<key>.json`), workflow
|
|
37
53
|
* file names (`.github/workflows/ship-<lane>.yml`), and env vars, so
|
|
38
54
|
* restrict them conservatively: ASCII lowercase, digits, dash, underscore. */
|
|
@@ -156,6 +172,7 @@ export function DEFAULT_CONFIG_V2() {
|
|
|
156
172
|
default: { provider: null },
|
|
157
173
|
overrides: {},
|
|
158
174
|
},
|
|
175
|
+
process: DEFAULT_PROCESS_CONFIG(),
|
|
159
176
|
lanes: {},
|
|
160
177
|
artifacts: v1.artifacts,
|
|
161
178
|
cache: v1.cache,
|
|
@@ -163,6 +180,28 @@ export function DEFAULT_CONFIG_V2() {
|
|
|
163
180
|
};
|
|
164
181
|
}
|
|
165
182
|
|
|
183
|
+
export function DEFAULT_PROCESS_CONFIG() {
|
|
184
|
+
return {
|
|
185
|
+
id: "development",
|
|
186
|
+
name: "Development Process",
|
|
187
|
+
primary: true,
|
|
188
|
+
states: [
|
|
189
|
+
{ id: "task_intake", name: "Intake", specialist: { id: "intake", name: "Intake specialist" }, layout: { x: 72, y: 170 } },
|
|
190
|
+
{ id: "ba_requirements", name: "Requirements", specialist: { id: "business_analyst", name: "Business analyst" }, layout: { x: 338, y: 170 } },
|
|
191
|
+
{ id: "dev_implementation", name: "Implementation", specialist: { id: "developer", name: "Developer" }, layout: { x: 604, y: 170 } },
|
|
192
|
+
{ id: "qa_manual", name: "Quality Review", specialist: { id: "qa_engineer", name: "QA engineer" }, layout: { x: 870, y: 170 } },
|
|
193
|
+
{ id: "pr_review", name: "Final Review", specialist: { id: "review_owner", name: "Review owner" }, layout: { x: 1136, y: 170 } },
|
|
194
|
+
],
|
|
195
|
+
transitions: [
|
|
196
|
+
{ from: "task_intake", to: "ba_requirements" },
|
|
197
|
+
{ from: "ba_requirements", to: "dev_implementation" },
|
|
198
|
+
{ from: "dev_implementation", to: "qa_manual" },
|
|
199
|
+
{ from: "qa_manual", to: "pr_review" },
|
|
200
|
+
],
|
|
201
|
+
routines: [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
166
205
|
/**
|
|
167
206
|
* Back-compat alias. Pre-existing callers expect DEFAULT_CONFIG() to
|
|
168
207
|
* return the current schema's shape; default to v2 so new installs
|
|
@@ -190,6 +229,7 @@ const KNOWN_TOP_LEVEL_V2 = new Set([
|
|
|
190
229
|
"api",
|
|
191
230
|
"stack",
|
|
192
231
|
"agent",
|
|
232
|
+
"process",
|
|
193
233
|
"lanes",
|
|
194
234
|
"artifacts",
|
|
195
235
|
"cache",
|
|
@@ -269,6 +309,105 @@ function validateSharedSections(obj, errors, warnings) {
|
|
|
269
309
|
* @param {string[]} errors
|
|
270
310
|
* @param {string[]} warnings
|
|
271
311
|
*/
|
|
312
|
+
function validateV2Process(obj, errors, warnings) {
|
|
313
|
+
const process = obj.process;
|
|
314
|
+
if (process === undefined) return;
|
|
315
|
+
if (!isPlainObject(process)) {
|
|
316
|
+
errors.push("process: must be an object");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
pushUnknownKeyWarnings(
|
|
320
|
+
process,
|
|
321
|
+
new Set(["id", "name", "primary", "states", "transitions", "routines"]),
|
|
322
|
+
"process",
|
|
323
|
+
warnings,
|
|
324
|
+
);
|
|
325
|
+
if (typeof process.id !== "string" || !process.id.trim()) {
|
|
326
|
+
errors.push("process.id: must be a non-empty string");
|
|
327
|
+
}
|
|
328
|
+
if (process.name !== undefined && (typeof process.name !== "string" || !process.name.trim())) {
|
|
329
|
+
errors.push("process.name: must be a non-empty string when set");
|
|
330
|
+
}
|
|
331
|
+
if (process.primary !== undefined && typeof process.primary !== "boolean") {
|
|
332
|
+
errors.push("process.primary: must be boolean when set");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!Array.isArray(process.states) || process.states.length < 1) {
|
|
336
|
+
errors.push("process.states: must contain at least one state");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const stateIds = new Set();
|
|
340
|
+
for (let i = 0; i < process.states.length; i += 1) {
|
|
341
|
+
const state = process.states[i];
|
|
342
|
+
const prefix = `process.states[${i}]`;
|
|
343
|
+
if (!isPlainObject(state)) {
|
|
344
|
+
errors.push(`${prefix}: must be an object`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
pushUnknownKeyWarnings(
|
|
348
|
+
state,
|
|
349
|
+
new Set(["id", "name", "specialist", "layout", "instructions", "triggers", "exit_conditions", "block_conditions"]),
|
|
350
|
+
prefix,
|
|
351
|
+
warnings,
|
|
352
|
+
);
|
|
353
|
+
if (typeof state.id !== "string" || !state.id.trim()) {
|
|
354
|
+
errors.push(`${prefix}.id: must be a non-empty string`);
|
|
355
|
+
} else if (stateIds.has(state.id)) {
|
|
356
|
+
errors.push(`${prefix}.id: duplicate state id ${JSON.stringify(state.id)}`);
|
|
357
|
+
} else {
|
|
358
|
+
stateIds.add(state.id);
|
|
359
|
+
}
|
|
360
|
+
if (state.name !== undefined && (typeof state.name !== "string" || !state.name.trim())) {
|
|
361
|
+
errors.push(`${prefix}.name: must be a non-empty string when set`);
|
|
362
|
+
}
|
|
363
|
+
if (state.instructions !== undefined && typeof state.instructions !== "string") {
|
|
364
|
+
errors.push(`${prefix}.instructions: must be a string when set`);
|
|
365
|
+
}
|
|
366
|
+
if (state.specialist !== undefined && !isPlainObject(state.specialist) && typeof state.specialist !== "string") {
|
|
367
|
+
errors.push(`${prefix}.specialist: must be an object or string when set`);
|
|
368
|
+
}
|
|
369
|
+
if (state.layout !== undefined) {
|
|
370
|
+
if (!isPlainObject(state.layout)) {
|
|
371
|
+
errors.push(`${prefix}.layout: must be an object when set`);
|
|
372
|
+
} else {
|
|
373
|
+
pushUnknownKeyWarnings(state.layout, new Set(["x", "y"]), `${prefix}.layout`, warnings);
|
|
374
|
+
if (typeof state.layout.x !== "number" || !Number.isFinite(state.layout.x)) {
|
|
375
|
+
errors.push(`${prefix}.layout.x: must be a finite number`);
|
|
376
|
+
}
|
|
377
|
+
if (typeof state.layout.y !== "number" || !Number.isFinite(state.layout.y)) {
|
|
378
|
+
errors.push(`${prefix}.layout.y: must be a finite number`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (process.transitions !== undefined) {
|
|
385
|
+
if (!Array.isArray(process.transitions)) {
|
|
386
|
+
errors.push("process.transitions: must be a list when set");
|
|
387
|
+
} else {
|
|
388
|
+
for (let i = 0; i < process.transitions.length; i += 1) {
|
|
389
|
+
const transition = process.transitions[i];
|
|
390
|
+
const prefix = `process.transitions[${i}]`;
|
|
391
|
+
if (!isPlainObject(transition)) {
|
|
392
|
+
errors.push(`${prefix}: must be an object`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
pushUnknownKeyWarnings(transition, new Set(["from", "to", "condition"]), prefix, warnings);
|
|
396
|
+
if (!stateIds.has(transition.from)) {
|
|
397
|
+
errors.push(`${prefix}.from: must reference an existing state id`);
|
|
398
|
+
}
|
|
399
|
+
if (!stateIds.has(transition.to)) {
|
|
400
|
+
errors.push(`${prefix}.to: must reference an existing state id`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (process.routines !== undefined && !Array.isArray(process.routines)) {
|
|
407
|
+
errors.push("process.routines: must be a list when set");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
272
411
|
function validateV2Lanes(obj, errors, warnings) {
|
|
273
412
|
const agent = obj.agent;
|
|
274
413
|
if (agent !== undefined) {
|
|
@@ -331,7 +470,9 @@ function validateV2Lanes(obj, errors, warnings) {
|
|
|
331
470
|
const KNOWN_LANE_COMMON = new Set([
|
|
332
471
|
"kind",
|
|
333
472
|
"pattern",
|
|
473
|
+
"patterns",
|
|
334
474
|
"pattern_version",
|
|
475
|
+
"fanout",
|
|
335
476
|
"permissions",
|
|
336
477
|
"runner",
|
|
337
478
|
"timeout_minutes",
|
|
@@ -366,8 +507,36 @@ function validateLane(laneId, lane, errors, warnings) {
|
|
|
366
507
|
`${prefix}.kind: must be one of ${LANE_KINDS.join("|")}; got ${JSON.stringify(lane.kind)}`,
|
|
367
508
|
);
|
|
368
509
|
}
|
|
369
|
-
|
|
370
|
-
|
|
510
|
+
// ``patterns: [ids]`` is the canonical multi-pattern form (RFC-0008 C3);
|
|
511
|
+
// ``pattern: <id>`` is kept as the single-pattern alias so existing
|
|
512
|
+
// configs keep working. Exactly one of the two must be present.
|
|
513
|
+
const hasPatterns = Array.isArray(lane.patterns);
|
|
514
|
+
const hasPattern = typeof lane.pattern === "string";
|
|
515
|
+
if (hasPatterns && hasPattern) {
|
|
516
|
+
errors.push(
|
|
517
|
+
`${prefix}: use either 'pattern' (single) or 'patterns' (list), not both`,
|
|
518
|
+
);
|
|
519
|
+
} else if (hasPatterns) {
|
|
520
|
+
if (lane.patterns.length < 1) {
|
|
521
|
+
errors.push(`${prefix}.patterns: must contain at least one pattern id`);
|
|
522
|
+
} else {
|
|
523
|
+
for (let i = 0; i < lane.patterns.length; i += 1) {
|
|
524
|
+
const p = lane.patterns[i];
|
|
525
|
+
if (typeof p !== "string" || !p.trim()) {
|
|
526
|
+
errors.push(
|
|
527
|
+
`${prefix}.patterns[${i}]: must be a non-empty pattern id string`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else if (hasPattern) {
|
|
533
|
+
if (!lane.pattern.trim()) {
|
|
534
|
+
errors.push(`${prefix}.pattern: must be a non-empty pattern id`);
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
errors.push(
|
|
538
|
+
`${prefix}: must declare 'pattern' (single) or 'patterns' (list)`,
|
|
539
|
+
);
|
|
371
540
|
}
|
|
372
541
|
if (
|
|
373
542
|
lane.pattern_version !== undefined &&
|
|
@@ -375,6 +544,21 @@ function validateLane(laneId, lane, errors, warnings) {
|
|
|
375
544
|
) {
|
|
376
545
|
errors.push(`${prefix}.pattern_version: must be a non-empty semver string when set`);
|
|
377
546
|
}
|
|
547
|
+
// RFC-0008 C3.2 — `fanout` picks how multi-pattern lanes execute.
|
|
548
|
+
// Single-pattern lanes ignore it (it's a no-op for them); we emit a
|
|
549
|
+
// warning rather than an error so schedule templates that set it
|
|
550
|
+
// blindly remain portable across single/multi-pattern use.
|
|
551
|
+
if (lane.fanout !== undefined) {
|
|
552
|
+
if (typeof lane.fanout !== "string" || !LANE_FANOUT_MODES.includes(lane.fanout)) {
|
|
553
|
+
errors.push(
|
|
554
|
+
`${prefix}.fanout: must be one of ${LANE_FANOUT_MODES.join("|")}; got ${JSON.stringify(lane.fanout)}`,
|
|
555
|
+
);
|
|
556
|
+
} else if (hasPattern || (hasPatterns && lane.patterns.length < 2)) {
|
|
557
|
+
warnings.push(
|
|
558
|
+
`${prefix}.fanout: ignored for single-pattern lanes (has no effect unless 'patterns' has ≥2 entries)`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
378
562
|
if (lane.permissions !== undefined && !isPlainObject(lane.permissions)) {
|
|
379
563
|
errors.push(`${prefix}.permissions: must be an object when set`);
|
|
380
564
|
}
|
|
@@ -499,6 +683,7 @@ export function validateConfig(obj) {
|
|
|
499
683
|
|
|
500
684
|
validateSharedSections(obj, errors, warnings);
|
|
501
685
|
if (isV2) {
|
|
686
|
+
validateV2Process(obj, errors, warnings);
|
|
502
687
|
validateV2Lanes(obj, errors, warnings);
|
|
503
688
|
}
|
|
504
689
|
|
|
@@ -648,3 +833,69 @@ export function validateConfig(obj) {
|
|
|
648
833
|
if (errors.length) return { ok: false, errors, warnings };
|
|
649
834
|
return { ok: true, config: obj, warnings };
|
|
650
835
|
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Return the canonical list of pattern ids for a lane.
|
|
839
|
+
*
|
|
840
|
+
* Accepts both the canonical ``patterns: [ids]`` (RFC-0008) and the
|
|
841
|
+
* legacy ``pattern: <id>`` single-string alias. Use this helper
|
|
842
|
+
* everywhere that reads ``lane.pattern`` / ``lane.patterns`` so the
|
|
843
|
+
* call-site never has to branch on the two shapes.
|
|
844
|
+
*
|
|
845
|
+
* For lanes that declared neither key (e.g. malformed config that
|
|
846
|
+
* slipped past validateConfig), returns an empty list rather than
|
|
847
|
+
* throwing — the caller already surfaced the validation error.
|
|
848
|
+
*
|
|
849
|
+
* @param {any} lane
|
|
850
|
+
* @returns {string[]}
|
|
851
|
+
*/
|
|
852
|
+
export function lanePatterns(lane) {
|
|
853
|
+
if (!lane || typeof lane !== "object") return [];
|
|
854
|
+
if (Array.isArray(lane.patterns)) {
|
|
855
|
+
return lane.patterns
|
|
856
|
+
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
|
857
|
+
.filter(Boolean);
|
|
858
|
+
}
|
|
859
|
+
if (typeof lane.pattern === "string" && lane.pattern.trim()) {
|
|
860
|
+
return [lane.pattern.trim()];
|
|
861
|
+
}
|
|
862
|
+
return [];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* The "primary" pattern id for a lane. Shorthand for ``lanePatterns(lane)[0]``
|
|
867
|
+
* with an explicit null for lanes that have no pattern.
|
|
868
|
+
*
|
|
869
|
+
* Intended for call-sites that currently only consume a single pattern
|
|
870
|
+
* (shipctl run, the renderer, the dashboard). They read this and will
|
|
871
|
+
* continue to work until multi-pattern execution lands (C3.2); until
|
|
872
|
+
* then, a lane with ``patterns.length > 1`` is rejected upstream by
|
|
873
|
+
* the executor with a clear error.
|
|
874
|
+
*
|
|
875
|
+
* @param {any} lane
|
|
876
|
+
* @returns {string | null}
|
|
877
|
+
*/
|
|
878
|
+
export function lanePrimaryPattern(lane) {
|
|
879
|
+
const list = lanePatterns(lane);
|
|
880
|
+
return list.length ? list[0] : null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Resolve the effective fan-out mode for a lane.
|
|
885
|
+
*
|
|
886
|
+
* Returns ``LANE_FANOUT_DEFAULT`` (``matrix``) when the lane doesn't
|
|
887
|
+
* declare one or has at most one pattern (in which case the concept
|
|
888
|
+
* doesn't apply but we still want a deterministic value for downstream
|
|
889
|
+
* consumers). Unknown / invalid values fall back to the default; the
|
|
890
|
+
* validator already flags them as errors at config-load time.
|
|
891
|
+
*
|
|
892
|
+
* @param {any} lane
|
|
893
|
+
* @returns {"matrix" | "sequential" | "concurrent"}
|
|
894
|
+
*/
|
|
895
|
+
export function laneFanout(lane) {
|
|
896
|
+
if (!lane || typeof lane !== "object") return LANE_FANOUT_DEFAULT;
|
|
897
|
+
if (typeof lane.fanout === "string" && LANE_FANOUT_MODES.includes(lane.fanout)) {
|
|
898
|
+
return lane.fanout;
|
|
899
|
+
}
|
|
900
|
+
return LANE_FANOUT_DEFAULT;
|
|
901
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -8,6 +8,7 @@ export function extractGlobalArgv(argv) {
|
|
|
8
8
|
baseUrl: (
|
|
9
9
|
process.env.SHIP_API_BASE || "https://ship.elmundi.com/api/methodology"
|
|
10
10
|
).replace(/\/$/, ""),
|
|
11
|
+
baseUrlSource: process.env.SHIP_API_BASE ? "env" : "default",
|
|
11
12
|
json: false,
|
|
12
13
|
yes: false,
|
|
13
14
|
force: false,
|
|
@@ -39,10 +40,12 @@ export function extractGlobalArgv(argv) {
|
|
|
39
40
|
if (a === "--base-url" && copy[1]) {
|
|
40
41
|
copy.shift();
|
|
41
42
|
out.baseUrl = String(copy.shift()).replace(/\/$/, "");
|
|
43
|
+
out.baseUrlSource = "flag";
|
|
42
44
|
continue;
|
|
43
45
|
}
|
|
44
46
|
if (a.startsWith("--base-url=")) {
|
|
45
47
|
out.baseUrl = a.slice("--base-url=".length).replace(/\/$/, "");
|
|
48
|
+
out.baseUrlSource = "flag";
|
|
46
49
|
copy.shift();
|
|
47
50
|
continue;
|
|
48
51
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* {
|
|
13
13
|
* "version": 1,
|
|
14
14
|
* "lane": "seed_knowledge_starters",
|
|
15
|
-
* "pattern_id": "seed-knowledge
|
|
15
|
+
* "pattern_id": "onboard-seed-knowledge",
|
|
16
16
|
* "pattern_sha256": "…",
|
|
17
17
|
* "pattern_version": "1.0.0",
|
|
18
18
|
* "completed_at": "2026-04-21T14:20:00Z",
|
package/lib/templates.mjs
CHANGED
|
@@ -30,8 +30,8 @@ Agent protocol (must follow before applying an artifact):
|
|
|
30
30
|
|
|
31
31
|
\`\`\`bash
|
|
32
32
|
shipctl pattern list
|
|
33
|
-
shipctl pattern show
|
|
34
|
-
shipctl pattern fetch
|
|
33
|
+
shipctl pattern show role-developer # resolves latest or pin
|
|
34
|
+
shipctl pattern fetch role-developer --version 1.4.2
|
|
35
35
|
shipctl search "release gates and qa split" --top-k 8
|
|
36
36
|
shipctl docs fetch documentation/adoption/delivery-quality-and-release-process.md
|
|
37
37
|
shipctl sync # reconcile .ship/cache/
|
|
@@ -41,7 +41,7 @@ shipctl sync # reconcile .ship/cache/
|
|
|
41
41
|
|
|
42
42
|
\`\`\`bash
|
|
43
43
|
curl -sS -X POST "${baseUrl}/fetch" -H "Content-Type: application/json" \\
|
|
44
|
-
-d '{"kind":"pattern","id":"
|
|
44
|
+
-d '{"kind":"pattern","id":"role-developer"}'
|
|
45
45
|
curl -sS "${baseUrl}/patterns"
|
|
46
46
|
\`\`\`
|
|
47
47
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elmundi/ship-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Ship CLI
|
|
5
|
+
"description": "Ship CLI: bootstrap a repo, sync the Plays catalog, run Automations, report Runs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Denys Kuzin",
|
|
8
8
|
"repository": {
|