feega 0.0.0-stage → 0.1.3
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 +8 -2
- package/bin/feega.mjs +118 -0
- package/launcher.mjs +96 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# feega
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
```bash
|
|
4
|
+
npx feega my-board.feega
|
|
5
|
+
```
|
|
6
|
+
|
|
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
|
+
|
|
9
|
+
Options: `--port <n>`, `--no-open`. `FEEGA_APP_BUNDLE=<path or url>` uses another bundle.
|
package/bin/feega.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { createWriteStream, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
|
|
9
|
+
import { appDir, bundleSource, cachedVersions, isCached, latestAppVersion, markCached, parseArgs, pickVersion } from '../launcher.mjs';
|
|
10
|
+
|
|
11
|
+
const MIN_NODE_MAJOR = 22;
|
|
12
|
+
const WEBSTORAGE_NODE_MAJOR = 25;
|
|
13
|
+
const READY_POLL_MS = 300;
|
|
14
|
+
const OPENERS = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] };
|
|
15
|
+
const RELEASES_API = 'https://api.github.com/repos/andreabuttarelli/feega/releases?per_page=50';
|
|
16
|
+
const CHECK_TIMEOUT_MS = 2000;
|
|
17
|
+
const LOCAL_BUNDLE_VERSION = 'local';
|
|
18
|
+
|
|
19
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
20
|
+
|
|
21
|
+
async function download(source, target) {
|
|
22
|
+
if (!source.startsWith('http')) {
|
|
23
|
+
return source;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const res = await fetch(source);
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(`download failed (${res.status}): ${source}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function latestRelease() {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(RELEASES_API, { signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), headers: { accept: 'application/vnd.github+json' } });
|
|
38
|
+
return res.ok ? latestAppVersion(await res.json()) : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function chooseVersion(home) {
|
|
45
|
+
if (process.env.FEEGA_APP_BUNDLE) {
|
|
46
|
+
const cached = isCached(appDir(home, LOCAL_BUNDLE_VERSION)) ? [LOCAL_BUNDLE_VERSION] : [];
|
|
47
|
+
return pickVersion(LOCAL_BUNDLE_VERSION, cached);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return pickVersion(await latestRelease(), cachedVersions(home));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function install(dir, version) {
|
|
54
|
+
console.log(`feega: downloading the app ${version} (once per version)…`);
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
|
|
58
|
+
const archive = await download(bundleSource(version, process.env), join(tmpdir(), `feega-app-${version}-${process.pid}.tar.gz`));
|
|
59
|
+
const untar = spawnSync('tar', ['-xzf', archive, '-C', dir], { stdio: 'inherit' });
|
|
60
|
+
if (untar.status !== 0) {
|
|
61
|
+
throw new Error('could not extract the app bundle');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log('feega: installing its dependencies for this machine…');
|
|
65
|
+
const deps = spawnSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
|
|
66
|
+
cwd: dir,
|
|
67
|
+
stdio: 'inherit',
|
|
68
|
+
shell: process.platform === 'win32'
|
|
69
|
+
});
|
|
70
|
+
if (deps.status !== 0) {
|
|
71
|
+
throw new Error('could not install the app dependencies');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
markCached(dir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function openWhenReady(url) {
|
|
78
|
+
while (!(await fetch(url).then((r) => r.ok, () => false))) {
|
|
79
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [cmd, ...args] = OPENERS[process.platform] ?? ['xdg-open'];
|
|
83
|
+
spawn(cmd, [...args, url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main() {
|
|
87
|
+
if (nodeMajor < MIN_NODE_MAJOR) {
|
|
88
|
+
throw new Error(`Node ${MIN_NODE_MAJOR}+ is required (found ${process.versions.node})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { file, port, browser } = parseArgs(process.argv.slice(2), process.cwd());
|
|
92
|
+
const { version, action } = await chooseVersion(homedir());
|
|
93
|
+
const dir = appDir(homedir(), version);
|
|
94
|
+
if (action === 'download') {
|
|
95
|
+
await install(dir, version);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const url = `http://localhost:${port}`;
|
|
99
|
+
console.log(`feega: saving to ${file}`);
|
|
100
|
+
console.log(`feega: open ${url}`);
|
|
101
|
+
|
|
102
|
+
const nodeOptions = nodeMajor >= WEBSTORAGE_NODE_MAJOR ? '--no-experimental-webstorage' : '';
|
|
103
|
+
const env = { ...process.env, NODE_OPTIONS: [process.env.NODE_OPTIONS, nodeOptions].filter(Boolean).join(' ') };
|
|
104
|
+
const server = spawn(process.execPath, [realpathSync(join(dir, 'scripts/local.mjs')), file, `--port=${port}`], { stdio: 'inherit', env });
|
|
105
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
106
|
+
process.on(signal, () => server.kill(signal));
|
|
107
|
+
}
|
|
108
|
+
server.on('exit', (code) => process.exit(code ?? 0));
|
|
109
|
+
|
|
110
|
+
if (browser === 'open') {
|
|
111
|
+
await openWhenReady(url);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main().catch((error) => {
|
|
116
|
+
console.error(`feega: ${error.message}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
package/launcher.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FILE = 'canvas.feega';
|
|
5
|
+
const DEFAULT_PORT = 5173;
|
|
6
|
+
const CACHED_MARKER = '.complete';
|
|
7
|
+
const APP_TAG = 'app-v';
|
|
8
|
+
const RELEASES = 'https://github.com/andreabuttarelli/feega/releases/download';
|
|
9
|
+
export const BUNDLE_NAME = 'feega-app.tar.gz';
|
|
10
|
+
|
|
11
|
+
const portOf = (raw) => {
|
|
12
|
+
const port = Number(raw);
|
|
13
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
14
|
+
throw new Error(`invalid --port: ${raw}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return port;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function parseArgs(argv, cwd) {
|
|
21
|
+
const rest = [...argv];
|
|
22
|
+
const files = [];
|
|
23
|
+
let port = DEFAULT_PORT;
|
|
24
|
+
let browser = 'open';
|
|
25
|
+
|
|
26
|
+
while (rest.length) {
|
|
27
|
+
const arg = rest.shift();
|
|
28
|
+
if (arg === '--no-open') {
|
|
29
|
+
browser = 'skip';
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (arg === '--port') {
|
|
34
|
+
port = portOf(rest.shift());
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (arg.startsWith('--port=')) {
|
|
39
|
+
port = portOf(arg.slice('--port='.length));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
files.push(arg);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { file: resolve(cwd, files[0] ?? DEFAULT_FILE), port, browser };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const appDir = (home, version) => join(home, '.feega', 'app', version);
|
|
50
|
+
|
|
51
|
+
export const isCached = (dir) => existsSync(join(dir, CACHED_MARKER));
|
|
52
|
+
|
|
53
|
+
export function markCached(dir) {
|
|
54
|
+
mkdirSync(dir, { recursive: true });
|
|
55
|
+
writeFileSync(join(dir, CACHED_MARKER), '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const bundleSource = (version, env) => env.FEEGA_APP_BUNDLE ?? `${RELEASES}/${APP_TAG}${version}/${BUNDLE_NAME}`;
|
|
59
|
+
|
|
60
|
+
const semver = (v) => v.split('.').map(Number);
|
|
61
|
+
|
|
62
|
+
const newestFirst = (a, b) => {
|
|
63
|
+
const [x, y] = [semver(a), semver(b)];
|
|
64
|
+
const diff = x.map((n, i) => y[i] - n).find((d) => d !== 0);
|
|
65
|
+
return diff ?? 0;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function latestAppVersion(releases) {
|
|
69
|
+
const versions = releases
|
|
70
|
+
.filter((r) => !r.draft && !r.prerelease && r.tag_name.startsWith(APP_TAG))
|
|
71
|
+
.map((r) => r.tag_name.slice(APP_TAG.length));
|
|
72
|
+
return versions.sort(newestFirst)[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function cachedVersions(home) {
|
|
76
|
+
const root = join(home, '.feega', 'app');
|
|
77
|
+
if (!existsSync(root)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return readdirSync(root)
|
|
82
|
+
.filter((v) => /^\d+\.\d+\.\d+$/.test(v) && isCached(join(root, v)))
|
|
83
|
+
.sort(newestFirst);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function pickVersion(latest, cached) {
|
|
87
|
+
if (latest) {
|
|
88
|
+
return { version: latest, action: cached.includes(latest) ? 'cache' : 'download' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!cached.length) {
|
|
92
|
+
throw new Error('offline and no cached app: connect once to download it');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { version: cached[0], action: 'cache' };
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feega",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Run feega's infinite canvas locally, saved to a .feega file. No account, no database.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"feega": "bin/feega.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"launcher.mjs"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/andreabuttarelli/feega.git",
|
|
20
|
+
"directory": "npx"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://feega.app"
|
|
23
|
+
}
|