@codori/server 0.0.3 → 0.0.5

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.
Files changed (44) hide show
  1. package/README.md +34 -0
  2. package/client-dist/200.html +1 -1
  3. package/client-dist/404.html +1 -1
  4. package/client-dist/_nuxt/BsofOenw.js +1 -0
  5. package/client-dist/_nuxt/C1GdLDfB.js +1 -0
  6. package/client-dist/_nuxt/{CaHFvrMF.js → CNSSoePX.js} +1 -1
  7. package/client-dist/_nuxt/CQVB8E20.js +1 -0
  8. package/client-dist/_nuxt/{Cvj6lHH1.js → CsE-687t.js} +51 -51
  9. package/client-dist/_nuxt/{Bn41X3Zq.js → DJfsg7Kb.js} +1 -1
  10. package/client-dist/_nuxt/DcJmCJZR.js +1 -0
  11. package/client-dist/_nuxt/{CxIrrT6Q.js → Dg2XLMZm.js} +1 -1
  12. package/client-dist/_nuxt/DhLoSG-h.js +3 -0
  13. package/client-dist/_nuxt/DhRbzQPR.js +1 -0
  14. package/client-dist/_nuxt/{B9M-aXlQ.js → OylMiRf9.js} +3 -3
  15. package/client-dist/_nuxt/builds/latest.json +1 -1
  16. package/client-dist/_nuxt/builds/meta/468a0ff2-bd27-45c6-bd89-5ac776d98662.json +1 -0
  17. package/client-dist/_nuxt/{ClvUKBzL.js → ecRbsnab.js} +1 -1
  18. package/client-dist/index.html +1 -1
  19. package/dist/cli.d.ts +5 -1
  20. package/dist/cli.js +171 -19
  21. package/dist/config.d.ts +2 -0
  22. package/dist/config.js +26 -2
  23. package/dist/http-server.d.ts +8 -0
  24. package/dist/http-server.js +59 -1
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.js +3 -0
  27. package/dist/process-manager.d.ts +19 -0
  28. package/dist/process-manager.js +126 -3
  29. package/dist/runtime-store.js +5 -3
  30. package/dist/service-adapters.d.ts +39 -0
  31. package/dist/service-adapters.js +185 -0
  32. package/dist/service-update.d.ts +26 -0
  33. package/dist/service-update.js +196 -0
  34. package/dist/service.d.ts +86 -0
  35. package/dist/service.js +616 -0
  36. package/dist/types.d.ts +13 -0
  37. package/package.json +1 -1
  38. package/client-dist/_nuxt/B13tqEXg.js +0 -1
  39. package/client-dist/_nuxt/Bgck3A5L.js +0 -1
  40. package/client-dist/_nuxt/DS99AY4f.js +0 -1
  41. package/client-dist/_nuxt/Dp21CzWX.js +0 -1
  42. package/client-dist/_nuxt/ER2AV0-Z.js +0 -1
  43. package/client-dist/_nuxt/builds/meta/5f4263d4-eac2-4ff0-9a82-eb8be28751d5.json +0 -1
  44. package/client-dist/_nuxt/nHwHvv6y.js +0 -3
