@yadurajfleetos/cli 0.1.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 +86 -0
- package/dist/api.js +97 -0
- package/dist/args.js +27 -0
- package/dist/commands/alerts.js +53 -0
- package/dist/commands/auth.js +101 -0
- package/dist/commands/config.js +51 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/down.js +50 -0
- package/dist/commands/index.js +34 -0
- package/dist/commands/nodes.js +76 -0
- package/dist/commands/open.js +65 -0
- package/dist/commands/services.js +365 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/up.js +110 -0
- package/dist/config.js +35 -0
- package/dist/detect.js +277 -0
- package/dist/index.js +136 -0
- package/dist/mark.js +102 -0
- package/dist/render.js +159 -0
- package/dist/ui.js +210 -0
- package/package.json +40 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** Terminal rendering. Colour is dropped when output is piped or NO_COLOR is set. */
|
|
2
|
+
const useColour = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
3
|
+
/**
|
|
4
|
+
* 0 none, 1 the sixteen ANSI colours, 2 twenty-four bit. The brand palette only
|
|
5
|
+
* survives intact at level 2; below that every shade collapses onto the nearest
|
|
6
|
+
* basic colour, which is why nothing here encodes meaning in shade alone.
|
|
7
|
+
*/
|
|
8
|
+
export const colourDepth = !useColour
|
|
9
|
+
? 0
|
|
10
|
+
: /truecolor|24bit/i.test(process.env.COLORTERM ?? '')
|
|
11
|
+
? 2
|
|
12
|
+
: 1;
|
|
13
|
+
const ESC = '\x1b[';
|
|
14
|
+
const wrap = (code) => (s) => (useColour ? `${ESC}${code}m${s}${ESC}0m` : s);
|
|
15
|
+
/** Truecolour when the terminal has it, otherwise the supplied fallback. */
|
|
16
|
+
export const rgb = (r, g, b, fallback = (s) => s) => (s) => colourDepth === 2 ? `${ESC}38;2;${r};${g};${b}m${s}${ESC}0m` : fallback(s);
|
|
17
|
+
export const c = {
|
|
18
|
+
dim: wrap('2'),
|
|
19
|
+
bold: wrap('1'),
|
|
20
|
+
green: wrap('32'),
|
|
21
|
+
yellow: wrap('33'),
|
|
22
|
+
red: wrap('31'),
|
|
23
|
+
cyan: wrap('36'),
|
|
24
|
+
/** The one accent from the marketing site, reserved for live things. */
|
|
25
|
+
signal: rgb(0x3f, 0xe0, 0x8b, wrap('32')),
|
|
26
|
+
grey: rgb(0x6b, 0x72, 0x80, wrap('2')),
|
|
27
|
+
};
|
|
28
|
+
export const cursor = {
|
|
29
|
+
// Cursor controls are terminal capabilities, not colour capabilities. The
|
|
30
|
+
// progress UI writes to stderr, so stdout may be piped to jq while stderr is
|
|
31
|
+
// still an interactive terminal that needs its cursor restored.
|
|
32
|
+
hide: () => `${ESC}?25l`,
|
|
33
|
+
show: () => `${ESC}?25h`,
|
|
34
|
+
up: (n) => `${ESC}${n}A`,
|
|
35
|
+
clearLine: () => `\r${ESC}2K`,
|
|
36
|
+
clearBelow: () => `${ESC}0J`,
|
|
37
|
+
};
|
|
38
|
+
export const statusColour = (status) => {
|
|
39
|
+
switch (status) {
|
|
40
|
+
case 'online':
|
|
41
|
+
case 'running':
|
|
42
|
+
return c.green(status);
|
|
43
|
+
case 'offline':
|
|
44
|
+
case 'failed':
|
|
45
|
+
return c.red(status);
|
|
46
|
+
case 'cordoned':
|
|
47
|
+
case 'draining':
|
|
48
|
+
case 'deploying':
|
|
49
|
+
case 'pinned_unavailable':
|
|
50
|
+
return c.yellow(status);
|
|
51
|
+
default:
|
|
52
|
+
return c.dim(status);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Column widths are measured on the visible text, not the escaped string —
|
|
57
|
+
* otherwise colour codes count toward the width and every column drifts.
|
|
58
|
+
*/
|
|
59
|
+
// SGR is what Fleet emits today, but accept all CSI sequences so a value that
|
|
60
|
+
// arrives already decorated by a caller cannot make width accounting drift.
|
|
61
|
+
// OSC covers terminal hyperlinks, which are zero-width too.
|
|
62
|
+
const ANSI = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
|
|
63
|
+
const isZeroWidth = (code) => (code >= 0x0300 && code <= 0x036f) ||
|
|
64
|
+
(code >= 0x1ab0 && code <= 0x1aff) ||
|
|
65
|
+
(code >= 0x1dc0 && code <= 0x1dff) ||
|
|
66
|
+
(code >= 0x20d0 && code <= 0x20ff) ||
|
|
67
|
+
(code >= 0xfe00 && code <= 0xfe0f) ||
|
|
68
|
+
(code >= 0xfe20 && code <= 0xfe2f) ||
|
|
69
|
+
code === 0x200d;
|
|
70
|
+
/** A pragmatic terminal-cell width for the CLI's labels and progress lines. */
|
|
71
|
+
const cellWidth = (grapheme) => {
|
|
72
|
+
const points = [...grapheme].map((char) => char.codePointAt(0));
|
|
73
|
+
if (!points.length || points.every(isZeroWidth))
|
|
74
|
+
return 0;
|
|
75
|
+
// Emoji presentation and common wide East Asian ranges occupy two cells in
|
|
76
|
+
// mainstream terminals. A grapheme stays one unit here, so ZWJ emoji do not
|
|
77
|
+
// accidentally count once per constituent code point.
|
|
78
|
+
if (points.some((code) => code >= 0x1f000 ||
|
|
79
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
80
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
81
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
82
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
83
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
84
|
+
(code >= 0xffe0 && code <= 0xffe6)))
|
|
85
|
+
return 2;
|
|
86
|
+
return 1;
|
|
87
|
+
};
|
|
88
|
+
const graphemes = (text) => {
|
|
89
|
+
// Segmenter prevents a truncation point from splitting an emoji, accent, or
|
|
90
|
+
// other user-visible character. The fallback remains correct enough on old
|
|
91
|
+
// Node versions: it only loses the nicer grapheme boundary.
|
|
92
|
+
const Segmenter = Intl.Segmenter;
|
|
93
|
+
return Segmenter
|
|
94
|
+
? [...new Segmenter().segment(text)].map((part) => part.segment)
|
|
95
|
+
: [...text];
|
|
96
|
+
};
|
|
97
|
+
const visibleLength = (s) => graphemes(s.replace(ANSI, '')).reduce((width, grapheme) => width + cellWidth(grapheme), 0);
|
|
98
|
+
export function table(headers, rows) {
|
|
99
|
+
// Printing a header over nothing looks like a bug; callers handle the
|
|
100
|
+
// empty case with a sentence instead.
|
|
101
|
+
if (!rows.length)
|
|
102
|
+
return '';
|
|
103
|
+
const widths = headers.map((h, i) => Math.max(visibleLength(h), ...rows.map((r) => visibleLength(r[i] ?? ''))));
|
|
104
|
+
const pad = (s, width) => s + ' '.repeat(Math.max(0, width - visibleLength(s)));
|
|
105
|
+
const head = headers.map((h, i) => c.dim(pad(h.toUpperCase(), widths[i]))).join(' ');
|
|
106
|
+
const body = rows.map((r) => r.map((cell, i) => pad(cell ?? '', widths[i])).join(' '));
|
|
107
|
+
return [head, ...body].join('\n');
|
|
108
|
+
}
|
|
109
|
+
export function keyValues(pairs) {
|
|
110
|
+
const width = Math.max(...pairs.map(([k]) => k.length));
|
|
111
|
+
return pairs.map(([k, v]) => `${c.dim(k.padEnd(width))} ${v}`).join('\n');
|
|
112
|
+
}
|
|
113
|
+
/** Handles both directions: a heartbeat in the past, a token expiring ahead. */
|
|
114
|
+
export function relativeTime(iso) {
|
|
115
|
+
if (!iso)
|
|
116
|
+
return 'never';
|
|
117
|
+
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
|
118
|
+
const magnitude = Math.abs(seconds);
|
|
119
|
+
const suffix = seconds >= 0 ? 'ago' : 'from now';
|
|
120
|
+
if (magnitude < 60)
|
|
121
|
+
return `${magnitude}s ${suffix}`;
|
|
122
|
+
if (magnitude < 3600)
|
|
123
|
+
return `${Math.round(magnitude / 60)}m ${suffix}`;
|
|
124
|
+
if (magnitude < 86400)
|
|
125
|
+
return `${Math.round(magnitude / 3600)}h ${suffix}`;
|
|
126
|
+
return `${Math.round(magnitude / 86400)}d ${suffix}`;
|
|
127
|
+
}
|
|
128
|
+
export const mb = (value) => value >= 1024 ? `${(value / 1024).toFixed(1)}GB` : `${value}MB`;
|
|
129
|
+
/**
|
|
130
|
+
* Cut to a visible width, stepping over colour codes rather than counting them.
|
|
131
|
+
* Anything that redraws in place depends on this: a line that wraps occupies two
|
|
132
|
+
* terminal rows, and the cursor arithmetic above it silently goes wrong.
|
|
133
|
+
*/
|
|
134
|
+
export function truncate(text, width) {
|
|
135
|
+
const limit = Math.max(1, width);
|
|
136
|
+
if (visibleLength(text) <= limit)
|
|
137
|
+
return text;
|
|
138
|
+
let out = '';
|
|
139
|
+
let visible = 0;
|
|
140
|
+
let hasSgr = false;
|
|
141
|
+
for (let i = 0; i < text.length; i++) {
|
|
142
|
+
const escape = text.slice(i).match(/^\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/);
|
|
143
|
+
if (escape) {
|
|
144
|
+
out += escape[0];
|
|
145
|
+
hasSgr ||= /^\x1b\[[0-9;]*m$/.test(escape[0]);
|
|
146
|
+
i += escape[0].length - 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const [grapheme] = graphemes(text.slice(i));
|
|
150
|
+
const cells = cellWidth(grapheme);
|
|
151
|
+
if (visible + cells > limit - 1)
|
|
152
|
+
break;
|
|
153
|
+
out += grapheme;
|
|
154
|
+
i += grapheme.length - 1;
|
|
155
|
+
visible += cells;
|
|
156
|
+
}
|
|
157
|
+
return `${out}…${hasSgr ? `${ESC}0m` : ''}`;
|
|
158
|
+
}
|
|
159
|
+
export { visibleLength };
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress reporting.
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape everything here. Animation goes to stderr, so `--json` on
|
|
5
|
+
* stdout stays pipeable into jq. And every animated form has a plain-line
|
|
6
|
+
* fallback, so output captured by CI or a log file reads as a transcript rather
|
|
7
|
+
* than as a smear of cursor escapes.
|
|
8
|
+
*/
|
|
9
|
+
import { c, cursor, truncate, visibleLength } from './render.js';
|
|
10
|
+
import { MARK_HEIGHT, markFrame, PEER_COUNT } from './mark.js';
|
|
11
|
+
const err = process.stderr;
|
|
12
|
+
/** `columns` reads 0 on some pseudo-terminals, so `??` is not enough. */
|
|
13
|
+
const width = () => Math.max(1, (err.columns || process.stdout.columns || 80) - 1);
|
|
14
|
+
/** Animate only where it can be erased again. */
|
|
15
|
+
export const animated = () => Boolean(err.isTTY) && !process.env.CI && !process.env.FLEET_NO_ANIMATION;
|
|
16
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
17
|
+
const TICK = 80;
|
|
18
|
+
export const glyph = {
|
|
19
|
+
ok: c.signal('✔'),
|
|
20
|
+
fail: c.red('✖'),
|
|
21
|
+
warn: c.yellow('▲'),
|
|
22
|
+
info: c.cyan('›'),
|
|
23
|
+
pending: c.dim('·'),
|
|
24
|
+
};
|
|
25
|
+
/** Elapsed time, shown only once it is long enough to be worth knowing. */
|
|
26
|
+
const elapsed = (startedAt) => {
|
|
27
|
+
const seconds = (Date.now() - startedAt) / 1000;
|
|
28
|
+
return seconds < 2 ? '' : c.dim(` ${seconds.toFixed(seconds < 10 ? 1 : 0)}s`);
|
|
29
|
+
};
|
|
30
|
+
let restoreCursorHooked = false;
|
|
31
|
+
function hookCursorRestore() {
|
|
32
|
+
if (restoreCursorHooked)
|
|
33
|
+
return;
|
|
34
|
+
restoreCursorHooked = true;
|
|
35
|
+
// A spinner interrupted by ^C must not leave the cursor hidden in the shell.
|
|
36
|
+
const restore = () => err.write(cursor.show());
|
|
37
|
+
process.on('exit', restore);
|
|
38
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
39
|
+
process.on(signal, () => {
|
|
40
|
+
restore();
|
|
41
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function spinner(label) {
|
|
46
|
+
const startedAt = Date.now();
|
|
47
|
+
let text = label;
|
|
48
|
+
let hintLines = [];
|
|
49
|
+
let frame = 0;
|
|
50
|
+
let timer;
|
|
51
|
+
let done = false;
|
|
52
|
+
if (!animated()) {
|
|
53
|
+
err.write(`${label}…\n`);
|
|
54
|
+
return {
|
|
55
|
+
update(next) {
|
|
56
|
+
text = next;
|
|
57
|
+
err.write(`${next}…\n`);
|
|
58
|
+
},
|
|
59
|
+
hints(lines) {
|
|
60
|
+
hintLines = lines;
|
|
61
|
+
},
|
|
62
|
+
note: (line) => err.write(`${line}\n`),
|
|
63
|
+
succeed: (final) => err.write(`${final ?? text} — done\n`),
|
|
64
|
+
fail: (final) => err.write(`${final ?? text} — failed\n`),
|
|
65
|
+
stop: () => { },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
hookCursorRestore();
|
|
69
|
+
err.write(cursor.hide());
|
|
70
|
+
const clear = () => err.write(cursor.clearLine());
|
|
71
|
+
const draw = () => {
|
|
72
|
+
// A hint every third of a spinner cycle: long enough to read, short enough
|
|
73
|
+
// that the line is visibly alive during a multi-minute build.
|
|
74
|
+
const hint = hintLines.length
|
|
75
|
+
? hintLines[Math.floor((Date.now() - startedAt) / 3200) % hintLines.length]
|
|
76
|
+
: undefined;
|
|
77
|
+
clear();
|
|
78
|
+
err.write(truncate(`${c.signal(FRAMES[frame % FRAMES.length])} ${text}${elapsed(startedAt)}` +
|
|
79
|
+
(hint ? c.dim(` ${hint}`) : ''), width()));
|
|
80
|
+
frame++;
|
|
81
|
+
};
|
|
82
|
+
draw();
|
|
83
|
+
timer = setInterval(draw, TICK);
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
const settle = (mark, final) => {
|
|
86
|
+
if (done)
|
|
87
|
+
return;
|
|
88
|
+
done = true;
|
|
89
|
+
clearInterval(timer);
|
|
90
|
+
clear();
|
|
91
|
+
err.write(`${mark} ${final ?? text}${elapsed(startedAt)}\n${cursor.show()}`);
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
update: (next) => {
|
|
95
|
+
text = next;
|
|
96
|
+
draw();
|
|
97
|
+
},
|
|
98
|
+
hints: (lines) => {
|
|
99
|
+
hintLines = lines;
|
|
100
|
+
},
|
|
101
|
+
note: (line) => {
|
|
102
|
+
clear();
|
|
103
|
+
err.write(`${line}\n`);
|
|
104
|
+
draw();
|
|
105
|
+
},
|
|
106
|
+
succeed: (final) => settle(glyph.ok, final),
|
|
107
|
+
fail: (final) => settle(glyph.fail, final),
|
|
108
|
+
stop: () => {
|
|
109
|
+
if (done)
|
|
110
|
+
return;
|
|
111
|
+
done = true;
|
|
112
|
+
clearInterval(timer);
|
|
113
|
+
clear();
|
|
114
|
+
err.write(cursor.show());
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Run work under a spinner, settling it correctly on either outcome. */
|
|
119
|
+
export async function task(label, run, opts = {}) {
|
|
120
|
+
const s = spinner(label);
|
|
121
|
+
if (opts.hints)
|
|
122
|
+
s.hints(opts.hints);
|
|
123
|
+
try {
|
|
124
|
+
const value = await run(s);
|
|
125
|
+
s.succeed(opts.done?.(value));
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
s.fail();
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The full-mark loading screen: the mesh pulses above a status line for the
|
|
135
|
+
* duration of the work. Reserved for the operations that genuinely take minutes
|
|
136
|
+
* — using it for a fast request would just add latency to look busy.
|
|
137
|
+
*/
|
|
138
|
+
export async function splash(label, run, opts = {}) {
|
|
139
|
+
const rows = process.stdout.rows ?? 24;
|
|
140
|
+
// Not enough room to redraw in place means the frames would scroll and stack.
|
|
141
|
+
if (!animated() || rows < MARK_HEIGHT + 4) {
|
|
142
|
+
return task(label, (s) => run(s), opts);
|
|
143
|
+
}
|
|
144
|
+
const startedAt = Date.now();
|
|
145
|
+
let text = label;
|
|
146
|
+
let hintLines = opts.hints ?? [];
|
|
147
|
+
let phase = 0;
|
|
148
|
+
let painted = 0;
|
|
149
|
+
hookCursorRestore();
|
|
150
|
+
err.write(cursor.hide());
|
|
151
|
+
const draw = () => {
|
|
152
|
+
if (painted)
|
|
153
|
+
err.write(cursor.up(painted) + '\r' + cursor.clearBelow());
|
|
154
|
+
const hint = hintLines.length
|
|
155
|
+
? hintLines[Math.floor((Date.now() - startedAt) / 3200) % hintLines.length]
|
|
156
|
+
: '';
|
|
157
|
+
const lines = [
|
|
158
|
+
...markFrame(phase).map((line) => ` ${line}`),
|
|
159
|
+
'',
|
|
160
|
+
` ${c.signal(FRAMES[Math.floor(phase * 2) % FRAMES.length])} ${text}${elapsed(startedAt)}`,
|
|
161
|
+
hint ? ` ${c.dim(hint)}` : '',
|
|
162
|
+
];
|
|
163
|
+
err.write(lines.map((line) => truncate(line, width())).join('\n') + '\n');
|
|
164
|
+
painted = lines.length;
|
|
165
|
+
// One peer every four ticks: slow enough to follow the pulse around.
|
|
166
|
+
phase = (phase + 0.25) % PEER_COUNT;
|
|
167
|
+
};
|
|
168
|
+
draw();
|
|
169
|
+
const timer = setInterval(draw, TICK);
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
const teardown = () => {
|
|
172
|
+
clearInterval(timer);
|
|
173
|
+
if (painted)
|
|
174
|
+
err.write(cursor.up(painted) + '\r' + cursor.clearBelow());
|
|
175
|
+
painted = 0;
|
|
176
|
+
err.write(cursor.show());
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
const value = await run({
|
|
180
|
+
update: (next) => {
|
|
181
|
+
text = next;
|
|
182
|
+
},
|
|
183
|
+
hints: (lines) => {
|
|
184
|
+
hintLines = lines;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
teardown();
|
|
188
|
+
err.write(`${glyph.ok} ${opts.done?.(value) ?? text}${elapsed(startedAt)}\n`);
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
teardown();
|
|
193
|
+
err.write(`${glyph.fail} ${text}${elapsed(startedAt)}\n`);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** A titled rule, for separating sections of a long report. */
|
|
198
|
+
export function rule(label) {
|
|
199
|
+
const width = Math.min(process.stdout.columns ?? 80, 72);
|
|
200
|
+
if (!label)
|
|
201
|
+
return c.dim('─'.repeat(width));
|
|
202
|
+
const line = '─'.repeat(Math.max(0, width - visibleLength(label) - 3));
|
|
203
|
+
return `${c.dim('──')} ${c.bold(label)} ${c.dim(line)}`;
|
|
204
|
+
}
|
|
205
|
+
/** A horizontal meter. Used for headroom, where the shape matters more than the number. */
|
|
206
|
+
export function bar(fraction, width = 12) {
|
|
207
|
+
const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width);
|
|
208
|
+
const colour = fraction > 0.85 ? c.red : fraction > 0.65 ? c.yellow : c.signal;
|
|
209
|
+
return colour('█'.repeat(filled)) + c.dim('░'.repeat(width - filled));
|
|
210
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yadurajfleetos/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"fleet": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "node --test --import tsx tests/*.test.ts"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"yaml": "^2.9.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^26.2.0",
|
|
29
|
+
"tsx": "^4.23.12",
|
|
30
|
+
"typescript": "^7.0.2"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"fleet",
|
|
34
|
+
"orchestration",
|
|
35
|
+
"deployment",
|
|
36
|
+
"homelab",
|
|
37
|
+
"docker",
|
|
38
|
+
"self-hosted"
|
|
39
|
+
]
|
|
40
|
+
}
|