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 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.log(`feega: downloading the app ${version} (once per version)…`);
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: 'inherit' });
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.log('feega: installing its dependencies for this machine…');
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: 'inherit',
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 { file, port, browser } = parseArgs(process.argv.slice(2), process.cwd());
92
- const { version, action } = await chooseVersion(homedir());
93
- const dir = appDir(homedir(), version);
94
- if (action === 'download') {
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
- const url = `http://localhost:${port}`;
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
- for (const signal of ['SIGINT', 'SIGTERM']) {
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 = 5173;
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
- if (arg === '--port') {
34
- port = portOf(rest.shift());
80
+ const flagged = portFlag(arg, rest);
81
+ if (flagged !== null) {
82
+ port = flagged;
35
83
  continue;
36
84
  }
37
85
 
38
- if (arg.startsWith('--port=')) {
39
- port = portOf(arg.slice('--port='.length));
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feega",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Run feega's infinite canvas locally, saved to a .feega file. No account, no database.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",