botrun-msync 0.1.0
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 +234 -0
- package/package.json +31 -0
- package/src/bin.mjs +100 -0
- package/src/commands/config/add-scope.mjs +13 -0
- package/src/commands/config/remove-scope.mjs +11 -0
- package/src/commands/config/show.mjs +5 -0
- package/src/commands/memory/init.mjs +72 -0
- package/src/commands/memory/scopes.mjs +27 -0
- package/src/commands/memory/sync.mjs +54 -0
- package/src/config.mjs +30 -0
- package/src/git/github.mjs +7 -0
- package/src/git/gitlab.mjs +3 -0
- package/src/git/provider.mjs +23 -0
- package/src/git-cmd.mjs +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# botrun-msync — Git-backed Memory Sync CLI for Agents
|
|
2
|
+
|
|
3
|
+
`bms` manages persistent memory for AI agents across ephemeral VMs. Memories are stored as files in Git repos (GitHub / GitLab), and `bms` handles the git plumbing — clone, sync, and scope management. Agents read/write memory files directly using their own tools.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx botrun-msync --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. Add a memory scope (bind token via env var name)
|
|
15
|
+
npx botrun-msync config add-scope my-notes \
|
|
16
|
+
--repo github.com/your-org/agent-memory \
|
|
17
|
+
--token-env MY_GITHUB_TOKEN \
|
|
18
|
+
--description "My personal notes" \
|
|
19
|
+
--access readwrite
|
|
20
|
+
|
|
21
|
+
# 2. Set the token
|
|
22
|
+
export MY_GITHUB_TOKEN=ghp_xxxxx
|
|
23
|
+
|
|
24
|
+
# 3. Clone the repo
|
|
25
|
+
npx botrun-msync memory init
|
|
26
|
+
|
|
27
|
+
# 4. Agent reads/writes files at the local path...
|
|
28
|
+
|
|
29
|
+
# 5. Push changes back
|
|
30
|
+
npx botrun-msync memory sync
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Concepts
|
|
34
|
+
|
|
35
|
+
### Scope
|
|
36
|
+
|
|
37
|
+
A **scope** is a logical name that maps to a git repo. Each agent can have multiple scopes pointing to different repos.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx botrun-msync config add-scope my-notes \
|
|
41
|
+
--repo github.com/org/my-memory \
|
|
42
|
+
--token-env BM_TOKEN_NOTES \
|
|
43
|
+
--description "Personal research notes" \
|
|
44
|
+
--access readwrite
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Multi-Repo Architecture
|
|
48
|
+
|
|
49
|
+
Different scopes can point to different repos. Permissions are controlled by Git provider tokens — not by `bms`. Each scope binds to its own token via `--token-env`, enabling per-repo permission control.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Director agent setup:
|
|
53
|
+
# Read-write token for personal repo
|
|
54
|
+
npx botrun-msync config add-scope director \
|
|
55
|
+
--repo github.com/org/director-memory \
|
|
56
|
+
--token-env BM_TOKEN_DIRECTOR \
|
|
57
|
+
--description "Director personal research" \
|
|
58
|
+
--access readwrite
|
|
59
|
+
|
|
60
|
+
# Read-only token for team repos
|
|
61
|
+
npx botrun-msync config add-scope team1 \
|
|
62
|
+
--repo github.com/org/team1-memory \
|
|
63
|
+
--token-env BM_TOKEN_TEAMS \
|
|
64
|
+
--description "Team 1 memory" \
|
|
65
|
+
--access readonly
|
|
66
|
+
|
|
67
|
+
npx botrun-msync config add-scope team2 \
|
|
68
|
+
--repo github.com/org/team2-memory \
|
|
69
|
+
--token-env BM_TOKEN_TEAMS \
|
|
70
|
+
--description "Team 2 memory" \
|
|
71
|
+
--access readonly
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Create separate GitHub Fine-grained PATs with different permissions:
|
|
75
|
+
- `BM_TOKEN_DIRECTOR` → Contents: Read and write (only `director-memory` repo)
|
|
76
|
+
- `BM_TOKEN_TEAMS` → Contents: Read-only (only `team1-memory` + `team2-memory` repos)
|
|
77
|
+
|
|
78
|
+
This way, even if a user modifies the config, they can't write to repos their token doesn't allow.
|
|
79
|
+
|
|
80
|
+
## Config
|
|
81
|
+
|
|
82
|
+
### Base Path
|
|
83
|
+
|
|
84
|
+
All `bms` data lives under a single base directory:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
~/.botrun/bm/ ← default base path
|
|
88
|
+
├── config.json ← scope definitions
|
|
89
|
+
└── data/
|
|
90
|
+
├── my-notes/ ← git clone of my-notes scope
|
|
91
|
+
├── team1/ ← git clone of team1 scope
|
|
92
|
+
└── team2/ ← git clone of team2 scope
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Override with CLI option or environment variable:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npx botrun-msync --bm-path /tmp/test-bm memory init # CLI option (highest priority)
|
|
99
|
+
BM_PATH=/custom/path npx botrun-msync memory init # environment variable
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Priority: `--bm-path` > `BM_PATH` > `~/.botrun/bm/`
|
|
103
|
+
|
|
104
|
+
### Config File
|
|
105
|
+
|
|
106
|
+
Located at `<BM_PATH>/config.json` (default: `~/.botrun/bm/config.json`).
|
|
107
|
+
|
|
108
|
+
Override config path independently with: `BM_CONFIG=/path/to/config.json`
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"scopes": {
|
|
113
|
+
"my-notes": {
|
|
114
|
+
"repo": "github.com/org/member1-memory",
|
|
115
|
+
"token_env": "BM_TOKEN_NOTES",
|
|
116
|
+
"description": "Personal research notes",
|
|
117
|
+
"access": "readwrite"
|
|
118
|
+
},
|
|
119
|
+
"team1": {
|
|
120
|
+
"repo": "github.com/org/team1-memory",
|
|
121
|
+
"branch": "dev",
|
|
122
|
+
"token_env": "BM_TOKEN_TEAMS",
|
|
123
|
+
"description": "Team 1 memory",
|
|
124
|
+
"access": "readonly"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| Field | Required | Description |
|
|
131
|
+
|-------|----------|-------------|
|
|
132
|
+
| `repo` | yes | Git repo URL (without `https://`) |
|
|
133
|
+
| `branch` | no | Git branch to use. Omit = repo default branch |
|
|
134
|
+
| `token_env` | no | Env var name for this scope's token (for per-repo permission control) |
|
|
135
|
+
| `description` | no | Description for agent context |
|
|
136
|
+
| `access` | no | Access hint for agent: `readwrite` or `readonly` (default: `readwrite`) |
|
|
137
|
+
| `provider` | no | `github` or `gitlab`. Auto-detected from URL |
|
|
138
|
+
|
|
139
|
+
### Config Commands
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx botrun-msync config add-scope <name> --repo <url> [--branch <branch>] [--token-env <envVar>] [--description <text>] [--access <mode>]
|
|
143
|
+
npx botrun-msync config remove-scope <name>
|
|
144
|
+
npx botrun-msync config show
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Environment Variables
|
|
148
|
+
|
|
149
|
+
| Variable | Purpose |
|
|
150
|
+
|----------|---------|
|
|
151
|
+
| `BM_PATH` | Base directory for all bm data (default: `~/.botrun/bm`) |
|
|
152
|
+
| `BM_CONFIG` | Config file path (overrides `<BM_PATH>/config.json`) |
|
|
153
|
+
|
|
154
|
+
Each scope's token is configured via `--token-env`, which points to an environment variable name. There are no global token variables — every scope must declare its own.
|
|
155
|
+
|
|
156
|
+
## Memory Commands
|
|
157
|
+
|
|
158
|
+
### `npx botrun-msync memory init`
|
|
159
|
+
|
|
160
|
+
Clones all configured scope repos to `<BM_PATH>/data/<scope-name>/`. If already cloned, pulls latest.
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"scopes": {
|
|
165
|
+
"my-notes": { "local": "/root/.botrun/bm/data/my-notes" },
|
|
166
|
+
"team1": { "local": "/root/.botrun/bm/data/team1" }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### `npx botrun-msync memory scopes`
|
|
172
|
+
|
|
173
|
+
Lists all scopes with their repo, description, access, and local filesystem path.
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"scopes": {
|
|
178
|
+
"my-notes": {
|
|
179
|
+
"repo": "github.com/org/member1-memory",
|
|
180
|
+
"description": "Personal research notes",
|
|
181
|
+
"access": "readwrite",
|
|
182
|
+
"local": "/root/.botrun/bm/data/my-notes"
|
|
183
|
+
},
|
|
184
|
+
"team1": {
|
|
185
|
+
"repo": "github.com/org/team1-memory",
|
|
186
|
+
"description": "Team 1 memory",
|
|
187
|
+
"access": "readonly",
|
|
188
|
+
"local": "/root/.botrun/bm/data/team1"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### `npx botrun-msync memory sync`
|
|
195
|
+
|
|
196
|
+
Commits and pushes all changed memory files back to remote repos.
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{
|
|
200
|
+
"synced": ["my-notes"],
|
|
201
|
+
"skipped": ["team1"]
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## JSON Output
|
|
206
|
+
|
|
207
|
+
All commands output structured JSON, including `--help`:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npx botrun-msync --help
|
|
211
|
+
npx botrun-msync config --help
|
|
212
|
+
npx botrun-msync memory --help
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Agent Lifecycle
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
VM starts
|
|
219
|
+
→ npx botrun-msync memory init # clone repos to <BM_PATH>/data/
|
|
220
|
+
→ agent reads/writes files # using native tools (Read, Write, grep)
|
|
221
|
+
→ npx botrun-msync memory sync # push changes
|
|
222
|
+
VM destroyed
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
npm install
|
|
229
|
+
npm test # 41 tests
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "botrun-msync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Git-backed memory sync CLI for AI agents (forked from botrun-mcli@0.2.2)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bms": "./src/bin.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "find test -name '*.test.mjs' | xargs node --test"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"ai",
|
|
17
|
+
"agent",
|
|
18
|
+
"memory",
|
|
19
|
+
"git",
|
|
20
|
+
"sync",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/botrun/botrun-memsync-cli.git"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"commander": "^13.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/bin.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { getConfigPath } from './config.mjs';
|
|
5
|
+
import { addScope } from './commands/config/add-scope.mjs';
|
|
6
|
+
import { removeScope } from './commands/config/remove-scope.mjs';
|
|
7
|
+
import { showConfig } from './commands/config/show.mjs';
|
|
8
|
+
import { initMemory } from './commands/memory/init.mjs';
|
|
9
|
+
import { syncMemory } from './commands/memory/sync.mjs';
|
|
10
|
+
import { listScopes } from './commands/memory/scopes.mjs';
|
|
11
|
+
|
|
12
|
+
function jsonHelp(cmd) {
|
|
13
|
+
const commands = cmd.commands.map(c => ({
|
|
14
|
+
name: c.name(),
|
|
15
|
+
description: c.description(),
|
|
16
|
+
}));
|
|
17
|
+
const options = cmd.options.map(o => ({
|
|
18
|
+
flags: o.flags,
|
|
19
|
+
description: o.description,
|
|
20
|
+
required: o.required,
|
|
21
|
+
}));
|
|
22
|
+
return { name: cmd.name(), description: cmd.description(), commands, options };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const program = new Command();
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.name('bms')
|
|
29
|
+
.description('Git-backed memory sync for agents')
|
|
30
|
+
.version('0.1.0')
|
|
31
|
+
.helpCommand(false)
|
|
32
|
+
.option('--bm-path <path>', 'Base directory for all bm data')
|
|
33
|
+
.configureHelp({ formatHelp: (cmd) => JSON.stringify(jsonHelp(cmd), null, 2) })
|
|
34
|
+
.hook('preAction', (thisCommand) => {
|
|
35
|
+
const bmPath = thisCommand.opts().bmPath;
|
|
36
|
+
if (bmPath) process.env.BM_PATH = bmPath;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// --- config ---
|
|
40
|
+
const configCmd = program.command('config').description('Manage configuration');
|
|
41
|
+
configCmd.configureHelp({ formatHelp: (cmd) => JSON.stringify(jsonHelp(cmd), null, 2) });
|
|
42
|
+
|
|
43
|
+
configCmd
|
|
44
|
+
.command('add-scope <name>')
|
|
45
|
+
.description('Add a scope to the configuration')
|
|
46
|
+
.requiredOption('--repo <url>', 'Git repo URL')
|
|
47
|
+
.option('--branch <branch>', 'Git branch to use')
|
|
48
|
+
.option('--token-env <envVar>', 'Environment variable name for this scope token')
|
|
49
|
+
.option('--description <text>', 'Description of this scope for agent context')
|
|
50
|
+
.option('--access <mode>', 'Access mode hint for agent: readwrite or readonly', 'readwrite')
|
|
51
|
+
.action(async (name, opts) => {
|
|
52
|
+
const result = await addScope(name, opts, getConfigPath());
|
|
53
|
+
console.log(JSON.stringify(result, null, 2));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
configCmd
|
|
57
|
+
.command('remove-scope <name>')
|
|
58
|
+
.description('Remove a scope from the configuration')
|
|
59
|
+
.action(async (name) => {
|
|
60
|
+
const result = await removeScope(name, getConfigPath());
|
|
61
|
+
console.log(JSON.stringify(result, null, 2));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
configCmd
|
|
65
|
+
.command('show')
|
|
66
|
+
.description('Show current configuration')
|
|
67
|
+
.action(async () => {
|
|
68
|
+
const result = await showConfig(getConfigPath());
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- memory ---
|
|
73
|
+
const memoryCmd = program.command('memory').description('Manage memory repos');
|
|
74
|
+
memoryCmd.configureHelp({ formatHelp: (cmd) => JSON.stringify(jsonHelp(cmd), null, 2) });
|
|
75
|
+
|
|
76
|
+
memoryCmd
|
|
77
|
+
.command('init')
|
|
78
|
+
.description('Clone all scope repos')
|
|
79
|
+
.action(async () => {
|
|
80
|
+
const result = await initMemory({ configPath: getConfigPath() });
|
|
81
|
+
console.log(JSON.stringify(result, null, 2));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
memoryCmd
|
|
85
|
+
.command('sync')
|
|
86
|
+
.description('Sync memory changes to remote repos')
|
|
87
|
+
.action(async () => {
|
|
88
|
+
const result = await syncMemory({ configPath: getConfigPath() });
|
|
89
|
+
console.log(JSON.stringify(result, null, 2));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
memoryCmd
|
|
93
|
+
.command('scopes')
|
|
94
|
+
.description('List available scopes and local paths')
|
|
95
|
+
.action(async () => {
|
|
96
|
+
const result = await listScopes({ configPath: getConfigPath() });
|
|
97
|
+
console.log(JSON.stringify(result, null, 2));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
program.parse();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from '../../config.mjs';
|
|
2
|
+
|
|
3
|
+
export async function addScope(name, options, configPath) {
|
|
4
|
+
const config = await loadConfig(configPath);
|
|
5
|
+
const scopeEntry = { repo: options.repo };
|
|
6
|
+
if (options.branch) scopeEntry.branch = options.branch;
|
|
7
|
+
if (options.tokenEnv) scopeEntry.token_env = options.tokenEnv;
|
|
8
|
+
if (options.description) scopeEntry.description = options.description;
|
|
9
|
+
if (options.access) scopeEntry.access = options.access;
|
|
10
|
+
config.scopes[name] = scopeEntry;
|
|
11
|
+
await saveConfig(configPath, config);
|
|
12
|
+
return { added: name, scope: scopeEntry };
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from '../../config.mjs';
|
|
2
|
+
|
|
3
|
+
export async function removeScope(name, configPath) {
|
|
4
|
+
const config = await loadConfig(configPath);
|
|
5
|
+
if (!config.scopes[name]) {
|
|
6
|
+
throw new Error(`Scope "${name}" not found`);
|
|
7
|
+
}
|
|
8
|
+
delete config.scopes[name];
|
|
9
|
+
await saveConfig(configPath, config);
|
|
10
|
+
return { removed: name };
|
|
11
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdir, lstat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig, getBasePath } from '../../config.mjs';
|
|
4
|
+
import { gitExec } from '../../git-cmd.mjs';
|
|
5
|
+
import { detectProvider, getProvider, resolveToken } from '../../git/provider.mjs';
|
|
6
|
+
|
|
7
|
+
async function exists(path) {
|
|
8
|
+
try {
|
|
9
|
+
await lstat(path);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function hasWorkingTree(dir) {
|
|
17
|
+
try {
|
|
18
|
+
const result = await gitExec(['-C', dir, 'rev-parse', '--is-inside-work-tree']);
|
|
19
|
+
return result === 'true';
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function cloneOrPull(repo, cloneDir, token, localMode, branch) {
|
|
26
|
+
if (await exists(cloneDir) && await hasWorkingTree(cloneDir)) {
|
|
27
|
+
await gitExec(['-C', cloneDir, 'pull', '--rebase']);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// If directory exists but broken (no working tree), remove and re-clone
|
|
32
|
+
if (await exists(cloneDir)) {
|
|
33
|
+
const { rm } = await import('node:fs/promises');
|
|
34
|
+
await rm(cloneDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let cloneUrl;
|
|
38
|
+
if (localMode) {
|
|
39
|
+
cloneUrl = repo;
|
|
40
|
+
} else {
|
|
41
|
+
const providerName = detectProvider(repo);
|
|
42
|
+
const provider = getProvider(providerName);
|
|
43
|
+
cloneUrl = provider.buildCloneUrl(repo, token);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cloneArgs = ['clone', '--depth', '1'];
|
|
47
|
+
if (branch) cloneArgs.push('--branch', branch);
|
|
48
|
+
cloneArgs.push(cloneUrl, cloneDir);
|
|
49
|
+
await gitExec(cloneArgs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function initMemory(options = {}) {
|
|
53
|
+
const configPath = options.configPath;
|
|
54
|
+
const dataDir = options.dataDir || join(getBasePath(), 'data');
|
|
55
|
+
const localMode = options.localMode || false;
|
|
56
|
+
|
|
57
|
+
const config = await loadConfig(configPath);
|
|
58
|
+
const result = { scopes: {} };
|
|
59
|
+
|
|
60
|
+
await mkdir(dataDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
for (const [name, scope] of Object.entries(config.scopes)) {
|
|
63
|
+
const cloneDir = join(dataDir, name);
|
|
64
|
+
const token = localMode ? undefined : resolveToken(scope);
|
|
65
|
+
|
|
66
|
+
await cloneOrPull(scope.repo, cloneDir, token, localMode, scope.branch);
|
|
67
|
+
|
|
68
|
+
result.scopes[name] = { local: cloneDir };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import { loadConfig, getBasePath } from '../../config.mjs';
|
|
4
|
+
|
|
5
|
+
export async function listScopes(options = {}) {
|
|
6
|
+
const config = await loadConfig(options.configPath);
|
|
7
|
+
const dataDir = options.dataDir || join(getBasePath(), 'data');
|
|
8
|
+
const result = { scopes: {} };
|
|
9
|
+
|
|
10
|
+
for (const [name, scope] of Object.entries(config.scopes)) {
|
|
11
|
+
const entry = { repo: scope.repo };
|
|
12
|
+
if (scope.description) entry.description = scope.description;
|
|
13
|
+
if (scope.access) entry.access = scope.access;
|
|
14
|
+
|
|
15
|
+
const scopeDir = join(dataDir, name);
|
|
16
|
+
try {
|
|
17
|
+
await lstat(scopeDir);
|
|
18
|
+
entry.local = scopeDir;
|
|
19
|
+
} catch {
|
|
20
|
+
entry.local = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
result.scopes[name] = entry;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { loadConfig, getBasePath } from '../../config.mjs';
|
|
3
|
+
import { gitExec } from '../../git-cmd.mjs';
|
|
4
|
+
|
|
5
|
+
export async function syncMemory(options = {}) {
|
|
6
|
+
const config = await loadConfig(options.configPath);
|
|
7
|
+
const dataDir = options.dataDir || join(getBasePath(), 'data');
|
|
8
|
+
|
|
9
|
+
const result = { synced: [], pulled: [], skipped: [] };
|
|
10
|
+
|
|
11
|
+
for (const [name, scope] of Object.entries(config.scopes)) {
|
|
12
|
+
const cloneDir = join(dataDir, name);
|
|
13
|
+
let didPull = false;
|
|
14
|
+
let didPush = false;
|
|
15
|
+
|
|
16
|
+
// 1. Always pull remote changes first
|
|
17
|
+
try {
|
|
18
|
+
const before = await gitExec(['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
19
|
+
await gitExec(['-C', cloneDir, 'pull', '--rebase']);
|
|
20
|
+
const after = await gitExec(['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
21
|
+
if (before !== after) didPull = true;
|
|
22
|
+
} catch {
|
|
23
|
+
// pull may fail if no upstream set; try setting it
|
|
24
|
+
try {
|
|
25
|
+
const branch = await gitExec(['-C', cloneDir, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
26
|
+
await gitExec(['-C', cloneDir, 'branch', '--set-upstream-to', `origin/${branch}`, branch]);
|
|
27
|
+
const before = await gitExec(['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
28
|
+
await gitExec(['-C', cloneDir, 'pull', '--rebase']);
|
|
29
|
+
const after = await gitExec(['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
30
|
+
if (before !== after) didPull = true;
|
|
31
|
+
} catch {
|
|
32
|
+
// still failed, continue
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Check for local uncommitted changes and push
|
|
37
|
+
const status = await gitExec(['-C', cloneDir, 'status', '--porcelain']);
|
|
38
|
+
if (status) {
|
|
39
|
+
await gitExec(['-C', cloneDir, 'add', '-A']);
|
|
40
|
+
await gitExec(['-C', cloneDir, 'commit', '-m', `bm: update ${name} memories`]);
|
|
41
|
+
await gitExec(['-C', cloneDir, 'push']);
|
|
42
|
+
didPush = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (didPull || didPush) {
|
|
46
|
+
result.synced.push(name);
|
|
47
|
+
if (didPull) result.pulled.push(name);
|
|
48
|
+
} else {
|
|
49
|
+
result.skipped.push(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE_PATH = join(homedir(), '.botrun', 'bm');
|
|
6
|
+
|
|
7
|
+
export function getBasePath() {
|
|
8
|
+
return process.env.BM_PATH || DEFAULT_BASE_PATH;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getConfigPath() {
|
|
12
|
+
return process.env.BM_CONFIG || join(getBasePath(), 'config.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadConfig(configPath = getConfigPath()) {
|
|
16
|
+
try {
|
|
17
|
+
const content = await readFile(configPath, 'utf-8');
|
|
18
|
+
return JSON.parse(content);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err.code === 'ENOENT') {
|
|
21
|
+
return { scopes: {} };
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function saveConfig(configPath = getConfigPath(), config) {
|
|
28
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
29
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as github from './github.mjs';
|
|
2
|
+
import * as gitlab from './gitlab.mjs';
|
|
3
|
+
|
|
4
|
+
const providers = { github, gitlab };
|
|
5
|
+
|
|
6
|
+
export function detectProvider(repoUrl) {
|
|
7
|
+
if (repoUrl.includes('github.com')) return 'github';
|
|
8
|
+
if (repoUrl.includes('gitlab.com')) return 'gitlab';
|
|
9
|
+
throw new Error(`Unknown git provider for URL: ${repoUrl}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getProvider(name) {
|
|
13
|
+
const provider = providers[name];
|
|
14
|
+
if (!provider) throw new Error(`Unknown provider: ${name}`);
|
|
15
|
+
// trigger "not yet implemented" for gitlab
|
|
16
|
+
if (name === 'gitlab') provider.buildCloneUrl();
|
|
17
|
+
return provider;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveToken(scope) {
|
|
21
|
+
if (!scope.token_env) return undefined;
|
|
22
|
+
return process.env[scope.token_env] || undefined;
|
|
23
|
+
}
|
package/src/git-cmd.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function gitExec(args, options = {}) {
|
|
7
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
8
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
9
|
+
...options,
|
|
10
|
+
});
|
|
11
|
+
return stdout.trim();
|
|
12
|
+
}
|