@clearlysh/beehaven 0.8.16
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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// beehaven CLI — feature flags
|
|
3
|
+
// ----------------------------------------------------------------------------
|
|
4
|
+
// Gate experimental / in-flux CLI behaviors. Each flag is env-overridable so
|
|
5
|
+
// it can be flipped per-invocation without a rebuild. Mirror of the app-side
|
|
6
|
+
// registry entry in the main repo's `lib/featureFlags.ts` (kept in sync by
|
|
7
|
+
// name) — the CLI is a separate process and can't read the web app's
|
|
8
|
+
// localStorage flags, so the functional gate lives here.
|
|
9
|
+
// ============================================================================
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
/** Truthy env check — accepts `1` / `true` (case-insensitive). */
|
|
14
|
+
function envOn(name) {
|
|
15
|
+
const v = process.env[name];
|
|
16
|
+
return v === '1' || (typeof v === 'string' && v.toLowerCase() === 'true');
|
|
17
|
+
}
|
|
18
|
+
// ── Persisted flags ─────────────────────────────────────────────────────────
|
|
19
|
+
// Some flags need to survive between invocations — `beehaven speak` is one
|
|
20
|
+
// command per utterance, so an env var set in one shell says nothing about the
|
|
21
|
+
// next. Those live in `~/.beehaven/config.json` (the same file that holds the
|
|
22
|
+
// relay endpoint + env name), read on demand rather than at import time so a
|
|
23
|
+
// write inside one process is visible to the next read.
|
|
24
|
+
const CONFIG_PATH = join(homedir(), '.beehaven', 'config.json');
|
|
25
|
+
/** Read `~/.beehaven/config.json`. Never throws — a corrupt file reads as `{}`. */
|
|
26
|
+
export function readCliConfig() {
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(CONFIG_PATH))
|
|
29
|
+
return {};
|
|
30
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) ?? {};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Merge `patch` into `~/.beehaven/config.json`.
|
|
38
|
+
*
|
|
39
|
+
* Read-modify-write on purpose: this file also holds auth tokens and the relay
|
|
40
|
+
* endpoint, so a flag write must never clobber keys it doesn't own. Setting a
|
|
41
|
+
* key to `undefined` deletes it (that's how a flag returns to its default,
|
|
42
|
+
* rather than persisting a literal "auto" forever).
|
|
43
|
+
*/
|
|
44
|
+
export function patchCliConfig(patch) {
|
|
45
|
+
const cfg = readCliConfig();
|
|
46
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
47
|
+
if (v === undefined)
|
|
48
|
+
delete cfg[k];
|
|
49
|
+
else
|
|
50
|
+
cfg[k] = v;
|
|
51
|
+
}
|
|
52
|
+
mkdirSync(join(homedir(), '.beehaven'), { recursive: true });
|
|
53
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
54
|
+
}
|
|
55
|
+
// ── TTS provider ────────────────────────────────────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* Which engine `beehaven speak` synthesizes with.
|
|
58
|
+
*
|
|
59
|
+
* auto Resolves to `qwen3-mlx`. Kept as a distinct value because it is
|
|
60
|
+
* what an unset flag means, and because it is the value the
|
|
61
|
+
* resolution treats as "no opinion" — an agent's BOUND voice wins
|
|
62
|
+
* over `auto` and loses to an explicit `--tts`.
|
|
63
|
+
* qwen3-mlx Qwen3-TTS running ON THIS MACHINE under Apple's MLX. Speaks in a
|
|
64
|
+
* CLONED voice, needs neither the Mac app nor a live relay, Apple
|
|
65
|
+
* Silicon only. Install: `beehaven voice setup`; clone a voice:
|
|
66
|
+
* `beehaven voice clone`.
|
|
67
|
+
* none Don't synthesize at all — for CI and agents that must stay
|
|
68
|
+
* silent. `speak` becomes a no-op that still honours the
|
|
69
|
+
* workspace-interaction flag below.
|
|
70
|
+
*
|
|
71
|
+
* ⚠⚠ THERE IS NO FALLBACK BETWEEN ENGINES ANY MORE. `avatar` (the Mac app's
|
|
72
|
+
* Kokoro) and `system` (macOS `say`) were deleted on 2026-08-27 with their rungs:
|
|
73
|
+
* a voice is an IDENTITY, so substituting a different one when the first is
|
|
74
|
+
* unavailable is a wrong answer, not a degraded one. `speak` now refuses and names
|
|
75
|
+
* the fix. `cloud` and `minimax` went earlier the same day, for network reasons.
|
|
76
|
+
*
|
|
77
|
+
* ⚠⚠ THIS ARRAY IS THE GATE, AND IT MUST MATCH THE RUNG TABLE IN BOTH DIRECTIONS.
|
|
78
|
+
* `--tts <name>` is validated against it, and the resolved name is then used to
|
|
79
|
+
* INDEX `rungs` in cli.ts. So:
|
|
80
|
+
* • a rung absent here is unreachable, silently — that is what happened to
|
|
81
|
+
* `minimax`, which sat in the table for days behind a comment promising it was
|
|
82
|
+
* "one flag away";
|
|
83
|
+
* • a name here with no rung is worse: it passes validation and then indexes
|
|
84
|
+
* `undefined`, which is a crash rather than a refusal.
|
|
85
|
+
* `speak-surface.test.ts` pins both directions. Add or remove a name and its rung
|
|
86
|
+
* in the same commit.
|
|
87
|
+
*/
|
|
88
|
+
export const TTS_PROVIDERS = [
|
|
89
|
+
'auto',
|
|
90
|
+
'qwen3-mlx',
|
|
91
|
+
'none',
|
|
92
|
+
];
|
|
93
|
+
export function isTtsProvider(v) {
|
|
94
|
+
return TTS_PROVIDERS.includes(v);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the active provider: `$BEEHAVEN_TTS_PROVIDER` beats the persisted
|
|
98
|
+
* `ttsProvider` in config.json, which beats `auto`. An unrecognized value
|
|
99
|
+
* degrades to `auto` rather than muting the CLI.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveTtsProvider() {
|
|
102
|
+
const env = (process.env.BEEHAVEN_TTS_PROVIDER ?? '').trim();
|
|
103
|
+
if (env && isTtsProvider(env))
|
|
104
|
+
return env;
|
|
105
|
+
const stored = String(readCliConfig().ttsProvider ?? '').trim();
|
|
106
|
+
if (stored && isTtsProvider(stored))
|
|
107
|
+
return stored;
|
|
108
|
+
return 'auto';
|
|
109
|
+
}
|
|
110
|
+
/** The cloned voice `qwen3-mlx` speaks with. `$BEEHAVEN_TTS_VOICE` wins. */
|
|
111
|
+
export function resolveTtsVoice() {
|
|
112
|
+
const env = (process.env.BEEHAVEN_TTS_VOICE ?? '').trim();
|
|
113
|
+
if (env)
|
|
114
|
+
return env;
|
|
115
|
+
const stored = String(readCliConfig().ttsVoice ?? '').trim();
|
|
116
|
+
return stored || undefined;
|
|
117
|
+
}
|
|
118
|
+
export const CLI_FEATURE_FLAGS = {
|
|
119
|
+
/**
|
|
120
|
+
* SPEAK_WORKSPACE_INTERACTION — does `beehaven speak` touch the workspace?
|
|
121
|
+
*
|
|
122
|
+
* OFF (default) → `beehaven speak` is purely LOCAL: it synthesizes + plays
|
|
123
|
+
* through the desktop avatar's local Kokoro voice and never
|
|
124
|
+
* calls the cloud. No canvas voice-frame is written.
|
|
125
|
+
* ON → additionally drop a durable voice-frame card on the
|
|
126
|
+
* connected workspace canvas (`canvas-add-voice`, which
|
|
127
|
+
* synthesizes server-side via ElevenLabs).
|
|
128
|
+
*
|
|
129
|
+
* Default: OFF. Override: `BEEHAVEN_SPEAK_WORKSPACE=1` (or `=true`).
|
|
130
|
+
*/
|
|
131
|
+
SPEAK_WORKSPACE_INTERACTION: envOn('BEEHAVEN_SPEAK_WORKSPACE'),
|
|
132
|
+
/**
|
|
133
|
+
* TTS_PROVIDER — which engine `beehaven speak` synthesizes with.
|
|
134
|
+
*
|
|
135
|
+
* A getter, not a snapshot: `beehaven voice provider <name>` writes the value
|
|
136
|
+
* and any later read in the same process must see it.
|
|
137
|
+
*
|
|
138
|
+
* Default: `auto`. Override: `BEEHAVEN_TTS_PROVIDER=qwen3-mlx`, or persist
|
|
139
|
+
* with `beehaven voice provider qwen3-mlx`.
|
|
140
|
+
*/
|
|
141
|
+
get TTS_PROVIDER() {
|
|
142
|
+
return resolveTtsProvider();
|
|
143
|
+
},
|
|
144
|
+
/**
|
|
145
|
+
* TTS_VOICE — the cloned voice `qwen3-mlx` speaks with (a name registered by
|
|
146
|
+
* `beehaven voice clone`). Ignored by every other provider.
|
|
147
|
+
*
|
|
148
|
+
* Default: unset (Qwen3 speaks in its own base voice). Override:
|
|
149
|
+
* `BEEHAVEN_TTS_VOICE=cove`, or persist with `beehaven voice use cove`.
|
|
150
|
+
*/
|
|
151
|
+
get TTS_VOICE() {
|
|
152
|
+
return resolveTtsVoice();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Beehaven Office - File Browser
|
|
3
|
+
// Reads directory listings for the remote Finder UI.
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import { readdirSync, statSync, realpathSync } from 'fs';
|
|
6
|
+
import { join, extname } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
const SKIP_DIRS = new Set([
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.git',
|
|
11
|
+
'dist',
|
|
12
|
+
'build',
|
|
13
|
+
'__pycache__',
|
|
14
|
+
'.next',
|
|
15
|
+
'.cache',
|
|
16
|
+
'coverage',
|
|
17
|
+
'.turbo',
|
|
18
|
+
'.vercel',
|
|
19
|
+
'.svelte-kit',
|
|
20
|
+
'.nuxt',
|
|
21
|
+
'target',
|
|
22
|
+
'vendor',
|
|
23
|
+
'.gradle',
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Browse a directory path and return its entries.
|
|
27
|
+
* Returns directories first, then files, both sorted alphabetically.
|
|
28
|
+
*/
|
|
29
|
+
export function browsePath(dirPath) {
|
|
30
|
+
const home = homedir();
|
|
31
|
+
let resolved;
|
|
32
|
+
try {
|
|
33
|
+
resolved = realpathSync(dirPath.replace(/^~/, home));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { entries: [], error: 'Directory not found' };
|
|
37
|
+
}
|
|
38
|
+
let rawEntries;
|
|
39
|
+
try {
|
|
40
|
+
rawEntries = readdirSync(resolved, { withFileTypes: true });
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT')
|
|
44
|
+
return { entries: [], error: 'Directory not found' };
|
|
45
|
+
if (err.code === 'EACCES' || err.code === 'EPERM')
|
|
46
|
+
return { entries: [], error: 'Restricted folder' };
|
|
47
|
+
if (err.code === 'ENOTDIR')
|
|
48
|
+
return { entries: [], error: 'Not a directory' };
|
|
49
|
+
return { entries: [], error: err.message || 'Unknown error' };
|
|
50
|
+
}
|
|
51
|
+
const dirs = [];
|
|
52
|
+
const files = [];
|
|
53
|
+
for (const entry of rawEntries) {
|
|
54
|
+
// Skip system/hidden files that clutter the view
|
|
55
|
+
if (entry.name === '.DS_Store' || entry.name === 'Thumbs.db')
|
|
56
|
+
continue;
|
|
57
|
+
const fullPath = join(resolved, entry.name);
|
|
58
|
+
let stat;
|
|
59
|
+
try {
|
|
60
|
+
stat = statSync(fullPath);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue; // Skip entries we can't stat
|
|
64
|
+
}
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
dirs.push({
|
|
67
|
+
name: entry.name,
|
|
68
|
+
type: 'directory',
|
|
69
|
+
size: 0,
|
|
70
|
+
ext: '',
|
|
71
|
+
modified: stat.mtimeMs,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else if (entry.isFile()) {
|
|
75
|
+
files.push({
|
|
76
|
+
name: entry.name,
|
|
77
|
+
type: 'file',
|
|
78
|
+
size: stat.size,
|
|
79
|
+
ext: extname(entry.name).slice(1).toLowerCase(),
|
|
80
|
+
modified: stat.mtimeMs,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Sort alphabetically, directories first
|
|
85
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
86
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
return { entries: [...dirs, ...files] };
|
|
88
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* first-party-hives — auto-install bundled hives on daemon startup.
|
|
3
|
+
*
|
|
4
|
+
* Vector hive (and image hive, eventually) ship inside the Mac app's
|
|
5
|
+
* bundle at Resources/beehaven/hives/. Users shouldn't have to run
|
|
6
|
+
* `beehaven app install vector` to get the editor canvas working —
|
|
7
|
+
* it's a first-party experience. This module ensures the canonical
|
|
8
|
+
* first-party set is present in ~/.beehaven/apps/ on every daemon
|
|
9
|
+
* start.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent — if the hive directory already exists in apps/, skip.
|
|
12
|
+
* Quiet — no user notification, just done. Failures are logged but
|
|
13
|
+
* don't crash daemon startup (worst case: user runs the manual
|
|
14
|
+
* install verb to recover).
|
|
15
|
+
*
|
|
16
|
+
* Discovery order for the bundled source:
|
|
17
|
+
* 1. <daemon-dir>/../hives/<name>/ (Mac app: Resources/beehaven/hives/)
|
|
18
|
+
* 2. <daemon-dir>/../../hives/<name>/ (one extra level — dev linkages)
|
|
19
|
+
* 3. <daemon-dir>/../../clearly-hives/<name>/ (monorepo dev path)
|
|
20
|
+
*
|
|
21
|
+
* ## ⚠⚠ VERSION-AWARE UPGRADE — this is what makes a shipped hive change reach anybody
|
|
22
|
+
*
|
|
23
|
+
* This used to skip whenever ANY copy of the hive existed, and its own note called that
|
|
24
|
+
* "acceptable for the v0 auto-install — we can add version-aware upgrade later." Later is now,
|
|
25
|
+
* because the consequence turned out to be total: the Mac app resolves the hive from
|
|
26
|
+
* `~/.beehaven/apps` in PREFERENCE to the copy inside its own bundle (`resolveUiDir` in
|
|
27
|
+
* avatar-overlay.ts), and nothing in the product ever removes an installed one. So a release
|
|
28
|
+
* could ship a rewritten companion and every existing user would keep the old one for ever.
|
|
29
|
+
* Verified against a real install: the bundled UI and the installed UI had diverged and nothing
|
|
30
|
+
* would have reconciled them.
|
|
31
|
+
*
|
|
32
|
+
* It is worse than a stale feature because it is INVISIBLE. The build succeeds, the bundle
|
|
33
|
+
* genuinely contains the new hive, and the app quietly loads the old one — so the release looks
|
|
34
|
+
* shipped and behaves un-shipped.
|
|
35
|
+
*
|
|
36
|
+
* Now: install when absent, UPGRADE IN PLACE when the bundled manifest is newer, skip otherwise.
|
|
37
|
+
*
|
|
38
|
+
* ⚠ IN PLACE, INTO THE EXISTING DIRECTORY — not a new `name@newversion` beside it. The daemon's
|
|
39
|
+
* `scanInstalledApps` enumerates this directory and dedupes by DIRECTORY NAME, so two versioned
|
|
40
|
+
* directories register as two separate apps and whichever `readdir` yields first wins. One
|
|
41
|
+
* directory per hive is the invariant that keeps the daemon and the Mac app on the same copy.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ NOTHING IS DELETED. `cpSync(force: true)` overwrites the files the bundle carries and leaves
|
|
44
|
+
* everything else, so a building's SQLite state (dataDir defaults under this same tree) survives
|
|
45
|
+
* an upgrade. Removing the old directory would look tidier and would take a user's data with it.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ THE INSTALLED VERSION IS READ FROM ITS `app.json`, NOT FROM THE DIRECTORY NAME. An in-place
|
|
48
|
+
* upgrade leaves the name reading `avatar@0.1.0` while the contents are newer; trusting the name
|
|
49
|
+
* would re-copy the whole hive on every launch — and would clobber a developer's local edits to
|
|
50
|
+
* an installed hive every time the daemon started.
|
|
51
|
+
*
|
|
52
|
+
* ⚠ A HIVE'S `app.json` VERSION MUST BE BUMPED WHEN ITS CONTENT CHANGES. That is the signal this
|
|
53
|
+
* relies on, and it is the one step a person has to remember; the Mac app's release preflight
|
|
54
|
+
* checks it (`scripts/release-preflight.mjs`).
|
|
55
|
+
*/
|
|
56
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, cpSync } from 'fs';
|
|
57
|
+
import { join, dirname } from 'path';
|
|
58
|
+
/**
|
|
59
|
+
* ⚠ ONE COMPARATOR. This file carried its own, which stripped the prerelease entirely
|
|
60
|
+
* (`split('-')[0]`) — so `1.0.0-rc.1` and `1.0.0` compared EQUAL and a hive on a release
|
|
61
|
+
* candidate would never update off it. Same name, different behaviour, in two files that never
|
|
62
|
+
* referenced each other.
|
|
63
|
+
*/
|
|
64
|
+
import { compareVersions } from './version-utils.js';
|
|
65
|
+
/**
|
|
66
|
+
* Ensure the named first-party hives are present in `appsDir`. Copies
|
|
67
|
+
* from the bundled location if missing. Idempotent.
|
|
68
|
+
*
|
|
69
|
+
* @param names e.g. ['vector', 'image']
|
|
70
|
+
* @param appsDir ~/.beehaven/apps/
|
|
71
|
+
* @param daemonDir The daemon's own directory — used to find bundled hives
|
|
72
|
+
*/
|
|
73
|
+
export async function ensureFirstPartyHives(names, appsDir, daemonDir) {
|
|
74
|
+
const installed = [];
|
|
75
|
+
const upgraded = [];
|
|
76
|
+
const skipped = [];
|
|
77
|
+
const failed = [];
|
|
78
|
+
mkdirSync(appsDir, { recursive: true });
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
try {
|
|
81
|
+
// Find the bundled source.
|
|
82
|
+
const source = findBundledHive(name, daemonDir);
|
|
83
|
+
if (!source) {
|
|
84
|
+
failed.push(name);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Read the manifest to learn the version.
|
|
88
|
+
const manifestPath = join(source, 'app.json');
|
|
89
|
+
if (!existsSync(manifestPath)) {
|
|
90
|
+
failed.push(name);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let manifest;
|
|
94
|
+
try {
|
|
95
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
failed.push(name);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const existing = installedCopies(name, appsDir);
|
|
102
|
+
// Nothing installed — first run. Fresh copy at ~/.beehaven/apps/<name>@<version>/.
|
|
103
|
+
if (existing.length === 0) {
|
|
104
|
+
const dest = join(appsDir, `${manifest.name}@${manifest.version}`);
|
|
105
|
+
cpSync(source, dest, { recursive: true, errorOnExist: false, force: false });
|
|
106
|
+
installed.push(name);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Upgrade every copy the bundle is newer than. Usually one; a machine that has both a
|
|
110
|
+
// legacy unversioned `avatar/` and an `avatar@x.y.z/` gets both brought forward, because
|
|
111
|
+
// leaving one behind is how the daemon and the app end up reading different code.
|
|
112
|
+
const stale = existing.filter((c) => compareVersions(manifest.version, c.version) > 0);
|
|
113
|
+
if (stale.length === 0) {
|
|
114
|
+
skipped.push(name);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const c of stale) {
|
|
118
|
+
cpSync(source, c.dir, { recursive: true, errorOnExist: false, force: true });
|
|
119
|
+
}
|
|
120
|
+
upgraded.push(name);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
console.warn(` [first-party-hives] ${name} failed: ${e?.message ?? e}`);
|
|
124
|
+
failed.push(name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (installed.length > 0) {
|
|
128
|
+
console.log(` [first-party-hives] auto-installed: ${installed.join(', ')}`);
|
|
129
|
+
}
|
|
130
|
+
// ⚠ AN UPGRADE IS LOGGED LOUDLY. It rewrites files under the user's home directory on a
|
|
131
|
+
// daemon start they did not ask for, and it is the only evidence that a shipped hive change
|
|
132
|
+
// actually landed — which is exactly what was impossible to see before.
|
|
133
|
+
if (upgraded.length > 0) {
|
|
134
|
+
console.log(` [first-party-hives] upgraded from the bundle: ${upgraded.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
return { installed, upgraded, skipped, failed };
|
|
137
|
+
}
|
|
138
|
+
/** Every installed copy of the hive, with the version its OWN manifest claims. */
|
|
139
|
+
function installedCopies(name, appsDir) {
|
|
140
|
+
if (!existsSync(appsDir))
|
|
141
|
+
return [];
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const e of readdirSync(appsDir, { withFileTypes: true })) {
|
|
144
|
+
if (!e.isDirectory())
|
|
145
|
+
continue;
|
|
146
|
+
if (e.name !== name && !e.name.startsWith(`${name}@`))
|
|
147
|
+
continue;
|
|
148
|
+
const dir = join(appsDir, e.name);
|
|
149
|
+
// ⚠ THE MANIFEST, NOT THE DIRECTORY NAME — an in-place upgrade leaves the name stale, and
|
|
150
|
+
// reading the name would re-copy the hive on every launch for ever. `0.0.0` for a copy with
|
|
151
|
+
// no readable manifest, so anything bundled beats it and a corrupt install self-heals.
|
|
152
|
+
let version = '0.0.0';
|
|
153
|
+
try {
|
|
154
|
+
const m = JSON.parse(readFileSync(join(dir, 'app.json'), 'utf8'));
|
|
155
|
+
if (typeof m?.version === 'string' && m.version)
|
|
156
|
+
version = m.version;
|
|
157
|
+
}
|
|
158
|
+
catch { /* unreadable manifest — treat as ancient */ }
|
|
159
|
+
out.push({ dir, version });
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Semver-ish compare: >0 when `a` is newer. Numeric per segment, because a lexicographic
|
|
165
|
+
* comparison puts 0.10.0 BEFORE 0.9.0 — the same bug `resolveUiDir` in the Mac app already had
|
|
166
|
+
* to fix in its own sort.
|
|
167
|
+
*
|
|
168
|
+
* Pre-release suffixes are ignored rather than ordered: these are first-party hives shipped
|
|
169
|
+
* inside the app, so `0.2.0-rc1` and `0.2.0` are the same release as far as this is concerned.
|
|
170
|
+
*/
|
|
171
|
+
/** Find the bundled source for a hive by checking known locations. */
|
|
172
|
+
/**
|
|
173
|
+
* The read-only home of a bundled first-party hive, or null.
|
|
174
|
+
*
|
|
175
|
+
* ⚠⚠ EXPORTED SO BUILDINGS CAN BE LOADED IN PLACE (2026-08-28). `ensureFirstPartyHives` COPIES a
|
|
176
|
+
* hive's whole project directory — `src/`, `scripts/`, `tsconfig.json`, and for the avatar a
|
|
177
|
+
* `ui/` the product then rendered from — into `~/.beehaven/apps`. That put an editable second copy
|
|
178
|
+
* of first-party SOURCE outside version control, and made which copy ran a question of timing.
|
|
179
|
+
*
|
|
180
|
+
* Reading the bundle directly removes the copy, and with it the whole class of bug: there is one
|
|
181
|
+
* location, it ships with the app, and nothing can edit it by accident.
|
|
182
|
+
*/
|
|
183
|
+
export function bundledHiveDir(name, daemonDir) {
|
|
184
|
+
return findBundledHive(name, daemonDir);
|
|
185
|
+
}
|
|
186
|
+
function findBundledHive(name, daemonDir) {
|
|
187
|
+
const candidates = [
|
|
188
|
+
// Mac app bundle: Resources/beehaven/hives/<name>/
|
|
189
|
+
//
|
|
190
|
+
// ⚠ This is the path the comment below has always DESCRIBED and the code
|
|
191
|
+
// never checked. `daemonDir` is where daemon-entry.js lives —
|
|
192
|
+
// `Resources/beehaven` — so `dirname(daemonDir)` is `Resources`, and the
|
|
193
|
+
// next candidate resolves to `Resources/hives/<name>`, one level ABOVE
|
|
194
|
+
// where the bundle actually puts them. Nothing matched, `findBundledHive`
|
|
195
|
+
// returned null for every hive, and the bootstrap silently installed
|
|
196
|
+
// nothing. Kept the old entries below: they're what a linked/dev layout
|
|
197
|
+
// uses, and removing them would break those.
|
|
198
|
+
join(daemonDir, 'hives', name),
|
|
199
|
+
join(dirname(daemonDir), 'hives', name),
|
|
200
|
+
// One more level up — dev/linked layouts
|
|
201
|
+
join(dirname(dirname(daemonDir)), 'hives', name),
|
|
202
|
+
// Monorepo dev: clearly-hives/<name>/
|
|
203
|
+
join(dirname(dirname(daemonDir)), 'clearly-hives', name),
|
|
204
|
+
join(dirname(dirname(dirname(daemonDir))), 'clearly-hives', name),
|
|
205
|
+
];
|
|
206
|
+
for (const c of candidates) {
|
|
207
|
+
if (existsSync(c) && existsSync(join(c, 'app.json')))
|
|
208
|
+
return c;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Beehaven Office - Folder Sync
|
|
3
|
+
// Links local folders to Clearly projects and watches for file changes.
|
|
4
|
+
// Uses native OS file watchers (fsevents/inotify) — no polling.
|
|
5
|
+
// chokidar is loaded lazily in start() so a daemon without chokidar bundled
|
|
6
|
+
// still boots and serves IPC — folder-sync just becomes a no-op.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { createHash } from 'crypto';
|
|
10
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
11
|
+
import { extname, relative, join } from 'path';
|
|
12
|
+
import { homedir, hostname } from 'os';
|
|
13
|
+
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
14
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
15
|
+
/** Directories and files to ignore */
|
|
16
|
+
const IGNORE_PATTERNS = [
|
|
17
|
+
'**/node_modules/**',
|
|
18
|
+
'**/.git/**',
|
|
19
|
+
'**/dist/**',
|
|
20
|
+
'**/build/**',
|
|
21
|
+
'**/__pycache__/**',
|
|
22
|
+
'**/.next/**',
|
|
23
|
+
'**/.cache/**',
|
|
24
|
+
'**/coverage/**',
|
|
25
|
+
'**/.turbo/**',
|
|
26
|
+
'**/.DS_Store',
|
|
27
|
+
'**/.env',
|
|
28
|
+
'**/.env.*',
|
|
29
|
+
'**/package-lock.json',
|
|
30
|
+
'**/yarn.lock',
|
|
31
|
+
'**/pnpm-lock.yaml',
|
|
32
|
+
'**/*.sqlite',
|
|
33
|
+
'**/*.db',
|
|
34
|
+
'**/*.pyc',
|
|
35
|
+
];
|
|
36
|
+
// ── Config Helpers ──────────────────────────────────────────────────────────
|
|
37
|
+
function loadConfig() {
|
|
38
|
+
if (!existsSync(CONFIG_FILE))
|
|
39
|
+
return {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function saveConfig(update) {
|
|
48
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
49
|
+
const existing = loadConfig();
|
|
50
|
+
const merged = { ...existing, ...update };
|
|
51
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
52
|
+
}
|
|
53
|
+
/** Get all linked folders from config */
|
|
54
|
+
export function getLinkedFolders() {
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
return config.linkedFolders || {};
|
|
57
|
+
}
|
|
58
|
+
// ── Link / Unlink Flows ─────────────────────────────────────────────────────
|
|
59
|
+
/** Link the current folder to a Clearly project (brand) */
|
|
60
|
+
export async function linkProject(relay, folderPath, brandId) {
|
|
61
|
+
const targetPath = folderPath || process.cwd();
|
|
62
|
+
if (!relay.isConfigured()) {
|
|
63
|
+
console.error(' Not linked to Clearly. Sign in from the Clearly Dev Mac app tray.');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// If no brandId provided, list projects and prompt
|
|
67
|
+
if (!brandId) {
|
|
68
|
+
const projects = await relay.listProjects();
|
|
69
|
+
/**
|
|
70
|
+
* ⚠ `null` MEANS THE CALL FAILED; `[]` MEANS THERE ARE NONE. Collapsing them is how this
|
|
71
|
+
* printed "No projects found" against a workspace holding 50 — see `listProjects`.
|
|
72
|
+
*/
|
|
73
|
+
if (projects === null) {
|
|
74
|
+
console.error(' Could not reach your workspace to list projects.');
|
|
75
|
+
console.error(' Check `beehaven status`, then try again.');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (projects.length === 0) {
|
|
79
|
+
console.error(' No projects found in your Clearly account.');
|
|
80
|
+
console.error(' Create a project at https://clearly.sh first.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
console.log('\n Available projects:\n');
|
|
84
|
+
for (let i = 0; i < projects.length; i++) {
|
|
85
|
+
console.log(` ${i + 1}. ${projects[i].name} (${projects[i].id})`);
|
|
86
|
+
}
|
|
87
|
+
// Use readline for interactive selection
|
|
88
|
+
const readline = await import('readline');
|
|
89
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
90
|
+
const answer = await new Promise((resolve) => {
|
|
91
|
+
rl.question('\n Select project number: ', resolve);
|
|
92
|
+
});
|
|
93
|
+
rl.close();
|
|
94
|
+
const idx = parseInt(answer, 10) - 1;
|
|
95
|
+
if (isNaN(idx) || idx < 0 || idx >= projects.length) {
|
|
96
|
+
console.error(' Invalid selection.');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
brandId = projects[idx].id;
|
|
100
|
+
console.log(`\n Linking to: ${projects[idx].name}`);
|
|
101
|
+
}
|
|
102
|
+
// Store link in config
|
|
103
|
+
const linked = getLinkedFolders();
|
|
104
|
+
linked[targetPath] = { brandId, linkedAt: Date.now() };
|
|
105
|
+
saveConfig({ linkedFolders: linked });
|
|
106
|
+
// Notify Clearly
|
|
107
|
+
await relay.updateBrandSync(brandId, {
|
|
108
|
+
localPath: targetPath,
|
|
109
|
+
syncDevice: hostname(),
|
|
110
|
+
syncStatus: 'connected',
|
|
111
|
+
});
|
|
112
|
+
console.log(`\n Linked: ${targetPath} → ${brandId}`);
|
|
113
|
+
console.log(' Beehaven will sync this folder when running.\n');
|
|
114
|
+
}
|
|
115
|
+
/** Unlink the current folder */
|
|
116
|
+
export async function unlinkProject(relay, folderPath) {
|
|
117
|
+
const targetPath = folderPath || process.cwd();
|
|
118
|
+
const linked = getLinkedFolders();
|
|
119
|
+
const entry = linked[targetPath];
|
|
120
|
+
if (!entry) {
|
|
121
|
+
console.error(` No linked project for: ${targetPath}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
// Clear sync status on Clearly
|
|
125
|
+
if (relay.isConfigured()) {
|
|
126
|
+
await relay.updateBrandSync(entry.brandId, {
|
|
127
|
+
syncStatus: 'disconnected',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
delete linked[targetPath];
|
|
131
|
+
saveConfig({ linkedFolders: linked });
|
|
132
|
+
console.log(`\n Unlinked: ${targetPath}\n`);
|
|
133
|
+
}
|
|
134
|
+
// ── Folder Watcher ──────────────────────────────────────────────────────────
|
|
135
|
+
export class FolderWatcher extends EventEmitter {
|
|
136
|
+
rootPath;
|
|
137
|
+
relay;
|
|
138
|
+
watcher = null;
|
|
139
|
+
fileHashes = new Map();
|
|
140
|
+
pendingChanges = new Map();
|
|
141
|
+
flushTimer = null;
|
|
142
|
+
brandId;
|
|
143
|
+
constructor(rootPath, brandId, relay) {
|
|
144
|
+
super();
|
|
145
|
+
this.rootPath = rootPath;
|
|
146
|
+
this.relay = relay;
|
|
147
|
+
this.brandId = brandId;
|
|
148
|
+
}
|
|
149
|
+
async start() {
|
|
150
|
+
// All failures are swallowed to a log — folder sync is optional, and
|
|
151
|
+
// callers fire-and-forget this method. We must never reject so nothing
|
|
152
|
+
// becomes an unhandled promise rejection in the daemon process.
|
|
153
|
+
try {
|
|
154
|
+
let watch;
|
|
155
|
+
try {
|
|
156
|
+
watch = (await import('chokidar')).watch;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Daemon running without chokidar — folder sync becomes a no-op.
|
|
160
|
+
// The rest of the daemon (IPC, relay) continues to function.
|
|
161
|
+
console.warn(`[folder-sync] chokidar not available — watcher for ${this.rootPath} disabled`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.watcher = watch(this.rootPath, {
|
|
165
|
+
persistent: true,
|
|
166
|
+
ignoreInitial: true, // Skip initial scan to avoid EMFILE on large repos
|
|
167
|
+
ignored: IGNORE_PATTERNS,
|
|
168
|
+
depth: 5, // Reduced from 8 — deep repos exhaust file descriptors
|
|
169
|
+
usePolling: false,
|
|
170
|
+
});
|
|
171
|
+
this.watcher
|
|
172
|
+
.on('add', (path) => this.handleFile('add', path))
|
|
173
|
+
.on('change', (path) => this.handleFile('change', path))
|
|
174
|
+
.on('unlink', (path) => this.handleFile('delete', path))
|
|
175
|
+
.on('error', (err) => this.emit('error', err));
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
console.warn(`[folder-sync] start failed for ${this.rootPath}: ${err?.message ?? err}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
stop() {
|
|
182
|
+
if (this.flushTimer)
|
|
183
|
+
clearTimeout(this.flushTimer);
|
|
184
|
+
this.watcher?.close();
|
|
185
|
+
this.watcher = null;
|
|
186
|
+
}
|
|
187
|
+
handleFile(action, filePath) {
|
|
188
|
+
const ext = extname(filePath).slice(1).toLowerCase();
|
|
189
|
+
const relativePath = relative(this.rootPath, filePath);
|
|
190
|
+
this.pendingChanges.set(relativePath, { action, relativePath, ext });
|
|
191
|
+
// Debounce 500ms — batch rapid changes
|
|
192
|
+
if (this.flushTimer)
|
|
193
|
+
clearTimeout(this.flushTimer);
|
|
194
|
+
this.flushTimer = setTimeout(() => this.flush(), 500);
|
|
195
|
+
}
|
|
196
|
+
async flush() {
|
|
197
|
+
const changes = Array.from(this.pendingChanges.values());
|
|
198
|
+
this.pendingChanges.clear();
|
|
199
|
+
if (changes.length === 0)
|
|
200
|
+
return;
|
|
201
|
+
this.emit('syncing', changes.length);
|
|
202
|
+
for (const change of changes) {
|
|
203
|
+
try {
|
|
204
|
+
if (change.action === 'delete') {
|
|
205
|
+
await this.relay.syncFileDelete(this.brandId, change.relativePath);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const fullPath = join(this.rootPath, change.relativePath);
|
|
209
|
+
if (!existsSync(fullPath))
|
|
210
|
+
continue;
|
|
211
|
+
const content = readFileSync(fullPath);
|
|
212
|
+
const hash = createHash('md5').update(content).digest('hex');
|
|
213
|
+
// Skip if unchanged
|
|
214
|
+
if (this.fileHashes.get(change.relativePath) === hash)
|
|
215
|
+
continue;
|
|
216
|
+
this.fileHashes.set(change.relativePath, hash);
|
|
217
|
+
await this.relay.syncFileUpload(this.brandId, change.relativePath, content, hash);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
console.error(`[folder-sync] Error syncing ${change.relativePath}: ${err.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.emit('synced', changes.length);
|
|
225
|
+
}
|
|
226
|
+
}
|