feega 0.1.3 → 0.1.5
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 -0
- package/bin/feega.mjs +53 -16
- package/launcher.mjs +66 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,3 +7,5 @@ npx feega my-board.feega
|
|
|
7
7
|
Starts feega's local mode on http://localhost:5173 and saves the canvas to `my-board.feega` in the current directory. Needs Node 22+. Each start checks for a newer app and downloads it into `~/.feega/app/<version>/`; offline it uses the newest cached one.
|
|
8
8
|
|
|
9
9
|
Options: `--port <n>`, `--no-open`. `FEEGA_APP_BUNDLE=<path or url>` uses another bundle.
|
|
10
|
+
|
|
11
|
+
AI keys: put them in the environment or in a `.env` in the folder you run `npx feega` from. Kie.ai, OpenRouter, the text LLM, Higgsfield, Wiro and ElevenLabs are supported; see the local mode docs for the variables.
|
package/bin/feega.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
-
import { createWriteStream, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
4
|
import { homedir, tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { Readable } from 'node:stream';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
|
|
9
|
-
import { appDir, bundleSource, cachedVersions, isCached, latestAppVersion, markCached, parseArgs, pickVersion } from '../launcher.mjs';
|
|
9
|
+
import { MCP_ENTRY, USAGE, appDir, bundleSource, boundUrl, cachedVersions, freePort, isCached, latestAppVersion, markCached, mcpEnv, parseArgs, pickVersion } from '../launcher.mjs';
|
|
10
10
|
|
|
11
11
|
const MIN_NODE_MAJOR = 22;
|
|
12
12
|
const WEBSTORAGE_NODE_MAJOR = 25;
|
|
@@ -15,6 +15,7 @@ const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] };
|
|
|
15
15
|
const RELEASES_API = 'https://api.github.com/repos/andreabuttarelli/feega/releases?per_page=50';
|
|
16
16
|
const CHECK_TIMEOUT_MS = 2000;
|
|
17
17
|
const LOCAL_BUNDLE_VERSION = 'local';
|
|
18
|
+
const TO_STDERR = ['ignore', 2, 2];
|
|
18
19
|
|
|
19
20
|
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
20
21
|
|
|
@@ -51,20 +52,20 @@ async function chooseVersion(home) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
async function install(dir, version) {
|
|
54
|
-
console.
|
|
55
|
+
console.error(`feega: downloading the app ${version} (once per version)…`);
|
|
55
56
|
rmSync(dir, { recursive: true, force: true });
|
|
56
57
|
mkdirSync(dir, { recursive: true });
|
|
57
58
|
|
|
58
59
|
const archive = await download(bundleSource(version, process.env), join(tmpdir(), `feega-app-${version}-${process.pid}.tar.gz`));
|
|
59
|
-
const untar = spawnSync('tar', ['-xzf', archive, '-C', dir], { stdio:
|
|
60
|
+
const untar = spawnSync('tar', ['-xzf', archive, '-C', dir], { stdio: TO_STDERR });
|
|
60
61
|
if (untar.status !== 0) {
|
|
61
62
|
throw new Error('could not extract the app bundle');
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
console.
|
|
65
|
+
console.error('feega: installing its dependencies for this machine…');
|
|
65
66
|
const deps = spawnSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
|
|
66
67
|
cwd: dir,
|
|
67
|
-
stdio:
|
|
68
|
+
stdio: TO_STDERR,
|
|
68
69
|
shell: process.platform === 'win32'
|
|
69
70
|
});
|
|
70
71
|
if (deps.status !== 0) {
|
|
@@ -83,29 +84,65 @@ async function openWhenReady(url) {
|
|
|
83
84
|
spawn(cmd, [...args, url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
async function readyApp() {
|
|
88
|
+
const { version, action } = await chooseVersion(homedir());
|
|
89
|
+
const dir = appDir(homedir(), version);
|
|
90
|
+
if (action === 'download') {
|
|
91
|
+
await install(dir, version);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return dir;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function forward(child) {
|
|
98
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
99
|
+
process.on(signal, () => child.kill(signal));
|
|
100
|
+
}
|
|
101
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function serveMcp(dir, port) {
|
|
105
|
+
const entry = join(dir, MCP_ENTRY);
|
|
106
|
+
if (!existsSync(entry)) {
|
|
107
|
+
throw new Error('this app version has no MCP server: run `npx feega@latest mcp`');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
forward(spawn(process.execPath, [entry], { stdio: 'inherit', env: mcpEnv(port, process.env) }));
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
async function main() {
|
|
87
114
|
if (nodeMajor < MIN_NODE_MAJOR) {
|
|
88
115
|
throw new Error(`Node ${MIN_NODE_MAJOR}+ is required (found ${process.versions.node})`);
|
|
89
116
|
}
|
|
90
117
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
await install(dir, version);
|
|
118
|
+
const args = parseArgs(process.argv.slice(2), process.cwd());
|
|
119
|
+
if (args.command === 'help') {
|
|
120
|
+
console.log(USAGE);
|
|
121
|
+
return;
|
|
96
122
|
}
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
if (args.command === 'version') {
|
|
125
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const dir = await readyApp();
|
|
130
|
+
if (args.command === 'mcp') {
|
|
131
|
+
serveMcp(dir, args.port);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { file, browser } = args;
|
|
136
|
+
const port = await freePort(args.port);
|
|
137
|
+
|
|
138
|
+
const url = boundUrl(port);
|
|
99
139
|
console.log(`feega: saving to ${file}`);
|
|
100
140
|
console.log(`feega: open ${url}`);
|
|
101
141
|
|
|
102
142
|
const nodeOptions = nodeMajor >= WEBSTORAGE_NODE_MAJOR ? '--no-experimental-webstorage' : '';
|
|
103
143
|
const env = { ...process.env, NODE_OPTIONS: [process.env.NODE_OPTIONS, nodeOptions].filter(Boolean).join(' ') };
|
|
104
144
|
const server = spawn(process.execPath, [realpathSync(join(dir, 'scripts/local.mjs')), file, `--port=${port}`], { stdio: 'inherit', env });
|
|
105
|
-
|
|
106
|
-
process.on(signal, () => server.kill(signal));
|
|
107
|
-
}
|
|
108
|
-
server.on('exit', (code) => process.exit(code ?? 0));
|
|
145
|
+
forward(server);
|
|
109
146
|
|
|
110
147
|
if (browser === 'open') {
|
|
111
148
|
await openWhenReady(url);
|
package/launcher.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
2
3
|
import { join, resolve } from 'node:path';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_FILE = 'canvas.feega';
|
|
5
|
-
const DEFAULT_PORT =
|
|
6
|
+
const DEFAULT_PORT = 0;
|
|
7
|
+
export const LOOPBACK = '127.0.0.1';
|
|
8
|
+
export const MCP_PORT = 5173;
|
|
9
|
+
export const MCP_ENTRY = 'mcp.mjs';
|
|
10
|
+
export const USAGE = `usage: feega [file.feega] [--port <n>] [--no-open] [--help] [--version]
|
|
11
|
+
feega mcp [--port <n>] MCP server (stdio) for the local feega on that port (default ${MCP_PORT})`;
|
|
6
12
|
const CACHED_MARKER = '.complete';
|
|
7
13
|
const APP_TAG = 'app-v';
|
|
8
14
|
const RELEASES = 'https://github.com/andreabuttarelli/feega/releases/download';
|
|
@@ -17,33 +23,74 @@ const portOf = (raw) => {
|
|
|
17
23
|
return port;
|
|
18
24
|
};
|
|
19
25
|
|
|
26
|
+
const portFlag = (arg, rest) => {
|
|
27
|
+
if (arg === '--port') {
|
|
28
|
+
return portOf(rest.shift());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (arg.startsWith('--port=')) {
|
|
32
|
+
return portOf(arg.slice('--port='.length));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function parseMcp(rest) {
|
|
39
|
+
let port = MCP_PORT;
|
|
40
|
+
while (rest.length) {
|
|
41
|
+
const arg = rest.shift();
|
|
42
|
+
const flagged = portFlag(arg, rest);
|
|
43
|
+
if (flagged === null) {
|
|
44
|
+
throw new Error(`unknown option: ${arg}\n${USAGE}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
port = flagged;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { command: 'mcp', port };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const mcpEnv = (port, env) => ({ ...env, PUBLIC_APP_URL: boundUrl(port) });
|
|
54
|
+
|
|
20
55
|
export function parseArgs(argv, cwd) {
|
|
21
56
|
const rest = [...argv];
|
|
57
|
+
if (rest[0] === 'mcp') {
|
|
58
|
+
return parseMcp(rest.slice(1));
|
|
59
|
+
}
|
|
60
|
+
|
|
22
61
|
const files = [];
|
|
23
62
|
let port = DEFAULT_PORT;
|
|
24
63
|
let browser = 'open';
|
|
25
64
|
|
|
26
65
|
while (rest.length) {
|
|
27
66
|
const arg = rest.shift();
|
|
67
|
+
if (arg === '--help' || arg === '-h') {
|
|
68
|
+
return { command: 'help' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (arg === '--version') {
|
|
72
|
+
return { command: 'version' };
|
|
73
|
+
}
|
|
74
|
+
|
|
28
75
|
if (arg === '--no-open') {
|
|
29
76
|
browser = 'skip';
|
|
30
77
|
continue;
|
|
31
78
|
}
|
|
32
79
|
|
|
33
|
-
|
|
34
|
-
|
|
80
|
+
const flagged = portFlag(arg, rest);
|
|
81
|
+
if (flagged !== null) {
|
|
82
|
+
port = flagged;
|
|
35
83
|
continue;
|
|
36
84
|
}
|
|
37
85
|
|
|
38
|
-
if (arg.startsWith('
|
|
39
|
-
|
|
40
|
-
continue;
|
|
86
|
+
if (arg.startsWith('-')) {
|
|
87
|
+
throw new Error(`unknown option: ${arg}\n${USAGE}`);
|
|
41
88
|
}
|
|
42
89
|
|
|
43
90
|
files.push(arg);
|
|
44
91
|
}
|
|
45
92
|
|
|
46
|
-
return { file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser };
|
|
93
|
+
return { command: 'run', file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser };
|
|
47
94
|
}
|
|
48
95
|
|
|
49
96
|
export const appDir = (home, version) => join(home, '.feega', 'app', version);
|
|
@@ -94,3 +141,15 @@ export function pickVersion(latest, cached) {
|
|
|
94
141
|
|
|
95
142
|
return { version: cached[0], action: 'cache' };
|
|
96
143
|
}
|
|
144
|
+
|
|
145
|
+
export const boundUrl = (port) => `http://${LOOPBACK}:${port}`;
|
|
146
|
+
|
|
147
|
+
export const freePort = (port) =>
|
|
148
|
+
new Promise((done, fail) => {
|
|
149
|
+
const probe = createServer();
|
|
150
|
+
probe.once('error', fail);
|
|
151
|
+
probe.listen(port, LOOPBACK, () => {
|
|
152
|
+
const { port: bound } = probe.address();
|
|
153
|
+
probe.close(() => done(bound));
|
|
154
|
+
});
|
|
155
|
+
});
|