@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.
@@ -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
+ }