@rse/ase 0.0.6 → 0.0.8

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.
@@ -6,70 +6,26 @@
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import net from "node:net";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { spawn } from "node:child_process";
10
- import { parseDocument } from "yaml";
11
11
  import Hapi from "@hapi/hapi";
12
12
  import axios from "axios";
13
+ import { isMap } from "yaml";
14
+ import * as v from "valibot";
15
+ import prettyMs from "pretty-ms";
16
+ import { Config, configSchema } from "./ase-config.js";
13
17
  const SERVE_ENV = "ASE_SERVICE_SERVE";
18
+ const PORT_ENV = "ASE_SERVICE_PORT";
14
19
  const HOST = "127.0.0.1";
15
20
  const IDLE_MS = 30 * 60 * 1000;
16
21
  const TICK_MS = 60 * 1000;
17
22
  const PORT_MIN = 42000;
18
23
  const PORT_MAX = 44000;
19
24
  const PORT_TRIES = 20;
20
- /* upward-walk on filesystem for a file path relative to a start directory */
21
- const findUpward = (start, rel) => {
22
- let dir = start;
23
- for (;;) {
24
- const candidate = path.join(dir, rel);
25
- if (fs.existsSync(candidate))
26
- return candidate;
27
- const parent = path.dirname(dir);
28
- if (parent === dir)
29
- return null;
30
- dir = parent;
31
- }
32
- };
33
- /* load optional ".ase/config.yaml" and ".ase/service.yaml" files */
34
- const loadContext = () => {
35
- /* find files */
36
- const cfgPath = findUpward(process.cwd(), ".ase/config.yaml");
37
- const svcPath = findUpward(process.cwd(), ".ase/service.yaml");
38
- /* determine project id */
39
- let projectId;
40
- if (cfgPath !== null) {
41
- const doc = parseDocument(fs.readFileSync(cfgPath, "utf8"));
42
- projectId = doc.get("project-id");
43
- }
44
- if (projectId === undefined || projectId === null)
45
- projectId = path.basename(process.cwd());
46
- if (typeof projectId !== "string" || projectId.length === 0)
47
- throw new Error(`invalid "project-id" in ${cfgPath ?? "<cwd basename>"}`);
48
- /* determine service port */
49
- let port = null;
50
- if (svcPath !== null) {
51
- const doc = parseDocument(fs.readFileSync(svcPath, "utf8"));
52
- const raw = doc.get("port");
53
- if (raw !== undefined && raw !== null) {
54
- if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1024 || raw > 65535)
55
- throw new Error(`invalid "port" in ${svcPath} (expected integer 1024..65535)`);
56
- port = raw;
57
- }
58
- }
59
- /* determine path to ".ase" directory */
60
- const aseDir = cfgPath !== null ? path.dirname(cfgPath) :
61
- svcPath !== null ? path.dirname(svcPath) :
62
- path.join(process.cwd(), ".ase");
63
- /* determine path to final ".ase/service.yaml" file */
64
- const finalSvc = svcPath !== null ? svcPath : path.join(aseDir, "service.yaml");
65
- /* return context information */
66
- return {
67
- projectId,
68
- port,
69
- svcPath: finalSvc,
70
- aseDir
71
- };
72
- };
25
+ /* schema for ".ase/service.yaml" */
26
+ const serviceSchema = v.nullish(v.strictObject({
27
+ port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
28
+ }));
73
29
  /* try binding a single candidate port to verify availability */
