create-svc 0.1.75 → 0.1.77
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/package.json
CHANGED
|
@@ -300,6 +300,21 @@ async function runDoctor() {
|
|
|
300
300
|
const state = JSON.parse(text) as SdkState;
|
|
301
301
|
return formatSdkModeDetail(state, bufModule());
|
|
302
302
|
});
|
|
303
|
+
await record(results, "SDK remote publish", "warn", async () => {
|
|
304
|
+
const text = await Bun.file(".service/sdk.json").text();
|
|
305
|
+
const state = JSON.parse(text) as SdkState;
|
|
306
|
+
const module = state.module || bufModule();
|
|
307
|
+
if (state.mode !== "remote") {
|
|
308
|
+
throw new Error(`SDK is in ${state.mode} mode; run service sdk publish to publish ${module}`);
|
|
309
|
+
}
|
|
310
|
+
const authEnv = resolveBufAuthEnv();
|
|
311
|
+
run("buf", ["registry", "module", "info", module], { env: authEnv });
|
|
312
|
+
const published = resolvePublishedSdk(authEnv);
|
|
313
|
+
if (state.remote?.commit && published.commit !== state.remote.commit) {
|
|
314
|
+
return `remote module readable; latest ${published.commit}, recorded ${state.remote.commit}`;
|
|
315
|
+
}
|
|
316
|
+
return `remote module readable at ${module}@${published.commit}`;
|
|
317
|
+
});
|
|
303
318
|
}
|
|
304
319
|
|
|
305
320
|
const output = results.map(formatDoctorResult).join("\n");
|
|
@@ -30,6 +30,43 @@ describe("local dev cleanup", () => {
|
|
|
30
30
|
expect(await Bun.file(join(root, ".service", "local-dev.pid")).exists()).toBe(false);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
test("stops service-owned listeners when the pid file is missing", async () => {
|
|
34
|
+
if (!Bun.which("lsof")) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const root = await tempRoot();
|
|
39
|
+
const port = await reservePort();
|
|
40
|
+
const child = Bun.spawn(
|
|
41
|
+
[
|
|
42
|
+
"bun",
|
|
43
|
+
"-e",
|
|
44
|
+
"Bun.serve({ port: Number(Bun.env.PORT), fetch() { return new Response('ok'); } }); setInterval(() => {}, 1000);",
|
|
45
|
+
],
|
|
46
|
+
{
|
|
47
|
+
cwd: root,
|
|
48
|
+
env: { ...process.env, PORT: String(port) },
|
|
49
|
+
stdout: "pipe",
|
|
50
|
+
stderr: "pipe",
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
if (!child.pid) {
|
|
54
|
+
throw new Error("spawned local dev process has no pid");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await waitForServer(port);
|
|
59
|
+
|
|
60
|
+
const result = await stopLocalDev({ root, dockerCompose: false, ports: [port] });
|
|
61
|
+
|
|
62
|
+
expect(result).toContain(`Stopped local dev process ${child.pid} on port ${port}`);
|
|
63
|
+
await waitForListenerStop(port, child.pid);
|
|
64
|
+
} finally {
|
|
65
|
+
child.kill("SIGKILL");
|
|
66
|
+
await child.exited.catch(() => undefined);
|
|
67
|
+
}
|
|
68
|
+
}, 10_000);
|
|
69
|
+
|
|
33
70
|
test("plans Docker Compose cleanup when compose exists", async () => {
|
|
34
71
|
const root = await tempRoot();
|
|
35
72
|
await Bun.write(join(root, "docker-compose.yml"), "services: {}\n");
|
|
@@ -45,3 +82,59 @@ async function tempRoot() {
|
|
|
45
82
|
roots.push(root);
|
|
46
83
|
return root;
|
|
47
84
|
}
|
|
85
|
+
|
|
86
|
+
async function reservePort() {
|
|
87
|
+
const server = Bun.serve({ port: 0, fetch: () => new Response("reserved") });
|
|
88
|
+
const port = server.port;
|
|
89
|
+
if (!port) {
|
|
90
|
+
throw new Error("failed to reserve a local port");
|
|
91
|
+
}
|
|
92
|
+
await server.stop();
|
|
93
|
+
return port;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function waitForServer(port: number) {
|
|
97
|
+
const deadline = Date.now() + 5_000;
|
|
98
|
+
while (Date.now() < deadline) {
|
|
99
|
+
if (await isReachable(port)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await Bun.sleep(50);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`server on port ${port} did not start`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function waitForListenerStop(port: number, pid: number) {
|
|
108
|
+
const deadline = Date.now() + 5_000;
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
if (!listenerHasPid(port, pid)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await Bun.sleep(50);
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`process ${pid} on port ${port} did not stop listening`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function isReachable(port: number) {
|
|
119
|
+
try {
|
|
120
|
+
const response = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(250) });
|
|
121
|
+
await response.text();
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function listenerHasPid(port: number, pid: number) {
|
|
129
|
+
const result = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], {
|
|
130
|
+
stdout: "pipe",
|
|
131
|
+
stderr: "pipe",
|
|
132
|
+
});
|
|
133
|
+
if (!result.success || !result.stdout) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return new TextDecoder()
|
|
137
|
+
.decode(result.stdout)
|
|
138
|
+
.split("\n")
|
|
139
|
+
.includes(`p${pid}`);
|
|
140
|
+
}
|
|
@@ -1,36 +1,49 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
4
5
|
type LocalDevOptions = {
|
|
5
6
|
root?: string;
|
|
6
7
|
dockerCompose?: boolean;
|
|
7
8
|
removeVolumes?: boolean;
|
|
9
|
+
ports?: number[];
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
export type LocalDevCleanupPlan = {
|
|
11
13
|
pidFile: string;
|
|
12
14
|
hasPidFile: boolean;
|
|
13
15
|
pid?: number;
|
|
16
|
+
portProcesses: PortProcess[];
|
|
14
17
|
hasDockerCompose: boolean;
|
|
15
18
|
resources: string[];
|
|
16
19
|
skipped: string[];
|
|
17
20
|
};
|
|
18
21
|
|
|
22
|
+
type PortProcess = {
|
|
23
|
+
pid: number;
|
|
24
|
+
port: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
const decoder = new TextDecoder();
|
|
28
|
+
const defaultLocalDevPorts = [8080, 3000];
|
|
20
29
|
|
|
21
30
|
export async function buildLocalDevCleanupPlan(options: LocalDevOptions = {}): Promise<LocalDevCleanupPlan> {
|
|
22
31
|
const root = options.root ?? defaultServiceRoot();
|
|
23
32
|
const pidFile = join(root, ".service", "local-dev.pid");
|
|
24
33
|
const hasPidFile = await Bun.file(pidFile).exists();
|
|
25
34
|
const pid = hasPidFile ? parsePid(await Bun.file(pidFile).text()) : undefined;
|
|
35
|
+
const ports = options.ports ?? defaultLocalDevPorts;
|
|
36
|
+
const portProcesses = findServicePortProcesses(root, ports, pid);
|
|
26
37
|
const hasDockerCompose = Boolean(options.dockerCompose) && (await Bun.file(join(root, "docker-compose.yml")).exists());
|
|
27
38
|
const resources: string[] = [];
|
|
28
39
|
const skipped: string[] = [];
|
|
29
40
|
|
|
30
41
|
if (hasPidFile) {
|
|
31
42
|
resources.push(`Local dev process from ${pidFile}`);
|
|
43
|
+
} else if (portProcesses.length > 0) {
|
|
44
|
+
resources.push(formatPortProcesses(portProcesses));
|
|
32
45
|
} else {
|
|
33
|
-
skipped.push(
|
|
46
|
+
skipped.push(`Local dev process: no .service/local-dev.pid or service-owned listener on ${ports.join(", ")}`);
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
if (hasDockerCompose) {
|
|
@@ -43,6 +56,7 @@ export async function buildLocalDevCleanupPlan(options: LocalDevOptions = {}): P
|
|
|
43
56
|
pidFile,
|
|
44
57
|
hasPidFile,
|
|
45
58
|
pid,
|
|
59
|
+
portProcesses,
|
|
46
60
|
hasDockerCompose,
|
|
47
61
|
resources,
|
|
48
62
|
skipped,
|
|
@@ -62,7 +76,12 @@ export async function stopLocalDev(options: LocalDevOptions = {}) {
|
|
|
62
76
|
}
|
|
63
77
|
await rm(plan.pidFile, { force: true });
|
|
64
78
|
} else {
|
|
65
|
-
|
|
79
|
+
const stopped = stopPortProcesses(plan.portProcesses);
|
|
80
|
+
messages.push(
|
|
81
|
+
stopped.length > 0
|
|
82
|
+
? `Stopped ${formatPortProcesses(stopped)}`
|
|
83
|
+
: "No local dev pid file found and no service-owned local dev process was listening",
|
|
84
|
+
);
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
if (plan.hasDockerCompose) {
|
|
@@ -84,12 +103,16 @@ function parsePid(raw: string) {
|
|
|
84
103
|
|
|
85
104
|
function stopPid(pid: number) {
|
|
86
105
|
const wasRunning = isRunning(pid);
|
|
106
|
+
if (!wasRunning) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
87
109
|
tryKill(-pid, "SIGTERM") || tryKill(pid, "SIGTERM");
|
|
88
|
-
|
|
110
|
+
waitForExit(pid, 1_000);
|
|
89
111
|
if (isRunning(pid)) {
|
|
90
112
|
tryKill(-pid, "SIGKILL") || tryKill(pid, "SIGKILL");
|
|
113
|
+
waitForExit(pid, 1_000);
|
|
91
114
|
}
|
|
92
|
-
return
|
|
115
|
+
return !isRunning(pid);
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
function isRunning(pid: number) {
|
|
@@ -110,6 +133,105 @@ function tryKill(pid: number, signal: NodeJS.Signals) {
|
|
|
110
133
|
}
|
|
111
134
|
}
|
|
112
135
|
|
|
136
|
+
function waitForExit(pid: number, timeoutMs: number) {
|
|
137
|
+
const deadline = Date.now() + timeoutMs;
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
if (!isRunning(pid)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
Bun.sleepSync(50);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findServicePortProcesses(root: string, ports: number[], pidFromFile?: number): PortProcess[] {
|
|
147
|
+
if (!Bun.which("lsof")) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const resolvedRoot = realpath(root);
|
|
152
|
+
const rootWithSlash = resolvedRoot.endsWith("/") ? resolvedRoot : `${resolvedRoot}/`;
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const processes: PortProcess[] = [];
|
|
155
|
+
for (const port of ports) {
|
|
156
|
+
for (const pid of listeningPids(port)) {
|
|
157
|
+
if (pid === pidFromFile) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const key = `${pid}:${port}`;
|
|
161
|
+
if (seen.has(key)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const cwd = processCwd(pid);
|
|
165
|
+
if (cwd && (cwd === resolvedRoot || cwd.startsWith(rootWithSlash))) {
|
|
166
|
+
processes.push({ pid, port });
|
|
167
|
+
seen.add(key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return processes;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function realpath(path: string) {
|
|
175
|
+
try {
|
|
176
|
+
return realpathSync(path);
|
|
177
|
+
} catch {
|
|
178
|
+
return path;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function listeningPids(port: number) {
|
|
183
|
+
const result = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], {
|
|
184
|
+
stdout: "pipe",
|
|
185
|
+
stderr: "pipe",
|
|
186
|
+
});
|
|
187
|
+
if (!result.success || !result.stdout) {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
return decoder
|
|
191
|
+
.decode(result.stdout)
|
|
192
|
+
.split("\n")
|
|
193
|
+
.map((line) => (line.startsWith("p") ? Number.parseInt(line.slice(1), 10) : undefined))
|
|
194
|
+
.filter((pid): pid is number => Boolean(pid && Number.isFinite(pid)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function processCwd(pid: number) {
|
|
198
|
+
if (process.platform === "linux") {
|
|
199
|
+
const procCwd = realpath(`/proc/${pid}/cwd`);
|
|
200
|
+
if (procCwd !== `/proc/${pid}/cwd`) {
|
|
201
|
+
return procCwd;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = Bun.spawnSync(["lsof", "-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
|
206
|
+
stdout: "pipe",
|
|
207
|
+
stderr: "pipe",
|
|
208
|
+
});
|
|
209
|
+
if (!result.success || !result.stdout) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
return decoder
|
|
213
|
+
.decode(result.stdout)
|
|
214
|
+
.split("\n")
|
|
215
|
+
.find((line) => line.startsWith("n"))
|
|
216
|
+
?.slice(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stopPortProcesses(processes: PortProcess[]) {
|
|
220
|
+
const stopped: PortProcess[] = [];
|
|
221
|
+
for (const portProcess of processes) {
|
|
222
|
+
if (stopPid(portProcess.pid) || !listeningPids(portProcess.port).includes(portProcess.pid)) {
|
|
223
|
+
stopped.push(portProcess);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return stopped;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function formatPortProcesses(processes: PortProcess[]) {
|
|
230
|
+
return processes
|
|
231
|
+
.map((portProcess) => `local dev process ${portProcess.pid} on port ${portProcess.port}`)
|
|
232
|
+
.join(", ");
|
|
233
|
+
}
|
|
234
|
+
|
|
113
235
|
function runDockerComposeDown(root: string, removeVolumes: boolean) {
|
|
114
236
|
if (!Bun.which("docker")) {
|
|
115
237
|
return "Docker is not installed; Docker Compose cleanup skipped";
|
package/src/service.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
formatOutsideServiceCommandError,
|
|
8
8
|
generatedDependenciesInstalled,
|
|
9
9
|
generatedServiceCommandHelp,
|
|
10
|
+
createSvcVersion,
|
|
10
11
|
normalizeScaffoldArgs,
|
|
11
12
|
} from "./service";
|
|
12
13
|
|
|
@@ -21,6 +22,11 @@ test("normalizeScaffoldArgs maps service help to generator help outside a servic
|
|
|
21
22
|
expect(normalizeScaffoldArgs(["help", "--verbose"])).toEqual(["--help", "--verbose"]);
|
|
22
23
|
});
|
|
23
24
|
|
|
25
|
+
test("createSvcVersion reports the package version", async () => {
|
|
26
|
+
const packageJson = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
27
|
+
expect(createSvcVersion()).toBe(packageJson.version);
|
|
28
|
+
});
|
|
29
|
+
|
|
24
30
|
test("formatOutsideServiceCommandError rejects repo-local commands outside generated services", () => {
|
|
25
31
|
expect(formatOutsideServiceCommandError("destroy")).toContain("service destroy must be run inside a generated service repo");
|
|
26
32
|
expect(formatOutsideServiceCommandError("deploy")).toContain("No service.jsonc was found");
|
package/src/service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { formatScaffoldHelp, run as runScaffoldCli } from "./cli";
|
|
4
4
|
import { parseJsonc } from "./jsonc";
|
|
@@ -19,6 +19,11 @@ const GENERATED_SERVICE_COMMANDS = new Set([
|
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
21
|
export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
|
|
22
|
+
if (isVersionCommand(argv)) {
|
|
23
|
+
console.log(createSvcVersion());
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
const serviceRoot = findGeneratedServiceRoot(cwd);
|
|
23
28
|
if (serviceRoot) {
|
|
24
29
|
await delegateToGeneratedService(serviceRoot, argv);
|
|
@@ -40,6 +45,15 @@ export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
|
|
|
40
45
|
process.exit(1);
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
function isVersionCommand(argv: string[]) {
|
|
49
|
+
return argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createSvcVersion() {
|
|
53
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
|
|
54
|
+
return packageJson.version || "unknown";
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
export function normalizeScaffoldArgs(argv: string[]) {
|
|
44
58
|
const [command, ...rest] = argv;
|
|
45
59
|
if (command && SCAFFOLD_COMMANDS.has(command)) {
|