@quenty/nevermore-cli 4.43.0-canary.783.30683872723.0 → 4.43.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 (32) hide show
  1. package/CHANGELOG.md +13 -16
  2. package/README.md +10 -0
  3. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.js +101 -37
  5. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  6. package/dist/commands/deploy-command/index.d.ts +2 -0
  7. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  8. package/dist/commands/deploy-command/index.js +35 -22
  9. package/dist/commands/deploy-command/index.js.map +1 -1
  10. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  11. package/dist/utils/job-context/base-job-context.js +1 -2
  12. package/dist/utils/job-context/base-job-context.js.map +1 -1
  13. package/dist/utils/open-cloud/open-cloud-client.d.ts +8 -4
  14. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  15. package/dist/utils/open-cloud/open-cloud-client.js +8 -4
  16. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  17. package/dist/utils/watch/github-context.test.js +9 -1
  18. package/dist/utils/watch/github-context.test.js.map +1 -1
  19. package/dist/utils/watch/http-watch-registry.js +5 -3
  20. package/dist/utils/watch/http-watch-registry.js.map +1 -1
  21. package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -1
  22. package/dist/utils/watch/stream-watch-loop.js +42 -51
  23. package/dist/utils/watch/stream-watch-loop.js.map +1 -1
  24. package/package.json +5 -5
  25. package/src/commands/batch-command/batch-deploy-command.ts +135 -41
  26. package/src/commands/deploy-command/index.ts +41 -24
  27. package/src/utils/job-context/base-job-context.ts +1 -2
  28. package/src/utils/open-cloud/open-cloud-client.ts +8 -4
  29. package/src/utils/watch/github-context.test.ts +10 -1
  30. package/src/utils/watch/http-watch-registry.ts +5 -3
  31. package/src/utils/watch/stream-watch-loop.ts +73 -59
  32. package/tsconfig.tsbuildinfo +1 -1
@@ -43,8 +43,16 @@ import {
43
43
  readPackageVersionAsync,
44
44
  } from '../../utils/nevermore-cli-utils.js';
45
45
  import { parseTestLogs } from '../../utils/testing/test-log-parser.js';
46
- import { parseWatchOption } from '@quenty/nevermore-deploy';
47
- import { registerWatchesAsync } from '../../utils/watch/register-watches.js';
46
+ import {
47
+ buildWatchMonitorName,
48
+ parseWatchOption,
49
+ type BasePlaceResolver,
50
+ type WatchOption,
51
+ } from '@quenty/nevermore-deploy';
52
+ import {
53
+ registerWatchesAsync,
54
+ type WatchCandidate,
55
+ } from '../../utils/watch/register-watches.js';
48
56
 
49
57
  const SMOKE_TEST_SCRIPT_PATH = resolvePackagePath(
50
58
  import.meta.url,
@@ -185,6 +193,27 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
185
193
  'Use --all to deploy every package with this target, or --base <ref> to change the comparison ref.'
186
194
  );
187
195
  }
196
+ // Renew anyway. Registration derives from committed config plus the lock,
197
+ // so it does not need this run to have deployed anything — and the lease is
198
+ // the only thing keeping the watch alive. Returning here instead is how a
199
+ // repo whose scheduled builds all diff clean quietly stops being watched,
200
+ // with no failure anywhere to notice.
201
+ if (watchOption) {
202
+ OutputHelper.info(
203
+ 'Renewing watches anyway, so the lease does not lapse.'
204
+ );
205
+ await _registerWatchesAsync({
206
+ targetName,
207
+ option: watchOption,
208
+ // Nothing deployed, so the lock is the whole truth about what each
209
+ // place was built from.
210
+ resolver: createLockOnlyBasePlaceResolver(),
211
+ useGhAuth: args.watchUseGhAuth,
212
+ shareApiKey: args.watchShareApiKey,
213
+ resolveApiKeyAsync: () => getApiKeyAsync(args),
214
+ dryrun: args.dryrun,
215
+ });
216
+ }
188
217
  return;
189
218
  }
190
219
 
@@ -197,20 +226,7 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
197
226
  // lock file, so it runs for real even on a dryrun — that is what makes the
198
227
  // watch path testable without shipping a build.