74
30
  const tryBind = (port) => {
75
31
  return new Promise((resolve) => {
@@ -93,20 +49,29 @@ const allocatePort = async () => {
93
49
  throw new Error(`failed to allocate a port in ${PORT_MIN}..${PORT_MAX} after ${PORT_TRIES} attempts`);
94
50
  };
95
51
  /* persist an allocated port into ".ase/service.yaml" */
96
- const persistPort = (svcPath, port) => {
97
- fs.mkdirSync(path.dirname(svcPath), { recursive: true });
98
- const text = fs.existsSync(svcPath) ? fs.readFileSync(svcPath, "utf8") : "";
99
- const doc = parseDocument(text);
100
- doc.set("port", port);
101
- fs.writeFileSync(svcPath, doc.toString(), "utf8");
52
+ const persistPort = (svc, port) => {
53
+ svc.set("port", port);
54
+ svc.write();
55
+ };
56
+ /* clear the persisted port and remove ".ase/service.yaml" if it is empty */
57
+ const clearPort = (svc) => {
58
+ svc.delete("port");
59
+ const root = svc.get();
60
+ const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
61
+ if (empty) {
62
+ if (fs.existsSync(svc.filename))
63
+ fs.rmSync(svc.filename);
64
+ }
65
+ else
66
+ svc.write();
102
67
  };
103
68
  /* distinguish ECONNREFUSED from other Axios transport errors */
104
69
  const isConnRefused = (err) => {
105
70
  const e = err;
106
71
  return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
107
72
  };
108
- /* probe the service */
109
- const probe = async (port) => {
73
+ /* probe the service and verify ASE identity banner */
74
+ const probe = async (port, projectId) => {
110
75
  try {
111
76
  const r = await axios.request({
112
77
  method: "OPTIONS",
@@ -114,7 +79,10 @@ const probe = async (port) => {
114
79
  timeout: 2000,
115
80
  validateStatus: () => true
116
81
  });
117
- return r.status;
82
+ if (r.status < 200 || r.status >= 300)
83
+ return false;
84
+ const d = r.data;
85
+ return d?.ase === true && d?.projectId === projectId;
118
86
  }
119
87
  catch (err) {
120
88
  if (isConnRefused(err))
@@ -122,148 +90,305 @@ const probe = async (port) => {
122
90
  throw err;
123
91
  }
124
92
  };
125
- /* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
126
- const runService = async (ctx) => {
127
- /* establish HAPI HTTP/REST service */
128
- const server = Hapi.server({ host: HOST, port: ctx.port });
129
- /* track last activity */
130
- let lastActivity = Date.now();
131
- server.ext("onRequest", (_request, h) => {
132
- lastActivity = Date.now();
133
- return h.continue;
134
- });
135
- /* listen to HTTP/REST endpoints */
136
- server.route({
137
- method: "OPTIONS",
138
- path: "/",
139
- handler: (_request, h) => {
140
- return h.response().code(204);
141
- }
142
- });
143
- server.route({
144
- method: "GET",
145
- path: "/stop",
146
- handler: (_request, h) => {
147
- setImmediate(async () => {
148
- await server.stop({ timeout: 1000 });
149
- process.exit(0);
150
- });
151
- return h.response({ ok: true }).code(200);
152
- }
153
- });
154
- server.route({
155
- method: "POST",
156
- path: "/command",
157
- options: { payload: { parse: true, allow: "application/json" } },
158
- handler: (request, h) => {
159
- const payload = request.payload;
160
- if (!payload || typeof payload.command !== "string")
161
- return h.response({ error: "missing or invalid 'command' field" }).code(400);
162
- if (payload.command === "foo") {
163
- return h.response({
164
- ok: true,
165
- projectId: ctx.projectId,
166
- command: "Hello World" // FIXME
167
- }).code(200);
168
- }
169
- else
170
- return h.response({ error: "invalid 'command' field" }).code(400);
171
- }
172
- });
173
- /* start service */
174
- try {
175
- await server.start();
176
- }
177
- catch (err) {
178
- const e = err;
179
- if (e.code === "EADDRINUSE") {
180
- /* race-loser re-probe: another "ase service start" won the race */
181
- const status = await probe(ctx.port).catch(() => null);
182
- if (status !== null && status >= 200 && status < 300)
183
- process.exit(0);
184
- process.stderr.write(`ase: service: port ${ctx.port} in use, but not responding!\n`);
185
- process.exit(1);
186
- }
187
- process.stderr.write(`ase: service: ${e.message}\n`);
188
- process.exit(1);
189
- }
190
- /* stop service after idle timeout */
191
- setInterval(() => {
192
- if (Date.now() - lastActivity > IDLE_MS) {
193
- server.stop({ timeout: 1000 }).then(() => {
194
- process.exit(0);
195
- });
196
- }
197
- }, TICK_MS).unref();
198
- };
199
93
  /* spawn the current executable detached as a background service */
200
- const spawnDetached = (aseDir) => {
94
+ const spawnDetached = (aseDir, port) => {
201
95
  fs.mkdirSync(aseDir, { recursive: true });
202
96
  const logFile = path.join(aseDir, "service.log");
203
- const log = fs.openSync(logFile, "a");
204
- const child = spawn(process.execPath, [process.argv[1], "service", "start"], {
97
+ const fd = fs.openSync(logFile, "a");
98
+ const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
99
+ const child = spawn(process.execPath, [entry, "service", "start"], {
205
100
  detached: true,
206
- env: { ...process.env, [SERVE_ENV]: "1" },
207
- stdio: ["ignore", log, log]
101
+ env: { ...process.env, [SERVE_ENV]: "1", [PORT_ENV]: String(port) },
102
+ stdio: ["ignore", fd, fd]
208
103
  });
209
- child.unref();
104
+ fs.closeSync(fd);
105
+ return { child, logFile };
210
106
  };
211
- /* start flow: ensure port, probe, optionally detach */
212
- const doStart = async () => {
213
- const ctx = loadContext();
214
- let port = ctx.port;
215
- if (port === null) {
216
- port = await allocatePort();
217
- persistPort(ctx.svcPath, port);
107
+ /* read the last N non-empty lines of a log file for diagnostics */
108
+ const readLogTail = (logFile, lines) => {
109
+ let fd = null;
110
+ try {
111
+ fd = fs.openSync(logFile, "r");
112
+ const size = fs.fstatSync(fd).size;
113
+ const CHUNK = 8192;
114
+ const buf = Buffer.alloc(CHUNK);
115
+ let pos = size;
116
+ let tail = "";
117
+ let count = 0;
118
+ while (pos > 0 && count <= lines) {
119
+ const n = Math.min(CHUNK, pos);
120
+ pos -= n;
121
+ fs.readSync(fd, buf, 0, n, pos);
122
+ tail = buf.toString("utf8", 0, n) + tail;
123
+ count = 0;
124
+ for (let i = 0; i < tail.length; i++)
125
+ if (tail.charCodeAt(i) === 10)
126
+ count++;
127
+ }
128
+ const all = tail.split("\n").filter((l) => l.length > 0);
129
+ return all.slice(-lines).join("\n");
218
130
  }
219
- if (process.env[SERVE_ENV] === "1") {
220
- await runService({ ...ctx, port });
221
- return await new Promise(() => { });
131
+ catch {
132
+ return "";
222
133
  }
223
- const status = await probe(port);
224
- if (status !== null && status >= 200 && status < 300)
225
- return 0;
226
- spawnDetached(ctx.aseDir);
227
- for (let i = 0; i < 50; i++) {
228
- await new Promise((resolve) => setTimeout(resolve, 100));
229
- const s = await probe(port);
230
- if (s !== null && s >= 200 && s < 300)
231
- return 0;
134
+ finally {
135
+ if (fd !== null)
136
+ fs.closeSync(fd);
232
137
  }
233
- throw new Error("service failed to start within timeout");
234
138
  };
235
- /* stop flow: no-op if no port configured or connection refused */
236
- const doStop = async () => {
237
- const ctx = loadContext();
238
- if (ctx.port === null)
239
- return 0;
240
- try {
241
- const r = await axios.request({
139
+ /* CLI command "ase service" */
140
+ export default class ServiceCommand {
141
+ log;
142
+ constructor(log) {
143
+ this.log = log;
144
+ }
145
+ /* load optional ".ase/config.yaml" and ".ase/service.yaml" files */
146
+ loadContext() {
147
+ /* load files */
148
+ const cfg = new Config("config", configSchema, this.log);
149
+ cfg.read();
150
+ const svc = new Config("service", serviceSchema, this.log);
151
+ svc.read();
152
+ /* determine project id */
153
+ const rawId = cfg.get("project.id");
154
+ const projectId = rawId ?? path.basename(process.cwd());
155
+ /* determine service port */
156
+ const rawPort = svc.get("port");
157
+ const port = rawPort ?? null;
158
+ /* determine path to ".ase" directory */
159
+ const aseDir = path.dirname(svc.filename);
160
+ /* return context information */
161
+ return {
162
+ projectId,
163
+ port,
164
+ svc,
165
+ aseDir
166
+ };
167
+ }
168
+ /* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
169
+ async runService(ctx) {
170
+ /* establish HAPI HTTP/REST service */
171
+ const server = Hapi.server({ host: HOST, port: ctx.port });
172
+ /* track start time and last activity */
173
+ const startTime = Date.now();
174
+ let lastActivity = Date.now();
175
+ let stopping = false;
176
+ server.ext("onRequest", (_request, h) => {
177
+ lastActivity = Date.now();
178
+ return h.continue;
179
+ });
180
+ /* listen to HTTP/REST endpoints */
181
+ server.route({
182
+ method: "OPTIONS",
183
+ path: "/",
184
+ handler: (_request, h) => {
185
+ return h.response({ ase: true, projectId: ctx.projectId }).code(200);
186
+ }
187
+ });
188
+ server.route({
242
189
  method: "GET",
243
- url: `http://${HOST}:${ctx.port}/stop`,
244
- timeout: 5000,
245
- validateStatus: () => true
190
+ path: "/stop",
191
+ handler: (_request, h) => {
192
+ setImmediate(async () => {
193
+ await server.stop({ timeout: 1000 });
194
+ process.exit(0);
195
+ });
196
+ return h.response({ ok: true }).code(200);
197
+ }
246
198
  });
247
- return r.status >= 200 && r.status < 300 ? 0 : 1;
199
+ server.route({
200
+ method: "POST",
201
+ path: "/command",
202
+ options: { payload: { parse: true, allow: "application/json" } },
203
+ handler: (request, h) => {
204
+ const payload = request.payload;
205
+ if (!payload || typeof payload.command !== "string")
206
+ return h.response({ error: "missing or invalid 'command' field" }).code(400);
207
+ const cmd = payload.command;
208
+ if (cmd === "ping")
209
+ return h.response({ ok: true, pong: true }).code(200);
210
+ if (cmd === "status")
211
+ return h.response({
212
+ ok: true,
213
+ projectId: ctx.projectId,
214
+ port: ctx.port,
215
+ uptimeMs: Date.now() - startTime
216
+ }).code(200);
217
+ return h.response({ error: "unknown command", command: cmd }).code(400);
218
+ }
219
+ });
220
+ /* start service */
221
+ try {
222
+ await server.start();
223
+ persistPort(ctx.svc, ctx.port);
224
+ }
225
+ catch (err) {
226
+ const e = err;
227
+ if (e.code === "EADDRINUSE") {
228
+ /* race-loser re-probe: another "ase service start" won the race */
229
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
230
+ if (match === true)
231
+ process.exit(0);
232
+ this.log.write("error", `service: port ${ctx.port} in use, but not responding!`);
233
+ clearPort(ctx.svc);
234
+ process.exit(1);
235
+ }
236
+ this.log.write("error", `service: ${e.message}`);
237
+ process.exit(1);
238
+ }
239
+ /* stop service after idle timeout */
240
+ setInterval(async () => {
241
+ if (stopping)
242
+ return;
243
+ if (Date.now() - lastActivity > IDLE_MS) {
244
+ stopping = true;
245
+ try {
246
+ await server.stop({ timeout: 1000 });
247
+ clearPort(ctx.svc);
248
+ process.exit(0);
249
+ }
250
+ catch (err) {
251
+ const e = err;
252
+ this.log.write("error", `service: stop failed: ${e.message}`);
253
+ clearPort(ctx.svc);
254
+ process.exit(1);
255
+ }
256
+ }
257
+ }, TICK_MS).unref();
248
258
  }
249
- catch (err) {
250
- if (isConnRefused(err))
259
+ /* start flow: ensure port, probe, optionally detach */
260
+ async doStart() {
261
+ const ctx = this.loadContext();
262
+ let port = ctx.port;
263
+ if (process.env[SERVE_ENV] === "1") {
264
+ const raw = process.env[PORT_ENV];
265
+ port = raw !== undefined ? Number(raw) : await allocatePort();
266
+ await this.runService({ ...ctx, port });
267
+ return await new Promise(() => { });
268
+ }
269
+ if (port !== null) {
270
+ const match = await probe(port, ctx.projectId);
271
+ if (match === true) {
272
+ this.log.write("info", `service: already running on port ${port}`);
273
+ return 0;
274
+ }
275
+ }
276
+ /* bounded retry across the bind/start TOCTOU window: on each attempt
277
+ re-allocate, re-persist, re-spawn; early-break on foreign listener */
278
+ let lastErr = new Error("service failed to start within timeout");
279
+ for (let attempt = 0; attempt < 3; attempt++) {
280
+ port = await allocatePort();
281
+ const { child, logFile } = spawnDetached(ctx.aseDir, port);
282
+ let exited = false;
283
+ let exitCode = null;
284
+ let resolveExit = () => { };
285
+ const exitPromise = new Promise((resolve) => {
286
+ resolveExit = resolve;
287
+ });
288
+ const onExit = (code) => {
289
+ exited = true;
290
+ exitCode = code;
291
+ resolveExit();
292
+ };
293
+ child.once("exit", onExit);
294
+ let foreign = false;
295
+ let success = false;
296
+ try {
297
+ for (let i = 0; i < 50; i++) {
298
+ await new Promise((resolve) => setTimeout(resolve, 100));
299
+ if (exited)
300
+ break;
301
+ const s = await probe(port, ctx.projectId);
302
+ if (s === true) {
303
+ this.log.write("info", `service: started on port ${port}`);
304
+ child.unref();
305
+ success = true;
306
+ return 0;
307
+ }
308
+ if (s === false) {
309
+ foreign = true;
310
+ break;
311
+ }
312
+ }
313
+ const tail = readLogTail(logFile, 20);
314
+ const reason = exited ?
315
+ `service exited during startup (code ${exitCode})` :
316
+ foreign ?
317
+ `service lost port ${port} race to a foreign listener` :
318
+ "service failed to start within timeout";
319
+ const detail = tail.length > 0 ? `\n---- ${logFile} (tail) ----\n${tail}` : "";
320
+ lastErr = new Error(`${reason}${detail}`);
321
+ }
322
+ finally {
323
+ child.removeListener("exit", onExit);
324
+ if (!success && !exited) {
325
+ child.kill("SIGTERM");
326
+ await Promise.race([
327
+ exitPromise,
328
+ new Promise((resolve) => setTimeout(resolve, 1000))
329
+ ]);
330
+ if (!exited) {
331
+ child.kill("SIGKILL");
332
+ await exitPromise;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ clearPort(ctx.svc);
338
+ throw lastErr;
339
+ }
340
+ /* status flow: report whether the service is running */
341
+ async doStatus() {
342
+ const ctx = this.loadContext();
343
+ if (ctx.port === null) {
344
+ process.stdout.write("service: not running (no port configured)\n");
345
+ return 1;
346
+ }
347
+ const match = await probe(ctx.port, ctx.projectId);
348
+ if (match === true) {
349
+ const r = await axios.request({
350
+ method: "POST",
351
+ url: `http://${HOST}:${ctx.port}/command`,
352
+ headers: { "Content-Type": "application/json" },
353
+ data: { command: "status" },
354
+ timeout: 2000,
355
+ validateStatus: () => true
356
+ });
357
+ const d = r.data;
358
+ const uptimeMs = typeof d?.uptimeMs === "number" ? d.uptimeMs : 0;
359
+ const uptime = prettyMs(uptimeMs, { verbose: true });
360
+ process.stdout.write(`service: running on port ${ctx.port} (uptime: ${uptime})\n`);
251
361
  return 0;
252
- throw err;
362
+ }
363
+ if (match === false) {
364
+ process.stdout.write(`service: not running (port ${ctx.port} in use by foreign service)\n`);
365
+ return 1;
366
+ }
367
+ process.stdout.write(`service: not running (port ${ctx.port} not responding)\n`);
368
+ return 1;
253
369
  }
254
- };
255
- /* passthrough flow: POST /command with the arbitrary cmd token */
256
- const doPassthrough = async (cmd) => {
257
- const ctx = loadContext();
258
- if (ctx.port === null)
259
- throw new Error("service not running (no port configured)");
260
- try {
370
+ /* send command: POST /command with the arbitrary cmd token */
371
+ async doSend(cmd) {
372
+ let ctx = this.loadContext();
373
+ if (ctx.port === null) {
374
+ await this.doStart();
375
+ ctx = this.loadContext();
376
+ if (ctx.port === null)
377
+ throw new Error("service not running (no port configured after auto-start)");
378
+ }
379
+ const match = await probe(ctx.port, ctx.projectId);
380
+ if (match !== true) {
381
+ await this.doStart();
382
+ ctx = this.loadContext();
383
+ if (ctx.port === null)
384
+ throw new Error("service not running (no port configured after auto-start)");
385
+ }
261
386
  const r = await axios.request({
262
387
  method: "POST",
263
388
  url: `http://${HOST}:${ctx.port}/command`,
264
389
  headers: { "Content-Type": "application/json" },
265
390
  data: { command: cmd },
266
- timeout: 0,
391
+ timeout: 5000,
267
392
  validateStatus: () => true,
268
393
  responseType: "text",
269
394
  transformResponse: [(x) => x]
@@ -274,36 +399,71 @@ const doPassthrough = async (cmd) => {
274
399
  process.stdout.write("\n");
275
400
  return r.status >= 200 && r.status < 300 ? 0 : 1;
276
401
  }
277
- catch (err) {
278
- if (isConnRefused(err))
279
- throw new Error("service not running (connection refused)");
280
- throw err;
402
+ /* stop flow: no-op if no port configured or connection refused */
403
+ async doStop() {
404
+ const ctx = this.loadContext();
405
+ if (ctx.port === null) {
406
+ this.log.write("info", "service: not running (no port configured)");
407
+ return 0;
408
+ }
409
+ const match = await probe(ctx.port, ctx.projectId);
410
+ if (match === false) {
411
+ this.log.write("info", `service: not running (port ${ctx.port} in use by foreign service)`);
412
+ return 1;
413
+ }
414
+ if (match === null) {
415
+ this.log.write("info", `service: not running (port ${ctx.port} not responding)`);
416
+ clearPort(ctx.svc);
417
+ return 0;
418
+ }
419
+ const r = await axios.request({
420
+ method: "GET",
421
+ url: `http://${HOST}:${ctx.port}/stop`,
422
+ timeout: 5000,
423
+ validateStatus: () => true
424
+ });
425
+ const ok = r.status >= 200 && r.status < 300;
426
+ clearPort(ctx.svc);
427
+ return ok ? 0 : 1;
281
428
  }
282
- };
283
- /* command-line handling */
284
- const serviceCommand = {
285
- command: "service",
286
- describe: "Manage per-project background HTTP service",
287
- builder: (yargs) => {
288
- return yargs
289
- .command("start", "Start the background service", {}, async () => {
290
- process.exit(await doStart());
291
- })
292
- .command("stop", "Stop the background service", {}, async () => {
293
- process.exit(await doStop());
294
- })
295
- .command("$0 <cmd>", "Send an arbitrary command to the service", (y) => {
296
- return y.positional("cmd", {
297
- type: "string",
298
- describe: "Command token to dispatch"
299
- });
300
- }, async (argv) => {
301
- process.exit(await doPassthrough(String(argv.cmd)));
302
- })
303
- .demandCommand(1, "You need to specify a service subcommand");
304
- },
305
- handler: () => {
306
- /* dispatched by nested subcommands */
429
+ /* register commands */
430
+ register(program) {
431
+ /* register CLI top-level command "ase service" */
432
+ const service = program
433
+ .command("service")
434
+ .description("Manage per-project background HTTP service")
435
+ .action(() => {
436
+ service.outputHelp();
437
+ process.exit(1);
438
+ });
439
+ /* register CLI sub-command "ase service start" */
440
+ service
441
+ .command("start")
442
+ .description("Start the background service")
443
+ .action(async () => {
444
+ process.exit(await this.doStart());
445
+ });
446
+ /* register CLI sub-command "ase service status" */
447
+ service
448
+ .command("status")
449
+ .description("Report whether the background service is running")
450
+ .action(async () => {
451
+ process.exit(await this.doStatus());
452
+ });
453
+ /* register CLI sub-command "ase service send" */
454
+ service
455
+ .command("send")
456
+ .description("Send a command to the background service")
457
+ .argument("<cmd>", "Command token to dispatch to the service")
458
+ .action(async (cmd) => {
459
+ process.exit(await this.doSend(cmd));
460
+ });
461
+ /* register CLI sub-command "ase service stop" */
462
+ service
463
+ .command("stop")
464
+ .description("Stop the background service")
465
+ .action(async () => {
466
+ process.exit(await this.doStop());
467
+ });
307
468
  }
308
- };
309
- export default serviceCommand;
469
+ }