@fuzdev/fuz_gitops 0.78.1 → 0.79.0

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 (179) hide show
  1. package/README.md +78 -24
  2. package/dist/ModulesDetail.svelte +15 -22
  3. package/dist/ModulesDetail.svelte.d.ts.map +1 -1
  4. package/dist/ModulesNav.svelte +4 -1
  5. package/dist/ModulesPage.svelte +1 -3
  6. package/dist/ModulesPage.svelte.d.ts.map +1 -1
  7. package/dist/PageHeader.svelte +2 -2
  8. package/dist/PageHeader.svelte.d.ts +0 -3
  9. package/dist/PageHeader.svelte.d.ts.map +1 -1
  10. package/dist/PullRequestsDetail.svelte +10 -9
  11. package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
  12. package/dist/PullRequestsPage.svelte +1 -3
  13. package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
  14. package/dist/ReposTable.svelte +27 -48
  15. package/dist/ReposTable.svelte.d.ts.map +1 -1
  16. package/dist/ReposTree.svelte +5 -19
  17. package/dist/ReposTree.svelte.d.ts.map +1 -1
  18. package/dist/ReposTreeNav.svelte +5 -7
  19. package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
  20. package/dist/TablePage.svelte +1 -3
  21. package/dist/TablePage.svelte.d.ts.map +1 -1
  22. package/dist/TreeItemPage.svelte +1 -3
  23. package/dist/TreeItemPage.svelte.d.ts.map +1 -1
  24. package/dist/TreePage.svelte +1 -3
  25. package/dist/TreePage.svelte.d.ts.map +1 -1
  26. package/dist/ci_reconcile.d.ts +6 -10
  27. package/dist/ci_reconcile.d.ts.map +1 -1
  28. package/dist/ci_reconcile.js +6 -8
  29. package/dist/dependency_graph.d.ts +26 -49
  30. package/dist/dependency_graph.d.ts.map +1 -1
  31. package/dist/dependency_graph.js +37 -80
  32. package/dist/dependency_updater.d.ts +1 -1
  33. package/dist/dependency_updater.d.ts.map +1 -1
  34. package/dist/dependency_updater.js +6 -1
  35. package/dist/fetch_repo_data.d.ts +34 -8
  36. package/dist/fetch_repo_data.d.ts.map +1 -1
  37. package/dist/fetch_repo_data.js +44 -22
  38. package/dist/fs_fetch_value_cache.d.ts +1 -1
  39. package/dist/fs_fetch_value_cache.js +1 -1
  40. package/dist/git_operations.d.ts +6 -49
  41. package/dist/git_operations.d.ts.map +1 -1
  42. package/dist/git_operations.js +13 -126
  43. package/dist/github.d.ts +17 -2
  44. package/dist/github.d.ts.map +1 -1
  45. package/dist/github.js +15 -6
  46. package/dist/github_helpers.d.ts.map +1 -1
  47. package/dist/github_helpers.js +3 -11
  48. package/dist/gitops_analyze.task.d.ts +24 -2
  49. package/dist/gitops_analyze.task.d.ts.map +1 -1
  50. package/dist/gitops_analyze.task.js +43 -30
  51. package/dist/gitops_config.d.ts +34 -79
  52. package/dist/gitops_config.d.ts.map +1 -1
  53. package/dist/gitops_config.js +76 -53
  54. package/dist/gitops_constants.d.ts +6 -4
  55. package/dist/gitops_constants.d.ts.map +1 -1
  56. package/dist/gitops_constants.js +6 -4
  57. package/dist/gitops_plan.task.d.ts +29 -3
  58. package/dist/gitops_plan.task.d.ts.map +1 -1
  59. package/dist/gitops_plan.task.js +80 -58
  60. package/dist/gitops_publish.task.d.ts +62 -2
  61. package/dist/gitops_publish.task.d.ts.map +1 -1
  62. package/dist/gitops_publish.task.js +187 -102
  63. package/dist/gitops_run.task.d.ts +74 -0
  64. package/dist/gitops_run.task.d.ts.map +1 -1
  65. package/dist/gitops_run.task.js +174 -160
  66. package/dist/gitops_sync.task.d.ts +65 -3
  67. package/dist/gitops_sync.task.d.ts.map +1 -1
  68. package/dist/gitops_sync.task.js +154 -30
  69. package/dist/gitops_task_helpers.d.ts +86 -59
  70. package/dist/gitops_task_helpers.d.ts.map +1 -1
  71. package/dist/gitops_task_helpers.js +120 -79
  72. package/dist/gitops_validate.task.d.ts +1 -2
  73. package/dist/gitops_validate.task.d.ts.map +1 -1
  74. package/dist/gitops_validate.task.js +15 -22
  75. package/dist/graph_validation.d.ts +16 -27
  76. package/dist/graph_validation.d.ts.map +1 -1
  77. package/dist/graph_validation.js +23 -64
  78. package/dist/local_repo.d.ts +50 -62
  79. package/dist/local_repo.d.ts.map +1 -1
  80. package/dist/local_repo.js +80 -193
  81. package/dist/log_helpers.d.ts +8 -8
  82. package/dist/log_helpers.d.ts.map +1 -1
  83. package/dist/multi_repo_publisher.d.ts +10 -0
  84. package/dist/multi_repo_publisher.d.ts.map +1 -1
  85. package/dist/multi_repo_publisher.js +115 -51
  86. package/dist/npm_registry.d.ts +23 -19
  87. package/dist/npm_registry.d.ts.map +1 -1
  88. package/dist/npm_registry.js +20 -47
  89. package/dist/operations.d.ts +75 -180
  90. package/dist/operations.d.ts.map +1 -1
  91. package/dist/operations.js +15 -7
  92. package/dist/operations_defaults.d.ts +17 -2
  93. package/dist/operations_defaults.d.ts.map +1 -1
  94. package/dist/operations_defaults.js +104 -121
  95. package/dist/output_helpers.d.ts +36 -0
  96. package/dist/output_helpers.d.ts.map +1 -1
  97. package/dist/output_helpers.js +37 -3
  98. package/dist/preflight_checks.d.ts +20 -27
  99. package/dist/preflight_checks.d.ts.map +1 -1
  100. package/dist/preflight_checks.js +37 -126
  101. package/dist/publish_steps.d.ts +2 -4
  102. package/dist/publish_steps.d.ts.map +1 -1
  103. package/dist/publish_steps.js +2 -6
  104. package/dist/publishing_event.d.ts +2 -8
  105. package/dist/publishing_event.d.ts.map +1 -1
  106. package/dist/publishing_event.js +5 -4
  107. package/dist/publishing_event_handler.d.ts +10 -8
  108. package/dist/publishing_event_handler.d.ts.map +1 -1
  109. package/dist/publishing_event_handler.js +14 -12
  110. package/dist/publishing_plan.d.ts +16 -1
  111. package/dist/publishing_plan.d.ts.map +1 -1
  112. package/dist/publishing_plan.js +328 -250
  113. package/dist/publishing_plan_logging.d.ts +1 -1
  114. package/dist/publishing_plan_logging.d.ts.map +1 -1
  115. package/dist/publishing_plan_logging.js +21 -14
  116. package/dist/repo.svelte.d.ts +7 -0
  117. package/dist/repo.svelte.d.ts.map +1 -1
  118. package/dist/repo.svelte.js +3 -0
  119. package/dist/repo_readiness.d.ts +210 -0
  120. package/dist/repo_readiness.d.ts.map +1 -0
  121. package/dist/repo_readiness.js +516 -0
  122. package/dist/repos_status.d.ts +3340 -0
  123. package/dist/repos_status.d.ts.map +1 -0
  124. package/dist/repos_status.js +555 -0
  125. package/dist/repos_status_load.d.ts +65 -0
  126. package/dist/repos_status_load.d.ts.map +1 -0
  127. package/dist/repos_status_load.js +131 -0
  128. package/package.json +7 -27
  129. package/src/lib/ci_reconcile.ts +7 -11
  130. package/src/lib/dependency_graph.ts +45 -97
  131. package/src/lib/dependency_updater.ts +7 -1
  132. package/src/lib/fetch_repo_data.ts +66 -29
  133. package/src/lib/fs_fetch_value_cache.ts +1 -1
  134. package/src/lib/git_operations.ts +15 -187
  135. package/src/lib/github.ts +20 -8
  136. package/src/lib/github_helpers.ts +5 -12
  137. package/src/lib/gitops_analyze.task.ts +79 -41
  138. package/src/lib/gitops_config.ts +88 -137
  139. package/src/lib/gitops_constants.ts +6 -4
  140. package/src/lib/gitops_plan.task.ts +113 -61
  141. package/src/lib/gitops_publish.task.ts +255 -122
  142. package/src/lib/gitops_run.task.ts +250 -178
  143. package/src/lib/gitops_sync.task.ts +208 -33
  144. package/src/lib/gitops_task_helpers.ts +166 -110
  145. package/src/lib/gitops_validate.task.ts +17 -27
  146. package/src/lib/graph_validation.ts +25 -86
  147. package/src/lib/local_repo.ts +109 -316
  148. package/src/lib/log_helpers.ts +8 -18
  149. package/src/lib/multi_repo_publisher.ts +138 -53
  150. package/src/lib/npm_registry.ts +37 -62
  151. package/src/lib/operations.ts +78 -150
  152. package/src/lib/operations_defaults.ts +108 -154
  153. package/src/lib/output_helpers.ts +55 -3
  154. package/src/lib/preflight_checks.ts +47 -183
  155. package/src/lib/publish_steps.ts +15 -13
  156. package/src/lib/publishing_event.ts +5 -4
  157. package/src/lib/publishing_event_handler.ts +18 -16
  158. package/src/lib/publishing_plan.ts +451 -287
  159. package/src/lib/publishing_plan_logging.ts +32 -23
  160. package/src/lib/repo.svelte.ts +8 -0
  161. package/src/lib/repo_readiness.ts +647 -0
  162. package/src/lib/repos_status.ts +671 -0
  163. package/src/lib/repos_status_load.ts +173 -0
  164. package/dist/config_reconcile.d.ts +0 -46
  165. package/dist/config_reconcile.d.ts.map +0 -1
  166. package/dist/config_reconcile.js +0 -64
  167. package/dist/paths.d.ts +0 -7
  168. package/dist/paths.d.ts.map +0 -1
  169. package/dist/paths.js +0 -6
  170. package/dist/repo_ops.d.ts +0 -59
  171. package/dist/repo_ops.d.ts.map +0 -1
  172. package/dist/repo_ops.js +0 -170
  173. package/dist/resolved_gitops_config.d.ts +0 -9
  174. package/dist/resolved_gitops_config.d.ts.map +0 -1
  175. package/dist/resolved_gitops_config.js +0 -12
  176. package/src/lib/config_reconcile.ts +0 -98
  177. package/src/lib/paths.ts +0 -6
  178. package/src/lib/repo_ops.ts +0 -216
  179. package/src/lib/resolved_gitops_config.ts +0 -27
