aionflare-connect 0.1.0 → 0.2.1
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 +2 -1
- package/bin/aionflare-connect.mjs +2 -0
- package/lib/core.mjs +1 -1
- package/lib/folders.mjs +114 -0
- package/lib/server.mjs +36 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ A local terminal for AIONFLARE Coder ([ADR-0094](../../docs/adr/0094-coder-cloud
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
3. Open the pairing link it prints, in the browser where you use AIONFLARE, within 10 minutes. Your browser may ask whether aionflare.com may connect to devices on your computer: allow it.
|
|
15
|
-
4. In Coder,
|
|
15
|
+
4. In a Coder project, turn on **Terminal** in the Environment panel (under Code). A terminal opens beside the chat; **+** opens another. Browsers paired before reconnect by themselves whenever the helper is running.
|
|
16
16
|
|
|
17
17
|
Press **Ctrl+C** to stop it and close every shell. `npx aionflare-connect --forget` forgets every paired browser. `--help` lists the options.
|
|
18
18
|
|
|
@@ -21,6 +21,7 @@ Press **Ctrl+C** to stop it and close every shell. `npx aionflare-connect --forg
|
|
|
21
21
|
- It listens on this computer only (127.0.0.1), never on your network.
|
|
22
22
|
- It accepts a connection only from the AIONFLARE website, naming this computer's address, with a key it gave that browser when you paired it. Keys are kept as their SHA-256 in `~/.aionflare/connect.json`.
|
|
23
23
|
- It opens up to 3 shells (PowerShell on Windows, your login shell elsewhere), starting in the chosen folder.
|
|
24
|
+
- For a Coder project, it can open your computer's own folder dialog so you choose the app's folder (an existing one, or a new one named after the project). It then works in that folder, and remembers the folders you picked; the website can switch it only to one of those.
|
|
24
25
|
- For Coder's **Copy project here** and **Save to project**, it reads and writes text files only inside the chosen folder, never through links, skipping `node_modules`, `.git` and build folders.
|
|
25
26
|
- It does nothing else. It makes no internet connections and sends nothing to AIONFLARE. Its window shows a line when a browser pairs, connects or opens a shell, never what you type.
|
|
26
27
|
|
|
@@ -45,6 +45,8 @@ async function loadConfig() {
|
|
|
45
45
|
return {
|
|
46
46
|
port: Number(raw.port) || undefined,
|
|
47
47
|
devices: Array.isArray(raw.devices) ? raw.devices : [],
|
|
48
|
+
// Owner (2026-11-07): the folders picked for projects in this computer's folder dialog.
|
|
49
|
+
folders: Array.isArray(raw.folders) ? raw.folders.filter((f) => typeof f === 'string') : [],
|
|
48
50
|
};
|
|
49
51
|
} catch {
|
|
50
52
|
return { devices: [] };
|
package/lib/core.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
5
5
|
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
7
7
|
|
|
8
|
-
export const version = '0.1
|
|
8
|
+
export const version = '0.2.1';
|
|
9
9
|
// The messages the Workspace and the helper exchange; raised when they change incompatibly.
|
|
10
10
|
export const protocol = 1;
|
|
11
11
|
export const defaultPort = 47613;
|
package/lib/folders.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Owner (2026-11-07): the app's folder for a Coder project, chosen by the member in their own
|
|
2
|
+
// computer's folder dialog (Windows Explorer, macOS Finder, or zenity on Linux): an existing folder,
|
|
3
|
+
// or a new one named after the project inside the folder they pick. The website never names a path:
|
|
4
|
+
// only folders picked here (or the one the helper started in) are used, and they are remembered in
|
|
5
|
+
// the helper's settings so a project finds its folder again.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
8
|
+
import { platform } from 'node:os';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// A new folder's name: the project's name with only letters, digits, spaces, dots, dashes and
|
|
12
|
+
// underscores (60 at most).
|
|
13
|
+
export function folderName(name) {
|
|
14
|
+
const clean = String(name ?? '')
|
|
15
|
+
.replace(/[^A-Za-z0-9 ._-]+/g, ' ')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/^[.\s]+|[.\s]+$/g, '')
|
|
19
|
+
.slice(0, 60);
|
|
20
|
+
return clean || 'Coder project';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function run(file, args, env = {}) {
|
|
24
|
+
return new Promise((done) => {
|
|
25
|
+
const child = spawn(file, args, {
|
|
26
|
+
env: { ...process.env, ...env },
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
windowsHide: false,
|
|
29
|
+
});
|
|
30
|
+
let out = '';
|
|
31
|
+
child.stdout.on('data', (d) => (out += d));
|
|
32
|
+
child.on('error', () => done(null));
|
|
33
|
+
child.on('close', (code) => done(code === 0 ? out.trim() : null));
|
|
34
|
+
// A dialog left open is closed after 5 minutes.
|
|
35
|
+
setTimeout(() => child.kill(), 5 * 60_000);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The folder dialog of this computer, its title passed as an environment variable (never as code).
|
|
40
|
+
// Null when the member cancels or the computer has no dialog.
|
|
41
|
+
export async function pickFolder(title) {
|
|
42
|
+
const env = { AIONFLARE_DIALOG_TITLE: String(title).slice(0, 120) };
|
|
43
|
+
const os = platform();
|
|
44
|
+
if (os === 'win32')
|
|
45
|
+
return run(
|
|
46
|
+
'powershell.exe',
|
|
47
|
+
[
|
|
48
|
+
'-NoProfile',
|
|
49
|
+
'-STA',
|
|
50
|
+
'-Command',
|
|
51
|
+
// Owner live check (2026-11-07): the window opened behind the browser, unseen. It now has a
|
|
52
|
+
// shown, always-on-top owner (invisible), brought to the front; and no property that only
|
|
53
|
+
// newer .NET has (Windows PowerShell 5.1 runs .NET Framework 4).
|
|
54
|
+
'Add-Type -AssemblyName System.Windows.Forms;' +
|
|
55
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog;' +
|
|
56
|
+
'$d.Description = $env:AIONFLARE_DIALOG_TITLE;' +
|
|
57
|
+
'$d.ShowNewFolderButton = $true;' +
|
|
58
|
+
'$top = New-Object System.Windows.Forms.Form;' +
|
|
59
|
+
'$top.TopMost = $true; $top.ShowInTaskbar = $false; $top.Opacity = 0;' +
|
|
60
|
+
"$top.StartPosition = 'CenterScreen'; $top.Size = New-Object System.Drawing.Size(1, 1);" +
|
|
61
|
+
'$top.Show(); $top.Activate();' +
|
|
62
|
+
'$r = $d.ShowDialog($top); $top.Close();' +
|
|
63
|
+
"if ($r -eq 'OK') { [Console]::Out.Write($d.SelectedPath) }",
|
|
64
|
+
],
|
|
65
|
+
env,
|
|
66
|
+
);
|
|
67
|
+
if (os === 'darwin')
|
|
68
|
+
return run(
|
|
69
|
+
'osascript',
|
|
70
|
+
[
|
|
71
|
+
'-e',
|
|
72
|
+
'set t to system attribute "AIONFLARE_DIALOG_TITLE"',
|
|
73
|
+
'-e',
|
|
74
|
+
'POSIX path of (choose folder with prompt t)',
|
|
75
|
+
],
|
|
76
|
+
env,
|
|
77
|
+
);
|
|
78
|
+
return (
|
|
79
|
+
(await run('zenity', [
|
|
80
|
+
'--file-selection',
|
|
81
|
+
'--directory',
|
|
82
|
+
'--title',
|
|
83
|
+
env.AIONFLARE_DIALOG_TITLE,
|
|
84
|
+
])) ??
|
|
85
|
+
run('kdialog', [
|
|
86
|
+
'--getexistingdirectory',
|
|
87
|
+
process.env.HOME ?? '/',
|
|
88
|
+
'--title',
|
|
89
|
+
env.AIONFLARE_DIALOG_TITLE,
|
|
90
|
+
])
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// "open": the folder picked. "create": a new folder named after the project inside the folder
|
|
95
|
+
// picked (a number added when the name is taken). Null when cancelled.
|
|
96
|
+
export async function chooseFolder(mode, name) {
|
|
97
|
+
const picked = await pickFolder(
|
|
98
|
+
mode === 'create'
|
|
99
|
+
? 'AIONFLARE Coder: choose where to create the folder for ' + folderName(name)
|
|
100
|
+
: 'AIONFLARE Coder: choose the folder of ' + folderName(name),
|
|
101
|
+
);
|
|
102
|
+
if (!picked) return null;
|
|
103
|
+
const base = resolve(picked.replace(/[\r\n]+$/g, ''));
|
|
104
|
+
if (!(await stat(base).catch(() => null))?.isDirectory()) return null;
|
|
105
|
+
if (mode !== 'create') return base;
|
|
106
|
+
const wanted = folderName(name);
|
|
107
|
+
for (let n = 1; n < 100; n++) {
|
|
108
|
+
const target = join(base, n === 1 ? wanted : wanted + ' ' + n);
|
|
109
|
+
if (await stat(target).catch(() => null)) continue;
|
|
110
|
+
await mkdir(target);
|
|
111
|
+
return target;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
writeText,
|
|
24
24
|
} from './core.mjs';
|
|
25
25
|
import { FrameReader, acceptKey, encodeFrame } from './ws.mjs';
|
|
26
|
+
import { chooseFolder } from './folders.mjs';
|
|
26
27
|
|
|
27
28
|
const require = createRequire(import.meta.url);
|
|
28
29
|
// A message can carry a whole project (2 MB of text, written as JSON).
|
|
@@ -35,7 +36,11 @@ function shellFor() {
|
|
|
35
36
|
return { file, args: ['-l'], name: basename(file) };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export function startServer({ folder, port, dev, config, saveConfig, log }) {
|
|
39
|
+
export function startServer({ folder: startFolder, port, dev, config, saveConfig, log }) {
|
|
40
|
+
// Owner (2026-11-07): the folder the helper works in: where it started, or one the member picked
|
|
41
|
+
// in their computer's folder dialog for a project (remembered in config.folders, the last 20).
|
|
42
|
+
let folder = startFolder;
|
|
43
|
+
const approved = () => [startFolder, ...(config.folders ?? [])];
|
|
39
44
|
const pty = require('node-pty');
|
|
40
45
|
const shell = shellFor();
|
|
41
46
|
// One pairing code each time the helper starts.
|
|
@@ -52,6 +57,7 @@ export function startServer({ folder, port, dev, config, saveConfig, log }) {
|
|
|
52
57
|
folder,
|
|
53
58
|
folder_name: basename(folder),
|
|
54
59
|
max_shells: maxShells,
|
|
60
|
+
features: ['choose', 'use'],
|
|
55
61
|
limits: fileLimits,
|
|
56
62
|
});
|
|
57
63
|
|
|
@@ -225,6 +231,31 @@ export function startServer({ folder, port, dev, config, saveConfig, log }) {
|
|
|
225
231
|
skipped: out.filter((f) => f.reason),
|
|
226
232
|
});
|
|
227
233
|
}
|
|
234
|
+
// Owner (2026-11-07): a project's folder, picked in this computer's own folder dialog
|
|
235
|
+
// (an existing one, or a new one named after the project); then the helper works there.
|
|
236
|
+
case 'choose': {
|
|
237
|
+
log('Opening the folder window on this computer (it may be behind the browser)…');
|
|
238
|
+
const picked = await chooseFolder(m.mode === 'create' ? 'create' : 'open', m.name);
|
|
239
|
+
if (!picked) {
|
|
240
|
+
log('No folder was chosen.');
|
|
241
|
+
return reply({ cancelled: true });
|
|
242
|
+
}
|
|
243
|
+
config.folders = [...(config.folders ?? []).filter((f) => f !== picked), picked].slice(
|
|
244
|
+
-20,
|
|
245
|
+
);
|
|
246
|
+
await saveConfig(config);
|
|
247
|
+
folder = picked;
|
|
248
|
+
log('Working in ' + folder + '.');
|
|
249
|
+
return reply({ folder, folder_name: basename(folder) });
|
|
250
|
+
}
|
|
251
|
+
// A folder picked before (or the one the helper started in), by its full path.
|
|
252
|
+
case 'use': {
|
|
253
|
+
const wanted = String(m.folder ?? '');
|
|
254
|
+
if (!approved().includes(wanted) || !(await isFolder(wanted)))
|
|
255
|
+
return reply({ refused: true });
|
|
256
|
+
folder = wanted;
|
|
257
|
+
return reply({ folder, folder_name: basename(folder) });
|
|
258
|
+
}
|
|
228
259
|
case 'forget': {
|
|
229
260
|
config.devices = (config.devices ?? []).filter((d) => d.id !== authed);
|
|
230
261
|
await saveConfig(config);
|
|
@@ -264,6 +295,10 @@ export function startServer({ folder, port, dev, config, saveConfig, log }) {
|
|
|
264
295
|
};
|
|
265
296
|
}
|
|
266
297
|
|
|
298
|
+
async function isFolder(path) {
|
|
299
|
+
const { stat } = await import('node:fs/promises');
|
|
300
|
+
return !!(await stat(path).catch(() => null))?.isDirectory();
|
|
301
|
+
}
|
|
267
302
|
function clamp(value, min, max, fallback) {
|
|
268
303
|
const n = Math.floor(Number(value));
|
|
269
304
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|