@codori/server 0.0.2 → 0.0.4
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/5cGRqVd7.js +1 -0
- package/client-dist/_nuxt/B1wExIrb.js +3 -0
- package/client-dist/_nuxt/{bTgPCKPF.js → B3F7AEX8.js} +1 -1
- package/client-dist/_nuxt/BLQDSwSv.js +1 -0
- package/client-dist/_nuxt/CNJnWNJ6.js +1 -0
- package/client-dist/_nuxt/CTkc1dr0.js +30 -0
- package/client-dist/_nuxt/Ce2C-7Tv.js +1 -0
- package/client-dist/_nuxt/CnVIfmli.js +1 -0
- package/client-dist/_nuxt/DQ92n70Y.js +202 -0
- package/client-dist/_nuxt/{BqBt7DE7.js → DfKxoeGc.js} +1 -1
- package/client-dist/_nuxt/VKebgJ9X.js +1 -0
- package/client-dist/_nuxt/_6KBkEBa.js +1 -0
- package/client-dist/_nuxt/_threadId_.BfTZeVnD.css +1 -0
- package/client-dist/_nuxt/builds/latest.json +1 -1
- package/client-dist/_nuxt/builds/meta/9f29d3f8-cf52-453d-9a00-836b5db2a30e.json +1 -0
- package/client-dist/_nuxt/entry.BFUss7SH.css +1 -0
- package/client-dist/index.html +1 -1
- package/dist/attachment-store.d.ts +32 -0
- package/dist/attachment-store.js +84 -0
- package/dist/cli.d.ts +5 -1
- package/dist/cli.js +171 -19
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -2
- package/dist/http-server.d.ts +3 -0
- package/dist/http-server.js +181 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- 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/package.json +6 -1
- package/client-dist/_nuxt/7-GD2bnK.js +0 -1
- package/client-dist/_nuxt/B3mzt2bn.js +0 -1
- package/client-dist/_nuxt/BC-nNqet.js +0 -1
- package/client-dist/_nuxt/BZ0PKZpG.js +0 -1
- package/client-dist/_nuxt/BtYrONTB.js +0 -30
- package/client-dist/_nuxt/CG5UFFba.js +0 -203
- package/client-dist/_nuxt/CcHTZT9i.js +0 -1
- package/client-dist/_nuxt/D39j61CJ.js +0 -1
- package/client-dist/_nuxt/Dgfnd7_d.js +0 -1
- package/client-dist/_nuxt/_threadId_.DWwkJvLa.css +0 -1
- package/client-dist/_nuxt/builds/meta/3230b6da-fb0f-48a4-a654-1a7f8145eecd.json +0 -1
- package/client-dist/_nuxt/entry.V8kD4EEO.css +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { open, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { resolveCodoriHome } from './config.js';
|
|
7
|
+
const hashSegment = (value) => createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
8
|
+
const sanitizeFilename = (value) => {
|
|
9
|
+
const normalized = basename(value).trim();
|
|
10
|
+
return normalized || 'attachment';
|
|
11
|
+
};
|
|
12
|
+
const createFileNameCandidate = (directory, filename, index) => {
|
|
13
|
+
const extension = extname(filename);
|
|
14
|
+
const base = extension ? filename.slice(0, -extension.length) : filename;
|
|
15
|
+
return join(directory, index === 0 ? filename : `${base}-${index}${extension}`);
|
|
16
|
+
};
|
|
17
|
+
const attachmentMetadataPath = (filePath) => `${filePath}.metadata.json`;
|
|
18
|
+
export const resolveAttachmentsRootDir = (rootDir) => resolve(rootDir ?? join(resolveCodoriHome(os.homedir()), 'attachments'));
|
|
19
|
+
export const resolveProjectAttachmentsDir = (projectPath, rootDir) => join(resolveAttachmentsRootDir(rootDir), hashSegment(projectPath));
|
|
20
|
+
export const resolveThreadAttachmentsDir = (projectPath, threadId, rootDir) => join(resolveProjectAttachmentsDir(projectPath, rootDir), hashSegment(threadId));
|
|
21
|
+
export const isPathInsideDirectory = (targetPath, directory) => {
|
|
22
|
+
const relativePath = relative(directory, targetPath);
|
|
23
|
+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
24
|
+
};
|
|
25
|
+
export const writeAttachmentMetadata = async (filePath, metadata) => {
|
|
26
|
+
await writeFile(attachmentMetadataPath(filePath), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
27
|
+
};
|
|
28
|
+
export const readAttachmentMetadata = async (filePath) => {
|
|
29
|
+
try {
|
|
30
|
+
const source = await readFile(attachmentMetadataPath(filePath), 'utf8');
|
|
31
|
+
const parsed = JSON.parse(source);
|
|
32
|
+
if (typeof parsed === 'object'
|
|
33
|
+
&& parsed !== null
|
|
34
|
+
&& 'mediaType' in parsed
|
|
35
|
+
&& (typeof parsed.mediaType === 'string' || parsed.mediaType === null)) {
|
|
36
|
+
return {
|
|
37
|
+
mediaType: parsed.mediaType
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
export const createThreadAttachmentTarget = async (input) => {
|
|
47
|
+
const directory = resolveThreadAttachmentsDir(input.projectPath, input.threadId, input.rootDir);
|
|
48
|
+
await mkdir(directory, { recursive: true });
|
|
49
|
+
const filename = sanitizeFilename(input.filename);
|
|
50
|
+
for (let index = 0; index < 10_000; index += 1) {
|
|
51
|
+
const candidate = createFileNameCandidate(directory, filename, index);
|
|
52
|
+
try {
|
|
53
|
+
const handle = await open(candidate, 'wx');
|
|
54
|
+
return {
|
|
55
|
+
filePath: candidate,
|
|
56
|
+
handle
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === 'EEXIST') {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Failed to allocate a unique attachment path for ${filename}.`);
|
|
67
|
+
};
|
|
68
|
+
export const persistThreadAttachmentStream = async (input) => {
|
|
69
|
+
const target = await createThreadAttachmentTarget(input);
|
|
70
|
+
try {
|
|
71
|
+
await pipeline(input.stream, target.handle.createWriteStream());
|
|
72
|
+
await writeAttachmentMetadata(target.filePath, {
|
|
73
|
+
mediaType: input.mediaType
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
filename: basename(target.filePath),
|
|
77
|
+
mediaType: input.mediaType,
|
|
78
|
+
path: target.filePath
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await target.handle.close().catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
};
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { type ServiceCommandDependencies } from './service.js';
|
|
3
|
+
export declare const CLI_USAGE: string;
|
|
4
|
+
export declare const resolveCliEntrypointPath: (value: string | undefined) => string | null;
|
|
5
|
+
export declare const isCliEntrypointPath: (argvPath: string | undefined, moduleUrl: string) => boolean;
|
|
6
|
+
export declare const runCli: (argv?: string[], dependencies?: ServiceCommandDependencies) => Promise<void>;
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve as resolvePath } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
2
5
|
import { parseArgs } from 'node:util';
|
|
3
6
|
import { asErrorMessage, CodoriError } from './errors.js';
|
|
4
7
|
import { startHttpServer } from './http-server.js';
|
|
5
8
|
import { createRuntimeManager } from './process-manager.js';
|
|
9
|
+
import { installService, restartService, uninstallService } from './service.js';
|
|
6
10
|
const printJson = (value) => {
|
|
7
11
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
8
12
|
};
|
|
@@ -35,6 +39,16 @@ const optionConfig = {
|
|
|
35
39
|
},
|
|
36
40
|
json: {
|
|
37
41
|
type: 'boolean'
|
|
42
|
+
},
|
|
43
|
+
scope: {
|
|
44
|
+
type: 'string'
|
|
45
|
+
},
|
|
46
|
+
yes: {
|
|
47
|
+
type: 'boolean'
|
|
48
|
+
},
|
|
49
|
+
help: {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
short: 'h'
|
|
38
52
|
}
|
|
39
53
|
};
|
|
40
54
|
const coercePort = (value) => {
|
|
@@ -45,22 +59,125 @@ const coercePort = (value) => {
|
|
|
45
59
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
46
60
|
};
|
|
47
61
|
const resolveCliRoot = (value) => value ?? process.cwd();
|
|
48
|
-
const
|
|
62
|
+
export const CLI_USAGE = [
|
|
63
|
+
'Usage:',
|
|
64
|
+
' npx @codori/server <command> [projectId] [options]',
|
|
65
|
+
' codori <command> [projectId] [options]',
|
|
66
|
+
'',
|
|
67
|
+
'Runtime commands:',
|
|
68
|
+
' serve',
|
|
69
|
+
' list',
|
|
70
|
+
' status [projectId]',
|
|
71
|
+
' start <projectId>',
|
|
72
|
+
' stop <projectId>',
|
|
73
|
+
'',
|
|
74
|
+
'Service commands:',
|
|
75
|
+
' install-service',
|
|
76
|
+
' setup-service',
|
|
77
|
+
' restart-service',
|
|
78
|
+
' uninstall-service',
|
|
79
|
+
'',
|
|
80
|
+
'Options:',
|
|
81
|
+
' --root <path>',
|
|
82
|
+
' --host <host>',
|
|
83
|
+
' --port <port>',
|
|
84
|
+
' --scope <user|system>',
|
|
85
|
+
' --yes',
|
|
86
|
+
' --json',
|
|
87
|
+
' --help',
|
|
88
|
+
'',
|
|
89
|
+
'Canonical service examples:',
|
|
90
|
+
' npx @codori/server install-service',
|
|
91
|
+
' npx @codori/server restart-service --root ~/Project/codori',
|
|
92
|
+
' npx @codori/server uninstall-service --root ~/Project/codori',
|
|
93
|
+
'',
|
|
94
|
+
'Installed binary examples:',
|
|
95
|
+
' codori install-service',
|
|
96
|
+
' codori restart-service --root ~/Project/codori'
|
|
97
|
+
].join('\n');
|
|
98
|
+
const printUsage = (stdout = process.stdout) => {
|
|
99
|
+
stdout.write(`${CLI_USAGE}\n`);
|
|
100
|
+
};
|
|
101
|
+
export const resolveCliEntrypointPath = (value) => {
|
|
102
|
+
if (!value) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const resolved = resolvePath(value);
|
|
106
|
+
try {
|
|
107
|
+
return realpathSync(resolved);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
export const isCliEntrypointPath = (argvPath, moduleUrl) => {
|
|
114
|
+
const entryPath = resolveCliEntrypointPath(argvPath);
|
|
115
|
+
const modulePath = resolveCliEntrypointPath(fileURLToPath(moduleUrl));
|
|
116
|
+
return entryPath !== null && entryPath === modulePath;
|
|
117
|
+
};
|
|
118
|
+
const executeServiceCommand = async (command, values, dependencies = {}) => {
|
|
119
|
+
const stdout = dependencies.stdout ?? process.stdout;
|
|
120
|
+
const options = {
|
|
121
|
+
root: values.root,
|
|
122
|
+
host: values.host,
|
|
123
|
+
port: values.port,
|
|
124
|
+
scope: values.scope,
|
|
125
|
+
yes: values.yes ?? false
|
|
126
|
+
};
|
|
127
|
+
switch (command) {
|
|
128
|
+
case 'install-service':
|
|
129
|
+
case 'setup-service': {
|
|
130
|
+
const result = await installService(options, dependencies);
|
|
131
|
+
stdout.write(`Installed service ${result.metadata.serviceName}\n`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case 'restart-service': {
|
|
135
|
+
const result = await restartService({
|
|
136
|
+
root: values.root,
|
|
137
|
+
scope: values.scope,
|
|
138
|
+
yes: values.yes ?? false
|
|
139
|
+
}, dependencies);
|
|
140
|
+
stdout.write(`Restarted service ${result.metadata.serviceName}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
case 'uninstall-service': {
|
|
144
|
+
const result = await uninstallService({
|
|
145
|
+
root: values.root,
|
|
146
|
+
yes: values.yes ?? false
|
|
147
|
+
}, dependencies);
|
|
148
|
+
stdout.write(`Removed service ${result.metadata.serviceName}\n`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
export const runCli = async (argv = process.argv.slice(2), dependencies = {}) => {
|
|
49
153
|
const parsed = parseArgs({
|
|
154
|
+
args: argv,
|
|
50
155
|
allowPositionals: true,
|
|
51
156
|
options: optionConfig
|
|
52
157
|
});
|
|
158
|
+
const values = parsed.values;
|
|
53
159
|
const [command = 'serve', maybeProjectId] = parsed.positionals;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
160
|
+
if (values.help) {
|
|
161
|
+
printUsage(dependencies.stdout ?? process.stdout);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (command === 'install-service'
|
|
165
|
+
|| command === 'setup-service'
|
|
166
|
+
|| command === 'restart-service'
|
|
167
|
+
|| command === 'uninstall-service') {
|
|
168
|
+
await executeServiceCommand(command, values, dependencies);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
62
171
|
switch (command) {
|
|
63
172
|
case 'list': {
|
|
173
|
+
const manager = createRuntimeManager({
|
|
174
|
+
configOverrides: {
|
|
175
|
+
root: resolveCliRoot(values.root),
|
|
176
|
+
host: values.host,
|
|
177
|
+
port: coercePort(values.port)
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const json = values.json ?? false;
|
|
64
181
|
const statuses = manager.listProjectStatuses();
|
|
65
182
|
if (json) {
|
|
66
183
|
printJson(statuses);
|
|
@@ -71,6 +188,14 @@ const main = async () => {
|
|
|
71
188
|
return;
|
|
72
189
|
}
|
|
73
190
|
case 'status': {
|
|
191
|
+
const manager = createRuntimeManager({
|
|
192
|
+
configOverrides: {
|
|
193
|
+
root: resolveCliRoot(values.root),
|
|
194
|
+
host: values.host,
|
|
195
|
+
port: coercePort(values.port)
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
const json = values.json ?? false;
|
|
74
199
|
if (maybeProjectId) {
|
|
75
200
|
const status = manager.getProjectStatus(maybeProjectId);
|
|
76
201
|
if (json) {
|
|
@@ -91,6 +216,14 @@ const main = async () => {
|
|
|
91
216
|
return;
|
|
92
217
|
}
|
|
93
218
|
case 'start': {
|
|
219
|
+
const manager = createRuntimeManager({
|
|
220
|
+
configOverrides: {
|
|
221
|
+
root: resolveCliRoot(values.root),
|
|
222
|
+
host: values.host,
|
|
223
|
+
port: coercePort(values.port)
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
const json = values.json ?? false;
|
|
94
227
|
if (!maybeProjectId) {
|
|
95
228
|
throw new CodoriError('MISSING_PROJECT_ID', 'The start command requires a project id.');
|
|
96
229
|
}
|
|
@@ -104,6 +237,14 @@ const main = async () => {
|
|
|
104
237
|
return;
|
|
105
238
|
}
|
|
106
239
|
case 'stop': {
|
|
240
|
+
const manager = createRuntimeManager({
|
|
241
|
+
configOverrides: {
|
|
242
|
+
root: resolveCliRoot(values.root),
|
|
243
|
+
host: values.host,
|
|
244
|
+
port: coercePort(values.port)
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
const json = values.json ?? false;
|
|
107
248
|
if (!maybeProjectId) {
|
|
108
249
|
throw new CodoriError('MISSING_PROJECT_ID', 'The stop command requires a project id.');
|
|
109
250
|
}
|
|
@@ -117,22 +258,33 @@ const main = async () => {
|
|
|
117
258
|
return;
|
|
118
259
|
}
|
|
119
260
|
case 'serve': {
|
|
261
|
+
const manager = createRuntimeManager({
|
|
262
|
+
configOverrides: {
|
|
263
|
+
root: resolveCliRoot(values.root),
|
|
264
|
+
host: values.host,
|
|
265
|
+
port: coercePort(values.port)
|
|
266
|
+
}
|
|
267
|
+
});
|
|
120
268
|
const app = await startHttpServer(manager);
|
|
121
269
|
process.stdout.write(`Running codori server with project root directory: ${manager.config.root}\n`);
|
|
122
270
|
process.stdout.write(`Codori listening on http://${manager.config.server.host}:${manager.config.server.port}\n`);
|
|
271
|
+
process.stdout.write('Private tunnel is not included. Expose Codori through your own network layer such as Tailscale or Cloudflare Tunnel when you need remote access.\n');
|
|
123
272
|
await app.ready();
|
|
124
273
|
return;
|
|
125
274
|
}
|
|
126
275
|
default:
|
|
127
|
-
|
|
276
|
+
printUsage(dependencies.stdout ?? process.stdout);
|
|
128
277
|
}
|
|
129
278
|
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
279
|
+
const isEntrypoint = isCliEntrypointPath(process.argv[1], import.meta.url);
|
|
280
|
+
if (isEntrypoint) {
|
|
281
|
+
void runCli().catch((error) => {
|
|
282
|
+
if (error instanceof CodoriError) {
|
|
283
|
+
process.stderr.write(`${error.code}: ${error.message}\n`);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
process.stderr.write(`${asErrorMessage(error)}\n`);
|
|
287
|
+
}
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
});
|
|
290
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { CodoriConfig, ConfigOverrides } from './types.js';
|
|
2
|
+
export declare const DEFAULT_SERVER_HOST = "127.0.0.1";
|
|
3
|
+
export declare const DEFAULT_SERVER_PORT = 4310;
|
|
2
4
|
export declare const resolveCodoriHome: (homeDir?: string) => string;
|
|
3
5
|
export declare const resolveCodoriConfigPath: (homeDir?: string) => string;
|
|
4
6
|
export declare const ensureCodoriDirectories: (homeDir?: string) => {
|
package/dist/config.js
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { CodoriError } from './errors.js';
|
|
5
|
-
const DEFAULT_SERVER_HOST = '127.0.0.1';
|
|
6
|
-
const DEFAULT_SERVER_PORT = 4310;
|
|
5
|
+
export const DEFAULT_SERVER_HOST = '127.0.0.1';
|
|
6
|
+
export const DEFAULT_SERVER_PORT = 4310;
|
|
7
7
|
const DEFAULT_PORT_START = 46000;
|
|
8
8
|
const DEFAULT_PORT_END = 46999;
|
|
9
9
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
package/dist/http-server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
2
|
+
import { type ServiceUpdateController } from './service-update.js';
|
|
2
3
|
import type { ProjectStatusRecord, StartProjectResult } from './types.js';
|
|
3
4
|
type MaybePromise<T> = T | Promise<T>;
|
|
4
5
|
export type RuntimeManagerLike = {
|
|
@@ -15,6 +16,8 @@ export type RuntimeManagerLike = {
|
|
|
15
16
|
};
|
|
16
17
|
export type HttpServerOptions = {
|
|
17
18
|
clientBundleDir?: string | null;
|
|
19
|
+
attachmentsRootDir?: string | null;
|
|
20
|
+
serviceUpdateController?: ServiceUpdateController | null;
|
|
18
21
|
};
|
|
19
22
|
export declare const createHttpServer: (manager: RuntimeManagerLike, options?: HttpServerOptions) => Promise<FastifyInstance>;
|
|
20
23
|
export declare const startHttpServer: (manager?: import("./process-manager.js").RuntimeManager) => Promise<Fastify.FastifyInstance<Fastify.RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, Fastify.FastifyBaseLogger, Fastify.FastifyTypeProviderDefault>>;
|
package/dist/http-server.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
1
2
|
import net from 'node:net';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import multipart from '@fastify/multipart';
|
|
5
7
|
import fastifyStatic from '@fastify/static';
|
|
6
8
|
import websocket from '@fastify/websocket';
|
|
7
9
|
import Fastify, {} from 'fastify';
|
|
10
|
+
import { lookup as lookupMimeType } from 'mime-types';
|
|
8
11
|
import WebSocket from 'ws';
|
|
12
|
+
import { isPathInsideDirectory, persistThreadAttachmentStream, readAttachmentMetadata, resolveProjectAttachmentsDir } from './attachment-store.js';
|
|
9
13
|
import { CodoriError } from './errors.js';
|
|
10
14
|
import { createRuntimeManager } from './process-manager.js';
|
|
15
|
+
import { createServiceUpdateController } from './service-update.js';
|
|
11
16
|
const isCodoriError = (error) => error instanceof CodoriError;
|
|
12
17
|
const resolveBundledClientDir = () => {
|
|
13
18
|
const candidates = [
|
|
@@ -23,14 +28,21 @@ const resolveBundledClientDir = () => {
|
|
|
23
28
|
};
|
|
24
29
|
const toRequestPath = (url) => url.split('?')[0]?.split('#')[0] ?? url;
|
|
25
30
|
const isAssetRequest = (pathname) => /\.[a-z0-9]+$/i.test(pathname);
|
|
31
|
+
const MAX_ATTACHMENTS_PER_MESSAGE = 8;
|
|
32
|
+
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
26
33
|
const toStatusCode = (error) => {
|
|
27
34
|
switch (error.code) {
|
|
28
35
|
case 'PROJECT_NOT_FOUND':
|
|
29
36
|
return 404;
|
|
30
37
|
case 'INVALID_CONFIG':
|
|
31
38
|
case 'MISSING_PROJECT_ID':
|
|
39
|
+
case 'MISSING_THREAD_ID':
|
|
40
|
+
case 'INVALID_ATTACHMENT':
|
|
32
41
|
case 'MISSING_ROOT':
|
|
33
42
|
return 400;
|
|
43
|
+
case 'SERVICE_UPDATE_UNAVAILABLE':
|
|
44
|
+
case 'SERVICE_UPDATE_IN_PROGRESS':
|
|
45
|
+
return 409;
|
|
34
46
|
default:
|
|
35
47
|
return 500;
|
|
36
48
|
}
|
|
@@ -42,6 +54,24 @@ const getProjectIdFromRequest = (value) => {
|
|
|
42
54
|
return value;
|
|
43
55
|
};
|
|
44
56
|
const resolveValue = async (value) => value;
|
|
57
|
+
const isStatusCodeCarrier = (error) => typeof error === 'object'
|
|
58
|
+
&& error !== null
|
|
59
|
+
&& 'statusCode' in error
|
|
60
|
+
&& typeof error.statusCode === 'number';
|
|
61
|
+
const normalizeImageMediaType = (input) => {
|
|
62
|
+
const declared = input.declaredMediaType?.trim().toLowerCase() ?? null;
|
|
63
|
+
if (declared?.startsWith('image/')) {
|
|
64
|
+
return declared;
|
|
65
|
+
}
|
|
66
|
+
if (declared) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const inferred = lookupMimeType(input.filename);
|
|
70
|
+
if (typeof inferred === 'string' && inferred.toLowerCase().startsWith('image/')) {
|
|
71
|
+
return inferred.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
45
75
|
const wait = async (ms) => new Promise((resolvePromise) => {
|
|
46
76
|
setTimeout(resolvePromise, ms);
|
|
47
77
|
});
|
|
@@ -78,6 +108,14 @@ export const createHttpServer = async (manager, options = {}) => {
|
|
|
78
108
|
const clientBundleDir = options.clientBundleDir === undefined
|
|
79
109
|
? resolveBundledClientDir()
|
|
80
110
|
: options.clientBundleDir;
|
|
111
|
+
const serviceUpdateController = options.serviceUpdateController ?? null;
|
|
112
|
+
await app.register(multipart, {
|
|
113
|
+
limits: {
|
|
114
|
+
files: MAX_ATTACHMENTS_PER_MESSAGE,
|
|
115
|
+
fields: 4,
|
|
116
|
+
fileSize: MAX_ATTACHMENT_BYTES
|
|
117
|
+
}
|
|
118
|
+
});
|
|
81
119
|
await app.register(websocket);
|
|
82
120
|
if (clientBundleDir) {
|
|
83
121
|
await app.register(fastifyStatic, {
|
|
@@ -95,6 +133,15 @@ export const createHttpServer = async (manager, options = {}) => {
|
|
|
95
133
|
});
|
|
96
134
|
return;
|
|
97
135
|
}
|
|
136
|
+
if (isStatusCodeCarrier(error)) {
|
|
137
|
+
reply.status(error.statusCode).send({
|
|
138
|
+
error: {
|
|
139
|
+
code: error.code ?? 'REQUEST_ERROR',
|
|
140
|
+
message: error.message ?? 'Request failed.'
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
98
145
|
reply.status(500).send({
|
|
99
146
|
error: {
|
|
100
147
|
code: 'INTERNAL_ERROR',
|
|
@@ -105,6 +152,26 @@ export const createHttpServer = async (manager, options = {}) => {
|
|
|
105
152
|
app.get('/api/projects', async () => ({
|
|
106
153
|
projects: await resolveValue(manager.listProjectStatuses())
|
|
107
154
|
}));
|
|
155
|
+
app.get('/api/service/update', async () => ({
|
|
156
|
+
serviceUpdate: serviceUpdateController
|
|
157
|
+
? await serviceUpdateController.getStatus()
|
|
158
|
+
: {
|
|
159
|
+
enabled: false,
|
|
160
|
+
updateAvailable: false,
|
|
161
|
+
updating: false,
|
|
162
|
+
installedVersion: null,
|
|
163
|
+
latestVersion: null
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
app.post('/api/service/update', async (_request, reply) => {
|
|
167
|
+
if (!serviceUpdateController) {
|
|
168
|
+
throw new CodoriError('SERVICE_UPDATE_UNAVAILABLE', 'Self-update is only available while Codori is running as a registered service.');
|
|
169
|
+
}
|
|
170
|
+
reply.status(202);
|
|
171
|
+
return {
|
|
172
|
+
serviceUpdate: await serviceUpdateController.requestUpdate()
|
|
173
|
+
};
|
|
174
|
+
});
|
|
108
175
|
app.get('/api/projects/:projectId', async (request) => ({
|
|
109
176
|
project: await resolveValue(manager.getProjectStatus(getProjectIdFromRequest(request.params.projectId)))
|
|
110
177
|
}));
|
|
@@ -117,6 +184,114 @@ export const createHttpServer = async (manager, options = {}) => {
|
|
|
117
184
|
app.post('/api/projects/:projectId/stop', async (request) => ({
|
|
118
185
|
project: await resolveValue(manager.stopProject(getProjectIdFromRequest(request.params.projectId)))
|
|
119
186
|
}));
|
|
187
|
+
app.post('/api/projects/:projectId/attachments', async (request, reply) => {
|
|
188
|
+
const projectId = getProjectIdFromRequest(request.params.projectId);
|
|
189
|
+
const project = await resolveValue(manager.getProjectStatus(projectId));
|
|
190
|
+
const files = [];
|
|
191
|
+
let threadId = null;
|
|
192
|
+
for await (const part of request.parts()) {
|
|
193
|
+
if (part.type === 'file') {
|
|
194
|
+
if (!threadId) {
|
|
195
|
+
throw new CodoriError('MISSING_THREAD_ID', 'Thread id must be provided before file parts.');
|
|
196
|
+
}
|
|
197
|
+
const mediaType = normalizeImageMediaType({
|
|
198
|
+
filename: part.filename ?? 'attachment',
|
|
199
|
+
declaredMediaType: part.mimetype || null
|
|
200
|
+
});
|
|
201
|
+
if (!mediaType) {
|
|
202
|
+
throw new CodoriError('INVALID_ATTACHMENT', 'Only image attachments are supported.');
|
|
203
|
+
}
|
|
204
|
+
const attachment = await persistThreadAttachmentStream({
|
|
205
|
+
projectPath: project.projectPath,
|
|
206
|
+
threadId,
|
|
207
|
+
filename: part.filename ?? 'attachment',
|
|
208
|
+
mediaType,
|
|
209
|
+
stream: part.file,
|
|
210
|
+
rootDir: options.attachmentsRootDir
|
|
211
|
+
});
|
|
212
|
+
files.push(attachment);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (part.fieldname === 'threadId' && typeof part.value === 'string') {
|
|
216
|
+
threadId = part.value.trim() || null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!threadId) {
|
|
220
|
+
throw new CodoriError('MISSING_THREAD_ID', 'Missing thread id.');
|
|
221
|
+
}
|
|
222
|
+
if (!files.length) {
|
|
223
|
+
throw new CodoriError('INVALID_ATTACHMENT', 'No files provided.');
|
|
224
|
+
}
|
|
225
|
+
reply.header('cache-control', 'no-store');
|
|
226
|
+
return {
|
|
227
|
+
threadId,
|
|
228
|
+
files
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
app.get('/api/projects/:projectId/attachments/file', async (request, reply) => {
|
|
232
|
+
const projectId = getProjectIdFromRequest(request.params.projectId);
|
|
233
|
+
const requestedPath = typeof request.query.path === 'string'
|
|
234
|
+
? request.query.path.trim()
|
|
235
|
+
: '';
|
|
236
|
+
if (!requestedPath) {
|
|
237
|
+
throw new CodoriError('INVALID_ATTACHMENT', 'Missing attachment path.');
|
|
238
|
+
}
|
|
239
|
+
const project = await resolveValue(manager.getProjectStatus(projectId));
|
|
240
|
+
const allowedRoot = resolveProjectAttachmentsDir(project.projectPath, options.attachmentsRootDir);
|
|
241
|
+
const resolvedPath = resolve(requestedPath);
|
|
242
|
+
if (!isPathInsideDirectory(resolvedPath, allowedRoot)) {
|
|
243
|
+
reply.status(403);
|
|
244
|
+
return {
|
|
245
|
+
error: {
|
|
246
|
+
code: 'FORBIDDEN',
|
|
247
|
+
message: 'Invalid attachment path.'
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
let fileStat;
|
|
252
|
+
try {
|
|
253
|
+
fileStat = await stat(resolvedPath);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
reply.status(404);
|
|
257
|
+
return {
|
|
258
|
+
error: {
|
|
259
|
+
code: 'NOT_FOUND',
|
|
260
|
+
message: 'Attachment not found.'
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (!fileStat.isFile()) {
|
|
265
|
+
reply.status(404);
|
|
266
|
+
return {
|
|
267
|
+
error: {
|
|
268
|
+
code: 'NOT_FOUND',
|
|
269
|
+
message: 'Attachment not found.'
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const attachmentMetadata = await readAttachmentMetadata(resolvedPath);
|
|
274
|
+
const inferredMediaType = typeof lookupMimeType(resolvedPath) === 'string'
|
|
275
|
+
? String(lookupMimeType(resolvedPath)).toLowerCase()
|
|
276
|
+
: null;
|
|
277
|
+
const mediaType = attachmentMetadata?.mediaType?.toLowerCase()
|
|
278
|
+
?? inferredMediaType
|
|
279
|
+
?? null;
|
|
280
|
+
if (!mediaType?.startsWith('image/')) {
|
|
281
|
+
reply.status(415);
|
|
282
|
+
return {
|
|
283
|
+
error: {
|
|
284
|
+
code: 'UNSUPPORTED_MEDIA_TYPE',
|
|
285
|
+
message: 'Attachment preview is only available for image files.'
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
reply.header('cache-control', 'private, max-age=3600');
|
|
290
|
+
reply.header('cross-origin-resource-policy', 'cross-origin');
|
|
291
|
+
reply.header('content-type', mediaType);
|
|
292
|
+
reply.header('content-disposition', `inline; filename="${basename(resolvedPath).replace(/"/g, '')}"`);
|
|
293
|
+
return await readFile(resolvedPath);
|
|
294
|
+
});
|
|
120
295
|
app.get('/api/projects/:projectId/rpc', { websocket: true }, async (clientSocket, request) => {
|
|
121
296
|
const projectId = getProjectIdFromRequest(request.params.projectId);
|
|
122
297
|
const pendingClientMessages = [];
|
|
@@ -210,10 +385,14 @@ export const createHttpServer = async (manager, options = {}) => {
|
|
|
210
385
|
return app;
|
|
211
386
|
};
|
|
212
387
|
export const startHttpServer = async (manager = createRuntimeManager()) => {
|
|
213
|
-
const app = await createHttpServer(manager);
|
|
214
388
|
if (!manager.config) {
|
|
215
389
|
throw new CodoriError('INVALID_CONFIG', 'Manager config is required to start the HTTP server.');
|
|
216
390
|
}
|
|
391
|
+
const app = await createHttpServer(manager, {
|
|
392
|
+
serviceUpdateController: createServiceUpdateController({
|
|
393
|
+
root: manager.config.root
|
|
394
|
+
})
|
|
395
|
+
});
|
|
217
396
|
await app.listen({
|
|
218
397
|
host: manager.config.server.host,
|
|
219
398
|
port: manager.config.server.port
|
package/dist/index.d.ts
CHANGED
|
@@ -4,5 +4,8 @@ export { createHttpServer, startHttpServer } from './http-server.js';
|
|
|
4
4
|
export { findAvailablePort } from './ports.js';
|
|
5
5
|
export { createRuntimeManager, RuntimeManager } from './process-manager.js';
|
|
6
6
|
export { scanProjects } from './project-scanner.js';
|
|
7
|
+
export * from './service-adapters.js';
|
|
8
|
+
export * from './service-update.js';
|
|
7
9
|
export { RuntimeStore } from './runtime-store.js';
|
|
10
|
+
export * from './service.js';
|
|
8
11
|
export type * from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,7 @@ export { createHttpServer, startHttpServer } from './http-server.js';
|
|
|
4
4
|
export { findAvailablePort } from './ports.js';
|
|
5
5
|
export { createRuntimeManager, RuntimeManager } from './process-manager.js';
|
|
6
6
|
export { scanProjects } from './project-scanner.js';
|
|
7
|
+
export * from './service-adapters.js';
|
|
8
|
+
export * from './service-update.js';
|
|
7
9
|
export { RuntimeStore } from './runtime-store.js';
|
|
10
|
+
export * from './service.js';
|