@alisio/server 0.1.0-alpha.1 → 0.1.0-alpha.3

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 (129) hide show
  1. package/dist/host/dir-listing.d.ts +20 -0
  2. package/dist/host/dir-listing.js +119 -0
  3. package/dist/host/folder-picker.d.ts +95 -0
  4. package/dist/host/folder-picker.js +273 -0
  5. package/dist/host/workspace-host.d.ts +10 -2
  6. package/dist/host/workspace-host.js +45 -10
  7. package/dist/http/errors.js +6 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +16 -2
  10. package/dist/routes/commands.js +3 -1
  11. package/dist/routes/files.js +3 -1
  12. package/dist/routes/folders.d.ts +17 -0
  13. package/dist/routes/folders.js +44 -0
  14. package/dist/routes/health.d.ts +3 -0
  15. package/dist/routes/health.js +4 -3
  16. package/dist/routes/sessions.js +2 -0
  17. package/dist/routes/side-questions.d.ts +12 -0
  18. package/dist/routes/side-questions.js +61 -0
  19. package/dist/routes/workspaces.d.ts +4 -1
  20. package/dist/routes/workspaces.js +23 -2
  21. package/dist/web/alisio-wordmark.png +0 -0
  22. package/dist/web/assets/BtwPanel-CfrlNsqD.css +1 -0
  23. package/dist/web/assets/BtwPanel-CtK6nL1k.js +1 -0
  24. package/dist/web/assets/SettingsModal-B69hjOh0.js +1 -0
  25. package/dist/web/assets/SettingsModal-DuS6eVxB.css +1 -0
  26. package/dist/web/assets/{abnfDiagram-VCTEODGH-Ct0-FsLe.js → abnfDiagram-VCTEODGH-W77lnbMi.js} +1 -1
  27. package/dist/web/assets/architecture-7GRP2DOG-D1ya7AUe.js +1 -0
  28. package/dist/web/assets/{architectureDiagram-5GKGNRK7-BvjnUlvj.js → architectureDiagram-5GKGNRK7-C6PBbaeo.js} +1 -1
  29. package/dist/web/assets/{blockDiagram-I7D4REHJ-CBHCj2Mn.js → blockDiagram-I7D4REHJ-C1PBdOkX.js} +1 -1
  30. package/dist/web/assets/{c4Diagram-7LVT6UL2-9I13K7Yy.js → c4Diagram-7LVT6UL2-C1siIqaQ.js} +1 -1
  31. package/dist/web/assets/channel-BKu2fpj-.js +1 -0
  32. package/dist/web/assets/{chunk-4HAMMTFA-Du4xf36t.js → chunk-4HAMMTFA-fteNd3-U.js} +1 -1
  33. package/dist/web/assets/{chunk-75Z2AOVW-93KpRXCT.js → chunk-75Z2AOVW-D1wpTnTM.js} +1 -1
  34. package/dist/web/assets/{chunk-DU6HZSFF-DP6dsL6K.js → chunk-DU6HZSFF-D67lp4wj.js} +1 -1
  35. package/dist/web/assets/{chunk-F27PBJKO-CxLLRIN4.js → chunk-F27PBJKO-o5VRPsdh.js} +1 -1
  36. package/dist/web/assets/{chunk-GMAD6QVW-BGv500CR.js → chunk-GMAD6QVW-Cj6iihx3.js} +1 -1
  37. package/dist/web/assets/{chunk-GVQU2GXP-BmXv3St8.js → chunk-GVQU2GXP-B_GDBHrL.js} +1 -1
  38. package/dist/web/assets/{chunk-IMKFNOWR-tmbCLM1n.js → chunk-IMKFNOWR-BtOT8hYb.js} +1 -1
  39. package/dist/web/assets/{chunk-L3NEJ4N5-BULbKNtl.js → chunk-L3NEJ4N5-C7JXpbh_.js} +1 -1
  40. package/dist/web/assets/{chunk-OSK3NFVY-DyvADXPn.js → chunk-OSK3NFVY-DCVpP_Rf.js} +1 -1
  41. package/dist/web/assets/{chunk-P2QGCYS3-YhA2sAhR.js → chunk-P2QGCYS3-pltljbMq.js} +1 -1
  42. package/dist/web/assets/{chunk-POPQ4Y6H-BLr1KktA.js → chunk-POPQ4Y6H-DsTguJIh.js} +1 -1
  43. package/dist/web/assets/{chunk-PWAF6VOD-DJCZC9ie.js → chunk-PWAF6VOD-CPzNIjNy.js} +1 -1
  44. package/dist/web/assets/{chunk-SHT3W25Y-B8xH9z5t.js → chunk-SHT3W25Y-QvVe8iDd.js} +1 -1
  45. package/dist/web/assets/{chunk-SVP7TREG-DxynkM9y.js → chunk-SVP7TREG-B-hStYeg.js} +1 -1
  46. package/dist/web/assets/{chunk-TICWLB2K-CZPXqwaW.js → chunk-TICWLB2K-Cn8kFuzV.js} +1 -1
  47. package/dist/web/assets/classDiagram-ZZMXUADV-YRQXLjhB.js +1 -0
  48. package/dist/web/assets/classDiagram-v2-VYDZK3BY-YRQXLjhB.js +1 -0
  49. package/dist/web/assets/{cynefin-OW5HDTMX-rct1-Bf_.js → cynefin-OW5HDTMX-CTCpFxz5.js} +1 -1
  50. package/dist/web/assets/{cynefinDiagram-5FMLGOSQ-CuBJbnvx.js → cynefinDiagram-5FMLGOSQ-Ccwq_del.js} +1 -1
  51. package/dist/web/assets/{dagre-GXQ25YYZ-w8zs2moB.js → dagre-GXQ25YYZ-DVldPe6v.js} +1 -1
  52. package/dist/web/assets/{diagram-S7CK7UJ4-BMBhFewM.js → diagram-S7CK7UJ4-C-a0JpHd.js} +1 -1
  53. package/dist/web/assets/{diagram-UQ7AKVKN-C7BN6534.js → diagram-UQ7AKVKN-CfDM8Y6F.js} +1 -1
  54. package/dist/web/assets/{diagram-VSXAHHWV-Dd8_wR6U.js → diagram-VSXAHHWV-ByLzrjJF.js} +1 -1
  55. package/dist/web/assets/{diagram-VX7I27RA-DR3aOC1b.js → diagram-VX7I27RA-D-gnUcwL.js} +1 -1
  56. package/dist/web/assets/{diagram-Z3DM3KII-5aBBRZ2Z.js → diagram-Z3DM3KII-B-riDjw6.js} +1 -1
  57. package/dist/web/assets/{ebnfDiagram-PWID7BFC-46Gc2APT.js → ebnfDiagram-PWID7BFC-9MyJsI3c.js} +1 -1
  58. package/dist/web/assets/{engine-CknQqOvU.js → engine-D40yLY_U.js} +4 -4
  59. package/dist/web/assets/{erDiagram-RLTQ6QDP-CNfUFQmD.js → erDiagram-RLTQ6QDP-N799CZm7.js} +1 -1
  60. package/dist/web/assets/eventmodeling-NTZA5JFV-BxVDr6Nm.js +1 -0
  61. package/dist/web/assets/flowDiagram-HODETNUW-DJ_RarP5.js +1 -0
  62. package/dist/web/assets/{ganttDiagram-EL5Y4UJY-B1RLLE0F.js → ganttDiagram-EL5Y4UJY-MVcwyLgI.js} +1 -1
  63. package/dist/web/assets/{gitGraph-4MIJSDKK-DmzdoCuA.js → gitGraph-4MIJSDKK-CK2nHEiv.js} +1 -1
  64. package/dist/web/assets/{gitGraphDiagram-WWUBYQGX-DN5-fMOS.js → gitGraphDiagram-WWUBYQGX-DKZy0hvy.js} +1 -1
  65. package/dist/web/assets/{highlight-C1LfZ1aI.js → highlight-C8FwCbLq.js} +1 -1
  66. package/dist/web/assets/index-CGOgNFx5.css +1 -0
  67. package/dist/web/assets/index-CMMMNWOn.js +75 -0
  68. package/dist/web/assets/{info-A6RAGUB7-CebZRx1l.js → info-A6RAGUB7-B7bsZFto.js} +1 -1
  69. package/dist/web/assets/{infoDiagram-27XIBGKW-BeY-irSX.js → infoDiagram-27XIBGKW-BOmlRrC_.js} +1 -1
  70. package/dist/web/assets/{ishikawaDiagram-5VMMS53U-Djn1dIqp.js → ishikawaDiagram-5VMMS53U-C9BfTY4y.js} +1 -1
  71. package/dist/web/assets/{journeyDiagram-3NMN7TZE-CV_b2MnE.js → journeyDiagram-3NMN7TZE-BBhEKs-j.js} +1 -1
  72. package/dist/web/assets/jsxRuntime-DnBCdh_Z.js +1 -0
  73. package/dist/web/assets/{kanban-definition-UXKFOSKX-CXTukynL.js → kanban-definition-UXKFOSKX-CRs3tvoC.js} +1 -1
  74. package/dist/web/assets/{line-DIt63x6x.js → line-B3IfyWYt.js} +1 -1
  75. package/dist/web/assets/{mermaid-parser.core-UsYAxtjN.js → mermaid-parser.core-OzvFg3MX.js} +3 -3
  76. package/dist/web/assets/{mindmap-definition-YA3MSWOX-G0K_7S5N.js → mindmap-definition-YA3MSWOX-CzqJfERM.js} +1 -1
  77. package/dist/web/assets/{packet-AYTQ26CC-D3_L-O-_.js → packet-AYTQ26CC-B0aiVtYc.js} +1 -1
  78. package/dist/web/assets/{pegDiagram-XKGWAZYB-BoSJtVsR.js → pegDiagram-XKGWAZYB-DTvoRl0E.js} +1 -1
  79. package/dist/web/assets/{pie-WAS4IAKB-DUARjLYc.js → pie-WAS4IAKB-DSgmxs5B.js} +1 -1
  80. package/dist/web/assets/{pieDiagram-E7YTZNPT-DaFXBymx.js → pieDiagram-E7YTZNPT-DNKsEHsO.js} +1 -1
  81. package/dist/web/assets/{quadrantDiagram-AXDQQJYC-DvJJGxS3.js → quadrantDiagram-AXDQQJYC-DZCyyT-7.js} +1 -1
  82. package/dist/web/assets/{radar-RG4KPBEZ-CoTA2z0h.js → radar-RG4KPBEZ-Br1zOmaV.js} +1 -1
  83. package/dist/web/assets/{railroad-74A4TZTK-D22_YeTS.js → railroad-74A4TZTK-BZWaWLI9.js} +1 -1
  84. package/dist/web/assets/railroad-abnf-HS5TGJTU-CovjZS2M.js +1 -0
  85. package/dist/web/assets/railroad-ebnf-LZEXJU2U-4XWWCoEA.js +1 -0
  86. package/dist/web/assets/railroad-peg-WCYAUIDC-ByQJEDvc.js +1 -0
  87. package/dist/web/assets/{railroadDiagram-O6MQD6OU-Cxnojv6Q.js → railroadDiagram-O6MQD6OU-BD4wMWFZ.js} +1 -1
  88. package/dist/web/assets/{requirementDiagram-BXWQKSXE-gW2PlZJn.js → requirementDiagram-BXWQKSXE-Bkq6uo8T.js} +1 -1
  89. package/dist/web/assets/{sankeyDiagram-P5KCCOFB-DcoJ0sYw.js → sankeyDiagram-P5KCCOFB-BD7DSO0l.js} +1 -1
  90. package/dist/web/assets/{sequenceDiagram-WJ2MYXX4-kRLbkAfp.js → sequenceDiagram-WJ2MYXX4-ySuq8V_9.js} +1 -1
  91. package/dist/web/assets/{stateDiagram-D77RDMKH-CEWdwxTm.js → stateDiagram-D77RDMKH-eMKYJ2yD.js} +1 -1
  92. package/dist/web/assets/stateDiagram-v2-MP3YSRHH-DIjhulyn.js +1 -0
  93. package/dist/web/assets/{swimlanes-42K2YHIH-oyoSxsbH.js → swimlanes-42K2YHIH-DVi1lTr7.js} +1 -1
  94. package/dist/web/assets/swimlanesDiagram-VR7AAH4N-DwPzD4HF.js +8 -0
  95. package/dist/web/assets/{timeline-definition-24CTP7MA-D97QYUq5.js → timeline-definition-24CTP7MA-Cuny1pIj.js} +1 -1
  96. package/dist/web/assets/{treeView-Q6P3EWNA-FwhRnXTt.js → treeView-Q6P3EWNA-qn0ZK8wv.js} +1 -1
  97. package/dist/web/assets/{treemap-WGGIJYW6-ByFNzzzU.js → treemap-WGGIJYW6-D2zgkQxn.js} +1 -1
  98. package/dist/web/assets/{vennDiagram-4TSXK5OY-CcqjaQy9.js → vennDiagram-4TSXK5OY-BPCM2sqH.js} +1 -1
  99. package/dist/web/assets/{view-BQZA99Bp.js → view-BSs9O8KC.js} +1 -1
  100. package/dist/web/assets/{view-CS-jPsy6.js → view-BuAv5SLI.js} +2 -2
  101. package/dist/web/assets/{view-DzlpughI.js → view-D00dIkJS.js} +1 -1
  102. package/dist/web/assets/{view-NDb0VvVU.js → view-D5ngm6EH.js} +1 -1
  103. package/dist/web/assets/{view-C9vvQ0AK.js → view-DI_PrZPK.js} +1 -1
  104. package/dist/web/assets/{view-CYRKKlxP.js → view-Dxls51Hk.js} +1 -1
  105. package/dist/web/assets/{view-0zE8CdnP.js → view-MPMg9bfD.js} +1 -1
  106. package/dist/web/assets/{view-DE2OI6Vv.js → view-niy0QSE1.js} +1 -1
  107. package/dist/web/assets/{wardley-WFR3VGLG-Bt4CePrl.js → wardley-WFR3VGLG-BTUkNa-6.js} +1 -1
  108. package/dist/web/assets/{wardleyDiagram-VM6X3IG4-CcgXimi3.js → wardleyDiagram-VM6X3IG4-Bxj-CZyy.js} +1 -1
  109. package/dist/web/assets/{xychartDiagram-S5SC5T6Z-0Jx20wNz.js → xychartDiagram-S5SC5T6Z-DrxQEmlK.js} +1 -1
  110. package/dist/web/favicon.png +0 -0
  111. package/dist/web/index.html +5 -4
  112. package/dist/web/logo-64.png +0 -0
  113. package/package.json +3 -3
  114. package/dist/web/assets/SettingsModal-Dj5G3JIz.js +0 -1
  115. package/dist/web/assets/SettingsModal-YCahs6O5.css +0 -1
  116. package/dist/web/assets/architecture-7GRP2DOG-B5Qnm7io.js +0 -1
  117. package/dist/web/assets/channel-B-TykkBN.js +0 -1
  118. package/dist/web/assets/classDiagram-ZZMXUADV-B6LKfKSk.js +0 -1
  119. package/dist/web/assets/classDiagram-v2-VYDZK3BY-B6LKfKSk.js +0 -1
  120. package/dist/web/assets/eventmodeling-NTZA5JFV-Dx_J4ait.js +0 -1
  121. package/dist/web/assets/flowDiagram-HODETNUW-Bs7QheBl.js +0 -1
  122. package/dist/web/assets/index-CGolUSWa.js +0 -75
  123. package/dist/web/assets/index-vUqwyH-2.css +0 -1
  124. package/dist/web/assets/jsxRuntime-D9k5B_Bf.js +0 -1
  125. package/dist/web/assets/railroad-abnf-HS5TGJTU-Mvl9LvJX.js +0 -1
  126. package/dist/web/assets/railroad-ebnf-LZEXJU2U-hvVPkAs6.js +0 -1
  127. package/dist/web/assets/railroad-peg-WCYAUIDC-XX_5aYEK.js +0 -1
  128. package/dist/web/assets/stateDiagram-v2-MP3YSRHH-DZ8dTBXf.js +0 -1
  129. package/dist/web/assets/swimlanesDiagram-VR7AAH4N-McbEeiYz.js +0 -8
