gestalt-mobile 0.20.0 → 0.21.0

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.
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
16
16
  <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
17
  <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
18
  <title>Gestalt Mobile</title>
19
- <script type="module" crossorigin src="/assets/index-Byfm-_gV.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DYERmevO.css">
19
+ <script type="module" crossorigin src="/assets/index-ufTniVfO.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-D9-XA9jj.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -17,6 +17,7 @@ import { registerAuthorizationBoundary } from './platform/http/authorization-bou
17
17
  import { registerSkillRoutes } from './features/skills/register-routes.js';
18
18
  import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/register-routes.js';
19
19
  import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
20
+ import { registerFileRoutes } from './features/files/register-routes.js';
20
21
  export async function buildApp(deps) {
21
22
  // Keep the default boundary finite even when an endpoint forgot a narrower schema.
22
23
  const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
@@ -48,6 +49,7 @@ export async function buildApp(deps) {
48
49
  if (deps.orgPlanAttention)
49
50
  registerOrgPlanAttentionRoutes(app, deps.orgPlanAttention);
50
51
  registerPlanRoutes(app, deps);
52
+ registerFileRoutes(app, deps);
51
53
  registerGitRoutes(app, deps);
52
54
  registerSkillRoutes(app, deps);
53
55
  registerProblemHandler(app, Boolean(deps.staticDir));
@@ -1512,6 +1512,42 @@ describe('production composition', () => {
1512
1512
  ['GET', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
1513
1513
  ['HEAD', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
1514
1514
  ['DELETE', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
1515
+ [
1516
+ 'GET',
1517
+ '/api/workspaces/:workspaceId/files',
1518
+ '/api/workspaces/workspace-1/files',
1519
+ 'protected',
1520
+ ],
1521
+ [
1522
+ 'HEAD',
1523
+ '/api/workspaces/:workspaceId/files',
1524
+ '/api/workspaces/workspace-1/files',
1525
+ 'protected',
1526
+ ],
1527
+ [
1528
+ 'POST',
1529
+ '/api/workspaces/:workspaceId/files/copy',
1530
+ '/api/workspaces/workspace-1/files/copy',
1531
+ 'protected',
1532
+ ],
1533
+ [
1534
+ 'POST',
1535
+ '/api/workspaces/:workspaceId/files/move',
1536
+ '/api/workspaces/workspace-1/files/move',
1537
+ 'protected',
1538
+ ],
1539
+ [
1540
+ 'PUT',
1541
+ '/api/workspaces/:workspaceId/files/upload',
1542
+ '/api/workspaces/workspace-1/files/upload',
1543
+ 'protected',
1544
+ ],
1545
+ [
1546
+ 'DELETE',
1547
+ '/api/workspaces/:workspaceId/files',
1548
+ '/api/workspaces/workspace-1/files',
1549
+ 'protected',
1550
+ ],
1515
1551
  [
1516
1552
  'GET',
1517
1553
  '/api/workspaces/:workspaceId/plans',
@@ -9,6 +9,7 @@ import { homedir } from 'node:os';
9
9
  import { basename, dirname, join, resolve } from 'node:path';
10
10
  import { buildApp } from './app.js';
11
11
  import { FilesystemWorkspaceCatalog } from './platform/catalog/filesystem-workspace-catalog.js';
12
+ import { FilesystemWorkspaceFiles } from './platform/filesystem/filesystem-workspace-files.js';
12
13
  import { protocolCompatibility } from './platform/codex/protocol-compatibility.js';
13
14
  import { launchCodexAppServer } from './platform/codex/codex-process-launcher.js';
14
15
  import { CodexModelCatalog } from './platform/codex/codex-model-catalog.js';
@@ -216,6 +217,7 @@ export async function composeRelayApp(options) {
216
217
  notifyAutopilotActivity = (sessionId) => autopilot.activityChanged(sessionId);
217
218
  options.onAutopilotCoordinator?.(autopilot);
218
219
  const workspaces = new FilesystemWorkspaceCatalog(root);
220
+ const workspaceFiles = new FilesystemWorkspaceFiles();
219
221
  const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
220
222
  const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
221
223
  const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
@@ -654,6 +656,7 @@ export async function composeRelayApp(options) {
654
656
  },
655
657
  },
656
658
  workspacePlanRoutes: { workspaces, plans: workspacePlanCatalog },
659
+ workspaceFileRoutes: { workspaces, files: workspaceFiles },
657
660
  autopilot,
658
661
  ...(runtime
659
662
  ? {
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export {};
@@ -0,0 +1,48 @@
1
+ export function registerCopyEntry(app, deps) {
2
+ app.post('/api/workspaces/:workspaceId/files/copy', async (request, reply) => mutation(request, reply, deps, 'copy'));
3
+ }
4
+ export async function mutation(request, reply, deps, operation) {
5
+ const body = request.body;
6
+ if (!body ||
7
+ typeof body.source !== 'string' ||
8
+ typeof body.destinationDirectory !== 'string' ||
9
+ !['reject', 'replace', 'keep-both'].includes(body.conflict ?? ''))
10
+ return reply.code(400).send({ code: 'INVALID_FILE_MUTATION' });
11
+ const method = deps.files[operation];
12
+ if (!method)
13
+ return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
14
+ try {
15
+ const workspace = await deps.workspaces.resolve(request.params.workspaceId);
16
+ const result = await method.call(deps.files, workspace.realPath, body);
17
+ return map(reply, result);
18
+ }
19
+ catch (error) {
20
+ return reply
21
+ .code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
22
+ .send({
23
+ code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
24
+ ? 'WORKSPACE_NOT_FOUND'
25
+ : 'WORKSPACE_FILES_UNAVAILABLE',
26
+ });
27
+ }
28
+ }
29
+ export function map(reply, result) {
30
+ if (result.kind === 'available')
31
+ return reply.send(result);
32
+ const mapped = {
33
+ missing: [404, 'FILE_NOT_FOUND'],
34
+ protected: [403, 'FILE_PROTECTED'],
35
+ symlink: [409, 'SYMLINK_UNSUPPORTED'],
36
+ 'invalid-destination': [400, 'INVALID_FILE_DESTINATION'],
37
+ 'source-inside-destination': [409, 'SOURCE_INSIDE_DESTINATION'],
38
+ 'same-parent': [409, 'SAME_PARENT_MOVE'],
39
+ conflict: [409, 'FILE_CONFLICT'],
40
+ 'replace-unsupported': [409, 'FILE_REPLACE_UNSUPPORTED'],
41
+ unreadable: [503, 'WORKSPACE_FILES_UNAVAILABLE'],
42
+ };
43
+ const [status, code] = mapped[result.kind] ?? [503, 'WORKSPACE_FILES_UNAVAILABLE'];
44
+ return reply.code(status).send({
45
+ code,
46
+ ...(result.kind === 'conflict' ? { replaceAllowed: result.replaceAllowed } : {}),
47
+ });
48
+ }
@@ -0,0 +1,23 @@
1
+ import { map } from '../copy-entry/endpoint.js';
2
+ export function registerDeleteEntry(app, deps) {
3
+ app.delete('/api/workspaces/:workspaceId/files', async (request, reply) => {
4
+ const body = request.body;
5
+ if (typeof body?.path !== 'string' || body.recursive !== true)
6
+ return reply.code(400).send({ code: 'INVALID_DELETE_REQUEST' });
7
+ if (!deps.files.delete)
8
+ return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
9
+ try {
10
+ const w = await deps.workspaces.resolve(request.params.workspaceId);
11
+ return map(reply, await deps.files.delete(w.realPath, { path: body.path, recursive: true }));
12
+ }
13
+ catch (error) {
14
+ return reply
15
+ .code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
16
+ .send({
17
+ code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
18
+ ? 'WORKSPACE_NOT_FOUND'
19
+ : 'WORKSPACE_FILES_UNAVAILABLE',
20
+ });
21
+ }
22
+ });
23
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export function parseRelativeDirectory(value) {
7
+ if (value === '')
8
+ return value;
9
+ if (value.includes('\\') || value.includes('\0') || value.startsWith('/') || value.endsWith('/'))
10
+ return null;
11
+ const parts = value.split('/');
12
+ if (parts.some((part) => part === '' || part === '.' || part === '..' || part === '.git'))
13
+ return null;
14
+ return value;
15
+ }
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export {};
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { parseRelativeDirectory } from '../domain/relative-directory.js';
7
+ const defaultLimit = 250;
8
+ const maximumLimit = 500;
9
+ export function registerListDirectory(app, deps) {
10
+ app.get('/api/workspaces/:workspaceId/files', async (request, reply) => {
11
+ const query = request.query;
12
+ const directoryRaw = query.directory === undefined ? '' : query.directory;
13
+ const directory = typeof directoryRaw === 'string' ? parseRelativeDirectory(directoryRaw) : null;
14
+ const cursor = query.cursor === undefined ? undefined : query.cursor;
15
+ const limit = parseLimit(query.limit);
16
+ if (directory === null ||
17
+ (cursor !== undefined && (typeof cursor !== 'string' || cursor === '')) ||
18
+ !limit)
19
+ return reply.code(400).send({ code: 'INVALID_DIRECTORY_REQUEST' });
20
+ try {
21
+ const workspace = await deps.workspaces.resolve(request.params.workspaceId);
22
+ const result = await deps.files.list(workspace.realPath, { directory, cursor, limit });
23
+ if (result.kind === 'available')
24
+ return reply.send(result.page);
25
+ const mapped = {
26
+ missing: [404, 'DIRECTORY_NOT_FOUND'],
27
+ 'not-directory': [409, 'DIRECTORY_NOT_DIRECTORY'],
28
+ unreadable: [403, 'DIRECTORY_UNREADABLE'],
29
+ 'invalid-cursor': [400, 'INVALID_DIRECTORY_CURSOR'],
30
+ 'stale-cursor': [409, 'STALE_DIRECTORY_CURSOR'],
31
+ };
32
+ const [status, code] = mapped[result.kind];
33
+ return reply.code(status).send({ code });
34
+ }
35
+ catch (error) {
36
+ if (error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND')
37
+ return reply.code(404).send({ code: 'WORKSPACE_NOT_FOUND' });
38
+ return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
39
+ }
40
+ });
41
+ }
42
+ function parseLimit(value) {
43
+ if (value === undefined)
44
+ return defaultLimit;
45
+ if (typeof value !== 'string' || !/^(?:[1-9]\d*)$/.test(value))
46
+ return null;
47
+ const parsed = Number(value);
48
+ return parsed <= maximumLimit ? parsed : null;
49
+ }
@@ -0,0 +1,4 @@
1
+ import { mutation } from '../copy-entry/endpoint.js';
2
+ export function registerMoveEntry(app, deps) {
3
+ app.post('/api/workspaces/:workspaceId/files/move', async (request, reply) => mutation(request, reply, deps, 'move'));
4
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { registerListDirectory } from './list-directory/endpoint.js';
7
+ import { registerCopyEntry } from './copy-entry/endpoint.js';
8
+ import { registerMoveEntry } from './move-entry/endpoint.js';
9
+ import { registerUploadFile } from './upload-file/endpoint.js';
10
+ import { registerDeleteEntry } from './delete-entry/endpoint.js';
11
+ export function registerFileRoutes(app, deps) {
12
+ if (!deps.workspaceFileRoutes)
13
+ return;
14
+ registerListDirectory(app, deps.workspaceFileRoutes);
15
+ registerCopyEntry(app, deps.workspaceFileRoutes);
16
+ registerMoveEntry(app, deps.workspaceFileRoutes);
17
+ registerUploadFile(app, deps.workspaceFileRoutes);
18
+ registerDeleteEntry(app, deps.workspaceFileRoutes);
19
+ }
@@ -0,0 +1,35 @@
1
+ import { map } from '../copy-entry/endpoint.js';
2
+ export function registerUploadFile(app, deps) {
3
+ app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body));
4
+ app.put('/api/workspaces/:workspaceId/files/upload', { bodyLimit: 25 * 1024 * 1024 }, async (request, reply) => {
5
+ const q = request.query;
6
+ const conflict = q.conflict;
7
+ if (typeof q.directory !== 'string' ||
8
+ typeof q.filename !== 'string' ||
9
+ !['reject', 'replace', 'keep-both'].includes(String(conflict)))
10
+ return reply.code(400).send({ code: 'INVALID_UPLOAD_REQUEST' });
11
+ if (!deps.files.upload)
12
+ return reply.code(503).send({ code: 'WORKSPACE_FILES_UNAVAILABLE' });
13
+ try {
14
+ const workspace = await deps.workspaces.resolve(request.params.workspaceId);
15
+ if (!Buffer.isBuffer(request.body))
16
+ return reply.code(415).send({ code: 'INVALID_UPLOAD_CONTENT' });
17
+ const content = request.body;
18
+ return map(reply, await deps.files.upload(workspace.realPath, {
19
+ directory: q.directory,
20
+ filename: q.filename,
21
+ conflict: conflict,
22
+ content,
23
+ }));
24
+ }
25
+ catch (error) {
26
+ return reply
27
+ .code(error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND' ? 404 : 503)
28
+ .send({
29
+ code: error instanceof Error && error.message === 'WORKSPACE_NOT_FOUND'
30
+ ? 'WORKSPACE_NOT_FOUND'
31
+ : 'WORKSPACE_FILES_UNAVAILABLE',
32
+ });
33
+ }
34
+ });
35
+ }