@@ -2,17 +2,29 @@ import { TaskError, type Task } from '@fuzdev/gro';
2
2
  import { z } from 'zod';
3
3
  import { readFile, writeFile } from 'node:fs/promises';
4
4
  import { format_file } from '@fuzdev/gro/format_file.ts';
5
- import { basename, resolve } from 'node:path';
5
+ import { basename, join, resolve } from 'node:path';
6
6
  import { print_path } from '@fuzdev/gro/paths.ts';
7
7
  import { load_from_env } from '@fuzdev/gro/env.ts';
8
8
  import { package_json_load } from '@fuzdev/gro/package_json.ts';
9
- import { existsSync } from 'node:fs';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { styleText as st } from 'node:util';
11
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
10
12
  import { compactReplacer } from 'svelte-docinfo';
11
13
 
12
14
  import { fetch_repo_data } from './fetch_repo_data.ts';
13
15
  import { create_fs_fetch_value_cache } from './fs_fetch_value_cache.ts';
14
- import { get_gitops_ready } from './gitops_task_helpers.ts';
16
+ import { resolve_gitops_repos } from './gitops_task_helpers.ts';
15
17
  import { GITOPS_CONFIG_PATH_DEFAULT } from './gitops_constants.ts';
18
+ import {
19
+ local_repos_load,
20
+ local_repos_resolve,
21
+ type LocalRepo,
22
+ type LocalRepoPath
23
+ } from './local_repo.ts';
24
+ import { load_repos_status, to_repos_command } from './repos_status_load.ts';
25
+ import { check_gen_readiness } from './repo_readiness.ts';
26
+ import type { ReposOperations } from './operations.ts';
27
+ import { default_repos_operations } from './operations_defaults.ts';
16
28
 
