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 +4 -2
- package/bin/feega.mjs +56 -11
- package/launcher.mjs +77 -5
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
121
|
-
const
|
|
122
|
-
const
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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' };
|