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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -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("Local dev process: no .service/local-dev.pid");
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
- messages.push("No local dev pid file found");
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
- Bun.sleepSync(1_000);
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 wasRunning;
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";
@@ -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)) {