@agent-native/core 0.165.2 → 0.165.4

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 (54) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +740 -2
  3. package/corpus/templates/analytics/changelog/2026-08-20-staged-data-limit-recovery.md +1 -0
  4. package/corpus/templates/assets/CHANGELOG.md +204 -2
  5. package/corpus/templates/brain/CHANGELOG.md +145 -2
  6. package/corpus/templates/calendar/CHANGELOG.md +272 -2
  7. package/corpus/templates/chat/CHANGELOG.md +89 -2
  8. package/corpus/templates/clips/CHANGELOG.md +864 -2
  9. package/corpus/templates/clips/app/i18n/en-US.ts +6 -2
  10. package/corpus/templates/clips/app/routes/download.tsx +111 -17
  11. package/corpus/templates/clips/changelog/2026-08-20-cross-tab-recording-controls.md +1 -0
  12. package/corpus/templates/clips/changelog/2026-08-20-overlay-follow-review-fixes.md +1 -0
  13. package/corpus/templates/clips/changelog/2026-08-20-preparing-recording-countdown-overlay.md +5 -0
  14. package/corpus/templates/clips/changelog/2026-08-20-provider-key-recovery.md +1 -0
  15. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +4 -6
  16. package/corpus/templates/clips/desktop/src/styles.css +18 -20
  17. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +9 -17
  18. package/corpus/templates/clips/server/routes/api/clips-latest.json.get.ts +109 -46
  19. package/corpus/templates/content/CHANGELOG.md +503 -2
  20. package/corpus/templates/crm/CHANGELOG.md +31 -2
  21. package/corpus/templates/design/CHANGELOG.md +870 -2
  22. package/corpus/templates/dispatch/CHANGELOG.md +196 -2
  23. package/corpus/templates/factory/CHANGELOG.md +121 -2
  24. package/corpus/templates/forms/CHANGELOG.md +166 -2
  25. package/corpus/templates/macros/CHANGELOG.md +49 -2
  26. package/corpus/templates/mail/CHANGELOG.md +201 -2
  27. package/corpus/templates/plan/CHANGELOG.md +277 -2
  28. package/corpus/templates/slides/CHANGELOG.md +437 -2
  29. package/corpus/templates/tasks/CHANGELOG.md +32 -2
  30. package/dist/changelog/parse.d.ts +3 -3
  31. package/dist/changelog/parse.js +35 -16
  32. package/dist/client/blocks/library/diagram.js +1 -1
  33. package/dist/collab/routes.d.ts +1 -1
  34. package/dist/collab/struct-routes.d.ts +1 -1
  35. package/dist/deploy/workspace-deploy.js +7 -0
  36. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  37. package/dist/mcp-client/oauth-routes.d.ts +1 -0
  38. package/dist/mcp-client/oauth-routes.js +15 -2
  39. package/dist/mcp-client/remote-store.js +83 -43
  40. package/dist/notifications/routes.d.ts +6 -6
  41. package/dist/resources/handlers.d.ts +2 -2
  42. package/dist/secrets/routes.d.ts +9 -9
  43. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  44. package/dist/server/google-oauth-credentials.js +14 -1
  45. package/dist/settings/index.d.ts +1 -1
  46. package/dist/settings/index.js +1 -1
  47. package/dist/settings/store.d.ts +7 -1
  48. package/dist/settings/store.js +25 -3
  49. package/dist/settings/user-settings.d.ts +2 -0
  50. package/dist/settings/user-settings.js +65 -2
  51. package/dist/templates/chat/CHANGELOG.md +89 -2
  52. package/docs/content/deployment.mdx +89 -14
  53. package/package.json +1 -1
  54. package/src/templates/chat/CHANGELOG.md +89 -2