17
29
  // TODO add flag to ignore or invalidate cache -- no-cache? clean?
18
30
 
@@ -22,85 +34,85 @@ export const Args = z.strictObject({
22
34
  .string()
23
35
  .meta({ description: 'path to the gitops config file, absolute or relative to the cwd' })
24
36
  .default(GITOPS_CONFIG_PATH_DEFAULT),
25
- dir: z
37
+ registry: z
26
38
  .string()
27
- .meta({ description: 'path containing the repos, defaults to the parent of the config dir' })
39
+ .meta({
40
+ description:
41
+ 'path to the repos.toml registry, when `repos` would not find it walking up from the cwd'
42
+ })
28
43
  .optional(),
29
44
  outdir: z
30
45
  .string()
31
46
  .meta({ description: 'path to the directory for the generated files, defaults to $routes/' })
32
47
  .optional(),
33
- download: z.boolean().meta({ description: 'download all missing local repos' }).default(false),
34
48
  check: z
35
49
  .boolean()
36
- .meta({ description: 'check repos are ready without fetching remote data' })
50
+ .meta({
51
+ description:
52
+ 'report whether the repos are ready to generate from, as of the local refs, and exit non-zero if a real run would refuse; fetches nothing, needs no token, and writes nothing'
53
+ })
37
54
  .default(false),
38
55
  allow_dirty: z
39
56
  .boolean()
40
57
  .meta({
41
58
  description:
42
- 'sync (switch branch, pull) tolerating uncommitted changes instead of failing on a dirty workspace'
59
+ 'read repos off their registry branch, dirty, or mid-operation as they sit, warning instead of refusing'
43
60
  })
44
61
  .default(false)
45
62
  });
46
63
  export type Args = z.infer<typeof Args>;
47
64
 
48
65
  /**
49
- * This is a task not a `.gen.` file because it makes network calls.
66
+ * Generates the dashboard's site data (`repos.json` and `repos.ts`) from each
67
+ * configured repo's working tree and its GitHub CI status and pull requests.
68
+ * It never moves a repo: see `prepare_gitops_sync` for what it reads and
69
+ * refuses. This is a task not a `.gen.` file because it makes network calls.
50
70
  *
51
71
  * @nodocs
52
72
  */
