@flisk-sh/flisk 1.4.6 → 1.4.7
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 +2 -2
- package/cli/dist/agent-runtimes/custom.js +94 -13
- package/cli/dist/commands/agent.js +144 -123
- package/cli/dist/commands/doctor.js +12 -0
- package/cli/dist/commands/logs.js +71 -4
- package/cli/dist/commands/project.js +16 -0
- package/cli/dist/commands/resource.js +3 -1
- package/cli/dist/commands/status.js +43 -24
- package/cli/dist/commands/ui.js +41 -2
- package/cli/dist/commands/up.js +54 -11
- package/cli/dist/components/DetailPanel.js +23 -3
- package/cli/dist/mcp/tools.js +9 -6
- package/cli/dist/types/index.js +0 -2
- package/cli/dist/utils/agent-format.js +18 -0
- package/cli/dist/utils/agent-sessions.js +122 -0
- package/cli/dist/utils/agent-skills.js +64 -0
- package/cli/dist/utils/cold-preflight.js +3 -0
- package/cli/dist/utils/doctor-fix-first.js +93 -0
- package/cli/dist/utils/doctor-report.js +3 -0
- package/cli/dist/utils/native-runtime.js +3 -0
- package/cli/dist/utils/native-service.js +2 -2
- package/cli/dist/utils/service-manifest.js +8 -15
- package/cli/dist/utils/services.js +7 -10
- package/cli/dist/utils/startup-report.js +45 -3
- package/cli/dist/utils/status-groups.js +23 -0
- package/cli/dist/utils/up-hints.js +42 -0
- package/cli/dist/utils/up-progress.js +117 -0
- package/cli/dist/utils/up-readiness.js +5 -0
- package/cli/dist-bundle/{chunk-U5FCQF42.js → chunk-6OW2GVQV.js} +1 -1
- package/cli/dist-bundle/chunk-B2JOM3HN.js +35 -0
- package/cli/dist-bundle/{chunk-R7DWXISM.js → chunk-F2DLIM75.js} +1 -1
- package/cli/dist-bundle/{chunk-ETXZ5LUG.js → chunk-L6O7TT3K.js} +10 -10
- package/cli/dist-bundle/chunk-MFY7RZ76.js +130 -0
- package/cli/dist-bundle/{chunk-MUDE4DZD.js → chunk-MYAYJF4Q.js} +1 -1
- package/cli/dist-bundle/{chunk-DPQK5TIS.js → chunk-P7GZ6NGM.js} +1 -1
- package/cli/dist-bundle/{chunk-IDNES5PE.js → chunk-T44DEEL6.js} +1 -1
- package/cli/dist-bundle/{chunk-C4UFBXRD.js → chunk-TIPSERWO.js} +1 -1
- package/cli/dist-bundle/{chunk-BIVAAWBX.js → chunk-VYPB7MY5.js} +1 -1
- package/cli/dist-bundle/{chunk-WCGBVETR.js → chunk-XHENT4KO.js} +1 -1
- package/cli/dist-bundle/{chunk-CAEMWNPF.js → chunk-YTBTCAWY.js} +1 -1
- package/cli/dist-bundle/cli.js +462 -377
- package/cli/dist-bundle/{cold-preflight-KUWOL2Q2.js → cold-preflight-DQVJUGBB.js} +1 -1
- package/cli/dist-bundle/{demo-stage-QCC4IWXR.js → demo-stage-F4YA7UL2.js} +1 -1
- package/cli/dist-bundle/{open-AZNX4AC5.js → open-ZUSSVPD3.js} +1 -1
- package/cli/dist-bundle/project-JFCTPRP5.js +2 -0
- package/cli/dist-bundle/{services-EY5WKPHQ.js → services-DOHDHOCT.js} +1 -1
- package/cli/dist-bundle/{tilt-port-owner-UBU6JWOA.js → tilt-port-owner-XODQHXHX.js} +1 -1
- package/cli/package.json +1 -1
- package/cli/templates/agent-skills/flisk-agent-ops/SKILL.md +39 -0
- package/cli/templates/agent-skills/flisk-build/SKILL.md +85 -0
- package/cli/templates/agent-skills/flisk-doctor/SKILL.md +28 -0
- package/cli/templates/agent-skills/flisk-recipe-ai-agent/SKILL.md +45 -0
- package/cli/templates/agent-skills/flisk-recipe-blog/SKILL.md +36 -0
- package/cli/templates/agent-skills/flisk-recipe-saas/SKILL.md +39 -0
- package/cli/templates/agent-skills/flisk-recipe-shop/SKILL.md +41 -0
- package/cli/templates/agent-skills/flisk-recipe-starters/SKILL.md +46 -0
- package/cli/templates/agent-skills/flisk-resource/SKILL.md +27 -0
- package/cli/templates/agent-skills/flisk-status/SKILL.md +25 -0
- package/cli/templates/agent-skills/flisk-troubleshoot/SKILL.md +227 -0
- package/cli/templates/agent-skills/flisk-up/SKILL.md +37 -0
- package/package.json +1 -1
- package/runtime/engine/schemas/service-schema.json +3 -15
- package/runtime/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +7 -1
- package/cli/dist-bundle/chunk-CDKEHFW6.js +0 -35
- package/cli/dist-bundle/chunk-GT76VMUL.js +0 -130
- package/cli/dist-bundle/project-5SWIGCNP.js +0 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/** "12s ago", "5m ago", "3h ago", "2d ago"; "-" when the agent sent no time. */
|
|
4
|
+
export function formatAge(iso, now = Date.now()) {
|
|
5
|
+
if (!iso)
|
|
6
|
+
return "-";
|
|
7
|
+
const at = Date.parse(iso);
|
|
8
|
+
if (Number.isNaN(at))
|
|
9
|
+
return iso;
|
|
10
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
11
|
+
if (seconds < 60)
|
|
12
|
+
return `${seconds}s ago`;
|
|
13
|
+
if (seconds < 3600)
|
|
14
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
15
|
+
if (seconds < 86_400)
|
|
16
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
17
|
+
return `${Math.floor(seconds / 86_400)}d ago`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import * as http from "node:http";
|
|
4
|
+
export const AGENT_REQUEST_TIMEOUT_MS = 10_000;
|
|
5
|
+
/** GET with node:http because fetch silently drops a custom Host header, which is what Traefik routes on. */
|
|
6
|
+
export function httpGet(url, headers, timeoutMs) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
// One signal covers the connection and the body read, so a hung agent cannot block the CLI.
|
|
9
|
+
const req = http.get(url, { headers, signal: AbortSignal.timeout(timeoutMs) }, (res) => {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
12
|
+
res.on("end", () => {
|
|
13
|
+
const status = res.statusCode ?? 0;
|
|
14
|
+
resolve({
|
|
15
|
+
ok: status >= 200 && status < 300,
|
|
16
|
+
status,
|
|
17
|
+
text: Buffer.concat(chunks).toString("utf-8"),
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
res.on("error", reject);
|
|
21
|
+
});
|
|
22
|
+
req.on("error", reject);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Agent containers publish no host port; the only way in is the Traefik route that `flisk status` prints as `url`
|
|
27
|
+
* (for example http://api.<project>.localhost:8080/api/bot). `*.localhost` does not resolve everywhere (glibc without
|
|
28
|
+
* systemd-resolved), so connect to loopback and keep the route's host in the Host header, which is all Traefik matches on.
|
|
29
|
+
*/
|
|
30
|
+
export function agentRequest(routeUrl, path, headers = {}) {
|
|
31
|
+
const route = new URL(routeUrl);
|
|
32
|
+
const allHeaders = { ...headers };
|
|
33
|
+
if (route.hostname.endsWith(".localhost")) {
|
|
34
|
+
allHeaders.host = route.host;
|
|
35
|
+
route.hostname = "127.0.0.1";
|
|
36
|
+
}
|
|
37
|
+
return { url: `${route.origin}${route.pathname.replace(/\/$/, "")}${path}`, headers: allHeaders };
|
|
38
|
+
}
|
|
39
|
+
/** The request for one session's checkpoint: `GET /session`, keyed by the `x-session-id` header. */
|
|
40
|
+
export function agentSessionRequest(routeUrl, sessionId) {
|
|
41
|
+
return agentRequest(routeUrl, "/session", { "x-session-id": sessionId });
|
|
42
|
+
}
|
|
43
|
+
/** The request for the session list: `GET /sessions`, which needs no session id. */
|
|
44
|
+
export function agentSessionListRequest(routeUrl) {
|
|
45
|
+
return agentRequest(routeUrl, "/sessions");
|
|
46
|
+
}
|
|
47
|
+
export async function fetchAgent(request, timeoutMs = AGENT_REQUEST_TIMEOUT_MS) {
|
|
48
|
+
let reply;
|
|
49
|
+
try {
|
|
50
|
+
reply = await httpGet(request.url, request.headers, timeoutMs);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { kind: "not-running" };
|
|
54
|
+
}
|
|
55
|
+
let body = {};
|
|
56
|
+
let isJson = true;
|
|
57
|
+
try {
|
|
58
|
+
body = reply.text ? JSON.parse(reply.text) : {};
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
body = { raw: reply.text };
|
|
62
|
+
isJson = false;
|
|
63
|
+
}
|
|
64
|
+
if (reply.ok)
|
|
65
|
+
return { kind: "ok", body };
|
|
66
|
+
// Traefik answers 502/503/504 when the route has no live container and a plain-text 404 when it has no route at
|
|
67
|
+
// all. An agent's own errors are JSON, so a non-JSON 404 is the gateway, not the agent.
|
|
68
|
+
if ([502, 503, 504].includes(reply.status) || (reply.status === 404 && !isJson)) {
|
|
69
|
+
return { kind: "not-running" };
|
|
70
|
+
}
|
|
71
|
+
if (reply.status === 404)
|
|
72
|
+
return { kind: "not-found", body };
|
|
73
|
+
const detail = body?.error;
|
|
74
|
+
return {
|
|
75
|
+
kind: "error",
|
|
76
|
+
status: reply.status,
|
|
77
|
+
detail: typeof detail === "string" ? detail : reply.text,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reads a `GET /sessions` body. Returns null when it is not the list shape, so a caller can tell a wrong endpoint from
|
|
82
|
+
* an agent with no sessions. Rows without a usable id are dropped; the agent owns its other fields.
|
|
83
|
+
*/
|
|
84
|
+
export function parseAgentSessionList(body) {
|
|
85
|
+
if (typeof body !== "object" || body === null)
|
|
86
|
+
return null;
|
|
87
|
+
const raw = body.sessions;
|
|
88
|
+
if (!Array.isArray(raw))
|
|
89
|
+
return null;
|
|
90
|
+
const sessions = [];
|
|
91
|
+
for (const row of raw) {
|
|
92
|
+
if (typeof row !== "object" || row === null)
|
|
93
|
+
continue;
|
|
94
|
+
const r = row;
|
|
95
|
+
if (typeof r.sessionId !== "string" || !r.sessionId)
|
|
96
|
+
continue;
|
|
97
|
+
sessions.push({
|
|
98
|
+
sessionId: r.sessionId,
|
|
99
|
+
lastActivity: typeof r.lastActivity === "string" ? r.lastActivity : null,
|
|
100
|
+
status: typeof r.status === "string" ? r.status : "unknown",
|
|
101
|
+
checkpointBytes: typeof r.checkpointBytes === "number" ? r.checkpointBytes : null,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const total = body.total;
|
|
105
|
+
return { sessions, total: typeof total === "number" ? total : sessions.length };
|
|
106
|
+
}
|
|
107
|
+
/** Reads `/ready` and `/sessions` once, in parallel. Never throws: a failure is part of the answer. */
|
|
108
|
+
export async function fetchAgentOverview(routeUrl, timeoutMs = 3_000) {
|
|
109
|
+
const [ready, list] = await Promise.all([
|
|
110
|
+
fetchAgent(agentRequest(routeUrl, "/ready"), timeoutMs),
|
|
111
|
+
fetchAgent(agentRequest(routeUrl, "/sessions"), timeoutMs),
|
|
112
|
+
]);
|
|
113
|
+
if (ready.kind === "not-running" && list.kind === "not-running")
|
|
114
|
+
return { state: "not-running" };
|
|
115
|
+
// /ready answers 503 with { ready: false } when the checkpoint store is down, which fetchAgent reports as "not-running".
|
|
116
|
+
const isReady = ready.kind === "ok" && ready.body?.ready === true;
|
|
117
|
+
return {
|
|
118
|
+
state: "up",
|
|
119
|
+
ready: isReady,
|
|
120
|
+
sessions: list.kind === "ok" ? parseAgentSessionList(list.body) : null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { loadTemplate } from "../generator/template-engine.js";
|
|
6
|
+
// The agent skills `flisk project` writes into a new project, so a coding agent opened there knows how to build and run
|
|
7
|
+
// the app with Flisk. Copies of .claude/skills/<name>/SKILL.md, shipped in cli/templates/agent-skills (a test keeps
|
|
8
|
+
// them identical).
|
|
9
|
+
export const PROJECT_AGENT_SKILLS = [
|
|
10
|
+
"flisk-build",
|
|
11
|
+
"flisk-recipe-starters",
|
|
12
|
+
"flisk-recipe-shop",
|
|
13
|
+
"flisk-recipe-saas",
|
|
14
|
+
"flisk-recipe-ai-agent",
|
|
15
|
+
"flisk-recipe-blog",
|
|
16
|
+
"flisk-doctor",
|
|
17
|
+
"flisk-up",
|
|
18
|
+
"flisk-resource",
|
|
19
|
+
"flisk-status",
|
|
20
|
+
"flisk-troubleshoot",
|
|
21
|
+
"flisk-agent-ops",
|
|
22
|
+
];
|
|
23
|
+
// Claude Code reads .claude/skills; Codex, Cursor and other agents read .agents/skills.
|
|
24
|
+
export const AGENT_SKILL_DIRS = [".claude/skills", ".agents/skills"];
|
|
25
|
+
const DOCS_URL = "https://github.com/flisk-sh/flisk/blob/main/docs/";
|
|
26
|
+
export const PROJECT_AGENTS_MD = `# AGENTS.md
|
|
27
|
+
|
|
28
|
+
This project runs on [Flisk](https://github.com/flisk-sh/flisk). To build or run something here, follow the Flisk skills in \`.agents/skills\` (Claude Code: \`.claude/skills\`), starting with \`flisk-build\`.
|
|
29
|
+
|
|
30
|
+
- Add a service: \`flisk resource <name> --type <frontend|backend|worker|agent|mcp> --stack <stack> --yes\`
|
|
31
|
+
- Start it: \`flisk up <stack>\`, then check every URL from \`flisk networks\`
|
|
32
|
+
- When something fails: \`flisk doctor\`, then \`flisk logs -s <service>\`
|
|
33
|
+
- Do not hand-write Dockerfiles, Compose files or Tiltfiles, and do not edit \`.autogenerated/\`: change \`service.json\` instead.
|
|
34
|
+
`;
|
|
35
|
+
// In the repository the skills link to docs/ with relative paths; in a user's project those point nowhere.
|
|
36
|
+
export function rewriteSkillLinks(markdown) {
|
|
37
|
+
return markdown.replace(/\]\((?:\.\.\/)+docs\//g, `](${DOCS_URL}`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Write the Flisk agent skills and an AGENTS.md into a project. Never overwrites: a file that already exists is left
|
|
41
|
+
* as it is, so a user's own edits survive `flisk project` running again. Returns the paths written, relative to the
|
|
42
|
+
* project root.
|
|
43
|
+
*/
|
|
44
|
+
export function installAgentSkills(projectRoot) {
|
|
45
|
+
const written = [];
|
|
46
|
+
for (const name of PROJECT_AGENT_SKILLS) {
|
|
47
|
+
const contents = rewriteSkillLinks(loadTemplate(`agent-skills/${name}/SKILL.md`));
|
|
48
|
+
for (const dir of AGENT_SKILL_DIRS) {
|
|
49
|
+
const rel = path.join(dir, name, "SKILL.md");
|
|
50
|
+
const target = path.join(projectRoot, rel);
|
|
51
|
+
if (existsSync(target))
|
|
52
|
+
continue;
|
|
53
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
54
|
+
writeFileSync(target, contents);
|
|
55
|
+
written.push(rel);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const agentsMd = path.join(projectRoot, "AGENTS.md");
|
|
59
|
+
if (!existsSync(agentsMd)) {
|
|
60
|
+
writeFileSync(agentsMd, PROJECT_AGENTS_MD);
|
|
61
|
+
written.push("AGENTS.md");
|
|
62
|
+
}
|
|
63
|
+
return written;
|
|
64
|
+
}
|
|
@@ -259,6 +259,9 @@ export async function assertMachineReadyOrExit(opts = {}) {
|
|
|
259
259
|
const machineFailure = result.items.some((check) => blockingIds.includes(check.id));
|
|
260
260
|
if (machineFailure) {
|
|
261
261
|
console.error(formatColdPreflight(result));
|
|
262
|
+
// What to run once the machine checks pass; the caller knows its own next step.
|
|
263
|
+
for (const hint of opts.hints ?? [])
|
|
264
|
+
console.error(`→ ${hint}`);
|
|
262
265
|
process.exit(1);
|
|
263
266
|
}
|
|
264
267
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CONTAINER_RUNTIME_CHECK } from "./constants.js";
|
|
2
|
+
import { DISK_SPACE_CHECK } from "./doctor-host.js";
|
|
3
|
+
const POSIX = (platform) => platform === "darwin" || platform === "linux";
|
|
4
|
+
function conflictingPort(result) {
|
|
5
|
+
const match = /^\s*(\d{2,5}) \(/m.exec(result.message);
|
|
6
|
+
return match ? Number(match[1]) : null;
|
|
7
|
+
}
|
|
8
|
+
function inspectPort(platform, port) {
|
|
9
|
+
if (port === null)
|
|
10
|
+
return null;
|
|
11
|
+
if (platform === "darwin")
|
|
12
|
+
return `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
|
|
13
|
+
if (platform === "linux")
|
|
14
|
+
return `ss -ltnp 'sport = :${port}'`;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const RULES = {
|
|
18
|
+
[CONTAINER_RUNTIME_CHECK]: {
|
|
19
|
+
tier: 0,
|
|
20
|
+
command: (platform) => platform === "darwin"
|
|
21
|
+
? "open -a Docker"
|
|
22
|
+
: platform === "linux" && !process.env.WSL_DISTRO_NAME
|
|
23
|
+
? "sudo systemctl start docker"
|
|
24
|
+
: null,
|
|
25
|
+
},
|
|
26
|
+
Colima: { tier: 1, command: (platform) => (POSIX(platform) ? "colima start" : null) },
|
|
27
|
+
"Host Ports": {
|
|
28
|
+
tier: 2,
|
|
29
|
+
command: (platform, result) => inspectPort(platform, conflictingPort(result)),
|
|
30
|
+
},
|
|
31
|
+
"Ingress Ports": { tier: 2, command: () => null },
|
|
32
|
+
"Tilt CLI": {
|
|
33
|
+
tier: 3,
|
|
34
|
+
command: (platform) => POSIX(platform)
|
|
35
|
+
? "curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash"
|
|
36
|
+
: null,
|
|
37
|
+
},
|
|
38
|
+
Bun: {
|
|
39
|
+
tier: 3,
|
|
40
|
+
command: (platform) => (POSIX(platform) ? "curl -fsSL https://bun.sh/install | bash" : null),
|
|
41
|
+
},
|
|
42
|
+
"Docker Network Pool": { tier: 4, command: () => "flisk down --prune-networks" },
|
|
43
|
+
"Docker networks": { tier: 4, command: () => "flisk down --prune-networks" },
|
|
44
|
+
[DISK_SPACE_CHECK]: { tier: 5, command: () => "docker system prune -af" },
|
|
45
|
+
"Flisk installs": { tier: 6, command: () => "flisk upgrade" },
|
|
46
|
+
"Flisk version": { tier: 6, command: () => "flisk upgrade" },
|
|
47
|
+
"Master Configs": { tier: 7, command: () => "flisk project" },
|
|
48
|
+
"Generated Runtime Assets": { tier: 7, command: () => "flisk config regenerate" },
|
|
49
|
+
"Starlark Load Exports": { tier: 7, command: () => "flisk config regenerate" },
|
|
50
|
+
};
|
|
51
|
+
/** Project-level findings come after every machine-level one that has no rule. */
|
|
52
|
+
const UNRANKED_TIER = 99;
|
|
53
|
+
export function checkId(name) {
|
|
54
|
+
return name
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
57
|
+
.replace(/^-+|-+$/g, "");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Failing checks ordered by how much they block, each with one runnable command. Hard failures come before
|
|
61
|
+
* warnings (a low-disk warning never outranks a stopped daemon), and a port 5432 conflict leads the port tier.
|
|
62
|
+
* Checks without a command that is valid on this platform are left out; their prose fix is still printed.
|
|
63
|
+
*/
|
|
64
|
+
export function rankDoctorFixes(results, platform = process.platform) {
|
|
65
|
+
const candidates = results
|
|
66
|
+
.filter((result) => !result.didPass && !result.isSkipped)
|
|
67
|
+
.map((result, index) => {
|
|
68
|
+
const rule = RULES[result.name];
|
|
69
|
+
return {
|
|
70
|
+
result,
|
|
71
|
+
index,
|
|
72
|
+
tier: rule?.tier ?? UNRANKED_TIER,
|
|
73
|
+
command: rule?.command(platform, result) ?? null,
|
|
74
|
+
};
|
|
75
|
+
})
|
|
76
|
+
.filter((entry) => entry.command !== null);
|
|
77
|
+
const weight = (entry) => (entry.result.isWarning ? 1000 : 0) +
|
|
78
|
+
entry.tier * 10 +
|
|
79
|
+
(entry.result.name === "Host Ports" && conflictingPort(entry.result) === 5432 ? 0 : 1);
|
|
80
|
+
candidates.sort((left, right) => weight(left) - weight(right) || left.index - right.index);
|
|
81
|
+
return candidates.map((entry, position) => ({
|
|
82
|
+
rank: position + 1,
|
|
83
|
+
checkId: checkId(entry.result.name),
|
|
84
|
+
check: entry.result.name,
|
|
85
|
+
command: entry.command,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
/** The human "Fix first" section, or an empty list when there is nothing to run. */
|
|
89
|
+
export function formatFixFirst(fixes) {
|
|
90
|
+
if (fixes.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
return fixes.flatMap((fix) => [` ${fix.rank}. ${fix.check}`, ` ${fix.command}`]);
|
|
93
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CONTAINER_RUNTIME_CHECK } from "./constants.js";
|
|
2
|
+
import { rankDoctorFixes } from "./doctor-fix-first.js";
|
|
2
3
|
import { SCHEMA_VERSION } from "./machine-output.js";
|
|
3
4
|
/** Wait for concurrent probes to finish even if one throws, so they can release resources. */
|
|
4
5
|
export async function collectDoctorChecks(machineChecks, projectChecks) {
|
|
@@ -28,6 +29,7 @@ function containerRuntimeReachable(checks) {
|
|
|
28
29
|
}
|
|
29
30
|
/** Produce the same readiness decision for human and machine consumers. */
|
|
30
31
|
export function createDoctorReport(checks, inProject, errors = [], portPlan, host) {
|
|
32
|
+
const fixFirst = rankDoctorFixes(checks);
|
|
31
33
|
return {
|
|
32
34
|
schemaVersion: SCHEMA_VERSION,
|
|
33
35
|
data: {
|
|
@@ -35,6 +37,7 @@ export function createDoctorReport(checks, inProject, errors = [], portPlan, hos
|
|
|
35
37
|
checks.every((check) => check.didPass || check.isSkipped || check.isWarning),
|
|
36
38
|
inProject,
|
|
37
39
|
checks,
|
|
40
|
+
...(fixFirst.length > 0 ? { fixFirst } : {}),
|
|
38
41
|
...(host
|
|
39
42
|
? {
|
|
40
43
|
host: {
|
|
@@ -406,6 +406,9 @@ export class NativeSupervisor {
|
|
|
406
406
|
spawn(service) {
|
|
407
407
|
const { plan } = service;
|
|
408
408
|
service.startedAt = Date.now();
|
|
409
|
+
// `command` is a shell command line the project owner wrote in their own service.json (`PORT=1 node s.js`, `$PORT`,
|
|
410
|
+
// `a && b`), run with the same trust as an npm script or a Procfile entry; nothing from outside the project reaches it.
|
|
411
|
+
// nosemgrep: javascript.lang.security.audit.spawn-shell-true.spawn-shell-true
|
|
409
412
|
const child = spawn(plan.command, {
|
|
410
413
|
cwd: plan.cwd,
|
|
411
414
|
env: { ...(this.options.baseEnv ?? process.env), ...plan.env },
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `command` runs as a local process started by `flisk up`, with no image, no Dockerfile and no generated files.
|
|
4
4
|
*
|
|
5
5
|
* ```json
|
|
6
|
-
* { "
|
|
6
|
+
* { "appName": "api", "appType": "backend", "command": "node server.js", "port": 4000, "env": { "GREETING": "hi" }, "dependsOn": [] }
|
|
7
7
|
* ```
|
|
8
8
|
*
|
|
9
9
|
* `dev.command` is a different field: it is the dev command inside a generated container (see the Python example) and does not
|
|
@@ -54,7 +54,7 @@ export function validateNativeServiceFields(manifest) {
|
|
|
54
54
|
return errors;
|
|
55
55
|
}
|
|
56
56
|
function serviceLabel(manifest) {
|
|
57
|
-
const name =
|
|
57
|
+
const name = manifest.appName;
|
|
58
58
|
return typeof name === "string" && name ? `"${name}"` : "(unnamed)";
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
@@ -186,21 +186,14 @@ export function validateServiceManifest(value, displayPath) {
|
|
|
186
186
|
}
|
|
187
187
|
const manifest = value;
|
|
188
188
|
const errors = [];
|
|
189
|
-
// A `command` service
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (!(
|
|
196
|
-
errors.push(`${displayPath}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
for (const field of ["appName", "appType", "stack", "schemaVersion"]) {
|
|
201
|
-
if (!(field in manifest))
|
|
202
|
-
errors.push(`${displayPath}.${field}: required field is missing`);
|
|
203
|
-
}
|
|
189
|
+
// A `command` service runs as a local process, so it has no stack to group generated output by (the minimal contract in
|
|
190
|
+
// utils/native-service.ts). Every other service keeps the full set.
|
|
191
|
+
const requiredFields = declaresCommand(manifest)
|
|
192
|
+
? ["appName", "appType", "schemaVersion"]
|
|
193
|
+
: ["appName", "appType", "stack", "schemaVersion"];
|
|
194
|
+
for (const field of requiredFields) {
|
|
195
|
+
if (!(field in manifest))
|
|
196
|
+
errors.push(`${displayPath}.${field}: required field is missing`);
|
|
204
197
|
}
|
|
205
198
|
for (const field of ["appName", "appType", "stack"]) {
|
|
206
199
|
if (field in manifest && typeof manifest[field] !== "string") {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs, { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { Cache, createCacheValidator } from "./cache.js";
|
|
6
|
-
import { AUTOGENERATED_FILE_PATTERNS,
|
|
6
|
+
import { AUTOGENERATED_FILE_PATTERNS, DOCKER_COMPOSE_FILE, PACKAGE_JSON, SERVICE_JSON, SKIP_DIRECTORIES, } from "./constants.js";
|
|
7
7
|
import { errorFactories } from "./errors.js";
|
|
8
8
|
import { formatCount } from "./formatting.js";
|
|
9
9
|
import { declaresCommand, validateNativeServiceFields } from "./native-service.js";
|
|
@@ -41,9 +41,9 @@ function isValidResourceConfig(value) {
|
|
|
41
41
|
if (!value || typeof value !== "object")
|
|
42
42
|
return false;
|
|
43
43
|
const config = value;
|
|
44
|
-
// A `command` service (the minimal contract)
|
|
44
|
+
// A `command` service (the minimal contract) has no legacy forms: appName and appType.
|
|
45
45
|
if (declaresCommand(config)) {
|
|
46
|
-
return typeof config.appName === "string"
|
|
46
|
+
return typeof config.appName === "string" && typeof config.appType === "string";
|
|
47
47
|
}
|
|
48
48
|
// Accept runtime, appType, or type (for backward compatibility with existing service.json files)
|
|
49
49
|
const hasRuntimeOrAppType = typeof config.runtime === "string" ||
|
|
@@ -84,7 +84,9 @@ function parseResource(serviceJsonPath) {
|
|
|
84
84
|
throw new InvalidServiceJsonError(`invalid JSON: ${errorMessage(error)}`);
|
|
85
85
|
}
|
|
86
86
|
if (!isValidResourceConfig(parsed)) {
|
|
87
|
-
throw new InvalidServiceJsonError(
|
|
87
|
+
throw new InvalidServiceJsonError(declaresCommand(parsed)
|
|
88
|
+
? "missing required fields (appName and appType)"
|
|
89
|
+
: "missing required fields (appName, runtime or appType)");
|
|
88
90
|
}
|
|
89
91
|
const nativeErrors = validateNativeServiceFields(parsed);
|
|
90
92
|
if (nativeErrors.length > 0)
|
|
@@ -106,13 +108,8 @@ function parseResource(serviceJsonPath) {
|
|
|
106
108
|
}
|
|
107
109
|
const config = {
|
|
108
110
|
...parsedConfig,
|
|
109
|
-
appName: parsedConfig.appName ?? String(parsedConfig.name),
|
|
110
|
-
// A `command` service without a type is the user's own program, like a bring-your-own container.
|
|
111
111
|
appType: (parsedConfig.appType ||
|
|
112
|
-
parsedConfig.type
|
|
113
|
-
(declaresCommand(parsedConfig)
|
|
114
|
-
? BRING_YOUR_OWN_TYPE
|
|
115
|
-
: undefined)),
|
|
112
|
+
parsedConfig.type),
|
|
116
113
|
};
|
|
117
114
|
const resourceDir = dirname(serviceJsonPath);
|
|
118
115
|
return {
|
|
@@ -4,6 +4,35 @@ import { parseTiltResourceFailures, summarizeTiltBuildError } from "./doctor-run
|
|
|
4
4
|
import { conflictPort, portConflictFix } from "./port-conflict-fix.js";
|
|
5
5
|
import { effectiveRuntimeStatus, isTiltResourcePending } from "./tilt-resource-state.js";
|
|
6
6
|
import { onlyEnabledResources } from "./up-readiness.js";
|
|
7
|
+
/** The resources Tilt says this one is waiting for. */
|
|
8
|
+
function waitingOn(item) {
|
|
9
|
+
return (item.status?.waiting?.on ?? [])
|
|
10
|
+
.filter((ref) => (ref.kind ?? "UIResource") === "UIResource" && ref.name)
|
|
11
|
+
.map((ref) => ref.name);
|
|
12
|
+
}
|
|
13
|
+
// Docker has no free subnet left for a new network (every project creates several and `flisk down` keeps them).
|
|
14
|
+
const NETWORK_POOL_EXHAUSTED = /all predefined address pools have been fully subnetted/i;
|
|
15
|
+
/**
|
|
16
|
+
* Tilt's error for a failed local command is only "exit status 1"; the reason (for example Docker's "all predefined
|
|
17
|
+
* address pools have been fully subnetted") is in the resource's log. Returns the fix for each failed resource whose
|
|
18
|
+
* message or log names a known machine problem.
|
|
19
|
+
*/
|
|
20
|
+
export async function machineProblemFixes(failures, fetchLogs) {
|
|
21
|
+
const fixes = new Map();
|
|
22
|
+
for (const { name, message } of failures.slice(0, 5)) {
|
|
23
|
+
const fix = machineProblemFix(message) ?? machineProblemFix((await fetchLogs(name)) ?? "");
|
|
24
|
+
if (fix)
|
|
25
|
+
fixes.set(name, fix);
|
|
26
|
+
}
|
|
27
|
+
return fixes;
|
|
28
|
+
}
|
|
29
|
+
/** A plain fix for a failure message that names a known machine problem, or null. */
|
|
30
|
+
export function machineProblemFix(message) {
|
|
31
|
+
if (NETWORK_POOL_EXHAUSTED.test(message)) {
|
|
32
|
+
return "Fix: Docker has no free network ranges left. Run `flisk down --prune-networks` in Flisk projects you no longer use (or `docker network prune` to remove every unused network), then run flisk up again";
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
7
36
|
export function transitiveDependencies(name, dependsOn) {
|
|
8
37
|
const seen = new Set();
|
|
9
38
|
const visit = (current) => {
|
|
@@ -31,13 +60,24 @@ export function buildStartupReport(jsonText, dependsOn, deferred = new Set()) {
|
|
|
31
60
|
}));
|
|
32
61
|
const failedNames = new Set(failed.map((entry) => entry.name));
|
|
33
62
|
const items = JSON.parse(enabledJson).items ?? [];
|
|
63
|
+
// service.json dependsOn, plus what Tilt reports as waited for (shared infrastructure such as init-networks, which no
|
|
64
|
+
// service.json names).
|
|
65
|
+
const deps = { ...dependsOn };
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
const name = item.metadata?.name;
|
|
68
|
+
if (!name)
|
|
69
|
+
continue;
|
|
70
|
+
const waits = waitingOn(item);
|
|
71
|
+
if (waits.length > 0)
|
|
72
|
+
deps[name] = [...new Set([...(deps[name] ?? []), ...waits])];
|
|
73
|
+
}
|
|
34
74
|
const blocked = [];
|
|
35
75
|
const starting = [];
|
|
36
76
|
for (const item of items) {
|
|
37
77
|
const name = item.metadata?.name ?? "unknown";
|
|
38
78
|
if (name === "(Tiltfile)" || failedNames.has(name) || deferred.has(name))
|
|
39
79
|
continue;
|
|
40
|
-
const because = [...transitiveDependencies(name,
|
|
80
|
+
const because = [...transitiveDependencies(name, deps)]
|
|
41
81
|
.filter((dep) => failedNames.has(dep))
|
|
42
82
|
.sort();
|
|
43
83
|
if (because.length > 0)
|
|
@@ -52,12 +92,14 @@ export function isStartupStalled(jsonText, dependsOn, deferred = new Set()) {
|
|
|
52
92
|
const report = buildStartupReport(jsonText, dependsOn, deferred);
|
|
53
93
|
return report.failed.length > 0 && report.starting.length === 0;
|
|
54
94
|
}
|
|
55
|
-
export function formatStartupReport(report, timedOut, occupantOf) {
|
|
95
|
+
export function formatStartupReport(report, timedOut, occupantOf, machineFixes = new Map()) {
|
|
56
96
|
const lines = [];
|
|
57
97
|
for (const { name, message } of report.failed) {
|
|
58
98
|
lines.push(`✗ ${name} failed: ${message}`);
|
|
59
99
|
const port = conflictPort(message);
|
|
60
|
-
const fix = portConflictFix(message, port !== null && occupantOf ? occupantOf(port) : null)
|
|
100
|
+
const fix = portConflictFix(message, port !== null && occupantOf ? occupantOf(port) : null) ??
|
|
101
|
+
machineFixes.get(name) ??
|
|
102
|
+
machineProblemFix(message);
|
|
61
103
|
if (fix)
|
|
62
104
|
lines.push(` ${fix}`);
|
|
63
105
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Label of the group that holds services declared without a stack. */
|
|
2
|
+
export const NO_STACK_LABEL = "(no stack)";
|
|
3
|
+
/** A service that is failing or still starting. Unknown (not running, starts on request) is neither. */
|
|
4
|
+
export function isUnhealthyState(state) {
|
|
5
|
+
return state?.status === "error" || state?.status === "pending";
|
|
6
|
+
}
|
|
7
|
+
/** Services grouped by stack, then the ones without a stack, each with its healthy/total count. */
|
|
8
|
+
export function groupServicesByStack(stacks, unassigned, states) {
|
|
9
|
+
const build = (name, resources) => ({
|
|
10
|
+
name,
|
|
11
|
+
resources: [...resources],
|
|
12
|
+
healthy: resources.filter((resource) => states?.[resource.name]?.status === "ready").length,
|
|
13
|
+
total: resources.length,
|
|
14
|
+
});
|
|
15
|
+
const groups = stacks.map((stack) => build(stack.name, stack.resources));
|
|
16
|
+
if (unassigned.length > 0)
|
|
17
|
+
groups.push(build(NO_STACK_LABEL, unassigned));
|
|
18
|
+
return groups;
|
|
19
|
+
}
|
|
20
|
+
/** Names of the failing or pending services, in discovery order. */
|
|
21
|
+
export function unhealthyServiceNames(resources, states) {
|
|
22
|
+
return resources.filter((r) => isUnhealthyState(states?.[r.name])).map((r) => r.name);
|
|
23
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
const DOCS = "https://github.com/flisk-sh/flisk/blob/main/docs";
|
|
4
|
+
/** Troubleshooting entries the hints point at. Each anchor is a heading in docs/troubleshooting.md. */
|
|
5
|
+
export const UP_HINT_DOCS = {
|
|
6
|
+
coldStart: `${DOCS}/troubleshooting.md#cold-start-blocked-docker-or-tilt-missing`,
|
|
7
|
+
unhealthy: `${DOCS}/troubleshooting.md#services-never-turn-healthy-or-are-left-behind`,
|
|
8
|
+
smoke: `${DOCS}/smoke.md`,
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* One or two commands to run next after a failed `flisk up`, plus the troubleshooting page for that failure. Printed
|
|
12
|
+
* under the error and, with `--json`, carried in the error's `suggestions` field.
|
|
13
|
+
*/
|
|
14
|
+
export function upFailureHints(failure) {
|
|
15
|
+
switch (failure.kind) {
|
|
16
|
+
case "machine":
|
|
17
|
+
return [
|
|
18
|
+
"Fix the checks above, then run: flisk doctor",
|
|
19
|
+
`Troubleshooting: ${UP_HINT_DOCS.coldStart}`,
|
|
20
|
+
];
|
|
21
|
+
case "invalid-tilt-port":
|
|
22
|
+
return ["Unset it to use the default port: unset TILT_PORT"];
|
|
23
|
+
case "tilt-exited":
|
|
24
|
+
return [
|
|
25
|
+
"Check Docker, Tilt and the project: flisk doctor",
|
|
26
|
+
"Rerun with Tilt's own output: flisk up --verbose",
|
|
27
|
+
];
|
|
28
|
+
case "services":
|
|
29
|
+
case "smoke": {
|
|
30
|
+
const logs = failure.failed.length > 0
|
|
31
|
+
? [`See why ${failure.failed[0]} failed: flisk logs -s ${failure.failed[0]}`]
|
|
32
|
+
: [];
|
|
33
|
+
return failure.kind === "smoke"
|
|
34
|
+
? [...logs, `Smoke checks: ${UP_HINT_DOCS.smoke}`]
|
|
35
|
+
: [
|
|
36
|
+
...logs,
|
|
37
|
+
"Check the whole stack: flisk doctor",
|
|
38
|
+
`Troubleshooting: ${UP_HINT_DOCS.unhealthy}`,
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|