@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.
Files changed (49) 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/5cGRqVd7.js +1 -0
  5. package/client-dist/_nuxt/B1wExIrb.js +3 -0
  6. package/client-dist/_nuxt/{bTgPCKPF.js → B3F7AEX8.js} +1 -1
  7. package/client-dist/_nuxt/BLQDSwSv.js +1 -0
  8. package/client-dist/_nuxt/CNJnWNJ6.js +1 -0
  9. package/client-dist/_nuxt/CTkc1dr0.js +30 -0
  10. package/client-dist/_nuxt/Ce2C-7Tv.js +1 -0
  11. package/client-dist/_nuxt/CnVIfmli.js +1 -0
  12. package/client-dist/_nuxt/DQ92n70Y.js +202 -0
  13. package/client-dist/_nuxt/{BqBt7DE7.js → DfKxoeGc.js} +1 -1
  14. package/client-dist/_nuxt/VKebgJ9X.js +1 -0
  15. package/client-dist/_nuxt/_6KBkEBa.js +1 -0
  16. package/client-dist/_nuxt/_threadId_.BfTZeVnD.css +1 -0
  17. package/client-dist/_nuxt/builds/latest.json +1 -1
  18. package/client-dist/_nuxt/builds/meta/9f29d3f8-cf52-453d-9a00-836b5db2a30e.json +1 -0
  19. package/client-dist/_nuxt/entry.BFUss7SH.css +1 -0
  20. package/client-dist/index.html +1 -1
  21. package/dist/attachment-store.d.ts +32 -0
  22. package/dist/attachment-store.js +84 -0
  23. package/dist/cli.d.ts +5 -1
  24. package/dist/cli.js +171 -19
  25. package/dist/config.d.ts +2 -0
  26. package/dist/config.js +2 -2
  27. package/dist/http-server.d.ts +3 -0
  28. package/dist/http-server.js +181 -2
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.js +3 -0
  31. package/dist/service-adapters.d.ts +39 -0
  32. package/dist/service-adapters.js +185 -0
  33. package/dist/service-update.d.ts +26 -0
  34. package/dist/service-update.js +196 -0
  35. package/dist/service.d.ts +86 -0
  36. package/dist/service.js +616 -0
  37. package/package.json +6 -1
  38. package/client-dist/_nuxt/7-GD2bnK.js +0 -1
  39. package/client-dist/_nuxt/B3mzt2bn.js +0 -1
  40. package/client-dist/_nuxt/BC-nNqet.js +0 -1
  41. package/client-dist/_nuxt/BZ0PKZpG.js +0 -1
  42. package/client-dist/_nuxt/BtYrONTB.js +0 -30
  43. package/client-dist/_nuxt/CG5UFFba.js +0 -203
  44. package/client-dist/_nuxt/CcHTZT9i.js +0 -1
  45. package/client-dist/_nuxt/D39j61CJ.js +0 -1
  46. package/client-dist/_nuxt/Dgfnd7_d.js +0 -1
  47. package/client-dist/_nuxt/_threadId_.DWwkJvLa.css +0 -1
  48. package/client-dist/_nuxt/builds/meta/3230b6da-fb0f-48a4-a654-1a7f8145eecd.json +0 -1
  49. 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
- export {};
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 main = async () => {
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
- const manager = createRuntimeManager({
55
- configOverrides: {
56
- root: resolveCliRoot(parsed.values.root),
57
- host: parsed.values.host,
58
- port: coercePort(parsed.values.port)
59
- }
60
- });
61
- const json = parsed.values.json ?? false;
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
- process.stdout.write('Usage: npx @codori/server [serve|list|status|start|stop] [projectId] --root <path> [--host <host>] [--port <port>] [--json]\n');
276
+ printUsage(dependencies.stdout ?? process.stdout);
128
277
  }
129
278
  };
130
- void main().catch((error) => {
131
- if (error instanceof CodoriError) {
132
- process.stderr.write(`${error.code}: ${error.message}\n`);
133
- }
134
- else {
135
- process.stderr.write(`${asErrorMessage(error)}\n`);
136
- }
137
- process.exitCode = 1;
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);
@@ -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>>;
@@ -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';