@@ -0,0 +1,616 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { createDarwinServiceDefinition, createLinuxServiceDefinition, getDarwinInstallCommands, getDarwinRestartCommands, getDarwinUninstallCommands, getLinuxInstallCommands, getLinuxRestartCommands, getLinuxUninstallCommands, resolveServicePlatform } from './service-adapters.js';
8
+ import { DEFAULT_SERVER_PORT, resolveCodoriHome } from './config.js';
9
+ import { CodoriError } from './errors.js';
10
+ import { scanProjects } from './project-scanner.js';
11
+ const WORKSPACE_MARKER_NAMES = [
12
+ 'package.json',
13
+ 'pnpm-workspace.yaml',
14
+ 'turbo.json'
15
+ ];
16
+ const SUPPORTED_SERVICE_SCOPES = new Set(['user', 'system']);
17
+ const WILDCARD_HOST_WARNING = [
18
+ 'Binding Codori to 0.0.0.0 can expose it without authentication.',
19
+ 'Set up a firewall or use a private network such as Tailscale before continuing.'
20
+ ].join(' ');
21
+ export const CODORI_SERVICE_MANAGED_ENV = 'CODORI_SERVICE_MANAGED';
22
+ export const CODORI_SERVICE_INSTALL_ID_ENV = 'CODORI_SERVICE_INSTALL_ID';
23
+ export const CODORI_SERVICE_SCOPE_ENV = 'CODORI_SERVICE_SCOPE';
24
+ const defaultCommandRunner = (command, args) => new Promise((resolvePromise, reject) => {
25
+ const child = spawn(command, args, {
26
+ stdio: ['ignore', 'pipe', 'pipe']
27
+ });
28
+ let stdout = '';
29
+ let stderr = '';
30
+ child.stdout.on('data', (chunk) => {
31
+ stdout += chunk.toString();
32
+ });
33
+ child.stderr.on('data', (chunk) => {
34
+ stderr += chunk.toString();
35
+ });
36
+ child.once('error', reject);
37
+ child.once('close', (exitCode) => {
38
+ resolvePromise({
39
+ exitCode,
40
+ stdout,
41
+ stderr
42
+ });
43
+ });
44
+ });
45
+ const shellEscape = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
46
+ const findFirstIpv4 = (values) => {
47
+ if (!Array.isArray(values)) {
48
+ return null;
49
+ }
50
+ for (const value of values) {
51
+ if (typeof value === 'string' && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value)) {
52
+ return value;
53
+ }
54
+ }
55
+ return null;
56
+ };
57
+ const writeLine = (stream, message) => {
58
+ stream.write(`${message}\n`);
59
+ };
60
+ const getCurrentUserId = () => (typeof process.getuid === 'function' ? process.getuid() : 0);
61
+ const createDefaultPrompt = (input = process.stdin, output = process.stdout) => {
62
+ const rl = createInterface({
63
+ input,
64
+ output
65
+ });
66
+ return {
67
+ input: async (message, defaultValue) => {
68
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
69
+ const answer = (await rl.question(`${message}${suffix}: `)).trim();
70
+ return answer || defaultValue || '';
71
+ },
72
+ confirm: async (message, defaultValue) => {
73
+ const suffix = defaultValue ? ' [Y/n]' : ' [y/N]';
74
+ const answer = (await rl.question(`${message}${suffix}: `)).trim().toLowerCase();
75
+ if (!answer) {
76
+ return defaultValue;
77
+ }
78
+ if (answer === 'y' || answer === 'yes') {
79
+ return true;
80
+ }
81
+ if (answer === 'n' || answer === 'no') {
82
+ return false;
83
+ }
84
+ return defaultValue;
85
+ },
86
+ close: async () => {
87
+ rl.close();
88
+ }
89
+ };
90
+ };
91
+ const buildCanonicalInvocation = (command, options) => {
92
+ const parts = ['npx', '@codori/server', command];
93
+ if (options.root) {
94
+ parts.push('--root', options.root);
95
+ }
96
+ if (options.host) {
97
+ parts.push('--host', options.host);
98
+ }
99
+ if (typeof options.port === 'number') {
100
+ parts.push('--port', String(options.port));
101
+ }
102
+ if (options.scope) {
103
+ parts.push('--scope', options.scope);
104
+ }
105
+ if (options.yes) {
106
+ parts.push('--yes');
107
+ }
108
+ return parts.map(shellEscape).join(' ');
109
+ };
110
+ const ensureDirectory = (path) => {
111
+ mkdirSync(path, { recursive: true });
112
+ };
113
+ const ensureExistingDirectory = (path) => {
114
+ if (!existsSync(path) || !statSync(path).isDirectory()) {
115
+ throw new CodoriError('INVALID_ROOT', `Project root "${path}" does not exist or is not a directory.`);
116
+ }
117
+ };
118
+ const normalizeServiceMetadata = (value) => {
119
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
120
+ return null;
121
+ }
122
+ const record = value;
123
+ if (typeof record.installId !== 'string'
124
+ || typeof record.root !== 'string'
125
+ || typeof record.host !== 'string'
126
+ || typeof record.port !== 'number'
127
+ || (record.scope !== 'user' && record.scope !== 'system')
128
+ || (record.platform !== 'darwin' && record.platform !== 'linux')
129
+ || typeof record.serviceName !== 'string'
130
+ || typeof record.serviceFilePath !== 'string'
131
+ || typeof record.launcherPath !== 'string'
132
+ || typeof record.installedAt !== 'string') {
133
+ return null;
134
+ }
135
+ return record;
136
+ };
137
+ const loadServiceMetadata = (root, homeDir = os.homedir()) => {
138
+ const installId = toServiceInstallId(root);
139
+ const metadataPath = getServiceMetadataPath(installId, homeDir);
140
+ if (!existsSync(metadataPath)) {
141
+ throw new CodoriError('SERVICE_NOT_INSTALLED', `No service metadata was found for ${resolve(root)}. Install it first with npx @codori/server install-service.`);
142
+ }
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(readFileSync(metadataPath, 'utf8'));
146
+ }
147
+ catch (error) {
148
+ throw new CodoriError('INVALID_SERVICE_METADATA', `Failed to parse ${metadataPath}.`, error);
149
+ }
150
+ const metadata = normalizeServiceMetadata(parsed);
151
+ if (!metadata) {
152
+ throw new CodoriError('INVALID_SERVICE_METADATA', `Service metadata at ${metadataPath} is malformed.`);
153
+ }
154
+ return metadata;
155
+ };
156
+ const resolveServiceDefinition = (metadata, homeDir) => {
157
+ const metadataDirectory = getServiceMetadataDirectory(metadata.installId, homeDir);
158
+ const launcherPath = getServiceLauncherPath(metadata.installId, homeDir);
159
+ if (metadata.platform === 'darwin') {
160
+ return createDarwinServiceDefinition({
161
+ installId: metadata.installId,
162
+ scope: metadata.scope,
163
+ launcherPath,
164
+ root: metadata.root,
165
+ metadataDirectory,
166
+ homeDir
167
+ });
168
+ }
169
+ return createLinuxServiceDefinition({
170
+ installId: metadata.installId,
171
+ scope: metadata.scope,
172
+ launcherPath,
173
+ root: metadata.root,
174
+ metadataDirectory,
175
+ homeDir
176
+ });
177
+ };
178
+ const resolveServiceCommands = (action, metadata, definition) => {
179
+ if (metadata.platform === 'darwin') {
180
+ if (action === 'install') {
181
+ return getDarwinInstallCommands(definition, metadata.scope);
182
+ }
183
+ if (action === 'restart') {
184
+ return getDarwinRestartCommands(definition, metadata.scope);
185
+ }
186
+ return getDarwinUninstallCommands(definition, metadata.scope);
187
+ }
188
+ if (action === 'install') {
189
+ return getLinuxInstallCommands(definition, metadata.scope);
190
+ }
191
+ if (action === 'restart') {
192
+ return getLinuxRestartCommands(definition, metadata.scope);
193
+ }
194
+ return getLinuxUninstallCommands(definition, metadata.scope);
195
+ };
196
+ const runCommandSequence = async (commands, runCommand, allowFailure = () => false) => {
197
+ for (const command of commands) {
198
+ let result;
199
+ try {
200
+ result = await runCommand(command.command, command.args);
201
+ }
202
+ catch (error) {
203
+ throw new CodoriError('SERVICE_COMMAND_FAILED', `Failed to execute ${command.command} ${command.args.join(' ')}.`, error);
204
+ }
205
+ if (result.exitCode === 0 || allowFailure(command, result)) {
206
+ continue;
207
+ }
208
+ throw new CodoriError('SERVICE_COMMAND_FAILED', `Command failed: ${command.command} ${command.args.join(' ')}`, result.stderr || result.stdout || null);
209
+ }
210
+ };
211
+ const ensureLinuxServiceManager = async (scope, runCommand) => {
212
+ let version;
213
+ try {
214
+ version = await runCommand('systemctl', ['--version']);
215
+ }
216
+ catch (error) {
217
+ throw new CodoriError('UNSUPPORTED_SERVICE_MANAGER', 'systemctl is required on Linux.', error);
218
+ }
219
+ if (version.exitCode !== 0) {
220
+ throw new CodoriError('UNSUPPORTED_SERVICE_MANAGER', 'systemctl is required on Linux.');
221
+ }
222
+ if (scope === 'system') {
223
+ return;
224
+ }
225
+ const environment = await runCommand('systemctl', ['--user', 'show-environment']);
226
+ if (environment.exitCode !== 0) {
227
+ throw new CodoriError('UNSUPPORTED_SERVICE_MANAGER', 'systemd user services are unavailable for this session.', environment.stderr || environment.stdout || null);
228
+ }
229
+ };
230
+ const ensureSystemScopePrivileges = (scope, command, options) => {
231
+ if (scope !== 'system' || getCurrentUserId() === 0) {
232
+ return;
233
+ }
234
+ const rerun = `sudo ${buildCanonicalInvocation(command, options)}`;
235
+ throw new CodoriError('SERVICE_REQUIRES_SUDO', `System service registration requires elevated privileges. Re-run with: ${rerun}`);
236
+ };
237
+ const shouldIgnoreCommandFailure = (action, metadata, command) => metadata.platform === 'darwin' && action !== 'restart' && command.args[0] === 'bootout';
238
+ const resolveRootWithPrompt = async (root, yes, cwd, prompt) => {
239
+ if (root) {
240
+ const resolvedRoot = resolve(root);
241
+ ensureExistingDirectory(resolvedRoot);
242
+ return resolvedRoot;
243
+ }
244
+ const defaultRoot = detectRootPromptDefault(cwd);
245
+ if (yes) {
246
+ if (!defaultRoot.value) {
247
+ throw new CodoriError('MISSING_ROOT', 'Project root is required. Pass --root or run interactively from a likely project root.');
248
+ }
249
+ ensureExistingDirectory(defaultRoot.value);
250
+ return defaultRoot.value;
251
+ }
252
+ if (defaultRoot.value) {
253
+ const useDefault = await prompt.confirm(`Use ${defaultRoot.value} as the project root`, true);
254
+ if (useDefault) {
255
+ ensureExistingDirectory(defaultRoot.value);
256
+ return defaultRoot.value;
257
+ }
258
+ }
259
+ const answer = await prompt.input('Project root directory', defaultRoot.value ?? undefined);
260
+ if (!answer) {
261
+ throw new CodoriError('MISSING_ROOT', 'Project root is required.');
262
+ }
263
+ const resolvedRoot = resolve(answer);
264
+ ensureExistingDirectory(resolvedRoot);
265
+ return resolvedRoot;
266
+ };
267
+ const resolvePromptedScope = async (scope, yes, prompt) => {
268
+ if (scope || yes) {
269
+ return resolveServiceScope(scope);
270
+ }
271
+ return resolveServiceScope(await prompt.input('Service scope', 'user'));
272
+ };
273
+ const resolvePromptedPort = async (port, yes, prompt) => {
274
+ const explicitPort = parseServicePort(port);
275
+ if (explicitPort !== undefined || yes) {
276
+ return resolveDefaultServicePort(explicitPort);
277
+ }
278
+ const answer = await prompt.input('Port for the Codori server', String(DEFAULT_SERVER_PORT));
279
+ return resolveDefaultServicePort(parseServicePort(answer));
280
+ };
281
+ const resolvePromptedHost = async (host, yes, prompt, runCommand, stdout) => {
282
+ const hostDefault = await resolveHostPromptDefault(host, runCommand);
283
+ if (hostDefault.warning) {
284
+ writeLine(stdout, `Warning: ${hostDefault.warning}`);
285
+ }
286
+ if (host || yes) {
287
+ return hostDefault.value;
288
+ }
289
+ const answer = await prompt.input('Host to bind Codori', hostDefault.value);
290
+ return answer || hostDefault.value;
291
+ };
292
+ const writeLauncherAndServiceFiles = (metadata, definition, homeDir, nodePath, npxPath) => {
293
+ const metadataDirectory = getServiceMetadataDirectory(metadata.installId, homeDir);
294
+ const launcherPath = getServiceLauncherPath(metadata.installId, homeDir);
295
+ ensureDirectory(metadataDirectory);
296
+ ensureDirectory(dirname(definition.serviceFilePath));
297
+ const launcherScript = buildLauncherScript({
298
+ installId: metadata.installId,
299
+ root: metadata.root,
300
+ host: metadata.host,
301
+ port: metadata.port,
302
+ scope: metadata.scope,
303
+ nodePath,
304
+ npxPath
305
+ });
306
+ writeFileSync(launcherPath, `${launcherScript}\n`, 'utf8');
307
+ chmodSync(launcherPath, 0o755);
308
+ writeFileSync(definition.serviceFilePath, `${definition.serviceFileContents}\n`, 'utf8');
309
+ };
310
+ const writeServiceMetadata = (metadata, homeDir) => {
311
+ const metadataPath = getServiceMetadataPath(metadata.installId, homeDir);
312
+ ensureDirectory(dirname(metadataPath));
313
+ writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
314
+ };
315
+ const printInstallSummary = (stdout, summary) => {
316
+ writeLine(stdout, 'Service installation summary:');
317
+ writeLine(stdout, ` root: ${summary.root}`);
318
+ writeLine(stdout, ` host: ${summary.host}`);
319
+ writeLine(stdout, ` port: ${summary.port}`);
320
+ writeLine(stdout, ` scope: ${summary.scope}`);
321
+ writeLine(stdout, ` launcher: ${summary.launcherPath}`);
322
+ writeLine(stdout, ` service file: ${summary.serviceFilePath}`);
323
+ };
324
+ const createOperationMetadata = (installId, action, values, now, previousMetadata) => ({
325
+ installId,
326
+ root: values.root,
327
+ host: values.host,
328
+ port: values.port,
329
+ scope: values.scope,
330
+ platform: values.platform,
331
+ serviceName: values.definition.serviceName,
332
+ serviceFilePath: values.definition.serviceFilePath,
333
+ launcherPath: getServiceLauncherPath(installId, values.homeDir),
334
+ installedAt: action === 'install'
335
+ ? now().toISOString()
336
+ : previousMetadata?.installedAt ?? now().toISOString()
337
+ });
338
+ export const toServiceInstallId = (root) => createHash('sha256').update(resolve(root)).digest('hex').slice(0, 12);
339
+ export const getServiceMetadataDirectory = (installId, homeDir = os.homedir()) => join(resolveCodoriHome(homeDir), 'services', installId);
340
+ export const getServiceMetadataPath = (installId, homeDir = os.homedir()) => join(getServiceMetadataDirectory(installId, homeDir), 'service.json');
341
+ export const getServiceLauncherPath = (installId, homeDir = os.homedir()) => join(getServiceMetadataDirectory(installId, homeDir), 'run-service.sh');
342
+ export const detectRootPromptDefault = (cwd) => {
343
+ const resolvedCwd = resolve(cwd);
344
+ if (existsSync(join(resolvedCwd, '.git'))) {
345
+ return {
346
+ value: resolvedCwd,
347
+ reason: 'git',
348
+ shouldConfirm: true
349
+ };
350
+ }
351
+ if (WORKSPACE_MARKER_NAMES.some(marker => existsSync(join(resolvedCwd, marker)))) {
352
+ return {
353
+ value: resolvedCwd,
354
+ reason: 'workspace-marker',
355
+ shouldConfirm: true
356
+ };
357
+ }
358
+ if (scanProjects(resolvedCwd).length > 0) {
359
+ return {
360
+ value: resolvedCwd,
361
+ reason: 'nested-git-projects',
362
+ shouldConfirm: true
363
+ };
364
+ }
365
+ return {
366
+ value: null,
367
+ reason: 'none',
368
+ shouldConfirm: false
369
+ };
370
+ };
371
+ export const resolveServiceScope = (value) => {
372
+ if (!value) {
373
+ return 'user';
374
+ }
375
+ if (SUPPORTED_SERVICE_SCOPES.has(value)) {
376
+ return value;
377
+ }
378
+ throw new Error(`Unsupported service scope "${value}". Expected "user" or "system".`);
379
+ };
380
+ export const parseServicePort = (value) => {
381
+ if (value === undefined) {
382
+ return undefined;
383
+ }
384
+ const parsed = typeof value === 'number'
385
+ ? value
386
+ : Number.parseInt(value, 10);
387
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
388
+ throw new Error(`Invalid service port "${value}". Expected an integer between 1 and 65535.`);
389
+ }
390
+ return parsed;
391
+ };
392
+ export const resolveDefaultServicePort = (value) => value ?? DEFAULT_SERVER_PORT;
393
+ export const getWildcardHostWarning = () => WILDCARD_HOST_WARNING;
394
+ export const detectTailscaleIpv4 = async (runCommand = defaultCommandRunner) => {
395
+ try {
396
+ const status = await runCommand('tailscale', ['status', '--json']);
397
+ if (status.exitCode === 0) {
398
+ const parsed = JSON.parse(status.stdout);
399
+ if (typeof parsed === 'object' && parsed !== null) {
400
+ const backendState = 'BackendState' in parsed ? parsed.BackendState : undefined;
401
+ const self = 'Self' in parsed ? parsed.Self : undefined;
402
+ const selfIps = typeof self === 'object' && self !== null && 'TailscaleIPs' in self
403
+ ? self.TailscaleIPs
404
+ : undefined;
405
+ const statusIps = 'TailscaleIPs' in parsed ? parsed.TailscaleIPs : undefined;
406
+ if (backendState === 'Running') {
407
+ return findFirstIpv4(selfIps) ?? findFirstIpv4(statusIps);
408
+ }
409
+ }
410
+ }
411
+ }
412
+ catch {
413
+ // Fall back to the direct IP command.
414
+ }
415
+ try {
416
+ const ipResult = await runCommand('tailscale', ['ip', '-4']);
417
+ if (ipResult.exitCode !== 0) {
418
+ return null;
419
+ }
420
+ const line = ipResult.stdout
421
+ .split(/\r?\n/u)
422
+ .map(value => value.trim())
423
+ .find(Boolean);
424
+ return line && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(line) ? line : null;
425
+ }
426
+ catch {
427
+ return null;
428
+ }
429
+ };
430
+ export const resolveHostPromptDefault = async (explicitHost, runCommand = defaultCommandRunner) => {
431
+ if (explicitHost) {
432
+ return {
433
+ value: explicitHost,
434
+ source: 'explicit',
435
+ warning: explicitHost === '0.0.0.0' ? WILDCARD_HOST_WARNING : null
436
+ };
437
+ }
438
+ const tailscaleIpv4 = await detectTailscaleIpv4(runCommand);
439
+ if (tailscaleIpv4) {
440
+ return {
441
+ value: tailscaleIpv4,
442
+ source: 'tailscale',
443
+ warning: null
444
+ };
445
+ }
446
+ return {
447
+ value: '0.0.0.0',
448
+ source: 'wildcard',
449
+ warning: WILDCARD_HOST_WARNING
450
+ };
451
+ };
452
+ export const buildLauncherScript = ({ installId, root, host, port, scope, nodePath, npxPath }) => {
453
+ const pathEntries = Array.from(new Set([dirname(nodePath), dirname(npxPath)]));
454
+ const exportPath = `${pathEntries.map(shellEscape).join(':')}:$PATH`;
455
+ return [
456
+ '#!/bin/sh',
457
+ 'set -eu',
458
+ `export PATH=${exportPath}`,
459
+ `export ${CODORI_SERVICE_MANAGED_ENV}=1`,
460
+ `export ${CODORI_SERVICE_INSTALL_ID_ENV}=${shellEscape(installId)}`,
461
+ `export ${CODORI_SERVICE_SCOPE_ENV}=${shellEscape(scope)}`,
462
+ `exec ${shellEscape(npxPath)} --yes @codori/server serve --root ${shellEscape(resolve(root))} --host ${shellEscape(host)} --port ${port}`
463
+ ].join('\n');
464
+ };
465
+ export const installService = async (options = {}, dependencies = {}) => {
466
+ const runCommand = dependencies.runCommand ?? defaultCommandRunner;
467
+ const stdout = dependencies.stdout ?? process.stdout;
468
+ const cwd = dependencies.cwd ?? process.cwd();
469
+ const homeDir = dependencies.homeDir ?? os.homedir();
470
+ const nodePath = dependencies.nodePath ?? process.execPath;
471
+ const npxPath = dependencies.npxPath ?? join(dirname(nodePath), 'npx');
472
+ const platform = resolveServicePlatform(dependencies.platform);
473
+ const now = dependencies.now ?? (() => new Date());
474
+ const prompt = dependencies.prompt ?? createDefaultPrompt((dependencies.stdin ?? process.stdin), stdout);
475
+ const yes = options.yes ?? false;
476
+ try {
477
+ const root = await resolveRootWithPrompt(options.root, yes, cwd, prompt);
478
+ const host = await resolvePromptedHost(options.host, yes, prompt, runCommand, stdout);
479
+ const port = await resolvePromptedPort(options.port, yes, prompt);
480
+ const scope = await resolvePromptedScope(options.scope, yes, prompt);
481
+ ensureSystemScopePrivileges(scope, 'install-service', {
482
+ root,
483
+ host,
484
+ port,
485
+ scope,
486
+ yes
487
+ });
488
+ if (platform === 'linux') {
489
+ await ensureLinuxServiceManager(scope, runCommand);
490
+ }
491
+ const installId = toServiceInstallId(root);
492
+ const definition = resolveServiceDefinition({
493
+ installId,
494
+ scope,
495
+ root,
496
+ platform
497
+ }, homeDir);
498
+ printInstallSummary(stdout, {
499
+ root,
500
+ host,
501
+ port,
502
+ scope,
503
+ launcherPath: getServiceLauncherPath(installId, homeDir),
504
+ serviceFilePath: definition.serviceFilePath
505
+ });
506
+ if (!yes) {
507
+ const confirmed = await prompt.confirm('Install this service now', true);
508
+ if (!confirmed) {
509
+ throw new CodoriError('SERVICE_ABORTED', 'Service installation was cancelled.');
510
+ }
511
+ }
512
+ const metadata = createOperationMetadata(installId, 'install', {
513
+ root,
514
+ host,
515
+ port,
516
+ scope,
517
+ platform,
518
+ definition,
519
+ homeDir
520
+ }, now);
521
+ writeLauncherAndServiceFiles(metadata, definition, homeDir, nodePath, npxPath);
522
+ await runCommandSequence(resolveServiceCommands('install', metadata, definition), runCommand, (command, result) => shouldIgnoreCommandFailure('install', metadata, command) && result.exitCode !== 0);
523
+ writeServiceMetadata(metadata, homeDir);
524
+ return {
525
+ action: 'install',
526
+ metadata
527
+ };
528
+ }
529
+ finally {
530
+ if (!dependencies.prompt) {
531
+ await prompt.close();
532
+ }
533
+ }
534
+ };
535
+ export const restartService = async (options = {}, dependencies = {}) => {
536
+ const runCommand = dependencies.runCommand ?? defaultCommandRunner;
537
+ const cwd = dependencies.cwd ?? process.cwd();
538
+ const homeDir = dependencies.homeDir ?? os.homedir();
539
+ const nodePath = dependencies.nodePath ?? process.execPath;
540
+ const npxPath = dependencies.npxPath ?? join(dirname(nodePath), 'npx');
541
+ const prompt = dependencies.prompt ?? createDefaultPrompt((dependencies.stdin ?? process.stdin), (dependencies.stdout ?? process.stdout));
542
+ const yes = options.yes ?? false;
543
+ try {
544
+ const root = await resolveRootWithPrompt(options.root, yes, cwd, prompt);
545
+ const metadata = loadServiceMetadata(root, homeDir);
546
+ if (options.scope) {
547
+ const requestedScope = resolveServiceScope(options.scope);
548
+ if (requestedScope !== metadata.scope) {
549
+ throw new CodoriError('SERVICE_SCOPE_MISMATCH', `Installed scope is ${metadata.scope}, not ${requestedScope}.`);
550
+ }
551
+ }
552
+ ensureSystemScopePrivileges(metadata.scope, 'restart-service', {
553
+ root: metadata.root,
554
+ scope: metadata.scope,
555
+ yes
556
+ });
557
+ if (metadata.platform === 'linux') {
558
+ await ensureLinuxServiceManager(metadata.scope, runCommand);
559
+ }
560
+ const definition = resolveServiceDefinition(metadata, homeDir);
561
+ writeLauncherAndServiceFiles(metadata, definition, homeDir, nodePath, npxPath);
562
+ await runCommandSequence(resolveServiceCommands('restart', metadata, definition), runCommand);
563
+ return {
564
+ action: 'restart',
565
+ metadata
566
+ };
567
+ }
568
+ finally {
569
+ if (!dependencies.prompt) {
570
+ await prompt.close();
571
+ }
572
+ }
573
+ };
574
+ export const uninstallService = async (options = {}, dependencies = {}) => {
575
+ const runCommand = dependencies.runCommand ?? defaultCommandRunner;
576
+ const cwd = dependencies.cwd ?? process.cwd();
577
+ const homeDir = dependencies.homeDir ?? os.homedir();
578
+ const prompt = dependencies.prompt ?? createDefaultPrompt((dependencies.stdin ?? process.stdin), (dependencies.stdout ?? process.stdout));
579
+ const yes = options.yes ?? false;
580
+ try {
581
+ const root = await resolveRootWithPrompt(options.root, yes, cwd, prompt);
582
+ const metadata = loadServiceMetadata(root, homeDir);
583
+ ensureSystemScopePrivileges(metadata.scope, 'uninstall-service', {
584
+ root: metadata.root,
585
+ scope: metadata.scope,
586
+ yes
587
+ });
588
+ if (!yes) {
589
+ const confirmed = await prompt.confirm(`Remove the service for ${metadata.root}`, true);
590
+ if (!confirmed) {
591
+ throw new CodoriError('SERVICE_ABORTED', 'Service removal was cancelled.');
592
+ }
593
+ }
594
+ if (metadata.platform === 'linux') {
595
+ await ensureLinuxServiceManager(metadata.scope, runCommand);
596
+ }
597
+ const definition = resolveServiceDefinition(metadata, homeDir);
598
+ const commands = resolveServiceCommands('uninstall', metadata, definition);
599
+ if (commands.length > 0) {
600
+ const [first, ...rest] = commands;
601
+ await runCommandSequence([first], runCommand, (command, result) => shouldIgnoreCommandFailure('uninstall', metadata, command) && result.exitCode !== 0);
602
+ rmSync(definition.serviceFilePath, { force: true });
603
+ await runCommandSequence(rest, runCommand);
604
+ rmSync(getServiceMetadataDirectory(metadata.installId, homeDir), { recursive: true, force: true });
605
+ }
606
+ return {
607
+ action: 'uninstall',
608
+ metadata
609
+ };
610
+ }
611
+ finally {
612
+ if (!dependencies.prompt) {
613
+ await prompt.close();
614
+ }
615
+ }
616
+ };
package/dist/types.d.ts CHANGED
@@ -8,11 +8,19 @@ export type CodoriConfig = {
8
8
  start: number;
9
9
  end: number;
10
10
  };
