@fuzeelogik/myflo 1.0.0-rc.4
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 +103 -0
- package/bin/flo.js +8 -0
- package/lib/activity.js +243 -0
- package/lib/agents-cmd.js +173 -0
- package/lib/agents-store.js +153 -0
- package/lib/completions.js +120 -0
- package/lib/doctor.js +101 -0
- package/lib/edit-cmd.js +136 -0
- package/lib/export.js +182 -0
- package/lib/guidance-audit.js +215 -0
- package/lib/help.js +49 -0
- package/lib/hook-cmd.js +129 -0
- package/lib/inbox-install.js +111 -0
- package/lib/inbox-registry.js +122 -0
- package/lib/inbox.js +320 -0
- package/lib/log-cmd.js +82 -0
- package/lib/main.js +97 -0
- package/lib/mcp-server.js +459 -0
- package/lib/memory-backend-agentdb.js +240 -0
- package/lib/memory-cmd.js +148 -0
- package/lib/memory-store.js +258 -0
- package/lib/messages.js +119 -0
- package/lib/migrate.js +88 -0
- package/lib/notes-cmd.js +110 -0
- package/lib/replace-ruflo.js +133 -0
- package/lib/sessions.js +82 -0
- package/lib/setup.js +93 -0
- package/lib/swarm.js +236 -0
- package/lib/tasks-cmd.js +160 -0
- package/lib/tasks-store.js +152 -0
- package/lib/terminal-attach.js +281 -0
- package/lib/transcribe-cmd.js +75 -0
- package/lib/transcribe.js +104 -0
- package/lib/transcripts.js +95 -0
- package/package.json +45 -0
- package/tests/smoke.sh +392 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// `flo completions <shell>` — emit autocomplete scripts for bash/zsh/fish.
|
|
2
|
+
// Static command list; users sourcing the output get tab completion.
|
|
3
|
+
|
|
4
|
+
const TOP_COMMANDS = [
|
|
5
|
+
'help', 'version', 'guidance', 'migrate', 'sessions', 'inbox', 'doctor',
|
|
6
|
+
'mcp', 'transcribe', 'swarm', 'memory', 'messages', 'transcripts',
|
|
7
|
+
'tasks', 'notes', 'setup', 'export', 'import', 'log', 'completions',
|
|
8
|
+
'session', 'activity', 'edit',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const SUBCOMMANDS = {
|
|
12
|
+
guidance: ['audit'],
|
|
13
|
+
sessions: ['list'],
|
|
14
|
+
inbox: ['watch', 'status', 'add', 'list', 'remove', 'install', 'uninstall', 'help'],
|
|
15
|
+
mcp: ['start'],
|
|
16
|
+
swarm: ['status'],
|
|
17
|
+
memory: ['store', 'search', 'list', 'get', 'delete', 'namespaces'],
|
|
18
|
+
messages: ['list', 'read', 'archive', 'help'],
|
|
19
|
+
transcripts: ['list'],
|
|
20
|
+
tasks: ['create', 'list', 'update', 'complete', 'delete', 'get', 'counts'],
|
|
21
|
+
notes: ['list', 'search'],
|
|
22
|
+
session: ['terminal-add', 'terminal-list', 'terminal-remove', 'terminal-restore'],
|
|
23
|
+
activity: ['list'],
|
|
24
|
+
completions: ['bash', 'zsh', 'fish'],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function completionsCommand(args) {
|
|
28
|
+
const [shell] = args;
|
|
29
|
+
if (!shell || shell === '--help' || shell === '-h') return printHelp();
|
|
30
|
+
if (shell === 'bash') return printBash();
|
|
31
|
+
if (shell === 'zsh') return printZsh();
|
|
32
|
+
if (shell === 'fish') return printFish();
|
|
33
|
+
console.error(`flo completions: unknown shell '${shell}'. Try: bash, zsh, fish`);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function printHelp() {
|
|
38
|
+
console.log(`flo completions — emit shell autocomplete scripts
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
flo completions bash >> ~/.bashrc # or sourceable file
|
|
42
|
+
flo completions zsh >> ~/.zshrc # or to a fpath dir
|
|
43
|
+
flo completions fish > ~/.config/fish/completions/flo.fish
|
|
44
|
+
|
|
45
|
+
Each output is a complete, sourceable shell snippet. No runtime dependencies
|
|
46
|
+
beyond a working flo binary (uses the static command list, not introspection).
|
|
47
|
+
`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function topWords() { return TOP_COMMANDS.sort().join(' '); }
|
|
51
|
+
|
|
52
|
+
function subcaseBash() {
|
|
53
|
+
return Object.entries(SUBCOMMANDS)
|
|
54
|
+
.map(([cmd, subs]) => ` ${cmd}) COMPREPLY=($(compgen -W "${subs.join(' ')}" -- "$cur")); return 0;;`)
|
|
55
|
+
.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printBash() {
|
|
59
|
+
console.log(`# flo bash completion. Source from .bashrc or save to /etc/bash_completion.d/flo
|
|
60
|
+
_flo() {
|
|
61
|
+
local cur prev cmd
|
|
62
|
+
COMPREPLY=()
|
|
63
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
64
|
+
cmd="\${COMP_WORDS[1]}"
|
|
65
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
66
|
+
COMPREPLY=($(compgen -W "${topWords()}" -- "$cur"))
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
if [ "$COMP_CWORD" -eq 2 ]; then
|
|
70
|
+
case "$cmd" in
|
|
71
|
+
${subcaseBash()}
|
|
72
|
+
esac
|
|
73
|
+
fi
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
complete -F _flo flo
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function subcaseZsh() {
|
|
81
|
+
return Object.entries(SUBCOMMANDS)
|
|
82
|
+
.map(([cmd, subs]) => ` ${cmd}) _values 'subcommand' ${subs.map((s) => `'${s}'`).join(' ')} ;;`)
|
|
83
|
+
.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printZsh() {
|
|
87
|
+
console.log(`#compdef flo
|
|
88
|
+
# flo zsh completion. Save to a directory in $fpath, e.g. ~/.zsh/completions/_flo
|
|
89
|
+
_flo() {
|
|
90
|
+
local -a top
|
|
91
|
+
top=(${TOP_COMMANDS.map((c) => `'${c}'`).join(' ')})
|
|
92
|
+
if (( CURRENT == 2 )); then
|
|
93
|
+
_values 'flo command' \${top[@]}
|
|
94
|
+
return
|
|
95
|
+
fi
|
|
96
|
+
if (( CURRENT == 3 )); then
|
|
97
|
+
case "\${words[2]}" in
|
|
98
|
+
${subcaseZsh()}
|
|
99
|
+
esac
|
|
100
|
+
fi
|
|
101
|
+
}
|
|
102
|
+
_flo "$@"
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function printFish() {
|
|
107
|
+
// fish: emit one completion line per subcommand
|
|
108
|
+
const lines = [];
|
|
109
|
+
lines.push(`# flo fish completion. Save to ~/.config/fish/completions/flo.fish`);
|
|
110
|
+
lines.push(`complete -c flo -f`);
|
|
111
|
+
for (const cmd of TOP_COMMANDS) {
|
|
112
|
+
lines.push(`complete -c flo -n '__fish_use_subcommand' -a '${cmd}'`);
|
|
113
|
+
}
|
|
114
|
+
for (const [cmd, subs] of Object.entries(SUBCOMMANDS)) {
|
|
115
|
+
for (const sub of subs) {
|
|
116
|
+
lines.push(`complete -c flo -n "__fish_seen_subcommand_from ${cmd}" -a '${sub}'`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
console.log(lines.join('\n'));
|
|
120
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { stat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CHECKS = [
|
|
8
|
+
{ name: 'node>=20', run: checkNode },
|
|
9
|
+
{ name: 'git', run: checkGit },
|
|
10
|
+
{ name: '.claude/ project', run: checkProjectClaude },
|
|
11
|
+
{ name: '.claude/checkpoints', run: checkCheckpoints },
|
|
12
|
+
{ name: '~/.claude/mcp.json', run: checkMcp },
|
|
13
|
+
{ name: 'flo bin resolvable', run: checkFloBin },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export async function doctor(args) {
|
|
17
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
18
|
+
console.log(`flo doctor — quick health check
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
flo doctor [--json]
|
|
22
|
+
`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const json = args.includes('--json');
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const c of CHECKS) {
|
|
28
|
+
try {
|
|
29
|
+
const r = await c.run();
|
|
30
|
+
results.push({ name: c.name, ok: r.ok, message: r.message });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
results.push({ name: c.name, ok: false, message: err.message });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (json) {
|
|
36
|
+
console.log(JSON.stringify(results, null, 2));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const longest = Math.max(...results.map(r => r.name.length));
|
|
40
|
+
for (const r of results) {
|
|
41
|
+
const pad = r.name.padEnd(longest);
|
|
42
|
+
const status = r.ok ? 'PASS' : 'FAIL';
|
|
43
|
+
console.log(` ${pad} ${status} ${r.message}`);
|
|
44
|
+
}
|
|
45
|
+
const failed = results.filter(r => !r.ok).length;
|
|
46
|
+
console.log(`\nflo doctor: ${results.length - failed}/${results.length} checks passed.`);
|
|
47
|
+
if (failed) process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function checkNode() {
|
|
51
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
52
|
+
if (major >= 20) return { ok: true, message: process.versions.node };
|
|
53
|
+
return { ok: false, message: `Node ${process.versions.node} (need >=20)` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function checkGit() {
|
|
57
|
+
try {
|
|
58
|
+
const v = execFileSync('git', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
59
|
+
return { ok: true, message: v };
|
|
60
|
+
} catch {
|
|
61
|
+
return { ok: false, message: 'git not on PATH' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function checkProjectClaude() {
|
|
66
|
+
const p = join(process.cwd(), '.claude');
|
|
67
|
+
if (existsSync(p)) {
|
|
68
|
+
const st = await stat(p);
|
|
69
|
+
if (st.isDirectory()) return { ok: true, message: p };
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, message: `${p} not found (not in a Claude Code project?)` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function checkCheckpoints() {
|
|
75
|
+
const p = join(process.cwd(), '.claude', 'checkpoints');
|
|
76
|
+
if (!existsSync(p)) return { ok: false, message: 'no checkpoints dir (sessions not yet captured)' };
|
|
77
|
+
return { ok: true, message: p };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function checkMcp() {
|
|
81
|
+
const p = join(homedir(), '.claude', 'mcp.json');
|
|
82
|
+
if (!existsSync(p)) return { ok: false, message: `${p} missing (run 'flo migrate')` };
|
|
83
|
+
try {
|
|
84
|
+
const raw = await readFile(p, 'utf8');
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
const servers = Object.keys(parsed.mcpServers || {});
|
|
87
|
+
const hasFlo = servers.includes('flo');
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
message: `servers=[${servers.join(',')}]${hasFlo ? '' : " (run 'flo migrate' to register flo)"}`,
|
|
91
|
+
};
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { ok: false, message: `parse error: ${err.message}` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function checkFloBin() {
|
|
98
|
+
const p = new URL('../bin/flo.js', import.meta.url).pathname;
|
|
99
|
+
if (existsSync(p)) return { ok: true, message: p };
|
|
100
|
+
return { ok: false, message: `expected ${p}` };
|
|
101
|
+
}
|
package/lib/edit-cmd.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// `flo edit` — open memory entries, notes, or tasks in $EDITOR.
|
|
2
|
+
// Writes the content to a tmpfile, invokes the editor, then applies the result.
|
|
3
|
+
// Memory/note edits create a new entry (the old one stays in history via the
|
|
4
|
+
// append-only log + tombstone pattern); task edits use updateTask.
|
|
5
|
+
|
|
6
|
+
import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { getEntry, storeEntry, deleteEntry } from './memory-store.js';
|
|
11
|
+
import { getTask, updateTask } from './tasks-store.js';
|
|
12
|
+
|
|
13
|
+
export async function editCommand(args) {
|
|
14
|
+
const [kind = 'help', ...rest] = args;
|
|
15
|
+
if (kind === 'help' || kind === '--help' || kind === '-h') return printHelp();
|
|
16
|
+
if (kind === 'memory' || kind === 'note') return editMemory(kind, rest);
|
|
17
|
+
if (kind === 'task') return editTask(rest);
|
|
18
|
+
console.error(`flo edit: unknown kind '${kind}'. Try: memory, note, task`);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function printHelp() {
|
|
23
|
+
console.log(`flo edit — open in \$EDITOR (defaults to vim)
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
flo edit memory <id> # edit memory entry by id
|
|
27
|
+
flo edit memory --key <key> [--namespace <ns>]
|
|
28
|
+
flo edit note <id> # alias of: flo edit memory <id> (in 'notes' ns)
|
|
29
|
+
flo edit task <id> # edit a task's subject + description
|
|
30
|
+
|
|
31
|
+
Memory/note edits create a new entry (the old one is tombstoned but still in
|
|
32
|
+
the history log). Task edits update the existing task event log.
|
|
33
|
+
|
|
34
|
+
\$EDITOR is honored; falls back to vim, then nano.
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pickEditor() {
|
|
39
|
+
if (process.env.EDITOR) return process.env.EDITOR;
|
|
40
|
+
if (process.env.VISUAL) return process.env.VISUAL;
|
|
41
|
+
// Reasonable fallbacks
|
|
42
|
+
return 'vim';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function openInEditor(initialContent) {
|
|
46
|
+
const dir = await mkdtemp(join(tmpdir(), 'flo-edit-'));
|
|
47
|
+
const path = join(dir, 'flo-edit.md');
|
|
48
|
+
await writeFile(path, initialContent, 'utf8');
|
|
49
|
+
const editor = pickEditor();
|
|
50
|
+
// Parse simple "editor +line" / "editor --wait" forms by splitting on whitespace.
|
|
51
|
+
// Shell expansion isn't supported (we pass an arg array to spawn, never a shell string).
|
|
52
|
+
const parts = editor.split(/\s+/);
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const proc = spawn(parts[0], [...parts.slice(1), path], { stdio: 'inherit' });
|
|
55
|
+
proc.on('error', reject);
|
|
56
|
+
proc.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`editor exited ${code}`)));
|
|
57
|
+
});
|
|
58
|
+
const updated = await readFile(path, 'utf8');
|
|
59
|
+
return { path, original: initialContent, updated };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseFlags(args) {
|
|
63
|
+
const out = { positional: [] };
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const a = args[i];
|
|
66
|
+
if (a === '--namespace') out.namespace = args[++i];
|
|
67
|
+
else if (a === '--key') out.key = args[++i];
|
|
68
|
+
else if (!a.startsWith('--')) out.positional.push(a);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function editMemory(kind, args) {
|
|
74
|
+
const opts = parseFlags(args);
|
|
75
|
+
let namespace = opts.namespace || (kind === 'note' ? 'notes' : 'default');
|
|
76
|
+
const id = opts.positional[0];
|
|
77
|
+
let entry;
|
|
78
|
+
if (id) {
|
|
79
|
+
entry = await getEntry({ namespace, id });
|
|
80
|
+
} else if (opts.key) {
|
|
81
|
+
entry = await getEntry({ namespace, key: opts.key });
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`flo edit ${kind}: need <id> or --key`);
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
if (!entry) {
|
|
87
|
+
console.error(`flo edit ${kind}: not found in namespace '${namespace}'`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const { updated } = await openInEditor(entry.value);
|
|
91
|
+
if (updated.trim() === entry.value.trim()) {
|
|
92
|
+
console.log(`flo edit ${kind}: no changes`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const next = await storeEntry({
|
|
96
|
+
namespace: entry.namespace,
|
|
97
|
+
key: entry.key,
|
|
98
|
+
value: updated,
|
|
99
|
+
tags: entry.tags,
|
|
100
|
+
metadata: { ...entry.metadata, editedFrom: entry.id, editedAt: new Date().toISOString() },
|
|
101
|
+
});
|
|
102
|
+
await deleteEntry({ namespace: entry.namespace, id: entry.id });
|
|
103
|
+
console.log(`flo edit ${kind}: ${entry.id} → ${next.id} (${updated.length} chars)`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function editTask(args) {
|
|
107
|
+
const id = args[0];
|
|
108
|
+
if (!id) {
|
|
109
|
+
console.error(`flo edit task: missing <id>`);
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
const task = await getTask(id);
|
|
113
|
+
if (!task) { console.error(`flo edit task: no task ${id}`); process.exit(1); }
|
|
114
|
+
const body = `# subject (first line) and optional description (below)
|
|
115
|
+
${task.subject}
|
|
116
|
+
${task.description ? '\n' + task.description : ''}
|
|
117
|
+
`;
|
|
118
|
+
const { updated } = await openInEditor(body);
|
|
119
|
+
const lines = updated.split('\n');
|
|
120
|
+
// Skip the comment line if user kept it
|
|
121
|
+
const firstReal = lines.findIndex((l) => l && !l.startsWith('#'));
|
|
122
|
+
const newSubject = (firstReal >= 0 ? lines[firstReal] : '').trim();
|
|
123
|
+
const newDesc = firstReal >= 0
|
|
124
|
+
? lines.slice(firstReal + 1).join('\n').trim() || null
|
|
125
|
+
: null;
|
|
126
|
+
if (!newSubject) {
|
|
127
|
+
console.error(`flo edit task: empty subject — refused`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
if (newSubject === task.subject && (newDesc || '') === (task.description || '')) {
|
|
131
|
+
console.log(`flo edit task: no changes`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const next = await updateTask({ id, subject: newSubject, description: newDesc });
|
|
135
|
+
console.log(`flo edit task: ${next.id} updated`);
|
|
136
|
+
}
|
package/lib/export.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// `flo export` / `flo import` — portable ~/.flo backup.
|
|
2
|
+
// Uses Node's stream APIs + a tiny tar-like JSON-bundle format so we don't
|
|
3
|
+
// pull in any tar library. Easy to unpack from any language.
|
|
4
|
+
//
|
|
5
|
+
// Bundle format (.flo.json.gz):
|
|
6
|
+
// gzipped JSON: { manifest: {version, exportedAt, source}, files: [
|
|
7
|
+
// { path: "memory/notes.jsonl", content: "<base64>", size, mtime },
|
|
8
|
+
// ...
|
|
9
|
+
// ]}
|
|
10
|
+
|
|
11
|
+
import { createGzip, createGunzip } from 'node:zlib';
|
|
12
|
+
import { readFile, writeFile, mkdir, stat, readdir } from 'node:fs/promises';
|
|
13
|
+
import { existsSync, createReadStream, createWriteStream } from 'node:fs';
|
|
14
|
+
import { pipeline } from 'node:stream/promises';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve, dirname, sep, normalize } from 'node:path';
|
|
17
|
+
import { Readable } from 'node:stream';
|
|
18
|
+
|
|
19
|
+
const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
|
|
20
|
+
const BUNDLE_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
export async function exportCommand(args) {
|
|
23
|
+
const opts = parseFlags(args);
|
|
24
|
+
if (opts.help) return printExportHelp();
|
|
25
|
+
const outPath = opts.out || `flo-export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.flo.json.gz`;
|
|
26
|
+
|
|
27
|
+
if (!existsSync(FLO_HOME)) {
|
|
28
|
+
console.error(`flo export: no ~/.flo directory at ${FLO_HOME}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const files = await collectFiles(FLO_HOME, '');
|
|
33
|
+
const bundle = {
|
|
34
|
+
manifest: {
|
|
35
|
+
version: BUNDLE_VERSION,
|
|
36
|
+
exportedAt: new Date().toISOString(),
|
|
37
|
+
source: FLO_HOME,
|
|
38
|
+
fileCount: files.length,
|
|
39
|
+
totalBytes: files.reduce((s, f) => s + f.size, 0),
|
|
40
|
+
},
|
|
41
|
+
files,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const json = JSON.stringify(bundle);
|
|
45
|
+
await pipeline(
|
|
46
|
+
Readable.from(json),
|
|
47
|
+
createGzip({ level: 9 }),
|
|
48
|
+
createWriteStream(resolve(outPath)),
|
|
49
|
+
);
|
|
50
|
+
const outStat = await stat(resolve(outPath));
|
|
51
|
+
console.log(`flo export: wrote ${outPath}`);
|
|
52
|
+
console.log(` ${files.length} files, ${bundle.manifest.totalBytes} bytes uncompressed, ${outStat.size} bytes compressed`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function importCommand(args) {
|
|
56
|
+
const opts = parseFlags(args);
|
|
57
|
+
if (opts.help) return printImportHelp();
|
|
58
|
+
const inPath = opts.positional[0];
|
|
59
|
+
if (!inPath) {
|
|
60
|
+
console.error(`flo import: missing <bundle>`);
|
|
61
|
+
console.error(`Usage: flo import <bundle.flo.json.gz> [--target <dir>] [--force]`);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(inPath)) {
|
|
65
|
+
console.error(`flo import: file not found: ${inPath}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const target = resolve((opts.target || FLO_HOME).replace(/^~/, homedir()));
|
|
69
|
+
|
|
70
|
+
// Stream-decode the gzipped JSON
|
|
71
|
+
const chunks = [];
|
|
72
|
+
await pipeline(
|
|
73
|
+
createReadStream(inPath),
|
|
74
|
+
createGunzip(),
|
|
75
|
+
async function* sink(source) {
|
|
76
|
+
for await (const chunk of source) chunks.push(Buffer.from(chunk));
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
80
|
+
let bundle;
|
|
81
|
+
try { bundle = JSON.parse(text); } catch (err) {
|
|
82
|
+
console.error(`flo import: bundle is not valid JSON — ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!bundle?.manifest?.version) {
|
|
87
|
+
console.error(`flo import: invalid bundle (no manifest)`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (bundle.manifest.version > BUNDLE_VERSION) {
|
|
91
|
+
console.error(`flo import: bundle version ${bundle.manifest.version} exceeds supported ${BUNDLE_VERSION}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`flo import: ${bundle.files.length} files from bundle ${bundle.manifest.exportedAt}`);
|
|
96
|
+
if (opts.dryRun) {
|
|
97
|
+
for (const f of bundle.files.slice(0, 10)) console.log(` would write: ${f.path}`);
|
|
98
|
+
if (bundle.files.length > 10) console.log(` …and ${bundle.files.length - 10} more`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (existsSync(target) && !opts.force) {
|
|
103
|
+
const existing = (await readdir(target)).filter((n) => !n.startsWith('.')).length;
|
|
104
|
+
if (existing > 0) {
|
|
105
|
+
console.error(`flo import: target ${target} is non-empty. Use --force to overwrite.`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await mkdir(target, { recursive: true });
|
|
110
|
+
|
|
111
|
+
for (const f of bundle.files) {
|
|
112
|
+
// Path safety: must be a relative path inside target, no traversal
|
|
113
|
+
const rel = normalize(f.path);
|
|
114
|
+
if (rel.startsWith('..') || rel.startsWith(sep) || rel.includes('\0')) {
|
|
115
|
+
console.error(`flo import: refusing unsafe path '${f.path}' — skipped`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const fullPath = join(target, rel);
|
|
119
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
120
|
+
await writeFile(fullPath, Buffer.from(f.content, 'base64'));
|
|
121
|
+
}
|
|
122
|
+
console.log(`flo import: extracted ${bundle.files.length} files to ${target}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function collectFiles(root, relPrefix) {
|
|
126
|
+
const out = [];
|
|
127
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
const full = join(root, e.name);
|
|
130
|
+
const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
|
|
131
|
+
if (e.isDirectory()) {
|
|
132
|
+
out.push(...await collectFiles(full, rel));
|
|
133
|
+
} else if (e.isFile()) {
|
|
134
|
+
const st = await stat(full);
|
|
135
|
+
const buf = await readFile(full);
|
|
136
|
+
out.push({
|
|
137
|
+
path: rel,
|
|
138
|
+
size: st.size,
|
|
139
|
+
mtime: st.mtimeMs,
|
|
140
|
+
content: buf.toString('base64'),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function printExportHelp() {
|
|
148
|
+
console.log(`flo export — bundle ~/.flo/ into a portable .flo.json.gz file
|
|
149
|
+
|
|
150
|
+
Usage:
|
|
151
|
+
flo export [--out <path>]
|
|
152
|
+
|
|
153
|
+
Default output filename: flo-export-<timestamp>.flo.json.gz
|
|
154
|
+
Format: gzipped JSON {manifest, files[]} with base64 content. No native deps,
|
|
155
|
+
portable across platforms.
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function printImportHelp() {
|
|
160
|
+
console.log(`flo import — restore a flo export bundle
|
|
161
|
+
|
|
162
|
+
Usage:
|
|
163
|
+
flo import <bundle.flo.json.gz> [--target <dir>] [--force] [--dry-run]
|
|
164
|
+
|
|
165
|
+
By default extracts to ~/.flo. Refuses to overwrite a non-empty target unless
|
|
166
|
+
--force. Path traversal in bundle entries is blocked.
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseFlags(args) {
|
|
171
|
+
const out = { positional: [] };
|
|
172
|
+
for (let i = 0; i < args.length; i++) {
|
|
173
|
+
const a = args[i];
|
|
174
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
175
|
+
else if (a === '--out') out.out = args[++i];
|
|
176
|
+
else if (a === '--target') out.target = args[++i];
|
|
177
|
+
else if (a === '--force') out.force = true;
|
|
178
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
179
|
+
else if (!a.startsWith('--')) out.positional.push(a);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|