feega 0.1.29 → 0.1.31

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]);
@@ -54,6 +56,7 @@ async function chooseVersion(home) {
54
56
  }
55
57
 
56
58
  async function install(dir, version) {
59
+ console.log(eventLine({ event: 'step', step: 'download' }));
57
60
  console.error(`feega: downloading the app ${version} (once per version)…`);
58
61
  rmSync(dir, { recursive: true, force: true });
59
62
  mkdirSync(dir, { recursive: true });
@@ -64,6 +67,7 @@ async function install(dir, version) {
64
67
  throw new Error('could not extract the app bundle');
65
68
  }
66
69
 
70
+ console.log(eventLine({ event: 'step', step: 'install' }));
67
71
  console.error('feega: installing its dependencies for this machine…');
68
72
  const deps = spawnSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
69
73
  cwd: dir,
@@ -100,6 +104,10 @@ async function readyApp() {
100
104
 
101
105
  async function wantsImport(app, file, mode) {
102
106
  const step = importStep({ mode, app, fileExists: existsSync(file), tty: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
107
+ if (step === ImportStep.NoApp) {
108
+ throw new Error(`nothing to import: ${process.cwd()} has no package.json with a "dev" script`);
109
+ }
110
+
103
111
  if (step !== ImportStep.Ask) {
104
112
  return step === ImportStep.Import;
105
113
  }
@@ -110,23 +118,68 @@ async function wantsImport(app, file, mode) {
110
118
  return answer === '' || answer.startsWith('y');
111
119
  }
112
120
 
113
- async function importFolder(url, folder) {
121
+ async function fetchJson(url, path, init) {
122
+ const res = await fetch(new URL(path, url), init);
123
+ return { res, body: await res.json().catch(() => ({})) };
124
+ }
125
+
126
+ async function pickApp(apps) {
127
+ const input = createInterface({ input: process.stdin, output: process.stdout });
128
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
129
+ if (interactive) {
130
+ apps.forEach((app, i) => console.log(` ${i + 1}) ${app}`));
131
+ } else {
132
+ console.log(eventLine({ event: 'choose-app', apps }));
133
+ }
134
+
135
+ const question = interactive ? input.question('feega: which app to import? (number) ') : new Promise((done) => input.once('line', done));
136
+ const closed = new Promise((done) => input.once('close', () => done('')));
137
+ const timeout = new Promise((done) => setTimeout(() => done(''), CHOOSE_APP_TIMEOUT_MS).unref());
138
+ const answer = String(await Promise.race([question, closed, timeout])).trim();
139
+ input.close();
140
+ return apps.find((app, i) => answer === app || answer === String(i + 1));
141
+ }
142
+
143
+ async function discover(url, place, folder, app) {
144
+ const { res, body } = await fetchJson(url, discoverAsk(place, folder, app));
145
+ if (res.status === HTTP_CONFLICT && body.apps?.length && !app) {
146
+ const chosen = await pickApp(body.apps);
147
+ if (!chosen) {
148
+ throw new Error(`this folder holds several apps (${body.apps.join(', ')}): pass --app <path>`);
149
+ }
150
+
151
+ return discover(url, place, folder, chosen);
152
+ }
153
+
154
+ return { found: res.ok ? { ok: true, ...body } : { ok: false }, app: app ?? body.app };
155
+ }
156
+
157
+ async function importFolder(url, folder, { routes, app, file }) {
114
158
  const home = await fetch(url, readyProbe);
115
159
  const place = placeOf(home.headers.get('location'));
116
160
  if (!place) {
117
161
  throw new Error('could not find the local canvas');
118
162
  }
119
163
 
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) });
164
+ console.log(eventLine({ event: 'step', step: 'find', state: 'active' }));
165
+ console.log(`feega: looking for the pages of ${basename(folder)}…`);
166
+ const discovered = await discover(url, place, folder, app);
167
+ const plan = routePlan(discovered.found, routes);
168
+ if (plan.fallback) {
169
+ console.log('feega: no router found, importing the home page only');
170
+ }
171
+
172
+ console.log(eventLine({ event: 'step', step: 'find', state: 'done', pages: plan.routes.length }));
173
+ console.log(`feega: importing ${basename(folder)}: ${plan.routes.length} pages (starts its dev server, can take a minute)…`);
174
+ const { path, body } = importAsk(place, folder, { routes: plan.routes, app: discovered.app });
175
+ const { res, body: done } = await fetchJson(url, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
123
176
  if (!res.ok) {
124
- const { error, detail } = await res.json().catch(() => ({}));
125
- throw new Error(`folder import failed: ${detail ?? error ?? res.status}`);
177
+ throw new Error(`folder import failed: ${done.detail ?? done.error ?? res.status}`);
126
178
  }
127
179
 
128
- const { pages = [] } = await res.json();
129
- console.log(`feega: imported ${basename(folder)} (${pages.length} pages)`);
180
+ const report = importReport(plan, done);
181
+ report.lines.forEach((line) => console.log(line));
182
+ console.log(eventLine({ event: 'imported', pages: report.imported.length, needsValue: report.needsValue, leftOut: report.leftOut.length, file }));
130
183
  }
131
184
 
132
185
  function forward(child) {
@@ -167,7 +220,7 @@ async function main() {
167
220
  return;
168
221
  }
169
222
 
170
- const { file, browser, folderImport } = args;
223
+ const { file, browser, folderImport, routes, app: chosenApp } = args;
171
224
  const app = folderApp(process.cwd());
172
225
  const importing = await wantsImport(app, file, folderImport);
173
226
  const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -180,6 +233,7 @@ async function main() {
180
233
  const port = await freePort(args.port);
181
234
 
182
235
  const url = boundUrl(port);
236
+ console.log(eventLine({ event: 'step', step: 'start' }));
183
237
  console.log(`feega: saving to ${file}`);
184
238
  console.log(`feega: open ${url}`);
185
239
 
@@ -194,7 +248,10 @@ async function main() {
194
248
  }
195
249
 
196
250
  if (importing) {
197
- await importFolder(url, app).catch((error) => console.error(`feega: ${error.message}`));
251
+ await importFolder(url, app, { routes, app: chosenApp, file }).catch((error) => {
252
+ console.error(`feega: ${error.message}`);
253
+ console.log(eventLine({ event: 'import-failed', message: error.message }));
254
+ });
198
255
  }
199
256
  }
200
257
 
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
- export const ImportStep = { Ask: 'ask', Import: 'import', Skip: 'skip' };
13
+ export const ImportStep = { Ask: 'ask', Import: 'import', Skip: 'skip', NoApp: 'no-app' };
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);
@@ -188,7 +218,7 @@ const STEPS = {
188
218
 
189
219
  export function importStep({ mode, app, fileExists, tty }) {
190
220
  if (!app) {
191
- return ImportStep.Skip;
221
+ return mode === FolderImport.Yes ? ImportStep.NoApp : ImportStep.Skip;
192
222
  }
193
223
 
194
224
  return STEPS[mode]({ fileExists, tty });
@@ -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.31",
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",