11
+ idleShutdown: {
12
+ enabled: boolean;
13
+ timeoutMs: number;
14
+ sweepIntervalMs: number;
15
+ };
11
16
  };
12
17
  export type ConfigOverrides = {
13
18
  root?: string;
14
19
  host?: string;
15
20
  port?: number;
21
+ idleShutdownEnabled?: boolean;
22
+ idleShutdownTimeoutMs?: number;
23
+ idleShutdownSweepIntervalMs?: number;
16
24
  };
17
25
  export type ProjectRecord = {
18
26
  id: string;
@@ -24,6 +32,7 @@ export type RuntimeRecord = {
24
32
  pid: number;
25
33
  port: number;
26
34
  startedAt: number;
35
+ lastActivityAt: number;
27
36
  };
28
37
  export type ProjectRuntimeStatus = 'running' | 'stopped' | 'error';
29
38
  export type ProjectStatusRecord = {
@@ -33,6 +42,10 @@ export type ProjectStatusRecord = {
33
42
  pid: number | null;
34
43
  port: number | null;
35
44
  startedAt: number | null;
45
+ lastActivityAt: number | null;
46
+ activeSessionCount: number;
47
+ idleTimeoutMs: number | null;
48
+ idleDeadlineAt: number | null;
36
49
  error: string | null;
37
50
  };
38
51
  export type StartProjectResult = ProjectStatusRecord & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codori/server",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "private": false,
5
5
  "description": "Codori server for Git project discovery, Codex runtime management, and bundled dashboard serving.",
6
6
  "type": "module",