@thxgg/steward 0.1.24 → 0.1.25
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/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/9ce7f1bc-d5e2-47bf-8026-f4910c257b2e.json +1 -0
- package/.output/server/chunks/_/prd-service.mjs.map +1 -1
- package/.output/server/chunks/nitro/nitro.mjs +818 -516
- package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
- package/.output/server/package.json +1 -1
- package/README.md +41 -0
- package/bin/prd +1 -1
- package/dist/host/src/index.js +10 -0
- package/dist/host/src/sync.js +201 -0
- package/dist/server/utils/db.js +64 -0
- package/dist/server/utils/prd-state.js +24 -2
- package/dist/server/utils/repos.js +12 -2
- package/dist/server/utils/state-migration.js +4 -3
- package/dist/server/utils/sync-apply.js +380 -0
- package/dist/server/utils/sync-export.js +183 -0
- package/dist/server/utils/sync-identity.js +231 -0
- package/dist/server/utils/sync-inspect.js +103 -0
- package/dist/server/utils/sync-merge.js +579 -0
- package/dist/server/utils/sync-schema.js +100 -0
- package/package.json +1 -1
- package/.output/public/_nuxt/builds/meta/8c342d49-fe70-4f67-a987-821c16f86125.json +0 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { dbGet, dbRun } from './db.js';
|
|
6
|
+
import { isGitRepo } from './git.js';
|
|
7
|
+
const SYNC_DEVICE_ID_KEY = 'sync:device-id';
|
|
8
|
+
const SYNC_KEY_PREFIX = 'rsk_';
|
|
9
|
+
const GIT_REMOTE_FINGERPRINT_KIND = 'git-remotes-v1';
|
|
10
|
+
const REPO_SHAPE_FINGERPRINT_KIND = 'repo-shape-v1';
|
|
11
|
+
function nowIso() {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
function sha256Hex(value) {
|
|
15
|
+
return createHash('sha256').update(value).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
function normalizePathSlashes(path) {
|
|
18
|
+
return path.replaceAll('\\', '/');
|
|
19
|
+
}
|
|
20
|
+
function createSyncKey() {
|
|
21
|
+
return `${SYNC_KEY_PREFIX}${randomUUID().replaceAll('-', '')}`;
|
|
22
|
+
}
|
|
23
|
+
async function execGit(repoPath, args) {
|
|
24
|
+
return new Promise((resolvePromise) => {
|
|
25
|
+
const proc = spawn('git', args, {
|
|
26
|
+
cwd: repoPath,
|
|
27
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
28
|
+
});
|
|
29
|
+
let stdout = '';
|
|
30
|
+
proc.stdout.on('data', (data) => {
|
|
31
|
+
stdout += data.toString();
|
|
32
|
+
});
|
|
33
|
+
proc.on('close', (code) => {
|
|
34
|
+
resolvePromise({
|
|
35
|
+
ok: code === 0,
|
|
36
|
+
stdout
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
proc.on('error', () => {
|
|
40
|
+
resolvePromise({ ok: false, stdout: '' });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function normalizeRemoteUrl(url) {
|
|
45
|
+
const trimmed = url.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
const scpLikeMatch = trimmed.match(/^([^@/\s]+)@([^:/\s]+):(.+)$/);
|
|
50
|
+
const candidate = scpLikeMatch
|
|
51
|
+
? `ssh://${scpLikeMatch[1]}@${scpLikeMatch[2]}/${scpLikeMatch[3]}`
|
|
52
|
+
: trimmed;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL(candidate);
|
|
55
|
+
const protocol = parsed.protocol.toLowerCase();
|
|
56
|
+
const username = parsed.username ? `${parsed.username}@` : '';
|
|
57
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
58
|
+
const port = parsed.port ? `:${parsed.port}` : '';
|
|
59
|
+
let pathname = parsed.pathname.replace(/\/+/g, '/').replace(/\.git$/i, '').replace(/\/$/, '');
|
|
60
|
+
pathname = pathname.toLowerCase();
|
|
61
|
+
return `${protocol}//${username}${hostname}${port}${pathname}`;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return candidate.replace(/\.git$/i, '').toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function mapRepoSyncMetaRow(row) {
|
|
68
|
+
return {
|
|
69
|
+
repoId: row.repo_id,
|
|
70
|
+
syncKey: row.sync_key,
|
|
71
|
+
fingerprint: row.fingerprint || '',
|
|
72
|
+
fingerprintKind: row.fingerprint_kind || '',
|
|
73
|
+
updatedAt: row.updated_at
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function getGitRoots(repo) {
|
|
77
|
+
const roots = new Map();
|
|
78
|
+
roots.set(resolve(repo.path), {
|
|
79
|
+
repoRef: '',
|
|
80
|
+
path: repo.path
|
|
81
|
+
});
|
|
82
|
+
for (const gitRepo of repo.gitRepos || []) {
|
|
83
|
+
const repoRef = normalizePathSlashes(gitRepo.relativePath).replace(/^\.\//, '').replace(/\/$/, '');
|
|
84
|
+
if (!repoRef) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
roots.set(resolve(gitRepo.absolutePath), {
|
|
88
|
+
repoRef,
|
|
89
|
+
path: gitRepo.absolutePath
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return Array.from(roots.values());
|
|
93
|
+
}
|
|
94
|
+
async function readGitRemoteSignatures(repo) {
|
|
95
|
+
const roots = getGitRoots(repo);
|
|
96
|
+
const signatures = new Set();
|
|
97
|
+
for (const root of roots) {
|
|
98
|
+
if (!await isGitRepo(root.path)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const result = await execGit(root.path, ['config', '--get-regexp', '^remote\\..*\\.url$']);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const lines = result.stdout
|
|
106
|
+
.split('\n')
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter((line) => line.length > 0);
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const firstSpace = line.indexOf(' ');
|
|
111
|
+
const rawUrl = firstSpace >= 0 ? line.slice(firstSpace + 1).trim() : line;
|
|
112
|
+
const normalized = normalizeRemoteUrl(rawUrl);
|
|
113
|
+
if (!normalized) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
signatures.add(`${root.repoRef}:${normalized}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Array.from(signatures).sort((a, b) => a.localeCompare(b));
|
|
120
|
+
}
|
|
121
|
+
async function readPrdSlugs(repoPath) {
|
|
122
|
+
try {
|
|
123
|
+
const files = await fs.readdir(join(repoPath, 'docs', 'prd'));
|
|
124
|
+
return files
|
|
125
|
+
.filter((file) => file.endsWith('.md'))
|
|
126
|
+
.map((file) => basename(file, '.md'))
|
|
127
|
+
.sort((a, b) => a.localeCompare(b));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export async function calculateRepoFingerprint(repo) {
|
|
134
|
+
const remoteSignatures = await readGitRemoteSignatures(repo);
|
|
135
|
+
if (remoteSignatures.length > 0) {
|
|
136
|
+
return {
|
|
137
|
+
fingerprint: sha256Hex(JSON.stringify(remoteSignatures)),
|
|
138
|
+
fingerprintKind: GIT_REMOTE_FINGERPRINT_KIND
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const prdSlugs = await readPrdSlugs(repo.path);
|
|
142
|
+
const nestedRepos = (repo.gitRepos || [])
|
|
143
|
+
.map((gitRepo) => normalizePathSlashes(gitRepo.relativePath).replace(/^\.\//, '').replace(/\/$/, ''))
|
|
144
|
+
.filter((value) => value.length > 0)
|
|
145
|
+
.sort((a, b) => a.localeCompare(b));
|
|
146
|
+
return {
|
|
147
|
+
fingerprint: sha256Hex(JSON.stringify({
|
|
148
|
+
name: repo.name.trim().toLowerCase(),
|
|
149
|
+
prdSlugs,
|
|
150
|
+
nestedRepos
|
|
151
|
+
})),
|
|
152
|
+
fingerprintKind: REPO_SHAPE_FINGERPRINT_KIND
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export async function getOrCreateSyncDeviceId() {
|
|
156
|
+
const existing = await dbGet('SELECT value FROM app_meta WHERE key = ?', [SYNC_DEVICE_ID_KEY]);
|
|
157
|
+
if (existing?.value) {
|
|
158
|
+
return existing.value;
|
|
159
|
+
}
|
|
160
|
+
const createdAt = nowIso();
|
|
161
|
+
const generatedValue = randomUUID();
|
|
162
|
+
await dbRun(`
|
|
163
|
+
INSERT INTO app_meta (key, value, updated_at)
|
|
164
|
+
VALUES (?, ?, ?)
|
|
165
|
+
ON CONFLICT(key) DO NOTHING
|
|
166
|
+
`, [SYNC_DEVICE_ID_KEY, generatedValue, createdAt]);
|
|
167
|
+
const resolved = await dbGet('SELECT value FROM app_meta WHERE key = ?', [SYNC_DEVICE_ID_KEY]);
|
|
168
|
+
if (!resolved?.value) {
|
|
169
|
+
throw new Error('Failed to initialize sync device identifier');
|
|
170
|
+
}
|
|
171
|
+
return resolved.value;
|
|
172
|
+
}
|
|
173
|
+
export async function getRepoSyncMeta(repoId) {
|
|
174
|
+
const row = await dbGet(`
|
|
175
|
+
SELECT repo_id, sync_key, fingerprint, fingerprint_kind, updated_at
|
|
176
|
+
FROM repo_sync_meta
|
|
177
|
+
WHERE repo_id = ?
|
|
178
|
+
`, [repoId]);
|
|
179
|
+
return row ? mapRepoSyncMetaRow(row) : null;
|
|
180
|
+
}
|
|
181
|
+
export async function ensureRepoSyncMetaForRepo(repo) {
|
|
182
|
+
await getOrCreateSyncDeviceId();
|
|
183
|
+
const { fingerprint, fingerprintKind } = await calculateRepoFingerprint(repo);
|
|
184
|
+
const existing = await dbGet(`
|
|
185
|
+
SELECT repo_id, sync_key, fingerprint, fingerprint_kind, updated_at
|
|
186
|
+
FROM repo_sync_meta
|
|
187
|
+
WHERE repo_id = ?
|
|
188
|
+
`, [repo.id]);
|
|
189
|
+
const updatedAt = nowIso();
|
|
190
|
+
if (!existing) {
|
|
191
|
+
const created = {
|
|
192
|
+
repoId: repo.id,
|
|
193
|
+
syncKey: createSyncKey(),
|
|
194
|
+
fingerprint,
|
|
195
|
+
fingerprintKind,
|
|
196
|
+
updatedAt
|
|
197
|
+
};
|
|
198
|
+
await dbRun(`
|
|
199
|
+
INSERT INTO repo_sync_meta (repo_id, sync_key, fingerprint, fingerprint_kind, updated_at)
|
|
200
|
+
VALUES (?, ?, ?, ?, ?)
|
|
201
|
+
`, [created.repoId, created.syncKey, created.fingerprint, created.fingerprintKind, created.updatedAt]);
|
|
202
|
+
return created;
|
|
203
|
+
}
|
|
204
|
+
if (existing.fingerprint !== fingerprint || existing.fingerprint_kind !== fingerprintKind) {
|
|
205
|
+
await dbRun(`
|
|
206
|
+
UPDATE repo_sync_meta
|
|
207
|
+
SET fingerprint = ?, fingerprint_kind = ?, updated_at = ?
|
|
208
|
+
WHERE repo_id = ?
|
|
209
|
+
`, [fingerprint, fingerprintKind, updatedAt, repo.id]);
|
|
210
|
+
return {
|
|
211
|
+
repoId: existing.repo_id,
|
|
212
|
+
syncKey: existing.sync_key,
|
|
213
|
+
fingerprint,
|
|
214
|
+
fingerprintKind,
|
|
215
|
+
updatedAt
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return mapRepoSyncMetaRow(existing);
|
|
219
|
+
}
|
|
220
|
+
export async function ensureRepoSyncMetaForRepos(repos) {
|
|
221
|
+
const metaByRepoId = new Map();
|
|
222
|
+
if (repos.length === 0) {
|
|
223
|
+
return metaByRepoId;
|
|
224
|
+
}
|
|
225
|
+
await getOrCreateSyncDeviceId();
|
|
226
|
+
for (const repo of repos) {
|
|
227
|
+
const meta = await ensureRepoSyncMetaForRepo(repo);
|
|
228
|
+
metaByRepoId.set(repo.id, meta);
|
|
229
|
+
}
|
|
230
|
+
return metaByRepoId;
|
|
231
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { parseSyncBundle } from './sync-schema.js';
|
|
2
|
+
function uniqueSorted(values) {
|
|
3
|
+
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
|
4
|
+
}
|
|
5
|
+
function sortByKey(values) {
|
|
6
|
+
return [...values].sort((a, b) => a.repoSyncKey.localeCompare(b.repoSyncKey));
|
|
7
|
+
}
|
|
8
|
+
function toRepoInspection(accumulator) {
|
|
9
|
+
const stateSlugs = uniqueSorted(accumulator.stateSlugs);
|
|
10
|
+
const archiveSlugs = uniqueSorted(accumulator.archiveSlugs);
|
|
11
|
+
return {
|
|
12
|
+
repoSyncKey: accumulator.repo.repoSyncKey,
|
|
13
|
+
name: accumulator.repo.name,
|
|
14
|
+
...(accumulator.repo.pathHint ? { pathHint: accumulator.repo.pathHint } : {}),
|
|
15
|
+
fingerprint: accumulator.repo.fingerprint,
|
|
16
|
+
fingerprintKind: accumulator.repo.fingerprintKind,
|
|
17
|
+
stateCount: stateSlugs.length,
|
|
18
|
+
archiveCount: archiveSlugs.length,
|
|
19
|
+
stateSlugs,
|
|
20
|
+
archiveSlugs
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function collectUnknownRepoRows(rows, knownRepoKeys) {
|
|
24
|
+
const slugsByRepoKey = new Map();
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
if (knownRepoKeys.has(row.repoSyncKey)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const current = slugsByRepoKey.get(row.repoSyncKey);
|
|
30
|
+
if (current) {
|
|
31
|
+
current.push(row.slug);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
slugsByRepoKey.set(row.repoSyncKey, [row.slug]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return Array.from(slugsByRepoKey.entries())
|
|
38
|
+
.map(([repoSyncKey, slugs]) => ({
|
|
39
|
+
repoSyncKey,
|
|
40
|
+
slugs: uniqueSorted(slugs)
|
|
41
|
+
}))
|
|
42
|
+
.sort((a, b) => a.repoSyncKey.localeCompare(b.repoSyncKey));
|
|
43
|
+
}
|
|
44
|
+
export function inspectSyncBundle(bundleInput) {
|
|
45
|
+
const bundle = parseSyncBundle(bundleInput);
|
|
46
|
+
const repoAccumulators = new Map();
|
|
47
|
+
for (const repo of sortByKey(bundle.repos)) {
|
|
48
|
+
repoAccumulators.set(repo.repoSyncKey, {
|
|
49
|
+
repo,
|
|
50
|
+
stateSlugs: [],
|
|
51
|
+
archiveSlugs: []
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const state of bundle.states) {
|
|
55
|
+
const accumulator = repoAccumulators.get(state.repoSyncKey);
|
|
56
|
+
if (!accumulator) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
accumulator.stateSlugs.push(state.slug);
|
|
60
|
+
}
|
|
61
|
+
for (const archive of bundle.archives) {
|
|
62
|
+
const accumulator = repoAccumulators.get(archive.repoSyncKey);
|
|
63
|
+
if (!accumulator) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
accumulator.archiveSlugs.push(archive.slug);
|
|
67
|
+
}
|
|
68
|
+
const repos = Array.from(repoAccumulators.values())
|
|
69
|
+
.map((entry) => toRepoInspection(entry))
|
|
70
|
+
.sort((a, b) => a.repoSyncKey.localeCompare(b.repoSyncKey));
|
|
71
|
+
const knownRepoKeys = new Set(bundle.repos.map((repo) => repo.repoSyncKey));
|
|
72
|
+
const unknownRepoStates = collectUnknownRepoRows(bundle.states, knownRepoKeys);
|
|
73
|
+
const unknownRepoArchives = collectUnknownRepoRows(bundle.archives, knownRepoKeys);
|
|
74
|
+
return {
|
|
75
|
+
type: bundle.type,
|
|
76
|
+
formatVersion: bundle.formatVersion,
|
|
77
|
+
bundleId: bundle.bundleId,
|
|
78
|
+
createdAt: bundle.createdAt,
|
|
79
|
+
sourceDeviceId: bundle.sourceDeviceId,
|
|
80
|
+
stewardVersion: bundle.stewardVersion,
|
|
81
|
+
totals: {
|
|
82
|
+
repos: bundle.repos.length,
|
|
83
|
+
states: bundle.states.length,
|
|
84
|
+
archives: bundle.archives.length,
|
|
85
|
+
unknownRepoStates: unknownRepoStates.reduce((sum, item) => sum + item.slugs.length, 0),
|
|
86
|
+
unknownRepoArchives: unknownRepoArchives.reduce((sum, item) => sum + item.slugs.length, 0)
|
|
87
|
+
},
|
|
88
|
+
repos,
|
|
89
|
+
unknownRepoStates,
|
|
90
|
+
unknownRepoArchives
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function inspectSyncBundleJson(jsonPayload) {
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(jsonPayload);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
throw new Error(`Invalid bundle JSON: ${message}`);
|
|
101
|
+
}
|
|
102
|
+
return inspectSyncBundle(parsed);
|
|
103
|
+
}
|