@quenty/nevermore-cli 4.42.0 → 4.42.1-canary.783.30683606415.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 (172) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/args/global-args.d.ts +13 -0
  3. package/dist/args/global-args.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.d.ts +3 -0
  5. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  6. package/dist/commands/batch-command/batch-deploy-command.js +90 -1
  7. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  8. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  9. package/dist/commands/batch-command/batch-test-command.js +7 -2
  10. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  11. package/dist/commands/deploy-command/deploy-init.js +1 -1
  12. package/dist/commands/deploy-command/deploy-init.js.map +1 -1
  13. package/dist/commands/deploy-command/index.d.ts +3 -0
  14. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  15. package/dist/commands/deploy-command/index.js +301 -60
  16. package/dist/commands/deploy-command/index.js.map +1 -1
  17. package/dist/commands/deploy-command/select-target.js +1 -1
  18. package/dist/commands/deploy-command/select-target.js.map +1 -1
  19. package/dist/commands/deploy-command/version-command.d.ts.map +1 -1
  20. package/dist/commands/deploy-command/version-command.js +138 -12
  21. package/dist/commands/deploy-command/version-command.js.map +1 -1
  22. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  23. package/dist/commands/test-command/test-command.js +17 -7
  24. package/dist/commands/test-command/test-command.js.map +1 -1
  25. package/dist/nevermore.js +17 -0
  26. package/dist/nevermore.js.map +1 -1
  27. package/dist/utils/batch/changed-packages-utils.d.ts +1 -1
  28. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  29. package/dist/utils/batch/changed-packages-utils.js +1 -1
  30. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  31. package/dist/utils/build/base-place-resolver-factory.d.ts +25 -0
  32. package/dist/utils/build/base-place-resolver-factory.d.ts.map +1 -0
  33. package/dist/utils/build/base-place-resolver-factory.js +40 -0
  34. package/dist/utils/build/base-place-resolver-factory.js.map +1 -0
  35. package/dist/utils/build/build.d.ts +13 -2
  36. package/dist/utils/build/build.d.ts.map +1 -1
  37. package/dist/utils/build/build.js.map +1 -1
  38. package/dist/utils/build/upload.d.ts +1 -1
  39. package/dist/utils/build/upload.d.ts.map +1 -1
  40. package/dist/utils/deploy/deploy-metadata.d.ts +1 -1
  41. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  42. package/dist/utils/job-context/base-job-context.d.ts +3 -1
  43. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  44. package/dist/utils/job-context/base-job-context.js +45 -6
  45. package/dist/utils/job-context/base-job-context.js.map +1 -1
  46. package/dist/utils/job-context/batch-script-job-context.d.ts +6 -0
  47. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  48. package/dist/utils/job-context/batch-script-job-context.js +13 -0
  49. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  50. package/dist/utils/job-context/cloud-job-context.d.ts +2 -1
  51. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  52. package/dist/utils/job-context/cloud-job-context.js +2 -2
  53. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  54. package/dist/utils/job-context/local-job-context.d.ts +2 -1
  55. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  56. package/dist/utils/job-context/local-job-context.js +2 -2
  57. package/dist/utils/job-context/local-job-context.js.map +1 -1
  58. package/dist/utils/open-cloud/open-cloud-client.d.ts +20 -2
  59. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  60. package/dist/utils/open-cloud/open-cloud-client.js +74 -20
  61. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  62. package/dist/utils/open-cloud/open-cloud-client.test.js +89 -12
  63. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  64. package/dist/utils/testing/runner/combined-project-generator.d.ts +1 -1
  65. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  66. package/dist/utils/testing/runner/test-runner.d.ts +1 -1
  67. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  68. package/dist/utils/watch/github-context.d.ts +50 -0
  69. package/dist/utils/watch/github-context.d.ts.map +1 -0
  70. package/dist/utils/watch/github-context.js +125 -0
  71. package/dist/utils/watch/github-context.js.map +1 -0
  72. package/dist/utils/watch/github-context.test.d.ts +2 -0
  73. package/dist/utils/watch/github-context.test.d.ts.map +1 -0
  74. package/dist/utils/watch/github-context.test.js +112 -0
  75. package/dist/utils/watch/github-context.test.js.map +1 -0
  76. package/dist/utils/watch/http-watch-registry.d.ts +40 -0
  77. package/dist/utils/watch/http-watch-registry.d.ts.map +1 -0
  78. package/dist/utils/watch/http-watch-registry.js +262 -0
  79. package/dist/utils/watch/http-watch-registry.js.map +1 -0
  80. package/dist/utils/watch/http-watch-registry.test.d.ts +2 -0
  81. package/dist/utils/watch/http-watch-registry.test.d.ts.map +1 -0
  82. package/dist/utils/watch/http-watch-registry.test.js +211 -0
  83. package/dist/utils/watch/http-watch-registry.test.js.map +1 -0
  84. package/dist/utils/watch/local-watch-loop.d.ts +50 -0
  85. package/dist/utils/watch/local-watch-loop.d.ts.map +1 -0
  86. package/dist/utils/watch/local-watch-loop.js +103 -0
  87. package/dist/utils/watch/local-watch-loop.js.map +1 -0
  88. package/dist/utils/watch/local-watch-loop.test.d.ts +2 -0
  89. package/dist/utils/watch/local-watch-loop.test.d.ts.map +1 -0
  90. package/dist/utils/watch/local-watch-loop.test.js +170 -0
  91. package/dist/utils/watch/local-watch-loop.test.js.map +1 -0
  92. package/dist/utils/watch/register-notify-watches.d.ts +41 -0
  93. package/dist/utils/watch/register-notify-watches.d.ts.map +1 -0
  94. package/dist/utils/watch/register-notify-watches.js +159 -0
  95. package/dist/utils/watch/register-notify-watches.js.map +1 -0
  96. package/dist/utils/watch/register-notify-watches.test.d.ts +2 -0
  97. package/dist/utils/watch/register-notify-watches.test.d.ts.map +1 -0
  98. package/dist/utils/watch/register-notify-watches.test.js +225 -0
  99. package/dist/utils/watch/register-notify-watches.test.js.map +1 -0
  100. package/dist/utils/watch/register-watches.d.ts +68 -0
  101. package/dist/utils/watch/register-watches.d.ts.map +1 -0
  102. package/dist/utils/watch/register-watches.js +221 -0
  103. package/dist/utils/watch/register-watches.js.map +1 -0
  104. package/dist/utils/watch/register-watches.test.d.ts +2 -0
  105. package/dist/utils/watch/register-watches.test.d.ts.map +1 -0
  106. package/dist/utils/watch/register-watches.test.js +576 -0
  107. package/dist/utils/watch/register-watches.test.js.map +1 -0
  108. package/dist/utils/watch/stream-watch-loop.d.ts +54 -0
  109. package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -0
  110. package/dist/utils/watch/stream-watch-loop.js +282 -0
  111. package/dist/utils/watch/stream-watch-loop.js.map +1 -0
  112. package/dist/utils/watch/stream-watch-loop.test.d.ts +2 -0
  113. package/dist/utils/watch/stream-watch-loop.test.d.ts.map +1 -0
  114. package/dist/utils/watch/stream-watch-loop.test.js +392 -0
  115. package/dist/utils/watch/stream-watch-loop.test.js.map +1 -0
  116. package/dist/utils/watch/websocket-watch-stream.d.ts +25 -0
  117. package/dist/utils/watch/websocket-watch-stream.d.ts.map +1 -0
  118. package/dist/utils/watch/websocket-watch-stream.js +306 -0
  119. package/dist/utils/watch/websocket-watch-stream.js.map +1 -0
  120. package/dist/utils/watch/websocket-watch-stream.test.d.ts +2 -0
  121. package/dist/utils/watch/websocket-watch-stream.test.d.ts.map +1 -0
  122. package/dist/utils/watch/websocket-watch-stream.test.js +475 -0
  123. package/dist/utils/watch/websocket-watch-stream.test.js.map +1 -0
  124. package/package.json +7 -4
  125. package/src/args/global-args.ts +13 -0
  126. package/src/commands/batch-command/batch-deploy-command.ts +105 -1
  127. package/src/commands/batch-command/batch-test-command.ts +8 -2
  128. package/src/commands/deploy-command/deploy-init.ts +1 -1
  129. package/src/commands/deploy-command/index.ts +331 -32
  130. package/src/commands/deploy-command/select-target.ts +1 -1
  131. package/src/commands/deploy-command/version-command.ts +215 -21
  132. package/src/commands/test-command/test-command.ts +24 -18
  133. package/src/nevermore.ts +19 -0
  134. package/src/utils/batch/changed-packages-utils.ts +1 -1
  135. package/src/utils/build/base-place-resolver-factory.ts +49 -0
  136. package/src/utils/build/build.ts +13 -2
  137. package/src/utils/build/upload.ts +1 -1
  138. package/src/utils/deploy/deploy-metadata.ts +1 -1
  139. package/src/utils/job-context/base-job-context.ts +68 -6
  140. package/src/utils/job-context/batch-script-job-context.ts +18 -0
  141. package/src/utils/job-context/cloud-job-context.ts +7 -2
  142. package/src/utils/job-context/local-job-context.ts +7 -2
  143. package/src/utils/open-cloud/open-cloud-client.test.ts +133 -14
  144. package/src/utils/open-cloud/open-cloud-client.ts +127 -34
  145. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  146. package/src/utils/testing/runner/test-runner.ts +1 -1
  147. package/src/utils/watch/github-context.test.ts +154 -0
  148. package/src/utils/watch/github-context.ts +184 -0
  149. package/src/utils/watch/http-watch-registry.test.ts +330 -0
  150. package/src/utils/watch/http-watch-registry.ts +341 -0
  151. package/src/utils/watch/local-watch-loop.test.ts +197 -0
  152. package/src/utils/watch/local-watch-loop.ts +190 -0
  153. package/src/utils/watch/register-notify-watches.test.ts +282 -0
  154. package/src/utils/watch/register-notify-watches.ts +226 -0
  155. package/src/utils/watch/register-watches.test.ts +696 -0
  156. package/src/utils/watch/register-watches.ts +369 -0
  157. package/src/utils/watch/stream-watch-loop.test.ts +625 -0
  158. package/src/utils/watch/stream-watch-loop.ts +411 -0
  159. package/src/utils/watch/websocket-watch-stream.test.ts +581 -0
  160. package/src/utils/watch/websocket-watch-stream.ts +403 -0
  161. package/tsconfig.json +19 -6
  162. package/tsconfig.tsbuildinfo +1 -1
  163. package/dist/utils/build/deploy-config.d.ts +0 -71
  164. package/dist/utils/build/deploy-config.d.ts.map +0 -1
  165. package/dist/utils/build/deploy-config.js +0 -164
  166. package/dist/utils/build/deploy-config.js.map +0 -1
  167. package/dist/utils/build/deploy-config.test.d.ts +0 -2
  168. package/dist/utils/build/deploy-config.test.d.ts.map +0 -1
  169. package/dist/utils/build/deploy-config.test.js +0 -126
  170. package/dist/utils/build/deploy-config.test.js.map +0 -1
  171. package/src/utils/build/deploy-config.test.ts +0 -149
  172. package/src/utils/build/deploy-config.ts +0 -259
