shraga 0.0.2 → 0.1.1
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 +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +60 -15
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DATA_DIR } from './paths.ts';
|
|
4
|
+
import { emitEvent } from './events/bus.ts';
|
|
5
|
+
|
|
6
|
+
const TAG = '[data-sync]';
|
|
7
|
+
const DEPLOYMENT_ID_FILE = '.deployment-id';
|
|
8
|
+
|
|
9
|
+
export class DataSyncOptions {
|
|
10
|
+
repoUrl = process.env.DATA_SYNC_REPO || '';
|
|
11
|
+
branch = process.env.DATA_SYNC_BRANCH || 'main';
|
|
12
|
+
deploymentId = process.env.APP_NAME || '';
|
|
13
|
+
/**
|
|
14
|
+
* EXPLICIT, deliberate opt-in. The mere PRESENCE of DATA_SYNC_REPO in the env is NOT enough to
|
|
15
|
+
* activate sync — a leaked/inherited DATA_SYNC_REPO (dev shell pollution, a copied .env, a data
|
|
16
|
+
* dir carrying a sync .git remote) can otherwise push a stray/dev boot's state to a shared repo.
|
|
17
|
+
* A real deployment must ALSO set DATA_SYNC_ENABLE=1. Dev/verify boots never sync/push.
|
|
18
|
+
*/
|
|
19
|
+
enabled = process.env.DATA_SYNC_ENABLE === '1' || process.env.DATA_SYNC_ENABLE === 'true';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class DataSync {
|
|
23
|
+
public options: DataSyncOptions;
|
|
24
|
+
private pending = new Set<string>();
|
|
25
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
+
private pushing = false;
|
|
27
|
+
private ready = false;
|
|
28
|
+
private warnedDisabled = false;
|
|
29
|
+
|
|
30
|
+
constructor(opts?: Partial<DataSyncOptions>) {
|
|
31
|
+
this.options = { ...new DataSyncOptions(), ...opts };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isEnabled(): boolean {
|
|
35
|
+
if (!this.options.repoUrl) return false;
|
|
36
|
+
if (!this.options.enabled) {
|
|
37
|
+
if (!this.warnedDisabled) { // isEnabled() runs per-write via trackWrite — warn once, not per write
|
|
38
|
+
this.warnedDisabled = true;
|
|
39
|
+
console.warn(`${TAG} DATA_SYNC_REPO is set but DATA_SYNC_ENABLE is not — sync DISABLED (guarding against leaked-env pushes). Set DATA_SYNC_ENABLE=1 to activate.`);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async init(): Promise<void> {
|
|
47
|
+
if (!this.isEnabled()) return;
|
|
48
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
49
|
+
const gitDir = path.join(DATA_DIR, '.git');
|
|
50
|
+
|
|
51
|
+
if (!existsSync(gitDir)) {
|
|
52
|
+
await this.git('init', '-b', this.options.branch);
|
|
53
|
+
await this.git('remote', 'add', 'origin', this.authedUrl());
|
|
54
|
+
this.ensureGitignore();
|
|
55
|
+
await this.configureGit();
|
|
56
|
+
// Try to pull remote first; if it exists, reset to it, then layer local changes on top
|
|
57
|
+
try {
|
|
58
|
+
await this.git('fetch', 'origin', this.options.branch);
|
|
59
|
+
await this.git('reset', '--soft', `origin/${this.options.branch}`);
|
|
60
|
+
// CRITICAL: restore remote files absent from this (possibly sparse) worktree before `add -A`,
|
|
61
|
+
// otherwise add -A stages them as deletions and the commit/push WIPES remote-only data on a
|
|
62
|
+
// fresh init. (Past incident: a sparse instance deleted 186 workspace files this way.)
|
|
63
|
+
// -z → NUL-delimited, unquoted paths (safe for names with spaces/unicode).
|
|
64
|
+
const missing = (await this.git('diff', '--name-only', '-z', '--diff-filter=D', 'HEAD'))
|
|
65
|
+
.split('\0').filter(Boolean);
|
|
66
|
+
for (const f of missing) {
|
|
67
|
+
await this.git('checkout', 'HEAD', '--', f).catch((err) =>
|
|
68
|
+
console.warn(`${TAG} Could not restore remote file ${f}:`, (err as Error).message));
|
|
69
|
+
}
|
|
70
|
+
console.log(`${TAG} Initialized from remote${missing.length ? ` (restored ${missing.length} remote file(s))` : ''}`);
|
|
71
|
+
} catch {
|
|
72
|
+
console.log(`${TAG} No remote branch yet, starting fresh`);
|
|
73
|
+
}
|
|
74
|
+
// Commit any local-only changes on top of remote
|
|
75
|
+
await this.git('add', '-A');
|
|
76
|
+
const status = await this.git('status', '--porcelain');
|
|
77
|
+
if (status.trim()) {
|
|
78
|
+
if (await this.guardMassDeletions('init merge')) {
|
|
79
|
+
console.error(`${TAG} Init merge aborted — mass deletion blocked`);
|
|
80
|
+
} else {
|
|
81
|
+
await this.git('commit', '-m', 'data-sync: merge local state');
|
|
82
|
+
await this.git('push', '-u', 'origin', this.options.branch).catch(err => {
|
|
83
|
+
console.warn(`${TAG} Initial push failed:`, (err as Error).message);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
console.log(`${TAG} Initialized git repo in data/`);
|
|
88
|
+
this.ready = true;
|
|
89
|
+
} else {
|
|
90
|
+
const current = (await this.git('remote', 'get-url', 'origin')).trim();
|
|
91
|
+
const expected = this.authedUrl();
|
|
92
|
+
if (current !== expected) {
|
|
93
|
+
await this.git('remote', 'set-url', 'origin', expected);
|
|
94
|
+
}
|
|
95
|
+
this.ensureGitignore();
|
|
96
|
+
if (!this.verifyDeploymentId()) return;
|
|
97
|
+
await this.untrackIgnored();
|
|
98
|
+
await this.configureGit();
|
|
99
|
+
this.ready = true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await this.pull();
|
|
103
|
+
// Defer heavy sync I/O (reads all tracked files + execSync) to avoid blocking
|
|
104
|
+
// WS connections and page loads during startup.
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
this.scanForConflictMarkers().catch(err => {
|
|
107
|
+
console.warn(`${TAG} Post-init conflict scan failed:`, (err as Error).message);
|
|
108
|
+
});
|
|
109
|
+
this.runIntegrityAudit();
|
|
110
|
+
}, 60_000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Compare HEAD against HEAD~1 to catch regressions. Notifies owner — never auto-reverts. */
|
|
114
|
+
private runIntegrityAudit(): void {
|
|
115
|
+
try {
|
|
116
|
+
const { audit } = require('./integrity-audit.ts');
|
|
117
|
+
const issues = audit('HEAD~1') as { kind: string; file: string; detail: string }[];
|
|
118
|
+
if (issues.length) {
|
|
119
|
+
console.warn(`${TAG} ⚠️ DATA INTEGRITY: ${issues.length} issue(s) detected after sync:`);
|
|
120
|
+
for (const { kind, file, detail } of issues) {
|
|
121
|
+
console.warn(`${TAG} ${kind} ${file} — ${detail}`);
|
|
122
|
+
}
|
|
123
|
+
const list = issues.slice(0, 20).map(i => `• [${i.kind}] ${i.file} — ${i.detail}`).join('\n');
|
|
124
|
+
this.notifyOwners(
|
|
125
|
+
`⚠️ Data integrity: ${issues.length} issue(s) detected after sync\n\n${list}` +
|
|
126
|
+
(issues.length > 20 ? `\n…and ${issues.length - 20} more` : '') +
|
|
127
|
+
`\n\nRecover: \`cd data && git revert HEAD\``,
|
|
128
|
+
).catch(err => console.warn(`${TAG} Integrity notify failed:`, (err as Error).message));
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.warn(`${TAG} Integrity audit skipped:`, (err as Error).message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Block commits that stage an unusual number of file deletions OR a large in-file content
|
|
136
|
+
* shrink (e.g. contacts.json 111→5 lines — a legit-looking normalization that wiped shared
|
|
137
|
+
* data). Notifies owner and aborts. */
|
|
138
|
+
private async guardMassDeletions(context: string): Promise<boolean> {
|
|
139
|
+
const fileThreshold = parseInt(process.env.DATA_SYNC_DELETIONS_BLOCK || '10', 10);
|
|
140
|
+
const shrinkThreshold = parseInt(process.env.DATA_SYNC_SHRINK_BLOCK || '50', 10);
|
|
141
|
+
try {
|
|
142
|
+
// (1) Mass FILE deletions.
|
|
143
|
+
const out = await this.git('diff', '--cached', '--name-only', '--diff-filter=D');
|
|
144
|
+
const deleted = out.split('\n').map(l => l.trim()).filter(Boolean);
|
|
145
|
+
if (deleted.length > fileThreshold) {
|
|
146
|
+
console.error(`${TAG} 🚫 BLOCKED mass deletion (${context}): ${deleted.length} file(s) — threshold is ${fileThreshold}`);
|
|
147
|
+
const list = deleted.slice(0, 20).map(f => `• ${f}`).join('\n');
|
|
148
|
+
await this.notifyOwners(
|
|
149
|
+
`🚫 BLOCKED mass deletion in data/ (${context}): ${deleted.length} file(s) staged for deletion (threshold: ${fileThreshold})\n\n${list}` +
|
|
150
|
+
(deleted.length > 20 ? `\n…and ${deleted.length - 20} more` : '') +
|
|
151
|
+
`\n\nCommit was aborted. Manual intervention needed.`,
|
|
152
|
+
);
|
|
153
|
+
await this.git('reset', 'HEAD').catch(() => {});
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// (2) Large in-file content SHRINK — a single tracked file losing > shrinkThreshold NET lines
|
|
158
|
+
// (deleted − added). numstat: "<added>\t<deleted>\t<file>"; binary files show "-\t-".
|
|
159
|
+
const numstat = await this.git('diff', '--cached', '--numstat');
|
|
160
|
+
const shrunk: string[] = [];
|
|
161
|
+
for (const line of numstat.split('\n').map(l => l.trim()).filter(Boolean)) {
|
|
162
|
+
const [addRaw, delRaw, file] = line.split('\t');
|
|
163
|
+
if (addRaw === '-' || delRaw === '-' || !file) continue; // binary
|
|
164
|
+
const net = (parseInt(delRaw, 10) || 0) - (parseInt(addRaw, 10) || 0);
|
|
165
|
+
if (net > shrinkThreshold) shrunk.push(`• ${file} (−${net} net lines)`);
|
|
166
|
+
}
|
|
167
|
+
if (shrunk.length) {
|
|
168
|
+
console.error(`${TAG} 🚫 BLOCKED large content shrink (${context}): ${shrunk.length} file(s) — net-removal threshold is ${shrinkThreshold}`);
|
|
169
|
+
await this.notifyOwners(
|
|
170
|
+
`🚫 BLOCKED large content shrink in data/ (${context}): a tracked file lost more than ${shrinkThreshold} net lines (guards against wiping shared data like contacts.json)\n\n${shrunk.slice(0, 20).join('\n')}` +
|
|
171
|
+
`\n\nCommit was aborted. If intended, raise DATA_SYNC_SHRINK_BLOCK or commit manually.`,
|
|
172
|
+
);
|
|
173
|
+
await this.git('reset', 'HEAD').catch(() => {});
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn(`${TAG} Destructive-change check failed:`, (err as Error).message);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async pull(): Promise<void> {
|
|
184
|
+
if (!this.isEnabled()) return;
|
|
185
|
+
try {
|
|
186
|
+
await this.git('fetch', 'origin', this.options.branch);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.warn(`${TAG} Fetch failed:`, (err as Error).message);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const localRef = (await this.git('rev-parse', 'HEAD').catch(() => '')).trim();
|
|
193
|
+
const remoteRef = (await this.git('rev-parse', `origin/${this.options.branch}`).catch(() => '')).trim();
|
|
194
|
+
if (!remoteRef) {
|
|
195
|
+
console.log(`${TAG} Remote branch not found, skipping pull`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (localRef === remoteRef) {
|
|
199
|
+
console.log(`${TAG} Already up to date`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Stash dirty + untracked files before merging (untracked can block merge if remote adds same paths)
|
|
204
|
+
const dirty = !!(await this.git('status', '--porcelain')).trim();
|
|
205
|
+
if (dirty) {
|
|
206
|
+
try {
|
|
207
|
+
await this.git('stash', 'push', '--include-untracked', '-m', 'data-sync: pre-pull stash');
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.warn(`${TAG} Stash failed, skipping pull:`, (err as Error).message);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await this.git('merge', `origin/${this.options.branch}`, '--no-edit');
|
|
216
|
+
console.log(`${TAG} Pulled latest`);
|
|
217
|
+
} catch {
|
|
218
|
+
const conflicted = await this.getConflictedFiles();
|
|
219
|
+
if (conflicted.length) {
|
|
220
|
+
console.log(`${TAG} Merge conflicts in ${conflicted.length} file(s), resolving...`);
|
|
221
|
+
await this.resolveConflicts(conflicted);
|
|
222
|
+
} else {
|
|
223
|
+
console.error(`${TAG} Merge failed for unknown reason, aborting`);
|
|
224
|
+
await this.git('merge', '--abort').catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (dirty) await this.git('stash', 'pop').catch(err => {
|
|
229
|
+
console.error(`${TAG} Stash pop failed — local changes may be stuck in git stash:`, (err as Error).message);
|
|
230
|
+
});
|
|
231
|
+
this.rebuildLog().catch(() => {});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
trackWrite(relativePath: string): void {
|
|
235
|
+
if (!this.isEnabled() || !this.ready) return;
|
|
236
|
+
this.pending.add(relativePath);
|
|
237
|
+
if (this.timer) clearTimeout(this.timer);
|
|
238
|
+
this.timer = setTimeout(() => this.flush(), 2000);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async getLog(limit = 50): Promise<object[]> {
|
|
242
|
+
const logPath = path.join(DATA_DIR, 'git-log.json');
|
|
243
|
+
if (existsSync(logPath)) {
|
|
244
|
+
try { return JSON.parse(readFileSync(logPath, 'utf-8')); } catch { /* rebuild */ }
|
|
245
|
+
}
|
|
246
|
+
return this.rebuildLog(limit);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async rebuildLog(limit = 50): Promise<object[]> {
|
|
250
|
+
if (!existsSync(path.join(DATA_DIR, '.git'))) return [];
|
|
251
|
+
try {
|
|
252
|
+
const raw = await this.git('log', `--max-count=${limit}`, '--pretty=format:%H|%aI|%an|%s', '--name-only');
|
|
253
|
+
const entries: object[] = [];
|
|
254
|
+
for (const block of raw.split('\n\n').filter(Boolean)) {
|
|
255
|
+
const [header, ...fileLines] = block.split('\n');
|
|
256
|
+
const [hash, date, author, message] = header.split('|', 4);
|
|
257
|
+
entries.push({ hash: hash.slice(0, 8), date, author, message, files: fileLines.filter(Boolean) });
|
|
258
|
+
}
|
|
259
|
+
writeFileSync(path.join(DATA_DIR, 'git-log.json'), JSON.stringify(entries, null, 2));
|
|
260
|
+
return entries;
|
|
261
|
+
} catch { return []; }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async flush(): Promise<void> {
|
|
265
|
+
if (!this.pending.size || this.pushing) return;
|
|
266
|
+
this.pushing = true;
|
|
267
|
+
const files = [...this.pending];
|
|
268
|
+
this.pending.clear();
|
|
269
|
+
this.timer = null;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
for (const f of files) {
|
|
273
|
+
const abs = path.join(DATA_DIR, f);
|
|
274
|
+
if (existsSync(abs)) {
|
|
275
|
+
await this.git('add', f);
|
|
276
|
+
} else {
|
|
277
|
+
await this.git('rm', '--cached', f).catch(() => {});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const status = await this.git('status', '--porcelain');
|
|
282
|
+
if (!status.trim()) { this.pushing = false; return; }
|
|
283
|
+
|
|
284
|
+
if (await this.guardMassDeletions('flush')) { this.pushing = false; return; }
|
|
285
|
+
const msg = await this.generateCommitMessage(files);
|
|
286
|
+
await this.git('commit', '-m', msg).catch(() => {});
|
|
287
|
+
const ahead = await this.git('rev-list', '--count', `origin/${this.options.branch}..HEAD`).catch(() => '0');
|
|
288
|
+
if (parseInt(ahead.trim()) === 0) { this.pushing = false; return; }
|
|
289
|
+
await this.git('push', 'origin', this.options.branch).catch(async (err) => {
|
|
290
|
+
console.warn(`${TAG} Push failed, pulling first:`, (err as Error).message);
|
|
291
|
+
await this.pull();
|
|
292
|
+
await this.git('push', 'origin', this.options.branch);
|
|
293
|
+
});
|
|
294
|
+
console.log(`${TAG} Pushed: ${msg}`);
|
|
295
|
+
this.rebuildLog().catch(() => {});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error(`${TAG} Commit/push failed:`, (err as Error).message);
|
|
298
|
+
}
|
|
299
|
+
this.pushing = false;
|
|
300
|
+
if (this.pending.size && !this.timer) {
|
|
301
|
+
this.timer = setTimeout(() => this.flush(), 2000);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async generateCommitMessage(files: string[]): Promise<string> {
|
|
306
|
+
const fallback = `sync: ${files.join(', ')}`;
|
|
307
|
+
try {
|
|
308
|
+
const diff = await this.git('diff', '--cached', '--stat').catch(() => '');
|
|
309
|
+
const diffContent = await this.git('diff', '--cached', '--no-color', '-U2').catch(() => '');
|
|
310
|
+
if (!diffContent.trim()) return fallback;
|
|
311
|
+
const truncated = diffContent.slice(0, 3000);
|
|
312
|
+
const msg = await this.askClaude(
|
|
313
|
+
'Write a concise git commit message (max 72 chars, no quotes, no prefix like "feat:" or "sync:") for this change to an AI agent\'s behavioral config.\n' +
|
|
314
|
+
`Files: ${files.join(', ')}\nStats: ${diff}\n\nDiff:\n${truncated}`,
|
|
315
|
+
'claude-haiku-4-5-20251001', 100,
|
|
316
|
+
);
|
|
317
|
+
const line = msg.split('\n')[0].trim().slice(0, 72);
|
|
318
|
+
return line || fallback;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.warn(`${TAG} LLM commit msg failed:`, (err as Error).message);
|
|
321
|
+
return fallback;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private async resolveConflicts(files: string[]): Promise<void> {
|
|
326
|
+
const realFiles = files.filter(f => {
|
|
327
|
+
const abs = path.join(DATA_DIR, f);
|
|
328
|
+
try { return existsSync(abs) && !statSync(abs).isDirectory(); } catch { return false; }
|
|
329
|
+
});
|
|
330
|
+
if (!realFiles.length) {
|
|
331
|
+
console.warn(`${TAG} No resolvable conflicted files, aborting merge`);
|
|
332
|
+
await this.git('merge', '--abort').catch(() => {});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sections = realFiles.map(f => {
|
|
337
|
+
const content = readFileSync(path.join(DATA_DIR, f), 'utf-8');
|
|
338
|
+
return `=== FILE: ${f} ===\n${content}`;
|
|
339
|
+
}).join('\n\n');
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const resolved = await this.askClaude(
|
|
343
|
+
'You are resolving git merge conflicts in an AI agent\'s data directory.\n' +
|
|
344
|
+
'CRITICAL RULES:\n' +
|
|
345
|
+
'- NEVER delete entries/records that exist on either side — always keep both (union merge)\n' +
|
|
346
|
+
'- NEVER change "enabled" flags, field values, or settings that aren\'t inside a conflict marker\n' +
|
|
347
|
+
'- NEVER "simplify" or "clean up" — your ONLY job is to merge the conflicting sections\n' +
|
|
348
|
+
'- For JSON arrays (like schedules): keep ALL entries from both sides, deduplicate by "id"\n' +
|
|
349
|
+
'- When the same field has different values on each side: keep the one that preserves more data/state\n' +
|
|
350
|
+
'- Bias toward UNION (keep everything) over SIMPLIFICATION (remove things)\n\n' +
|
|
351
|
+
'For each file:\n' +
|
|
352
|
+
'1. Classify: "trivial" (both sides added content, or one is superset) or "ambiguous" (same field changed differently)\n' +
|
|
353
|
+
'2. Resolve it following the rules above.\n' +
|
|
354
|
+
'Output format:\n' +
|
|
355
|
+
'<file path="<path>" complexity="trivial|ambiguous" reason="short explanation">\n<resolved content>\n</file>\n' +
|
|
356
|
+
'Output ONLY the resolved files in this format.\n\n' + sections,
|
|
357
|
+
'claude-opus-4-6',
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const parsed = this.parseResolvedFiles(resolved);
|
|
361
|
+
if (!parsed.length) throw new Error('Failed to parse resolved files from Claude response');
|
|
362
|
+
|
|
363
|
+
const ambiguous: string[] = [];
|
|
364
|
+
for (const [filePath, content, complexity, reason] of parsed) {
|
|
365
|
+
if (!realFiles.includes(filePath)) continue;
|
|
366
|
+
const violations = this.validateResolution(filePath, content, realFiles);
|
|
367
|
+
if (violations.length) {
|
|
368
|
+
ambiguous.push(`• ${filePath}: VALIDATION FAILED — ${violations.join('; ')}`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
writeFileSync(path.join(DATA_DIR, filePath), content);
|
|
372
|
+
await this.git('add', filePath);
|
|
373
|
+
if (complexity === 'ambiguous') ambiguous.push(`• ${filePath}: ${reason}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
await this.git('commit', '-m', `data-sync: resolved ${realFiles.length} merge conflict(s)`);
|
|
377
|
+
await this.git('push', 'origin', this.options.branch);
|
|
378
|
+
console.log(`${TAG} Resolved ${realFiles.length} conflict(s) and pushed`);
|
|
379
|
+
|
|
380
|
+
if (ambiguous.length) {
|
|
381
|
+
await this.notifyOwners(
|
|
382
|
+
`⚠️ Merge conflict auto-resolved (needs review)\n\n` +
|
|
383
|
+
`${ambiguous.join('\n')}\n\n` +
|
|
384
|
+
`These were ambiguous — I picked what looked best but please verify.\n` +
|
|
385
|
+
`Recovery: \`cd data && git revert HEAD\``,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error(`${TAG} Conflict resolution failed:`, (err as Error).message);
|
|
390
|
+
await this.git('merge', '--abort').catch(() => {});
|
|
391
|
+
await this.notifyOwners(
|
|
392
|
+
`🚨 Merge conflict resolution FAILED in data/\n\n` +
|
|
393
|
+
`Files: ${realFiles.join(', ')}\n` +
|
|
394
|
+
`Error: ${(err as Error).message}\n\n` +
|
|
395
|
+
`Merge was aborted. Manual intervention needed.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Scan all tracked files for leftover conflict markers (<<<<<<< / ======= / >>>>>>>) */
|
|
401
|
+
async scanForConflictMarkers(): Promise<void> {
|
|
402
|
+
if (!existsSync(path.join(DATA_DIR, '.git'))) return;
|
|
403
|
+
try {
|
|
404
|
+
const tracked = (await this.git('ls-files')).split('\n').filter(Boolean);
|
|
405
|
+
const conflicted: string[] = [];
|
|
406
|
+
for (const f of tracked) {
|
|
407
|
+
const abs = path.join(DATA_DIR, f);
|
|
408
|
+
try {
|
|
409
|
+
if (!existsSync(abs) || statSync(abs).isDirectory()) continue;
|
|
410
|
+
const content = readFileSync(abs, 'utf-8');
|
|
411
|
+
if (/^<{7} /m.test(content)) conflicted.push(f);
|
|
412
|
+
} catch { /* skip unreadable */ }
|
|
413
|
+
}
|
|
414
|
+
if (!conflicted.length) return;
|
|
415
|
+
|
|
416
|
+
console.warn(`${TAG} Found conflict markers in ${conflicted.length} file(s): ${conflicted.join(', ')}`);
|
|
417
|
+
|
|
418
|
+
const sections = conflicted.map(f => {
|
|
419
|
+
const content = readFileSync(path.join(DATA_DIR, f), 'utf-8');
|
|
420
|
+
return `=== FILE: ${f} ===\n${content}`;
|
|
421
|
+
}).join('\n\n');
|
|
422
|
+
|
|
423
|
+
const resolved = await this.askClaude(
|
|
424
|
+
'These files in our agent data directory have leftover git merge conflict markers.\n' +
|
|
425
|
+
'CRITICAL RULES:\n' +
|
|
426
|
+
'- NEVER delete entries/records that exist on either side — always keep both (union merge)\n' +
|
|
427
|
+
'- NEVER change "enabled" flags, field values, or settings that aren\'t inside a conflict marker\n' +
|
|
428
|
+
'- NEVER "simplify" or "clean up" — your ONLY job is to resolve the conflicting sections\n' +
|
|
429
|
+
'- For JSON arrays (like schedules): keep ALL entries from both sides, deduplicate by "id"\n' +
|
|
430
|
+
'- Bias toward UNION (keep everything) over SIMPLIFICATION (remove things)\n\n' +
|
|
431
|
+
'For each file:\n' +
|
|
432
|
+
'1. Classify: "trivial" or "ambiguous"\n' +
|
|
433
|
+
'2. Produce the correct resolved content following the rules above.\n' +
|
|
434
|
+
'Output format:\n' +
|
|
435
|
+
'<file path="<path>" complexity="trivial|ambiguous" reason="short explanation">\n<resolved content>\n</file>\n' +
|
|
436
|
+
'Output ONLY the resolved files.\n\n' + sections,
|
|
437
|
+
'claude-opus-4-6',
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const parsed = this.parseResolvedFiles(resolved);
|
|
441
|
+
if (!parsed.length) {
|
|
442
|
+
console.error(`${TAG} Could not parse conflict resolution, notifying owners`);
|
|
443
|
+
await this.notifyOwners(
|
|
444
|
+
`🚨 Found conflict markers in data/ but auto-resolution failed.\n\nFiles: ${conflicted.join(', ')}\n\nManual fix needed.`,
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const ambiguous: string[] = [];
|
|
450
|
+
for (const [filePath, content, complexity, reason] of parsed) {
|
|
451
|
+
if (!conflicted.includes(filePath)) continue;
|
|
452
|
+
const violations = this.validateResolution(filePath, content, conflicted);
|
|
453
|
+
if (violations.length) {
|
|
454
|
+
ambiguous.push(`• ${filePath}: VALIDATION FAILED — ${violations.join('; ')}`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
writeFileSync(path.join(DATA_DIR, filePath), content);
|
|
458
|
+
await this.git('add', filePath);
|
|
459
|
+
if (complexity === 'ambiguous') ambiguous.push(`• ${filePath}: ${reason}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
await this.git('commit', '-m', `data-sync: fix leftover conflict markers in ${conflicted.join(', ')}`);
|
|
463
|
+
await this.git('push', 'origin', this.options.branch).catch(err => {
|
|
464
|
+
console.warn(`${TAG} Push after conflict fix failed:`, (err as Error).message);
|
|
465
|
+
});
|
|
466
|
+
console.log(`${TAG} Fixed conflict markers in ${conflicted.length} file(s)`);
|
|
467
|
+
|
|
468
|
+
const severity = ambiguous.length ? '⚠️' : '✅';
|
|
469
|
+
await this.notifyOwners(
|
|
470
|
+
`${severity} Fixed leftover conflict markers in data/\n\n` +
|
|
471
|
+
conflicted.map(f => `• ${f}`).join('\n') +
|
|
472
|
+
(ambiguous.length ? `\n\nAmbiguous resolutions (please verify):\n${ambiguous.join('\n')}\n\nRecovery: \`cd data && git revert HEAD\`` : ''),
|
|
473
|
+
);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.error(`${TAG} Conflict marker scan failed:`, (err as Error).message);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private parseResolvedFiles(output: string): [path: string, content: string, complexity: string, reason: string][] {
|
|
480
|
+
const results: [string, string, string, string][] = [];
|
|
481
|
+
const re = /<file path="([^"]+)"(?:\s+complexity="([^"]*)")?(?:\s+reason="([^"]*)")?\s*>\n([\s\S]*?)\n<\/file>/g;
|
|
482
|
+
let match;
|
|
483
|
+
while ((match = re.exec(output)) !== null) {
|
|
484
|
+
results.push([match[1], match[4] + '\n', match[2] || 'trivial', match[3] || '']);
|
|
485
|
+
}
|
|
486
|
+
return results;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Validate resolved content against the original to catch bad merges. */
|
|
490
|
+
private validateResolution(filePath: string, resolved: string, _allFiles: string[]): string[] {
|
|
491
|
+
const violations: string[] = [];
|
|
492
|
+
const abs = path.join(DATA_DIR, filePath);
|
|
493
|
+
if (!existsSync(abs)) return violations;
|
|
494
|
+
|
|
495
|
+
// Still has conflict markers
|
|
496
|
+
if (/^<{7} /m.test(resolved)) violations.push('still contains conflict markers');
|
|
497
|
+
|
|
498
|
+
// For JSON files: check no entries were lost
|
|
499
|
+
if (filePath.endsWith('.json')) {
|
|
500
|
+
try {
|
|
501
|
+
const original = JSON.parse(readFileSync(abs, 'utf-8').replace(/^<{7}.*$|^={7}$|^>{7}.*$/gm, ''));
|
|
502
|
+
const result = JSON.parse(resolved);
|
|
503
|
+
if (Array.isArray(original) && Array.isArray(result)) {
|
|
504
|
+
const origIds = new Set(original.map((e: { id?: string }) => e.id).filter(Boolean));
|
|
505
|
+
const resultIds = new Set(result.map((e: { id?: string }) => e.id).filter(Boolean));
|
|
506
|
+
const lost = [...origIds].filter(id => !resultIds.has(id));
|
|
507
|
+
if (lost.length) violations.push(`lost entries: ${lost.join(', ')}`);
|
|
508
|
+
|
|
509
|
+
// Check enabled flags weren't flipped
|
|
510
|
+
const origEnabled = new Map(original.map((e: { id?: string; enabled?: boolean }) => [e.id, e.enabled]));
|
|
511
|
+
for (const entry of result as { id?: string; enabled?: boolean }[]) {
|
|
512
|
+
if (entry.id && origEnabled.has(entry.id) && origEnabled.get(entry.id) === true && entry.enabled === false) {
|
|
513
|
+
violations.push(`"enabled" flipped to false for ${entry.id}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch { /* can't parse — conflicted JSON, skip structural checks */ }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return violations;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async notifyOwners(text: string): Promise<void> {
|
|
524
|
+
// No Slack coupling here — resolve owner contacts and publish a deploy notice on the event bus.
|
|
525
|
+
// The Slack feature (slackFeature) subscribes and DMs each owner. Owner resolution uses the
|
|
526
|
+
// contacts store only, so data-sync stays transport-agnostic.
|
|
527
|
+
const { getAll } = await import('./contacts.ts');
|
|
528
|
+
const ownerEmails = (process.env.OWNERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
529
|
+
const owners = getAll()
|
|
530
|
+
.filter(c => c.slackIds.length > 0 && c.emails.some(e => ownerEmails.includes(e.toLowerCase())))
|
|
531
|
+
.map(c => ({ name: c.name, slackId: c.slackIds[0] }));
|
|
532
|
+
if (!owners.length) {
|
|
533
|
+
console.warn(`${TAG} No owners (OWNERS env) with Slack IDs found, skipping notification`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
emitEvent('data-sync', { kind: 'deploy', owners, text });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private async askClaude(prompt: string, model = 'claude-sonnet-4-6', maxTokens = 8192): Promise<string> {
|
|
540
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
541
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
|
|
542
|
+
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: {
|
|
545
|
+
'x-api-key': apiKey,
|
|
546
|
+
'anthropic-version': '2023-06-01',
|
|
547
|
+
'content-type': 'application/json',
|
|
548
|
+
},
|
|
549
|
+
body: JSON.stringify({
|
|
550
|
+
model, max_tokens: maxTokens,
|
|
551
|
+
messages: [{ role: 'user', content: prompt }],
|
|
552
|
+
}),
|
|
553
|
+
});
|
|
554
|
+
if (!resp.ok) throw new Error(`Anthropic API ${resp.status}: ${await resp.text()}`);
|
|
555
|
+
const data = await resp.json() as { content: { type: string; text: string }[] };
|
|
556
|
+
return data.content.find(b => b.type === 'text')?.text || '';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private async getConflictedFiles(): Promise<string[]> {
|
|
560
|
+
const output = await this.git('diff', '--name-only', '--diff-filter=U');
|
|
561
|
+
return output.split('\n').map(l => l.trim()).filter(Boolean);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Canonical ignore entries. Kept in code so all envs converge on the same list. */
|
|
565
|
+
private static readonly GITIGNORE_ENTRIES = [
|
|
566
|
+
'conversations/', 'sessions.json', 'gmail-*.json',
|
|
567
|
+
'gmail-thread-sessions.json', 'slack/', 'slack-*.json',
|
|
568
|
+
'uploads/', 'repos/', '.tmp/', 'schedules.json.bak', 'git-log.json',
|
|
569
|
+
'.internal-token', 'comms-log.jsonl', 'sessions/', 'unread/', '.DS_Store',
|
|
570
|
+
'scheduler/', '.mcp-catalog.json',
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
/** Write or refresh .gitignore, appending any canonical entries it's missing (idempotent). */
|
|
574
|
+
private ensureGitignore(): void {
|
|
575
|
+
const gitignorePath = path.join(DATA_DIR, '.gitignore');
|
|
576
|
+
const existing = existsSync(gitignorePath)
|
|
577
|
+
? readFileSync(gitignorePath, 'utf-8').split('\n').map(l => l.trim())
|
|
578
|
+
: [];
|
|
579
|
+
const missing = DataSync.GITIGNORE_ENTRIES.filter(e => !existing.includes(e));
|
|
580
|
+
if (!missing.length && existsSync(gitignorePath)) return;
|
|
581
|
+
const lines = [...existing.filter(Boolean), ...missing];
|
|
582
|
+
writeFileSync(gitignorePath, lines.join('\n') + '\n');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Commit a refreshed .gitignore and untrack any committed files that now match it. */
|
|
586
|
+
private async untrackIgnored(): Promise<void> {
|
|
587
|
+
await this.git('add', '.gitignore').catch(() => {});
|
|
588
|
+
const out = await this.git('ls-files', '-i', '-c', '--exclude-standard').catch(() => '');
|
|
589
|
+
// Only untrack files that actually exist on disk AND match .gitignore.
|
|
590
|
+
// git ls-files -i can falsely report tracked files missing from the worktree
|
|
591
|
+
// (remote-only files restored during init). Removing those wipes remote data.
|
|
592
|
+
const files = out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
593
|
+
.filter(f => existsSync(path.join(DATA_DIR, f)));
|
|
594
|
+
if (files.length) {
|
|
595
|
+
await this.git('rm', '--cached', '--', ...files).catch(err => {
|
|
596
|
+
console.warn(`${TAG} Untrack ignored files failed:`, (err as Error).message);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
if (!(await this.git('diff', '--cached', '--name-only')).trim()) return;
|
|
600
|
+
if (await this.guardMassDeletions('untrackIgnored')) return;
|
|
601
|
+
const msg = files.length
|
|
602
|
+
? `data-sync: refresh .gitignore, untrack ${files.length} now-ignored file(s)`
|
|
603
|
+
: 'data-sync: refresh .gitignore';
|
|
604
|
+
await this.git('commit', '-m', msg).catch(() => {});
|
|
605
|
+
await this.git('push', 'origin', this.options.branch).catch(err => {
|
|
606
|
+
console.warn(`${TAG} Push after untrack failed:`, (err as Error).message);
|
|
607
|
+
});
|
|
608
|
+
console.log(`${TAG} ${msg}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Verify this instance owns the remote data repo. Write ID on first run, block on mismatch. */
|
|
612
|
+
private verifyDeploymentId(): boolean {
|
|
613
|
+
const idPath = path.join(DATA_DIR, DEPLOYMENT_ID_FILE);
|
|
614
|
+
const localId = this.options.deploymentId;
|
|
615
|
+
if (!localId) {
|
|
616
|
+
console.warn(`${TAG} No APP_NAME set — deployment identity guard disabled`);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
if (!existsSync(idPath)) {
|
|
620
|
+
writeFileSync(idPath, localId + '\n');
|
|
621
|
+
console.log(`${TAG} Wrote deployment identity: ${localId}`);
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
const remoteId = readFileSync(idPath, 'utf-8').trim();
|
|
625
|
+
if (remoteId === localId) return true;
|
|
626
|
+
console.error(`${TAG} 🚫 DEPLOYMENT IDENTITY MISMATCH: local="${localId}" remote="${remoteId}" — refusing to sync`);
|
|
627
|
+
this.notifyOwners(
|
|
628
|
+
`🚫 Deployment identity mismatch!\n\nLocal: \`${localId}\`\nData dir: \`${remoteId}\`\n\nA different shraga instance tried to sync to this data repo. Push blocked.`,
|
|
629
|
+
).catch(err => console.warn(`${TAG} Identity mismatch notify failed:`, (err as Error).message));
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private async configureGit(): Promise<void> {
|
|
634
|
+
const name = process.env.APP_NAME || 'shraga';
|
|
635
|
+
await this.git('config', 'user.name', `${name} agent`).catch(() => {});
|
|
636
|
+
await this.git('config', 'user.email', `agent@${name}.local`).catch(() => {});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private authedUrl(): string {
|
|
640
|
+
const url = this.options.repoUrl;
|
|
641
|
+
const token = process.env.GITHUB_TOKEN;
|
|
642
|
+
if (token && url.startsWith('https://')) {
|
|
643
|
+
return url.replace('https://', `https://x-access-token:${token}@`);
|
|
644
|
+
}
|
|
645
|
+
return url;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private async git(...args: string[]): Promise<string> {
|
|
649
|
+
return this.spawn('git', args, DATA_DIR);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private async spawn(cmd: string, args: string[], cwd = DATA_DIR): Promise<string> {
|
|
653
|
+
const proc = Bun.spawn([cmd, ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
654
|
+
const [stdout, stderr] = await Promise.all([
|
|
655
|
+
new Response(proc.stdout).text(),
|
|
656
|
+
new Response(proc.stderr).text(),
|
|
657
|
+
]);
|
|
658
|
+
const code = await proc.exited;
|
|
659
|
+
if (code !== 0) throw new Error(`${cmd} ${args[0]} failed (${code}): ${stderr.trim()}`);
|
|
660
|
+
return stdout;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export const dataSync = new DataSync();
|