@serve.zone/gitops 2.13.1

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 (258) hide show
  1. package/.smartconfig.json +114 -0
  2. package/binary/gitops.ts +4 -0
  3. package/changelog.md +185 -0
  4. package/cli.child.js +4 -0
  5. package/cli.js +4 -0
  6. package/cli.ts.js +5 -0
  7. package/deno.json +10 -0
  8. package/dist_serve/bundle.js +36362 -0
  9. package/dist_serve/index.html +33 -0
  10. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  11. package/dist_ts/00_commitinfo_data.js +9 -0
  12. package/dist_ts/cache/classes.cache.cleaner.d.ts +23 -0
  13. package/dist_ts/cache/classes.cache.cleaner.js +61 -0
  14. package/dist_ts/cache/classes.cached.document.d.ts +30 -0
  15. package/dist_ts/cache/classes.cached.document.js +101 -0
  16. package/dist_ts/cache/classes.cachedb.d.ts +22 -0
  17. package/dist_ts/cache/classes.cachedb.js +58 -0
  18. package/dist_ts/cache/classes.secrets.scan.service.d.ts +51 -0
  19. package/dist_ts/cache/classes.secrets.scan.service.js +237 -0
  20. package/dist_ts/cache/documents/classes.cached.project.d.ts +13 -0
  21. package/dist_ts/cache/documents/classes.cached.project.js +101 -0
  22. package/dist_ts/cache/documents/classes.cached.secret.d.ts +24 -0
  23. package/dist_ts/cache/documents/classes.cached.secret.js +158 -0
  24. package/dist_ts/cache/documents/index.d.ts +2 -0
  25. package/dist_ts/cache/documents/index.js +3 -0
  26. package/dist_ts/cache/index.d.ts +7 -0
  27. package/dist_ts/cache/index.js +6 -0
  28. package/dist_ts/classes/actionlog.d.ts +19 -0
  29. package/dist_ts/classes/actionlog.js +44 -0
  30. package/dist_ts/classes/connectionmanager.d.ts +57 -0
  31. package/dist_ts/classes/connectionmanager.js +247 -0
  32. package/dist_ts/classes/gitopsapp.d.ts +30 -0
  33. package/dist_ts/classes/gitopsapp.js +101 -0
  34. package/dist_ts/classes/jobmanager.d.ts +47 -0
  35. package/dist_ts/classes/jobmanager.js +301 -0
  36. package/dist_ts/classes/jobrunners/autobookstackdocs.runner.d.ts +29 -0
  37. package/dist_ts/classes/jobrunners/autobookstackdocs.runner.js +361 -0
  38. package/dist_ts/classes/jobrunners/base.jobrunner.d.ts +14 -0
  39. package/dist_ts/classes/jobrunners/base.jobrunner.js +3 -0
  40. package/dist_ts/classes/jobrunners/index.d.ts +5 -0
  41. package/dist_ts/classes/jobrunners/index.js +14 -0
  42. package/dist_ts/classes/managedsecrets.manager.d.ts +47 -0
  43. package/dist_ts/classes/managedsecrets.manager.js +247 -0
  44. package/dist_ts/classes/syncmanager.d.ts +189 -0
  45. package/dist_ts/classes/syncmanager.js +1787 -0
  46. package/dist_ts/index.d.ts +6 -0
  47. package/dist_ts/index.js +32 -0
  48. package/dist_ts/logging.d.ts +49 -0
  49. package/dist_ts/logging.js +134 -0
  50. package/dist_ts/opsserver/classes.opsserver.d.ts +25 -0
  51. package/dist_ts/opsserver/classes.opsserver.js +70 -0
  52. package/dist_ts/opsserver/handlers/actionlog.handler.d.ts +9 -0
  53. package/dist_ts/opsserver/handlers/actionlog.handler.js +24 -0
  54. package/dist_ts/opsserver/handlers/actions.handler.d.ts +9 -0
  55. package/dist_ts/opsserver/handlers/actions.handler.js +38 -0
  56. package/dist_ts/opsserver/handlers/admin.handler.d.ts +19 -0
  57. package/dist_ts/opsserver/handlers/admin.handler.js +96 -0
  58. package/dist_ts/opsserver/handlers/connections.handler.d.ts +10 -0
  59. package/dist_ts/opsserver/handlers/connections.handler.js +109 -0
  60. package/dist_ts/opsserver/handlers/groups.handler.d.ts +9 -0
  61. package/dist_ts/opsserver/handlers/groups.handler.js +24 -0
  62. package/dist_ts/opsserver/handlers/index.d.ts +13 -0
  63. package/dist_ts/opsserver/handlers/index.js +14 -0
  64. package/dist_ts/opsserver/handlers/jobs.handler.d.ts +16 -0
  65. package/dist_ts/opsserver/handlers/jobs.handler.js +146 -0
  66. package/dist_ts/opsserver/handlers/logs.handler.d.ts +9 -0
  67. package/dist_ts/opsserver/handlers/logs.handler.js +21 -0
  68. package/dist_ts/opsserver/handlers/managedsecrets.handler.d.ts +11 -0
  69. package/dist_ts/opsserver/handlers/managedsecrets.handler.js +110 -0
  70. package/dist_ts/opsserver/handlers/pipelines.handler.d.ts +31 -0
  71. package/dist_ts/opsserver/handlers/pipelines.handler.js +204 -0
  72. package/dist_ts/opsserver/handlers/projects.handler.d.ts +9 -0
  73. package/dist_ts/opsserver/handlers/projects.handler.js +24 -0
  74. package/dist_ts/opsserver/handlers/secrets.handler.d.ts +10 -0
  75. package/dist_ts/opsserver/handlers/secrets.handler.js +171 -0
  76. package/dist_ts/opsserver/handlers/sync.handler.d.ts +16 -0
  77. package/dist_ts/opsserver/handlers/sync.handler.js +166 -0
  78. package/dist_ts/opsserver/handlers/webhook.handler.d.ts +7 -0
  79. package/dist_ts/opsserver/handlers/webhook.handler.js +55 -0
  80. package/dist_ts/opsserver/helpers/guards.d.ts +5 -0
  81. package/dist_ts/opsserver/helpers/guards.js +12 -0
  82. package/dist_ts/opsserver/index.d.ts +1 -0
  83. package/dist_ts/opsserver/index.js +2 -0
  84. package/dist_ts/paths.d.ts +9 -0
  85. package/dist_ts/paths.js +13 -0
  86. package/dist_ts/plugins.d.ts +25 -0
  87. package/dist_ts/plugins.js +32 -0
  88. package/dist_ts/providers/classes.baseprovider.d.ts +51 -0
  89. package/dist_ts/providers/classes.baseprovider.js +17 -0
  90. package/dist_ts/providers/classes.giteaprovider.d.ts +40 -0
  91. package/dist_ts/providers/classes.giteaprovider.js +224 -0
  92. package/dist_ts/providers/classes.gitlabprovider.d.ts +39 -0
  93. package/dist_ts/providers/classes.gitlabprovider.js +207 -0
  94. package/dist_ts/providers/index.d.ts +3 -0
  95. package/dist_ts/providers/index.js +4 -0
  96. package/dist_ts/storage/classes.storagemanager.d.ts +33 -0
  97. package/dist_ts/storage/classes.storagemanager.js +135 -0
  98. package/dist_ts/storage/index.d.ts +2 -0
  99. package/dist_ts/storage/index.js +2 -0
  100. package/dist_ts/timers.d.ts +4 -0
  101. package/dist_ts/timers.js +24 -0
  102. package/dist_ts_bundled/bundle.d.ts +4 -0
  103. package/dist_ts_bundled/bundle.js +12 -0
  104. package/dist_ts_interfaces/data/actionlog.d.ts +12 -0
  105. package/dist_ts_interfaces/data/actionlog.js +2 -0
  106. package/dist_ts_interfaces/data/branch.d.ts +8 -0
  107. package/dist_ts_interfaces/data/branch.js +2 -0
  108. package/dist_ts_interfaces/data/connection.d.ts +12 -0
  109. package/dist_ts_interfaces/data/connection.js +2 -0
  110. package/dist_ts_interfaces/data/group.d.ts +10 -0
  111. package/dist_ts_interfaces/data/group.js +2 -0
  112. package/dist_ts_interfaces/data/identity.d.ts +7 -0
  113. package/dist_ts_interfaces/data/identity.js +2 -0
  114. package/dist_ts_interfaces/data/index.d.ts +11 -0
  115. package/dist_ts_interfaces/data/index.js +12 -0
  116. package/dist_ts_interfaces/data/job.d.ts +37 -0
  117. package/dist_ts_interfaces/data/job.js +2 -0
  118. package/dist_ts_interfaces/data/managedsecret.d.ts +37 -0
  119. package/dist_ts_interfaces/data/managedsecret.js +2 -0
  120. package/dist_ts_interfaces/data/pipeline.d.ts +22 -0
  121. package/dist_ts_interfaces/data/pipeline.js +2 -0
  122. package/dist_ts_interfaces/data/project.d.ts +12 -0
  123. package/dist_ts_interfaces/data/project.js +2 -0
  124. package/dist_ts_interfaces/data/secret.d.ts +11 -0
  125. package/dist_ts_interfaces/data/secret.js +2 -0
  126. package/dist_ts_interfaces/data/sync.d.ts +34 -0
  127. package/dist_ts_interfaces/data/sync.js +2 -0
  128. package/dist_ts_interfaces/index.d.ts +5 -0
  129. package/dist_ts_interfaces/index.js +8 -0
  130. package/dist_ts_interfaces/plugins.d.ts +2 -0
  131. package/dist_ts_interfaces/plugins.js +4 -0
  132. package/dist_ts_interfaces/requests/actionlog.d.ts +15 -0
  133. package/dist_ts_interfaces/requests/actionlog.js +3 -0
  134. package/dist_ts_interfaces/requests/actions.d.ts +31 -0
  135. package/dist_ts_interfaces/requests/actions.js +3 -0
  136. package/dist_ts_interfaces/requests/admin.d.ts +31 -0
  137. package/dist_ts_interfaces/requests/admin.js +3 -0
  138. package/dist_ts_interfaces/requests/connections.d.ts +71 -0
  139. package/dist_ts_interfaces/requests/connections.js +3 -0
  140. package/dist_ts_interfaces/requests/groups.d.ts +14 -0
  141. package/dist_ts_interfaces/requests/groups.js +3 -0
  142. package/dist_ts_interfaces/requests/index.d.ts +13 -0
  143. package/dist_ts_interfaces/requests/index.js +14 -0
  144. package/dist_ts_interfaces/requests/jobs.d.ts +86 -0
  145. package/dist_ts_interfaces/requests/jobs.js +3 -0
  146. package/dist_ts_interfaces/requests/logs.d.ts +14 -0
  147. package/dist_ts_interfaces/requests/logs.js +3 -0
  148. package/dist_ts_interfaces/requests/managedsecrets.d.ts +84 -0
  149. package/dist_ts_interfaces/requests/managedsecrets.js +3 -0
  150. package/dist_ts_interfaces/requests/pipelines.d.ts +55 -0
  151. package/dist_ts_interfaces/requests/pipelines.js +3 -0
  152. package/dist_ts_interfaces/requests/projects.d.ts +14 -0
  153. package/dist_ts_interfaces/requests/projects.js +3 -0
  154. package/dist_ts_interfaces/requests/secrets.d.ts +72 -0
  155. package/dist_ts_interfaces/requests/secrets.js +3 -0
  156. package/dist_ts_interfaces/requests/sync.d.ts +120 -0
  157. package/dist_ts_interfaces/requests/sync.js +3 -0
  158. package/dist_ts_interfaces/requests/webhook.d.ts +13 -0
  159. package/dist_ts_interfaces/requests/webhook.js +3 -0
  160. package/license +21 -0
  161. package/package.json +81 -0
  162. package/readme.md +177 -0
  163. package/readme.todo.md +3 -0
  164. package/ts/00_commitinfo_data.ts +8 -0
  165. package/ts/cache/classes.cache.cleaner.ts +69 -0
  166. package/ts/cache/classes.cached.document.ts +57 -0
  167. package/ts/cache/classes.cachedb.ts +72 -0
  168. package/ts/cache/classes.secrets.scan.service.ts +267 -0
  169. package/ts/cache/documents/classes.cached.project.ts +32 -0
  170. package/ts/cache/documents/classes.cached.secret.ts +81 -0
  171. package/ts/cache/documents/index.ts +2 -0
  172. package/ts/cache/index.ts +7 -0
  173. package/ts/classes/actionlog.ts +57 -0
  174. package/ts/classes/connectionmanager.ts +263 -0
  175. package/ts/classes/gitopsapp.ts +128 -0
  176. package/ts/classes/jobmanager.ts +337 -0
  177. package/ts/classes/jobrunners/autobookstackdocs.runner.ts +435 -0
  178. package/ts/classes/jobrunners/base.jobrunner.ts +16 -0
  179. package/ts/classes/jobrunners/index.ts +17 -0
  180. package/ts/classes/managedsecrets.manager.ts +322 -0
  181. package/ts/classes/syncmanager.ts +2117 -0
  182. package/ts/index.ts +37 -0
  183. package/ts/logging.ts +162 -0
  184. package/ts/opsserver/classes.opsserver.ts +86 -0
  185. package/ts/opsserver/handlers/actionlog.handler.ts +30 -0
  186. package/ts/opsserver/handlers/actions.handler.ts +50 -0
  187. package/ts/opsserver/handlers/admin.handler.ts +122 -0
  188. package/ts/opsserver/handlers/connections.handler.ts +162 -0
  189. package/ts/opsserver/handlers/groups.handler.ts +32 -0
  190. package/ts/opsserver/handlers/index.ts +13 -0
  191. package/ts/opsserver/handlers/jobs.handler.ts +189 -0
  192. package/ts/opsserver/handlers/logs.handler.ts +29 -0
  193. package/ts/opsserver/handlers/managedsecrets.handler.ts +158 -0
  194. package/ts/opsserver/handlers/pipelines.handler.ts +281 -0
  195. package/ts/opsserver/handlers/projects.handler.ts +32 -0
  196. package/ts/opsserver/handlers/secrets.handler.ts +224 -0
  197. package/ts/opsserver/handlers/sync.handler.ts +224 -0
  198. package/ts/opsserver/handlers/webhook.handler.ts +62 -0
  199. package/ts/opsserver/helpers/guards.ts +16 -0
  200. package/ts/opsserver/index.ts +1 -0
  201. package/ts/paths.ts +19 -0
  202. package/ts/plugins.ts +38 -0
  203. package/ts/providers/classes.baseprovider.ts +99 -0
  204. package/ts/providers/classes.giteaprovider.ts +279 -0
  205. package/ts/providers/classes.gitlabprovider.ts +265 -0
  206. package/ts/providers/index.ts +3 -0
  207. package/ts/storage/classes.storagemanager.ts +144 -0
  208. package/ts/storage/index.ts +2 -0
  209. package/ts/timers.ts +34 -0
  210. package/ts_interfaces/data/actionlog.ts +13 -0
  211. package/ts_interfaces/data/branch.ts +9 -0
  212. package/ts_interfaces/data/connection.ts +13 -0
  213. package/ts_interfaces/data/group.ts +10 -0
  214. package/ts_interfaces/data/identity.ts +7 -0
  215. package/ts_interfaces/data/index.ts +11 -0
  216. package/ts_interfaces/data/job.ts +42 -0
  217. package/ts_interfaces/data/managedsecret.ts +41 -0
  218. package/ts_interfaces/data/pipeline.ts +32 -0
  219. package/ts_interfaces/data/project.ts +12 -0
  220. package/ts_interfaces/data/secret.ts +11 -0
  221. package/ts_interfaces/data/sync.ts +37 -0
  222. package/ts_interfaces/index.ts +9 -0
  223. package/ts_interfaces/plugins.ts +6 -0
  224. package/ts_interfaces/requests/actionlog.ts +19 -0
  225. package/ts_interfaces/requests/actions.ts +39 -0
  226. package/ts_interfaces/requests/admin.ts +43 -0
  227. package/ts_interfaces/requests/connections.ts +95 -0
  228. package/ts_interfaces/requests/groups.ts +18 -0
  229. package/ts_interfaces/requests/index.ts +13 -0
  230. package/ts_interfaces/requests/jobs.ts +118 -0
  231. package/ts_interfaces/requests/logs.ts +18 -0
  232. package/ts_interfaces/requests/managedsecrets.ts +112 -0
  233. package/ts_interfaces/requests/pipelines.ts +71 -0
  234. package/ts_interfaces/requests/projects.ts +18 -0
  235. package/ts_interfaces/requests/secrets.ts +92 -0
  236. package/ts_interfaces/requests/sync.ts +157 -0
  237. package/ts_interfaces/requests/webhook.ts +18 -0
  238. package/ts_web/00_commitinfo_data.ts +8 -0
  239. package/ts_web/appstate.ts +1251 -0
  240. package/ts_web/elements/gitops-dashboard.ts +350 -0
  241. package/ts_web/elements/index.ts +10 -0
  242. package/ts_web/elements/shared/css.ts +29 -0
  243. package/ts_web/elements/shared/index.ts +1 -0
  244. package/ts_web/elements/views/actionlog/index.ts +101 -0
  245. package/ts_web/elements/views/actions/index.ts +209 -0
  246. package/ts_web/elements/views/buildlog/index.ts +196 -0
  247. package/ts_web/elements/views/connections/index.ts +260 -0
  248. package/ts_web/elements/views/groups/index.ts +134 -0
  249. package/ts_web/elements/views/jobs/index.ts +424 -0
  250. package/ts_web/elements/views/managedsecrets/index.ts +502 -0
  251. package/ts_web/elements/views/overview/index.ts +86 -0
  252. package/ts_web/elements/views/pipelines/index.ts +561 -0
  253. package/ts_web/elements/views/projects/index.ts +149 -0
  254. package/ts_web/elements/views/secrets/index.ts +310 -0
  255. package/ts_web/elements/views/sync/index.ts +512 -0
  256. package/ts_web/index.ts +7 -0
  257. package/ts_web/plugins.ts +15 -0
  258. package/tsconfig.json +15 -0
