@myvillage/cli 2.19.0 → 2.21.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 +6 -0
- package/package.json +2 -1
- package/src/commands/agent-credentials.js +2 -1
- package/src/commands/team.js +274 -0
- package/src/index.js +94 -3
- package/src/utils/team-git-api.js +59 -0
- package/src/utils/team-git.js +410 -0
- package/src/utils/templates.js +3 -0
package/README.md
CHANGED
|
@@ -26,6 +26,11 @@ myvillage init-game ./my-game
|
|
|
26
26
|
# Deploy to the platform
|
|
27
27
|
myvillage deploy
|
|
28
28
|
|
|
29
|
+
# Share it with your team on Village Git (any folder, not just games)
|
|
30
|
+
myvillage team create "Pollen Path" # or: myvillage team link <project>
|
|
31
|
+
myvillage team push -m "Add level 2" # save a version and share it
|
|
32
|
+
myvillage team pull # get your team's latest
|
|
33
|
+
|
|
29
34
|
# Publish a PDF story book to the Read tab
|
|
30
35
|
myvillage publish-book ./my-story.pdf
|
|
31
36
|
```
|
|
@@ -34,6 +39,7 @@ myvillage publish-book ./my-story.pdf
|
|
|
34
39
|
|
|
35
40
|
- **Publish Story Books** — Upload a comic-style PDF; the platform turns it into pages for the muni Read tab, with MVT rewards for readers who finish.
|
|
36
41
|
- **Build & Deploy Games** — Scaffold projects from built-in templates (quiz, exploration, narrative, or custom Three.js) and deploy to the M-UNI game platform.
|
|
42
|
+
- **Team Projects** — Save versions of a game, app or any folder and share them with your village team on Village Git. No git install or GitHub account needed; `myvillage team --help` lists the commands.
|
|
37
43
|
- **Community Network** — Browse feeds, create posts, join communities, comment, vote, and search — right from the CLI.
|
|
38
44
|
- **AI Agents** — Create and manage local or external AI agents that participate in the MyVillage Agent Network.
|
|
39
45
|
- **Business Requirements** — Run an AI-guided intake process that turns organizational needs into developer-ready project specs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myvillage/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.0",
|
|
4
4
|
"description": "MyVillageOS CLI for community developers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"commander": "^11.1.0",
|
|
42
42
|
"conf": "^12.0.0",
|
|
43
43
|
"inquirer": "^9.2.12",
|
|
44
|
+
"isomorphic-git": "^1.44.11",
|
|
44
45
|
"jpeg-js": "^0.4.4",
|
|
45
46
|
"music-metadata": "^11.14.0",
|
|
46
47
|
"open": "^10.0.3",
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
// The villager connects the provider once in MyVillage Studio or at
|
|
19
19
|
// myvillageproject.ai. These commands only manage the per-agent consent.
|
|
20
20
|
|
|
21
|
-
const VALID_PROVIDERS = ['google', 'microsoft', 'zoom', 'slack'];
|
|
21
|
+
const VALID_PROVIDERS = ['google', 'microsoft', 'zoom', 'slack', 'plaid'];
|
|
22
22
|
|
|
23
23
|
// Human-facing labels keyed by the lowercase slug the CLI accepts.
|
|
24
24
|
const PROVIDER_LABELS = {
|
|
@@ -26,6 +26,7 @@ const PROVIDER_LABELS = {
|
|
|
26
26
|
microsoft: 'Microsoft 365 (Outlook mail, Calendar)',
|
|
27
27
|
zoom: 'Zoom',
|
|
28
28
|
slack: 'Slack (channels, messages, search — acts as you)',
|
|
29
|
+
plaid: 'Bank accounts via Plaid (balances, transactions — read only)',
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
function fail(message) {
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `myvillage team` — team projects on Village Git, for any folder (a game, a
|
|
3
|
+
* web app, anything a team builds together). The same operations as Studio's
|
|
4
|
+
* team bar: save a version, share it, get the team's latest.
|
|
5
|
+
*
|
|
6
|
+
* Every subcommand takes `--dir` (default: the current folder) and `--json`
|
|
7
|
+
* (one JSON object on stdout: `{ ok: true, … }` or `{ ok: false, error, code? }`,
|
|
8
|
+
* and a non-zero exit code on failure), so scripts and agents can drive it.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { basename, join, resolve } from 'path';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import inquirer from 'inquirer';
|
|
14
|
+
import { brand, villageSpinner } from '../utils/brand.js';
|
|
15
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
16
|
+
import { getMyVillages } from '../utils/village-resolver.js';
|
|
17
|
+
import { TeamGitError } from '../utils/team-git.js';
|
|
18
|
+
import { createTeamRepo, getTeamGitCredentials, listTeamRepos, teamGit } from '../utils/team-git-api.js';
|
|
19
|
+
|
|
20
|
+
const projectDir = (options) => resolve(options.dir || process.cwd());
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs one team operation: checks sign-in, shows a spinner (or nothing with
|
|
24
|
+
* --json), and prints the result with `print` or the error.
|
|
25
|
+
*/
|
|
26
|
+
async function run(options, label, fn, print) {
|
|
27
|
+
const json = !!options.json;
|
|
28
|
+
const fail = (error, extra = {}) => {
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
if (json) {
|
|
31
|
+
console.log(JSON.stringify({ ok: false, error, ...extra }));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
console.log(chalk.red(` ✗ ${error}`));
|
|
35
|
+
for (const f of extra.files ?? []) console.log(brand.teal(` ${f}`));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (!isAuthenticated()) return fail("Sign in to MyVillage first: run 'myvillage login'.");
|
|
39
|
+
|
|
40
|
+
const spinner = json ? null : villageSpinner(label).start();
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
result = await fn();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
spinner?.stop();
|
|
46
|
+
if (err instanceof TeamGitError) return fail(err.message, { code: err.code, files: err.files });
|
|
47
|
+
return fail(err?.message ?? String(err));
|
|
48
|
+
}
|
|
49
|
+
spinner?.stop();
|
|
50
|
+
if (json) console.log(JSON.stringify({ ok: true, ...result }));
|
|
51
|
+
else print(result);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printCommands(commands = []) {
|
|
55
|
+
if (commands.length) console.log(brand.teal(` ran: ${commands.join(' && ')}`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shortOid(oid) {
|
|
59
|
+
return oid ? oid.slice(0, 7) : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A friendly name for the folder's first version: the game/app title, else the folder name. */
|
|
63
|
+
function projectTitle(dir) {
|
|
64
|
+
for (const file of ['myvillage.json', 'package.json']) {
|
|
65
|
+
const p = join(dir, file);
|
|
66
|
+
if (!existsSync(p)) continue;
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
69
|
+
const title = data.myvillage?.title || data.title || data.name;
|
|
70
|
+
if (title) return title;
|
|
71
|
+
} catch {
|
|
72
|
+
/* unreadable: fall through */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return basename(dir);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A folder can join a team project only if it exists and has no git history
|
|
80
|
+
* of its own. Checked before `create` makes anything on the server.
|
|
81
|
+
*/
|
|
82
|
+
function assertCanLink(dir) {
|
|
83
|
+
if (!existsSync(dir)) throw new Error(`${dir} doesn't exist.`);
|
|
84
|
+
if (existsSync(join(dir, '.git'))) {
|
|
85
|
+
throw new TeamGitError(
|
|
86
|
+
'has-history',
|
|
87
|
+
'This folder already has its own git history. Make a copy without the .git folder, or link it to the team project it already belongs to.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Finds a team project by clone URL, `org/name` or `name`. */
|
|
93
|
+
async function findTeamRepo(query) {
|
|
94
|
+
const repos = await listTeamRepos();
|
|
95
|
+
const q = query.trim().toLowerCase().replace(/\.git$/, '');
|
|
96
|
+
const matches = repos.filter((r) => {
|
|
97
|
+
const url = r.cloneUrl.toLowerCase().replace(/\.git$/, '');
|
|
98
|
+
return url === q || `${r.org}/${r.name}`.toLowerCase() === q || r.name.toLowerCase() === q;
|
|
99
|
+
});
|
|
100
|
+
if (matches.length === 1) return matches[0];
|
|
101
|
+
if (matches.length > 1) {
|
|
102
|
+
throw new Error(`More than one team project is called "${query}": ${matches.map((r) => `${r.org}/${r.name}`).join(', ')}. Use org/name.`);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`No team project "${query}" in your villages. See them with 'myvillage team projects'.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The village to start a project in: --village (id or name), your only village, or a prompt. */
|
|
108
|
+
async function pickVillage(options) {
|
|
109
|
+
const villages = await getMyVillages();
|
|
110
|
+
if (!villages.length) throw new Error("You aren't in a village yet, so you can't start a team project.");
|
|
111
|
+
if (options.village) {
|
|
112
|
+
const v = options.village.trim().toLowerCase();
|
|
113
|
+
const match = villages.find((x) => x.id === options.village || x.name?.toLowerCase() === v);
|
|
114
|
+
if (!match) throw new Error(`You aren't in a village "${options.village}". Your villages: ${villages.map((x) => x.name).join(', ')}.`);
|
|
115
|
+
return match;
|
|
116
|
+
}
|
|
117
|
+
if (villages.length === 1) return villages[0];
|
|
118
|
+
if (options.json || options.yes) {
|
|
119
|
+
throw new Error(`Choose a village with --village. Your villages: ${villages.map((x) => `${x.name} (${x.id})`).join(', ')}.`);
|
|
120
|
+
}
|
|
121
|
+
const { id } = await inquirer.prompt([{
|
|
122
|
+
type: 'list',
|
|
123
|
+
name: 'id',
|
|
124
|
+
message: 'Which village is this team project for?',
|
|
125
|
+
choices: villages.map((x) => ({ name: x.name, value: x.id })),
|
|
126
|
+
}]);
|
|
127
|
+
return villages.find((x) => x.id === id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Commands ─────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export async function teamProjectsCommand(options = {}) {
|
|
133
|
+
await run(options, 'Loading team projects...', async () => ({ projects: await listTeamRepos() }), ({ projects }) => {
|
|
134
|
+
if (!projects.length) {
|
|
135
|
+
console.log(brand.teal(" No team projects in your villages yet. Start one with 'myvillage team create <name>'."));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log(chalk.bold('\n Team projects\n'));
|
|
139
|
+
for (const p of projects) {
|
|
140
|
+
console.log(` ${brand.gold(`${p.org}/${p.name}`)} ${brand.teal(p.villageName)}`);
|
|
141
|
+
if (p.description) console.log(` ${p.description}`);
|
|
142
|
+
console.log(brand.teal(` ${p.cloneUrl}`));
|
|
143
|
+
}
|
|
144
|
+
console.log('');
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function teamCreateCommand(name, options = {}) {
|
|
149
|
+
const dir = projectDir(options);
|
|
150
|
+
await run(options, 'Starting the team project...', async () => {
|
|
151
|
+
assertCanLink(dir);
|
|
152
|
+
const village = await pickVillage(options);
|
|
153
|
+
const project = await createTeamRepo({ villageId: village.id, name, description: options.description });
|
|
154
|
+
const linked = await teamGit.link(dir, project.cloneUrl, projectTitle(dir));
|
|
155
|
+
return { project, ...linked };
|
|
156
|
+
}, ({ project, commands }) => {
|
|
157
|
+
console.log(brand.green(` ✓ Started ${project.org}/${project.name} in ${project.villageName} and shared this folder with it.`));
|
|
158
|
+
printCommands(commands);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function teamLinkCommand(project, options = {}) {
|
|
163
|
+
const dir = projectDir(options);
|
|
164
|
+
await run(options, 'Connecting to the team project...', async () => {
|
|
165
|
+
if (!existsSync(dir)) throw new Error(`${dir} doesn't exist.`);
|
|
166
|
+
const repo = await findTeamRepo(project);
|
|
167
|
+
const linked = await teamGit.link(dir, repo.cloneUrl, projectTitle(dir));
|
|
168
|
+
return { project: repo, ...linked };
|
|
169
|
+
}, ({ project: repo, commands }) => {
|
|
170
|
+
console.log(brand.green(` ✓ This folder is now part of ${repo.org}/${repo.name}.`));
|
|
171
|
+
printCommands(commands);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function teamCloneCommand(project, dirArg, options = {}) {
|
|
176
|
+
await run(options, 'Downloading the team project...', async () => {
|
|
177
|
+
const repo = await findTeamRepo(project);
|
|
178
|
+
const dir = resolve(dirArg || join(process.cwd(), repo.name));
|
|
179
|
+
const { commands } = await teamGit.clone(repo.cloneUrl, dir);
|
|
180
|
+
return { project: repo, dir, commands };
|
|
181
|
+
}, ({ project: repo, dir, commands }) => {
|
|
182
|
+
console.log(brand.green(` ✓ Downloaded ${repo.org}/${repo.name} to ${dir}`));
|
|
183
|
+
printCommands(commands);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function teamStatusCommand(options = {}) {
|
|
188
|
+
const dir = projectDir(options);
|
|
189
|
+
await run(options, 'Checking...', async () => ({ status: await teamGit.status(dir) }), ({ status }) => {
|
|
190
|
+
if (!status.linked) {
|
|
191
|
+
console.log(brand.teal(" This folder isn't part of a team project. Use 'myvillage team link <project>' or 'myvillage team create <name>'."));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
console.log(`\n Team project: ${brand.gold(status.remoteUrl)}`);
|
|
195
|
+
if (status.lastVersion) {
|
|
196
|
+
console.log(` Last version: ${status.lastVersion.message} ${brand.teal(`(${status.lastVersion.author}, ${shortOid(status.lastVersion.oid)})`)}`);
|
|
197
|
+
}
|
|
198
|
+
if (status.changes.length) {
|
|
199
|
+
console.log(` Unsaved changes (${status.changes.length}):`);
|
|
200
|
+
for (const c of status.changes) console.log(brand.teal(` ${c.change.padEnd(8)} ${c.path}`));
|
|
201
|
+
} else {
|
|
202
|
+
console.log(' No unsaved changes.');
|
|
203
|
+
}
|
|
204
|
+
console.log(status.unshared ? ` ${status.unshared} saved version(s) not shared yet.\n` : ' Everything saved is shared.\n');
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function teamHistoryCommand(options = {}) {
|
|
209
|
+
const dir = projectDir(options);
|
|
210
|
+
const depth = options.limit ? parseInt(options.limit, 10) : 30;
|
|
211
|
+
await run(options, 'Loading versions...', async () => ({ versions: await teamGit.history(dir, depth) }), ({ versions }) => {
|
|
212
|
+
if (!versions.length) {
|
|
213
|
+
console.log(brand.teal(' No saved versions yet.'));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const v of versions) {
|
|
217
|
+
console.log(` ${brand.gold(shortOid(v.oid))} ${v.message} ${brand.teal(`${v.author}, ${new Date(v.when).toLocaleString()}`)}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function teamSaveCommand(options = {}) {
|
|
223
|
+
const dir = projectDir(options);
|
|
224
|
+
await run(options, 'Saving a version...', () => teamGit.saveVersion(dir, options.message), ({ version, commands }) => {
|
|
225
|
+
console.log(brand.green(` ✓ Saved "${version.message}" (${shortOid(version.oid)}). Share it with 'myvillage team share'.`));
|
|
226
|
+
printCommands(commands);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function teamShareCommand(options = {}) {
|
|
231
|
+
const dir = projectDir(options);
|
|
232
|
+
await run(options, 'Sharing with your team...', () => teamGit.share(dir), ({ shared, commands }) => {
|
|
233
|
+
console.log(shared ? brand.green(` ✓ Shared ${shared} version(s) with your team.`) : brand.teal(' Nothing new to share.'));
|
|
234
|
+
printCommands(commands);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function teamPushCommand(options = {}) {
|
|
239
|
+
const dir = projectDir(options);
|
|
240
|
+
await run(options, 'Saving and sharing...', () => teamGit.push(dir, options.message), ({ saved, shared, commands }) => {
|
|
241
|
+
if (saved) console.log(brand.green(` ✓ Saved "${saved.message}" (${shortOid(saved.oid)}).`));
|
|
242
|
+
console.log(shared ? brand.green(` ✓ Shared ${shared} version(s) with your team.`) : brand.teal(' Nothing new to share.'));
|
|
243
|
+
printCommands(commands);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function teamPullCommand(options = {}) {
|
|
248
|
+
if (options.mine && options.theirs) {
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
console.log(chalk.red(' ✗ Choose --mine or --theirs, not both.'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const dir = projectDir(options);
|
|
254
|
+
const resolveWith = options.mine ? 'mine' : options.theirs ? 'theirs' : undefined;
|
|
255
|
+
await run(options, "Getting your team's latest...", () => teamGit.getLatest(dir, resolveWith ? { resolve: resolveWith } : {}), ({ updated, files, commands }) => {
|
|
256
|
+
if (!updated) console.log(brand.teal(" You already have your team's latest."));
|
|
257
|
+
else {
|
|
258
|
+
console.log(brand.green(` ✓ Got your team's latest (${files.length} file(s) changed).`));
|
|
259
|
+
for (const f of files) console.log(brand.teal(` ${f}`));
|
|
260
|
+
}
|
|
261
|
+
printCommands(commands);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Checks that this account can use Village Git, without touching any folder. */
|
|
266
|
+
export async function teamWhoamiCommand(options = {}) {
|
|
267
|
+
await run(options, 'Checking Village Git...', async () => {
|
|
268
|
+
const c = await getTeamGitCredentials();
|
|
269
|
+
return { host: c.host, gitUsername: c.gitUsername, author: c.author };
|
|
270
|
+
}, ({ host, gitUsername, author }) => {
|
|
271
|
+
console.log(` Village Git: ${brand.gold(host)}`);
|
|
272
|
+
console.log(` You are ${brand.gold(gitUsername)} (commits as ${author.name} <${author.email}>)`);
|
|
273
|
+
});
|
|
274
|
+
}
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,19 @@ import { createGameCommand } from './commands/create-game.js';
|
|
|
19
19
|
import { initGameCommand } from './commands/init-game.js';
|
|
20
20
|
import { createCommand } from './commands/create-app.js';
|
|
21
21
|
import { deployCommand } from './commands/deploy.js';
|
|
22
|
+
import {
|
|
23
|
+
teamProjectsCommand,
|
|
24
|
+
teamCreateCommand,
|
|
25
|
+
teamLinkCommand,
|
|
26
|
+
teamCloneCommand,
|
|
27
|
+
teamStatusCommand,
|
|
28
|
+
teamHistoryCommand,
|
|
29
|
+
teamSaveCommand,
|
|
30
|
+
teamShareCommand,
|
|
31
|
+
teamPushCommand,
|
|
32
|
+
teamPullCommand,
|
|
33
|
+
teamWhoamiCommand,
|
|
34
|
+
} from './commands/team.js';
|
|
22
35
|
import { publishBookCommand } from './commands/publish-book.js';
|
|
23
36
|
import { booksListCommand } from './commands/books.js';
|
|
24
37
|
import { publishEpisodeCommand } from './commands/publish-episode.js';
|
|
@@ -676,17 +689,17 @@ export function run() {
|
|
|
676
689
|
.description('List the MCP servers currently configured for an agent')
|
|
677
690
|
.action(agentListMcpCommand);
|
|
678
691
|
|
|
679
|
-
// Per-agent
|
|
692
|
+
// Per-agent credential grants (Google/Microsoft/Zoom/Slack/Plaid). The villager
|
|
680
693
|
// connects the provider once in the app/portal; these authorize a specific
|
|
681
694
|
// agent to use it via the MyVillage MCP server. Tokens never touch the CLI.
|
|
682
695
|
agentCmd
|
|
683
696
|
.command('grant <name> <provider>')
|
|
684
|
-
.description('Authorize an agent to use a connected provider (google, microsoft, zoom)')
|
|
697
|
+
.description('Authorize an agent to use a connected provider (google, microsoft, zoom, slack, plaid)')
|
|
685
698
|
.action(agentGrantCommand);
|
|
686
699
|
|
|
687
700
|
agentCmd
|
|
688
701
|
.command('revoke <name> <provider>')
|
|
689
|
-
.description('Revoke an agent\'s access to a provider (google, microsoft, zoom)')
|
|
702
|
+
.description('Revoke an agent\'s access to a provider (google, microsoft, zoom, slack, plaid)')
|
|
690
703
|
.action(agentRevokeCredentialCommand);
|
|
691
704
|
|
|
692
705
|
agentCmd
|
|
@@ -1120,6 +1133,84 @@ export function run() {
|
|
|
1120
1133
|
.option('--json', 'Output raw JSON')
|
|
1121
1134
|
.action(modelPredictCommand);
|
|
1122
1135
|
|
|
1136
|
+
// ── Team projects (Village Git) ────────────────────────
|
|
1137
|
+
const teamCmd = program
|
|
1138
|
+
.command('team')
|
|
1139
|
+
.description('Team projects on Village Git: save, share and get the latest of any folder');
|
|
1140
|
+
const dirOpt = ['--dir <path>', 'Project folder (default: current folder)'];
|
|
1141
|
+
const jsonOpt = ['--json', 'Output the result as JSON (non-interactive)'];
|
|
1142
|
+
teamCmd
|
|
1143
|
+
.command('projects')
|
|
1144
|
+
.description("List the team projects in your villages")
|
|
1145
|
+
.option(...jsonOpt)
|
|
1146
|
+
.action(teamProjectsCommand);
|
|
1147
|
+
teamCmd
|
|
1148
|
+
.command('create <name>')
|
|
1149
|
+
.description('Start a team project in your village and share this folder as its first version')
|
|
1150
|
+
.option('--village <idOrName>', 'Village to start it in (default: your only village, or a prompt)')
|
|
1151
|
+
.option('--description <text>', 'Short description of the project')
|
|
1152
|
+
.option('-y, --yes', 'Non-interactive: never prompt')
|
|
1153
|
+
.option(...dirOpt)
|
|
1154
|
+
.option(...jsonOpt)
|
|
1155
|
+
.action(teamCreateCommand);
|
|
1156
|
+
teamCmd
|
|
1157
|
+
.command('link <project>')
|
|
1158
|
+
.description('Connect this folder to an existing team project (name, org/name or clone URL) and share it')
|
|
1159
|
+
.option(...dirOpt)
|
|
1160
|
+
.option(...jsonOpt)
|
|
1161
|
+
.action(teamLinkCommand);
|
|
1162
|
+
teamCmd
|
|
1163
|
+
.command('clone <project> [dir]')
|
|
1164
|
+
.description('Download a team project (default folder: ./<project name>)')
|
|
1165
|
+
.option(...jsonOpt)
|
|
1166
|
+
.action(teamCloneCommand);
|
|
1167
|
+
teamCmd
|
|
1168
|
+
.command('status')
|
|
1169
|
+
.description('Show unsaved changes and versions not shared yet')
|
|
1170
|
+
.option(...dirOpt)
|
|
1171
|
+
.option(...jsonOpt)
|
|
1172
|
+
.action(teamStatusCommand);
|
|
1173
|
+
teamCmd
|
|
1174
|
+
.command('history')
|
|
1175
|
+
.description('List saved versions')
|
|
1176
|
+
.option('--limit <n>', 'How many versions to show (default 30)')
|
|
1177
|
+
.option(...dirOpt)
|
|
1178
|
+
.option(...jsonOpt)
|
|
1179
|
+
.action(teamHistoryCommand);
|
|
1180
|
+
teamCmd
|
|
1181
|
+
.command('save')
|
|
1182
|
+
.description('Save a version of everything changed (git add -A && git commit)')
|
|
1183
|
+
.option('-m, --message <text>', 'What changed')
|
|
1184
|
+
.option(...dirOpt)
|
|
1185
|
+
.option(...jsonOpt)
|
|
1186
|
+
.action(teamSaveCommand);
|
|
1187
|
+
teamCmd
|
|
1188
|
+
.command('share')
|
|
1189
|
+
.description('Share saved versions with your team (git push)')
|
|
1190
|
+
.option(...dirOpt)
|
|
1191
|
+
.option(...jsonOpt)
|
|
1192
|
+
.action(teamShareCommand);
|
|
1193
|
+
teamCmd
|
|
1194
|
+
.command('push')
|
|
1195
|
+
.description('Save a version if anything changed, then share it: one step for scripts and agents')
|
|
1196
|
+
.option('-m, --message <text>', 'What changed')
|
|
1197
|
+
.option(...dirOpt)
|
|
1198
|
+
.option(...jsonOpt)
|
|
1199
|
+
.action(teamPushCommand);
|
|
1200
|
+
teamCmd
|
|
1201
|
+
.command('pull')
|
|
1202
|
+
.description("Get your team's latest (git pull); refuses while you have unsaved changes")
|
|
1203
|
+
.option('--mine', 'Where you both changed the same lines, keep your side')
|
|
1204
|
+
.option('--theirs', "Where you both changed the same lines, keep your teammate's side")
|
|
1205
|
+
.option(...dirOpt)
|
|
1206
|
+
.option(...jsonOpt)
|
|
1207
|
+
.action(teamPullCommand);
|
|
1208
|
+
teamCmd
|
|
1209
|
+
.command('whoami')
|
|
1210
|
+
.description('Check that your account can use Village Git')
|
|
1211
|
+
.option(...jsonOpt)
|
|
1212
|
+
.action(teamWhoamiCommand);
|
|
1213
|
+
|
|
1123
1214
|
// ── Fleet runner ───────────────────────────────────────
|
|
1124
1215
|
const runnerCmd = program
|
|
1125
1216
|
.command('runner')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Village Git through the signed-in MyVillage account: the credentials and
|
|
3
|
+
* team-project endpoints on ed-platform (`/api/v1/git/*`), plus the
|
|
4
|
+
* ready-to-use `teamGit` operations (team-git.js) wired to them.
|
|
5
|
+
*/
|
|
6
|
+
import { getApiClient } from './api.js';
|
|
7
|
+
import { createTeamGit } from './team-git.js';
|
|
8
|
+
|
|
9
|
+
export class TeamGitUnavailableError extends Error {}
|
|
10
|
+
|
|
11
|
+
async function call(method, path, data) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await getApiClient().request({ method, url: path, data });
|
|
14
|
+
return res.data;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const status = err.response?.status;
|
|
17
|
+
const body = err.response?.data;
|
|
18
|
+
const message = typeof body === 'object' && body ? body.error : undefined;
|
|
19
|
+
if (status === 401) throw new Error('Sign in to MyVillage first: run `myvillage login`.');
|
|
20
|
+
if (status === 503 || (status === 404 && !message)) {
|
|
21
|
+
throw new TeamGitUnavailableError("Team projects aren't available on this MyVillage server yet.");
|
|
22
|
+
}
|
|
23
|
+
if (status) throw new Error(message ?? `MyVillage answered ${status}.`);
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Tokens last an hour; refresh with a few minutes to spare.
|
|
29
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
30
|
+
let cached = null;
|
|
31
|
+
let inflight = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A one-hour Village Git token for the signed-in villager:
|
|
35
|
+
* { host, username, password, expiresAt, author: { name, email }, gitUsername }.
|
|
36
|
+
*/
|
|
37
|
+
export async function getTeamGitCredentials(force = false) {
|
|
38
|
+
if (!force && cached && Date.parse(cached.expiresAt) - Date.now() > REFRESH_MARGIN_MS) return cached;
|
|
39
|
+
if (!inflight) {
|
|
40
|
+
inflight = call('post', '/git/credentials')
|
|
41
|
+
.then((c) => (cached = c))
|
|
42
|
+
.finally(() => {
|
|
43
|
+
inflight = null;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return inflight;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Team projects in the villager's villages. */
|
|
50
|
+
export async function listTeamRepos() {
|
|
51
|
+
return (await call('get', '/git/repos')).repos;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Starts a team project in a village. Leads and Members can; Family can't. */
|
|
55
|
+
export async function createTeamRepo({ villageId, name, description }) {
|
|
56
|
+
return (await call('post', '/git/repos', { villageId, name, description })).repo;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const teamGit = createTeamGit((force) => getTeamGitCredentials(force));
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team version control on Village Git (the MyVillage-hosted Forgejo; see
|
|
3
|
+
* ed-platform docs/architecture/village-git.md) for any project folder: a
|
|
4
|
+
* game, a web app, or anything else a team builds together.
|
|
5
|
+
*
|
|
6
|
+
* Pure-JS git (isomorphic-git), so villagers never install git. Every
|
|
7
|
+
* operation also reports the equivalent git commands, so callers can show
|
|
8
|
+
* "ran: git commit -m …" and villagers learn the real thing.
|
|
9
|
+
*
|
|
10
|
+
* Friendly name → git:
|
|
11
|
+
* Save a version → git add -A && git commit
|
|
12
|
+
* Share with team → git push
|
|
13
|
+
* Get team's latest → git pull
|
|
14
|
+
*
|
|
15
|
+
* Ported from MyVillage Studio's cli-bridge team-git.ts, which writes the same
|
|
16
|
+
* .git layout. No chalk/inquirer here: Studio can import this in-process.
|
|
17
|
+
* This module only needs a credentials provider (team-git-api.js has the
|
|
18
|
+
* signed-in one), so it can be tested against any git HTTP server.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import git from 'isomorphic-git';
|
|
23
|
+
import http from 'isomorphic-git/http/node';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ username: string, password: string, author: { name: string, email: string } }} GitCredentials
|
|
27
|
+
* @typedef {(force?: boolean) => Promise<GitCredentials>} CredentialsProvider
|
|
28
|
+
* `force` asks for fresh credentials (the server rejected the cached ones).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Codes: not-linked | has-history | unsaved | nothing-to-save | behind | conflict | auth
|
|
33
|
+
*/
|
|
34
|
+
export class TeamGitError extends Error {
|
|
35
|
+
constructor(code, message, files = []) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.files = files;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const BRANCH = 'main';
|
|
43
|
+
const REMOTE = 'origin';
|
|
44
|
+
const REMOTE_REF = `refs/remotes/${REMOTE}/${BRANCH}`;
|
|
45
|
+
|
|
46
|
+
/** Never shared: dependencies, build output, OS junk, Unity's regenerated folders. */
|
|
47
|
+
const DEFAULT_IGNORES = ['node_modules/', 'dist/', '.DS_Store', 'Thumbs.db', '.env', '.env.*', 'Library/', 'Temp/', 'Logs/', 'obj/'];
|
|
48
|
+
|
|
49
|
+
const NOT_LINKED = "This folder isn't part of a team project yet.";
|
|
50
|
+
|
|
51
|
+
const quote = (s) => `"${s.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
52
|
+
|
|
53
|
+
/** @param {CredentialsProvider} getCredentials */
|
|
54
|
+
export function createTeamGit(getCredentials) {
|
|
55
|
+
/** onAuth hooks for isomorphic-git: retry once with fresh credentials, then give up. */
|
|
56
|
+
function authHooks() {
|
|
57
|
+
let retried = false;
|
|
58
|
+
return {
|
|
59
|
+
onAuth: async () => {
|
|
60
|
+
const c = await getCredentials();
|
|
61
|
+
return { username: c.username, password: c.password };
|
|
62
|
+
},
|
|
63
|
+
onAuthFailure: async () => {
|
|
64
|
+
if (retried) return { cancel: true };
|
|
65
|
+
retried = true;
|
|
66
|
+
const c = await getCredentials(true);
|
|
67
|
+
return { username: c.username, password: c.password };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function wrapAuth(err) {
|
|
73
|
+
const code = err?.code;
|
|
74
|
+
if (code === 'UserCanceledError' || (code === 'HttpError' && /40[13]/.test(String(err.message)))) {
|
|
75
|
+
throw new TeamGitError('auth', "Village Git didn't let you in. You may no longer be on this team, or you need to sign in again.");
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function isRepo(dir) {
|
|
81
|
+
try {
|
|
82
|
+
await git.resolveRef({ fs, dir, ref: 'HEAD' });
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return fs.existsSync(path.join(dir, '.git'));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function remoteUrl(dir) {
|
|
90
|
+
if (!fs.existsSync(path.join(dir, '.git'))) return null;
|
|
91
|
+
return (await git.getConfig({ fs, dir, path: `remote.${REMOTE}.url` })) ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function oid(dir, ref) {
|
|
95
|
+
try {
|
|
96
|
+
return await git.resolveRef({ fs, dir, ref });
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function changes(dir) {
|
|
103
|
+
const matrix = await git.statusMatrix({ fs, dir });
|
|
104
|
+
const out = [];
|
|
105
|
+
// "Racy git": isomorphic-git trusts file stats (whole seconds + size), so a
|
|
106
|
+
// same-size edit in the same second as the last save looks unchanged. Like
|
|
107
|
+
// real git, re-check those files by content.
|
|
108
|
+
const indexFile = path.join(dir, '.git', 'index');
|
|
109
|
+
const indexSecond = fs.existsSync(indexFile) ? Math.floor(fs.statSync(indexFile).mtimeMs / 1000) : 0;
|
|
110
|
+
const head = await oid(dir, 'HEAD');
|
|
111
|
+
for (const [filepath, inHead, workdir] of matrix) {
|
|
112
|
+
if (inHead === 0 && workdir === 2) out.push({ path: filepath, change: 'added' });
|
|
113
|
+
else if (inHead === 1 && workdir === 2) out.push({ path: filepath, change: 'modified' });
|
|
114
|
+
else if (inHead === 1 && workdir === 0) out.push({ path: filepath, change: 'deleted' });
|
|
115
|
+
else if (inHead === 1 && workdir === 1 && head && (await racilyChanged(dir, head, filepath, indexSecond))) {
|
|
116
|
+
out.push({ path: filepath, change: 'modified' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function racilyChanged(dir, head, filepath, indexSecond) {
|
|
123
|
+
const abs = path.join(dir, filepath);
|
|
124
|
+
const stat = fs.statSync(abs, { throwIfNoEntry: false });
|
|
125
|
+
if (!stat || Math.floor(stat.mtimeMs / 1000) < indexSecond) return false;
|
|
126
|
+
const [{ oid: inWorkdir }, { oid: inHead }] = await Promise.all([
|
|
127
|
+
git.hashBlob({ object: fs.readFileSync(abs) }),
|
|
128
|
+
git.readBlob({ fs, dir, oid: head, filepath }),
|
|
129
|
+
]);
|
|
130
|
+
return inWorkdir !== inHead;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function countUnshared(dir) {
|
|
134
|
+
const local = await oid(dir, BRANCH);
|
|
135
|
+
if (!local) return 0;
|
|
136
|
+
const remote = await oid(dir, REMOTE_REF);
|
|
137
|
+
if (local === remote) return 0;
|
|
138
|
+
// Versions reachable from ours but not from the team's. After getting the
|
|
139
|
+
// team's latest, the log interleaves both sides, so check every entry.
|
|
140
|
+
const log = await git.log({ fs, dir, ref: BRANCH, depth: 100 });
|
|
141
|
+
let n = 0;
|
|
142
|
+
for (const entry of log) {
|
|
143
|
+
if (entry.oid === remote) continue;
|
|
144
|
+
const shared = remote ? await git.isDescendent({ fs, dir, oid: remote, ancestor: entry.oid, depth: -1 }).catch(() => false) : false;
|
|
145
|
+
if (!shared) n++;
|
|
146
|
+
}
|
|
147
|
+
return n;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function lastVersion(dir) {
|
|
151
|
+
try {
|
|
152
|
+
const [entry] = await git.log({ fs, dir, ref: BRANCH, depth: 1 });
|
|
153
|
+
return entry ? toVersion(entry) : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toVersion(entry) {
|
|
160
|
+
return {
|
|
161
|
+
oid: entry.oid,
|
|
162
|
+
message: entry.commit.message.trim(),
|
|
163
|
+
author: entry.commit.author.name,
|
|
164
|
+
when: entry.commit.author.timestamp * 1000,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ensureIgnores(dir) {
|
|
169
|
+
const file = path.join(dir, '.gitignore');
|
|
170
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
171
|
+
const have = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
|
172
|
+
const missing = DEFAULT_IGNORES.filter((l) => !have.has(l));
|
|
173
|
+
if (missing.length === 0) return;
|
|
174
|
+
const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
175
|
+
const header = existing ? '' : '# Not shared with the team (added by MyVillage)\n';
|
|
176
|
+
fs.writeFileSync(file, `${existing}${prefix}${header}${missing.join('\n')}\n`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function fetchTeam(dir) {
|
|
180
|
+
try {
|
|
181
|
+
await git.fetch({ fs, http, dir, remote: REMOTE, ref: BRANCH, singleBranch: true, tags: false, ...authHooks() });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// An empty team project has no main yet; nothing to fetch.
|
|
184
|
+
if (err?.code === 'NotFoundError') return;
|
|
185
|
+
wrapAuth(err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function status(dir) {
|
|
190
|
+
const url = await remoteUrl(dir);
|
|
191
|
+
if (!url || !(await isRepo(dir))) {
|
|
192
|
+
return { linked: false, remoteUrl: url, changes: [], unshared: 0, lastVersion: null };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
linked: true,
|
|
196
|
+
remoteUrl: url,
|
|
197
|
+
changes: await changes(dir),
|
|
198
|
+
unshared: await countUnshared(dir),
|
|
199
|
+
lastVersion: await lastVersion(dir),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function saveVersion(dir, message) {
|
|
204
|
+
if (!(await remoteUrl(dir))) throw new TeamGitError('not-linked', NOT_LINKED);
|
|
205
|
+
const pending = await changes(dir);
|
|
206
|
+
if (pending.length === 0) throw new TeamGitError('nothing-to-save', 'Nothing has changed since your last saved version.');
|
|
207
|
+
for (const c of pending) {
|
|
208
|
+
if (c.change === 'deleted') await git.remove({ fs, dir, filepath: c.path });
|
|
209
|
+
else await git.add({ fs, dir, filepath: c.path });
|
|
210
|
+
}
|
|
211
|
+
const { author } = await getCredentials();
|
|
212
|
+
const text = (message ?? '').trim() || 'Save a version';
|
|
213
|
+
const oidSaved = await git.commit({ fs, dir, message: text, author });
|
|
214
|
+
const [entry] = await git.log({ fs, dir, ref: oidSaved, depth: 1 });
|
|
215
|
+
return { version: toVersion(entry), commands: ['git add -A', `git commit -m ${quote(text)}`] };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function share(dir) {
|
|
219
|
+
if (!(await remoteUrl(dir))) throw new TeamGitError('not-linked', NOT_LINKED);
|
|
220
|
+
await fetchTeam(dir);
|
|
221
|
+
const unshared = await countUnshared(dir);
|
|
222
|
+
if (unshared === 0) return { shared: 0, commands: [`git push ${REMOTE} ${BRANCH}`] };
|
|
223
|
+
try {
|
|
224
|
+
const res = await git.push({ fs, http, dir, remote: REMOTE, ref: BRANCH, ...authHooks() });
|
|
225
|
+
if (!res.ok) throw new Error(res.error ?? 'Push failed');
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (err?.code === 'PushRejectedError') {
|
|
228
|
+
throw new TeamGitError('behind', "Your team shared changes you don't have yet. Get the team's latest first, then share.");
|
|
229
|
+
}
|
|
230
|
+
wrapAuth(err);
|
|
231
|
+
}
|
|
232
|
+
return { shared: unshared, commands: [`git push ${REMOTE} ${BRANCH}`] };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Saves a version when anything changed, then shares everything not yet
|
|
237
|
+
* shared: one step for scripts and agents. `saved` is null when there was
|
|
238
|
+
* nothing new to save.
|
|
239
|
+
*/
|
|
240
|
+
async function push(dir, message) {
|
|
241
|
+
let saved = null;
|
|
242
|
+
try {
|
|
243
|
+
saved = await saveVersion(dir, message);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (!(err instanceof TeamGitError && err.code === 'nothing-to-save')) throw err;
|
|
246
|
+
}
|
|
247
|
+
const shared = await share(dir);
|
|
248
|
+
return {
|
|
249
|
+
saved: saved?.version ?? null,
|
|
250
|
+
shared: shared.shared,
|
|
251
|
+
commands: [...(saved?.commands ?? []), ...shared.commands],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Brings in the team's shared versions. Refuses while there are unsaved
|
|
257
|
+
* changes, so nothing of the villager's is ever overwritten. When both sides
|
|
258
|
+
* changed the same lines, returns the files unless `resolve` says whose wins.
|
|
259
|
+
*
|
|
260
|
+
* @param {{ resolve?: 'mine' | 'theirs' }} [opts]
|
|
261
|
+
*/
|
|
262
|
+
async function getLatest(dir, opts = {}) {
|
|
263
|
+
if (!(await remoteUrl(dir))) throw new TeamGitError('not-linked', NOT_LINKED);
|
|
264
|
+
const pending = await changes(dir);
|
|
265
|
+
if (pending.length > 0) {
|
|
266
|
+
throw new TeamGitError('unsaved', 'Save a version first, so none of your work gets overwritten.', pending.map((c) => c.path));
|
|
267
|
+
}
|
|
268
|
+
const commands = [`git fetch ${REMOTE}`, `git merge ${REMOTE}/${BRANCH}`];
|
|
269
|
+
await fetchTeam(dir);
|
|
270
|
+
const local = await oid(dir, BRANCH);
|
|
271
|
+
const remote = await oid(dir, REMOTE_REF);
|
|
272
|
+
if (!remote || local === remote) return { updated: false, files: [], commands };
|
|
273
|
+
if (local && (await git.isDescendent({ fs, dir, oid: local, ancestor: remote, depth: -1 }))) {
|
|
274
|
+
return { updated: false, files: [], commands };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const before = local;
|
|
278
|
+
const { author } = await getCredentials();
|
|
279
|
+
try {
|
|
280
|
+
await git.merge({
|
|
281
|
+
fs,
|
|
282
|
+
dir,
|
|
283
|
+
ours: BRANCH,
|
|
284
|
+
theirs: REMOTE_REF,
|
|
285
|
+
author,
|
|
286
|
+
message: "Get team's latest",
|
|
287
|
+
...(opts.resolve
|
|
288
|
+
? {
|
|
289
|
+
mergeDriver: ({ contents }) => ({
|
|
290
|
+
cleanMerge: true,
|
|
291
|
+
mergedText: opts.resolve === 'mine' ? contents[1] : contents[2],
|
|
292
|
+
}),
|
|
293
|
+
}
|
|
294
|
+
: {}),
|
|
295
|
+
});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (err?.code === 'MergeConflictError') {
|
|
298
|
+
throw new TeamGitError(
|
|
299
|
+
'conflict',
|
|
300
|
+
'You and a teammate changed the same part of these files. Choose whose changes to keep.',
|
|
301
|
+
err.data?.filepaths ?? [],
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (err?.code === 'MergeNotSupportedError') {
|
|
305
|
+
throw new TeamGitError('conflict', "You and a teammate changed the same files in ways that can't be combined automatically yet.");
|
|
306
|
+
}
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
await git.checkout({ fs, dir, ref: BRANCH });
|
|
310
|
+
const after = await oid(dir, BRANCH);
|
|
311
|
+
const files = before && after ? await changedBetween(dir, before, after) : [];
|
|
312
|
+
if (opts.resolve) commands.push(`# kept ${opts.resolve === 'mine' ? 'your' : "your teammate's"} side where you both changed the same lines`);
|
|
313
|
+
return { updated: true, files, commands };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function changedBetween(dir, a, b) {
|
|
317
|
+
const files = [];
|
|
318
|
+
await git.walk({
|
|
319
|
+
fs,
|
|
320
|
+
dir,
|
|
321
|
+
trees: [git.TREE({ ref: a }), git.TREE({ ref: b })],
|
|
322
|
+
map: async (filepath, [x, y]) => {
|
|
323
|
+
if (filepath === '.') return undefined;
|
|
324
|
+
const [tx, ty] = [await x?.type(), await y?.type()];
|
|
325
|
+
if (tx === 'tree' || ty === 'tree') return undefined;
|
|
326
|
+
if ((await x?.oid()) !== (await y?.oid())) files.push(filepath);
|
|
327
|
+
return undefined;
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
return files;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Connects an existing folder to a team project. The folder's files are
|
|
335
|
+
* kept; anything already in the project the folder doesn't have is added;
|
|
336
|
+
* then everything is saved and shared as the project's first version from
|
|
337
|
+
* this folder.
|
|
338
|
+
*/
|
|
339
|
+
async function link(dir, cloneUrl, title) {
|
|
340
|
+
const existing = await remoteUrl(dir);
|
|
341
|
+
if (existing === cloneUrl) return { status: await status(dir), commands: [] };
|
|
342
|
+
if (existing || (await oid(dir, 'HEAD'))) {
|
|
343
|
+
throw new TeamGitError(
|
|
344
|
+
'has-history',
|
|
345
|
+
'This folder already has its own git history. Make a copy without the .git folder, or ask a Lead to start the team project from it.',
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const commands = [
|
|
350
|
+
`git init -b ${BRANCH}`,
|
|
351
|
+
`git remote add ${REMOTE} ${cloneUrl}`,
|
|
352
|
+
`git fetch ${REMOTE}`,
|
|
353
|
+
];
|
|
354
|
+
await git.init({ fs, dir, defaultBranch: BRANCH });
|
|
355
|
+
await git.setConfig({ fs, dir, path: `remote.${REMOTE}.url`, value: cloneUrl });
|
|
356
|
+
await git.setConfig({ fs, dir, path: `remote.${REMOTE}.fetch`, value: `+refs/heads/*:refs/remotes/${REMOTE}/*` });
|
|
357
|
+
await git.setConfig({ fs, dir, path: `branch.${BRANCH}.remote`, value: REMOTE });
|
|
358
|
+
await git.setConfig({ fs, dir, path: `branch.${BRANCH}.merge`, value: `refs/heads/${BRANCH}` });
|
|
359
|
+
ensureIgnores(dir);
|
|
360
|
+
|
|
361
|
+
await fetchTeam(dir);
|
|
362
|
+
const remote = await oid(dir, REMOTE_REF);
|
|
363
|
+
if (remote) {
|
|
364
|
+
// Start from the project's history without touching the folder's files…
|
|
365
|
+
await git.writeRef({ fs, dir, ref: `refs/heads/${BRANCH}`, value: remote, force: true });
|
|
366
|
+
// The index must match that history too, or the first save would drop the project's files.
|
|
367
|
+
for (const filepath of await git.listFiles({ fs, dir, ref: BRANCH })) {
|
|
368
|
+
await git.resetIndex({ fs, dir, filepath });
|
|
369
|
+
}
|
|
370
|
+
// …and bring in project files this folder doesn't have (e.g. its README).
|
|
371
|
+
const missing = [];
|
|
372
|
+
for (const [filepath, head, workdir] of await git.statusMatrix({ fs, dir })) {
|
|
373
|
+
if (head === 1 && workdir === 0) missing.push(filepath);
|
|
374
|
+
}
|
|
375
|
+
if (missing.length) await git.checkout({ fs, dir, ref: BRANCH, filepaths: missing, force: true });
|
|
376
|
+
commands.push(`git reset ${REMOTE}/${BRANCH}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const saved = await saveVersion(dir, `Add ${title} to the team project`);
|
|
380
|
+
const shared = await share(dir);
|
|
381
|
+
return { status: await status(dir), commands: [...commands, ...saved.commands, ...shared.commands] };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Downloads a team project into `dir` (which must not exist or be empty). */
|
|
385
|
+
async function clone(cloneUrl, dir) {
|
|
386
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
|
|
387
|
+
throw new Error(`${dir} already has files in it. Choose an empty folder.`);
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await git.clone({ fs, http, dir, url: cloneUrl, ref: BRANCH, singleBranch: true, ...authHooks() });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
393
|
+
wrapAuth(err);
|
|
394
|
+
}
|
|
395
|
+
await git.setConfig({ fs, dir, path: `branch.${BRANCH}.remote`, value: REMOTE });
|
|
396
|
+
await git.setConfig({ fs, dir, path: `branch.${BRANCH}.merge`, value: `refs/heads/${BRANCH}` });
|
|
397
|
+
return { commands: [`git clone ${cloneUrl}`] };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function history(dir, depth = 30) {
|
|
401
|
+
if (!(await isRepo(dir))) return [];
|
|
402
|
+
try {
|
|
403
|
+
return (await git.log({ fs, dir, ref: BRANCH, depth })).map(toVersion);
|
|
404
|
+
} catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { status, saveVersion, share, push, getLatest, link, clone, history };
|
|
410
|
+
}
|
package/src/utils/templates.js
CHANGED
|
@@ -1147,6 +1147,7 @@ function generate2DGameAgents(name, gameType, orientation) {
|
|
|
1147
1147
|
' npm run dev # local dev server, live reload',
|
|
1148
1148
|
' npm run build # production build -> dist/ (must pass before deploy)',
|
|
1149
1149
|
' myvillage deploy # build, then upload + submit to MyVillage',
|
|
1150
|
+
' myvillage team push -m "what changed" # team projects: save a version + share it on Village Git',
|
|
1150
1151
|
'',
|
|
1151
1152
|
'Keep asset URLs relative (Vite `base: \'./\'`). The host serves the game from a sub-path, so absolute `/...` URLs will break.',
|
|
1152
1153
|
'',
|
|
@@ -1241,6 +1242,7 @@ function generate3DGameAgents(name, type, orientation) {
|
|
|
1241
1242
|
' npm run dev # local dev server, live reload',
|
|
1242
1243
|
' npm run build # production build -> dist/ (must pass before deploy)',
|
|
1243
1244
|
' myvillage deploy # build, then upload + submit to MyVillage',
|
|
1245
|
+
' myvillage team push -m "what changed" # team projects: save a version + share it on Village Git',
|
|
1244
1246
|
'',
|
|
1245
1247
|
'Keep asset URLs relative (Vite `base: \'./\'`). The host serves from a sub-path, so absolute `/...` URLs break.',
|
|
1246
1248
|
'',
|
|
@@ -1305,6 +1307,7 @@ function generateConvertedGameAgents(name, orientation) {
|
|
|
1305
1307
|
' npm run dev # local dev server, live reload',
|
|
1306
1308
|
' npm run build # production build -> dist/ (must pass before deploy)',
|
|
1307
1309
|
' myvillage deploy # build, then upload + submit to MyVillage',
|
|
1310
|
+
' myvillage team push -m "what changed" # team projects: save a version + share it on Village Git',
|
|
1308
1311
|
'',
|
|
1309
1312
|
'Keep asset URLs relative (Vite `base: \'./\'`). The host serves from a sub-path, so absolute `/...` URLs break.',
|
|
1310
1313
|
'',
|