@@ -615,12 +615,16 @@ const messages = {
615
615
  alsoFor: "Also available for {{platform}}",
616
616
  backToLibrary: "Back to library",
617
617
  clipsDesktop: "Clips Desktop",
618
+ stable: "Stable",
619
+ nightly: "Nightly",
620
+ switchToNightly: "Switch to Nightly builds",
621
+ switchToStable: "Switch to stable builds",
622
+ retry: "Try again",
618
623
  heroDescription:
619
624
  "A menu-bar recorder for screen, camera, and screen + camera. One-click start, draggable camera bubble, instant-share link when you stop.",
620
625
  versionReleased: "Version {{version}} — released {{date}}",
621
626
  version: "Version {{version}}",
622
- manifestError:
623
- "Could not load release manifest — pick an installer from the releases page.",
627
+ manifestError: "Could not load release manifest. Try again.",
624
628
  loadingRelease: "Loading latest release…",
625
629
  chromeTitle: "Chrome extension for browser logs",
626
630
  chromeDescription:
@@ -29,6 +29,7 @@ export function meta() {
29
29
  }
30
30
 
31
31
  type PlatformId = "mac" | "windows" | "linux";
32
+ type ReleaseChannel = "production" | "nightly";
32
33
 
33
34
  interface PlatformVariant {
34
35
  id: PlatformId;
@@ -48,9 +49,6 @@ interface PlatformVariant {
48
49
 
49
50
  const LATEST_JSON_URL = `${appBasePath()}/api/clips-latest.json`;
50
51
 
51
- const RELEASE_PAGE_URL =
52
- "https://github.com/BuilderIO/agent-native/releases?q=clips-v";
53
-
54
52
  const VARIANTS: PlatformVariant[] = [
55
53
  {
56
54
  id: "mac",
@@ -114,12 +112,18 @@ function primaryDownloadButton(
114
112
  manifest: Manifest | null,
115
113
  manifestError: boolean,
116
114
  downloadLabel: string,
115
+ retryLabel: string,
116
+ onRetry: () => void,
117
117
  ) {
118
118
  const asset = pickAsset(manifest, variant);
119
119
  const Icon = variant.icon;
120
120
  if (asset) {
121
121
  return (
122
- <Button asChild size="lg" className="h-12 gap-2 px-6 text-base">
122
+ <Button
123
+ asChild
124
+ size="lg"
125
+ className="h-12 min-w-[252px] gap-2 px-6 text-base"
126
+ >
123
127
  <a href={asset.url} download onClick={markDesktopAppDownloaded}>
124
128
  <Icon className="h-5 w-5" />
125
129
  {downloadLabel}
@@ -130,17 +134,28 @@ function primaryDownloadButton(
130
134
  if (manifest === null && !manifestError) {
131
135
  return <Skeleton className="h-12 w-[252px] rounded-md" />;
132
136
  }
137
+ if (manifestError) {
138
+ return (
139
+ <Button
140
+ size="lg"
141
+ variant="outline"
142
+ className="h-12 min-w-[252px] gap-2 px-6 text-base"
143
+ onClick={onRetry}
144
+ >
145
+ <Icon className="h-5 w-5" />
146
+ {retryLabel}
147
+ </Button>
148
+ );
149
+ }
133
150
  return (
134
151
  <Button
135
- asChild
136
152
  size="lg"
137
153
  variant="outline"
138
- className="h-12 gap-2 px-6 text-base"
154
+ className="h-12 min-w-[252px] gap-2 px-6 text-base"
155
+ disabled
139
156
  >
140
- <a href={RELEASE_PAGE_URL} rel="noreferrer">
141
- <Icon className="h-5 w-5" />
142
- {downloadLabel}
143
- </a>
157
+ <Icon className="h-5 w-5" />
158
+ {downloadLabel}
144
159
  </Button>
145
160
  );
146
161
  }
@@ -150,6 +165,8 @@ function secondaryDownloadButton(
150
165
  manifest: Manifest | null,
151
166
  manifestError: boolean,
152
167
  downloadLabel: string,
168
+ retryLabel: string,
169
+ onRetry: () => void,
153
170
  ) {
154
171
  const asset = pickAsset(manifest, variant);
155
172
  const Icon = variant.icon;
@@ -168,12 +185,18 @@ function secondaryDownloadButton(
168
185
  if (manifest === null && !manifestError) {
169
186
  return <Skeleton className="h-7 w-[208px] rounded-md" />;
170
187
  }
171
- return (
172
- <Button asChild variant="ghost" className={className}>
173
- <a href={RELEASE_PAGE_URL} rel="noreferrer">
188
+ if (manifestError) {
189
+ return (
190
+ <Button variant="ghost" className={className} onClick={onRetry}>
174
191
  <Icon className="h-4 w-4" />
175
- {downloadLabel}
176
- </a>
192
+ {retryLabel}
193
+ </Button>
194
+ );
195
+ }
196
+ return (
197
+ <Button variant="ghost" className={className} disabled>
198
+ <Icon className="h-4 w-4" />
199
+ {downloadLabel}
177
200
  </Button>
178
201
  );
179
202
  }
@@ -181,9 +204,11 @@ function secondaryDownloadButton(
181
204
  export default function DownloadPage() {
182
205
  const chromeExtensionEnabled = useClipsChromeExtensionEnabled();
183
206
  const t = useT();
207
+ const [channel, setChannel] = useState<ReleaseChannel>("production");
184
208
  const [manifest, setManifest] = useState<Manifest | null>(null);
185
209
  const [manifestError, setManifestError] = useState(false);
186
210
  const [detected, setDetected] = useState<PlatformId | null>(null);
211
+ const [manifestRequest, setManifestRequest] = useState(0);
187
212
 
188
213
  useEffect(() => {
189
214
  setDetected(detectPlatform());
@@ -191,7 +216,13 @@ export default function DownloadPage() {
191
216
 
192
217
  useEffect(() => {
193
218
  let cancelled = false;
194
- fetch(LATEST_JSON_URL)
219
+ setManifest(null);
220
+ setManifestError(false);
221
+ const manifestUrl =
222
+ channel === "nightly"
223
+ ? `${LATEST_JSON_URL}?channel=nightly`
224
+ : LATEST_JSON_URL;
225
+ fetch(manifestUrl)
195
226
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))))
196
227
  .then((json) => {
197
228
  if (!cancelled) setManifest(json as Manifest);
@@ -202,7 +233,17 @@ export default function DownloadPage() {
202
233
  return () => {
203
234
  cancelled = true;
204
235
  };
205
- }, []);
236
+ }, [channel, manifestRequest]);
237
+
238
+ const retryManifest = () => {
239
+ setManifestRequest((request) => request + 1);
240
+ };
241
+
242
+ const handleChannelChange = (nextChannel: ReleaseChannel) => {
243
+ setManifest(null);
244
+ setManifestError(false);
245
+ setChannel(nextChannel);
246
+ };
206
247
 
207
248
  const primary = VARIANTS.find((v) => v.id === detected) ?? VARIANTS[0];
208
249
  const secondary = VARIANTS.filter((v) => v.id !== primary.id);
@@ -242,6 +283,14 @@ export default function DownloadPage() {
242
283
  <div className="flex flex-col items-center text-center">
243
284
  <h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
244
285
  {t("downloadRoute.clipsDesktop")}
286
+ {channel === "nightly" && (
287
+ <>
288
+ {" "}
289
+ <span className="text-highlight">
290
+ {t("downloadRoute.nightly")}
291
+ </span>
292
+ </>
293
+ )}
245
294
  </h1>
246
295
  <p className="mt-4 max-w-xl text-base text-muted-foreground">
247
296
  {t("downloadRoute.heroDescription")}
@@ -253,6 +302,8 @@ export default function DownloadPage() {
253
302
  manifest,
254
303
  manifestError,
255
304
  t("downloadRoute.downloadFor", { platform: primary.label }),
305
+ t("downloadRoute.retry"),
306
+ retryManifest,
256
307
  )}
257
308
  {secondary.map((variant) => (
258
309
  <div key={variant.id}>
@@ -261,6 +312,8 @@ export default function DownloadPage() {
261
312
  manifest,
262
313
  manifestError,
263
314
  t("downloadRoute.alsoFor", { platform: variant.label }),
315
+ t("downloadRoute.retry"),
316
+ retryManifest,
264
317
  )}
265
318
  </div>
266
319
  ))}
@@ -282,6 +335,47 @@ export default function DownloadPage() {
282
335
  <>{t("downloadRoute.loadingRelease")}</>
283
336
  )}
284
337
  </div>
338
+ <div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
339
+ <span>{t("downloadRoute.stable")}</span>
340
+ <button
341
+ type="button"
342
+ role="switch"
343
+ aria-checked={channel === "nightly"}
344
+ aria-label={t(
345
+ channel === "nightly"
346
+ ? "downloadRoute.switchToStable"
347
+ : "downloadRoute.switchToNightly",
348
+ )}
349
+ onClick={() =>
350
+ handleChannelChange(
351
+ channel === "nightly" ? "production" : "nightly",
352
+ )
353
+ }
354
+ className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
355
+ channel === "nightly"
356
+ ? "bg-foreground"
357
+ : "bg-muted-foreground/20"
358
+ }`}
359
+ >
360
+ <span
361
+ aria-hidden="true"
362
+ className={`block size-3.5 rounded-full bg-primary-foreground shadow-sm transition-transform ${
363
+ channel === "nightly"
364
+ ? "translate-x-[18px]"
365
+ : "translate-x-[2px]"
366
+ }`}
367
+ />
368
+ </button>
369
+ <span
370
+ className={
371
+ channel === "nightly"
372
+ ? "font-medium text-foreground"
373
+ : "text-muted-foreground"
374
+ }
375
+ >
376
+ {t("downloadRoute.nightly")}
377
+ </span>
378
+ </div>
285
379
  </div>
286
380
 
287
381
  {chromeExtensionEnabled && (
@@ -2,4 +2,5 @@
2
2
  type: improved
3
3
  date: 2026-08-20
4
4
  ---
5
+
5
6
  Recording controls now follow you across browser tabs and page navigations.
@@ -2,4 +2,5 @@
2
2
  type: fixed
3
3
  date: 2026-08-20
4
4
  ---
5
+
5
6
  Clips overlays now follow tab switches during the recording countdown and recover cleanly on tabs opened before Clips.
@@ -0,0 +1,5 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-20
4
+ ---
5
+ Recording preparation now uses a centered full-screen overlay that transitions smoothly into the 3-2-1 countdown.
@@ -2,4 +2,5 @@
2
2
  type: fixed
3
3
  date: 2026-08-20
4
4
  ---
5
+
5
6
  Clips now opens provider setup instead of retrying a rejected key in a loop.
@@ -1,12 +1,10 @@
1
- /** Visible readiness state shown while the shared Rewind Clip writer and
2
- * resumable upload session are prepared. The numeric countdown replaces it
3
- * only after zero can be an immediate media boundary. */
1
+ /** Visible readiness state shown before the numeric countdown takes over. */
4
2
  export function Preparing() {
5
3
  return (
6
- <div className="preparing-root">
7
- <div className="preparing-card" role="status" aria-live="polite">
4
+ <div className="countdown-root preparing-root">
5
+ <div className="preparing-content" role="status" aria-live="polite">
6
+ <span className="preparing-label">Preparing recording</span>
8
7
  <div className="preparing-spinner" aria-hidden="true" />
9
- <span>Preparing recording…</span>
10
8
  </div>
11
9
  </div>
12
10
  );
@@ -4250,42 +4250,40 @@ body[data-clips-route="recording-pill"] #root {
4250
4250
  }
4251
4251
 
4252
4252
  /* ------------------------------------------------------------------------- */
4253
- /* Overlay: finalizing spinner */
4253
+ /* Overlay: recording preparation */
4254
4254
  /* ------------------------------------------------------------------------- */
4255
4255
 
4256
4256
  .preparing-root {
4257
4257
  position: fixed;
4258
4258
  inset: 0;
4259
4259
  display: flex;
4260
- align-items: flex-end;
4261
- justify-content: flex-start;
4262
- padding: 10px;
4263
- background: transparent;
4260
+ align-items: center;
4261
+ justify-content: center;
4262
+ pointer-events: none;
4264
4263
  }
4265
4264
 
4266
- .preparing-card {
4265
+ .preparing-content {
4267
4266
  display: flex;
4267
+ flex-direction: column;
4268
4268
  align-items: center;
4269
- gap: 10px;
4270
- width: 100%;
4271
- padding: 12px 14px;
4272
- border-radius: 16px;
4273
- background: rgba(20, 22, 28, 0.94);
4274
- backdrop-filter: blur(20px);
4275
- -webkit-backdrop-filter: blur(20px);
4276
- border: 1px solid rgba(255, 255, 255, 0.08);
4269
+ gap: 16px;
4277
4270
  color: white;
4278
- font-size: 13px;
4279
- font-weight: 600;
4271
+ }
4272
+
4273
+ .preparing-label {
4274
+ font-size: 20px;
4275
+ font-weight: 650;
4276
+ letter-spacing: -0.01em;
4277
+ line-height: 1;
4280
4278
  }
4281
4279
 
4282
4280
  .preparing-spinner {
4283
- width: 18px;
4284
- height: 18px;
4281
+ width: 28px;
4282
+ height: 28px;
4285
4283
  flex: 0 0 auto;
4286
4284
  border-radius: 50%;
4287
- border: 2px solid rgba(255, 255, 255, 0.18);
4288
- border-top-color: #ffffff;
4285
+ border: 3px solid color-mix(in srgb, currentColor 18%, transparent);
4286
+ border-top-color: currentColor;
4289
4287
  animation: finalizing-spin 0.9s linear infinite;
4290
4288
  }
4291
4289
 
@@ -472,25 +472,17 @@ fn stop_countdown_control_tracking() {
472
472
  COUNTDOWN_CONTROL_TRACKING.store(false, Ordering::SeqCst);
473
473
  }
474
474
 
475
- /// Compact visible readiness state for the slow work that intentionally runs
476
- /// before the numeric countdown. This capture-excluded card briefly takes
477
- /// focus as the first stage of the explicit modal start flow, so the user sees
478
- /// that Start was accepted without changing the exact countdown-zero boundary.
475
+ /// Full-screen visible readiness state for the slow work that intentionally
476
+ /// runs before the numeric countdown. This capture-excluded overlay briefly
477
+ /// takes focus as the first stage of the explicit modal start flow, so the user
478
+ /// sees that Start was accepted without changing the exact countdown-zero
479
+ /// boundary.
479
480
  #[tauri::command]
480
481
  pub async fn show_preparing(app: AppHandle) -> Result<(), String> {
481
482
  if let Some(existing) = app.get_webview_window(PREPARING_LABEL) {
482
483
  let _ = existing.close();
483
484
  }
484
485
  let (mx, my, mw, mh) = tray_monitor_physical_rect(&app);
485
- let scale = overlay_scale_factor(&app);
486
- let content_w: u32 = (260.0 * scale).round() as u32;
487
- let content_h: u32 = (58.0 * scale).round() as u32;
488
- let margin: i32 = (14.0 * scale).round() as i32;
489
- let gutter = overlay_shadow_gutter_physical(&app);
490
- let w = content_w + gutter * 2;
491
- let h = content_h + gutter * 2;
492
- let x = (mx + (mw.saturating_sub(w) / 2) as i32).max(mx);
493
- let y = (my + mh as i32 - h as i32 - margin).max(my);
494
486
  let win = WebviewWindowBuilder::new(&app, PREPARING_LABEL, build_overlay_url("preparing"))
495
487
  .title("Preparing recording")
496
488
  .decorations(false)
@@ -503,14 +495,14 @@ pub async fn show_preparing(app: AppHandle) -> Result<(), String> {
503
495
  .focused(true)
504
496
  .build()
505
497
  .map_err(|error| format!("preparing window build failed: {error}"))?;
506
- let _ = win.set_size(tauri::Size::Physical(PhysicalSize::new(w, h)));
507
- let _ = win.set_position(PhysicalPosition::new(x, y));
498
+ let _ = win.set_size(tauri::Size::Physical(PhysicalSize::new(mw, mh)));
499
+ let _ = win.set_position(PhysicalPosition::new(mx, my));
508
500
  let _ = win.set_ignore_cursor_events(true);
509
501
  set_capture_excluded(&win);
510
502
  configure_overlay_behavior(&win);
511
503
  // Preparation and countdown are one explicit modal start flow. Making the
512
- // compact readiness card key ensures it gets a real first paint and is
513
- // announced before the full-screen countdown replaces it.
504
+ // readiness overlay key ensures it gets a real first paint and is announced
505
+ // before the countdown replaces it.
514
506
  present_interactive_window(&win);
515
507
  let _ = app.emit("clips:toolbar-preparing", true);
516
508
  // A newly-created webview needs one paint before the async preparation can
@@ -1,4 +1,9 @@
1
- import { defineEventHandler, setResponseHeaders, createError } from "h3";
1
+ import {
2
+ createError,
3
+ defineEventHandler,
4
+ getQuery,
5
+ setResponseHeaders,
6
+ } from "h3";
2
7
 
3
8
  /**
4
9
  * Same-origin endpoint that tells the download page which user-facing
@@ -12,14 +17,13 @@ import { defineEventHandler, setResponseHeaders, createError } from "h3";
12
17
  * /download want the raw installers (.dmg / .msi / .AppImage).
13
18
  *
14
19
  * This route therefore hits GitHub's REST API, paginates through
15
- * releases until it finds the most recent published `clips-v*` release,
16
- * and returns its asset list plus metadata.
20
+ * releases until it finds the most recent published release for the requested
21
+ * channel, and returns its asset list plus metadata.
17
22
  *
18
- * The `clips-latest` pointer release is still the release-channel hint:
19
- * when it has a signed updater manifest, we resolve that manifest's
20
- * version back to the matching `clips-v*` release before scanning. That
21
- * keeps the manual download page and the in-app updater pointed at the
22
- * same build by default.
23
+ * The channel's pointer release is still the release-channel hint: when it
24
+ * has a signed updater manifest, we resolve that manifest's version back to
25
+ * the matching versioned release before scanning. That keeps the manual
26
+ * download page and the in-app updater pointed at the same build by default.
23
27
  *
24
28
  * ## Rate-limit hardening
25
29
  *
@@ -38,14 +42,30 @@ import { defineEventHandler, setResponseHeaders, createError } from "h3";
38
42
 
39
43
  const RELEASES_URL_BASE =
40
44
  "https://api.github.com/repos/BuilderIO/agent-native/releases";
41
- const UPDATER_MANIFEST_URL =
42
- "https://github.com/BuilderIO/agent-native/releases/download/clips-latest/clips-latest.json";
43
45
  const PER_PAGE = 100;
44
- // Up to 10 pages = 1000 releases. If clips-v* hasn't shown up by then,
45
- // something else is wrong and the 404 is correct.
46
+ // Up to 10 pages = 1000 releases. If the requested channel has not appeared
47
+ // by then, something else is wrong and the 404 is correct.
46
48
  const MAX_PAGES = 10;
47
49
  const CACHE_TTL_MS = 5 * 60_000;
48
50
 
51
+ export type ClipsReleaseChannel = "production" | "nightly";
52
+
53
+ const RELEASE_CHANNEL_CONFIG: Record<
54
+ ClipsReleaseChannel,
55
+ { releasePrefix: string; updaterManifestUrl: string }
56
+ > = {
57
+ production: {
58
+ releasePrefix: "clips-v",
59
+ updaterManifestUrl:
60
+ "https://github.com/BuilderIO/agent-native/releases/download/clips-latest/clips-latest.json",
61
+ },
62
+ nightly: {
63
+ releasePrefix: "clips-nightly-v",
64
+ updaterManifestUrl:
65
+ "https://github.com/BuilderIO/agent-native/releases/download/clips-nightly-latest/clips-nightly-latest.json",
66
+ },
67
+ };
68
+
49
69
  interface GhAsset {
50
70
  name: string;
51
71
  browser_download_url: string;
@@ -121,7 +141,9 @@ export function classifyClipsAsset(
121
141
  }
122
142
 
123
143
  function parseClipsVersion(tagName: string): number[] | null {
124
- const match = /^clips-v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(tagName);
144
+ const match = /^clips(?:-nightly)?-v(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(
145
+ tagName,
146
+ );
125
147
  if (!match) return null;
126
148
  return match.slice(1, 4).map((part) => Number(part));
127
149
  }
@@ -139,6 +161,22 @@ export function compareClipsReleaseTags(a: string, b: string): number {
139
161
  return 0;
140
162
  }
141
163
 
164
+ export function isClipsReleaseForChannel(
165
+ release: GhRelease,
166
+ channel: ClipsReleaseChannel,
167
+ ): boolean {
168
+ if (release.draft || !hasInstallerAssets(release)) return false;
169
+ if (channel === "production") {
170
+ return (
171
+ !release.prerelease && /^clips-v\d+\.\d+\.\d+$/.test(release.tag_name)
172
+ );
173
+ }
174
+ return (
175
+ release.prerelease &&
176
+ /^clips-nightly-v\d+\.\d+\.\d+(?:[-+].*)?$/.test(release.tag_name)
177
+ );
178
+ }
179
+
142
180
  function isBetterRelease(candidate: GhRelease, current: GhRelease | null) {
143
181
  if (!current) return true;
144
182
  const versionOrder = compareClipsReleaseTags(
@@ -158,8 +196,11 @@ function hasInstallerAssets(release: GhRelease) {
158
196
  );
159
197
  }
160
198
 
161
- let cache: { data: DownloadManifest; ts: number } | null = null;
162
- let inFlight: Promise<DownloadManifest> | null = null;
199
+ const cache = new Map<
200
+ ClipsReleaseChannel,
201
+ { data: DownloadManifest; ts: number }
202
+ >();
203
+ const inFlight = new Map<ClipsReleaseChannel, Promise<DownloadManifest>>();
163
204
 
164
205
  class UpstreamError extends Error {
165
206
  statusCode: number;
@@ -215,8 +256,10 @@ function isUpdaterManifestLike(value: unknown): value is UpdaterManifest {
215
256
  return typeof obj.version === "string" && obj.version.length > 0;
216
257
  }
217
258
 
218
- async function fetchUpdaterManifest(): Promise<UpdaterManifest> {
219
- const res = await fetch(UPDATER_MANIFEST_URL, {
259
+ async function fetchUpdaterManifest(
260
+ channel: ClipsReleaseChannel,
261
+ ): Promise<UpdaterManifest> {
262
+ const res = await fetch(RELEASE_CHANNEL_CONFIG[channel].updaterManifestUrl, {
220
263
  headers: {
221
264
  accept: "application/json",
222
265
  "user-agent": "clips-download-page",
@@ -236,35 +279,38 @@ async function fetchUpdaterManifest(): Promise<UpdaterManifest> {
236
279
  return json;
237
280
  }
238
281
 
239
- async function findUpdaterPinnedRelease(): Promise<GhRelease | null> {
282
+ async function findUpdaterPinnedRelease(
283
+ channel: ClipsReleaseChannel,
284
+ ): Promise<GhRelease | null> {
240
285
  try {
241
- const manifest = await fetchUpdaterManifest();
242
- const version = manifest.version.replace(/^clips-v/, "");
243
- const release = await fetchReleaseByTag(`clips-v${version}`);
244
- if (!release) return null;
245
- if (release.draft || release.prerelease) return null;
246
- if (!release.tag_name.startsWith("clips-v")) return null;
247
- if (!hasInstallerAssets(release)) return null;
248
- return release;
286
+ const manifest = await fetchUpdaterManifest(channel);
287
+ const releasePrefix = RELEASE_CHANNEL_CONFIG[channel].releasePrefix;
288
+ const version = manifest.version
289
+ .replace(/^clips(?:-nightly)?-v/, "")
290
+ .replace(/^v/, "");
291
+ const release = await fetchReleaseByTag(`${releasePrefix}${version}`);
292
+ return release && isClipsReleaseForChannel(release, channel)
293
+ ? release
294
+ : null;
249
295
  } catch {
250
296
  return null;
251
297
  }
252
298
  }
253
299
 
254
- async function findLatestClipsRelease(): Promise<GhRelease | null> {
255
- // Start with the updater's stable pointer so fresh manual installs and
300
+ async function findLatestClipsRelease(
301
+ channel: ClipsReleaseChannel,
302
+ ): Promise<GhRelease | null> {
303
+ // Start with the updater's channel pointer so fresh manual installs and
256
304
  // auto-updates agree about the channel's current version. Then scan the
257
305
  // versioned releases as a fallback/guard and prefer the highest semver
258
306
  // tag; a republished older tag must not beat a newer build just because
259
307
  // it has a later `published_at`.
260
- let best: GhRelease | null = await findUpdaterPinnedRelease();
308
+ let best: GhRelease | null = await findUpdaterPinnedRelease(channel);
261
309
  for (let page = 1; page <= MAX_PAGES; page++) {
262
310
  const batch = await fetchPage(page);
263
311
  if (batch.length === 0) break;
264
312
  for (const r of batch) {
265
- if (r.draft || r.prerelease) continue;
266
- if (!r.tag_name.startsWith("clips-v")) continue;
267
- if (!hasInstallerAssets(r)) continue;
313
+ if (!isClipsReleaseForChannel(r, channel)) continue;
268
314
  if (isBetterRelease(r, best)) {
269
315
  best = r;
270
316
  }
@@ -274,16 +320,21 @@ async function findLatestClipsRelease(): Promise<GhRelease | null> {
274
320
  return best;
275
321
  }
276
322
 
277
- async function buildManifest(): Promise<DownloadManifest> {
278
- const latest = await findLatestClipsRelease();
323
+ async function buildManifest(
324
+ channel: ClipsReleaseChannel,
325
+ ): Promise<DownloadManifest> {
326
+ const latest = await findLatestClipsRelease(channel);
279
327
  if (!latest) {
280
328
  throw createError({
281
329
  statusCode: 404,
282
- statusMessage: "No published clips-v* release found",
330
+ statusMessage:
331
+ channel === "nightly"
332
+ ? "No published clips-nightly-v* release found"
333
+ : "No published clips-v* release found",
283
334
  });
284
335
  }
285
336
  return {
286
- version: latest.tag_name.replace(/^clips-v/, ""),
337
+ version: latest.tag_name.replace(/^clips(?:-nightly)?-v/, ""),
287
338
  tag: latest.tag_name,
288
339
  pub_date: latest.published_at,
289
340
  notes: latest.body,
@@ -296,31 +347,43 @@ async function buildManifest(): Promise<DownloadManifest> {
296
347
  };
297
348
  }
298
349
 
299
- async function getManifest(): Promise<DownloadManifest> {
350
+ async function getManifest(
351
+ channel: ClipsReleaseChannel = "production",
352
+ ): Promise<DownloadManifest> {
300
353
  const now = Date.now();
301
- if (cache && now - cache.ts < CACHE_TTL_MS) return cache.data;
302
- if (inFlight) return inFlight;
303
- inFlight = (async () => {
354
+ const cached = cache.get(channel);
355
+ if (cached && now - cached.ts < CACHE_TTL_MS) return cached.data;
356
+ const pending = inFlight.get(channel);
357
+ if (pending) return pending;
358
+ const request = (async () => {
304
359
  try {
305
- const data = await buildManifest();
306
- cache = { data, ts: Date.now() };
360
+ const data = await buildManifest(channel);
361
+ cache.set(channel, { data, ts: Date.now() });
307
362
  return data;
308
363
  } catch (err) {
309
364
  // Stale-while-error: if we have an older payload, serve it. Only
310
365
  // bubble the error if the cache is empty.
311
- if (cache) return cache.data;
366
+ if (cached) return cached.data;
312
367
  throw err;
313
368
  } finally {
314
- inFlight = null;
369
+ inFlight.delete(channel);
315
370
  }
316
371
  })();
317
- return inFlight;
372
+ inFlight.set(channel, request);
373
+ return request;
374
+ }
375
+
376
+ export function normalizeClipsReleaseChannel(
377
+ value: unknown,
378
+ ): ClipsReleaseChannel {
379
+ return value === "nightly" ? "nightly" : "production";
318
380
  }
319
381
 
320
382
  export default defineEventHandler(async (event) => {
383
+ const channel = normalizeClipsReleaseChannel(getQuery(event).channel);
321
384
  let manifest: DownloadManifest;
322
385
  try {
323
- manifest = await getManifest();
386
+ manifest = await getManifest(channel);
324
387
  } catch (err) {
325
388
  const e = err as {
326
389
  statusCode?: number;