@@ -0,0 +1,20 @@
1
+ import type { DirectoryListing } from "@alisio/sdk";
2
+ /** Absolute paths only; on Windows `""` is the drive list above every drive root. */
3
+ export declare function isBrowsablePath(path: string, platform?: NodeJS.Platform): boolean;
4
+ /** Breadcrumbs from the filesystem root to `path` (server-built: the web assumes no separator). */
5
+ export declare function directorySegments(path: string, platform?: NodeJS.Platform): Array<{
6
+ name: string;
7
+ path: string;
8
+ }>;
9
+ /** Parent directory; a Windows drive root's parent is the drive list (`""`); none at the top. */
10
+ export declare function directoryParent(path: string, platform?: NodeJS.Platform): string | undefined;
11
+ /** The absolute path of `name` inside `dir` (inside the drive list, `name` is a drive root). */
12
+ export declare function childPath(dir: string, name: string, platform?: NodeJS.Platform): string;
13
+ /**
14
+ * One listing page: subdirectories of `path` (symlinks included when they point at a directory),
15
+ * sorted by name, dot-folders only when `hidden`. `path` defaults to the home directory.
16
+ */
17
+ export declare function listDirectories(input: {
18
+ path?: string | null;
19
+ hidden?: boolean;
20
+ }, platform?: NodeJS.Platform): Promise<DirectoryListing>;
@@ -0,0 +1,119 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ import { HttpError } from "../http/errors.js";
5
+ const api = (platform) => (platform === "win32" ? win32 : posix);
6
+ /** Absolute paths only; on Windows `""` is the drive list above every drive root. */
7
+ export function isBrowsablePath(path, platform = process.platform) {
8
+ if (path === "")
9
+ return platform === "win32";
10
+ return api(platform).isAbsolute(path);
11
+ }
12
+ /** Breadcrumbs from the filesystem root to `path` (server-built: the web assumes no separator). */
13
+ export function directorySegments(path, platform = process.platform) {
14
+ if (path === "")
15
+ return [];
16
+ const p = api(platform);
17
+ const normalized = p.normalize(path);
18
+ const root = p.parse(normalized).root;
19
+ const segments = [{ name: root, path: root }];
20
+ let current = root;
21
+ for (const part of normalized.slice(root.length).split(p.sep).filter(Boolean)) {
22
+ current = p.join(current, part);
23
+ segments.push({ name: part, path: current });
24
+ }
25
+ return segments;
26
+ }
27
+ /** Parent directory; a Windows drive root's parent is the drive list (`""`); none at the top. */
28
+ export function directoryParent(path, platform = process.platform) {
29
+ if (path === "")
30
+ return undefined;
31
+ const p = api(platform);
32
+ const normalized = p.normalize(path);
33
+ const root = p.parse(normalized).root;
34
+ if (normalized === root || `${normalized}${p.sep}` === root)
35
+ return platform === "win32" && /^[A-Za-z]:\\$/.test(root) ? "" : undefined;
36
+ return p.dirname(normalized);
37
+ }
38
+ /** The absolute path of `name` inside `dir` (inside the drive list, `name` is a drive root). */
39
+ export function childPath(dir, name, platform = process.platform) {
40
+ if (dir === "" && platform === "win32")
41
+ return name;
42
+ return api(platform).join(dir, name);
43
+ }
44
+ const MAX_ENTRIES = 2_000;
45
+ /** Drive roots that exist (`A:\` … `Z:\`), for the top of the Windows browser. */
46
+ async function drives() {
47
+ const letters = Array.from({ length: 26 }, (_, i) => `${String.fromCharCode(65 + i)}:\\`);
48
+ const found = await Promise.all(letters.map(async (root) => ((await isDirectory(root)) ? root : undefined)));
49
+ return found.filter((root) => !!root);
50
+ }
51
+ async function isDirectory(path) {
52
+ try {
53
+ return (await stat(path)).isDirectory();
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /** Filesystem errors become 403 `permission_denied` or 404 `not_found`, never a 500. */
60
+ function fsError(error, path) {
61
+ const code = error?.code;
62
+ if (code === "EACCES" || code === "EPERM")
63
+ return new HttpError("permission_denied", `Cannot read ${path}`, { path });
64
+ return new HttpError("not_found", `Directory not found: ${path}`, { path });
65
+ }
66
+ /**
67
+ * One listing page: subdirectories of `path` (symlinks included when they point at a directory),
68
+ * sorted by name, dot-folders only when `hidden`. `path` defaults to the home directory.
69
+ */
70
+ export async function listDirectories(input, platform = process.platform) {
71
+ const p = api(platform);
72
+ const home = homedir();
73
+ const raw = input.path ?? home;
74
+ if (!isBrowsablePath(raw, platform))
75
+ throw new HttpError("validation_failed", "path must be absolute", { fields: ["path"] });
76
+ const path = raw === "" ? "" : p.normalize(raw);
77
+ const base = {
78
+ home,
79
+ separator: p.sep,
80
+ segments: directorySegments(path, platform),
81
+ ...(directoryParent(path, platform) !== undefined
82
+ ? { parent: directoryParent(path, platform) }
83
+ : {}),
84
+ };
85
+ if (path === "") {
86
+ const roots = await drives();
87
+ return {
88
+ ...base,
89
+ path,
90
+ entries: roots.map((root) => ({ name: root, path: root, hidden: false })),
91
+ };
92
+ }
93
+ let dirents;
94
+ try {
95
+ if (!(await stat(path)).isDirectory())
96
+ throw Object.assign(new Error(), { code: "ENOTDIR" });
97
+ dirents = await readdir(path, { withFileTypes: true });
98
+ }
99
+ catch (error) {
100
+ throw fsError(error, path);
101
+ }
102
+ const entries = [];
103
+ for (const dirent of dirents) {
104
+ const hidden = dirent.name.startsWith(".");
105
+ if (hidden && !input.hidden)
106
+ continue;
107
+ const full = childPath(path, dirent.name, platform);
108
+ const directory = dirent.isDirectory() || (dirent.isSymbolicLink() && (await isDirectory(full)));
109
+ if (directory)
110
+ entries.push({ name: dirent.name, path: full, hidden });
111
+ }
112
+ entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
113
+ return {
114
+ ...base,
115
+ path,
116
+ entries: entries.slice(0, MAX_ENTRIES),
117
+ ...(entries.length > MAX_ENTRIES ? { truncated: true } : {}),
118
+ };
119
+ }
@@ -0,0 +1,95 @@
1
+ export type PickerTool = "zenity" | "kdialog" | "yad" | "osascript" | "powershell";
2
+ /** The dialog tool chosen for this machine. */
3
+ export interface PickerStrategy {
4
+ tool: PickerTool;
5
+ /** Absolute path of the executable. */
6
+ file: string;
7
+ platform: NodeJS.Platform;
8
+ }
9
+ type Env = Record<string, string | undefined>;
10
+ type Exists = (file: string) => Promise<boolean>;
11
+ /**
12
+ * First `name` on `PATH` (no `which`/`where`): splits `PATH` with the platform's delimiter and, on
13
+ * Windows, tries every `PATHEXT` extension.
14
+ */
15
+ export declare function findOnPath(name: string, options?: {
16
+ platform?: NodeJS.Platform;
17
+ env?: Env;
18
+ exists?: Exists;
19
+ }): Promise<string | undefined>;
20
+ /**
21
+ * The dialog tool for a platform, or `undefined` when none can show a dialog here:
22
+ * Linux/BSD need a desktop session (`DISPLAY` or `WAYLAND_DISPLAY`) and zenity, kdialog or yad;
23
+ * macOS uses `osascript`; Windows uses Windows PowerShell (or `pwsh`).
24
+ * `ALISIO_NATIVE_PICKER=0` turns the native dialog off (the in-app browser is used instead).
25
+ */
26
+ export declare function selectPicker(options?: {
27
+ platform?: NodeJS.Platform;
28
+ env?: Env;
29
+ exists?: Exists;
30
+ }): Promise<PickerStrategy | undefined>;
31
+ /** The exact executable, arguments and extra environment for one dialog. */
32
+ export declare function pickerCommand(strategy: PickerStrategy, options: {
33
+ title: string;
34
+ start?: string;
35
+ }): {
36
+ file: string;
37
+ args: string[];
38
+ env?: Record<string, string>;
39
+ };
40
+ export interface PickerProcessResult {
41
+ /** Exit code (`null` when killed). */
42
+ code: number | null;
43
+ stdout: string;
44
+ stderr?: string;
45
+ /** Killed by the timeout: treated as a cancellation. */
46
+ timedOut?: boolean;
47
+ }
48
+ export type PickerOutcome = {
49
+ path: string;
50
+ } | {
51
+ cancelled: true;
52
+ } | {
53
+ error: string;
54
+ };
55
+ /**
56
+ * Maps a dialog's exit to the chosen absolute folder (normalized with the platform's own path
57
+ * rules: macOS trailing slash, Windows backslashes and drive roots, CRLF), a cancellation, or an
58
+ * error.
59
+ */
60
+ export declare function parsePickerOutput(platform: NodeJS.Platform, result: PickerProcessResult): PickerOutcome;
61
+ /** Runs one dialog process: resolves with its exit, never rejects on a non-zero exit. */
62
+ export type PickerRun = (file: string, args: string[], options: {
63
+ env?: Record<string, string>;
64
+ timeoutMs: number;
65
+ signal?: AbortSignal;
66
+ }) => Promise<PickerProcessResult>;
67
+ export interface FolderPicker {
68
+ /** A dialog can be shown on this machine. */
69
+ available(): Promise<boolean>;
70
+ /** A dialog is open right now. */
71
+ readonly busy: boolean;
72
+ /**
73
+ * Opens the dialog and waits for the user. Rejects with 409 `picker_busy` while another dialog
74
+ * is open and 503 `picker_unavailable` when no dialog can be shown or it fails to start.
75
+ */
76
+ pick(options: {
77
+ title: string;
78
+ start?: string;
79
+ signal?: AbortSignal;
80
+ }): Promise<{
81
+ path: string;
82
+ } | {
83
+ cancelled: true;
84
+ }>;
85
+ }
86
+ /**
87
+ * A picker over the selected strategy. `strategy` defaults to `selectPicker()` (detected once);
88
+ * `run` is injectable so tests never open a real dialog.
89
+ */
90
+ export declare function createNativePicker(options?: {
91
+ strategy?: PickerStrategy | undefined;
92
+ run?: PickerRun;
93
+ timeoutMs?: number;
94
+ }): FolderPicker;
95
+ export {};
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Native folder dialog for "Open a workspace". Browsers cannot hand a page an absolute folder path
3
+ * (`showDirectoryPicker` returns a handle, never a path), so the local server opens the operating
4
+ * system's dialog on its own desktop and returns the chosen path. Every command is spawned with
5
+ * `execFile` (no shell) and fixed arguments; the only inputs are the dialog title and an optional,
6
+ * already validated start directory, passed as separate arguments or environment variables and
7
+ * never interpolated into a script.
8
+ *
9
+ * The pure pieces (PATH lookup, strategy selection, command construction, output parsing) take the
10
+ * platform as a parameter, so all three operating systems are unit tested on any host.
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ import { constants } from "node:fs";
14
+ import { access } from "node:fs/promises";
15
+ import { posix, win32 } from "node:path";
16
+ import { HttpError } from "../http/errors.js";
17
+ const pathApi = (platform) => (platform === "win32" ? win32 : posix);
18
+ /** Case-insensitive environment lookup (Windows spells it `Path`). */
19
+ function envValue(env, name) {
20
+ if (env[name] !== undefined)
21
+ return env[name];
22
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
23
+ return key ? env[key] : undefined;
24
+ }
25
+ const executable = async (file) => {
26
+ try {
27
+ await access(file, process.platform === "win32" ? constants.F_OK : constants.X_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ };
34
+ /**
35
+ * First `name` on `PATH` (no `which`/`where`): splits `PATH` with the platform's delimiter and, on
36
+ * Windows, tries every `PATHEXT` extension.
37
+ */
38
+ export async function findOnPath(name, options = {}) {
39
+ const platform = options.platform ?? process.platform;
40
+ const env = options.env ?? process.env;
41
+ const exists = options.exists ?? executable;
42
+ const api = pathApi(platform);
43
+ const dirs = (envValue(env, "PATH") ?? "").split(api.delimiter).filter(Boolean);
44
+ const extensions = platform === "win32" && !api.extname(name)
45
+ ? (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
46
+ : [""];
47
+ for (const dir of dirs)
48
+ for (const extension of extensions) {
49
+ const file = api.join(dir, `${name}${extension}`);
50
+ if (await exists(file))
51
+ return file;
52
+ }
53
+ return undefined;
54
+ }
55
+ const LINUX_TOOLS = ["zenity", "kdialog", "yad"];
56
+ const UNIX_DESKTOPS = new Set(["linux", "freebsd", "openbsd", "netbsd"]);
57
+ /**
58
+ * The dialog tool for a platform, or `undefined` when none can show a dialog here:
59
+ * Linux/BSD need a desktop session (`DISPLAY` or `WAYLAND_DISPLAY`) and zenity, kdialog or yad;
60
+ * macOS uses `osascript`; Windows uses Windows PowerShell (or `pwsh`).
61
+ * `ALISIO_NATIVE_PICKER=0` turns the native dialog off (the in-app browser is used instead).
62
+ */
63
+ export async function selectPicker(options = {}) {
64
+ const platform = options.platform ?? process.platform;
65
+ const env = options.env ?? process.env;
66
+ const exists = options.exists ?? executable;
67
+ const lookup = { platform, env, exists };
68
+ if (["0", "off", "false", "no"].includes((envValue(env, "ALISIO_NATIVE_PICKER") ?? "").toLowerCase()))
69
+ return undefined;
70
+ if (UNIX_DESKTOPS.has(platform)) {
71
+ if (!envValue(env, "DISPLAY") && !envValue(env, "WAYLAND_DISPLAY"))
72
+ return undefined;
73
+ for (const tool of LINUX_TOOLS) {
74
+ const file = await findOnPath(tool, lookup);
75
+ if (file)
76
+ return { tool, file, platform };
77
+ }
78
+ return undefined;
79
+ }
80
+ if (platform === "darwin") {
81
+ // `osascript` ships with every macOS; PATH may be minimal when launched from a GUI.
82
+ const file = (await findOnPath("osascript", lookup)) ?? "/usr/bin/osascript";
83
+ return { tool: "osascript", file, platform };
84
+ }
85
+ if (platform === "win32") {
86
+ const file = (await findOnPath("powershell", lookup)) ??
87
+ (await findOnPath("pwsh", lookup)) ??
88
+ (await (async () => {
89
+ const root = envValue(env, "SystemRoot") ?? "C:\\Windows";
90
+ const system = win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
91
+ return (await exists(system)) ? system : undefined;
92
+ })());
93
+ return file ? { tool: "powershell", file, platform } : undefined;
94
+ }
95
+ return undefined;
96
+ }
97
+ /** AppleScript run with `osascript -e … title [start]`: inputs arrive as `argv`, never inlined. */
98
+ const APPLESCRIPT = [
99
+ "on run argv",
100
+ "set promptText to item 1 of argv",
101
+ "if (count of argv) > 1 then",
102
+ "set chosen to choose folder with prompt promptText default location (POSIX file (item 2 of argv))",
103
+ "else",
104
+ "set chosen to choose folder with prompt promptText",
105
+ "end if",
106
+ "return POSIX path of chosen",
107
+ "end run",
108
+ ];
109
+ /**
110
+ * Fixed PowerShell script (`-STA` is required by WinForms dialogs). Title and start directory come
111
+ * from environment variables. Output is UTF-8; a cancelled dialog prints nothing. Falls back to
112
+ * the Shell.Application folder browser when WinForms is unavailable.
113
+ */
114
+ const POWERSHELL = [
115
+ "$ErrorActionPreference = 'Stop'",
116
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
117
+ "try {",
118
+ " Add-Type -AssemblyName System.Windows.Forms",
119
+ " $dialog = New-Object System.Windows.Forms.FolderBrowserDialog",
120
+ " $dialog.Description = $env:ALISIO_PICKER_TITLE",
121
+ " $dialog.ShowNewFolderButton = $true",
122
+ " if ($env:ALISIO_PICKER_START) { $dialog.SelectedPath = $env:ALISIO_PICKER_START }",
123
+ " $owner = New-Object System.Windows.Forms.Form",
124
+ " $owner.TopMost = $true",
125
+ " if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($dialog.SelectedPath) }",
126
+ " $owner.Dispose()",
127
+ "} catch {",
128
+ " $shell = New-Object -ComObject Shell.Application",
129
+ " $folder = $shell.BrowseForFolder(0, $env:ALISIO_PICKER_TITLE, 0x51, 0)",
130
+ " if ($folder) { [Console]::Out.Write($folder.Self.Path) }",
131
+ "}",
132
+ ].join("\n");
133
+ /** The exact executable, arguments and extra environment for one dialog. */
134
+ export function pickerCommand(strategy, options) {
135
+ const { title, start } = options;
136
+ // A trailing separator makes zenity/yad open *inside* the start directory.
137
+ const inside = start ? `${posix.join(start, "/")}` : undefined;
138
+ switch (strategy.tool) {
139
+ case "zenity":
140
+ return {
141
+ file: strategy.file,
142
+ args: [
143
+ "--file-selection",
144
+ "--directory",
145
+ `--title=${title}`,
146
+ ...(inside ? [`--filename=${inside}`] : []),
147
+ ],
148
+ };
149
+ case "yad":
150
+ return {
151
+ file: strategy.file,
152
+ args: [
153
+ "--file",
154
+ "--directory",
155
+ `--title=${title}`,
156
+ ...(inside ? [`--filename=${inside}`] : []),
157
+ ],
158
+ };
159
+ case "kdialog":
160
+ return {
161
+ file: strategy.file,
162
+ args: ["--getexistingdirectory", ...(start ? [start] : []), "--title", title],
163
+ };
164
+ case "osascript":
165
+ return {
166
+ file: strategy.file,
167
+ args: [...APPLESCRIPT.flatMap((line) => ["-e", line]), title, ...(start ? [start] : [])],
168
+ };
169
+ case "powershell":
170
+ return {
171
+ file: strategy.file,
172
+ args: ["-NoProfile", "-STA", "-Command", POWERSHELL],
173
+ env: { ALISIO_PICKER_TITLE: title, ...(start ? { ALISIO_PICKER_START: start } : {}) },
174
+ };
175
+ }
176
+ }
177
+ /** Cancel exit codes: 1 (zenity, kdialog, yad, osascript), 5 (zenity timeout), 252 (yad close). */
178
+ const CANCEL_CODES = new Set([1, 5, 252]);
179
+ /**
180
+ * Maps a dialog's exit to the chosen absolute folder (normalized with the platform's own path
181
+ * rules: macOS trailing slash, Windows backslashes and drive roots, CRLF), a cancellation, or an
182
+ * error.
183
+ */
184
+ export function parsePickerOutput(platform, result) {
185
+ if (result.timedOut)
186
+ return { cancelled: true };
187
+ if (result.code !== 0) {
188
+ if (result.code !== null && CANCEL_CODES.has(result.code))
189
+ return { cancelled: true };
190
+ if (/\(-128\)/.test(result.stderr ?? ""))
191
+ return { cancelled: true };
192
+ const detail = (result.stderr ?? "").trim().slice(0, 300);
193
+ return {
194
+ error: `Folder dialog exited with ${result.code ?? "a signal"}${detail ? `: ${detail}` : ""}`,
195
+ };
196
+ }
197
+ const line = result.stdout
198
+ .replace(/^\uFEFF/, "")
199
+ .split(/\r?\n/)[0]
200
+ ?.trim() ?? "";
201
+ if (!line)
202
+ return { cancelled: true };
203
+ const api = pathApi(platform);
204
+ if (!api.isAbsolute(line))
205
+ return { error: "Folder dialog returned a relative path" };
206
+ let path = api.normalize(line);
207
+ const root = api.parse(path).root;
208
+ while (path.length > root.length && (path.endsWith("/") || path.endsWith(api.sep)))
209
+ path = path.slice(0, -1);
210
+ return { path };
211
+ }
212
+ const defaultRun = (file, args, options) => new Promise((resolve) => {
213
+ execFile(file, args, {
214
+ encoding: "utf8",
215
+ timeout: options.timeoutMs,
216
+ maxBuffer: 64 * 1024,
217
+ windowsHide: true,
218
+ env: { ...process.env, ...options.env },
219
+ ...(options.signal ? { signal: options.signal } : {}),
220
+ }, (error, stdout, stderr) => {
221
+ const failure = error;
222
+ const code = failure ? (typeof failure.code === "number" ? failure.code : null) : 0;
223
+ resolve({
224
+ code,
225
+ stdout: String(stdout ?? ""),
226
+ stderr: String(stderr ?? failure?.message ?? ""),
227
+ ...(failure?.killed || failure?.name === "AbortError" ? { timedOut: true } : {}),
228
+ });
229
+ });
230
+ });
231
+ /**
232
+ * A picker over the selected strategy. `strategy` defaults to `selectPicker()` (detected once);
233
+ * `run` is injectable so tests never open a real dialog.
234
+ */
235
+ export function createNativePicker(options = {}) {
236
+ const detected = "strategy" in options
237
+ ? Promise.resolve(options.strategy)
238
+ : selectPicker().catch(() => undefined);
239
+ const run = options.run ?? defaultRun;
240
+ const timeoutMs = options.timeoutMs ?? 5 * 60_000;
241
+ let open = false;
242
+ return {
243
+ available: async () => !!(await detected),
244
+ get busy() {
245
+ return open;
246
+ },
247
+ async pick({ title, start, signal }) {
248
+ if (open)
249
+ throw new HttpError("picker_busy", "A folder dialog is already open");
250
+ open = true;
251
+ try {
252
+ const strategy = await detected;
253
+ if (!strategy)
254
+ throw new HttpError("picker_unavailable", "No native folder dialog on this server");
255
+ const command = pickerCommand(strategy, { title, ...(start ? { start } : {}) });
256
+ const result = await run(command.file, command.args, {
257
+ timeoutMs,
258
+ ...(command.env ? { env: command.env } : {}),
259
+ ...(signal ? { signal } : {}),
260
+ });
261
+ const outcome = parsePickerOutput(strategy.platform, result);
262
+ if ("error" in outcome)
263
+ throw new HttpError("picker_unavailable", "The folder dialog could not be opened", {
264
+ reason: outcome.error,
265
+ });
266
+ return outcome;
267
+ }
268
+ finally {
269
+ open = false;
270
+ }
271
+ },
272
+ };
273
+ }
@@ -32,6 +32,8 @@ export interface WorkspaceHostOptions {
32
32
  create?: typeof createApplication;
33
33
  now?: () => number;
34
34
  }
35
+ /** Whether a workspace folder is still an accessible directory (a known one may be deleted). */
36
+ export declare function workspaceExists(path: string): Promise<boolean>;
35
37
  /** Opaque, stable workspace id: a short sha256 of the canonical path (never a path in URLs). */
36
38
  export declare const workspaceId: (path: string) => string;
37
39
  /**
@@ -72,14 +74,20 @@ export declare class WorkspaceHost {
72
74
  recycle(id: string): Promise<OpenWorkspace | undefined>;
73
75
  /** Closes every app in parallel (each close is capped by the core's teardown timeouts). */
74
76
  closeAll(): Promise<void>;
75
- /** Every known workspace: recorded or with root sessions, the default one and the open ones. */
76
- list(): Promise<WorkspaceInfo[]>;
77
+ /**
78
+ * Every known workspace: recorded or with root sessions, the default one and the open ones.
79
+ * `archived` filters like `GET /api/sessions` (`all` by default).
80
+ */
81
+ list(archived?: "true" | "false" | "all"): Promise<WorkspaceInfo[]>;
82
+ /** Whether a canonical workspace path is archived. */
83
+ archived(path: string): boolean;
77
84
  /** The `WorkspaceInfo` of one canonical path. */
78
85
  info(path: string, meta?: {
79
86
  path: string;
80
87
  label?: string;
81
88
  pinned: boolean;
82
89
  lastOpenedAt?: number;
90
+ archivedAt?: number;
83
91
  sessions: number;
84
92
  } | undefined): Promise<WorkspaceInfo>;
85
93
  /** Canonical path of a workspace id among the known ones. */
@@ -2,6 +2,17 @@ import { createHash } from "node:crypto";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { createApplication, findWorkspace, resolveTrust, } from "@alisio/core";
4
4
  import { HttpError } from "../http/errors.js";
5
+ const MISSING_ERRNO = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"]);
6
+ const missing = (path) => new HttpError("workspace_missing", `Workspace folder not found: ${path}`, { path });
7
+ /** Whether a workspace folder is still an accessible directory (a known one may be deleted). */
8
+ export async function workspaceExists(path) {
9
+ try {
10
+ return (await stat(path)).isDirectory();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
5
16
  /** Opaque, stable workspace id: a short sha256 of the canonical path (never a path in URLs). */
6
17
  export const workspaceId = (path) => createHash("sha256").update(path).digest("hex").slice(0, 16);
7
18
  /**
@@ -48,6 +59,9 @@ export class WorkspaceHost {
48
59
  async openPath(path) {
49
60
  if (this.closed)
50
61
  throw new HttpError("shutting_down", "Server is shutting down");
62
+ // A known workspace (from old sessions) may have been deleted or moved: 404, never a 500.
63
+ if (!(await workspaceExists(path)))
64
+ throw missing(path);
51
65
  const id = workspaceId(path);
52
66
  const existing = this.open.get(id);
53
67
  if (existing) {
@@ -70,12 +84,22 @@ export class WorkspaceHost {
70
84
  const explicit = !!base.trustProject || !!base.config;
71
85
  const trust = explicit ? undefined : await resolveTrust(path);
72
86
  const trusted = explicit || !!trust?.trusted;
73
- const app = await (this.options.create ?? createApplication)({
74
- ...base,
75
- trustProject: explicit ? base.trustProject : trusted,
76
- cwd: path,
77
- ...(this.options.wire?.(id, path) ?? {}),
78
- });
87
+ let app;
88
+ try {
89
+ app = await (this.options.create ?? createApplication)({
90
+ ...base,
91
+ trustProject: explicit ? base.trustProject : trusted,
92
+ cwd: path,
93
+ ...(this.options.wire?.(id, path) ?? {}),
94
+ });
95
+ }
96
+ catch (error) {
97
+ // The folder vanished between the check and the open (the core canonicalizes it).
98
+ const code = error?.code;
99
+ if (code && MISSING_ERRNO.has(code) && !(await workspaceExists(path)))
100
+ throw missing(path);
101
+ throw error;
102
+ }
79
103
  const entry = {
80
104
  id,
81
105
  path,
@@ -145,8 +169,11 @@ export class WorkspaceHost {
145
169
  await Promise.allSettled([...this.opening.values()]);
146
170
  await Promise.allSettled(this.entries().map((w) => this.close(w.id)));
147
171
  }
148
- /** Every known workspace: recorded or with root sessions, the default one and the open ones. */
149
- async list() {
172
+ /**
173
+ * Every known workspace: recorded or with root sessions, the default one and the open ones.
174
+ * `archived` filters like `GET /api/sessions` (`all` by default).
175
+ */
176
+ async list(archived = "all") {
150
177
  const known = new Map(this.options.catalog.workspaces().map((w) => [w.path, w]));
151
178
  const paths = new Set(known.keys());
152
179
  for (const w of this.open.values())
@@ -156,7 +183,12 @@ export class WorkspaceHost {
156
183
  : undefined;
157
184
  if (fallback)
158
185
  paths.add(fallback);
159
- return Promise.all([...paths].sort().map((path) => this.info(path, known.get(path))));
186
+ const infos = await Promise.all([...paths].sort().map((path) => this.info(path, known.get(path))));
187
+ return archived === "all" ? infos : infos.filter((w) => w.archived === (archived === "true"));
188
+ }
189
+ /** Whether a canonical workspace path is archived. */
190
+ archived(path) {
191
+ return this.options.catalog.workspaces().some((w) => w.path === path && !!w.archivedAt);
160
192
  }
161
193
  /** The `WorkspaceInfo` of one canonical path. */
162
194
  async info(path, meta = this.options.catalog.workspaces().find((w) => w.path === path)) {
@@ -164,7 +196,8 @@ export class WorkspaceHost {
164
196
  const entry = this.open.get(id);
165
197
  let trusted = entry?.trusted ?? false;
166
198
  let untrusted = entry?.untrustedResources ?? false;
167
- if (!entry) {
199
+ const exists = await workspaceExists(path);
200
+ if (!entry && exists) {
168
201
  const base = this.options.base;
169
202
  if (base.trustProject || base.config)
170
203
  trusted = true;
@@ -180,8 +213,10 @@ export class WorkspaceHost {
180
213
  ...(meta?.label ? { label: meta.label } : {}),
181
214
  pinned: meta?.pinned ?? false,
182
215
  open: !!entry,
216
+ exists,
183
217
  trusted,
184
218
  untrustedResources: untrusted,
219
+ archived: !!meta?.archivedAt,
185
220
  ...(meta?.lastOpenedAt !== undefined ? { lastOpenedAt: meta.lastOpenedAt } : {}),
186
221
  };
187
222
  }
@@ -9,6 +9,7 @@ export const STATUS = {
9
9
  session_busy: 409,
10
10
  session_locked: 409,
11
11
  workspace_limit: 503,
12
+ workspace_missing: 404,
12
13
  payload_too_large: 413,
13
14
  unsupported_media_type: 415,
14
15
  path_outside_workspace: 403,
@@ -22,6 +23,11 @@ export const STATUS = {
22
23
  protocol_mismatch: 426,
23
24
  shutting_down: 503,
24
25
  stream_limit: 503,
26
+ workspace_archived: 409,
27
+ picker_busy: 409,
28
+ picker_unavailable: 503,
29
+ permission_denied: 403,
30
+ cancelled: 409,
25
31
  internal: 500,
26
32
  };
27
33
  /** A typed API failure; routes throw it and the server renders it as an `ApiError` body. */