@thxgg/steward 0.1.23 → 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,183 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { promises as fs } from 'node:fs';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dbAll } from './db.js';
7
+ import { getRepos } from './repos.js';
8
+ import { createSyncFieldHashes, parseSyncBundle } from './sync-schema.js';
9
+ import { ensureRepoSyncMetaForRepos, getOrCreateSyncDeviceId } from './sync-identity.js';
10
+ import { parseStoredProgressFile, parseTasksFile } from './state-schema.js';
11
+ function parseStoredJson(rawValue, fieldName, parseValue) {
12
+ if (!rawValue) {
13
+ return null;
14
+ }
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(rawValue);
18
+ }
19
+ catch (error) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ throw new Error(`Invalid JSON stored in ${fieldName}: ${message}`);
22
+ }
23
+ return parseValue(parsed);
24
+ }
25
+ function resolvePathHint(path, mode) {
26
+ if (mode === 'none') {
27
+ return undefined;
28
+ }
29
+ if (mode === 'absolute') {
30
+ return resolve(path);
31
+ }
32
+ return basename(resolve(path));
33
+ }
34
+ function toClockValue(primary, fallback, hasValue) {
35
+ if (primary) {
36
+ return primary;
37
+ }
38
+ if (hasValue) {
39
+ return fallback;
40
+ }
41
+ return null;
42
+ }
43
+ function findPackageRoot(startDir) {
44
+ let currentDir = startDir;
45
+ while (true) {
46
+ const packageJsonPath = join(currentDir, 'package.json');
47
+ if (existsSync(packageJsonPath)) {
48
+ return currentDir;
49
+ }
50
+ const parentDir = dirname(currentDir);
51
+ if (parentDir === currentDir) {
52
+ return startDir;
53
+ }
54
+ currentDir = parentDir;
55
+ }
56
+ }
57
+ async function readStewardVersion() {
58
+ const packageRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
59
+ const packageJsonPath = join(packageRoot, 'package.json');
60
+ try {
61
+ const contents = await fs.readFile(packageJsonPath, 'utf-8');
62
+ const parsed = JSON.parse(contents);
63
+ if (typeof parsed.version === 'string' && parsed.version.trim().length > 0) {
64
+ return parsed.version;
65
+ }
66
+ }
67
+ catch {
68
+ // Fall back to unknown when package metadata cannot be loaded.
69
+ }
70
+ return 'unknown';
71
+ }
72
+ export async function buildSyncBundle(options = {}) {
73
+ const pathHintsMode = options.pathHints || 'basename';
74
+ const createdAt = options.createdAt || new Date().toISOString();
75
+ const [allRepos, sourceDeviceId, stewardVersion] = await Promise.all([
76
+ getRepos(),
77
+ getOrCreateSyncDeviceId(),
78
+ options.stewardVersion ? Promise.resolve(options.stewardVersion) : readStewardVersion()
79
+ ]);
80
+ const filteredRepoIds = new Set(Array.isArray(options.repoIds)
81
+ ? options.repoIds.filter((repoId) => typeof repoId === 'string' && repoId.trim().length > 0)
82
+ : []);
83
+ const repos = filteredRepoIds.size > 0
84
+ ? allRepos.filter((repo) => filteredRepoIds.has(repo.id))
85
+ : allRepos;
86
+ const repoMetaById = await ensureRepoSyncMetaForRepos(repos);
87
+ const repoIds = repos.map((repo) => repo.id);
88
+ let stateRows = [];
89
+ let archiveRows = [];
90
+ if (repoIds.length > 0) {
91
+ const placeholders = repoIds.map(() => '?').join(', ');
92
+ stateRows = await dbAll(`
93
+ SELECT
94
+ repo_id,
95
+ slug,
96
+ tasks_json,
97
+ progress_json,
98
+ notes_md,
99
+ updated_at,
100
+ tasks_updated_at,
101
+ progress_updated_at,
102
+ notes_updated_at
103
+ FROM prd_states
104
+ WHERE repo_id IN (${placeholders})
105
+ ORDER BY repo_id ASC, slug ASC
106
+ `, repoIds);
107
+ archiveRows = await dbAll(`
108
+ SELECT repo_id, slug, archived_at
109
+ FROM prd_archives
110
+ WHERE repo_id IN (${placeholders})
111
+ ORDER BY repo_id ASC, slug ASC
112
+ `, repoIds);
113
+ }
114
+ const syncRepos = repos.map((repo) => {
115
+ const repoMeta = repoMetaById.get(repo.id);
116
+ if (!repoMeta) {
117
+ throw new Error(`Missing sync metadata for repository ${repo.id}`);
118
+ }
119
+ const pathHint = resolvePathHint(repo.path, pathHintsMode);
120
+ return {
121
+ repoSyncKey: repoMeta.syncKey,
122
+ name: repo.name,
123
+ ...(pathHint && { pathHint }),
124
+ fingerprint: repoMeta.fingerprint,
125
+ fingerprintKind: repoMeta.fingerprintKind
126
+ };
127
+ });
128
+ const states = stateRows.map((row) => {
129
+ const repoMeta = repoMetaById.get(row.repo_id);
130
+ if (!repoMeta) {
131
+ throw new Error(`Missing sync metadata for repository ${row.repo_id}`);
132
+ }
133
+ const tasks = parseStoredJson(row.tasks_json, 'prd_states.tasks_json', parseTasksFile);
134
+ const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', (value) => parseStoredProgressFile(value, {
135
+ totalTasksHint: Array.isArray(tasks?.tasks) ? tasks.tasks.length : undefined,
136
+ prdNameFallback: tasks?.prd?.name || row.slug
137
+ }));
138
+ const clocks = {
139
+ tasksUpdatedAt: toClockValue(row.tasks_updated_at, row.updated_at, tasks !== null),
140
+ progressUpdatedAt: toClockValue(row.progress_updated_at, row.updated_at, progress !== null),
141
+ notesUpdatedAt: toClockValue(row.notes_updated_at, row.updated_at, row.notes_md !== null)
142
+ };
143
+ const hashes = createSyncFieldHashes({
144
+ tasks,
145
+ progress,
146
+ notes: row.notes_md
147
+ });
148
+ return {
149
+ repoSyncKey: repoMeta.syncKey,
150
+ slug: row.slug,
151
+ tasks,
152
+ progress,
153
+ notes: row.notes_md,
154
+ clocks,
155
+ hashes
156
+ };
157
+ });
158
+ const archives = archiveRows.map((row) => {
159
+ const repoMeta = repoMetaById.get(row.repo_id);
160
+ if (!repoMeta) {
161
+ throw new Error(`Missing sync metadata for repository ${row.repo_id}`);
162
+ }
163
+ return {
164
+ repoSyncKey: repoMeta.syncKey,
165
+ slug: row.slug,
166
+ archivedAt: row.archived_at
167
+ };
168
+ });
169
+ return parseSyncBundle({
170
+ type: 'steward-sync-bundle',
171
+ formatVersion: 1,
172
+ bundleId: options.bundleId || randomUUID(),
173
+ createdAt,
174
+ sourceDeviceId,
175
+ stewardVersion,
176
+ repos: syncRepos,
177
+ states,
178
+ archives
179
+ });
180
+ }
181
+ export function serializeSyncBundle(bundle) {
182
+ return JSON.stringify(bundle, null, 2);
183
+ }
@@ -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
+ }