@@ -0,0 +1,435 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { logger } from '../../logging.js';
3
+ import type * as interfaces from '../../../ts_interfaces/index.js';
4
+ import type { BaseProvider } from '../../providers/classes.baseprovider.js';
5
+ import type { StorageManager } from '../../storage/index.js';
6
+ import { BaseJobRunner, type IJobRunContext } from './base.jobrunner.js';
7
+
8
+ const KEYCHAIN_PREFIX = 'keychain:';
9
+ const BATCH_SIZE = 5;
10
+ const BATCH_DELAY_MS = 200;
11
+ const HASH_STORAGE_PREFIX = '/job-hashes/';
12
+
13
+ /** Simple SHA-256 hex hash */
14
+ async function sha256(content: string): Promise<string> {
15
+ const data = new TextEncoder().encode(content);
16
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
17
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, '0')).join('');
18
+ }
19
+
20
+ /**
21
+ * If the first line of markdown is a heading matching the book name (e.g. "# @org/repo"),
22
+ * strip it — the book title already provides that context.
23
+ */
24
+ const REDUNDANT_HEADINGS = ['changelog'];
25
+
26
+ function stripRedundantHeading(markdown: string, bookName: string): string {
27
+ const firstNewline = markdown.indexOf('\n');
28
+ const firstLine = (firstNewline === -1 ? markdown : markdown.slice(0, firstNewline)).trim();
29
+ if (firstLine.startsWith('# ')) {
30
+ const heading = firstLine.slice(2).trim().toLowerCase();
31
+ const fullName = bookName.toLowerCase();
32
+ const repoName = bookName.split('/').pop()?.toLowerCase() || '';
33
+ // Strip headings matching the book/repo name (with optional trailing decorations)
34
+ if (heading === fullName || heading === repoName
35
+ || heading.startsWith(fullName + ' ') || heading.startsWith(repoName + ' ')) {
36
+ return firstNewline === -1 ? '' : markdown.slice(firstNewline + 1);
37
+ }
38
+ // Strip generic redundant headings (e.g. "# Changelog")
39
+ if (REDUNDANT_HEADINGS.includes(heading)) {
40
+ return firstNewline === -1 ? '' : markdown.slice(firstNewline + 1);
41
+ }
42
+ }
43
+ return markdown;
44
+ }
45
+
46
+ /** Persisted content hashes for a job — avoids unnecessary BookStack updates */
47
+ type ContentHashMap = Record<string, string>; // page key → sha256 hash
48
+
49
+ /**
50
+ * Mapping:
51
+ * Git org/group → BookStack Shelf
52
+ * Git repository → BookStack Book (named @group/repo, assigned to shelf)
53
+ * readme.md → BookStack Page (named "readme.md for @group/repo")
54
+ * docs/*.md → BookStack Pages (named "docs/file.md for @group/repo")
55
+ *
56
+ * Content hashes are persisted in storage to skip updates when content hasn't changed.
57
+ */
58
+ export class AutoBookstackDocsRunner extends BaseJobRunner {
59
+ readonly jobType: interfaces.data.TJobType = 'autobookstackdocs';
60
+
61
+ async execute(context: IJobRunContext): Promise<void> {
62
+ const config = context.jobConfig.autoBookstackDocsConfig;
63
+ if (!config) {
64
+ throw new Error('Missing autoBookstackDocsConfig');
65
+ }
66
+
67
+ // 1. Resolve BookStack token from keychain
68
+ let tokenSecret = config.bookstackTarget.tokenSecret;
69
+ if (tokenSecret.startsWith(KEYCHAIN_PREFIX)) {
70
+ const keychainId = tokenSecret.slice(KEYCHAIN_PREFIX.length);
71
+ const resolved = await context.smartSecret.getSecret(keychainId);
72
+ if (!resolved) {
73
+ throw new Error('Could not retrieve BookStack token from keychain');
74
+ }
75
+ tokenSecret = resolved;
76
+ }
77
+
78
+ // 2. Connect to BookStack
79
+ const bookstack = new plugins.bookstackClient.BookStackAccount(
80
+ config.bookstackTarget.baseUrl,
81
+ config.bookstackTarget.tokenId,
82
+ tokenSecret,
83
+ );
84
+ await bookstack.testConnection();
85
+ logger.jobLog('info', `Connected to BookStack at ${config.bookstackTarget.baseUrl}`, 'bookstack');
86
+
87
+ // 3. Load persisted content hashes (for diff-based updates)
88
+ const hashKey = `${HASH_STORAGE_PREFIX}${context.jobConfig.id}.json`;
89
+ const oldHashes: ContentHashMap = (await context.storageManager.getJSON<ContentHashMap>(hashKey)) || {};
90
+ const newHashes: ContentHashMap = {};
91
+
92
+ // 4. Pre-load existing shelves for find-or-create
93
+ const existingShelves = await bookstack.getShelves();
94
+ const shelfMap = new Map<string, plugins.bookstackClient.BookStackShelf>();
95
+ for (const shelf of existingShelves) {
96
+ shelfMap.set(shelf.name.toLowerCase(), shelf);
97
+ }
98
+
99
+ // Track desired state for deletion reconciliation
100
+ const syncedShelfNames = new Set<string>();
101
+ const syncedBookNames = new Map<string, Set<string>>();
102
+ const shelfBookIds = new Map<string, number[]>();
103
+
104
+ let pagesCreated = 0;
105
+ let pagesUpdated = 0;
106
+ let pagesSkipped = 0;
107
+
108
+ // 5. Process each source connection
109
+ for (const connectionId of config.sourceConnectionIds) {
110
+ const conn = context.connectionManager.getConnection(connectionId);
111
+ if (!conn || conn.status === 'paused') {
112
+ logger.jobLog('warn', `Skipping connection ${connectionId} (not found or paused)`, 'bookstack');
113
+ continue;
114
+ }
115
+
116
+ const provider = context.connectionManager.getProvider(connectionId);
117
+ const groups = await provider.getGroups();
118
+ logger.jobLog('info', `Connection "${conn.name}": ${groups.length} groups`, 'bookstack');
119
+
120
+ for (const group of groups) {
121
+ // Apply group filters
122
+ if (config.includeGroups && config.includeGroups.length > 0) {
123
+ if (!config.includeGroups.some((g) => g.toLowerCase() === group.name.toLowerCase() || g.toLowerCase() === group.fullPath.toLowerCase())) {
124
+ continue;
125
+ }
126
+ }
127
+ if (config.excludeGroups && config.excludeGroups.length > 0) {
128
+ if (config.excludeGroups.some((g) => g.toLowerCase() === group.name.toLowerCase() || g.toLowerCase() === group.fullPath.toLowerCase())) {
129
+ continue;
130
+ }
131
+ }
132
+
133
+ // Find or create shelf for this org/group
134
+ const shelfName = group.fullPath || group.name;
135
+ const shelfKey = shelfName.toLowerCase();
136
+ syncedShelfNames.add(shelfKey);
137
+ if (!syncedBookNames.has(shelfKey)) {
138
+ syncedBookNames.set(shelfKey, new Set());
139
+ }
140
+ if (!shelfBookIds.has(shelfKey)) {
141
+ shelfBookIds.set(shelfKey, []);
142
+ }
143
+
144
+ let shelf = shelfMap.get(shelfKey);
145
+ if (!shelf) {
146
+ shelf = await bookstack.createShelf({
147
+ name: shelfName,
148
+ description: group.description || `Documentation for ${shelfName}`,
149
+ });
150
+ shelfMap.set(shelfKey, shelf);
151
+ logger.jobLog('info', `Created shelf: ${shelfName}`, 'bookstack');
152
+ }
153
+
154
+ // Get projects in this group
155
+ const projects = await provider.getGroupProjects(group.id);
156
+
157
+ // Process repos in batches
158
+ for (let i = 0; i < projects.length; i += BATCH_SIZE) {
159
+ if (i > 0) await new Promise((r) => setTimeout(r, BATCH_DELAY_MS));
160
+ const batch = projects.slice(i, i + BATCH_SIZE);
161
+ const results = await Promise.all(
162
+ batch.map((project) => {
163
+ const bookName = `@${group.fullPath}/${project.name}`;
164
+ syncedBookNames.get(shelfKey)!.add(bookName.toLowerCase());
165
+ return this.syncRepoAsBook(
166
+ provider, bookstack, context.storageManager, project, bookName, config,
167
+ group.visibility, oldHashes, newHashes,
168
+ );
169
+ }),
170
+ );
171
+ for (const result of results) {
172
+ if (result) {
173
+ shelfBookIds.get(shelfKey)!.push(result.bookId);
174
+ pagesCreated += result.created;
175
+ pagesUpdated += result.updated;
176
+ pagesSkipped += result.skipped;
177
+ }
178
+ }
179
+ }
180
+
181
+ // Assign all synced books to this shelf (only if book list changed)
182
+ const bookIds = shelfBookIds.get(shelfKey)!;
183
+ if (bookIds.length > 0) {
184
+ const shelfHashKey = `__shelf__${shelfKey}`;
185
+ const desiredShelfState = bookIds.slice().sort((a, b) => a - b).join(',');
186
+ if (oldHashes[shelfHashKey] !== desiredShelfState) {
187
+ await shelf.update({ books: bookIds });
188
+ logger.jobLog('info', `Updated shelf "${shelfName}" book assignments`, 'bookstack');
189
+ }
190
+ newHashes[shelfHashKey] = desiredShelfState;
191
+ }
192
+ }
193
+ }
194
+
195
+ // 6. Deletion reconciliation
196
+ if (config.propagateDeletes) {
197
+ await this.reconcileDeletes(bookstack, shelfMap, syncedShelfNames, syncedBookNames);
198
+ }
199
+
200
+ // 7. Persist updated content hashes
201
+ await context.storageManager.setJSON(hashKey, newHashes);
202
+
203
+ logger.jobLog('success', `Sync complete — ${pagesCreated} created, ${pagesUpdated} updated, ${pagesSkipped} unchanged`, 'bookstack');
204
+ }
205
+
206
+ /**
207
+ * Syncs a single git repository as a BookStack Book.
208
+ * Returns book ID + page stats, or null if skipped (no docs).
209
+ */
210
+ private async syncRepoAsBook(
211
+ provider: BaseProvider,
212
+ bookstack: plugins.bookstackClient.BookStackAccount,
213
+ storageManager: StorageManager,
214
+ project: interfaces.data.IProject,
215
+ bookName: string,
216
+ config: interfaces.data.IAutoBookstackDocsConfig,
217
+ groupVisibility: string,
218
+ oldHashes: ContentHashMap,
219
+ newHashes: ContentHashMap,
220
+ ): Promise<{ bookId: number; created: number; updated: number; skipped: number } | null> {
221
+ try {
222
+ // 0. Collect tags from package.json keywords + git repo topics
223
+ const tags = await this.collectTags(provider, project);
224
+
225
+ // Collect all pages to sync for this repo
226
+ const pagesToSync: { name: string; content: string; hash: string }[] = [];
227
+
228
+ // 1. Fetch explicit root files (e.g. readme.md)
229
+ for (const filePath of config.filePaths) {
230
+ let content = await provider.getFileContent(project.fullPath, filePath);
231
+ if (content) {
232
+ content = stripRedundantHeading(content, bookName);
233
+ const tagsStr = tags.map((t) => t.name).join(',');
234
+ const hash = await sha256(content + '\0' + tagsStr);
235
+ const pageName = `${filePath} for ${bookName}`;
236
+ pagesToSync.push({ name: pageName, content, hash });
237
+ newHashes[pageName.toLowerCase()] = hash;
238
+ }
239
+ }
240
+
241
+ // 2. Scan doc directories for .md files
242
+ for (const dir of config.docDirs) {
243
+ const entries = await provider.getDirectoryContents(project.fullPath, dir);
244
+ if (!entries) continue;
245
+
246
+ const mdFiles = entries.filter(
247
+ (e) => e.type === 'file' && e.name.toLowerCase().endsWith('.md'),
248
+ );
249
+ for (const mdFile of mdFiles) {
250
+ let content = await provider.getFileContent(project.fullPath, mdFile.path);
251
+ if (content) {
252
+ content = stripRedundantHeading(content, bookName);
253
+ const tagsStr = tags.map((t) => t.name).join(',');
254
+ const hash = await sha256(content + '\0' + tagsStr);
255
+ const pageName = `${mdFile.path} for ${bookName}`;
256
+ pagesToSync.push({ name: pageName, content, hash });
257
+ newHashes[pageName.toLowerCase()] = hash;
258
+ }
259
+ }
260
+ }
261
+
262
+ // Skip repos that have no docs at all
263
+ if (pagesToSync.length === 0) return null;
264
+
265
+ // 3. Find or create book for this repo
266
+ const book = await this.findOrCreateBook(bookstack, bookName, project.description || '');
267
+
268
+ // 4. Get existing pages in this book
269
+ const existingPages = await book.getPages();
270
+ const pageMap = new Map<string, plugins.bookstackClient.BookStackPage>();
271
+ for (const p of existingPages) {
272
+ pageMap.set(p.name.toLowerCase(), p);
273
+ }
274
+
275
+ // 5. Create or update pages (only if content changed)
276
+ let created = 0;
277
+ let updated = 0;
278
+ let skipped = 0;
279
+ const syncedPageNames = new Set<string>();
280
+
281
+ for (const page of pagesToSync) {
282
+ syncedPageNames.add(page.name.toLowerCase());
283
+ const existing = pageMap.get(page.name.toLowerCase());
284
+
285
+ if (existing) {
286
+ // Check hash — skip update if content hasn't changed
287
+ const oldHash = oldHashes[page.name.toLowerCase()];
288
+ if (oldHash === page.hash) {
289
+ skipped++;
290
+ continue;
291
+ }
292
+ await existing.update({ markdown: page.content, tags });
293
+ updated++;
294
+ } else {
295
+ await bookstack.createPage({
296
+ book_id: book.id,
297
+ name: page.name,
298
+ markdown: page.content,
299
+ tags,
300
+ });
301
+ created++;
302
+ }
303
+ }
304
+
305
+ // 6. Delete stale pages within this book
306
+ if (config.propagateDeletes) {
307
+ for (const [name, page] of pageMap) {
308
+ if (!syncedPageNames.has(name)) {
309
+ await page.delete();
310
+ logger.jobLog('warn', `Deleted stale page "${page.name}" from book "${bookName}"`, 'bookstack');
311
+ }
312
+ }
313
+ }
314
+
315
+ // 7. Sync visibility — only update permissions if visibility state changed
316
+ if (config.syncVisibility) {
317
+ const isPrivate = project.visibility === 'private' || project.visibility === 'internal'
318
+ || groupVisibility === 'private' || groupVisibility === 'internal';
319
+ const visKey = `__vis__${bookName.toLowerCase()}`;
320
+ const desiredVis = isPrivate ? `private:${config.privateRoleId || ''}` : 'public';
321
+ const oldVis = oldHashes[visKey];
322
+ newHashes[visKey] = desiredVis;
323
+
324
+ if (oldVis !== desiredVis) {
325
+ if (isPrivate) {
326
+ const permUpdate: any = {
327
+ fallback_permissions: { inheriting: false, view: false, create: false, update: false, delete: false },
328
+ };
329
+ if (config.privateRoleId) {
330
+ permUpdate.role_permissions = [{ role_id: config.privateRoleId, view: true, create: false, update: false, delete: false }];
331
+ }
332
+ await bookstack.updateContentPermissions('book', book.id, permUpdate);
333
+ logger.jobLog('info', `Restricted book "${bookName}" (private)`, 'bookstack');
334
+ } else {
335
+ await bookstack.updateContentPermissions('book', book.id, {
336
+ fallback_permissions: { inheriting: true },
337
+ });
338
+ logger.jobLog('info', `Set book "${bookName}" to public`, 'bookstack');
339
+ }
340
+ }
341
+ }
342
+
343
+ if (created > 0 || updated > 0) {
344
+ logger.jobLog('info', `${bookName}: ${created} created, ${updated} updated, ${skipped} unchanged`, 'bookstack');
345
+ }
346
+ return { bookId: book.id, created, updated, skipped };
347
+ } catch (err) {
348
+ logger.jobLog('warn', `Failed to sync repo ${bookName}: ${err}`, 'bookstack');
349
+ return null;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Removes stale BookStack shelves and orphaned books.
355
+ */
356
+ private async reconcileDeletes(
357
+ bookstack: plugins.bookstackClient.BookStackAccount,
358
+ shelfMap: Map<string, plugins.bookstackClient.BookStackShelf>,
359
+ syncedShelfNames: Set<string>,
360
+ syncedBookNames: Map<string, Set<string>>,
361
+ ): Promise<void> {
362
+ // Build flat set of all desired book names
363
+ const allDesiredBookNames = new Set<string>();
364
+ for (const bookNames of syncedBookNames.values()) {
365
+ for (const name of bookNames) {
366
+ allDesiredBookNames.add(name);
367
+ }
368
+ }
369
+
370
+ // Delete stale shelves
371
+ for (const [shelfKey, shelf] of shelfMap) {
372
+ if (!syncedShelfNames.has(shelfKey)) {
373
+ await shelf.delete();
374
+ logger.jobLog('warn', `Deleted stale shelf "${shelf.name}"`, 'bookstack');
375
+ }
376
+ }
377
+
378
+ // Delete all orphaned books
379
+ const allBooks = await bookstack.getBooks();
380
+ for (const book of allBooks) {
381
+ if (!allDesiredBookNames.has(book.name.toLowerCase())) {
382
+ await book.delete();
383
+ logger.jobLog('warn', `Deleted orphaned book "${book.name}"`, 'bookstack');
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Collects tags from package.json keywords + git repo topics, deduplicated.
390
+ */
391
+ private async collectTags(
392
+ provider: BaseProvider,
393
+ project: interfaces.data.IProject,
394
+ ): Promise<{ name: string; value: string }[]> {
395
+ const tagNames = new Set<string>();
396
+
397
+ // Add git repo topics
398
+ if (project.topics) {
399
+ for (const topic of project.topics) {
400
+ if (topic) tagNames.add(topic.toLowerCase());
401
+ }
402
+ }
403
+
404
+ // Fetch package.json and extract keywords
405
+ try {
406
+ const pkgJson = await provider.getFileContent(project.fullPath, 'package.json');
407
+ if (pkgJson) {
408
+ const pkg = JSON.parse(pkgJson);
409
+ if (Array.isArray(pkg.keywords)) {
410
+ for (const kw of pkg.keywords) {
411
+ if (typeof kw === 'string' && kw) tagNames.add(kw.toLowerCase());
412
+ }
413
+ }
414
+ }
415
+ } catch {
416
+ // package.json missing or invalid — no keywords
417
+ }
418
+
419
+ return Array.from(tagNames).map((name) => ({ name, value: '' }));
420
+ }
421
+
422
+ private async findOrCreateBook(
423
+ bookstack: plugins.bookstackClient.BookStackAccount,
424
+ bookName: string,
425
+ description: string,
426
+ ): Promise<plugins.bookstackClient.BookStackBook> {
427
+ const books = await bookstack.getBooks();
428
+ const existing = books.find((b) => b.name.toLowerCase() === bookName.toLowerCase());
429
+ if (existing) return existing;
430
+ return bookstack.createBook({
431
+ name: bookName,
432
+ description: description || `Documentation for ${bookName}`,
433
+ });
434
+ }
435
+ }
@@ -0,0 +1,16 @@
1
+ import type * as interfaces from '../../../ts_interfaces/index.js';
2
+ import type { ConnectionManager } from '../connectionmanager.js';
3
+ import type { StorageManager } from '../../storage/index.js';
4
+ import type * as plugins from '../../plugins.js';
5
+
6
+ export interface IJobRunContext {
7
+ jobConfig: interfaces.data.IJobConfig;
8
+ connectionManager: ConnectionManager;
9
+ storageManager: StorageManager;
10
+ smartSecret: plugins.smartsecret.SmartSecret;
11
+ }
12
+
13
+ export abstract class BaseJobRunner {
14
+ abstract readonly jobType: interfaces.data.TJobType;
15
+ abstract execute(context: IJobRunContext): Promise<void>;
16
+ }
@@ -0,0 +1,17 @@
1
+ import type * as interfaces from '../../../ts_interfaces/index.js';
2
+ import { BaseJobRunner } from './base.jobrunner.js';
3
+ import { AutoBookstackDocsRunner } from './autobookstackdocs.runner.js';
4
+
5
+ export { BaseJobRunner, type IJobRunContext } from './base.jobrunner.js';
6
+ export { AutoBookstackDocsRunner } from './autobookstackdocs.runner.js';
7
+
8
+ const runners = new Map<interfaces.data.TJobType, BaseJobRunner>();
9
+ runners.set('autobookstackdocs', new AutoBookstackDocsRunner());
10
+
11
+ export function getRunner(jobType: interfaces.data.TJobType): BaseJobRunner {
12
+ const runner = runners.get(jobType);
13
+ if (!runner) {
14
+ throw new Error(`No runner registered for job type: ${jobType}`);
15
+ }
16
+ return runner;
17
+ }