199
228
  if (watchOption) {
200
- OutputHelper.info(
201
- '[DRYRUN] Registering separate dryrun watches and firing them, to ' +
202
- 'prove routing works. This does not build or upload here — but ' +
203
- 'firing really does dispatch the workflow once per package, and ' +
204
- 'each of those runs a real deploy on the runner.'
205
- );
206
- if (args.watchShareApiKey) {
207
- OutputHelper.warn(
208
- '[DRYRUN] Registering without the Open Cloud key, so a "saved" base ' +
209
- 'place is skipped here even though a real run would watch it.'
210
- );
211
- }
212
- const allPackages = await discoverAllTargetPackagesAsync(targetName);
213
- // Under its own monitor name, never the one a real run owns.
229
+ // Under its own monitor names, never the ones a real run owns.
214
230
  //
215
231
  // Re-registering replaces a monitor's entire watch list, and a dryrun
216
232
  // cannot reproduce a real registration: the Open Cloud key is resolved
@@ -219,18 +235,13 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
219
235
  // driver and its content-hash vocabulary, dropping the baselines, and
220
236
  // deleting any "saved" watch outright, because an anonymous
221
237
  // registration skips those.
222
- await registerWatchesAsync({
238
+ await _registerWatchesAsync({
239
+ targetName,
223
240
  option: watchOption,
224
- monitorName: `${targetName}/dryrun`,
225
241
  resolver: createLockOnlyBasePlaceResolver(),
226
242
  useGhAuth: args.watchUseGhAuth,
227
- triggerAfterRegister: true,
228
- candidates: flattenToBatchTargets(allPackages).map((buildTarget) => ({
229
- targetName,
230
- packageName: buildTarget.packageName,
231
- packagePath: buildTarget.path,
232
- place: buildTarget.target,
233
- })),
243
+ shareApiKey: args.watchShareApiKey,
244
+ dryrun: true,
234
245
  });
235
246
  }
236
247
  return;
@@ -394,25 +405,13 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
394
405
  // re-registers the same wrong baseline. The failure becomes permanent and
395
406
  // invisible.
396
407
  if (watchOption && results.summary.failed === 0) {
397
- // Deliberately every package with this target, not just the ones that
398
- // deployed. One monitor holds the repo's watches for a target and a
399
- // re-apply replaces its whole list, so registering only what changed
400
- // would delete the watches of every package that happened not to change
401
- // this run. Packages that did not deploy still have a lock entry, which
402
- // is the correct baseline for them.
403
- const allPackages = await discoverAllTargetPackagesAsync(targetName);
404
- await registerWatchesAsync({
408
+ await _registerWatchesAsync({
409
+ targetName,
405
410
  option: watchOption,
406
- monitorName: targetName,
407
411
  resolver: basePlaceResolver,
408
412
  useGhAuth: args.watchUseGhAuth,
409
- robloxApiKey: args.watchShareApiKey ? apiKey : undefined,
410
- candidates: flattenToBatchTargets(allPackages).map((buildTarget) => ({
411
- targetName,
412
- packageName: buildTarget.packageName,
413
- packagePath: buildTarget.path,
414
- place: buildTarget.target,
415
- })),
413
+ shareApiKey: args.watchShareApiKey,
414
+ resolveApiKeyAsync: async () => apiKey,
416
415
  });
417
416
  }
418
417
  } catch (err) {
@@ -426,6 +425,101 @@ async function _runAsync(args: BatchDeployArgs): Promise<void> {
426
425
  process.exit(exitCode);
427
426
  }
428
427
 
428
+ /**
429
+ * Register one monitor per package, covering every package with this target.
430
+ *
431
+ * Per package rather than one for the batch, because `deploy run --watch`
432
+ * registers a package's places too. Two names for one package's watches means
433
+ * two monitors on the same base place, so one artist publish dispatches twice
434
+ * and each rebuild races the other's lock write. Both paths take the name from
435
+ * `buildWatchMonitorName`, so registering from either replaces the same list.
436
+ *
437
+ * Every package with the target, never just the ones that deployed: a re-apply
438
+ * replaces a monitor's whole list, and a package that didn't change still has a
439
+ * lock entry, which is exactly the right baseline for it.
440
+ */
441
+ async function _registerWatchesAsync(options: {
442
+ targetName: string;
443
+ option: WatchOption;
444
+ resolver: BasePlaceResolver;
445
+ useGhAuth?: boolean;
446
+ /** Whether `--watch-share-api-key` was passed. A dryrun never shares one. */
447
+ shareApiKey?: boolean;
448
+ /** Called only if a key is actually going to be sent. */
449
+ resolveApiKeyAsync?: () => Promise<string>;
450
+ dryrun?: boolean;
451
+ }): Promise<void> {
452
+ const { targetName } = options;
453
+
454
+ if (options.dryrun) {
455
+ OutputHelper.info(
456
+ '[DRYRUN] Registering separate dryrun watches and firing them, to ' +
457
+ 'prove routing works. This does not build or upload here — but ' +
458
+ 'firing really does dispatch the workflow once per package, and ' +
459
+ 'each of those runs a real deploy on the runner.'
460
+ );
461
+ if (options.shareApiKey) {
462
+ OutputHelper.warn(
463
+ '[DRYRUN] Registering without the Open Cloud key, so a "saved" base ' +
464
+ 'place is skipped here even though a real run would watch it.'
465
+ );
466
+ }
467
+ }
468
+
469
+ const robloxApiKey =
470
+ options.shareApiKey && !options.dryrun
471
+ ? await options.resolveApiKeyAsync?.()
472
+ : undefined;
473
+
474
+ const allPackages = await discoverAllTargetPackagesAsync(targetName);
475
+
476
+ // Places that never asked to be watched are dropped here rather than passed
477
+ // in to be reported as skips. Most places in a batch are in that state, and
478
+ // per-package registration would otherwise warn about each package that has
479
+ // no watch at all. A place that asked and can't be watched still goes
480
+ // through, because that is the case worth saying out loud.
481
+ const byPackage = new Map<string, WatchCandidate[]>();
482
+ for (const buildTarget of flattenToBatchTargets(allPackages)) {
483
+ if (buildTarget.target.watch == null) {
484
+ continue;
485
+ }
486
+ const candidates = byPackage.get(buildTarget.packageName) ?? [];
487
+ candidates.push({
488
+ targetName,
489
+ packageName: buildTarget.packageName,
490
+ packagePath: buildTarget.path,
491
+ place: buildTarget.target,
492
+ });
493
+ byPackage.set(buildTarget.packageName, candidates);
494
+ }
495
+
496
+ if (byPackage.size === 0) {
497
+ OutputHelper.warn(
498
+ `--watch was requested, but no place in a "${targetName}" target has a ` +
499
+ '"watch" field. Add one naming the workflow that rebuilds it.'
500
+ );
501
+ return;
502
+ }
503
+
504
+ for (const [packageName, candidates] of byPackage) {
505
+ await registerWatchesAsync({
506
+ option: options.option,
507
+ monitorName: buildWatchMonitorName({
508
+ packageName,
509
+ targetName,
510
+ dryrun: options.dryrun,
511
+ }),
512
+ resolver: options.resolver,
513
+ useGhAuth: options.useGhAuth,
514
+ robloxApiKey,
515
+ // Firing proves the workflow really receives the selector, which is the
516
+ // half that historically breaks. Only a dryrun asks for it.
517
+ triggerAfterRegister: options.dryrun,
518
+ candidates,
519
+ });
520
+ }
521
+ }
522
+
429
523
  function _annotateSmokeTestFailure(logs: string): string {
430
524
  const header =
431
525
  'Post-deploy smoke test failed. The deploy itself succeeded, but a server ' +
@@ -39,16 +39,20 @@ import {
39
39
  readPackageVersionAsync,
40
40
  } from '../../utils/nevermore-cli-utils.js';
41
41
  import {
42
+ WATCH_MODES,
43
+ buildWatchMonitorName,
44
+ checkBasePlaceWatchable,
42
45
  formatTargetSelector,
43
- isBasePlaceVersionKeyword,
44
46
  loadDeployConfigAsync,
45
47
  parseTargetSelector,
46
48
  parseWatchOption,
47
49
  resolveDeployConfigPath,
48
50
  resolveDeployTargetPlaces,
51
+ resolveWatchDelivery,
49
52
  toManifestPlaceInfo,
50
53
  type BasePlaceResolver,
51
54
  type DeployConfig,
55
+ type WatchMode,
52
56
  } from '@quenty/nevermore-deploy';
53
57
  import {
54
58
  registerWatchesAsync,
@@ -77,14 +81,10 @@ const MULTI_PLACE_CONCURRENCY = 10;
77
81
  /**
78
82
  * Which base places a local watch polls.
79
83
  *
80
- * Deliberately looser than the cloud plan. `watch` is a GitHub workflow path —
81
- * a dispatch address — and nothing is dispatched locally, so requiring it would
82
- * only stop a developer hot-reloading a place that never intends to use CI.
83
- * `"saved"` is fair game too: the service cannot poll it yet, but this machine
84
- * has Open Cloud credentials and can.
85
- *
86
- * An exact version pin is still excluded, for the same reason as always: it
87
- * means "hold this still", so there is nothing to follow.
84
+ * Deliberately looser than the cloud plan, in exactly one way: `watch` is a
85
+ * dispatch address, nothing is dispatched locally, so requiring it would only
86
+ * stop a developer hot-reloading a place that never intends to use CI.
87
+ * Everything about the base place itself is the shared rule.
88
88
  */
89
89
  async function _buildLocalWatchEntriesAsync(
90
90
  config: DeployConfig,
@@ -95,21 +95,19 @@ async function _buildLocalWatchEntriesAsync(
95
95
  const entries: LocalWatchEntry[] = [];
96
96
  for (const place of resolveDeployTargetPlaces(config, targetName)) {
97
97
  const basePlace = place.basePlace;
98
- if (basePlace == null) {
99
- continue;
100
- }
101
- if (
102
- basePlace.version != null &&
103
- !isBasePlaceVersionKeyword(basePlace.version)
104
- ) {
98
+ // The same rule a registration uses, asked with `credentialed: true`: this
99
+ // machine holds the Open Cloud key, so "saved" is readable here even when
100
+ // the service could not see it. Only the workflow half differs, and a local
101
+ // watch has nothing to dispatch.
102
+ const check = checkBasePlaceWatchable(basePlace, { credentialed: true });
103
+ if (basePlace == null || !check.watchable) {
105
104
  continue;
106
105
  }
107
- const versionType = basePlace.version ?? 'published';
108
106
  entries.push({
109
107
  label: formatTargetSelector({ targetName, placeName: place.name }),
110
108
  universeId: basePlace.universeId,
111
109
  placeId: basePlace.placeId,
112
- versionType,
110
+ versionType: check.versionType,
113
111
  baseline: await resolver.peekAsync(packagePath, basePlace),
114
112
  });
115
113
  }
@@ -130,6 +128,7 @@ export interface DeployArgs extends NevermoreGlobalArgs {
130
128
  output?: string;
131
129
  logs?: boolean;
132
130
  watch?: string;
131
+ watchMode?: WatchMode;
133
132
  watchShareApiKey?: boolean;
134
133
  watchUseGhAuth?: boolean;
135
134
  }
@@ -303,6 +302,16 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
303
302
  'Takes the register endpoint URL, ending in the lease: https://<watch-service>/v1/register/7d',
304
303
  type: 'string',
305
304
  })
305
+ .option('watch-mode', {
306
+ describe:
307
+ 'How the watch reaches whoever rebuilds: "dispatch" fires the workflow, ' +
308
+ '"notify" holds a stream here and rebuilds in this terminal. ' +
309
+ 'Defaults to "auto", which dispatches on GitHub Actions and notifies elsewhere — ' +
310
+ 'set it explicitly in any other automated context, where notifying would hang the run.',
311
+ type: 'string',
312
+ choices: WATCH_MODES,
313
+ default: 'auto',
314
+ })
306
315
  .option('watch-use-gh-auth', {
307
316
  describe:
308
317
  'Let the GitHub CLI supply the watch token when no environment variable does. ' +
@@ -420,7 +429,11 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
420
429
  // registration skips those.
421
430
  await registerWatchesAsync({
422
431
  option: watchOption,
423
- monitorName: `${packageName}/${watchedTarget}/dryrun`,
432
+ monitorName: buildWatchMonitorName({
433
+ packageName,
434
+ targetName: watchedTarget,
435
+ dryrun: true,
436
+ }),
424
437
  resolver: createLockOnlyBasePlaceResolver(),
425
438
  useGhAuth: args.watchUseGhAuth,
426
439
  triggerAfterRegister: true,
@@ -636,7 +649,7 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
636
649
  // Only after a clean deploy: what happens next records the base place
637
650
  // version that just shipped, which is not a fact yet if the deploy failed.
638
651
  if (watchOption && first.failed === 0) {
639
- if (isCI()) {
652
+ if (resolveWatchDelivery(args.watchMode, isCI()) === 'dispatch') {
640
653
  // The monitor is named for the whole target, so it has to carry every
641
654
  // place in that target — not just the ones this invocation deployed.
642
655
  // Re-registering replaces its watch list, so a narrowed selector
@@ -644,9 +657,10 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
644
657
  // places' watches. Places that did not deploy keep their lock baseline.
645
658
  watchResult = await registerWatchesAsync({
646
659
  option: watchOption,
647
- // Scoped to this package and target, so deploying one package can
648
- // never replace the watch list another package registered.
649
- monitorName: `${packageName}/${watchedTarget}`,
660
+ monitorName: buildWatchMonitorName({
661
+ packageName,
662
+ targetName: watchedTarget,
663
+ }),
650
664
  resolver: first.resolver,
651
665
  useGhAuth: args.watchUseGhAuth,
652
666
  robloxApiKey: args.watchShareApiKey ? apiKey : undefined,
@@ -680,7 +694,10 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
680
694
 
681
695
  const notify = await tryRegisterNotifyWatchesAsync({
682
696
  option: watchOption,
683
- monitorName: `${packageName}/${watchedTarget}`,
697
+ monitorName: buildWatchMonitorName({
698
+ packageName,
699
+ targetName: watchedTarget,
700
+ }),
684
701
  entries: localEntries,
685
702
  useGhAuth: args.watchUseGhAuth,
686
703
  robloxApiKey: args.watchShareApiKey ? apiKey : undefined,
@@ -150,8 +150,7 @@ export abstract class BaseJobContext implements JobContext {
150
150
  // Waiter: the in-flight build was started under a different
151
151
  // packageName, so emit our own 'building' phase change for the reporter.
152
152
  const resolvedName =
153
- options.packageName ??
154
- path.basename(options.packagePath);
153
+ options.packageName ?? path.basename(options.packagePath);
155
154
  this._reporter.onPackagePhaseChange(resolvedName, 'building');
156
155
  return existing.promise;
157
156
  }
@@ -502,10 +502,14 @@ export class OpenCloudClient {
502
502
 
503
503
  /**
504
504
  * Resolve the newest saved or published version number of a place, via the
505
- * Open Cloud Assets API (`asset:read` scope; `legacy-asset:manage` also
506
- * grants it). The returned number is the same value the Asset Delivery API's
507
- * `/version/{n}` route expects, so it can be written straight into a
508
- * `basePlace.version` pin.
505
+ * Open Cloud Assets API (`asset:read` scope). The returned number is the same
506
+ * value the Asset Delivery API's `/version/{n}` route expects, so it can be
507
+ * written straight into a `basePlace.version` pin.
508
+ *
509
+ * This used to add that `legacy-asset:manage` grants `asset:read` too. They
510
+ * are separate boxes on the credentials page and nothing here ever verified
511
+ * the implication, so the claim is gone rather than restated — it had already
512
+ * been copied into the `nevermore login` instructions as fact once.
509
513
  *
510
514
  * `saved` is the newest version of any kind — Studio saves and publishes
511
515
  * share one increasing version sequence. `published` is the newest version
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import {
3
3
  describeMissingWatchToken,
4
4
  parseGithubRepository,
@@ -35,6 +35,15 @@ describe('tryResolveGithubDispatchContext token', () => {
35
35
  'git rev-parse': 'main',
36
36
  };
37
37
 
38
+ // Cleared so these exercise the git fallback rather than whatever the host
39
+ // happens to export. Actions sets both, and on a pull request GITHUB_REF_NAME
40
+ // is a merge ref — which resolution rightly refuses, so without this the
41
+ // whole describe passes locally and fails only in CI.
42
+ beforeEach(() => {
43
+ vi.stubEnv('GITHUB_REPOSITORY', '');
44
+ vi.stubEnv('GITHUB_REF_NAME', '');
45
+ });
46
+
38
47
  // Registering ships the token to the watch service, so it has to be one the
39
48
  // user handed over. Nearly every dev machine has a logged-in `gh`, so reading
40
49
  // it by default would turn a local watch into a silent credential send.
@@ -239,9 +239,11 @@ function _describeFailure(
239
239
  case 422: {
240
240
  // 422 is the service's "your config cannot work" answer, and it covers
241
241
  // several unrelated causes now — a missing workflow, an Open Cloud key
242
- // without universe.place:read, a "saved" watch with no key, a baseline in
243
- // the wrong vocabulary. Its own message names which, so the only thing
244
- // worth adding is a remedy that could not already be in it.
242
+ // that cannot read a base place, a "saved" watch with no key, a baseline
243
+ // in the wrong vocabulary. Its own message names which, so the only thing
244
+ // worth adding is a remedy that could not already be in it. The scope
245
+ // itself is deliberately not named here: this file would then be a second
246
+ // place to correct when it is wrong, and it already has been once.
245
247
  const workflows = [
246
248
  ...new Set(
247
249
  request.watches.flatMap((w) =>