@@ -0,0 +1,184 @@
1
+ import { execSync } from 'child_process';
2
+
3
+ /**
4
+ * Env var holding the token the watch service dispatches with. Checked before
5
+ * `GITHUB_TOKEN` so a long-lived registration token can be kept separate from
6
+ * the short-lived one a workflow run is handed — an Actions `GITHUB_TOKEN`
7
+ * expires with its job, which would outlive nothing.
8
+ */
9
+ const WATCH_TOKEN_ENV = 'NEVERMORE_WATCH_TOKEN';
10
+
11
+ /** Everything the watch service needs to dispatch a workflow back at us. */
12
+ export interface GithubDispatchContext {
13
+ /** `owner/repo`. */
14
+ repository: string;
15
+ /** Git ref the dispatched workflow should run on. */
16
+ ref: string;
17
+ /** Token the service dispatches with; absent when none was provided. */
18
+ token?: string;
19
+ }
20
+
21
+ export interface GithubContextOptions {
22
+ /**
23
+ * Read the token from the GitHub CLI when no environment variable supplies
24
+ * one.
25
+ *
26
+ * Off unless asked for, and that is the whole point. Registering sends a PAT
27
+ * to the watch service and creates a monitor against the repository, so the
28
+ * credential has to be one the user handed over — an env var is handing it
29
+ * over, reading whatever `gh` happens to be holding is not. Nearly every
30
+ * developer machine has a logged-in `gh`, so defaulting this on would turn a
31
+ * command that used to watch locally into one that quietly ships a token.
32
+ */
33
+ useGhAuth?: boolean;
34
+ }
35
+
36
+ export type GithubContextResult =
37
+ | { success: true; context: GithubDispatchContext }
38
+ | {
39
+ success: false;
40
+ reason: 'no_repository' | 'no_ref' | 'undispatchable_ref';
41
+ };
42
+
43
+ /**
44
+ * A `pull_request` run exposes `GITHUB_REF_NAME` as `42/merge` — a synthetic
45
+ * merge ref that exists only for that check run. It is not a branch, so
46
+ * `workflow_dispatch` cannot run on it, and it disappears when the PR closes.
47
+ */
48
+ const MERGE_REF_PATTERN = /^\d+\/(merge|head)$/;
49
+
50
+ /**
51
+ * Read an env var, treating empty as absent.
52
+ *
53
+ * CI sets a variable to the empty string when the secret behind it is missing
54
+ * (`FOO: ${{ secrets.ABSENT }}`), which `??` would accept as a real value —
55
+ * so the documented fallback to the next source would never fire.
56
+ */
57
+ function _env(name: string): string | undefined {
58
+ const value = process.env[name];
59
+ return value == null || value === '' ? undefined : value;
60
+ }
61
+
62
+ /**
63
+ * Work out which repo and ref a watch should dispatch.
64
+ *
65
+ * Prefers the Actions environment, which is authoritative when we are already
66
+ * running inside the workflow being registered, and falls back to the git
67
+ * remote so `--watch <url>` also works from a developer's machine. Best-effort:
68
+ * outside a git repo with no Actions env there is simply nothing to dispatch.
69
+ */
70
+ export function tryResolveGithubDispatchContext(
71
+ options: GithubContextOptions = {}
72
+ ): GithubContextResult {
73
+ const repository = _env('GITHUB_REPOSITORY') ?? _repositoryFromRemote();
74
+ if (!repository) {
75
+ return { success: false, reason: 'no_repository' };
76
+ }
77
+
78
+ const ref =
79
+ _env('GITHUB_REF_NAME') ?? _git(['rev-parse', '--abbrev-ref', 'HEAD']);
80
+ // A detached HEAD gives "HEAD", which is not a ref a dispatch can run on.
81
+ if (!ref || ref === 'HEAD') {
82
+ return { success: false, reason: 'no_ref' };
83
+ }
84
+ if (MERGE_REF_PATTERN.test(ref)) {
85
+ return { success: false, reason: 'undispatchable_ref' };
86
+ }
87
+
88
+ const token =
89
+ _env(WATCH_TOKEN_ENV) ??
90
+ _env('GITHUB_TOKEN') ??
91
+ (options.useGhAuth ? _githubCliToken() : undefined);
92
+
93
+ return { success: true, context: { repository, ref, token } };
94
+ }
95
+
96
+ /** Human-facing explanation for a failed context resolution. */
97
+ export function describeGithubContextFailure(
98
+ reason: 'no_repository' | 'no_ref' | 'undispatchable_ref'
99
+ ): string {
100
+ if (reason === 'no_repository') {
101
+ return (
102
+ 'Could not determine the GitHub repository to dispatch. Set ' +
103
+ 'GITHUB_REPOSITORY (owner/repo), or run from a clone with a GitHub ' +
104
+ '"origin" remote.'
105
+ );
106
+ }
107
+ if (reason === 'undispatchable_ref') {
108
+ return (
109
+ 'Refusing to register a watch from a pull request merge ref. That ref ' +
110
+ 'exists only for this check run, so a dispatch against it would fail ' +
111
+ 'and it disappears when the PR closes. Register from a branch instead ' +
112
+ '(e.g. on push to main).'
113
+ );
114
+ }
115
+ return (
116
+ 'Could not determine the git ref to dispatch on. Set GITHUB_REF_NAME, or ' +
117
+ 'check out a branch (a detached HEAD has no ref to run a workflow from).'
118
+ );
119
+ }
120
+
121
+ /** Hint printed when a cloud registration has no credential to hand over. */
122
+ export function describeMissingWatchToken(): string {
123
+ return [
124
+ 'No GitHub token found. The watch service identifies you by repository, ' +
125
+ 'so registering needs a token with "actions: write" on it. Either:',
126
+ ` - Set: ${WATCH_TOKEN_ENV} (or GITHUB_TOKEN)`,
127
+ ' - Pass: --watch-use-gh-auth, to use the token `gh` already holds',
128
+ ].join('\n');
129
+ }
130
+
131
+ /**
132
+ * Pull `owner/repo` out of a git remote URL. Handles the HTTPS, SCP-style, and
133
+ * ssh:// spellings git hands out, and ignores non-GitHub hosts — a watch
134
+ * registration names a GitHub workflow, so anything else is not dispatchable.
135
+ */
136
+ export function parseGithubRepository(remoteUrl: string): string | undefined {
137
+ const trimmed = remoteUrl.trim();
138
+ const match =
139
+ /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/i.exec(
140
+ trimmed
141
+ );
142
+ if (!match) {
143
+ return undefined;
144
+ }
145
+ return `${match[1]}/${match[2]}`;
146
+ }
147
+
148
+ function _repositoryFromRemote(): string | undefined {
149
+ const remote = _git(['remote', 'get-url', 'origin']);
150
+ return remote == null ? undefined : parseGithubRepository(remote);
151
+ }
152
+
153
+ /**
154
+ * The token `gh` is already holding, if it is installed and logged in.
155
+ *
156
+ * Only ever called when the caller opted in. Not stored anywhere by us on
157
+ * purpose: `gh` owns its own refresh, and copying a token into `~/.nevermore`
158
+ * would leave a stale duplicate to expire behind everyone's back — the point is
159
+ * reading the live one, for the one run that asked.
160
+ */
161
+ function _githubCliToken(): string | undefined {
162
+ const token = _run('gh', ['auth', 'token']);
163
+ // A logged-out `gh` exits non-zero, but an odd version could print a notice
164
+ // on stdout instead; a token is never whitespace.
165
+ return token == null || /\s/.test(token) ? undefined : token;
166
+ }
167
+
168
+ function _git(args: string[]): string | undefined {
169
+ return _run('git', args);
170
+ }
171
+
172
+ function _run(command: string, args: string[]): string | undefined {
173
+ try {
174
+ const output = execSync(`${command} ${args.join(' ')}`, {
175
+ encoding: 'utf-8',
176
+ timeout: 5000,
177
+ stdio: ['pipe', 'pipe', 'pipe'],
178
+ }).trim();
179
+ return output === '' ? undefined : output;
180
+ } catch {
181
+ // Not installed, not logged in, or not a repo — all "no answer here".
182
+ return undefined;
183
+ }
184
+ }
@@ -0,0 +1,330 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { type WatchRegistrationRequest } from '@quenty/nevermore-deploy';
3
+ import { HttpWatchRegistry } from './http-watch-registry.js';
4
+
5
+ const REGISTER_URL = 'https://watch.example.com/v1/register/7d/';
6
+
7
+ function makeRequest(
8
+ overrides: Partial<WatchRegistrationRequest> = {}
9
+ ): WatchRegistrationRequest {
10
+ return {
11
+ monitorName: 'integration',
12
+ repository: 'Quenty/egg-hunt-2026',
13
+ githubToken: 'token-value',
14
+ watches: [
15
+ {
16
+ name: 'quenty/hub/hub',
17
+ source: {
18
+ type: 'roblox-place',
19
+ universeId: 10,
20
+ placeId: 20,
21
+ versionType: 'published',
22
+ },
23
+ baselineVersion: '158',
24
+ baselineVersionKind: 'roblox-place-version',
25
+ action: {
26
+ type: 'github-workflow-dispatch',
27
+ workflow: '.github/workflows/build.yml',
28
+ ref: 'main',
29
+ inputs: { target: 'integration.places.hub' },
30
+ },
31
+ },
32
+ ],
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function makeResponse(
38
+ body: string,
39
+ init: { status?: number; statusText?: string } = {}
40
+ ): Response {
41
+ return {
42
+ ok: (init.status ?? 200) < 400,
43
+ status: init.status ?? 200,
44
+ statusText: init.statusText ?? 'OK',
45
+ text: async () => body,
46
+ } as Response;
47
+ }
48
+
49
+ function registryReturning(
50
+ response: Response,
51
+ calls?: Array<{ url: string; init: RequestInit }>
52
+ ): HttpWatchRegistry {
53
+ return new HttpWatchRegistry({
54
+ registerUrl: REGISTER_URL,
55
+ fetchImpl: (async (url: string, init: RequestInit) => {
56
+ calls?.push({ url, init });
57
+ return response;
58
+ }) as unknown as typeof fetch,
59
+ });
60
+ }
61
+
62
+ describe('HttpWatchRegistry', () => {
63
+ it('posts a MonitorRequest to the register URL', async () => {
64
+ const calls: Array<{ url: string; init: RequestInit }> = [];
65
+ await registryReturning(makeResponse('{}'), calls).registerAsync(
66
+ makeRequest()
67
+ );
68
+
69
+ expect(calls).toHaveLength(1);
70
+ expect(calls[0]!.url).toBe(REGISTER_URL);
71
+ expect(calls[0]!.init.method).toBe('POST');
72
+
73
+ expect(JSON.parse(calls[0]!.init.body as string)).toEqual({
74
+ name: 'integration',
75
+ auth: {
76
+ github: {
77
+ token: 'token-value',
78
+ repository: 'Quenty/egg-hunt-2026',
79
+ },
80
+ },
81
+ watches: [
82
+ {
83
+ name: 'quenty/hub/hub',
84
+ source: {
85
+ type: 'roblox-place',
86
+ universeId: 10,
87
+ placeId: 20,
88
+ versionType: 'published',
89
+ },
90
+ baselineVersion: '158',
91
+ baselineVersionKind: 'roblox-place-version',
92
+ action: {
93
+ type: 'github-workflow-dispatch',
94
+ workflow: '.github/workflows/build.yml',
95
+ ref: 'main',
96
+ inputs: { target: 'integration.places.hub' },
97
+ },
98
+ },
99
+ ],
100
+ });
101
+ });
102
+
103
+ it('sends every watch in one request', async () => {
104
+ const calls: Array<{ url: string; init: RequestInit }> = [];
105
+ const base = makeRequest();
106
+ await registryReturning(makeResponse('{}'), calls).registerAsync({
107
+ ...base,
108
+ watches: [base.watches[0]!, { ...base.watches[0]!, name: 'pkg/lobby' }],
109
+ });
110
+
111
+ expect(calls).toHaveLength(1);
112
+ expect(JSON.parse(calls[0]!.init.body as string).watches).toHaveLength(2);
113
+ });
114
+
115
+ it('omits the Open Cloud key unless one was shared', async () => {
116
+ const calls: Array<{ url: string; init: RequestInit }> = [];
117
+ await registryReturning(makeResponse('{}'), calls).registerAsync(
118
+ makeRequest()
119
+ );
120
+
121
+ expect(JSON.parse(calls[0]!.init.body as string).auth).not.toHaveProperty(
122
+ 'robloxOpenCloud'
123
+ );
124
+ });
125
+
126
+ it('includes the Open Cloud key when shared', async () => {
127
+ const calls: Array<{ url: string; init: RequestInit }> = [];
128
+ await registryReturning(makeResponse('{}'), calls).registerAsync(
129
+ makeRequest({ robloxApiKey: 'oc-key' })
130
+ );
131
+
132
+ expect(
133
+ JSON.parse(calls[0]!.init.body as string).auth.robloxOpenCloud
134
+ ).toEqual({ apiKey: 'oc-key' });
135
+ });
136
+
137
+ // The kind describes the value, so sending it alone would declare the
138
+ // vocabulary of a baseline that is not there.
139
+ it('omits the declared kind along with the baseline', async () => {
140
+ const calls: Array<{ url: string; init: RequestInit }> = [];
141
+ const base = makeRequest();
142
+ await registryReturning(makeResponse('{}'), calls).registerAsync({
143
+ ...base,
144
+ watches: [{ ...base.watches[0]!, baselineVersion: undefined }],
145
+ });
146
+
147
+ expect(
148
+ JSON.parse(calls[0]!.init.body as string).watches[0]
149
+ ).not.toHaveProperty('baselineVersionKind');
150
+ });
151
+
152
+ it('omits baselineVersion when there is no baseline', async () => {
153
+ const calls: Array<{ url: string; init: RequestInit }> = [];
154
+ const base = makeRequest();
155
+ await registryReturning(makeResponse('{}'), calls).registerAsync({
156
+ ...base,
157
+ watches: [{ ...base.watches[0]!, baselineVersion: undefined }],
158
+ });
159
+
160
+ expect(
161
+ JSON.parse(calls[0]!.init.body as string).watches[0]
162
+ ).not.toHaveProperty('baselineVersion');
163
+ });
164
+
165
+ it('reads back the monitor the service reports', async () => {
166
+ const result = await registryReturning(
167
+ makeResponse(
168
+ JSON.stringify({
169
+ monitorId: 'mon_9f2a',
170
+ revision: 'abc123',
171
+ leaseExpiresAt: '2026-08-06T00:00:00Z',
172
+ changed: true,
173
+ watches: [{ name: 'a' }, { name: 'b' }],
174
+ unexpected: 'ignored',
175
+ })
176
+ )
177
+ ).registerAsync(makeRequest());
178
+
179
+ expect(result).toEqual({
180
+ monitorId: 'mon_9f2a',
181
+ revision: 'abc123',
182
+ leaseExpiresAt: '2026-08-06T00:00:00Z',
183
+ changed: true,
184
+ watchCount: 2,
185
+ });
186
+ });
187
+
188
+ it('reports an unchanged re-apply', async () => {
189
+ const result = await registryReturning(
190
+ makeResponse(JSON.stringify({ monitorId: 'mon_1', changed: false }))
191
+ ).registerAsync(makeRequest());
192
+
193
+ expect(result.changed).toBe(false);
194
+ });
195
+
196
+ it('treats a 2xx with an unusable body as registered without details', async () => {
197
+ expect(
198
+ await registryReturning(makeResponse('not json')).registerAsync(
199
+ makeRequest()
200
+ )
201
+ ).toEqual({});
202
+ });
203
+
204
+ describe('failure messages', () => {
205
+ it('leads with the service message and adds our remedy on 401', async () => {
206
+ await expect(
207
+ registryReturning(
208
+ makeResponse(JSON.stringify({ error: 'bad credentials' }), {
209
+ status: 401,
210
+ statusText: 'Unauthorized',
211
+ })
212
+ ).registerAsync(makeRequest())
213
+ ).rejects.toThrowError(/^bad credentials\nSet NEVERMORE_WATCH_TOKEN/);
214
+ });
215
+
216
+ it('falls back to our own wording when the service says nothing', async () => {
217
+ await expect(
218
+ registryReturning(
219
+ makeResponse('', { status: 401, statusText: 'Unauthorized' })
220
+ ).registerAsync(makeRequest())
221
+ ).rejects.toThrowError(/^GitHub rejected the dispatch token\./);
222
+ });
223
+
224
+ it('names the repository and required scope on 403', async () => {
225
+ await expect(
226
+ registryReturning(
227
+ makeResponse('', { status: 403, statusText: 'Forbidden' })
228
+ ).registerAsync(makeRequest())
229
+ ).rejects.toThrowError(
230
+ /cannot write to Quenty\/egg-hunt-2026.*actions: write/s
231
+ );
232
+ });
233
+
234
+ it('explains a quota failure on 409', async () => {
235
+ await expect(
236
+ registryReturning(
237
+ makeResponse('', { status: 409, statusText: 'Conflict' })
238
+ ).registerAsync(makeRequest())
239
+ ).rejects.toThrowError(/quota exceeded for Quenty\/egg-hunt-2026/);
240
+ });
241
+
242
+ it('lists the workflows it referenced on 422', async () => {
243
+ await expect(
244
+ registryReturning(
245
+ makeResponse('', { status: 422, statusText: 'Unprocessable Entity' })
246
+ ).registerAsync(makeRequest())
247
+ ).rejects.toThrowError(
248
+ /workflow does not exist.*\.github\/workflows\/build\.yml/s
249
+ );
250
+ });
251
+
252
+ // Observed against prod: a notify-only monitor was refused 422 because the
253
+ // Open Cloud key lacked a scope, and the reply appended "Referenced: ." plus
254
+ // advice about a workflow path — under a message that had nothing to do with
255
+ // workflows. 422 now covers several causes and the service names which.
256
+ it('adds no workflow advice when the monitor dispatches none', async () => {
257
+ const request = makeRequest();
258
+ request.watches = request.watches.map((watch) => ({
259
+ ...watch,
260
+ action: { type: 'notify' as const, payload: { target: 'x' } },
261
+ }));
262
+
263
+ await expect(
264
+ registryReturning(
265
+ makeResponse(
266
+ JSON.stringify({
267
+ error: 'The Open Cloud key is not allowed to read place 1 (403).',
268
+ }),
269
+ { status: 422, statusText: 'Unprocessable Entity' }
270
+ )
271
+ ).registerAsync(request)
272
+ ).rejects.toThrowError(/not allowed to read place 1/);
273
+
274
+ await expect(
275
+ registryReturning(
276
+ makeResponse(JSON.stringify({ error: 'nope' }), {
277
+ status: 422,
278
+ statusText: 'Unprocessable Entity',
279
+ })
280
+ ).registerAsync(request)
281
+ ).rejects.not.toThrowError(/Referenced|workflow/);
282
+ });
283
+
284
+ it('surfaces the API error body', async () => {
285
+ await expect(
286
+ registryReturning(
287
+ makeResponse(JSON.stringify({ error: 'lease too long' }), {
288
+ status: 400,
289
+ statusText: 'Bad Request',
290
+ })
291
+ ).registerAsync(makeRequest())
292
+ ).rejects.toThrowError(/lease too long/);
293
+ });
294
+
295
+ it('includes the API validation detail list', async () => {
296
+ await expect(
297
+ registryReturning(
298
+ makeResponse(
299
+ JSON.stringify({
300
+ error: "request/body must have required property 'name'",
301
+ errors: [{ path: '/body/name', errorCode: 'required' }],
302
+ }),
303
+ { status: 400, statusText: 'Bad Request' }
304
+ )
305
+ ).registerAsync(makeRequest())
306
+ ).rejects.toThrowError(/\/body\/name/);
307
+ });
308
+
309
+ it('falls back to status text on an unmapped code', async () => {
310
+ await expect(
311
+ registryReturning(
312
+ makeResponse('', { status: 503, statusText: 'Service Unavailable' })
313
+ ).registerAsync(makeRequest())
314
+ ).rejects.toThrowError(/503 Service Unavailable/);
315
+ });
316
+
317
+ it('reports the endpoint when the request cannot be made', async () => {
318
+ const registry = new HttpWatchRegistry({
319
+ registerUrl: REGISTER_URL,
320
+ fetchImpl: (async () => {
321
+ throw new Error('ENOTFOUND');
322
+ }) as unknown as typeof fetch,
323
+ });
324
+
325
+ await expect(registry.registerAsync(makeRequest())).rejects.toThrowError(
326
+ /could not reach https:\/\/watch\.example\.com/
327
+ );
328
+ });
329
+ });
330
+ });