feega 0.1.29 → 0.1.30

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
@@ -6,9 +6,11 @@ npx feega my-board.feega
6
6
 
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
- Run it inside a project with a `dev` script and it asks once to import the folder: its home page lands on the canvas as an editable frame. `--import` imports without asking, `--no-import` never asks.
9
+ Run it inside a project with a `dev` script and it asks once to import the folder: every static page of its router (up to 20) lands on the canvas as editable frames; dynamic pages are skipped and listed, and with no router found only the home page is imported. `--import` imports without asking, `--no-import` never asks.
10
10
 
11
- Options: `--port <n>`, `--no-open`, `--import`, `--no-import`. `FEEGA_APP_BUNDLE=<path or url>` uses another bundle.
11
+ `--routes <list|all>` picks pages (`/,/about`, default `all`) and `--app <path>` picks the app in a monorepo; without it a terminal asks.
12
+
13
+ Options: `--port <n>`, `--no-open`, `--import`, `--no-import`, `--routes`, `--app`. `FEEGA_APP_BUNDLE=<path or url>` uses another bundle.
12
14
 
13
15
  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.
14
16
 
package/bin/feega.mjs CHANGED
@@ -8,7 +8,7 @@ import { Readable } from 'node:stream';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
 
10
10
  import { sendTelemetry, telemetryEnv } from '../telemetry.mjs';
11
- import { ImportStep, MCP_ENTRY, USAGE, appDir, bundleSource, boundUrl, cachedVersions, folderApp, freePort, importAsk, importStep, isCached, latestAppVersion, markCached, mcpEnv, parseArgs, pickVersion, placeOf, readyProbe, served } from '../launcher.mjs';
11
+ import { ImportStep, MCP_ENTRY, USAGE, appDir, bundleSource, boundUrl, cachedVersions, folderApp, freePort, importAsk, discoverAsk, importReport, routePlan, eventLine, importStep, isCached, latestAppVersion, markCached, mcpEnv, parseArgs, pickVersion, placeOf, readyProbe, served } from '../launcher.mjs';
12
12
 
13
13
  const MIN_NODE_MAJOR = 22;
14
14
  const WEBSTORAGE_NODE_MAJOR = 25;
@@ -17,6 +17,8 @@ const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] };
17
17
  const RELEASES_API = 'https://api.github.com/repos/andreabuttarelli/feega/releases?per_page=50';
18
18
  const CHECK_TIMEOUT_MS = 2000;
19
19
  const LOCAL_BUNDLE_VERSION = 'local';
20
+ const HTTP_CONFLICT = 409;
21
+ const CHOOSE_APP_TIMEOUT_MS = 120_000;
20
22
  const TO_STDERR = ['ignore', 2, 2];
21
23
 
22
24
  const nodeMajor = Number(process.versions.node.split('.')[0]);
@@ -110,23 +112,66 @@ async function wantsImport(app, file, mode) {
110
112
  return answer === '' || answer.startsWith('y');
111
113
  }
112
114
 
