feega 0.1.28 → 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 +5 -1
- package/bin/feega.mjs +92 -6
- package/launcher.mjs +131 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +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
|
-
|
|
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
|
+
|
|
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.
|
|
10
14
|
|
|
11
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.
|
|
12
16
|
|
package/bin/feega.mjs
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
4
|
import { homedir, tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
7
|
import { Readable } from 'node:stream';
|
|
7
8
|
import { pipeline } from 'node:stream/promises';
|
|
8
9
|
|
|
9
10
|
import { sendTelemetry, telemetryEnv } from '../telemetry.mjs';
|
|
10
|
-
import { MCP_ENTRY, USAGE, appDir, bundleSource, boundUrl, cachedVersions, freePort, isCached, latestAppVersion, markCached, mcpEnv, parseArgs, pickVersion } 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';
|
|
11
12
|
|
|
12
13
|
const MIN_NODE_MAJOR = 22;
|
|
13
14
|
const WEBSTORAGE_NODE_MAJOR = 25;
|
|
@@ -16,6 +17,8 @@ const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] };
|
|
|
16
17
|
const RELEASES_API = 'https://api.github.com/repos/andreabuttarelli/feega/releases?per_page=50';
|
|
17
18
|
const CHECK_TIMEOUT_MS = 2000;
|
|
18
19
|
const LOCAL_BUNDLE_VERSION = 'local';
|
|
20
|
+
const HTTP_CONFLICT = 409;
|
|
21
|
+
const CHOOSE_APP_TIMEOUT_MS = 120_000;
|
|
19
22
|
const TO_STDERR = ['ignore', 2, 2];
|
|
20
23
|
|
|
21
24
|
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
@@ -76,11 +79,13 @@ async function install(dir, version) {
|
|
|
76
79
|
markCached(dir);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
async function
|
|
80
|
-
while (!(await fetch(url).then((r) => r.
|
|
82
|
+
async function whenReady(url) {
|
|
83
|
+
while (!(await fetch(url, readyProbe).then((r) => served(r.status), () => false))) {
|
|
81
84
|
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
82
85
|
}
|
|
86
|
+
}
|
|
83
87
|
|
|
88
|
+
function openBrowser(url) {
|
|
84
89
|
const [cmd, ...args] = OPENERS[process.platform] ?? ['xdg-open'];
|
|
85
90
|
spawn(cmd, [...args, url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
86
91
|
}
|
|
@@ -95,6 +100,80 @@ async function readyApp() {
|
|
|
95
100
|
return dir;
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
async function wantsImport(app, file, mode) {
|
|
104
|
+
const step = importStep({ mode, app, fileExists: existsSync(file), tty: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
|
|
105
|
+
if (step !== ImportStep.Ask) {
|
|
106
|
+
return step === ImportStep.Import;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
110
|
+
const answer = (await prompt.question(`feega: import the current folder (${basename(app)}) into the canvas? (Y/n) `)).trim().toLowerCase();
|
|
111
|
+
prompt.close();
|
|
112
|
+
return answer === '' || answer.startsWith('y');
|
|
113
|
+
}
|
|
114
|
+
|
|
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 }) {
|
|
152
|
+
const home = await fetch(url, readyProbe);
|
|
153
|
+
const place = placeOf(home.headers.get('location'));
|
|
154
|
+
if (!place) {
|
|
155
|
+
throw new Error('could not find the local canvas');
|
|
156
|
+
}
|
|
157
|
+
|
|
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) });
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw new Error(`folder import failed: ${done.detail ?? done.error ?? res.status}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
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 }));
|
|
175
|
+
}
|
|
176
|
+
|
|
98
177
|
function forward(child) {
|
|
99
178
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
100
179
|
process.on(signal, () => child.kill(signal));
|
|
@@ -133,7 +212,9 @@ async function main() {
|
|
|
133
212
|
return;
|
|
134
213
|
}
|
|
135
214
|
|
|
136
|
-
const { file, browser } = args;
|
|
215
|
+
const { file, browser, folderImport, routes, app: chosenApp } = args;
|
|
216
|
+
const app = folderApp(process.cwd());
|
|
217
|
+
const importing = await wantsImport(app, file, folderImport);
|
|
137
218
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
138
219
|
void sendTelemetry({
|
|
139
220
|
home: homedir(),
|
|
@@ -152,8 +233,13 @@ async function main() {
|
|
|
152
233
|
const server = spawn(process.execPath, [realpathSync(join(dir, 'scripts/local.mjs')), file, `--port=${port}`], { stdio: 'inherit', env });
|
|
153
234
|
forward(server);
|
|
154
235
|
|
|
236
|
+
await whenReady(url);
|
|
155
237
|
if (browser === 'open') {
|
|
156
|
-
|
|
238
|
+
openBrowser(url);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (importing) {
|
|
242
|
+
await importFolder(url, app, { routes, app: chosenApp, file }).catch((error) => console.error(`feega: ${error.message}`));
|
|
157
243
|
}
|
|
158
244
|
}
|
|
159
245
|
|
package/launcher.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createServer } from 'node:net';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
|
|
@@ -7,8 +7,16 @@ 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] [--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
|
+
export const FolderImport = { Ask: 'ask', Yes: 'yes', No: 'no' };
|
|
13
|
+
export const ImportStep = { Ask: 'ask', Import: 'import', Skip: 'skip' };
|
|
14
|
+
|
|
15
|
+
const IMPORT_FLAGS = { '--import': FolderImport.Yes, '--no-import': FolderImport.No };
|
|
16
|
+
const HOME_ROUTE = '/';
|
|
17
|
+
const ALL_ROUTES = 'all';
|
|
18
|
+
const PLACE = /\/p\/([^/]+)\/c\/([^/?#]+)/;
|
|
19
|
+
|
|
12
20
|
const CACHED_MARKER = '.complete';
|
|
13
21
|
const APP_TAG = 'app-v';
|
|
14
22
|
const RELEASES = 'https://github.com/andreabuttarelli/feega/releases/download';
|
|
@@ -35,6 +43,21 @@ const portFlag = (arg, rest) => {
|
|
|
35
43
|
return null;
|
|
36
44
|
};
|
|
37
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
|
+
|
|
38
61
|
function parseMcp(rest) {
|
|
39
62
|
let port = MCP_PORT;
|
|
40
63
|
while (rest.length) {
|
|
@@ -61,6 +84,9 @@ export function parseArgs(argv, cwd) {
|
|
|
61
84
|
const files = [];
|
|
62
85
|
let port = DEFAULT_PORT;
|
|
63
86
|
let browser = 'open';
|
|
87
|
+
let folderImport = FolderImport.Ask;
|
|
88
|
+
let routes;
|
|
89
|
+
let app;
|
|
64
90
|
|
|
65
91
|
while (rest.length) {
|
|
66
92
|
const arg = rest.shift();
|
|
@@ -77,12 +103,29 @@ export function parseArgs(argv, cwd) {
|
|
|
77
103
|
continue;
|
|
78
104
|
}
|
|
79
105
|
|
|
106
|
+
if (IMPORT_FLAGS[arg]) {
|
|
107
|
+
folderImport = IMPORT_FLAGS[arg];
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
80
111
|
const flagged = portFlag(arg, rest);
|
|
81
112
|
if (flagged !== null) {
|
|
82
113
|
port = flagged;
|
|
83
114
|
continue;
|
|
84
115
|
}
|
|
85
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
|
+
|
|
86
129
|
if (arg.startsWith('-')) {
|
|
87
130
|
throw new Error(`unknown option: ${arg}\n${USAGE}`);
|
|
88
131
|
}
|
|
@@ -90,7 +133,7 @@ export function parseArgs(argv, cwd) {
|
|
|
90
133
|
files.push(arg);
|
|
91
134
|
}
|
|
92
135
|
|
|
93
|
-
return { command: 'run', file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser };
|
|
136
|
+
return { command: 'run', file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser, folderImport, routes, app };
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
export const appDir = (home, version) => join(home, '.feega', 'app', version);
|
|
@@ -153,3 +196,88 @@ export const freePort = (port) =>
|
|
|
153
196
|
probe.close(() => done(bound));
|
|
154
197
|
});
|
|
155
198
|
});
|
|
199
|
+
|
|
200
|
+
export function folderApp(dir) {
|
|
201
|
+
const manifest = join(dir, 'package.json');
|
|
202
|
+
if (!existsSync(manifest)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).scripts?.dev ? dir : null;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const STEPS = {
|
|
214
|
+
[FolderImport.No]: () => ImportStep.Skip,
|
|
215
|
+
[FolderImport.Yes]: () => ImportStep.Import,
|
|
216
|
+
[FolderImport.Ask]: ({ fileExists, tty }) => (!fileExists && tty ? ImportStep.Ask : ImportStep.Skip)
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export function importStep({ mode, app, fileExists, tty }) {
|
|
220
|
+
if (!app) {
|
|
221
|
+
return ImportStep.Skip;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return STEPS[mode]({ fileExists, tty });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function placeOf(location) {
|
|
228
|
+
const found = PLACE.exec(location ?? '');
|
|
229
|
+
return found ? { projectId: found[1], canvasId: found[2] } : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
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 }
|
|
237
|
+
});
|
|
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
|
+
|
|
279
|
+
const HTTP_ERROR_MIN = 400;
|
|
280
|
+
|
|
281
|
+
export const readyProbe = { redirect: 'manual' };
|
|
282
|
+
|
|
283
|
+
export const served = (status) => status < HTTP_ERROR_MIN;
|