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 +4 -2
- package/bin/feega.mjs +68 -11
- package/launcher.mjs +79 -7
- 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]);
|
|
@@ -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
|
|
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(
|
|
121
|
-
|
|
122
|
-
const
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
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) =>
|
|
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
|
-
|
|
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' };
|