113
- async function importFolder(url, folder) {
115
+ async function fetchJson(url, path, init) {
116
+ const res = await fetch(new URL(path, url), init);
117
+ return { res, body: await res.json().catch(() => ({})) };
118
+ }
119
+
120
+ async function pickApp(apps) {
121
+ const input = createInterface({ input: process.stdin, output: process.stdout });
122
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
123
+ if (interactive) {
124
+ apps.forEach((app, i) => console.log(` ${i + 1}) ${app}`));
125
+ } else {
126
+ console.log(eventLine({ event: 'choose-app', apps }));
127
+ }
128
+
129
+ const question = interactive ? input.question('feega: which app to import? (number) ') : new Promise((done) => input.once('line', done));
130
+ const closed = new Promise((done) => input.once('close', () => done('')));
131
+ const timeout = new Promise((done) => setTimeout(() => done(''), CHOOSE_APP_TIMEOUT_MS).unref());
132
+ const answer = String(await Promise.race([question, closed, timeout])).trim();
133
+ input.close();
134
+ return apps.find((app, i) => answer === app || answer === String(i + 1));
135
+ }
136
+
137
+ async function discover(url, place, folder, app) {
138
+ const { res, body } = await fetchJson(url, discoverAsk(place, folder, app));
139
+ if (res.status === HTTP_CONFLICT && body.apps?.length && !app) {
140
+ const chosen = await pickApp(body.apps);
141
+ if (!chosen) {
142
+ throw new Error(`this folder holds several apps (${body.apps.join(', ')}): pass --app <path>`);
143
+ }
144
+
145
+ return discover(url, place, folder, chosen);
146
+ }
147
+
148
+ return { found: res.ok ? { ok: true, ...body } : { ok: false }, app: app ?? body.app };
149
+ }
150
+
151
+ async function importFolder(url, folder, { routes, app, file }) {
114
152
  const home = await fetch(url, readyProbe);
115
153
  const place = placeOf(home.headers.get('location'));
116
154
  if (!place) {
117
155
  throw new Error('could not find the local canvas');
118
156
  }
119
157
 
120
- console.log(`feega: importing ${basename(folder)} (starts its dev server, can take a minute)…`);
121
- const { path, body } = importAsk(place, folder);
122
- const res = await fetch(new URL(path, url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
158
+ console.log(`feega: looking for the pages of ${basename(folder)}…`);
159
+ const discovered = await discover(url, place, folder, app);
160
+ const plan = routePlan(discovered.found, routes);
161
+ if (plan.fallback) {
162
+ console.log('feega: no router found, importing the home page only');
163
+ }
164
+
165
+ console.log(`feega: importing ${basename(folder)}: ${plan.routes.length} pages (starts its dev server, can take a minute)…`);
166
+ const { path, body } = importAsk(place, folder, { routes: plan.routes, app: discovered.app });
167
+ const { res, body: done } = await fetchJson(url, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
123
168
  if (!res.ok) {
124
- const { error, detail } = await res.json().catch(() => ({}));
125
- throw new Error(`folder import failed: ${detail ?? error ?? res.status}`);
169
+ throw new Error(`folder import failed: ${done.detail ?? done.error ?? res.status}`);
126
170
  }
127
171
 
128
- const { pages = [] } = await res.json();
129
- console.log(`feega: imported ${basename(folder)} (${pages.length} pages)`);
172
+ const report = importReport(plan, done);
173
+ report.lines.forEach((line) => console.log(line));
174
+ console.log(eventLine({ event: 'imported', pages: report.imported.length, needsValue: report.needsValue, leftOut: report.leftOut.length, file }));
130
175
  }
131
176
 
132
177
  function forward(child) {
@@ -167,7 +212,7 @@ async function main() {
167
212
  return;
168
213
  }
169
214
 
170
- const { file, browser, folderImport } = args;
215
+ const { file, browser, folderImport, routes, app: chosenApp } = args;
171
216
  const app = folderApp(process.cwd());
172
217
  const importing = await wantsImport(app, file, folderImport);
173
218
  const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -194,7 +239,7 @@ async function main() {
194
239
  }
195
240
 
196
241
  if (importing) {
197
- await importFolder(url, app).catch((error) => console.error(`feega: ${error.message}`));
242
+ await importFolder(url, app, { routes, app: chosenApp, file }).catch((error) => console.error(`feega: ${error.message}`));
198
243
  }
199
244
  }
200
245
 
package/launcher.mjs CHANGED
@@ -7,13 +7,14 @@ const DEFAULT_PORT = 0;
7
7
  export const LOOPBACK = '127.0.0.1';
8
8
  export const MCP_PORT = 5173;
9
9
  export const MCP_ENTRY = 'mcp.mjs';
10
- export const USAGE = `usage: feega [file.feega] [--port <n>] [--no-open] [--import | --no-import] [--help] [--version]
10
+ export const USAGE = `usage: feega [file.feega] [--port <n>] [--no-open] [--import | --no-import] [--routes <list|all>] [--app <path>] [--help] [--version]
11
11
  feega mcp [--port <n>] MCP server (stdio) for the local feega on that port (default ${MCP_PORT})`;
12
12
  export const FolderImport = { Ask: 'ask', Yes: 'yes', No: 'no' };
13
13
  export const ImportStep = { Ask: 'ask', Import: 'import', Skip: 'skip' };
14
14
 
15
15
  const IMPORT_FLAGS = { '--import': FolderImport.Yes, '--no-import': FolderImport.No };
16
16
  const HOME_ROUTE = '/';
17
+ const ALL_ROUTES = 'all';
17
18
  const PLACE = /\/p\/([^/]+)\/c\/([^/?#]+)/;
18
19
 
19
20
  const CACHED_MARKER = '.complete';
@@ -42,6 +43,21 @@ const portFlag = (arg, rest) => {
42
43
  return null;
43
44
  };
44
45
 
46
+ const valueFlag = (name, arg, rest) => {
47
+ if (arg === name) {
48
+ const value = rest.shift();
49
+ if (!value || value.startsWith('-')) {
50
+ throw new Error(`${name} needs a value\n${USAGE}`);
51
+ }
52
+
53
+ return value;
54
+ }
55
+
56
+ return arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null;
57
+ };
58
+
59
+ const routesOf = (raw) => (raw === ALL_ROUTES ? ALL_ROUTES : raw.split(/[\s,]+/).filter(Boolean));
60
+
45
61
  function parseMcp(rest) {
46
62
  let port = MCP_PORT;
47
63
  while (rest.length) {
@@ -69,6 +85,8 @@ export function parseArgs(argv, cwd) {
69
85
  let port = DEFAULT_PORT;
70
86
  let browser = 'open';
71
87
  let folderImport = FolderImport.Ask;
88
+ let routes;
89
+ let app;
72
90
 
73
91
  while (rest.length) {
74
92
  const arg = rest.shift();
@@ -96,6 +114,18 @@ export function parseArgs(argv, cwd) {
96
114
  continue;
97
115
  }
98
116
 
117
+ const routesRaw = valueFlag('--routes', arg, rest);
118
+ if (routesRaw !== null) {
119
+ routes = routesOf(routesRaw);
120
+ continue;
121
+ }
122
+
123
+ const appRaw = valueFlag('--app', arg, rest);
124
+ if (appRaw !== null) {
125
+ app = appRaw;
126
+ continue;
127
+ }
128
+
99
129
  if (arg.startsWith('-')) {
100
130
  throw new Error(`unknown option: ${arg}\n${USAGE}`);
101
131
  }
@@ -103,7 +133,7 @@ export function parseArgs(argv, cwd) {
103
133
  files.push(arg);
104
134
  }
105
135
 
106
- return { command: 'run', file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser, folderImport };
136
+ return { command: 'run', file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser, folderImport, routes, app };
107
137
  }
108
138
 
109
139
  export const appDir = (home, version) => join(home, '.feega', 'app', version);
@@ -199,11 +229,53 @@ export function placeOf(location) {
199
229
  return found ? { projectId: found[1], canvasId: found[2] } : null;
200
230
  }
201
231
 
202
- export const importAsk = (place, folder) => ({
203
- path: `/api/v1/projects/${place.projectId}/code-import`,
204
- body: { canvasId: place.canvasId, folder, routes: [HOME_ROUTE], x: 0, y: 0 }
232
+ const CODE_IMPORT = (projectId) => `/api/v1/projects/${projectId}/code-import`;
233
+
234
+ export const importAsk = (place, folder, { routes, app }) => ({
235
+ path: CODE_IMPORT(place.projectId),
236
+ body: { canvasId: place.canvasId, folder, routes, ...(app ? { app } : {}), x: 0, y: 0 }
205
237
  });
206
238
 
239
+ export const discoverAsk = (place, folder, app) => `${CODE_IMPORT(place.projectId)}/routes?${new URLSearchParams({ folder, ...(app ? { app } : {}) })}`;
240
+
241
+ const NEEDS_VALUE = 'needs a value';
242
+ const DID_NOT_OPEN = 'did not open';
243
+ const NOT_OPENED = 'not_opened';
244
+
245
+ export function routePlan(found, spec) {
246
+ if (Array.isArray(spec)) {
247
+ return { routes: spec, leftOut: [], fallback: false };
248
+ }
249
+
250
+ if (!found.ok) {
251
+ return { routes: [HOME_ROUTE], leftOut: [], fallback: true };
252
+ }
253
+
254
+ const wanted = found.routes.filter((route) => route.selected);
255
+ const dynamic = found.routes.filter((route) => !route.selected).map(({ path }) => ({ path, reason: NEEDS_VALUE }));
256
+ const over = wanted.slice(found.max).map(({ path }) => ({ path, reason: `over the ${found.max}-page limit` }));
257
+ return { routes: wanted.slice(0, found.max).map(({ path }) => path), leftOut: [...dynamic, ...over], fallback: false };
258
+ }
259
+
260
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
261
+
262
+ export function importReport(plan, done) {
263
+ const imported = done.pages ?? [];
264
+ const known = new Set(plan.leftOut.map(({ path }) => path));
265
+ const failed = (done.routes?.leftOut ?? []).filter((o) => o.reason === NOT_OPENED && !known.has(o.path)).map(({ path }) => ({ path, reason: DID_NOT_OPEN }));
266
+ const leftOut = [...plan.leftOut, ...failed];
267
+ const lines = [`feega: imported ${plural(imported.length, 'page')}: ${imported.join(', ')}`];
268
+ if (leftOut.length) {
269
+ lines.push(`feega: left out ${leftOut.length}: ${leftOut.map((o) => `${o.path} (${o.reason})`).join(', ')}`);
270
+ }
271
+
272
+ return { imported, leftOut, needsValue: leftOut.filter((o) => o.reason === NEEDS_VALUE).length, lines };
273
+ }
274
+
275
+ export const EVENT_PREFIX = 'feega:event ';
276
+
277
+ export const eventLine = (event) => `${EVENT_PREFIX}${JSON.stringify(event)}`;
278
+
207
279
  const HTTP_ERROR_MIN = 400;
208
280
 
209
281
  export const readyProbe = { redirect: 'manual' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feega",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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",