@flisk-sh/flisk 1.4.2 → 1.4.6
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/CHANGELOG.md +4 -0
- package/README.md +148 -49
- package/cli/dist/backend-languages/bun.js +5 -0
- package/cli/dist/backend-languages/go.js +3 -1
- package/cli/dist/cli.js +9 -0
- package/cli/dist/commands/completion.js +8 -1
- package/cli/dist/commands/config.js +130 -55
- package/cli/dist/commands/doctor.js +66 -27
- package/cli/dist/commands/down.js +33 -1
- package/cli/dist/commands/logs.js +39 -1
- package/cli/dist/commands/project.js +12 -0
- package/cli/dist/commands/resource.js +39 -11
- package/cli/dist/commands/status.js +57 -19
- package/cli/dist/commands/up.js +221 -18
- package/cli/dist/commands/upgrade.js +2 -2
- package/cli/dist/frontend-frameworks/next.js +197 -0
- package/cli/dist/frontend-frameworks/react.js +81 -12
- package/cli/dist/frontend-frameworks/registry.js +5 -4
- package/cli/dist/frontend-frameworks/vue.js +82 -12
- package/cli/dist/generator/resource-templates.js +2 -4
- package/cli/dist/generator/template-engine.js +5 -3
- package/cli/dist/utils/bun-requirement.js +7 -4
- package/cli/dist/utils/cold-preflight.js +55 -34
- package/cli/dist/utils/config-explain.js +197 -0
- package/cli/dist/utils/demo-reset.js +22 -0
- package/cli/dist/utils/discovery-paths.js +7 -1
- package/cli/dist/utils/doctor-native.js +96 -0
- package/cli/dist/utils/host-port-plan.js +8 -3
- package/cli/dist/utils/native-project.js +41 -0
- package/cli/dist/utils/native-runtime.js +513 -0
- package/cli/dist/utils/native-service.js +95 -0
- package/cli/dist/utils/native-up.js +156 -0
- package/cli/dist/utils/platform.js +1 -1
- package/cli/dist/utils/port-conflict-fix.js +16 -14
- package/cli/dist/utils/port-occupant.js +35 -0
- package/cli/dist/utils/project-features.js +7 -16
- package/cli/dist/utils/project-templates.js +9 -9
- package/cli/dist/utils/resource-features.js +5 -5
- package/cli/dist/utils/service-manifest.js +20 -3
- package/cli/dist/utils/service-urls.js +11 -0
- package/cli/dist/utils/services.js +15 -2
- package/cli/dist/utils/startup-report.js +4 -3
- package/cli/dist/utils/tar.js +33 -28
- package/cli/dist/utils/tilt-logs.js +8 -0
- package/cli/dist/utils/tilt-process.js +4 -3
- package/cli/dist/utils/windows-doctor.js +18 -15
- package/cli/dist-bundle/chunk-BIVAAWBX.js +24 -0
- package/cli/dist-bundle/{chunk-YNCBBJ4V.js → chunk-C4UFBXRD.js} +1 -1
- package/cli/dist-bundle/{chunk-53L2JN7G.js → chunk-CAEMWNPF.js} +1 -1
- package/cli/dist-bundle/chunk-CDKEHFW6.js +35 -0
- package/cli/dist-bundle/{chunk-5QBYM33Q.js → chunk-DPQK5TIS.js} +1 -1
- package/cli/dist-bundle/chunk-ETXZ5LUG.js +18 -0
- package/cli/dist-bundle/chunk-GPUJ55MI.js +3 -0
- package/cli/dist-bundle/chunk-GT76VMUL.js +130 -0
- package/cli/dist-bundle/{chunk-IGN5UC3P.js → chunk-IDNES5PE.js} +14 -7
- package/cli/dist-bundle/{chunk-MWOVYIBB.js → chunk-MUDE4DZD.js} +5 -5
- package/cli/dist-bundle/chunk-QEBN6ZVQ.js +2 -0
- package/cli/dist-bundle/{chunk-I7TGIK5N.js → chunk-R7DWXISM.js} +1 -1
- package/cli/dist-bundle/{chunk-DU2BN46C.js → chunk-U5FCQF42.js} +1 -1
- package/cli/dist-bundle/{chunk-3XODDLRJ.js → chunk-WCGBVETR.js} +1 -1
- package/cli/dist-bundle/cli.js +626 -389
- package/cli/dist-bundle/cold-preflight-KUWOL2Q2.js +2 -0
- package/cli/dist-bundle/{demo-stage-OHNTP2UJ.js → demo-stage-QCC4IWXR.js} +1 -1
- package/cli/dist-bundle/{extension-fetch-VEFSCG4B.js → extension-fetch-KIPYDIRM.js} +1 -1
- package/cli/dist-bundle/{open-XROJ6YEY.js → open-AZNX4AC5.js} +1 -1
- package/cli/dist-bundle/project-5SWIGCNP.js +2 -0
- package/cli/dist-bundle/{project-templates-EYSQG2EO.js → project-templates-2VQPUBSQ.js} +1 -1
- package/cli/dist-bundle/{services-BIMF6MVU.js → services-EY5WKPHQ.js} +1 -1
- package/cli/dist-bundle/{tilt-port-owner-ZY5M2JC4.js → tilt-port-owner-UBU6JWOA.js} +1 -1
- package/cli/package.json +1 -1
- package/examples/{tdk-example → flisk-example}/README.md +2 -2
- package/package.json +2 -2
- package/runtime/discovery/AGENTS.md +1 -1
- package/runtime/discovery/TILT_DISCOVERY.star +1 -1
- package/runtime/discovery/deterministic.star +1 -1
- package/runtime/engine/AGENTS.md +3 -3
- package/runtime/engine/README.md +1 -1
- package/runtime/engine/REFACTORING_MANIFEST_SYSTEM.md +1 -1
- package/runtime/engine/assets/docker/wake-gateway/decide.ts +1 -1
- package/runtime/engine/assets/docker/wake-gateway/server.ts +2 -2
- package/runtime/engine/assets/scripts/mac-m1-resource-monitor.sh +6 -6
- package/runtime/engine/schemas/service-schema.json +99 -21
- package/runtime/engine/scripts/debug-container.sh +1 -1
- package/runtime/engine/scripts/generate-manifest.sh +2 -2
- package/runtime/engine/topologies/platform/docker/compose/compose.star +40 -4
- package/runtime/engine/topologies/platform/docker/compose/traefik_standalone.star +19 -4
- package/runtime/engine/topologies/platform/docker/constants.star +2 -2
- package/runtime/engine/topologies/platform/docker/dockerfile/language_dockerfile.star +6 -0
- package/runtime/engine/topologies/platform/docker/dockerfile/next_dockerfile.star +83 -0
- package/runtime/engine/topologies/platform/docker/index.star +4 -0
- package/runtime/engine/topologies/platform/docker/layers/prisma/filesystem_setup.star +7 -0
- package/runtime/engine/topologies/platform/docker/networking/traefik.star +85 -46
- package/runtime/engine/topologies/platform/docker/networking/traefik_static_routes.star +2 -2
- package/runtime/engine/topologies/tilt/common/utils_output.star +1 -0
- package/runtime/engine/topologies/tilt/generators/validators.star +1 -1
- package/runtime/engine/topologies/tilt/generators/vite/frontend.star +2 -2
- package/runtime/engine/topologies/tilt/generators/vite/generators.star +3 -0
- package/runtime/engine/topologies/tilt/manifest/constants.star +3 -0
- package/runtime/engine/topologies/tilt/manifest/mapper.star +1 -1
- package/runtime/engine/topologies/tilt/manifest/parser.star +9 -3
- package/runtime/engine/topologies/tilt/resources/orchestrator/apply_compose_resource_registration.star +9 -4
- package/runtime/engine/topologies/tilt/resources/orchestrator/generators/dependencies/resolver.star +50 -24
- package/runtime/engine/topologies/tilt/resources/orchestrator/generators/env.star +11 -2
- package/runtime/engine/topologies/tilt/resources/orchestrator/generators/manifest_resource.star +23 -9
- package/runtime/ext/ide-components/README.md +1 -1
- package/runtime/ext/ui-enhancements/extension.yaml +2 -2
- package/runtime/specs/specs/TILT_SERVICE_DEFAULTS.star +1 -1
- package/runtime/specs/specs/TILT_TECH_STACK.star +1 -1
- package/cli/dist-bundle/chunk-43XWL2PB.js +0 -61
- package/cli/dist-bundle/chunk-5F7YP5IR.js +0 -17
- package/cli/dist-bundle/chunk-ETKUVKJ2.js +0 -35
- package/cli/dist-bundle/chunk-K6SOCO6V.js +0 -3
- package/cli/dist-bundle/chunk-YHYRGWUH.js +0 -24
- package/cli/dist-bundle/chunk-YL56J2WX.js +0 -2
- package/cli/dist-bundle/cold-preflight-ZOPR7PWJ.js +0 -2
- package/cli/dist-bundle/project-7PTIOHGF.js +0 -2
- /package/examples/{tdk-example → flisk-example}/LICENSE +0 -0
- /package/examples/{tdk-example → flisk-example}/TILT_SERVICE_DEFAULTS.star +0 -0
- /package/examples/{tdk-example → flisk-example}/TILT_TECH_STACK.star +0 -0
- /package/examples/{tdk-example → flisk-example}/Tiltfile +0 -0
- /package/examples/{tdk-example → flisk-example}/services/platform/messaging/docker-compose.yml +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/package.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/service.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-api/src/index.ts +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/index.html +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/package.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/public/.gitkeep +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/service.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/main.tsx +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/orders.tsx +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-app/src/style.css +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/package.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/service.json +0 -0
- /package/examples/{tdk-example → flisk-example}/services/shop/orders-worker/src/index.ts +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
|
+
import { FliskError } from "./errors.js";
|
|
4
5
|
import { hasLocalListener } from "./port-listener-probe.js";
|
|
5
6
|
export const DEFAULT_HOST_PORT_RANGES = {
|
|
6
7
|
ingressHttp: { start: 8080, end: 8180 },
|
|
@@ -43,7 +44,7 @@ export async function isHostPortAvailable(port) {
|
|
|
43
44
|
function parsePort(name, value) {
|
|
44
45
|
const port = Number(value);
|
|
45
46
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
46
|
-
throw new
|
|
47
|
+
throw new FliskError(`${name} must be an integer between 1 and 65535 (received ${JSON.stringify(value)})`, ["Provide a valid port number between 1 and 65535"], 1);
|
|
47
48
|
}
|
|
48
49
|
return port;
|
|
49
50
|
}
|
|
@@ -74,11 +75,15 @@ export async function createHostPortPlan(options = {}) {
|
|
|
74
75
|
break;
|
|
75
76
|
}
|
|
76
77
|
if (explicit) {
|
|
77
|
-
throw new
|
|
78
|
+
throw new FliskError(`${ENV_KEYS[key]}=${port} is already in use. Choose a free host port and retry.`, [
|
|
79
|
+
"Choose a different free host port or unset the environment variable to use fallback ranges",
|
|
80
|
+
], 1);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
if (chosen === undefined) {
|
|
81
|
-
throw new
|
|
84
|
+
throw new FliskError(`No available host port in ${range.start}-${range.end} for ${key}. Set ${ENV_KEYS[key]} to a free port and retry.`, [
|
|
85
|
+
`Free up port range ${range.start}-${range.end} or set ${ENV_KEYS[key]} to an available port`,
|
|
86
|
+
], 1);
|
|
82
87
|
}
|
|
83
88
|
reserved.add(chosen);
|
|
84
89
|
selected[key] = chosen;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SERVICE_JSON } from "./constants.js";
|
|
6
|
+
import { isNativeProject } from "./native-service.js";
|
|
7
|
+
import { findProjectRoot } from "./paths.js";
|
|
8
|
+
import { discoverResourcesWithProblems } from "./services.js";
|
|
9
|
+
/** True when `dir` or a directory up to two levels below it has a service.json; a cheap test before a full discovery scan. */
|
|
10
|
+
export function hasNearbyServiceJson(dir, depth = 0) {
|
|
11
|
+
if (existsSync(join(dir, SERVICE_JSON)))
|
|
12
|
+
return true;
|
|
13
|
+
if (depth >= 2)
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
return readdirSync(dir, { withFileTypes: true }).some((entry) => entry.isDirectory() &&
|
|
17
|
+
!entry.name.startsWith(".") &&
|
|
18
|
+
entry.name !== "node_modules" &&
|
|
19
|
+
hasNearbyServiceJson(join(dir, entry.name), depth + 1));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The project a command for local processes works on: the Flisk project around `cwd`, or, before the first `flisk up` wrote
|
|
27
|
+
* .flisk/project.json, `cwd` itself when every service.json near it declares `command` (the directory `flisk up` adopts).
|
|
28
|
+
* Returns its services only when they all declare `command`; null otherwise, including outside any project.
|
|
29
|
+
*/
|
|
30
|
+
export function findNativeProject(cwd = process.cwd()) {
|
|
31
|
+
const projectRoot = findProjectRoot(cwd) ?? (hasNearbyServiceJson(cwd) ? cwd : null);
|
|
32
|
+
if (!projectRoot)
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const { resources } = discoverResourcesWithProblems(projectRoot);
|
|
36
|
+
return isNativeProject(resources) ? { projectRoot, resources } : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Copyright (c) 2026 Flisk Landscape contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { commandProgram } from "./native-service.js";
|
|
9
|
+
import { findOnPath } from "./which.js";
|
|
10
|
+
/** The directory, under the project's .flisk/, that holds the run state and log files. */
|
|
11
|
+
export const NATIVE_DIR = join(".flisk", "native");
|
|
12
|
+
const RUN_FILE = "run.json";
|
|
13
|
+
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
|
14
|
+
const ALIVE_GRACE_MS = 1_000;
|
|
15
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
16
|
+
const MAX_QUICK_CRASHES = 5;
|
|
17
|
+
/** A process that ran at least this long before exiting crashed "slowly": its restart delay starts over. */
|
|
18
|
+
const QUICK_CRASH_MS = 10_000;
|
|
19
|
+
/** `http://localhost:<port>` for display and health checks. Built here so callers never hold a URL literal. */
|
|
20
|
+
export function localUrl(port, path = "") {
|
|
21
|
+
return `http://localhost:${port}${path}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Expands `${VAR}` and `${VAR:-default}` from `vars`. An unknown variable without a default becomes an empty string, the way a
|
|
25
|
+
* shell expands it, and is reported through `onMissing` so `flisk up` can say which one.
|
|
26
|
+
*/
|
|
27
|
+
export function expandEnvValue(value, vars, onMissing = () => { }) {
|
|
28
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_match, name, fallback) => {
|
|
29
|
+
const found = vars[name];
|
|
30
|
+
if (found !== undefined && found !== "")
|
|
31
|
+
return found;
|
|
32
|
+
if (fallback !== undefined)
|
|
33
|
+
return fallback;
|
|
34
|
+
onMissing(name);
|
|
35
|
+
return "";
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start order as waves: every service in a wave depends only on services in earlier waves. Names in `dependsOn` that are not
|
|
40
|
+
* part of the selection are ignored here (planNativeRun reports them). Throws on a cycle; the service config gate refuses
|
|
41
|
+
* cycles first, so this is a safety net.
|
|
42
|
+
*/
|
|
43
|
+
export function dependencyWaves(services) {
|
|
44
|
+
const names = new Set(services.map((service) => service.name));
|
|
45
|
+
const remaining = new Map(services.map((service) => [service.name, service.dependsOn.filter((dep) => names.has(dep))]));
|
|
46
|
+
const done = new Set();
|
|
47
|
+
const waves = [];
|
|
48
|
+
while (remaining.size > 0) {
|
|
49
|
+
const wave = [...remaining.entries()]
|
|
50
|
+
.filter(([, deps]) => deps.every((dep) => done.has(dep)))
|
|
51
|
+
.map(([name]) => name)
|
|
52
|
+
.sort();
|
|
53
|
+
if (wave.length === 0) {
|
|
54
|
+
throw new Error(`circular dependsOn between ${[...remaining.keys()].sort().join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
for (const name of wave) {
|
|
57
|
+
remaining.delete(name);
|
|
58
|
+
done.add(name);
|
|
59
|
+
}
|
|
60
|
+
waves.push(wave);
|
|
61
|
+
}
|
|
62
|
+
return waves;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Turns the selected `command` services into start plans and reports what would stop the run: a dependsOn that is not a service
|
|
66
|
+
* of the selection (a database or the gateway needs Docker for now), and two services on one port.
|
|
67
|
+
*/
|
|
68
|
+
export function planNativeRun(selected, vars = process.env) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
const missingVars = [];
|
|
71
|
+
const names = new Set(selected.map((resource) => resource.name));
|
|
72
|
+
const portOwners = new Map();
|
|
73
|
+
const services = selected.map((resource) => {
|
|
74
|
+
const config = resource.config ?? {};
|
|
75
|
+
const dependsOn = Array.isArray(config.dependsOn) ? config.dependsOn : [];
|
|
76
|
+
for (const dep of dependsOn) {
|
|
77
|
+
if (!names.has(dep)) {
|
|
78
|
+
errors.push(`service "${resource.name}" dependsOn "${dep}", which is not a service with a command in this project. Shared infrastructure such as a database or the gateway still needs Docker: add a "${dep}" service with a command, or remove it from dependsOn.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const port = typeof config.port === "number" ? config.port : undefined;
|
|
82
|
+
if (port !== undefined) {
|
|
83
|
+
const owner = portOwners.get(port);
|
|
84
|
+
if (owner)
|
|
85
|
+
errors.push(`services "${owner}" and "${resource.name}" both use port ${port}`);
|
|
86
|
+
else
|
|
87
|
+
portOwners.set(port, resource.name);
|
|
88
|
+
}
|
|
89
|
+
const env = {};
|
|
90
|
+
for (const [key, value] of Object.entries(config.env ?? {})) {
|
|
91
|
+
env[key] = expandEnvValue(String(value), { ...vars, ...env }, (name) => missingVars.push(`${resource.name}: ${name}`));
|
|
92
|
+
}
|
|
93
|
+
if (port !== undefined)
|
|
94
|
+
env.PORT = String(port);
|
|
95
|
+
const healthPath = typeof config.healthCheckPath === "string" ? config.healthCheckPath : "";
|
|
96
|
+
return {
|
|
97
|
+
name: resource.name,
|
|
98
|
+
command: String(config.command),
|
|
99
|
+
cwd: resource.path,
|
|
100
|
+
port,
|
|
101
|
+
env,
|
|
102
|
+
dependsOn: dependsOn.filter((dep) => names.has(dep)),
|
|
103
|
+
healthUrl: port !== undefined && healthPath ? localUrl(port, healthPath) : undefined,
|
|
104
|
+
url: port !== undefined ? localUrl(port) : undefined,
|
|
105
|
+
restart: config.restart !== "no",
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
dependencyWaves(services);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
113
|
+
}
|
|
114
|
+
return { services, errors, missingVars };
|
|
115
|
+
}
|
|
116
|
+
/** Services whose command starts a program that is not on PATH (a relative path such as ./bin/api is checked in the service directory). */
|
|
117
|
+
export function findMissingPrograms(plans, find = findOnPath, exists = existsSync) {
|
|
118
|
+
const missing = [];
|
|
119
|
+
for (const plan of plans) {
|
|
120
|
+
const program = commandProgram(plan.command);
|
|
121
|
+
// A shell builtin or an expression we cannot parse is left to the shell to report.
|
|
122
|
+
if (!program || ["cd", ".", "source", "true", ":"].includes(program))
|
|
123
|
+
continue;
|
|
124
|
+
const found = program.includes("/") ? exists(join(plan.cwd, program)) : Boolean(find(program));
|
|
125
|
+
if (!found)
|
|
126
|
+
missing.push({ name: plan.name, program });
|
|
127
|
+
}
|
|
128
|
+
return missing;
|
|
129
|
+
}
|
|
130
|
+
/** True when something accepts TCP connections on localhost:port. */
|
|
131
|
+
export function isPortListening(port, timeoutMs = 1_000) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const socket = connect({ host: "localhost", port });
|
|
134
|
+
const finish = (listening) => {
|
|
135
|
+
socket.destroy();
|
|
136
|
+
resolve(listening);
|
|
137
|
+
};
|
|
138
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
139
|
+
socket.once("connect", () => finish(true));
|
|
140
|
+
socket.once("error", () => finish(false));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async function healthAnswers(url) {
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: "manual" });
|
|
146
|
+
await response.body?.cancel();
|
|
147
|
+
return response.status < 400;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Polls a plan's readiness signal until it passes, `isAlive` turns false, or the timeout passes. */
|
|
154
|
+
export async function waitForServiceReady(plan, options) {
|
|
155
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
156
|
+
const interval = options.intervalMs ?? 250;
|
|
157
|
+
if (!plan.healthUrl && plan.port === undefined) {
|
|
158
|
+
await sleep(ALIVE_GRACE_MS);
|
|
159
|
+
return options.isAlive();
|
|
160
|
+
}
|
|
161
|
+
while (Date.now() < deadline) {
|
|
162
|
+
if (!options.isAlive())
|
|
163
|
+
return false;
|
|
164
|
+
const ready = plan.healthUrl
|
|
165
|
+
? await healthAnswers(plan.healthUrl)
|
|
166
|
+
: await isPortListening(plan.port);
|
|
167
|
+
if (ready)
|
|
168
|
+
return true;
|
|
169
|
+
await sleep(interval);
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
function sleep(milliseconds) {
|
|
174
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
175
|
+
}
|
|
176
|
+
export function nativeRunStatePath(projectRoot) {
|
|
177
|
+
return join(projectRoot, NATIVE_DIR, RUN_FILE);
|
|
178
|
+
}
|
|
179
|
+
export function nativeLogPath(projectRoot, service) {
|
|
180
|
+
return join(projectRoot, NATIVE_DIR, "logs", `${service}.log`);
|
|
181
|
+
}
|
|
182
|
+
export function readNativeRunState(projectRoot) {
|
|
183
|
+
try {
|
|
184
|
+
const state = JSON.parse(readFileSync(nativeRunStatePath(projectRoot), "utf-8"));
|
|
185
|
+
return state && typeof state.pid === "number" && state.services ? state : null;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function writeNativeRunState(projectRoot, state) {
|
|
192
|
+
const dir = join(projectRoot, NATIVE_DIR);
|
|
193
|
+
mkdirSync(join(dir, "logs"), { recursive: true });
|
|
194
|
+
// Run state and logs are this machine's, never the project's: keep them out of git without touching the user's .gitignore.
|
|
195
|
+
const ignore = join(dir, ".gitignore");
|
|
196
|
+
if (!existsSync(ignore))
|
|
197
|
+
writeFileSync(ignore, "*\n");
|
|
198
|
+
writeFileSync(nativeRunStatePath(projectRoot), `${JSON.stringify(state, null, 2)}\n`);
|
|
199
|
+
}
|
|
200
|
+
/** True while the process exists (a zombie or another user's process counts as existing). */
|
|
201
|
+
export function isProcessAlive(pid) {
|
|
202
|
+
try {
|
|
203
|
+
process.kill(pid, 0);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return error.code === "EPERM";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Signals a service's whole process group (the shell and what it started), falling back to the process alone. */
|
|
211
|
+
function signalGroup(pid, signal) {
|
|
212
|
+
try {
|
|
213
|
+
process.kill(-pid, signal);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
try {
|
|
217
|
+
process.kill(pid, signal);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Already gone.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function waitForExit(pid, timeoutMs) {
|
|
225
|
+
const deadline = Date.now() + timeoutMs;
|
|
226
|
+
while (Date.now() < deadline) {
|
|
227
|
+
if (!isProcessAlive(pid))
|
|
228
|
+
return true;
|
|
229
|
+
await sleep(100);
|
|
230
|
+
}
|
|
231
|
+
return !isProcessAlive(pid);
|
|
232
|
+
}
|
|
233
|
+
/** SIGTERM to the process group, SIGKILL after the timeout. */
|
|
234
|
+
async function stopProcessGroup(pid, timeoutMs = STOP_TIMEOUT_MS) {
|
|
235
|
+
signalGroup(pid, "SIGTERM");
|
|
236
|
+
if (!(await waitForExit(pid, timeoutMs)))
|
|
237
|
+
signalGroup(pid, "SIGKILL");
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Stops the native run recorded for this project: asks the supervising `flisk up` to stop its services, then stops any service
|
|
241
|
+
* process still alive (a supervisor killed with SIGKILL cannot stop them itself). Returns the services that were stopped.
|
|
242
|
+
*/
|
|
243
|
+
export async function stopNativeRun(projectRoot, options = {}) {
|
|
244
|
+
const state = readNativeRunState(projectRoot);
|
|
245
|
+
if (!state)
|
|
246
|
+
return { wasRunning: false, services: [] };
|
|
247
|
+
const timeoutMs = options.timeoutMs ?? STOP_TIMEOUT_MS * 2;
|
|
248
|
+
if (state.pid !== process.pid && isProcessAlive(state.pid)) {
|
|
249
|
+
try {
|
|
250
|
+
process.kill(state.pid, "SIGTERM");
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Gone between the check and the signal.
|
|
254
|
+
}
|
|
255
|
+
await waitForExit(state.pid, timeoutMs);
|
|
256
|
+
}
|
|
257
|
+
for (const service of Object.values(state.services)) {
|
|
258
|
+
if (service.pid && isProcessAlive(service.pid))
|
|
259
|
+
await stopProcessGroup(service.pid);
|
|
260
|
+
}
|
|
261
|
+
rmSync(nativeRunStatePath(projectRoot), { force: true });
|
|
262
|
+
return { wasRunning: true, services: Object.keys(state.services), supervisorPid: state.pid };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Each service's state from the run on record, in the shape `flisk status` reports Tilt's: ready once it passed its readiness
|
|
266
|
+
* check, pending while it starts or the supervisor restarts it, unknown when nothing runs.
|
|
267
|
+
*/
|
|
268
|
+
export function nativeServiceStates(projectRoot, services) {
|
|
269
|
+
const state = readNativeRunState(projectRoot);
|
|
270
|
+
const isUp = state !== null && isProcessAlive(state.pid);
|
|
271
|
+
return Object.fromEntries(services.map((name) => {
|
|
272
|
+
const service = state?.services[name];
|
|
273
|
+
if (!isUp || !service)
|
|
274
|
+
return [name, { status: "unknown", reason: "not running" }];
|
|
275
|
+
if (!service.pid || !isProcessAlive(service.pid)) {
|
|
276
|
+
return [name, { status: "pending", reason: "restarting" }];
|
|
277
|
+
}
|
|
278
|
+
return service.ready
|
|
279
|
+
? [name, { status: "ready" }]
|
|
280
|
+
: [name, { status: "pending", reason: "starting" }];
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The last `tail` lines the given services wrote, oldest first, merged across services by time. `sinceMs` drops lines older
|
|
285
|
+
* than that many milliseconds. A service that wrote nothing yet has no log file and contributes no lines.
|
|
286
|
+
*/
|
|
287
|
+
export function readNativeLogLines(projectRoot, services, options) {
|
|
288
|
+
const cutoff = options.sinceMs === undefined ? null : (options.now ?? Date.now()) - options.sinceMs;
|
|
289
|
+
const lines = [];
|
|
290
|
+
for (const resource of services) {
|
|
291
|
+
let content;
|
|
292
|
+
try {
|
|
293
|
+
content = readFileSync(nativeLogPath(projectRoot, resource), "utf-8");
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
for (const raw of content.split("\n")) {
|
|
299
|
+
if (!raw)
|
|
300
|
+
continue;
|
|
301
|
+
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) (.*)$/.exec(raw);
|
|
302
|
+
const time = match?.[1] ?? null;
|
|
303
|
+
if (cutoff !== null && (!time || Date.parse(time) < cutoff))
|
|
304
|
+
continue;
|
|
305
|
+
lines.push({ time, resource, level: null, source: null, text: match?.[2] ?? raw });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Stable sort: lines of one service keep their order when two share a millisecond.
|
|
309
|
+
lines.sort((a, b) => (a.time ?? "").localeCompare(b.time ?? ""));
|
|
310
|
+
return lines.slice(-options.tail);
|
|
311
|
+
}
|
|
312
|
+
const PREFIX_COLORS = [chalk.cyan, chalk.magenta, chalk.green, chalk.yellow, chalk.blue, chalk.red];
|
|
313
|
+
/** Starts and supervises the planned services. One instance per `flisk up`. */
|
|
314
|
+
export class NativeSupervisor {
|
|
315
|
+
projectRoot;
|
|
316
|
+
events;
|
|
317
|
+
options;
|
|
318
|
+
services = new Map();
|
|
319
|
+
startedAt = new Date().toISOString();
|
|
320
|
+
stopping = false;
|
|
321
|
+
constructor(projectRoot, plans, events = {}, options = {}) {
|
|
322
|
+
this.projectRoot = projectRoot;
|
|
323
|
+
this.events = events;
|
|
324
|
+
this.options = options;
|
|
325
|
+
for (const plan of plans) {
|
|
326
|
+
this.services.set(plan.name, {
|
|
327
|
+
plan,
|
|
328
|
+
startedAt: 0,
|
|
329
|
+
quickCrashes: 0,
|
|
330
|
+
gaveUp: false,
|
|
331
|
+
isReady: false,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** The width service names are padded to in prefixed output. */
|
|
336
|
+
get prefixWidth() {
|
|
337
|
+
return Math.max(...[...this.services.keys()].map((name) => name.length));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Starts every service once its dependencies are ready. Resolves with the services that never became ready (empty when the
|
|
341
|
+
* whole selection is up); a service whose dependency failed is reported as blocked and never started.
|
|
342
|
+
*/
|
|
343
|
+
async start() {
|
|
344
|
+
this.saveState();
|
|
345
|
+
const plans = [...this.services.values()].map((service) => service.plan);
|
|
346
|
+
const ready = new Map();
|
|
347
|
+
const failures = [];
|
|
348
|
+
for (const name of dependencyWaves(plans).flat()) {
|
|
349
|
+
const service = this.services.get(name);
|
|
350
|
+
ready.set(name, (async () => {
|
|
351
|
+
const deps = await Promise.all(service.plan.dependsOn.map((dep) => ready.get(dep)));
|
|
352
|
+
if (!deps.every(Boolean)) {
|
|
353
|
+
const blockedBy = service.plan.dependsOn.filter((_dep, index) => !deps[index]);
|
|
354
|
+
failures.push({ name, reason: `not started: ${blockedBy.join(", ")} did not start` });
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (this.stopping)
|
|
358
|
+
return false;
|
|
359
|
+
this.spawn(service);
|
|
360
|
+
const isReady = await waitForServiceReady(service.plan, {
|
|
361
|
+
isAlive: () => !this.stopping && !service.gaveUp,
|
|
362
|
+
timeoutMs: this.options.readyTimeoutMs,
|
|
363
|
+
});
|
|
364
|
+
if (isReady) {
|
|
365
|
+
service.isReady = true;
|
|
366
|
+
this.saveState();
|
|
367
|
+
this.events.onNote?.(name, "ready");
|
|
368
|
+
}
|
|
369
|
+
else if (!this.stopping) {
|
|
370
|
+
failures.push({
|
|
371
|
+
name,
|
|
372
|
+
reason: service.gaveUp
|
|
373
|
+
? "kept exiting; see its output above"
|
|
374
|
+
: `not ready after ${Math.round((this.options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS) / 1000)}s (${service.plan.healthUrl ? `no answer from ${service.plan.healthUrl}` : `nothing listening on port ${service.plan.port}`})`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return isReady;
|
|
378
|
+
})());
|
|
379
|
+
}
|
|
380
|
+
await Promise.all(ready.values());
|
|
381
|
+
return failures;
|
|
382
|
+
}
|
|
383
|
+
/** Stops every service, dependents before what they depend on, and removes the run state. */
|
|
384
|
+
async stop() {
|
|
385
|
+
if (this.stopping)
|
|
386
|
+
return;
|
|
387
|
+
this.stopping = true;
|
|
388
|
+
const order = dependencyWaves([...this.services.values()].map((s) => s.plan))
|
|
389
|
+
.flat()
|
|
390
|
+
.reverse();
|
|
391
|
+
for (const name of order) {
|
|
392
|
+
const service = this.services.get(name);
|
|
393
|
+
if (service.restartTimer)
|
|
394
|
+
clearTimeout(service.restartTimer);
|
|
395
|
+
const pid = service.child?.pid;
|
|
396
|
+
if (pid && service.child?.exitCode === null && service.child.signalCode === null) {
|
|
397
|
+
await stopProcessGroup(pid);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
rmSync(nativeRunStatePath(this.projectRoot), { force: true });
|
|
401
|
+
}
|
|
402
|
+
/** The current process id of a service, for tests and diagnostics. */
|
|
403
|
+
pidOf(name) {
|
|
404
|
+
return this.services.get(name)?.child?.pid;
|
|
405
|
+
}
|
|
406
|
+
spawn(service) {
|
|
407
|
+
const { plan } = service;
|
|
408
|
+
service.startedAt = Date.now();
|
|
409
|
+
const child = spawn(plan.command, {
|
|
410
|
+
cwd: plan.cwd,
|
|
411
|
+
env: { ...(this.options.baseEnv ?? process.env), ...plan.env },
|
|
412
|
+
shell: true,
|
|
413
|
+
// Its own process group, so a stop reaches what the shell started too, and a Ctrl+C in the terminal reaches only
|
|
414
|
+
// `flisk up`, which then stops the services in order.
|
|
415
|
+
detached: true,
|
|
416
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
417
|
+
});
|
|
418
|
+
service.child = child;
|
|
419
|
+
this.events.onNote?.(plan.name, `started: ${plan.command}${child.pid ? ` (pid ${child.pid})` : ""}`);
|
|
420
|
+
this.pipeLines(plan.name, child.stdout, "stdout");
|
|
421
|
+
this.pipeLines(plan.name, child.stderr, "stderr");
|
|
422
|
+
child.once("error", (error) => {
|
|
423
|
+
this.events.onNote?.(plan.name, `could not start: ${error.message}`);
|
|
424
|
+
});
|
|
425
|
+
child.once("exit", (code, signal) => this.onExit(service, child, code, signal));
|
|
426
|
+
this.saveState();
|
|
427
|
+
}
|
|
428
|
+
onExit(service, child, code, signal) {
|
|
429
|
+
if (this.stopping || service.child !== child)
|
|
430
|
+
return;
|
|
431
|
+
service.isReady = false;
|
|
432
|
+
this.saveState();
|
|
433
|
+
const how = signal ? `was stopped by ${signal}` : `exited with code ${code}`;
|
|
434
|
+
if (!service.plan.restart) {
|
|
435
|
+
service.gaveUp = true;
|
|
436
|
+
this.events.onNote?.(service.plan.name, `${how}; not restarting ("restart": "no")`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const ranFor = Date.now() - service.startedAt;
|
|
440
|
+
service.quickCrashes = ranFor < QUICK_CRASH_MS ? service.quickCrashes + 1 : 1;
|
|
441
|
+
if (service.quickCrashes >= MAX_QUICK_CRASHES) {
|
|
442
|
+
service.gaveUp = true;
|
|
443
|
+
this.events.onNote?.(service.plan.name, `${how}; it exited ${MAX_QUICK_CRASHES} times in a row, so it is not restarted. Fix it, then run flisk up again.`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const delay = (this.options.restartDelayMs ?? 1_000) * 2 ** (service.quickCrashes - 1);
|
|
447
|
+
this.events.onNote?.(service.plan.name, `${how}; restarting in ${delay / 1000}s`);
|
|
448
|
+
service.restartTimer = setTimeout(() => {
|
|
449
|
+
if (this.stopping)
|
|
450
|
+
return;
|
|
451
|
+
this.spawn(service);
|
|
452
|
+
void waitForServiceReady(service.plan, {
|
|
453
|
+
isAlive: () => !this.stopping && service.child?.exitCode === null,
|
|
454
|
+
timeoutMs: this.options.readyTimeoutMs,
|
|
455
|
+
}).then((isReady) => {
|
|
456
|
+
if (!isReady || this.stopping)
|
|
457
|
+
return;
|
|
458
|
+
service.isReady = true;
|
|
459
|
+
this.saveState();
|
|
460
|
+
this.events.onNote?.(service.plan.name, "ready again");
|
|
461
|
+
});
|
|
462
|
+
}, delay);
|
|
463
|
+
}
|
|
464
|
+
pipeLines(name, stream, kind) {
|
|
465
|
+
if (!stream)
|
|
466
|
+
return;
|
|
467
|
+
const logPath = nativeLogPath(this.projectRoot, name);
|
|
468
|
+
let pending = "";
|
|
469
|
+
const emit = (line) => {
|
|
470
|
+
try {
|
|
471
|
+
// A timestamp per line lets `flisk logs` merge services in order and honour --since.
|
|
472
|
+
appendFileSync(logPath, `${new Date().toISOString()} ${line}\n`);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// A full disk must not stop the service; the terminal still shows the line.
|
|
476
|
+
}
|
|
477
|
+
this.events.onLine?.(name, line, kind);
|
|
478
|
+
};
|
|
479
|
+
stream.setEncoding?.("utf-8");
|
|
480
|
+
stream.on("data", (chunk) => {
|
|
481
|
+
const lines = (pending + chunk).split(/\r?\n/);
|
|
482
|
+
pending = lines.pop() ?? "";
|
|
483
|
+
for (const line of lines)
|
|
484
|
+
emit(line);
|
|
485
|
+
});
|
|
486
|
+
stream.on("end", () => {
|
|
487
|
+
if (pending)
|
|
488
|
+
emit(pending);
|
|
489
|
+
pending = "";
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
saveState() {
|
|
493
|
+
const services = {};
|
|
494
|
+
for (const [name, service] of this.services) {
|
|
495
|
+
services[name] = {
|
|
496
|
+
pid: service.child?.pid,
|
|
497
|
+
port: service.plan.port,
|
|
498
|
+
url: service.plan.url,
|
|
499
|
+
ready: service.isReady,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
writeNativeRunState(this.projectRoot, {
|
|
503
|
+
pid: process.pid,
|
|
504
|
+
startedAt: this.startedAt,
|
|
505
|
+
services,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** `name | ` in the service's color, padded so the output of several services lines up. */
|
|
510
|
+
export function formatPrefix(name, index, width) {
|
|
511
|
+
const color = PREFIX_COLORS[index % PREFIX_COLORS.length] ?? chalk.cyan;
|
|
512
|
+
return color(`${name.padEnd(width)} | `);
|
|
513
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minimal service contract (openspec/changes/make-flisk-unopinionated, #2227): a service.json that declares a top-level
|
|
3
|
+
* `command` runs as a local process started by `flisk up`, with no image, no Dockerfile and no generated files.
|
|
4
|
+
*
|
|
5
|
+
* ```json
|
|
6
|
+
* { "name": "api", "command": "node server.js", "port": 4000, "env": { "GREETING": "hi" }, "dependsOn": [], "healthCheckPath": "/health" }
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* `dev.command` is a different field: it is the dev command inside a generated container (see the Python example) and does not
|
|
10
|
+
* make a service native.
|
|
11
|
+
*/
|
|
12
|
+
/** Container runtime sources. A service has exactly one runtime source, so `command` excludes both. */
|
|
13
|
+
const CONTAINER_SOURCE_FIELDS = ["image", "dockerfile"];
|
|
14
|
+
/** True when the manifest declares a non-empty top-level `command`. */
|
|
15
|
+
export function declaresCommand(manifest) {
|
|
16
|
+
return typeof manifest?.command === "string" && manifest.command.trim() !== "";
|
|
17
|
+
}
|
|
18
|
+
/** True for a service that runs as a local process (its service.json declares `command`). */
|
|
19
|
+
export function isNativeService(resource) {
|
|
20
|
+
return declaresCommand(resource.config);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Errors in the native part of one service.json: a second runtime source next to `command`, a `command` that is not a string,
|
|
24
|
+
* and an `env` that is not a map of strings. Each error names the service, so a project with many files says which one is wrong.
|
|
25
|
+
* A manifest without `command` returns no errors: the container contract is validated elsewhere and is unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export function validateNativeServiceFields(manifest) {
|
|
28
|
+
const name = serviceLabel(manifest);
|
|
29
|
+
const errors = [];
|
|
30
|
+
if ("command" in manifest && !declaresCommand(manifest)) {
|
|
31
|
+
errors.push(`service ${name}: command must be a non-empty string`);
|
|
32
|
+
}
|
|
33
|
+
if (!declaresCommand(manifest))
|
|
34
|
+
return errors;
|
|
35
|
+
const conflicting = CONTAINER_SOURCE_FIELDS.filter((field) => manifest[field] !== undefined && manifest[field] !== "");
|
|
36
|
+
if (conflicting.length > 0) {
|
|
37
|
+
errors.push(`service ${name}: declares both command and ${conflicting.join(" and ")}; a service has exactly one runtime source (remove ${conflicting.length === 1 ? "it" : "them"} to run the command locally, or remove command to run in Docker)`);
|
|
38
|
+
}
|
|
39
|
+
const env = manifest.env;
|
|
40
|
+
if (env !== undefined) {
|
|
41
|
+
const isMap = env !== null && typeof env === "object" && !Array.isArray(env);
|
|
42
|
+
const badKeys = isMap
|
|
43
|
+
? Object.entries(env)
|
|
44
|
+
.filter(([key, value]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== "string")
|
|
45
|
+
.map(([key]) => key)
|
|
46
|
+
: [];
|
|
47
|
+
if (!isMap) {
|
|
48
|
+
errors.push(`service ${name}: env must be an object of string values`);
|
|
49
|
+
}
|
|
50
|
+
else if (badKeys.length > 0) {
|
|
51
|
+
errors.push(`service ${name}: env ${badKeys.join(", ")}: names must be letters, digits or _ (not starting with a digit) and values must be strings`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return errors;
|
|
55
|
+
}
|
|
56
|
+
function serviceLabel(manifest) {
|
|
57
|
+
const name = typeof manifest.appName === "string" ? manifest.appName : manifest.name;
|
|
58
|
+
return typeof name === "string" && name ? `"${name}"` : "(unnamed)";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* How `flisk up` runs a selection: natively when every selected service declares `command`, in containers (Docker + Tilt) when
|
|
62
|
+
* none does. A selection that mixes both, or a container selection in a project that also holds native services (Tilt discovers
|
|
63
|
+
* every service.json, not just the selected ones), cannot run yet: `mixed` lists the native services in the way.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveRuntimeMode(selected, all = selected) {
|
|
66
|
+
const native = all.filter(isNativeService).map((resource) => resource.name);
|
|
67
|
+
if (selected.length > 0 && selected.every(isNativeService))
|
|
68
|
+
return { mode: "native", mixed: [] };
|
|
69
|
+
return { mode: "container", mixed: native };
|
|
70
|
+
}
|
|
71
|
+
/** The refusal for a selection that mixes native and container services. */
|
|
72
|
+
export function formatMixedRuntimeRefusal(nativeNames, containerNames) {
|
|
73
|
+
return [
|
|
74
|
+
`This run mixes services that run as local processes (${nativeNames.join(", ")}) with services that run in Docker (${containerNames.join(", ")}).`,
|
|
75
|
+
"Flisk cannot start both kinds in one project yet.",
|
|
76
|
+
"Give every service a `command` to run without Docker, or remove `command` from these services to run them in Docker.",
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
/** True when a project's services all run natively, so Docker, Compose and Tilt are not needed. An empty project is not native. */
|
|
80
|
+
export function isNativeProject(resources) {
|
|
81
|
+
return resources.length > 0 && resources.every(isNativeService);
|
|
82
|
+
}
|
|
83
|
+
/** The program a shell command runs first (`node` for `PORT=1 node server.js`), or null when it cannot be told. */
|
|
84
|
+
export function commandProgram(command) {
|
|
85
|
+
const words = command.trim().split(/\s+/);
|
|
86
|
+
for (const word of words) {
|
|
87
|
+
// Leading VAR=value assignments set the environment of the program, they are not the program.
|
|
88
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))
|
|
89
|
+
continue;
|
|
90
|
+
if (word === "exec" || word === "env")
|
|
91
|
+
continue;
|
|
92
|
+
return /^[A-Za-z0-9_./@+-]+$/.test(word) ? word : null;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|