@aikotools/repo-maintenance 1.0.2

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.
Files changed (29) hide show
  1. package/README.md +227 -0
  2. package/bin/loader.js +10 -0
  3. package/bin/repohub.js +5 -0
  4. package/dist/client/assets/index-Bh_JYZxI.js +15 -0
  5. package/dist/client/assets/index-D-CxJxP4.css +1 -0
  6. package/dist/client/index.html +13 -0
  7. package/dist/server/server/index.js +118 -0
  8. package/dist/server/server/services/bulk-service.js +157 -0
  9. package/dist/server/server/services/cascade-service.js +474 -0
  10. package/dist/server/server/services/config-service.js +343 -0
  11. package/dist/server/server/services/dependency-resolver.js +144 -0
  12. package/dist/server/server/services/git-service.js +320 -0
  13. package/dist/server/server/services/package-service.js +152 -0
  14. package/dist/server/server/services/process.js +51 -0
  15. package/dist/server/server/services/pull-all-service.js +415 -0
  16. package/dist/server/server/services/repo-scanner.js +230 -0
  17. package/dist/server/server/services/task-queue.js +29 -0
  18. package/dist/server/server/trpc/context.js +4 -0
  19. package/dist/server/server/trpc/init.js +7 -0
  20. package/dist/server/server/trpc/procedures/bulk.js +110 -0
  21. package/dist/server/server/trpc/procedures/cascade.js +207 -0
  22. package/dist/server/server/trpc/procedures/dependencies.js +26 -0
  23. package/dist/server/server/trpc/procedures/git.js +151 -0
  24. package/dist/server/server/trpc/procedures/package.js +43 -0
  25. package/dist/server/server/trpc/procedures/project.js +181 -0
  26. package/dist/server/server/trpc/procedures/repos.js +42 -0
  27. package/dist/server/server/trpc/router.js +20 -0
  28. package/dist/server/shared/types.js +3 -0
  29. package/package.json +68 -0
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Service for cascade updates - propagates changes through all transitive dependents
3
+ * in topological order. Supports parallel execution within layers, CI monitoring,
4
+ * and pause/abort controls.
5
+ */
6
+ import { readFile, writeFile } from 'fs/promises';
7
+ import path from 'path';
8
+ import { spawnProcess } from './process';
9
+ import { TaskQueue } from './task-queue';
10
+ export class CascadeService {
11
+ configService;
12
+ parallelLimit;
13
+ executions = new Map();
14
+ executionRepos = new Map();
15
+ abortSignals = new Map();
16
+ pauseSignals = new Map();
17
+ constructor(configService, parallelLimit) {
18
+ this.configService = configService;
19
+ this.parallelLimit = parallelLimit;
20
+ }
21
+ /**
22
+ * Build a cascade plan from a source repo through all affected repos.
23
+ * Resolves the source repo's published version from npm (single call).
24
+ * For transitive deps, keeps the current version spec (fast, no network).
25
+ */
26
+ async createPlan(sourceRepoId, repos, resolver, options) {
27
+ const affected = resolver.getAffected(sourceRepoId);
28
+ const repoMap = new Map(repos.map((r) => [r.id, r]));
29
+ const sourceRepo = repoMap.get(sourceRepoId);
30
+ // Resolve the source repo's latest published version (single npm call)
31
+ const sourcePublishedVersion = sourceRepo
32
+ ? await this.resolvePublishedVersion(sourceRepo.npmPackage)
33
+ : null;
34
+ const sourceVersion = sourcePublishedVersion || sourceRepo?.version || '0.0.0';
35
+ console.log(`[Cascade] Source ${sourceRepoId}: local=${sourceRepo?.version}, published=${sourcePublishedVersion}`);
36
+ // Group affected repos by layer
37
+ const layerMap = new Map();
38
+ for (const a of affected.affected) {
39
+ const existing = layerMap.get(a.layer) || [];
40
+ existing.push(a.id);
41
+ layerMap.set(a.layer, existing);
42
+ }
43
+ const layers = [];
44
+ const sortedLayers = Array.from(layerMap.keys()).sort((a, b) => a - b);
45
+ // Track resolved versions: only the source repo's published version is known
46
+ // Other repos' versions will be resolved at execution time (after CI publishes them)
47
+ const resolvedVersions = new Map();
48
+ if (sourceRepo) {
49
+ resolvedVersions.set(sourceRepo.npmPackage, sourceVersion);
50
+ }
51
+ for (const layerIndex of sortedLayers) {
52
+ const repoIds = layerMap.get(layerIndex) || [];
53
+ const steps = repoIds.map((repoId) => {
54
+ const repo = repoMap.get(repoId);
55
+ const depsToUpdate = [];
56
+ if (repo) {
57
+ for (const dep of repo.dependencies) {
58
+ const resolvedVersion = resolvedVersions.get(dep.npmName);
59
+ if (resolvedVersion) {
60
+ depsToUpdate.push({
61
+ npmName: dep.npmName,
62
+ fromVersion: dep.versionSpec,
63
+ toVersion: resolvedVersion,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ const depNames = depsToUpdate.map((d) => d.npmName.split('/').pop()).join(', ');
69
+ const commitMessage = `${options.commitPrefix}update ${depNames || repoId}`;
70
+ return {
71
+ repoId,
72
+ status: 'pending',
73
+ commitMessage,
74
+ depsToUpdate,
75
+ };
76
+ });
77
+ // For next layers: use the current version spec of repos in this layer
78
+ // (they haven't been republished yet, so we keep their existing version)
79
+ for (const step of steps) {
80
+ const repo = repoMap.get(step.repoId);
81
+ if (repo) {
82
+ resolvedVersions.set(repo.npmPackage, repo.version);
83
+ }
84
+ }
85
+ layers.push({
86
+ layerIndex,
87
+ mode: steps.length > 1 ? 'parallel' : 'sequential',
88
+ steps,
89
+ });
90
+ }
91
+ return {
92
+ sourceRepoId,
93
+ sourceCommitMessage: `Source: ${sourceRepoId} v${sourceVersion}`,
94
+ layers,
95
+ totalRepos: affected.totalCount,
96
+ waitForCi: options.waitForCi,
97
+ runTests: options.runTests,
98
+ commitPrefix: options.commitPrefix,
99
+ };
100
+ }
101
+ /**
102
+ * Start executing a cascade plan. Returns execution ID.
103
+ * The execution runs asynchronously in the background.
104
+ */
105
+ startExecution(plan, repos) {
106
+ const id = `cascade-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
107
+ const execution = {
108
+ id,
109
+ plan,
110
+ status: 'running',
111
+ currentLayerIndex: 0,
112
+ completedCount: 0,
113
+ failedCount: 0,
114
+ skippedCount: 0,
115
+ startedAt: new Date().toISOString(),
116
+ };
117
+ this.executions.set(id, execution);
118
+ this.executionRepos.set(id, repos);
119
+ this.abortSignals.set(id, false);
120
+ this.pauseSignals.set(id, false);
121
+ // Start async execution loop (fire and forget)
122
+ this.executeLoop(id).catch((err) => {
123
+ console.error(`[Cascade] Execution ${id} failed:`, err);
124
+ const exec = this.executions.get(id);
125
+ if (exec) {
126
+ exec.status = 'failed';
127
+ exec.error = err instanceof Error ? err.message : String(err);
128
+ exec.completedAt = new Date().toISOString();
129
+ }
130
+ });
131
+ return id;
132
+ }
133
+ getExecution(id) {
134
+ return this.executions.get(id);
135
+ }
136
+ listExecutions() {
137
+ return Array.from(this.executions.values()).sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
138
+ }
139
+ abort(id) {
140
+ const exec = this.executions.get(id);
141
+ if (!exec || exec.status !== 'running')
142
+ return false;
143
+ this.abortSignals.set(id, true);
144
+ return true;
145
+ }
146
+ pause(id) {
147
+ const exec = this.executions.get(id);
148
+ if (!exec || exec.status !== 'running')
149
+ return false;
150
+ this.pauseSignals.set(id, true);
151
+ return true;
152
+ }
153
+ resume(id) {
154
+ const exec = this.executions.get(id);
155
+ if (!exec || exec.status !== 'paused')
156
+ return false;
157
+ this.pauseSignals.set(id, false);
158
+ exec.status = 'running';
159
+ // Re-enter the execution loop with stored repos
160
+ this.executeLoop(id).catch((err) => {
161
+ console.error(`[Cascade] Resume failed for ${id}:`, err);
162
+ });
163
+ return true;
164
+ }
165
+ skipStep(executionId, repoId) {
166
+ const exec = this.executions.get(executionId);
167
+ if (!exec)
168
+ return false;
169
+ for (const layer of exec.plan.layers) {
170
+ const step = layer.steps.find((s) => s.repoId === repoId);
171
+ if (step && step.status === 'failed') {
172
+ step.status = 'skipped';
173
+ step.completedAt = new Date().toISOString();
174
+ exec.skippedCount++;
175
+ exec.failedCount--;
176
+ return true;
177
+ }
178
+ }
179
+ return false;
180
+ }
181
+ setPublishedVersion(executionId, repoId, version) {
182
+ const exec = this.executions.get(executionId);
183
+ if (!exec)
184
+ return false;
185
+ for (const layer of exec.plan.layers) {
186
+ const step = layer.steps.find((s) => s.repoId === repoId);
187
+ if (step) {
188
+ step.publishedVersion = version;
189
+ return true;
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+ // ── Private execution logic ──
195
+ async executeLoop(id) {
196
+ const exec = this.executions.get(id);
197
+ if (!exec)
198
+ return;
199
+ const repos = this.executionRepos.get(id) || [];
200
+ const repoMap = new Map(repos.map((r) => [r.id, r]));
201
+ for (let i = exec.currentLayerIndex; i < exec.plan.layers.length; i++) {
202
+ // Check abort
203
+ if (this.abortSignals.get(id)) {
204
+ exec.status = 'aborted';
205
+ exec.completedAt = new Date().toISOString();
206
+ await this.saveToHistory(exec);
207
+ return;
208
+ }
209
+ // Check pause
210
+ if (this.pauseSignals.get(id)) {
211
+ exec.status = 'paused';
212
+ exec.currentLayerIndex = i;
213
+ return;
214
+ }
215
+ exec.currentLayerIndex = i;
216
+ const layer = exec.plan.layers[i];
217
+ // Skip layers where all steps are already done/skipped
218
+ const pendingSteps = layer.steps.filter((s) => s.status !== 'done' && s.status !== 'skipped');
219
+ if (pendingSteps.length === 0)
220
+ continue;
221
+ // Execute steps in the layer
222
+ const queue = new TaskQueue(layer.mode === 'parallel'
223
+ ? Math.min(this.parallelLimit, pendingSteps.length)
224
+ : 1);
225
+ await queue.run(pendingSteps, async (step) => {
226
+ // Check abort before each step
227
+ if (this.abortSignals.get(id))
228
+ return;
229
+ await this.executeStep(step, repoMap, exec);
230
+ });
231
+ // After layer: check if any failures should stop execution
232
+ const failedSteps = layer.steps.filter((s) => s.status === 'failed');
233
+ if (failedSteps.length > 0) {
234
+ // Don't auto-fail entire cascade; user can skip or abort
235
+ // But pause to let user decide
236
+ exec.status = 'paused';
237
+ exec.currentLayerIndex = i;
238
+ console.log(`[Cascade] Layer ${i} has ${failedSteps.length} failed steps. Pausing for user action.`);
239
+ return;
240
+ }
241
+ // Wait for CI if enabled
242
+ if (exec.plan.waitForCi) {
243
+ const ciSteps = layer.steps.filter((s) => s.status === 'done' && !s.publishedVersion);
244
+ for (const step of ciSteps) {
245
+ step.ciStatus = 'pending';
246
+ await this.waitForCi(step, repoMap, id);
247
+ }
248
+ }
249
+ }
250
+ // All layers complete
251
+ exec.status = 'completed';
252
+ exec.completedAt = new Date().toISOString();
253
+ await this.saveToHistory(exec);
254
+ console.log(`[Cascade] Execution ${id} completed successfully.`);
255
+ }
256
+ async executeStep(step, repoMap, exec) {
257
+ const repo = repoMap.get(step.repoId);
258
+ if (!repo) {
259
+ step.status = 'failed';
260
+ step.error = 'Repo not found';
261
+ exec.failedCount++;
262
+ return;
263
+ }
264
+ step.startedAt = new Date().toISOString();
265
+ try {
266
+ // 1. Update deps in package.json
267
+ step.status = 'updating-deps';
268
+ await this.updatePackageJsonDeps(repo.absolutePath, step.depsToUpdate);
269
+ // 2. Install
270
+ step.status = 'installing';
271
+ await this.runCommand(repo.absolutePath, ['pnpm', 'install', '--no-frozen-lockfile']);
272
+ // 3. Test (optional)
273
+ if (exec.plan.runTests) {
274
+ step.status = 'testing';
275
+ await this.runCommand(repo.absolutePath, ['pnpm', 'test']);
276
+ }
277
+ // 4. Commit (skip if nothing changed)
278
+ step.status = 'committing';
279
+ await this.runCommand(repo.absolutePath, ['git', 'add', '-A']);
280
+ const statusOutput = await this.runCommand(repo.absolutePath, [
281
+ 'git',
282
+ 'status',
283
+ '--porcelain',
284
+ ]);
285
+ if (!statusOutput.trim()) {
286
+ // No changes to commit (version was already up to date)
287
+ step.status = 'done';
288
+ step.completedAt = new Date().toISOString();
289
+ exec.completedCount++;
290
+ return;
291
+ }
292
+ await this.runCommand(repo.absolutePath, ['git', 'commit', '-m', step.commitMessage]);
293
+ // 5. Pull rebase + Push
294
+ step.status = 'pushing';
295
+ try {
296
+ await this.runCommand(repo.absolutePath, ['git', 'pull', '--rebase']);
297
+ }
298
+ catch {
299
+ // pull may fail if no upstream tracking, ignore
300
+ }
301
+ await this.runCommand(repo.absolutePath, ['git', 'push']);
302
+ // Done
303
+ step.status = 'done';
304
+ step.completedAt = new Date().toISOString();
305
+ exec.completedCount++;
306
+ }
307
+ catch (err) {
308
+ step.status = 'failed';
309
+ step.error = err instanceof Error ? err.message : String(err);
310
+ step.completedAt = new Date().toISOString();
311
+ exec.failedCount++;
312
+ }
313
+ }
314
+ async updatePackageJsonDeps(repoPath, depsToUpdate) {
315
+ if (depsToUpdate.length === 0)
316
+ return;
317
+ const pkgPath = path.join(repoPath, 'package.json');
318
+ const content = await readFile(pkgPath, 'utf-8');
319
+ const pkg = JSON.parse(content);
320
+ for (const dep of depsToUpdate) {
321
+ if (pkg.dependencies?.[dep.npmName]) {
322
+ pkg.dependencies[dep.npmName] = dep.toVersion;
323
+ }
324
+ if (pkg.devDependencies?.[dep.npmName]) {
325
+ pkg.devDependencies[dep.npmName] = dep.toVersion;
326
+ }
327
+ if (pkg.peerDependencies?.[dep.npmName]) {
328
+ pkg.peerDependencies[dep.npmName] = dep.toVersion;
329
+ }
330
+ }
331
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
332
+ }
333
+ async runCommand(cwd, cmd) {
334
+ const { promise } = spawnProcess(cmd, { cwd });
335
+ const result = await promise;
336
+ if (result.exitCode !== 0) {
337
+ throw new Error(`Command failed (${cmd.join(' ')}): ${result.stderr || result.stdout}`);
338
+ }
339
+ return result.stdout;
340
+ }
341
+ async waitForCi(step, repoMap, executionId) {
342
+ const repo = repoMap.get(step.repoId);
343
+ if (!repo)
344
+ return;
345
+ const slug = await this.getGitHubSlug(repo.absolutePath);
346
+ if (!slug) {
347
+ step.ciStatus = 'skipped';
348
+ return;
349
+ }
350
+ // Poll CI status every 15 seconds, max 20 minutes
351
+ const maxAttempts = 80;
352
+ step.ciStatus = 'pending';
353
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
354
+ // Check abort
355
+ if (this.abortSignals.get(executionId))
356
+ return;
357
+ await new Promise((resolve) => setTimeout(resolve, 15_000));
358
+ try {
359
+ const result = await this.checkCiStatus(slug);
360
+ step.ciStatus = result.status;
361
+ step.ciRunUrl = result.url;
362
+ if (result.status === 'success') {
363
+ // Try to resolve published version
364
+ step.publishedVersion = (await this.resolvePublishedVersion(repo.npmPackage)) || undefined;
365
+ return;
366
+ }
367
+ if (result.status === 'failure') {
368
+ return;
369
+ }
370
+ }
371
+ catch {
372
+ // gh CLI not available or error
373
+ step.ciStatus = 'skipped';
374
+ return;
375
+ }
376
+ }
377
+ // Timeout
378
+ step.ciStatus = 'failure';
379
+ step.error = 'CI monitoring timed out after 20 minutes';
380
+ }
381
+ async getGitHubSlug(repoPath) {
382
+ try {
383
+ const result = await this.runCommand(repoPath, [
384
+ 'git',
385
+ 'remote',
386
+ 'get-url',
387
+ 'origin',
388
+ ]);
389
+ const url = result.trim();
390
+ // Parse: https://github.com/org/repo.git or git@github.com:org/repo.git
391
+ const match = url.match(/github\.com[/:](.+?)(?:\.git)?$/) ||
392
+ url.match(/github\.com[/:](.+)$/);
393
+ return match?.[1] || null;
394
+ }
395
+ catch {
396
+ return null;
397
+ }
398
+ }
399
+ async checkCiStatus(slug) {
400
+ try {
401
+ const output = await this.runCommand('.', [
402
+ 'gh',
403
+ 'run',
404
+ 'list',
405
+ '--repo',
406
+ slug,
407
+ '--limit',
408
+ '1',
409
+ '--json',
410
+ 'status,conclusion,url',
411
+ ]);
412
+ const runs = JSON.parse(output);
413
+ if (!runs || runs.length === 0) {
414
+ return { status: 'pending' };
415
+ }
416
+ const run = runs[0];
417
+ const url = run.url;
418
+ if (run.status === 'completed') {
419
+ return {
420
+ status: run.conclusion === 'success' ? 'success' : 'failure',
421
+ url,
422
+ };
423
+ }
424
+ if (run.status === 'in_progress' || run.status === 'queued') {
425
+ return { status: 'running', url };
426
+ }
427
+ return { status: 'pending', url };
428
+ }
429
+ catch {
430
+ throw new Error('gh CLI unavailable');
431
+ }
432
+ }
433
+ /**
434
+ * Resolve the latest published version of an npm package from the registry.
435
+ * Returns null if not found or registry unavailable.
436
+ */
437
+ async resolvePublishedVersion(npmPackage) {
438
+ try {
439
+ const config = await this.configService.getProjectConfig();
440
+ const registry = config.npmRegistry || 'https://npm.pkg.github.com';
441
+ const output = await this.runCommand('.', [
442
+ 'npm',
443
+ 'view',
444
+ `${npmPackage}@latest`,
445
+ 'version',
446
+ `--registry=${registry}`,
447
+ ]);
448
+ const version = output.trim();
449
+ return version || null;
450
+ }
451
+ catch {
452
+ return null;
453
+ }
454
+ }
455
+ async saveToHistory(exec) {
456
+ const entry = {
457
+ id: exec.id,
458
+ sourceRepoId: exec.plan.sourceRepoId,
459
+ status: exec.status,
460
+ totalRepos: exec.plan.totalRepos,
461
+ completedCount: exec.completedCount,
462
+ failedCount: exec.failedCount,
463
+ startedAt: exec.startedAt,
464
+ completedAt: exec.completedAt,
465
+ layers: exec.plan.layers,
466
+ };
467
+ try {
468
+ await this.configService.saveHistory(entry);
469
+ }
470
+ catch (err) {
471
+ console.error('[Cascade] Failed to save history:', err);
472
+ }
473
+ }
474
+ }