53
73
  export const task: Task<Args> = {
54
74
  Args,
55
- summary: 'syncs local repos and generates UI data from repo metadata',
75
+ summary: 'generates UI data from repo metadata, reading each repo as it sits',
56
76
  run: async (ctx) => {
57
77
  const { args, log, invoke_task } = ctx;
58
78
  // `ctx.svelte_config` is a lazy getter, so it's only read when `outdir` isn't provided
59
79
  const {
60
80
  config,
61
- dir,
81
+ registry,
62
82
  outdir = (await ctx.svelte_config).routes_path,
63
- download,
64
83
  check,
65
84
  allow_dirty
66
85
  } = args;
67
86
 
68
- // `gitops_sync` is the task whose job is to mutate working trees, so it always syncs.
69
- const { local_repos } = await get_gitops_ready({
87
+ // The generated `repos.json` is the host project's site data, so a public host
88
+ // must never carry a private repo's metadata: `host` refuses the private repos
89
+ // the registry declares, before any fetch.
90
+ const package_json = await package_json_load();
91
+
92
+ const prepared = await prepare_gitops_sync({
70
93
  config,
71
- dir,
72
- download,
73
- sync: true,
94
+ registry,
95
+ host: { name: package_json.name, private: package_json.private === true },
96
+ check,
74
97
  allow_dirty,
75
98
  log
76
99
  });
100
+ if (!prepared) return;
101
+ const { local_repos, token } = prepared;
77
102
 
78
103
  const outfile_json = resolve(outdir, 'repos.json');
79
104
  const outfile_ts = resolve(outdir, 'repos.ts');
80
105
 
81
- // This searches the parent directory for the env var, so we don't use SvelteKit's $env imports
82
- const token = load_from_env('SECRET_GITHUB_API_TOKEN');
83
- if (!token) {
84
- throw new TaskError('the env var SECRET_GITHUB_API_TOKEN was not found');
85
- }
86
-
87
- // Exit early if only checking repo readiness
88
- if (check) {
89
- log.info('repos are ready');
90
- return;
91
- }
92
-
93
106
  const cache = await create_fs_fetch_value_cache('repos');
94
107
 
95
108
  log.info('fetching remote repo data');
96
- const repos_json = await fetch_repo_data(local_repos, token, cache.data, log);
109
+ const repos_json = await fetch_repo_data({ local_repos, token, cache: cache.data, log });
97
110
 
98
111
  // TODO should package_json be provided in the Gro task/gen contexts? check if it's always loaded
99
- const package_json = await package_json_load();
100
112
  const repo_specifier =
101
113
  package_json.name === '@fuzdev/fuz_gitops'
102
- ? '$lib/repo.svelte.js'
103
- : '@fuzdev/fuz_gitops/repo.svelte.js';
114
+ ? '$lib/repo.svelte.ts'
115
+ : '@fuzdev/fuz_gitops/repo.svelte.ts';
104
116
 
105
117
  log.info(`generating ${outfile_json} and ${outfile_ts}`);
106
118
 
@@ -153,3 +165,166 @@ export const task: Task<Args> = {
153
165
  }
154
166
  }
155
167
  };
168
+
169
+ /**
170
+ * The side effects `prepare_gitops_sync` reaches through, injectable for tests.
171
+ *
172
+ * @nodocs
173
+ */
174
+ export interface GitopsSyncDeps {
175
+ /** Runs `repos status`. */
176
+ repos_ops: ReposOperations;
177
+ /** Loads each resolved repo's library as it sits (`local_repos_load`). */
178
+ load_repos: (options: {
179
+ local_repo_paths: Array<LocalRepoPath>;
180
+ log?: Logger;
181
+ }) => Promise<Array<LocalRepo>>;
182
+ /** Reads `SECRET_GITHUB_API_TOKEN`; `undefined` when it isn't set. */
183
+ load_token: () => string | undefined;
184
+ /** Whether a path exists, for the analysis-setup warning. */
185
+ exists: (path: string) => boolean;
186
+ /** Reads a file as UTF-8, for the analysis-setup warning. */
187
+ read_file: (path: string) => string;
188
+ }
189
+
190
+ const default_gitops_sync_deps: GitopsSyncDeps = {
191
+ repos_ops: default_repos_operations,
192
+ load_repos: local_repos_load,
193
+ // this searches the parent directory for the env var, so we don't use SvelteKit's $env imports
194
+ load_token: () => load_from_env('SECRET_GITHUB_API_TOKEN') || undefined,
195
+ exists: existsSync,
196
+ read_file: (path) => readFileSync(path, 'utf8')
197
+ };
198
+
199
+ /**
200
+ * Everything `gitops_sync` does before it fetches from GitHub, in order:
201
+ *
202
+ * 1. resolves the config's repos from `repos status <keys…> --json` (local
203
+ * refs), refusing a private repo when the host package is public
204
+ * 2. checks each repo is ready to generate from (`check_gen_readiness`): on
205
+ * its registry branch, clean, and idle, unless `allow_dirty` — refusing
206
+ * before any network. With `check`, that report is the whole run: its
207
+ * warnings are logged, and it throws when a real run would refuse
208
+ * 3. reads `SECRET_GITHUB_API_TOKEN`
209
+ * 4. fetches the repos from origin (`repos status <keys…> --fetch --json`,
210
+ * which writes remote-tracking refs and nothing else) and checks them
211
+ * again, warning on a followed branch not in sync with origin and a
212
+ * failed fetch
213
+ * 5. warns on each npm repo the library analysis can't fully read: no
214
+ * `node_modules`, or no `.svelte-kit/tsconfig.json` when its tsconfig
215
+ * extends it (external types then read as `any`) — it installs nothing
216
+ * 6. loads each repo's library as it sits
217
+ *
218
+ * @returns the loaded repos and the token, or `null` when `check` passed
219
+ * @throws {TaskError} if resolving fails, a repo isn't ready, or the token is missing
220
+ * @nodocs
221
+ */
222
+ export const prepare_gitops_sync = async (
223
+ options: {
224
+ config: string;
225
+ registry?: string;
226
+ host?: { name: string; private: boolean };
227
+ check?: boolean;
228
+ allow_dirty?: boolean;
229
+ log: Logger;
230
+ },
231
+ deps: Partial<GitopsSyncDeps> = {}
232
+ ): Promise<{ local_repos: Array<LocalRepo>; token: string } | null> => {
233
+ const { config, registry, host, check = false, allow_dirty = false, log } = options;
234
+ const { repos_ops, load_repos, load_token, exists, read_file } = {
235
+ ...default_gitops_sync_deps,
236
+ ...deps
237
+ };
238
+ const repos_command = to_repos_command(registry);
239
+
240
+ const { config_path, gitops_config, report } = await resolve_gitops_repos({
241
+ config,
242
+ registry,
243
+ host,
244
+ log,
245
+ repos_ops
246
+ });
247
+ const keys = gitops_config.repos;
248
+
249
+ const local = check_gen_readiness({ report, keys, allow_dirty, repos_command });
250
+ if (check) {
251
+ log_gen_warnings(local.warnings, log);
252
+ if (!local.ok) throw new TaskError(local.message);
253
+ log.info(st('green', `all ${keys.length} repos are ready to generate the site data from`));
254
+ return null;
255
+ }
256
+ if (!local.ok) throw new TaskError(local.message);
257
+
258
+ const token = load_token();
259
+ if (!token) {
260
+ throw new TaskError('the env var SECRET_GITHUB_API_TOKEN was not found');
261
+ }
262
+
263
+ log.info(`fetching ${keys.length} repos from origin`);
264
+ const fetched = await load_repos_status({ keys, registry, fetch: true, repos_ops });
265
+ if (!fetched.ok) {
266
+ throw new TaskError(`fetching the repos failed: ${fetched.message}`);
267
+ }
268
+ const resolved = local_repos_resolve({ keys, report: fetched.report, host, registry });
269
+ if (!resolved.ok) {
270
+ throw new TaskError(`${config_path}: ${resolved.message}`);
271
+ }
272
+ const checked = check_gen_readiness({
273
+ report: fetched.report,
274
+ keys,
275
+ allow_dirty,
276
+ repos_command
277
+ });
278
+ log_gen_warnings(checked.warnings, log);
279
+ if (!checked.ok) throw new TaskError(checked.message);
280
+
281
+ const local_repo_paths = resolved.value;
282
+ const gaps = local_repo_paths
283
+ .map((p) => ({ key: p.repo_name, missing: analysis_setup_gaps(p.repo_dir, exists, read_file) }))
284
+ .filter((g) => g.missing.length > 0);
285
+ if (gaps.length > 0) {
286
+ log.warn(
287
+ st(
288
+ 'yellow',
289
+ "the library analysis reads these repos without their dependencies' types, so external types read as `any` — `npm install` and `gro sync` in each fixes it:"
290
+ )
291
+ );
292
+ for (const { key, missing } of gaps) {
293
+ log.warn(st('yellow', ` ${key}: no ${missing.join(' or ')}`));
294
+ }
295
+ }
296
+
297
+ const local_repos = await load_repos({ local_repo_paths, log });
298
+ return { local_repos, token };
299
+ };
300
+
301
+ const log_gen_warnings = (warnings: Array<string>, log: Logger): void => {
302
+ if (warnings.length === 0) return;
303
+ log.warn(st('yellow', "read as they sit, beside origin's CI:"));
304
+ for (const line of warnings) log.warn(st('yellow', ` ${line}`));
305
+ };
306
+
307
+ /**
308
+ * What an npm repo lacks for the library analysis to resolve its dependencies'
309
+ * types: `node_modules`, and `.svelte-kit/tsconfig.json` when its
310
+ * `tsconfig.json` extends it. Empty for a repo without a `package.json`.
311
+ */
312
+ const analysis_setup_gaps = (
313
+ repo_dir: string,
314
+ exists: (path: string) => boolean,
315
+ read_file: (path: string) => string
316
+ ): Array<string> => {
317
+ if (!exists(join(repo_dir, 'package.json'))) return [];
318
+ const missing: Array<string> = [];
319
+ if (!exists(join(repo_dir, 'node_modules'))) missing.push('`node_modules`');
320
+ const tsconfig_path = join(repo_dir, 'tsconfig.json');
321
+ const svelte_kit_tsconfig = '.svelte-kit/tsconfig.json';
322
+ if (
323
+ exists(tsconfig_path) &&
324
+ read_file(tsconfig_path).includes(svelte_kit_tsconfig) &&
325
+ !exists(join(repo_dir, svelte_kit_tsconfig))
326
+ ) {
327
+ missing.push(`\`${svelte_kit_tsconfig}\``);
328
+ }
329
+ return missing;
330
+ };
@@ -1,156 +1,212 @@
1
1
  /**
2
2
  * Shared initialization logic for all gitops tasks.
3
3
  *
4
- * Provides `get_gitops_ready()` which orchestrates:
5
- * - Config loading and normalization
6
- * - Repo resolution (local path discovery)
7
- * - Branch switching and syncing
8
- * - Dependency installation
4
+ * `resolve_gitops_repos()` loads the config's registry keys, runs
5
+ * `repos status <keys…> --json`, and resolves each key to its checkout.
6
+ * `get_gitops_ready()` then loads each repo's library as its working tree
7
+ * sits. `gate_publish_readiness()` is the read-only gate a real publish runs
8
+ * before its prompt, and `log_readiness_block()` the diagnostics' report of
9
+ * repos not at rest (both over `repo_readiness.ts`).
9
10
  *
10
11
  * Used by: `gitops_sync.task.ts`, `gitops_analyze.task.ts`, `gitops_plan.task.ts`,
11
- * `gitops_publish.task.ts`, and `gitops_validate.task.ts`.
12
+ * `gitops_publish.task.ts`, `gitops_validate.task.ts`, and `gitops_run.task.ts`.
12
13
  *
13
- * Accepts `git_ops` and `npm_ops` parameters to support testing via operations pattern
14
- * (see `operations.ts` for dependency injection details).
14
+ * Accepts `repos_ops` to support testing via the operations pattern (see
15
+ * `operations.ts` for dependency injection details).
15
16
  *
16
17
  * @module
17
18
  */
18
19
 
19
20
  import { TaskError } from '@fuzdev/gro';
20
21
  import { styleText as st } from 'node:util';
21
- import { resolve, dirname } from 'node:path';
22
- import { print_path } from '@fuzdev/gro/paths.ts';
22
+ import { resolve } from 'node:path';
23
23
  import type { Logger } from '@fuzdev/fuz_util/log.ts';
24
+ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
24
25
 
25
26
  import { load_gitops_config, type GitopsConfig } from './gitops_config.ts';
26
- import { local_repos_load, local_repos_ensure, type LocalRepo } from './local_repo.ts';
27
- import { resolve_gitops_config } from './resolved_gitops_config.ts';
28
- import { DEFAULT_REPOS_DIR } from './paths.ts';
29
- import type { GitOperations, NpmOperations } from './operations.ts';
27
+ import {
28
+ local_repos_load,
29
+ local_repos_resolve,
30
+ repo_is_npm,
31
+ type LocalRepo,
32
+ type LocalRepoPath
33
+ } from './local_repo.ts';
34
+ import { load_repos_status, to_repos_command } from './repos_status_load.ts';
35
+ import {
36
+ check_publish_readiness,
37
+ format_readiness_ahead,
38
+ format_readiness_block,
39
+ repos_not_at_rest
40
+ } from './repo_readiness.ts';
41
+ import type { ReposStatusReport } from './repos_status.ts';
42
+ import type { ReposOperations } from './operations.ts';
43
+ import { default_repos_operations } from './operations_defaults.ts';
30
44
 
31
- export interface GetGitopsReadyOptions {
45
+ export interface ResolveGitopsReposOptions {
46
+ /** Path to the gitops config, absolute or relative to the cwd. */
32
47
  config: string;
33
- dir?: string;
34
- download: boolean;
35
- log?: Logger;
36
- git_ops?: GitOperations;
37
- npm_ops?: NpmOperations;
38
- parallel?: boolean;
39
- concurrency?: number;
48
+ /** A `repos.toml` to use instead of the one `repos` finds walking up from the cwd. */
49
+ registry?: string;
40
50
  /**
41
- * Sync each repo's working tree to its configured branch before loading
42
- * (switch branch, pull, install). When `false`, repos load exactly as they
43
- * sit on disk — the safe default for read-only diagnostics. Defaults to `true`.
51
+ * The package whose generated data the run writes; when it's public, a
52
+ * private repo in the config fails the resolve.
44
53
  */
45
- sync?: boolean;
46
- /** When syncing, tolerate uncommitted changes instead of throwing. Defaults to `false`. */
47
- allow_dirty?: boolean;
54
+ host?: { name: string; private: boolean };
55
+ log?: Logger;
56
+ repos_ops?: ReposOperations;
48
57
  }
49
58
 
50
59
  /**
51
- * Central initialization function for all gitops tasks.
52
- *
53
- * Initialization sequence:
54
- * 1. Loads and normalizes config from `gitops.config.ts`
55
- * 2. Resolves local repo paths (creates missing with `--download`)
56
- * 3. If `sync`, switches branches and pulls latest changes (in parallel by default)
57
- * 4. If `sync`, auto-installs deps if `package.json` changed during pull
58
- *
59
- * With `sync: false` (the default for read-only diagnostics), steps 3-4 are
60
- * skipped and repos are loaded exactly as checked out — no branch switch, pull,
61
- * install, or clean-workspace check.
62
- *
63
- * Priority for path resolution:
64
- * - `dir` argument (explicit override)
65
- * - Config `repos_dir` setting
66
- * - `DEFAULT_REPOS_DIR` constant
60
+ * Resolves the gitops config's repos through `repos status`: loads the
61
+ * config's registry keys, reports on them, and resolves each to its checkout,
62
+ * in config order. Reads nothing but git state and writes nothing.
67
63
  *
68
- * @param options.git_ops - for testing (defaults to real git operations)
69
- * @param options.npm_ops - for testing (defaults to real npm operations)
70
- * @param options.parallel - whether to load repos in parallel (default: true)
71
- * @param options.concurrency - max concurrent repo loads (default: 5)
72
- * @param options.sync - sync working trees before loading (default: true)
73
- * @param options.allow_dirty - when syncing, tolerate uncommitted changes (default: false)
74
- * @returns initialized config and fully loaded repos ready for operations
75
- * @throws {TaskError} if config loading or repo resolution fails
64
+ * @returns the config, the `repos status` report, and each repo's path and entry
65
+ * @throws {TaskError} if the config is missing, invalid, or lists no repos, `repos status` fails, or any configured repo is unknown, a reference, missing, not a repo, unprobed, or private under a public `host`
76
66
  */
77
- export const get_gitops_ready = async (
78
- options: GetGitopsReadyOptions
67
+ export const resolve_gitops_repos = async (
68
+ options: ResolveGitopsReposOptions
79
69
  ): Promise<{
80
70
  config_path: string;
81
- repos_dir: string;
82
71
  gitops_config: GitopsConfig;
83
- local_repos: Array<LocalRepo>;
72
+ report: ReposStatusReport;
73
+ local_repo_paths: Array<LocalRepoPath>;
84
74
  }> => {
85
- const { config, dir, download, log, git_ops, npm_ops, parallel, concurrency, sync, allow_dirty } =
86
- options;
75
+ const { config, registry, host, log, repos_ops = default_repos_operations } = options;
87
76
  const config_path = resolve(config);
88
77
  const gitops_config = await import_gitops_config(config_path);
78
+ const keys = gitops_config.repos;
79
+ if (keys.length === 0) {
80
+ throw new TaskError(`No repos are configured in ${config_path}`);
81
+ }
89
82
 
90
- // Priority: explicit dir arg → config repos_dir → default (two dirs up from config)
91
- const repos_dir = resolve_gitops_paths({
92
- config,
93
- dir,
94
- config_repos_dir: gitops_config.repos_dir
95
- }).repos_dir;
96
-
97
- log?.info(
98
- `resolving gitops configs on the filesystem in ${repos_dir}`,
99
- gitops_config.repos.map((r) => r.repo_url)
100
- );
101
- const resolved_config = resolve_gitops_config(gitops_config, repos_dir);
83
+ log?.info(`reading the state of ${keys.length} repos from \`repos status\``);
84
+ log?.debug('repos status targets', keys);
85
+ const loaded = await load_repos_status({ keys, registry, repos_ops });
86
+ if (!loaded.ok) {
87
+ // an unknown key is the config's to fix, so name the config
88
+ throw new TaskError(
89
+ loaded.error?.kind === 'unknown_entry' ? `${config_path}: ${loaded.message}` : loaded.message
90
+ );
91
+ }
92
+ const { report } = loaded;
102
93
 
103
- const local_repo_paths = await local_repos_ensure({
104
- resolved_config,
105
- repos_dir,
106
- gitops_config,
107
- download,
108
- log,
109
- npm_ops
110
- });
94
+ const resolved = local_repos_resolve({ keys, report, host, registry });
95
+ if (!resolved.ok) {
96
+ throw new TaskError(`${config_path}: ${resolved.message}`);
97
+ }
111
98
 
112
- const local_repos = await local_repos_load({
113
- local_repo_paths,
114
- log,
115
- git_ops,
116
- npm_ops,
117
- parallel,
118
- concurrency,
119
- sync,
120
- allow_dirty
121
- });
99
+ return { config_path, gitops_config, report, local_repo_paths: resolved.value };
100
+ };
122
101
 
123
- return { config_path, repos_dir, gitops_config, local_repos };
102
+ /**
103
+ * Central initialization function for the gitops tasks that load libraries:
104
+ * resolves the config's repos through `repos status` (`resolve_gitops_repos`),
105
+ * then loads each repo's library as its working tree sits (`local_repos_load`).
106
+ * Moves no ref; gro caches each library at `.gro/library.json` in its repo, at
107
+ * a clean commit.
108
+ *
109
+ * @returns the loaded repos, in config order
110
+ * @throws {TaskError} if resolving the repos or loading them fails
111
+ */
112
+ export const get_gitops_ready = async (
113
+ options: ResolveGitopsReposOptions
114
+ ): Promise<{ local_repos: Array<LocalRepo> }> => {
115
+ const { local_repo_paths } = await resolve_gitops_repos(options);
116
+ const local_repos = await local_repos_load({ local_repo_paths, log: options.log });
117
+ return { local_repos };
124
118
  };
125
119
 
126
- export interface ResolveGitopsPathsOptions {
127
- config: string;
128
- dir?: string;
129
- config_repos_dir?: string;
120
+ export interface GatePublishReadinessOptions {
121
+ /** The loaded repos; the npm ones are gated. */
122
+ local_repos: ReadonlyArray<LocalRepo>;
123
+ /** A `repos.toml` to use instead of the one `repos` finds walking up from the cwd. */
124
+ registry?: string;
125
+ /**
126
+ * The package names the plan publishes, to say whether a repo's commits ahead
127
+ * of origin go out with its release or stay unpushed.
128
+ */
129
+ publishing?: ReadonlySet<string>;
130
+ log?: Logger;
131
+ repos_ops?: ReposOperations;
130
132
  }
131
133
 
132
- export const resolve_gitops_paths = (
133
- options: ResolveGitopsPathsOptions
134
- ): { config_path: string; repos_dir: string } => {
135
- const { config, dir, config_repos_dir } = options;
136
- const config_path = resolve(config);
137
- const config_dir = dirname(config_path);
134
+ /**
135
+ * The readiness gate `gitops_publish --wetrun` runs before its confirmation
136
+ * prompt: fetches every npm repo from origin (`repos status <keys…> --fetch
137
+ * --json`, which writes remote-tracking refs and nothing else) and refuses
138
+ * unless each is ready (`check_publish_readiness`) — on its registry branch,
139
+ * clean, idle, in sync with origin or ahead of it, fetched without error, no
140
+ * other live session in its checkout, and nothing left to a person. Each
141
+ * ready repo ahead of origin is logged, saying whether its release push
142
+ * carries those commits or they stay unpushed.
143
+ *
144
+ * Every npm repo, not just those the plan publishes or rewrites: the plan
145
+ * reads each one's working tree (changesets, versions, dependency ranges), so
146
+ * a repo off its branch or behind origin can hide a changeset and leave the
147
+ * plan wrong about what to publish. Changes nothing.
148
+ *
149
+ * @param options - the loaded repos, the `--registry` if any, the package names the plan publishes, a logger, and the `repos` runner
150
+ * @throws {TaskError} if `repos status` fails or any npm repo isn't ready, naming each problem and its fix
151
+ */
152
+ export const gate_publish_readiness = async (
153
+ options: GatePublishReadinessOptions
154
+ ): Promise<void> => {
155
+ const { local_repos, registry, publishing, log, repos_ops = default_repos_operations } = options;
156
+ const npm_repos = local_repos.filter(repo_is_npm);
157
+ const keys = npm_repos.map((r) => r.entry.key);
158
+ if (keys.length === 0) return;
138
159
 
139
- // Priority: explicit dir arg → config repos_dir → default (parent of config dir)
140
- const repos_dir =
141
- dir !== undefined
142
- ? resolve(dir)
143
- : config_repos_dir !== undefined
144
- ? resolve(config_dir, config_repos_dir)
145
- : resolve(config_dir, DEFAULT_REPOS_DIR);
160
+ log?.info(`fetching ${keys.length} npm repos to check they're ready to publish`);
161
+ const loaded = await load_repos_status({ keys, registry, fetch: true, repos_ops });
162
+ if (!loaded.ok) {
163
+ throw new TaskError(`the readiness check failed: ${loaded.message}`);
164
+ }
165
+ const repos_command = to_repos_command(registry);
166
+ const checked = check_publish_readiness({ report: loaded.report, keys, repos_command });
167
+ if (!checked.ok) {
168
+ throw new TaskError(checked.message);
169
+ }
170
+ log?.info(st('green', `all ${keys.length} npm repos are ready to publish`));
171
+ const name_by_key = new Map(npm_repos.map((r) => [r.entry.key, r.library.name] as const));
172
+ for (const ahead of checked.ahead) {
173
+ const name = name_by_key.get(ahead.key);
174
+ const publishes = name !== undefined && publishing?.has(name) === true;
175
+ log?.info(st('yellow', format_readiness_ahead(ahead, publishes, { repos_command })));
176
+ }
177
+ };
146
178
 
147
- return { config_path, repos_dir };
179
+ /**
180
+ * Logs the diagnostics' readiness block as warnings (stderr, so a `--format
181
+ * json` or `markdown` document on stdout stays clean): each npm repo not at
182
+ * rest, and how. Logs nothing when every one is at rest. Reads the entries
183
+ * `repos status` already reported, from local refs.
184
+ *
185
+ * @param local_repos - the loaded repos; the npm ones are reported
186
+ * @param log - where the warnings go
187
+ * @param now - the current time in unix seconds (defaults to the clock)
188
+ */
189
+ export const log_readiness_block = (
190
+ local_repos: ReadonlyArray<LocalRepo>,
191
+ log: Logger,
192
+ now: number = Math.floor(Date.now() / 1000)
193
+ ): void => {
194
+ const not_ready = repos_not_at_rest(local_repos.filter(repo_is_npm).map((r) => r.entry));
195
+ for (const line of format_readiness_block(not_ready, now)) {
196
+ log.warn(st('yellow', line));
197
+ }
148
198
  };
149
199
 
150
- export const import_gitops_config = async (config_path: string): Promise<GitopsConfig> => {
151
- const gitops_config = await load_gitops_config(config_path);
200
+ const import_gitops_config = async (config_path: string): Promise<GitopsConfig> => {
201
+ let gitops_config: GitopsConfig | null;
202
+ try {
203
+ gitops_config = await load_gitops_config(config_path);
204
+ } catch (err) {
205
+ // an invalid config is the user's to fix, not an unexpected task failure
206
+ throw new TaskError(to_error_message(err));
207
+ }
152
208
  if (!gitops_config) {
153
- throw new TaskError(st('red', `No gitops config found at ${print_path(config_path)}`));
209
+ throw new TaskError(st('red', `No gitops config found at ${config_path}`));
154
210
  }
155
211
  return gitops_config;
156
212
  };