@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.
- package/README.md +34 -0
- package/client-dist/200.html +1 -1
- package/client-dist/404.html +1 -1
- package/client-dist/_nuxt/BsofOenw.js +1 -0
- package/client-dist/_nuxt/C1GdLDfB.js +1 -0
- package/client-dist/_nuxt/{CaHFvrMF.js → CNSSoePX.js} +1 -1
- package/client-dist/_nuxt/CQVB8E20.js +1 -0
- package/client-dist/_nuxt/{Cvj6lHH1.js → CsE-687t.js} +51 -51
- package/client-dist/_nuxt/{Bn41X3Zq.js → DJfsg7Kb.js} +1 -1
- package/client-dist/_nuxt/DcJmCJZR.js +1 -0
- package/client-dist/_nuxt/{CxIrrT6Q.js → Dg2XLMZm.js} +1 -1
- package/client-dist/_nuxt/DhLoSG-h.js +3 -0
- package/client-dist/_nuxt/DhRbzQPR.js +1 -0
- package/client-dist/_nuxt/{B9M-aXlQ.js → OylMiRf9.js} +3 -3
- package/client-dist/_nuxt/builds/latest.json +1 -1
- package/client-dist/_nuxt/builds/meta/468a0ff2-bd27-45c6-bd89-5ac776d98662.json +1 -0
- package/client-dist/_nuxt/{ClvUKBzL.js → ecRbsnab.js} +1 -1
- package/client-dist/index.html +1 -1
- package/dist/cli.d.ts +5 -1
- package/dist/cli.js +171 -19
- package/dist/config.d.ts +2 -0
- package/dist/config.js +26 -2
- package/dist/http-server.d.ts +8 -0
- package/dist/http-server.js +59 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/process-manager.d.ts +19 -0
- package/dist/process-manager.js +126 -3
- package/dist/runtime-store.js +5 -3
- package/dist/service-adapters.d.ts +39 -0
- package/dist/service-adapters.js +185 -0
- package/dist/service-update.d.ts +26 -0
- package/dist/service-update.js +196 -0
- package/dist/service.d.ts +86 -0
- package/dist/service.js +616 -0
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
- package/client-dist/_nuxt/B13tqEXg.js +0 -1
- package/client-dist/_nuxt/Bgck3A5L.js +0 -1
- package/client-dist/_nuxt/DS99AY4f.js +0 -1
- package/client-dist/_nuxt/Dp21CzWX.js +0 -1
- package/client-dist/_nuxt/ER2AV0-Z.js +0 -1
- package/client-dist/_nuxt/builds/meta/5f4263d4-eac2-4ff0-9a82-eb8be28751d5.json +0 -1
- package/client-dist/_nuxt/nHwHvv6y.js +0 -3
package/dist/service.js
ADDED
|
@@ -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 & {
|