@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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet up — one command that takes any repo from zero to a live HTTPS URL.
|
|
3
|
+
*
|
|
4
|
+
* Chains: detect → init → apply → deploy → wait → URL.
|
|
5
|
+
*
|
|
6
|
+
* Every step re-uses the existing CLI primitives (`task`, `splash`, `request`)
|
|
7
|
+
* so the experience is consistent with the granular commands; this just removes
|
|
8
|
+
* the operator from the loop between them.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { request, requireFleet, CliError, EXIT } from '../api.js';
|
|
13
|
+
import { c } from '../render.js';
|
|
14
|
+
import { task, splash, glyph } from '../ui.js';
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
export const upCommand = {
|
|
17
|
+
async run(args, flags) {
|
|
18
|
+
const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
|
|
19
|
+
const manifestPath = 'fleet.yaml';
|
|
20
|
+
// ── Step 1: scaffold if needed ────────────────────────────────────
|
|
21
|
+
let needsApply = false;
|
|
22
|
+
try {
|
|
23
|
+
await access(manifestPath);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// No fleet.yaml — run the smart init inline.
|
|
27
|
+
const { detect, manifestTemplate } = await import('../detect.js');
|
|
28
|
+
const d = await task('detecting project framework', async () => detect());
|
|
29
|
+
const name = (typeof flags.name === 'string' ? flags.name : '') ||
|
|
30
|
+
args[0] ||
|
|
31
|
+
process.cwd().split('/').pop()?.toLowerCase().replace(/[^a-z0-9-]+/g, '-') ||
|
|
32
|
+
'app';
|
|
33
|
+
// Write Dockerfile if generated
|
|
34
|
+
if (d.dockerfile) {
|
|
35
|
+
await writeFile(join(process.cwd(), 'Dockerfile'), d.dockerfile);
|
|
36
|
+
console.log(`${glyph.ok} ${c.green('created')} Dockerfile ${c.dim(`(${d.label}, port ${d.port})`)}`);
|
|
37
|
+
}
|
|
38
|
+
// Write manifest
|
|
39
|
+
await writeFile(manifestPath, manifestTemplate(name, d));
|
|
40
|
+
console.log(`${glyph.ok} ${c.green('created')} ${manifestPath} ${c.dim(`(${d.label})`)}`);
|
|
41
|
+
needsApply = true;
|
|
42
|
+
}
|
|
43
|
+
// ── Step 2: read and apply the manifest ───────────────────────────
|
|
44
|
+
const manifest = await readFile(manifestPath, 'utf8');
|
|
45
|
+
const applyResult = await task(`applying ${manifestPath}`, async () => (await request('POST', `/fleets/${fleetId}/services`, { body: { manifest } })).body, {
|
|
46
|
+
done: (b) => b.created.length || b.updated.length
|
|
47
|
+
? `applied ${b.created.length + b.updated.length} service(s)`
|
|
48
|
+
: 'no changes',
|
|
49
|
+
});
|
|
50
|
+
for (const w of applyResult.warnings) {
|
|
51
|
+
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
|
52
|
+
}
|
|
53
|
+
// ── Step 3: pick the target service ───────────────────────────────
|
|
54
|
+
const serviceName = args[0] || applyResult.created[0] || applyResult.updated[0];
|
|
55
|
+
if (!serviceName) {
|
|
56
|
+
throw new CliError('Could not determine which service to deploy. Pass the name: fleet up <service>', EXIT.usage);
|
|
57
|
+
}
|
|
58
|
+
// Look it up
|
|
59
|
+
const { body: listBody } = await request('GET', `/fleets/${fleetId}/services`);
|
|
60
|
+
const service = listBody.services.find((s) => s.name === serviceName || s.id === serviceName);
|
|
61
|
+
if (!service) {
|
|
62
|
+
throw new CliError(`Service "${serviceName}" not found after apply. Known: ${listBody.services.map((s) => s.name).join(', ')}`, EXIT.usage);
|
|
63
|
+
}
|
|
64
|
+
// ── Step 4: deploy ────────────────────────────────────────────────
|
|
65
|
+
const gitSha = typeof flags.sha === 'string' ? flags.sha : undefined;
|
|
66
|
+
const deployResult = await splash(`deploying ${c.bold(service.name)}`, async () => (await request('POST', `/services/${service.id}/deploy`, { body: { gitSha } })).body, {
|
|
67
|
+
hints: [
|
|
68
|
+
'scoring every online node on headroom, reliability and load',
|
|
69
|
+
'building for every architecture an eligible node runs',
|
|
70
|
+
'the first multi-arch build is the slow one; layers cache after it',
|
|
71
|
+
'pushing the image to the fleet registry',
|
|
72
|
+
],
|
|
73
|
+
done: (b) => `built and scheduled onto ${c.bold(b.placedOn.name)} ${c.dim(`score ${b.score?.toFixed(3)}`)}`,
|
|
74
|
+
});
|
|
75
|
+
for (const w of deployResult.warnings ?? []) {
|
|
76
|
+
console.log(`${glyph.warn} ${c.yellow('warning')} ${w}`);
|
|
77
|
+
}
|
|
78
|
+
// ── Step 5: wait for healthy ──────────────────────────────────────
|
|
79
|
+
if (!flags['no-wait']) {
|
|
80
|
+
await task(`waiting for ${c.bold(service.name)} to come up`, async (s) => {
|
|
81
|
+
s.hints([
|
|
82
|
+
'the agent picks up desired state on its next poll',
|
|
83
|
+
"a cold image pull takes as long as the node's uplink does",
|
|
84
|
+
'this clears once the agent reports the container running',
|
|
85
|
+
]);
|
|
86
|
+
const deadline = Date.now() + 180_000;
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
const { body } = await request('GET', `/fleets/${fleetId}/services`);
|
|
89
|
+
const current = body.services.find((s) => s.id === service.id)?.current;
|
|
90
|
+
if (current?.status === 'running')
|
|
91
|
+
return;
|
|
92
|
+
if (current?.status === 'failed') {
|
|
93
|
+
throw new CliError(`"${service.name}" did not start. \`fleet deployments ${service.name}\` has the reason.`, EXIT.healthCheckFailed);
|
|
94
|
+
}
|
|
95
|
+
await sleep(2000);
|
|
96
|
+
}
|
|
97
|
+
throw new CliError(`"${service.name}" was scheduled but has not reported running.`, EXIT.healthCheckFailed);
|
|
98
|
+
}, { done: () => `${c.bold(service.name)} is running` });
|
|
99
|
+
}
|
|
100
|
+
// ── Step 6: print the URL ─────────────────────────────────────────
|
|
101
|
+
const url = deployResult.url ?? service.domain ?? service.hostname;
|
|
102
|
+
if (url) {
|
|
103
|
+
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
104
|
+
console.log(`\n${glyph.ok} ${c.green('live')} ${c.bold(c.cyan(fullUrl))}`);
|
|
105
|
+
}
|
|
106
|
+
console.log(c.dim(`\n fleet open ${service.name} open in browser`));
|
|
107
|
+
console.log(c.dim(` fleet logs ${service.name} follow logs`));
|
|
108
|
+
console.log(c.dim(` fleet down ${service.name} tear down`));
|
|
109
|
+
},
|
|
110
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const configPath = () => process.env.FLEET_CONFIG ?? join(homedir(), '.config', 'fleet-os', 'config.json');
|
|
5
|
+
export async function loadProfile() {
|
|
6
|
+
const fromEnv = {
|
|
7
|
+
api: process.env.FLEET_API ?? '',
|
|
8
|
+
accessToken: process.env.FLEET_TOKEN,
|
|
9
|
+
fleetId: process.env.FLEET_ID,
|
|
10
|
+
};
|
|
11
|
+
try {
|
|
12
|
+
const stored = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
13
|
+
// Environment wins, so CI can override a developer's saved login.
|
|
14
|
+
return {
|
|
15
|
+
...stored,
|
|
16
|
+
...Object.fromEntries(Object.entries(fromEnv).filter(([, v]) => v)),
|
|
17
|
+
// Older builds could save an empty api field. Keep it empty so the
|
|
18
|
+
// caller can give a precise configuration error rather than constructing
|
|
19
|
+
// an invalid relative URL such as /auth/login.
|
|
20
|
+
api: fromEnv.api || stored.api || '',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// The public control plane is the useful default for a first-time install.
|
|
25
|
+
// Self-hosters and CI can always override it with FLEET_API or --api.
|
|
26
|
+
return { api: fromEnv.api || 'https://fleetapi.plastikworld.xyz', ...fromEnv };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function saveProfile(profile) {
|
|
30
|
+
const path = configPath();
|
|
31
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
32
|
+
// Contains a bearer token; 0600 or it is readable by anything on the box.
|
|
33
|
+
await writeFile(path, JSON.stringify(profile, null, 2), { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
export const configLocation = configPath;
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework detection engine.
|
|
3
|
+
*
|
|
4
|
+
* Inspects the current working directory for well-known project signals and
|
|
5
|
+
* returns everything `fleet init` needs to scaffold both a Dockerfile and a
|
|
6
|
+
* fleet.yaml. Detection is deliberately shallow — fast, no child_process, no
|
|
7
|
+
* network. A wrong guess is corrected with a one-line edit rather than a
|
|
8
|
+
* five-minute hang.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, access } from 'node:fs/promises';
|
|
11
|
+
import { join, basename } from 'node:path';
|
|
12
|
+
const exists = async (path) => {
|
|
13
|
+
try {
|
|
14
|
+
await access(path);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const readJson = async (path) => {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const readText = async (path) => {
|
|
30
|
+
try {
|
|
31
|
+
return await readFile(path, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** Extract EXPOSE port from an existing Dockerfile. */
|
|
38
|
+
function parseExpose(dockerfile) {
|
|
39
|
+
const match = dockerfile.match(/^EXPOSE\s+(\d+)/m);
|
|
40
|
+
return match ? parseInt(match[1], 10) : null;
|
|
41
|
+
}
|
|
42
|
+
const hasDep = (pkg, name) => {
|
|
43
|
+
const deps = pkg.dependencies;
|
|
44
|
+
const devDeps = pkg.devDependencies;
|
|
45
|
+
return Boolean(deps?.[name] || devDeps?.[name]);
|
|
46
|
+
};
|
|
47
|
+
// ── Dockerfile templates ────────────────────────────────────────────────
|
|
48
|
+
const NEXTJS_DOCKERFILE = `# --- Build ---
|
|
49
|
+
FROM node:22-alpine AS builder
|
|
50
|
+
WORKDIR /app
|
|
51
|
+
COPY package*.json ./
|
|
52
|
+
RUN npm ci
|
|
53
|
+
COPY . .
|
|
54
|
+
RUN npm run build
|
|
55
|
+
|
|
56
|
+
# --- Run ---
|
|
57
|
+
FROM node:22-alpine AS runner
|
|
58
|
+
WORKDIR /app
|
|
59
|
+
ENV NODE_ENV=production
|
|
60
|
+
COPY --from=builder /app/.next/standalone ./
|
|
61
|
+
COPY --from=builder /app/.next/static ./.next/static
|
|
62
|
+
COPY --from=builder /app/public ./public
|
|
63
|
+
EXPOSE 3000
|
|
64
|
+
CMD ["node", "server.js"]
|
|
65
|
+
`;
|
|
66
|
+
const VITE_DOCKERFILE = `# --- Build ---
|
|
67
|
+
FROM node:22-alpine AS builder
|
|
68
|
+
WORKDIR /app
|
|
69
|
+
COPY package*.json ./
|
|
70
|
+
RUN npm ci
|
|
71
|
+
COPY . .
|
|
72
|
+
RUN npm run build
|
|
73
|
+
|
|
74
|
+
# --- Serve ---
|
|
75
|
+
FROM nginx:1.27-alpine
|
|
76
|
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
77
|
+
EXPOSE 80
|
|
78
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
79
|
+
`;
|
|
80
|
+
const NODE_DOCKERFILE = `FROM node:22-alpine
|
|
81
|
+
WORKDIR /app
|
|
82
|
+
COPY package*.json ./
|
|
83
|
+
RUN npm ci --omit=dev
|
|
84
|
+
COPY . .
|
|
85
|
+
EXPOSE 3000
|
|
86
|
+
CMD ["node", "src/index.js"]
|
|
87
|
+
`;
|
|
88
|
+
const PYTHON_DOCKERFILE = (entry, usesPoetry) => `FROM python:3.12-slim
|
|
89
|
+
WORKDIR /app
|
|
90
|
+
${usesPoetry
|
|
91
|
+
? `COPY pyproject.toml poetry.lock* ./
|
|
92
|
+
RUN pip install --no-cache-dir poetry && poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-dev`
|
|
93
|
+
: `COPY requirements*.txt ./
|
|
94
|
+
RUN pip install --no-cache-dir -r requirements.txt`}
|
|
95
|
+
COPY . .
|
|
96
|
+
EXPOSE 8000
|
|
97
|
+
CMD ["python", "-m", "${entry}"]
|
|
98
|
+
`;
|
|
99
|
+
const GO_DOCKERFILE = (module) => `# --- Build ---
|
|
100
|
+
FROM golang:1.24-alpine AS builder
|
|
101
|
+
WORKDIR /app
|
|
102
|
+
COPY go.mod go.sum* ./
|
|
103
|
+
RUN go mod download
|
|
104
|
+
COPY . .
|
|
105
|
+
RUN CGO_ENABLED=0 go build -o /server .
|
|
106
|
+
|
|
107
|
+
# --- Run ---
|
|
108
|
+
FROM alpine:3.21
|
|
109
|
+
COPY --from=builder /server /server
|
|
110
|
+
EXPOSE 8080
|
|
111
|
+
CMD ["/server"]
|
|
112
|
+
`;
|
|
113
|
+
const RUST_DOCKERFILE = `# --- Build ---
|
|
114
|
+
FROM rust:1.87-slim AS builder
|
|
115
|
+
WORKDIR /app
|
|
116
|
+
COPY Cargo.toml Cargo.lock* ./
|
|
117
|
+
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
|
|
118
|
+
COPY . .
|
|
119
|
+
RUN cargo build --release
|
|
120
|
+
|
|
121
|
+
# --- Run ---
|
|
122
|
+
FROM debian:bookworm-slim
|
|
123
|
+
COPY --from=builder /app/target/release/* /usr/local/bin/app
|
|
124
|
+
EXPOSE 8080
|
|
125
|
+
CMD ["app"]
|
|
126
|
+
`;
|
|
127
|
+
// ── Detection logic ─────────────────────────────────────────────────────
|
|
128
|
+
export async function detect(cwd = process.cwd()) {
|
|
129
|
+
const hasDockerfile = await exists(join(cwd, 'Dockerfile'));
|
|
130
|
+
// When a Dockerfile already exists, read its EXPOSE and skip auto-generation.
|
|
131
|
+
if (hasDockerfile) {
|
|
132
|
+
const df = await readText(join(cwd, 'Dockerfile'));
|
|
133
|
+
const port = (df && parseExpose(df)) || 3000;
|
|
134
|
+
return {
|
|
135
|
+
framework: 'unknown',
|
|
136
|
+
label: 'existing Dockerfile',
|
|
137
|
+
port,
|
|
138
|
+
healthPath: '/',
|
|
139
|
+
dockerfile: null,
|
|
140
|
+
hasDockerfile: true,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// ── Node-based frameworks ──
|
|
144
|
+
const pkg = await readJson(join(cwd, 'package.json'));
|
|
145
|
+
if (pkg) {
|
|
146
|
+
// Next.js
|
|
147
|
+
if (hasDep(pkg, 'next')) {
|
|
148
|
+
return {
|
|
149
|
+
framework: 'nextjs',
|
|
150
|
+
label: 'Next.js',
|
|
151
|
+
port: 3000,
|
|
152
|
+
healthPath: '/',
|
|
153
|
+
dockerfile: NEXTJS_DOCKERFILE,
|
|
154
|
+
hasDockerfile: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// Vite / React / Vue / Svelte / SvelteKit
|
|
158
|
+
if (hasDep(pkg, 'vite') || hasDep(pkg, '@vitejs/plugin-react') || hasDep(pkg, '@sveltejs/vite-plugin-svelte')) {
|
|
159
|
+
return {
|
|
160
|
+
framework: 'vite',
|
|
161
|
+
label: 'Vite',
|
|
162
|
+
port: 80,
|
|
163
|
+
healthPath: '/',
|
|
164
|
+
dockerfile: VITE_DOCKERFILE,
|
|
165
|
+
hasDockerfile: false,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// Node API servers
|
|
169
|
+
if (hasDep(pkg, 'express') ||
|
|
170
|
+
hasDep(pkg, 'fastify') ||
|
|
171
|
+
hasDep(pkg, '@nestjs/core') ||
|
|
172
|
+
hasDep(pkg, 'hono') ||
|
|
173
|
+
hasDep(pkg, 'koa')) {
|
|
174
|
+
return {
|
|
175
|
+
framework: 'node',
|
|
176
|
+
label: 'Node.js API',
|
|
177
|
+
port: 3000,
|
|
178
|
+
healthPath: '/health',
|
|
179
|
+
dockerfile: NODE_DOCKERFILE,
|
|
180
|
+
hasDockerfile: false,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ── Python ──
|
|
185
|
+
const hasRequirements = await exists(join(cwd, 'requirements.txt'));
|
|
186
|
+
const hasPyproject = await exists(join(cwd, 'pyproject.toml'));
|
|
187
|
+
if (hasRequirements || hasPyproject) {
|
|
188
|
+
// Try to guess the ASGI/WSGI framework
|
|
189
|
+
const reqText = (await readText(join(cwd, 'requirements.txt'))) ??
|
|
190
|
+
(await readText(join(cwd, 'pyproject.toml'))) ??
|
|
191
|
+
'';
|
|
192
|
+
const isFastapi = /fastapi/i.test(reqText);
|
|
193
|
+
const isFlask = /flask/i.test(reqText);
|
|
194
|
+
const isDjango = /django/i.test(reqText);
|
|
195
|
+
const entry = isDjango
|
|
196
|
+
? `django`
|
|
197
|
+
: isFastapi
|
|
198
|
+
? `uvicorn main:app --host 0.0.0.0 --port 8000`.split(' ')[0]
|
|
199
|
+
: isFlask
|
|
200
|
+
? `flask`
|
|
201
|
+
: 'app';
|
|
202
|
+
// For FastAPI, override CMD to use uvicorn properly
|
|
203
|
+
const pythonDF = isFastapi
|
|
204
|
+
? PYTHON_DOCKERFILE(entry, hasPyproject && !hasRequirements).replace(`CMD ["python", "-m", "${entry}"]`, `CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`)
|
|
205
|
+
: isDjango
|
|
206
|
+
? PYTHON_DOCKERFILE(entry, hasPyproject && !hasRequirements).replace(`CMD ["python", "-m", "${entry}"]`, `CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]`)
|
|
207
|
+
: PYTHON_DOCKERFILE(entry, hasPyproject && !hasRequirements);
|
|
208
|
+
return {
|
|
209
|
+
framework: 'python',
|
|
210
|
+
label: isFastapi ? 'Python (FastAPI)' : isDjango ? 'Python (Django)' : isFlask ? 'Python (Flask)' : 'Python',
|
|
211
|
+
port: 8000,
|
|
212
|
+
healthPath: '/health',
|
|
213
|
+
dockerfile: pythonDF,
|
|
214
|
+
hasDockerfile: false,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// ── Go ──
|
|
218
|
+
const goMod = await readText(join(cwd, 'go.mod'));
|
|
219
|
+
if (goMod) {
|
|
220
|
+
const moduleMatch = goMod.match(/^module\s+(.+)$/m);
|
|
221
|
+
const moduleName = moduleMatch?.[1] ?? basename(cwd);
|
|
222
|
+
return {
|
|
223
|
+
framework: 'go',
|
|
224
|
+
label: 'Go',
|
|
225
|
+
port: 8080,
|
|
226
|
+
healthPath: '/healthz',
|
|
227
|
+
dockerfile: GO_DOCKERFILE(moduleName),
|
|
228
|
+
hasDockerfile: false,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// ── Rust ──
|
|
232
|
+
if (await exists(join(cwd, 'Cargo.toml'))) {
|
|
233
|
+
return {
|
|
234
|
+
framework: 'rust',
|
|
235
|
+
label: 'Rust',
|
|
236
|
+
port: 8080,
|
|
237
|
+
healthPath: '/healthz',
|
|
238
|
+
dockerfile: RUST_DOCKERFILE,
|
|
239
|
+
hasDockerfile: false,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// ── Static / unknown ──
|
|
243
|
+
// If there's an index.html, it's probably a static site
|
|
244
|
+
if (await exists(join(cwd, 'index.html'))) {
|
|
245
|
+
return {
|
|
246
|
+
framework: 'static',
|
|
247
|
+
label: 'Static site',
|
|
248
|
+
port: 80,
|
|
249
|
+
healthPath: '/',
|
|
250
|
+
dockerfile: null,
|
|
251
|
+
hasDockerfile: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
framework: 'unknown',
|
|
256
|
+
label: 'unknown project',
|
|
257
|
+
port: 3000,
|
|
258
|
+
healthPath: '/',
|
|
259
|
+
dockerfile: null,
|
|
260
|
+
hasDockerfile: false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// ── Manifest template ───────────────────────────────────────────────────
|
|
264
|
+
export function manifestTemplate(name, d) {
|
|
265
|
+
const build = d.hasDockerfile || d.dockerfile ? 'build: .' : `image: nginx:1.27`;
|
|
266
|
+
const portLine = d.port !== 80 ? `\n container_port: ${d.port}` : '';
|
|
267
|
+
return `fleet: homelab
|
|
268
|
+
|
|
269
|
+
services:
|
|
270
|
+
${name}:
|
|
271
|
+
${build}
|
|
272
|
+
placement: flexible
|
|
273
|
+
resources: { ram: 512Mi, cpu: 0.5 }
|
|
274
|
+
health: { path: ${d.healthPath} }${portLine}
|
|
275
|
+
# domain: ${name}.yourdomain.dev
|
|
276
|
+
`;
|
|
277
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { CliError, EXIT } from './api.js';
|
|
5
|
+
import { c } from './render.js';
|
|
6
|
+
import { banner } from './mark.js';
|
|
7
|
+
import { commands } from './commands/index.js';
|
|
8
|
+
import { parseArgs } from './args.js';
|
|
9
|
+
export { parseArgs };
|
|
10
|
+
/**
|
|
11
|
+
* Grouped by the order an operator meets them, not alphabetically: the first
|
|
12
|
+
* group is a first session with the tool, read top to bottom.
|
|
13
|
+
*/
|
|
14
|
+
const GROUPS = [
|
|
15
|
+
[
|
|
16
|
+
'getting started',
|
|
17
|
+
[
|
|
18
|
+
['up [service]', 'Detect, scaffold, apply, and deploy in one command'],
|
|
19
|
+
['init', 'Scaffold a fleet.yaml and Dockerfile from this repository'],
|
|
20
|
+
['config show', 'Show the saved control plane and selected fleet'],
|
|
21
|
+
['use <fleet>', 'Select the default fleet for later commands'],
|
|
22
|
+
['auth login', 'Sign in and save a secure local session'],
|
|
23
|
+
['nodes pair', 'Mint a pairing token for a new machine'],
|
|
24
|
+
['doctor', 'Check the control plane, nodes, services, ingress, and GitHub'],
|
|
25
|
+
['apply [file]', 'Apply a fleet.yaml to the fleet'],
|
|
26
|
+
['deploy <service>', 'Build, schedule, and roll out'],
|
|
27
|
+
],
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
'looking around',
|
|
31
|
+
[
|
|
32
|
+
['open [service]', 'Open the live service in your default browser'],
|
|
33
|
+
['status', 'One-screen view of the whole fleet'],
|
|
34
|
+
['services', 'List services and where they are running'],
|
|
35
|
+
['nodes', 'List nodes'],
|
|
36
|
+
['where <service>', 'Explain where a service would be placed, and why'],
|
|
37
|
+
['deployments <service>', 'Deployment history'],
|
|
38
|
+
['logs <service> --follow', 'Follow the latest agent-reported container tail'],
|
|
39
|
+
['events', 'Unified event timeline'],
|
|
40
|
+
],
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
'operating',
|
|
44
|
+
[
|
|
45
|
+
['down <service>', 'Stop and tear down a service deployment'],
|
|
46
|
+
['validate [file]', 'Check a fleet.yaml without applying it'],
|
|
47
|
+
['reschedule <service>', 'Force a service to move'],
|
|
48
|
+
['restart <service>', 'Replace the current release on its node'],
|
|
49
|
+
['rollback <service> [release]', 'Restore the previous or selected release'],
|
|
50
|
+
['nodes cordon <name>', 'Stop scheduling new work onto a node'],
|
|
51
|
+
['nodes uncordon <name>', 'Allow scheduling again'],
|
|
52
|
+
['nodes rm <name>', 'Revoke and remove a node'],
|
|
53
|
+
['alerts', 'List, add, and test alert rules'],
|
|
54
|
+
['auth login|logout|whoami', 'Sign in to a control plane'],
|
|
55
|
+
],
|
|
56
|
+
],
|
|
57
|
+
];
|
|
58
|
+
const OPTIONS = [
|
|
59
|
+
['--fleet <id>', 'Operate on a specific fleet'],
|
|
60
|
+
['--api <url>', 'Control plane URL (default: saved profile)'],
|
|
61
|
+
['--json', 'Machine-readable output on stdout'],
|
|
62
|
+
['--plan, --dry-run', 'Show the deploy placement plan without changing anything'],
|
|
63
|
+
['--yes', 'Skip the interactive deploy confirmation'],
|
|
64
|
+
['--no-wait', 'Return once scheduled, without following the rollout'],
|
|
65
|
+
['-h, --help', 'Show help'],
|
|
66
|
+
];
|
|
67
|
+
// One column width across every group, so the glosses form a single edge down
|
|
68
|
+
// the page rather than stepping in and out per section.
|
|
69
|
+
const TERM_WIDTH = Math.max(...GROUPS.flatMap(([, rows]) => rows.map(([term]) => term.length)), ...OPTIONS.map(([term]) => term.length));
|
|
70
|
+
const definitions = (rows) => rows.map(([term, gloss]) => ` ${term.padEnd(TERM_WIDTH)} ${c.dim(gloss)}`).join('\n');
|
|
71
|
+
const usage = () => [
|
|
72
|
+
banner('deploy to hardware you own'),
|
|
73
|
+
'',
|
|
74
|
+
`${c.dim('usage')} fleet <command> [options]`,
|
|
75
|
+
...GROUPS.flatMap(([title, rows]) => ['', c.bold(title), definitions(rows)]),
|
|
76
|
+
'',
|
|
77
|
+
c.bold('options'),
|
|
78
|
+
definitions(OPTIONS),
|
|
79
|
+
'',
|
|
80
|
+
c.dim('exit codes 0 ok · 1 failure · 2 usage · 3 no eligible node · 4 health check failed'),
|
|
81
|
+
'',
|
|
82
|
+
].join('\n');
|
|
83
|
+
async function version() {
|
|
84
|
+
const { readFile } = await import('node:fs/promises');
|
|
85
|
+
const path = new URL('../package.json', import.meta.url);
|
|
86
|
+
const pkg = JSON.parse(await readFile(path, 'utf8'));
|
|
87
|
+
return pkg.version ?? '0.0.0';
|
|
88
|
+
}
|
|
89
|
+
async function main() {
|
|
90
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
91
|
+
const [name, ...rest] = positional;
|
|
92
|
+
if (flags.version || flags.v) {
|
|
93
|
+
console.log(await version());
|
|
94
|
+
process.exit(EXIT.ok);
|
|
95
|
+
}
|
|
96
|
+
if (!name || flags.help || flags.h) {
|
|
97
|
+
console.log(usage());
|
|
98
|
+
// A bare `fleet` is someone asking what this is, not a malformed command.
|
|
99
|
+
process.exit(EXIT.ok);
|
|
100
|
+
}
|
|
101
|
+
const command = commands[name];
|
|
102
|
+
if (!command) {
|
|
103
|
+
const near = Object.keys(commands).filter((k) => k.startsWith(name[0] ?? ''));
|
|
104
|
+
console.error(`${c.red('unknown command')} "${name}"` + (near.length ? `\n did you mean: ${near.join(', ')}?` : ''));
|
|
105
|
+
process.exit(EXIT.usage);
|
|
106
|
+
}
|
|
107
|
+
await command.run(rest, flags);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Only run when invoked as a command. Importing the entrypoint — from a test,
|
|
111
|
+
* or another tool — must not execute it.
|
|
112
|
+
*/
|
|
113
|
+
const invokedDirectly = process.argv[1] !== undefined &&
|
|
114
|
+
// npm link exposes the bin as a symlink. ESM resolves this module to its
|
|
115
|
+
// real path, whereas argv retains the symlink, so compare canonical paths.
|
|
116
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
117
|
+
if (invokedDirectly) {
|
|
118
|
+
main().catch(onError);
|
|
119
|
+
}
|
|
120
|
+
function onError(err) {
|
|
121
|
+
if (err instanceof CliError) {
|
|
122
|
+
console.error(`${c.red('error')} ${err.message}`);
|
|
123
|
+
if (err.detail) {
|
|
124
|
+
const lines = Array.isArray(err.detail) ? err.detail : [err.detail];
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
console.error(' ' +
|
|
127
|
+
(typeof line === 'string'
|
|
128
|
+
? line
|
|
129
|
+
: `${line.path ?? ''} ${line.message ?? JSON.stringify(line)}`));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
process.exit(err.exitCode);
|
|
133
|
+
}
|
|
134
|
+
console.error(`${c.red('error')} ${err instanceof Error ? err.message : String(err)}`);
|
|
135
|
+
process.exit(EXIT.failure);
|
|
136
|
+
}
|
package/dist/mark.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Fleet mark, drawn in the terminal: eight peers around one live hub — the
|
|
3
|
+
* same topology as the mesh on the marketing site, so the CLI and the browser
|
|
4
|
+
* are recognisably the same product.
|
|
5
|
+
*
|
|
6
|
+
* The mark is a fixed character grid rather than a string per state, because
|
|
7
|
+
* the loading animation lights individual spokes and needs to address cells.
|
|
8
|
+
*/
|
|
9
|
+
import { c, colourDepth, rgb } from './render.js';
|
|
10
|
+
const GRID = [
|
|
11
|
+
' ○ ○ ○ ',
|
|
12
|
+
' ╲ │ ╱ ',
|
|
13
|
+
'○───────◉───────○',
|
|
14
|
+
' ╱ │ ╲ ',
|
|
15
|
+
' ○ ○ ○ ',
|
|
16
|
+
];
|
|
17
|
+
export const MARK_WIDTH = 17;
|
|
18
|
+
export const MARK_HEIGHT = GRID.length;
|
|
19
|
+
/** Where the hub sits. Always lit — a fleet with no control plane is not a fleet. */
|
|
20
|
+
const HUB = [2, 8];
|
|
21
|
+
/** Each peer owns its node glyph and the spoke connecting it to the hub. */
|
|
22
|
+
const PEERS = [
|
|
23
|
+
[[0, 2], [1, 3]],
|
|
24
|
+
[[0, 8], [1, 8]],
|
|
25
|
+
[[0, 14], [1, 13]],
|
|
26
|
+
[[2, 0], [2, 1], [2, 2], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7]],
|
|
27
|
+
[[2, 16], [2, 15], [2, 14], [2, 13], [2, 12], [2, 11], [2, 10], [2, 9]],
|
|
28
|
+
[[4, 2], [3, 3]],
|
|
29
|
+
[[4, 8], [3, 8]],
|
|
30
|
+
[[4, 14], [3, 13]],
|
|
31
|
+
];
|
|
32
|
+
export const PEER_COUNT = PEERS.length;
|
|
33
|
+
const key = ([row, col]) => `${row}:${col}`;
|
|
34
|
+
/**
|
|
35
|
+
* Brightness 1 is fully lit, 0 is the resting state. Intermediate values only
|
|
36
|
+
* read as a gradient on truecolour terminals; elsewhere anything lit at all
|
|
37
|
+
* takes the accent, which keeps the animation legible rather than uniform.
|
|
38
|
+
*/
|
|
39
|
+
function shade(glyph, brightness) {
|
|
40
|
+
if (brightness <= 0.02)
|
|
41
|
+
return c.grey(glyph);
|
|
42
|
+
if (colourDepth < 2)
|
|
43
|
+
return brightness > 0.45 ? c.signal(glyph) : c.grey(glyph);
|
|
44
|
+
// Resting slate → signal green, so a spoke appears to charge rather than blink.
|
|
45
|
+
const t = Math.min(1, brightness);
|
|
46
|
+
const r = Math.round(0x4b + (0x3f - 0x4b) * t);
|
|
47
|
+
const g = Math.round(0x52 + (0xe0 - 0x52) * t);
|
|
48
|
+
const b = Math.round(0x5d + (0x8b - 0x5d) * t);
|
|
49
|
+
return rgb(r, g, b)(glyph);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Render the mark with a travelling pulse. `phase` advances continuously; peers
|
|
53
|
+
* light in sequence and decay behind the head, so the mark reads as traffic
|
|
54
|
+
* moving through a mesh rather than as a spinner wearing a costume.
|
|
55
|
+
*/
|
|
56
|
+
export function markFrame(phase) {
|
|
57
|
+
const brightness = new Map();
|
|
58
|
+
// A negative phase is the resting mark: hub live, peers quiet.
|
|
59
|
+
const resting = !(phase >= 0);
|
|
60
|
+
for (let i = 0; resting === false && i < PEERS.length; i++) {
|
|
61
|
+
// Distance from the pulse head, wrapped, so the trail crosses the seam.
|
|
62
|
+
const raw = (phase - i + PEERS.length) % PEERS.length;
|
|
63
|
+
const distance = Math.min(raw, PEERS.length - raw);
|
|
64
|
+
const level = Math.max(0, 1 - distance / 2.4);
|
|
65
|
+
for (const [index, cell] of PEERS[i].entries()) {
|
|
66
|
+
// Along a spoke the outer end burns brightest, so the glow reads as
|
|
67
|
+
// arriving at the peer rather than washing the whole edge at once.
|
|
68
|
+
const along = PEERS[i].length > 2 ? 1 - index / PEERS[i].length : 1;
|
|
69
|
+
brightness.set(key(cell), Math.max(brightness.get(key(cell)) ?? 0, level * along));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return GRID.map((line, row) => [...line]
|
|
73
|
+
.map((glyph, col) => {
|
|
74
|
+
if (glyph === ' ')
|
|
75
|
+
return glyph;
|
|
76
|
+
if (row === HUB[0] && col === HUB[1])
|
|
77
|
+
return c.bold(c.signal(glyph));
|
|
78
|
+
return shade(glyph, brightness.get(`${row}:${col}`) ?? 0);
|
|
79
|
+
})
|
|
80
|
+
.join(''));
|
|
81
|
+
}
|
|
82
|
+
/** The resting mark: hub live, peers quiet. */
|
|
83
|
+
export const mark = () => markFrame(-1);
|
|
84
|
+
const WORDMARK = ['█▀▀ █ █▀▀ █▀▀ ▀█▀', '█▀ █ █▀ █▀ █ ', '▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ '];
|
|
85
|
+
/**
|
|
86
|
+
* Mark and wordmark side by side, with the tagline tucked under the wordmark so
|
|
87
|
+
* the block stays rectangular. Falls back to a single line when the terminal is
|
|
88
|
+
* too narrow to hold both without wrapping — a wrapped logo looks broken.
|
|
89
|
+
*/
|
|
90
|
+
export function banner(subtitle) {
|
|
91
|
+
// `columns` is 0, not undefined, on some pseudo-terminals — `??` would miss it.
|
|
92
|
+
const columns = process.stdout.columns || 80;
|
|
93
|
+
if (columns < 46)
|
|
94
|
+
return `${c.signal('◉')} ${c.bold('fleet')}${subtitle ? c.dim(` ${subtitle}`) : ''}`;
|
|
95
|
+
// The wordmark sits against the middle three rows of the mark; the tagline
|
|
96
|
+
// takes the last. Every mark row is exactly MARK_WIDTH visible columns, so a
|
|
97
|
+
// fixed gutter aligns them without measuring around the colour codes.
|
|
98
|
+
const right = ['', ...WORDMARK.map(c.bold), subtitle ? c.dim(subtitle) : ''];
|
|
99
|
+
return mark()
|
|
100
|
+
.map((line, i) => ` ${line}${right[i] ? ` ${right[i]}` : ''}`.trimEnd())
|
|
101
|
+
.join('\n');
|
|
102
|
+
}
|